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
+153
View File
@@ -49,6 +49,7 @@ struct SettingsPage {
@State mirrorKeys: string[] = []; // 要镜像的 DAV 日历本 **bookId**(稳定标识,不用会变的 calKey)
@State mirrorRunning: boolean = false; // 正在执行镜像
@State purgeRunning: boolean = false; // 正在清理残留镜像账户
@State wipeAllRunning: boolean = false; // 正在清除全部镜像(卸载前清干净)
@State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌)
@State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关)
@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> {
if (this.context === undefined) {
return;
@@ -1163,6 +1239,7 @@ struct SettingsPage {
this.runMirrorNow();
})
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
.fontSize(13)
.fontColor($r('app.color.brand_text'))
@@ -1206,6 +1283,82 @@ struct SettingsPage {
.backgroundColor($r('app.color.card_bg'))
.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 后默认展示的视图(月/周/列表)
Column({ space: 8 }) {
Row({ space: 10 }) {