feat(清理): 卸载残留治理 —— 显式清除入口 + 孤儿兜底加强 #3

鸿蒙没有「应用即将被卸载」的回调(网传的 preUninstallHook /
uninstallDataPolicy 在本机 SDK 中均不存在),而系统日历数据属于
沙箱外,卸载时不会被系统清理 ⇒ 用户卸载后会永久残留镜像账户
(还会继续提醒)。本次提供显式入口 + 状态兜底。

1. AccountStore.isEmptyVaultConfirmed()
   区分「账号库真的空」与「loadAll 吞异常返回空」。
   依据:loadAll 内部把所有异常 catch 后返回 [],调用方无法从返回值
   辨别,而孤儿清理一旦误判就会把用户真实在用的镜像全删掉。

2. SystemCalendarMirror.purgeOrphanAccountMirrors()
   原来 accounts.length === 0 一律跳过(安全阀),导致卸载重装后
   旧镜像永远清不掉。改为三态:
     ① 有账号           → 只清 accId 不匹配的(原逻辑)
     ② 空 + 确认空白     → 全清(卸载/重置后的残留)
     ③ 空 + 拿不准       → 仍跳过(读异常,不能误删)
   全清模式下不再因「解析不出 accId」跳过脏账户,否则清不干净。

3. SettingsPage 新增独立卡片「清除系统日历数据」
   - 「彻底清除系统日历镜像数据」:二次确认后 removeAll,
     并关闭镜像开关(否则下一轮同步立刻重建,用户以为没清掉),
     镜像本消失后把提醒交回 App(避免漏提醒)
   - 零结果提示区分「没权限」与「本来就没有」,用户要的是确认清干净
   - 卡片放在镜像开关之外:关着镜像时也能清残留(正是典型场景)
   - 原「清理残留镜像账户(诊断)」一并从镜像卡片内移入本卡

只删系统日历镜像;CalDAV 账号与服务器日程完全不动。
This commit is contained in:
2026-10-30 13:34:09 +08:00
parent 63b1a75b03
commit 199b6c177f
3 changed files with 214 additions and 9 deletions
@@ -699,4 +699,45 @@ export class AccountStore {
return false;
}
}
/**
* ⭐ 账号库是否**确认为空白**(不是"读取失败导致的空")。
*
* 为什么需要它:`loadAll()` 内部把所有异常都 catch 掉并返回 `[]`,
* 于是调用方无法从返回值区分两种情况:
* ① 账号库真的空(全新安装 / 用户重置 / **卸载重装**);
* ② 密文存在但解不开,或读取过程抛错(临时故障)。
*
* 系统日历镜像的孤儿清理必须只在 ① 时动手 —— ② 若照常清理,
* 会把"账号其实还在"的镜像账户全部误删(用户日程从系统日历里消失)。
*
* 判定口径(三者同时成立才算"确认空白"):
* - 偏好里**没有**账号密文(`data_b64` 为空);
* - 旧版明文存储也没有账号;
* - 读取过程没有抛异常。
*
* @returns true = 确认空白,可安全清理孤儿镜像;false = 拿不准,调用方必须保守跳过
*/
static async isEmptyVaultConfirmed(context: common.Context): Promise<boolean> {
try {
const store: preferences.Preferences = await Vault.prefs(context);
const data: string = await store.get(VAULT_DATA, '') as string;
if (data !== '') {
// 有密文:只有能解出"0 个账号"才算确认空白;解不开一律视为拿不准
const plain: string | null = await Vault.decrypt(context, data);
if (plain === null) {
return false;
}
const arr: Array<Record<string, Object>> = JSON.parse(plain) as Array<Record<string, Object>>;
return arr.length === 0;
}
// 无密文:再看旧版明文存储是否还有残留账号
const legacy: DavAccount[] | null = await AccountStore.loadLegacy(context);
return legacy === null || legacy.length === 0;
} catch (err) {
const e = err as BusinessError;
console.error(`[AccountStore] isEmptyVaultConfirmed 判定失败(按"拿不准"处理): ${e.message}`);
return false;
}
}
}
@@ -795,10 +795,13 @@ export class SystemCalendarMirror {
/**
* ⭐ 兜底:清理"账号已不存在"的孤儿镜像账户(同步时调用,防止删除路径万一漏掉)。
*
* ⚠️ **安全阀**:当前账号列表为空时**直接跳过** —— 无法区分"用户删光了账号"与
* "账号存储临时读失败返回空",后者若照常清理会把所有镜像账户误删。
* "删光账号"这种场景已由 `removeByAccount` / 重置账号库两条显式路径覆盖。
* 只要还剩至少一个账号,就只清"accId 前缀不在当前账号集合里"的那些 —— 精准、不会误伤。
* 三种情况(0.1.1 起,配合"卸载残留"问题):
* ① **还有账号** → 只清"accId 不在当前账号集合里"的 —— 原逻辑,精准不误伤。
* ② **账号库为空、且确认是真空**(`isEmptyVaultConfirmed()`)→ **全部清掉**。
* 这是卸载重装 / 用户重置账号库后的场景:旧镜像账户的 accId 不可能再匹配上,
* 留着只会变成系统日历里永远删不掉的幽灵本,还会继续提醒。
* ③ **账号库为空、但拿不准**(读取异常 / 密文解不开)→ **仍然跳过**。
* `loadAll()` 会把异常吞掉返回 `[]`,若不区分就会把用户真实在用的镜像全删了。
*
* @returns 删除的账户数
*/
@@ -812,8 +815,14 @@ export class SystemCalendarMirror {
} catch (err) {
return 0; // 读失败一律不动
}
let wipeAll: boolean = false;
if (accounts.length === 0) {
return 0; // 安全阀:见注释
// 空账号库:只有"确认空白"才敢全清,否则保守跳过(见上方 ③)
wipeAll = await AccountStore.isEmptyVaultConfirmed(context);
if (!wipeAll) {
return 0;
}
LogUtil.write('兜底清理孤儿镜像:账号库确认为空 → 清理全部镜像账户(卸载/重置后残留)');
}
const validAccIds: Set<string> = new Set<string>();
for (const a of accounts) {
@@ -838,11 +847,13 @@ export class SystemCalendarMirror {
} else {
sep = dash >= 0 ? dash : under;
}
if (sep <= 0) {
continue; // 解析不出 accId → 不动(宁可留,不误删)
// 非全清模式:必须能解析出 accId 才能比对,解析不出就跳过(宁可留,不误删)
// 全清模式:前缀已命中就是本应用建的,脏账户(解析不出 accId)也要删干净
if (!wipeAll && sep <= 0) {
continue;
}
const accId: string = bucket.substring(0, sep);
if (validAccIds.has(accId)) {
const accId: string = sep > 0 ? bucket.substring(0, sep) : bucket;
if (!wipeAll && validAccIds.has(accId)) {
continue;
}
try {