删掉一个住在本机的 agent:8 处要清,顺序不能反
Andee 是一个跑在 Android 上、靠无障碍服务自己看界面、自己点屏幕的 agent。大脑、笔记本、保险箱全在本机,没有账号,没有厂商后端。它设置页最下面有一个"恢复出厂设置"------这个按钮是整个项目里最难写对的设置项之一,因为它要一次性抹掉一个"有记忆的东西"。
10 月 29 日,深兰科技联合华为鸿蒙生态发布了 Wowoker,一台面向政企的企业级"智能体工作手机":声音克隆复刻机主声线、7×24 小时自动外呼、设备资产归企业所有、管理员可远程锁定与清除数据。产品本身不去评它,但它把一个问题顶到了台面上------删除一个住在设备里的 agent,不是删文件。 删错顺序会留下肉眼可见的坏结果。
这篇只讲实现:清单怎么组织、三处不能直接删的目标、调用点那一步、以及这套做法真实的代价。
结论先给
- 清单和确认文案必须同源,否则确认页迟早开始骗人。
- 顺序是删除的一部分:闹钟必须在笔记本之前取消。
- 有的目标不能"删目录",必须走各自的 API,因为目录不是它的全部。
- 擦完必须重建进程内状态,否则用户以为删了,设备还记得。
证据:188 行,两个列表
config/FactoryReset.kt 总共 188 行,核心是两个列表加一个控制流。
kotlin
private class Target(@StringRes val label: Int, val clear: (Context) -> Unit)
private val TARGETS: List<Target> = listOf(/* 7 项 */)
private val ALSO_CLEARED: List<(Context) -> Unit> = listOf(/* 1 项 */)
fun wipe(context: Context): Int // 返回清掉了几项
TARGETS 的 7 项依次是:key 与语音/大脑设置、笔记本、保险箱、唤醒词模板、对话历史与生成过的页面、WebView 数据、接地日志。ALSO_CLEARED 只有 1 项:哪些 App 被打开过、打开过几次。它不进确认页,因为它只是笔记本里"装备架"的另一份拷贝,单列一行等于同一个承诺说两遍。
实现
1. 清单即确认页
Target 把 @StringRes label 和 clear 放在同一个对象里。这不是为了少写一行 when,是因为分开维护一定会漂移:加了一项删除、忘了加一行说明,确认页就从"告知"变成"隐瞒"。而一个删掉了用户没被告知的东西的重置,比不做重置更糟。
文案用资源 id 而不是 english: Boolean 参数------那个开关只能在两段硬编码字符串之间选,加第三种语言要改每一个调用点。用资源 id,第三种语言是一个新文件。
2. 顺序:闹钟先死
kotlin
Scheduler.cancelSweep(context)
for (todo in Notebook.todos(context, "all")) Scheduler.cancelTodo(context, todo.id)
这两行在 TARGETS 循环之前。原因:AlarmManager 的闹钟是按 todo id 登记的,而 id 存在那个马上要被 Notebook.wipe() 擦掉的文件里。反过来做,会得到一个"承诺还在响、但设备已经不知道许过什么愿"的状态------通知栏弹一条没人能追溯的通知,用户只能长按静音,然后怀疑这软件是不是坏了。
这个顺序依赖不是靠记忆维持的,是靠注释和一行"先取消"写在函数开头。
3. 三类不能直接删的目标
key 与设置:留一个例外。 先读出 codeflying_key,清空 voice_prefs,再写回去,三次都用 commit()。
kotlin
val keep = prefs.getString(KEPT_KEY, null)
prefs(ctx, "voice_prefs") // clear().commit()
if (!keep.isNullOrBlank()) prefs.edit().putString(KEPT_KEY, keep).commit()
用 commit() 不用 apply():紧接着就会重建大脑,异步落盘会撞上读。
WebView:走 API,不删目录。 CookieManager.removeAllCookies(null) + WebStorage.deleteAllData()。原因是 HtmlActivity 把每个生成的页面当真实 origin 加载(自带 base URL、DOM storage、JS 全开),页面能写 cookie 和 localStorage。而 app_webview/ 目录里同时住着 WebView 自己的 variations_seed、pref_store、GPU cache------递归删是和正在跑的渲染器抢,还会顺手带走别人的东西。HTTP cache 也不清:清它需要一个活着的 WebView 实例,为几个字节把整个 Chromium 从 service 里拉起来不划算。
接地日志:必须真递归删。 它在外部存储目录(本来就设计成可以用 adb 拉走),所以 deleteRecursively();删不干净就抛 IllegalStateException,让调用方知道这次没成功。这里刻意没用 GroundingLog.shutdown()------那会把 writer 关到下一次 service 启动,用户重新打开接地之后日志仍然是哑的。
4. 调用点:擦完要换一个脑子
FactoryReset.wipe() 只负责磁盘。真正让"删除"成立的,是紧接着的两步:
kotlin
private fun applyFactoryReset() {
val cleared = FactoryReset.wipe(this)
stopBrain()
startBrain()
}
因为本机大脑把这段对话放在内存里,把旧 key 放在它自己的 HTTP client 里。不重建,设备会继续用一个已经不存在的凭据说话;而 startBrain() 读到的 prefs 是空的,会落到默认(hub、无地址),一个请求都不发------这才是"刚装好"的定义。
clearChatHistory() 是同一个形状:只清 ChatHistory 而不重建大脑,等于你清掉了一段设备仍然一字不差记得的对话。
代价
没有备份,也不可能有。 保险箱和笔记本是 Keystore AES-GCM 加密的 blob,key 跟着 App 走;prefs 一清,密文留着也打不开。注释里那句是"这里没有任何东西是可逆的"。
8 处清、7 处列。 第 8 项被折进笔记本那行文案里,这是个有意的取舍,但严格说,确认页列出的和实际发生的不是一一对应。
系统权限不归我们删。 无障碍开关和运行时权限属于系统设置,不属于这个 App。一个"权限和配置一起丢"的设备,比"只丢配置"的难恢复得多------这是从真实支持成本里换来的决定。
部分失败只有 logcat 知道。 每一项都包在 runCatching 里,失败记日志、继续下一项。好处是中途一个目录不存在不会让整个重置停在一半;坏处是理论上存在"部分清空",而 UI 只说"清了 N 项",用户不会去看 logcat。
这套代码不解决"组织怎么管一批设备"。 没有账号、没有后台,也就没有远程指令、没有审计流水、没有集中配置。企业采购场景里这些是硬需求,这里一条都没有。反向的代价同样要在:设备开着 0.0.0.0:9008 的 WebSocket 且不校验凭据(SECURITY.md 已知限制第 1 条),任何能到这个 IP 的机器都能像 App 一样驾驶它。没有后台来管你,也没有门来挡别人,这是同一个架构决定的两面。
如果只记一句:删除一个本地 agent 的难点从来不是"删哪些",而是"顺序、同源、以及删完之后那些还在内存里的东西"。