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:
@@ -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 {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user