From 199b6c177f8f3b24dec3e46b39180e3b600ec228 Mon Sep 17 00:00:00 2001 From: laoyang Date: Fri, 30 Oct 2026 13:34:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=85=E7=90=86):=20=E5=8D=B8=E8=BD=BD?= =?UTF-8?q?=E6=AE=8B=E7=95=99=E6=B2=BB=E7=90=86=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E6=98=BE=E5=BC=8F=E6=B8=85=E9=99=A4=E5=85=A5=E5=8F=A3=20+=20?= =?UTF-8?q?=E5=AD=A4=E5=84=BF=E5=85=9C=E5=BA=95=E5=8A=A0=E5=BC=BA=20#3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 鸿蒙没有「应用即将被卸载」的回调(网传的 preUninstallHook / uninstallDataPolicy 在本机 SDK 中均不存在),而系统日历数据属于 沙箱外,卸载时不会被系统清理 ⇒ 用户卸载后会永久残留镜像账户 (还会继续提醒)。本次提供显式入口 + 状态兜底。 1. AccountStore.isEmptyVaultConfirmed() 区分「账号库真的空」与「loadAll 吞异常返回空」。 依据:loadAll 内部把所有异常 catch 后返回 [],调用方无法从返回值 辨别,而孤儿清理一旦误判就会把用户真实在用的镜像全删掉。 2. SystemCalendarMirror.purgeOrphanAccountMirrors() 原来 accounts.length === 0 一律跳过(安全阀),导致卸载重装后 旧镜像永远清不掉。改为三态: ① 有账号 → 只清 accId 不匹配的(原逻辑) ② 空 + 确认空白 → 全清(卸载/重置后的残留) ③ 空 + 拿不准 → 仍跳过(读异常,不能误删) 全清模式下不再因「解析不出 accId」跳过脏账户,否则清不干净。 3. SettingsPage 新增独立卡片「清除系统日历数据」 - 「彻底清除系统日历镜像数据」:二次确认后 removeAll, 并关闭镜像开关(否则下一轮同步立刻重建,用户以为没清掉), 镜像本消失后把提醒交回 App(避免漏提醒) - 零结果提示区分「没权限」与「本来就没有」,用户要的是确认清干净 - 卡片放在镜像开关之外:关着镜像时也能清残留(正是典型场景) - 原「清理残留镜像账户(诊断)」一并从镜像卡片内移入本卡 只删系统日历镜像;CalDAV 账号与服务器日程完全不动。 --- entry/src/main/ets/common/AccountStore.ets | 41 +++++ .../main/ets/common/SystemCalendarMirror.ets | 29 ++-- entry/src/main/ets/pages/SettingsPage.ets | 153 ++++++++++++++++++ 3 files changed, 214 insertions(+), 9 deletions(-) diff --git a/entry/src/main/ets/common/AccountStore.ets b/entry/src/main/ets/common/AccountStore.ets index 308239b..24e85a0 100644 --- a/entry/src/main/ets/common/AccountStore.ets +++ b/entry/src/main/ets/common/AccountStore.ets @@ -699,4 +699,45 @@ export class AccountStore { return false; } } + + /** + * ⭐ 账号库是否**确认为空白**(不是"读取失败导致的空")。 + * + * 为什么需要它:`loadAll()` 内部把所有异常都 catch 掉并返回 `[]`, + * 于是调用方无法从返回值区分两种情况: + * ① 账号库真的空(全新安装 / 用户重置 / **卸载重装**); + * ② 密文存在但解不开,或读取过程抛错(临时故障)。 + * + * 系统日历镜像的孤儿清理必须只在 ① 时动手 —— ② 若照常清理, + * 会把"账号其实还在"的镜像账户全部误删(用户日程从系统日历里消失)。 + * + * 判定口径(三者同时成立才算"确认空白"): + * - 偏好里**没有**账号密文(`data_b64` 为空); + * - 旧版明文存储也没有账号; + * - 读取过程没有抛异常。 + * + * @returns true = 确认空白,可安全清理孤儿镜像;false = 拿不准,调用方必须保守跳过 + */ + static async isEmptyVaultConfirmed(context: common.Context): Promise { + 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> = JSON.parse(plain) as Array>; + 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; + } + } } diff --git a/entry/src/main/ets/common/SystemCalendarMirror.ets b/entry/src/main/ets/common/SystemCalendarMirror.ets index e676879..57d9502 100644 --- a/entry/src/main/ets/common/SystemCalendarMirror.ets +++ b/entry/src/main/ets/common/SystemCalendarMirror.ets @@ -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 = new Set(); 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 { diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index a0a5c4d..193a03e 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -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 { + 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 { 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 }) {