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; 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 {
/** /**
* ⭐ 兜底:清理"账号已不存在"的孤儿镜像账户(同步时调用,防止删除路径万一漏掉)。 * ⭐ 兜底:清理"账号已不存在"的孤儿镜像账户(同步时调用,防止删除路径万一漏掉)。
* *
* ⚠️ **安全阀**:当前账号列表为空时**直接跳过** —— 无法区分"用户删光了账号"与 * 三种情况(0.1.1 起,配合"卸载残留"问题):
* "账号存储临时读失败返回空",后者若照常清理会把所有镜像账户误删。 * ① **还有账号** → 只清"accId 不在当前账号集合里"的 —— 原逻辑,精准不误伤。
* "删光账号"这种场景已由 `removeByAccount` / 重置账号库两条显式路径覆盖。 * ② **账号库为空、且确认是真空**(`isEmptyVaultConfirmed()`)→ **全部清掉**。
* 只要还剩至少一个账号,就只清"accId 前缀不在当前账号集合里"的那些 —— 精准、不会误伤。 * 这是卸载重装 / 用户重置账号库后的场景:旧镜像账户的 accId 不可能再匹配上,
* 留着只会变成系统日历里永远删不掉的幽灵本,还会继续提醒。
* ③ **账号库为空、但拿不准**(读取异常 / 密文解不开)→ **仍然跳过**。
* `loadAll()` 会把异常吞掉返回 `[]`,若不区分就会把用户真实在用的镜像全删了。
* *
* @returns 删除的账户数 * @returns 删除的账户数
*/ */
@@ -812,8 +815,14 @@ export class SystemCalendarMirror {
} catch (err) { } catch (err) {
return 0; // 读失败一律不动 return 0; // 读失败一律不动
} }
let wipeAll: boolean = false;
if (accounts.length === 0) { if (accounts.length === 0) {
return 0; // 安全阀:见注释 // 空账号库:只有"确认空白"才敢全清,否则保守跳过(见上方 ③)
wipeAll = await AccountStore.isEmptyVaultConfirmed(context);
if (!wipeAll) {
return 0;
}
LogUtil.write('兜底清理孤儿镜像:账号库确认为空 → 清理全部镜像账户(卸载/重置后残留)');
} }
const validAccIds: Set<string> = new Set<string>(); const validAccIds: Set<string> = new Set<string>();
for (const a of accounts) { for (const a of accounts) {
@@ -838,11 +847,13 @@ export class SystemCalendarMirror {
} else { } else {
sep = dash >= 0 ? dash : under; sep = dash >= 0 ? dash : under;
} }
if (sep <= 0) { // 非全清模式:必须能解析出 accId 才能比对,解析不出就跳过(宁可留,不误删)
continue; // 解析不出 accId → 不动(宁可留,不误删) // 全清模式:前缀已命中就是本应用建的,脏账户(解析不出 accId)也要删干净
if (!wipeAll && sep <= 0) {
continue;
} }
const accId: string = bucket.substring(0, sep); const accId: string = sep > 0 ? bucket.substring(0, sep) : bucket;
if (validAccIds.has(accId)) { if (!wipeAll && validAccIds.has(accId)) {
continue; continue;
} }
try { try {
+153
View File
@@ -49,6 +49,7 @@ struct SettingsPage {
@State mirrorKeys: string[] = []; // 要镜像的 DAV 日历本 **bookId**(稳定标识,不用会变的 calKey) @State mirrorKeys: string[] = []; // 要镜像的 DAV 日历本 **bookId**(稳定标识,不用会变的 calKey)
@State mirrorRunning: boolean = false; // 正在执行镜像 @State mirrorRunning: boolean = false; // 正在执行镜像
@State purgeRunning: boolean = false; // 正在清理残留镜像账户 @State purgeRunning: boolean = false; // 正在清理残留镜像账户
@State wipeAllRunning: boolean = false; // 正在清除全部镜像(卸载前清干净)
@State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌) @State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌)
@State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关) @State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关)
@State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充) @State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充)
@@ -681,6 +682,81 @@ struct SettingsPage {
} }
} }
/**
* ⭐「彻底清除全部镜像数据」(卸载前清干净用)。
*
* 为什么需要它:鸿蒙**没有**「应用即将被卸载」的回调,系统日历属于**沙箱外**数据,
* 卸载时不会自动清理。用户卸载后,系统日历里会永久留着一堆
* 「同步日历·xxx」账户(还会继续提醒),只有重装并重新同步才能被当孤儿清掉。
* ⇒ 提供显式入口,让用户在卸载前主动清干净。
*
* ⚠️ 清除范围(务必与文案一致):
* - 只删**系统日历里本应用建立的镜像账户及其日程**
* - **不动** CalDAV 账号配置(账号密码仍保留,重装后可直接用)
* - **不动** 服务器上的任何日程(同步过去的内容一条都不会少)
*/
private confirmWipeAllMirrors(): void {
if (this.context === undefined || this.wipeAllRunning) {
return;
}
this.getUIContext().showAlertDialog({
title: '彻底清除系统日历镜像数据',
message: '将删除本应用写入系统日历的全部镜像日历本及其日程。\n\n'
+ '只影响系统日历:你的 CalDAV 账号与服务器上的日程完全不受影响。\n'
+ '清除后系统日历里不再显示这些日程,也不再提醒;镜像开关会一并关闭。\n\n'
+ '准备卸载本应用时,请先点这里清干净(卸载时系统不会自动清除)。',
autoCancel: true,
alignment: DialogAlignment.Center,
primaryButton: {
value: '取消',
action: (): void => {
}
},
secondaryButton: {
value: '确认清除',
action: (): void => {
this.doWipeAllMirrors();
}
}
});
}
private async doWipeAllMirrors(): Promise<void> {
if (this.context === undefined || this.wipeAllRunning) {
return;
}
this.wipeAllRunning = true;
try {
const n: number = await SystemCalendarMirror.removeAll(this.context);
// ⭐ 必须同时把"镜像开关"关掉并落库:否则下一轮同步会立刻按勾选重新建回来,
// 用户会以为"清了等于没清"。
this.mirrorEnabled = false;
await AppSettings.setMirrorEnabled(this.context, false);
// 镜像本没了 → 提醒要交回 App 负责,否则这些本会既没系统提醒、也没 App 提醒(漏提醒)
ReminderService.invalidateMirrorCache();
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
// ⭐ 清空结果必须说清楚:用户点这个按钮就是为了"确认卸载后不留东西",
// 所以"0 个"也要区分是权限没给(没清成)还是本来就干净(放心)
const granted: boolean = await SystemCalendarMirror.hasPermission();
let msg: string = '';
if (n > 0) {
msg = `已清除 ${n} 个镜像日历本,系统日历中不再有本应用的日程;镜像开关已关闭`;
} else if (!granted) {
msg = '未授予"写入系统日历"权限,无法清除。请先在设置中允许本应用读写日历后重试';
} else {
msg = '系统日历中已没有本应用的镜像数据,无需清除';
}
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 5000 });
} catch (err) {
const e = err as BusinessError;
this.getUIContext().getPromptAction().showToast({
message: `清除失败:${e.message ?? '请确认已授予"写入系统日历"权限'}`
});
} finally {
this.wipeAllRunning = false;
}
}
private async saveInterval(minutes: number): Promise<void> { private async saveInterval(minutes: number): Promise<void> {
if (this.context === undefined) { if (this.context === undefined) {
return; return;
@@ -1163,6 +1239,7 @@ struct SettingsPage {
this.runMirrorNow(); this.runMirrorNow();
}) })
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)') Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
.fontSize(13) .fontSize(13)
.fontColor($r('app.color.brand_text')) .fontColor($r('app.color.brand_text'))
@@ -1206,6 +1283,82 @@ struct SettingsPage {
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') }) .border({ width: 1, color: $r('app.color.shadow_color') })
// ⭐ WIPE_CARD_MOVED:数据清除独立成卡(不依赖镜像开关是否开启)
// 鸿蒙没有"应用即将被卸载"的回调,而系统日历属于沙箱外的系统数据,
// 卸载时不会自动清理 ⇒ 必须提供显式入口,让用户卸载前主动清干净。
Column({ space: 8 }) {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('清除系统日历数据')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('卸载本应用前请先清除,否则系统日历里会一直留着这些日程(还会提醒)')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Button(this.wipeAllRunning ? '正在清除…' : '彻底清除系统日历镜像数据')
.fontSize(14)
.fontColor($r('app.color.error'))
.backgroundColor(Color.Transparent)
.border({ width: 1, color: $r('app.color.error') })
.width('100%')
.enabled(!this.wipeAllRunning)
.onClick(() => {
this.confirmWipeAllMirrors();
})
Text('只删系统日历里的镜像本;CalDAV 账号与服务器上的日程一条都不会少。清除后镜像开关会自动关闭。')
.fontSize(11)
.fontColor($r('app.color.text_hint'))
.width('100%')
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
.backgroundColor(Color.Transparent)
.width('100%')
.enabled(!this.purgeRunning)
.onClick(() => {
this.runPurgeOrphans();
})
if (this.accountDump !== '') {
Column({ space: 4 }) {
Text('系统日历账户清单(MINE=我们建的,KEEP=本次保留;长按可复制):')
.fontSize(11)
.fontColor($r('app.color.text_secondary'))
.width('100%')
Scroll() {
Text(this.accountDump)
.fontSize(10)
.lineHeight(14)
.fontColor($r('app.color.text_primary'))
.copyOption(CopyOptions.LocalDevice)
.width('100%')
}
.height(150)
.width('100%')
.backgroundColor($r('app.color.page_bg'))
.borderRadius(8)
.padding(6)
}
.width('100%')
.padding({ top: 4 })
}
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
// 默认视图:打开 App 后默认展示的视图(月/周/列表) // 默认视图:打开 App 后默认展示的视图(月/周/列表)
Column({ space: 8 }) { Column({ space: 8 }) {
Row({ space: 10 }) { Row({ space: 10 }) {