手套箱 04 · 密码与账号验证体系
本篇回答四件事:密码怎么存、怎么验、错误锁定怎么解、页面怎么拉起。 基线:
fuben4_micarsettings/MiCarSettings-5458412@origin/dev(930be9145),行号以此为准; 出货对照pack_a17_cn_20260923快照与本篇引用的全部 15 个文件 diff 为空,结论可直接外推出货线。 密码框架是通用件(base/settingsBaseUi/.../common/password/),与升降顶舱共用——改手套箱密码逻辑前先读 03-开箱主流程源码精讲 确认波及面。
1. 存储设计:Settings.Global 三键 + SHA-256 加盐
1.1 三键定义
手套箱侧不自己碰存储,只做参数化声明(GloveBoxPasswordTools.kt:33-38):
internal val config = PinPasswordConfig(
settingsKeyPassword = "glove_box_password",
settingsKeyWrongTimes = "glove_box_password_wrong_times",
settingsKeySalt = "glove_box_password_salt",
broadcastAction = "com.android.car.settings.action.PASSWORD_VERIFY_RESULT"
)真正落盘在通用层 PinPasswordTools / PinPasswordEncryptor,三键全部走 Settings.Global(跟车存储,恢复出厂即清,不跟账号、不跟用户):
| 键名 | 类型 | 内容 | 写入点 | 读取点 |
|---|---|---|---|---|
glove_box_password | String | SHA-256(salt + pwd) 十六进制 | PinPasswordTools.savePwd() PinPasswordTools.kt:266-275;清空在 clearPwd() :282-290(写 "") | getLocalPwd() :296-303;验证比对在 verifyPwd() :241 |
glove_box_password_salt | Long | 时间戳盐(毫秒) | PinPasswordEncryptor.getSalt(isNeedCreateNew=true) PinPasswordEncryptor.kt:116-122 | 验证时读取 :123-129 |
glove_box_password_wrong_times | Int | 连续错误次数,默认 0 | setPwdWrongTimes() PinPasswordTools.kt:323-330 | getPwdWrongTimes() :309-317;isPwdLimit() :336-341 |
1.2 哈希方案与盐轮换
PinPasswordEncryptor.hashPassword()(PinPasswordEncryptor.kt:52-64)的流程:
- 取盐:保存时(
isSave=true)用System.currentTimeMillis()生成新盐并写入 Global;验证时(isSave=false)读已存的盐,读不到默认0L。 md.update(salt字节)后md.digest(password.toByteArray()),逐字节转两位十六进制拼接(:57-60)。
注意被哈希的”密码串”不是 "1234",而是 IntArray 的 contentToString() 形式 "[1, 2, 3, 4]"(verifyPwd() 里 mFirstInputPwd.contentToString(),PinPasswordTools.kt:239)——台架用 PinPasswordEncryptor.convertPinToIntArrayString()(PinPasswordEncryptor.kt:93-100)做离线与在线比对时务必对齐这个格式。
盐轮换语义:每次 savePwd() 都会生成新盐(PinPasswordTools.kt:268 传 isSave=true),旧哈希自动失效;不存在”改密码但盐不变”的路径。clearPwd() 只清密码哈希和错误次数(:284-289),不清盐——盐残留无害,下次保存会被覆盖。
1.3 三个边界事实
- 上限常量
PWD_WRONG_MAX_TIMES = 5(PinPasswordTools.kt:62),密码长度PWD_MAX_LENGTH = 4(:65),输入完成到触发校验的动画延迟DELAY_TIMES = 200L(:68)。 - “是否已设密码”的判定全仓统一为
getLocalPwd().isNullOrEmpty()(如GloveBoxSettingsDialogActivity.kt:149、:214、GloveBoxNavigationHandler.kt:68)——空串与 null 都视为未设。 - 快照模块镜像了键名常量:
settingsPage/micarSnapshot/.../LocalSettingsGlobal.kt:105GLOVE_BOX_PWD = "glove_box_password",说明设置快照/换机迁移链路会感知这个键,改键名属破坏性变更。
2. 四个事件模式:同一密码页的四种行为
事件常量定义在 PinPasswordTools.kt:50-59,由 PinPasswordCheckUtils.goToCheckPassword() 以 Intent extra param_event_id 传入(PinPasswordCheckUtils.kt:151),Fragment 在 initData() 里分流(BasePinPasswordFragment.kt:209-251):
| event | 常量 | inputMode 实际值 | 标题 | ”忘记密码”按钮 | 触发入口 |
|---|---|---|---|---|---|
| 0 | INPUT_EVENT_CHECK_PWD | CHECK_PWD | ”请输入密码”;isClosePwd=true 时”输入密码以关闭”(:225-229) | 显示(:223) | 开箱前验证、关闭密码保护、MIS 语音开箱 |
| 1 | INPUT_EVENT_SET_PWD | SET_PWD | ”请输入密码” | 隐藏(:233) | 首次开启密码(GloveBoxSettingsDialogActivity.kt:153-155) |
| 2 | INPUT_EVENT_CHANGE_PWD | 先 CHECK_PWD(:241) | “请输入旧密码” | 显示(:239) | “修改密码”按钮(GloveBoxSettingsDialogActivity.kt:120-126 else 分支) |
| 3 | INPUT_EVENT_CHANGE_PWD_VERIFIED | SET_PWD(:247) | “请设置新密码” | 隐藏(:246) | 账号验证已通过后的改密(GloveBoxSettingsDialogActivity.kt:122-124) |
两个容易搞错的点:
- CHANGE_PWD 是”两段式”:先按 CHECK_PWD 模式验旧密码,
verifyCallback里旧密码对了才就地切SET_PWD模式、换标题为”请设置新密码”(BasePinPasswordFragment.kt:301-308),页面不重建。 - CHANGE_PWD 的第二次输入标题是”请再次输入新密码”,而 SET_PWD 是”请再次输入密码”,分流在
onSecondPwdInputStart(:354-361)。
SET 模式的两次输入比对在 checkDoubleInputs()(PinPasswordTools.kt:214-228):逐位不等即 reset() + setPwdCallback(false);全等才 savePwd()。
3. 密码页架构分层:Dialog → Fragment → Tools → CheckUtils
classDiagram class GloveBoxPasswordDialog { +singleInstance / taskAffinity=":GloveBoxPassword" +isConflictDialog() = true +showFragment() 先 cancelVerify() } class BaseConflictDialogActivity { <<framework>> 冲突弹窗互斥 / needToolbar } class GloveBoxPasswordFragment { +getPasswordConfig() +getPasswordTools() +openAccountVerifyPage(isNeedCheckGear, callback) +cancelAccountVerify() } class BasePinPasswordFragment { <<framework>> 四事件 initData() verifyCallback / setPwdCallback noticeVerifyResult() 发广播+finish } class GloveBoxPasswordTools { object 薄委托 config: 三键+广播Action } class PinPasswordTools { <<framework>> inputNum / verifyPwd / savePwd / clearPwd wrongTimes / isPwdLimit } class PinPasswordEncryptor { <<framework>> SHA-256 + 时间戳盐 } class GloveBoxPasswordCheckUtils { object 薄委托 goToCheckPassword(ctx, event, isClosePwd, cb) } class PinPasswordCheckUtils { <<framework>> LocalBroadcast 单回调槽 mCallback } GloveBoxPasswordDialog --|> BaseConflictDialogActivity GloveBoxPasswordDialog --> GloveBoxPasswordFragment : replace 承载 GloveBoxPasswordFragment --|> BasePinPasswordFragment GloveBoxPasswordFragment --> GloveBoxPasswordTools : 取 config/impl GloveBoxPasswordTools --> PinPasswordTools : 全部转发 PinPasswordTools --> PinPasswordEncryptor : 哈希 GloveBoxPasswordCheckUtils --> PinPasswordCheckUtils : 全部转发 PinPasswordCheckUtils --> GloveBoxPasswordDialog : startActivity(NEW_TASK) BasePinPasswordFragment --> PinPasswordCheckUtils : LocalBroadcast 结果回灌
分层要点(全部有码可指):
- Dialog 是宿主壳:
GloveBoxPasswordDialog(GloveBoxPasswordDialog.kt:31)继承BaseConflictDialogActivity,isConflictDialog()=true(:81)进冲突弹窗互斥池;Manifest 里singleInstance+taskAffinity=":GloveBoxPassword"+exported=false(AndroidManifest.xml:30-38),独占任务栈且不可被外部直接拉起(例外见 §6 台架章节)。onNewIntent也走showFragment()重载 Fragment(:59-64),支持弹窗已存活时再次触发验证。 - 每次加载 Fragment 先取消账号验证:
showFragment()第一行就是GloveBoxAccountVerifyUtils.cancelVerify()(GloveBoxPasswordDialog.kt:74),防止残留扫码弹窗叠在密码键盘上。 - Fragment 只做参数化:
GloveBoxPasswordFragment覆写四个方法——getPasswordConfig()(GloveBoxPasswordFragment.kt:39)、getPasswordTools()(:42)、openAccountVerifyPage()(:54-60)、cancelAccountVerify()(:66-69),UI 资源全部吃 baseui 默认(pin_password_*,见PinPasswordConfig.kt:66-106)。 - 结果回灌走 LocalBroadcast 单回调槽:
PinPasswordCheckUtils持有唯一mCallback字段(PinPasswordCheckUtils.kt:58)和一个常驻 receiver(:64-70,init 即注册:75-77);noticeVerifyResult()在 Fragment 侧LocalBroadcastManager.sendBroadcast后finish()(BasePinPasswordFragment.kt:422-433)。单回调槽意味着并发拉起会互相覆盖 callback——冲突弹窗机制(singleInstance + isConflictDialog)正是这个约束的上层保险。 - 设置页与密码页互斥但有白名单:
GloveBoxSettingsDialogActivity.conflictExceptSource()把GloveBoxPasswordDialog放进排除列表(GloveBoxSettingsDialogActivity.kt:241-243),允许密码键盘盖在设置弹窗之上。 - 附带说明:
GloveBoxButton.kt:20-77是手套箱自定义圆角按钮控件(clipToOutline+ViewOutlineProvider圆角),当前 dev 树内未检索到引用点,属预留/历史控件,密码链路不经过它。
4. 错误锁定 → 账号验证全链
4.1 锁定判定与两个拉起调用点
错误累加在 verifyPwd()(PinPasswordTools.kt:238-259):比对失败 times+1 落盘(:244-246),times >= 5 时回调 verifyCallback(false, true) 并 return(:247-252);比对成功则清零(:253-255)。isPwdLimit() 就是 wrongTimes >= 5(:336-341)。
BasePinPasswordFragment 里账号验证有两个调用点,挡位要求不同:
| 调用点 | 位置 | 场景 | isNeedCheckGear |
|---|---|---|---|
| ① 忘记密码 | BasePinPasswordFragment.kt:264(openAccountVerifyPage(false)) | 用户主动点”忘记密码” | false——不查 P 挡 |
| ② 达上限被动锁定 | BasePinPasswordFragment.kt:319(openAccountVerifyPage { ... },用默认参) | 第 5 次输错 | true——查 P 挡 |
P 挡检查在手套箱薄层:GloveBoxAccountVerifyUtils.openVerifyPage()(GloveBoxAccountVerifyUtils.kt:45-57),isNeedCheckGear=true 且 Driving.GEAR != Gear.PARK 时 Toast glove_box_fail_pwd_limit 并 return false(:50-55),根本不到账号页。
4.2 扫码页不在本仓:ContentProvider call 到 com.mi.car.account
GloveBoxAccountVerifyUtils → PinAccountVerifyUtils.openVerifyPage()(PinAccountVerifyUtils.kt:38-41)→ AccountVerifyHelper.openVerifyPage()(AccountVerifyHelper.kt:121-159)。真正的验证页是账号应用的 ContentProvider 接口,证据:
- URI 常量
ACCOUNT_VERIFY_URI = "content://mi.car.account"(AccountVerifyHelper.kt:14); - 回调 Binder 接口
import com.mi.car.account.IAccountConfirmResponse(:9)——扫码页归属com.mi.car.account包,不在 MiCarSettings 仓; - 调用方式
contentResolver.call(targetUri, "confirm", null, bundle)(:147-149),Bundle 里confirm_scene="glovebox_verify"(:70、:127)、confirm_style="qr_code"(:64、:128),另带扫码提示与页面标题字符串(:131-138); - 拉起结果码
:151-154,0=成功拉起,1=未登录,2=验证中;验证结果异步走IAccountConfirmResponse.Stub的onResult(成功,带user_id)与onError(:91-113)。 - 取消是同一个 URI 的
cancelConfirm方法(AccountVerifyHelper.kt:165-191),取消结果码语义注释在:177-182(0 成功 / 5 参数错 / 7 不在验证中 / 8 不允许取消)。
4.3 验证成功后的三条业务分支
sequenceDiagram participant U as "用户" participant F as "GloveBoxPasswordFragment" participant T as "PinPasswordTools" participant G as "GloveBoxAccountVerifyUtils" participant A as "AccountVerifyHelper(com.mi.car.account)" participant S as "Settings.Global" U->>F: "输入第 5 次错误密码" F->>T: "inputNum ×4 → verifyPwd()" T->>S: "wrong_times 4→5" T-->>F: "verifyCallback(false, isLimit=true)" Note over F: "BasePinPasswordFragment.kt:319 调用点②" F->>G: "openAccountVerifyPage(默认 isNeedCheckGear=true)" G->>G: "查 Driving.GEAR == PARK?" alt "非 P 挡" G-->>U: "Toast glove_box_fail_pwd_limit,return false" else "P 挡" G->>A: "contentResolver.call(confirm, scene=glovebox_verify, style=qr_code)" A-->>U: "拉起扫码页(车主手机小米汽车 App 扫码)" U->>A: "扫码完成" A-->>G: "IAccountConfirmResponse.onResult(userId)" G-->>F: "callback(true)" F->>S: "setPwdWrongTimes(0)" F-->>U: "noticeVerifyResult(true) → LocalBroadcast → finish()" end
清零后的动作按入口分三条(互不相同的业务语义,别混):
- 密码页内达上限(
BasePinPasswordFragment.kt:319-326):清零 +noticeVerifyResult(true)——把”验证通过”广播给最初拉起密码页的调用方,由它继续自己的业务(开箱/关密码)。 - 设置页开关/改密按钮达上限(
GloveBoxSettingsDialogActivity.kt:85-99与showAccountVerify():193-205):清零 +clearPwd()+updateUIState()+reportGloveBoxForgotPasswordTrack()(:200,忘记密码验证成功埋点);开关场景下验证通过等于”连密码一起清掉”,设置页回到未设密码态。 - MIS 语音入口达上限(
GloveBoxNavigationHandler.kt:114-122):验证通过 →openGloveBox()直接开箱 +setPwdWrongTimes(0)。注意这条分支不清密码,只清零计数——箱体开了,密码还在。
5. MIS 语音入口:应用 Context 拉页 + 关系统弹窗 + 属性开箱
MIS(语音/远程)开箱不进设置 UI,走服务发布通道:
GloveBoxModulePublishService(GloveBoxModulePublishService.kt:11-22)继承AbsModulePublishService,getServiceHandlerList()把GloveBoxNavigationHandler挂到list.gloveCompartment(:19)。Manifest 声明(AndroidManifest.xml:10-28):exported=true+com.xiaomi.permission.ACCESS_MIS_SPEC_SERVICE权限 +static_ddf_configuration静态注册;keep_alive=false(:24-27)——按注释口径,默认true表示”只要有 Property/Event 被订阅就拉起服务”,置false即手套箱服务不做保活,MIS 指令到达时才被拉起处理,处理完不驻留。GloveBoxNavigationHandler.onAction()(GloveBoxNavigationHandler.kt:62-86)四分支:锁类型非POWER→1003不支持;未设密码 → 直接开箱回1001;达上限 → 拉起账号验证回1004;已设密码 → 弹密码页回1002(响应码常量:45-54)。- 从应用 Context 拉 Activity:
showPasswordCheckDialog()用BaseApplication.getGlobalApp()当 context(:96-98),PinPasswordCheckUtils.goToCheckPassword()内部补FLAG_ACTIVITY_NEW_TASK(PinPasswordCheckUtils.kt:156)——非 Activity 上下文拉页的标准写法。 - 先关控制中心再弹验证:
closeControlCenter()发Intent.ACTION_CLOSE_SYSTEM_DIALOGS(GloveBoxNavigationHandler.kt:105-107),避免系统面板遮挡密码键盘/扫码页;达上限分支同样先关(:76)。 - 开箱先例:
openGloveBox()走GlobalCarPropertyManager.getCarManager().setCarProperty(MiCarPropertyIds.DrivingCtrl.GLOVE_BOX, CommonParams.OpenClose.OPEN)(:125-131)——与设置 UI 开箱是同一条属性写入路径,MIS 只是换了个入口,没有第二套下发通道(属性语义见 01-需求与信号契约)。
6. 台架调出与坑(2026-10-20 mach 实证)
详细操作手册见 car-signal-mock-test skill 的 LESSONS 库(/home/zbc/下载/test/车机JIRA/.claude/skills/car-signal-mock-test/LESSONS.md)教训 17、18,这里只留结论与命令:
-
root 直拉(教训 18):两个弹窗 Activity 都是
exported=false,shell 身份am start会被 Permission Denial;adb root后(u:r:su:s0)可跳过 exported 检查直拉:adb root # 密码页,event 0=验证 / 1=设置 / 2=修改 / 3=已验证后修改 adb shell am start -n com.android.car.settings/.miauto.vehicle.glovebox.GloveBoxPasswordDialog --ei param_event_id 0 # 密码设置页 adb shell am start -n com.android.car.settings/.miauto.vehicle.glovebox.GloveBoxSettingsDialogActivity直拉等价于
goToCheckPassword的 event 参数透传,但没有注册 LocalBroadcast 结果回调——验证通过后的业务动作(开箱等)不会发生,要验全链路必须走真实入口。 -
wrong_times=5 残留直达扫码页(教训 17):拉密码页却出来”扫码验证车主身份”,大概率是
glove_box_password_wrong_times残留 ≥5,verifyCallback达上限分支直接把页面顶去账号验证。处置:adb shell settings list global | grep glove_box # 先看三键状态 adb shell settings put global glove_box_password_wrong_times 0 adb shell am force-stop com.android.car.settings演示完保持 0——≥上限本身就是异常态,不需要”恢复现场”。
-
未设密码时输任意 4 位必败并累加:这是代码实证而非经验——
verifyPwd()里getLocalPwd()返回 null,hashPassword(...) == null恒 false(PinPasswordTools.kt:240-243),于是走失败分支times+1(:244-246)。台架空状态乱点密码键盘,5 下就把车机打进锁定态,排查”怎么突然要扫码”时先想这一条。
7. 密码验证状态机
stateDiagram-v2 [*] --> "空闲" "空闲" --> "输入中" : "inputNum(0-9)" "输入中" --> "输入中" : "未满4位/删除退格" "输入中" --> "校验中" : "满4位 + 200ms延迟" "校验中" --> "校验通过" : "hash == localPwd" "校验通过" --> "空闲" : "wrong_times 清零 + reset()" "校验中" --> "校验失败" : "hash != localPwd(含未设密码)" "校验失败" --> "输入中" : "wrong_times+1 < 5,显示错误提示" "校验失败" --> "达上限锁定" : "wrong_times+1 >= 5" "达上限锁定" --> "账号验证" : "openAccountVerifyPage(查P挡)" "达上限锁定" --> "账号验证" : "忘记密码入口(不查P挡)" "账号验证" --> "空闲" : "扫码成功:wrong_times 清零,按入口续业务(开箱/清密码/改密)" "账号验证" --> "达上限锁定" : "取消/失败:计数保持 ≥5" "空闲" --> "关闭" : "noticeVerifyResult 发广播 + finish()" "关闭" --> [*]
要点:锁定态不是页面态而是数据态(glove_box_password_wrong_times >= 5),任何入口(设置页开关、改密按钮、MIS 语音、密码页输入)先做 isPwdLimit() 判定,无一例外地被导向账号验证;唯一的解锁动作是扫码成功后的 setPwdWrongTimes(0)。
证据清单
| 文件 | 行号区间 | 说明 |
|---|---|---|
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxPasswordTools.kt | 33-38, 101-139 | 三键 config 声明;clearPwd/getPwdWrongTimes/isPwdLimit 薄委托 |
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxPasswordCheckUtils.kt | 29-58 | 绑定 GloveBoxPasswordDialog + 广播 Action;goToCheckPassword 参数语义 |
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxPasswordDialog.kt | 31, 59-84 | singleInstance 宿主;showFragment 先 cancelVerify;isConflictDialog/needToolbar |
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxPasswordFragment.kt | 32, 39-69 | 广播 Action 常量;四覆写;openAccountVerifyPage 转发 |
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxSettingsDialogActivity.kt | 85-99, 145-205, 213-223, 241-243 | 改密/开关的 isPwdLimit 分支;showAccountVerify 清零+clearPwd+埋点;冲突排除列表 |
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxAccountVerifyUtils.kt | 45-68 | P 挡检查(Driving.GEAR/Gear.PARK);转发 PinAccountVerifyUtils |
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxNavigationHandler.kt | 45-54, 62-131 | MIS 响应码 1001-1004;四分支;应用 Context 拉页;CLOSE_SYSTEM_DIALOGS;setCarProperty 开箱 |
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxModulePublishService.kt | 11-22 | MIS 服务发布,gloveCompartment 挂载 |
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxButton.kt | 20-77 | 自定义圆角按钮控件(树内未检索到引用点) |
settingsPage/VehicleBodyControl/src/main/AndroidManifest.xml | 10-48 | 服务 keep_alive=false;两 Activity singleInstance/taskAffinity/exported=false |
base/settingsBaseUi/.../password/PinPasswordTools.kt | 50-68, 127-173, 214-341 | 四事件常量;5 次上限/4 位/200ms;inputNum 分流;checkDoubleInputs;verifyPwd 累加与清零;savePwd/clearPwd/wrongTimes 读写 |
base/settingsBaseUi/.../password/PinPasswordCheckUtils.kt | 44-163 | LocalBroadcast 单回调槽 mCallback;常驻 receiver;FLAG_ACTIVITY_NEW_TASK 拉页 |
base/settingsBaseUi/.../password/BasePinPasswordFragment.kt | 209-361, 422-443 | 四事件 initData;忘记密码调用点①(:264);达上限调用点②(:319);CHANGE 两段式;noticeVerifyResult 广播+finish |
base/settingsBaseUi/.../password/PinAccountVerifyUtils.kt | 38-53 | openVerifyPage/cancelVerify 转发 AccountVerifyHelper |
base/settingsBaseUi/.../password/PinPasswordEncryptor.kt | 52-64, 93-132 | SHA-256 哈希流程;时间戳盐生成/读取;IntArray 串格式 |
base/settingsBaseUi/.../password/PinPasswordConfig.kt | 32-107 | 三键/广播 Action/UI 资源默认值的配置数据类 |
base/settingsBaseLib/.../utils/AccountVerifyHelper.kt | 9-14, 54-87, 91-113, 121-191 | content://mi.car.account URI;confirm/cancelConfirm;qr_code + glovebox_verify 场景;IAccountConfirmResponse 回调;拉起/取消结果码 |
settingsPage/micarSnapshot/.../data/LocalSettingsGlobal.kt | 105 | 快照模块镜像 glove_box_password 键名 |
/home/zbc/下载/test/车机JIRA/.claude/skills/car-signal-mock-test/LESSONS.md | 教训 17/18 | wrong_times 残留直达扫码页;root 直拉 exported=false 弹窗(2026-10-20 mach 实证) |