手套箱 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_passwordStringSHA-256(salt + pwd) 十六进制PinPasswordTools.savePwd() PinPasswordTools.kt:266-275;清空在 clearPwd() :282-290(写 "")getLocalPwd() :296-303;验证比对在 verifyPwd() :241
glove_box_password_saltLong时间戳盐(毫秒)PinPasswordEncryptor.getSalt(isNeedCreateNew=true) PinPasswordEncryptor.kt:116-122验证时读取 :123-129
glove_box_password_wrong_timesInt连续错误次数,默认 0setPwdWrongTimes() PinPasswordTools.kt:323-330getPwdWrongTimes() :309-317;isPwdLimit() :336-341

1.2 哈希方案与盐轮换

PinPasswordEncryptor.hashPassword()(PinPasswordEncryptor.kt:52-64)的流程:

  1. 取盐:保存时(isSave=true)用 System.currentTimeMillis() 生成新盐并写入 Global;验证时(isSave=false)读已存的盐,读不到默认 0L。
  2. 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:105 GLOVE_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 实际值标题”忘记密码”按钮触发入口
0INPUT_EVENT_CHECK_PWDCHECK_PWD”请输入密码”;isClosePwd=true 时”输入密码以关闭”(:225-229)显示(:223)开箱前验证、关闭密码保护、MIS 语音开箱
1INPUT_EVENT_SET_PWDSET_PWD”请输入密码”隐藏(:233)首次开启密码(GloveBoxSettingsDialogActivity.kt:153-155)
2INPUT_EVENT_CHANGE_PWD先 CHECK_PWD(:241)“请输入旧密码”显示(:239)“修改密码”按钮(GloveBoxSettingsDialogActivity.kt:120-126 else 分支)
3INPUT_EVENT_CHANGE_PWD_VERIFIEDSET_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 结果回灌

分层要点(全部有码可指):

  1. 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),支持弹窗已存活时再次触发验证。
  2. 每次加载 Fragment 先取消账号验证:showFragment() 第一行就是 GloveBoxAccountVerifyUtils.cancelVerify()(GloveBoxPasswordDialog.kt:74),防止残留扫码弹窗叠在密码键盘上。
  3. Fragment 只做参数化:GloveBoxPasswordFragment 覆写四个方法——getPasswordConfig()(GloveBoxPasswordFragment.kt:39)、getPasswordTools()(:42)、openAccountVerifyPage()(:54-60)、cancelAccountVerify()(:66-69),UI 资源全部吃 baseui 默认(pin_password_*,见 PinPasswordConfig.kt:66-106)。
  4. 结果回灌走 LocalBroadcast 单回调槽:PinPasswordCheckUtils 持有唯一 mCallback 字段(PinPasswordCheckUtils.kt:58)和一个常驻 receiver(:64-70,init 即注册 :75-77);noticeVerifyResult() 在 Fragment 侧 LocalBroadcastManager.sendBroadcast 后 finish()(BasePinPasswordFragment.kt:422-433)。单回调槽意味着并发拉起会互相覆盖 callback——冲突弹窗机制(singleInstance + isConflictDialog)正是这个约束的上层保险。
  5. 设置页与密码页互斥但有白名单:GloveBoxSettingsDialogActivity.conflictExceptSource() 把 GloveBoxPasswordDialog 放进排除列表(GloveBoxSettingsDialogActivity.kt:241-243),允许密码键盘盖在设置弹窗之上。
  6. 附带说明: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

清零后的动作按入口分三条(互不相同的业务语义,别混):

  1. 密码页内达上限(BasePinPasswordFragment.kt:319-326):清零 + noticeVerifyResult(true)——把”验证通过”广播给最初拉起密码页的调用方,由它继续自己的业务(开箱/关密码)。
  2. 设置页开关/改密按钮达上限(GloveBoxSettingsDialogActivity.kt:85-99 与 showAccountVerify() :193-205):清零 + clearPwd() + updateUIState() + reportGloveBoxForgotPasswordTrack()(:200,忘记密码验证成功埋点);开关场景下验证通过等于”连密码一起清掉”,设置页回到未设密码态。
  3. 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,这里只留结论与命令:

  1. 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 结果回调——验证通过后的业务动作(开箱等)不会发生,要验全链路必须走真实入口。

  2. 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——≥上限本身就是异常态,不需要”恢复现场”。

  3. 未设密码时输任意 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.kt33-38, 101-139三键 config 声明;clearPwd/getPwdWrongTimes/isPwdLimit 薄委托
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxPasswordCheckUtils.kt29-58绑定 GloveBoxPasswordDialog + 广播 Action;goToCheckPassword 参数语义
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxPasswordDialog.kt31, 59-84singleInstance 宿主;showFragment 先 cancelVerify;isConflictDialog/needToolbar
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxPasswordFragment.kt32, 39-69广播 Action 常量;四覆写;openAccountVerifyPage 转发
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxSettingsDialogActivity.kt85-99, 145-205, 213-223, 241-243改密/开关的 isPwdLimit 分支;showAccountVerify 清零+clearPwd+埋点;冲突排除列表
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxAccountVerifyUtils.kt45-68P 挡检查(Driving.GEAR/Gear.PARK);转发 PinAccountVerifyUtils
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxNavigationHandler.kt45-54, 62-131MIS 响应码 1001-1004;四分支;应用 Context 拉页;CLOSE_SYSTEM_DIALOGS;setCarProperty 开箱
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxModulePublishService.kt11-22MIS 服务发布,gloveCompartment 挂载
settingsPage/VehicleBodyControl/.../glovebox/GloveBoxButton.kt20-77自定义圆角按钮控件(树内未检索到引用点)
settingsPage/VehicleBodyControl/src/main/AndroidManifest.xml10-48服务 keep_alive=false;两 Activity singleInstance/taskAffinity/exported=false
base/settingsBaseUi/.../password/PinPasswordTools.kt50-68, 127-173, 214-341四事件常量;5 次上限/4 位/200ms;inputNum 分流;checkDoubleInputs;verifyPwd 累加与清零;savePwd/clearPwd/wrongTimes 读写
base/settingsBaseUi/.../password/PinPasswordCheckUtils.kt44-163LocalBroadcast 单回调槽 mCallback;常驻 receiver;FLAG_ACTIVITY_NEW_TASK 拉页
base/settingsBaseUi/.../password/BasePinPasswordFragment.kt209-361, 422-443四事件 initData;忘记密码调用点①(:264);达上限调用点②(:319);CHANGE 两段式;noticeVerifyResult 广播+finish
base/settingsBaseUi/.../password/PinAccountVerifyUtils.kt38-53openVerifyPage/cancelVerify 转发 AccountVerifyHelper
base/settingsBaseUi/.../password/PinPasswordEncryptor.kt52-64, 93-132SHA-256 哈希流程;时间戳盐生成/读取;IntArray 串格式
base/settingsBaseUi/.../password/PinPasswordConfig.kt32-107三键/广播 Action/UI 资源默认值的配置数据类
base/settingsBaseLib/.../utils/AccountVerifyHelper.kt9-14, 54-87, 91-113, 121-191content://mi.car.account URI;confirm/cancelConfirm;qr_code + glovebox_verify 场景;IAccountConfirmResponse 回调;拉起/取消结果码
settingsPage/micarSnapshot/.../data/LocalSettingsGlobal.kt105快照模块镜像 glove_box_password 键名
/home/zbc/下载/test/车机JIRA/.claude/skills/car-signal-mock-test/LESSONS.md教训 17/18wrong_times 残留直达扫码页;root 直拉 exported=false 弹窗(2026-10-20 mach 实证)