From 19cfe3cf345db3ae7bbc0d73808c8116333b754a Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 14 Oct 2026 14:00:58 +0800 Subject: [PATCH] =?UTF-8?q?0.0.8=20=E4=BF=AE=E5=A4=8D=EF=BC=9A=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=97=A5=E5=8E=86=E9=87=8D=E5=A4=8D=E6=8F=90=E9=86=92?= =?UTF-8?q?=20/=20=E5=B7=B2=E8=BF=87=E6=97=A5=E7=A8=8B=E4=BB=8D=E6=8F=90?= =?UTF-8?q?=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈:① 日程已开始/已过去,系统日历仍提醒;② 同一提前量(如提前 15 分钟) 提醒两次;③ 怀疑"打开 App 后又写一次系统日历"就多提醒一次。 诊断见《系统日历重复提醒-根因诊断.md》。用户怀疑正确,确为两个 bug。 修复一:消除双通道重复提醒(提醒两次) - 根因:镜像到系统日历后,**系统日历自己会提醒**(条目 reminderTime), 而 ReminderService 为**所有**本地日程排代理提醒、**完全不区分**该本是否已镜像 → 同一条日程、同一提前量被两个应用各弹一次。 - 改法:新增 `ReminderService.mirroredKeys()` —— 取"已镜像到系统日历"的日历本 calKey 集合(仅在「镜像开关已开 + 有系统日历权限 + 有选中本」时才非空), `refreshReminders` 与 `tickReminders` **两处都跳过**这些本,改由系统日历提醒。 - ⚠️ 保守设计:判定失败一律返回**空集**(不跳过任何本)→ 宁可多提醒,绝不漏提醒。 - ⚠️ 交互完整性:镜像开关变化时必须**立即失效缓存并重排提醒**(否则会漏提醒): · 开启镜像 → 交回系统日历负责;· 关闭镜像 → **立刻恢复 App 提醒**; · 勾选本变化、权限被系统回收 → 同样失效缓存并重排。 新增 `ReminderService.invalidateMirrorCache()`。 修复二:消除"已过去的日程仍提醒" - 根因:镜像窗口含**过去 12 个月**(windowStart 往前推 12 月),过去的日程也在系统日历里; 而 `updateEvent` 是**整条覆写**(带 reminderTime),系统在覆写时会**重新计算提醒时刻**, 时刻已过就**立刻补发**一条 → 表现为"过去了还提醒"。 - 改法:`toSystemEvent` 写入时只带**提醒时刻尚未到**的提醒(新增 `futureReminders()`)。 已过期提醒已无意义 → 不写,从根上消除覆写补发。 ⭐ 全天日程按系统口径以**当天 09:00** 为提醒基准(新增 `reminderBaseMs()`)。 - 刻意**不收缩镜像窗口**:保住历史日程在系统日历中的可见性,只去掉提醒。 修复三:让"改了提醒时间"能同步到系统日历(反向 bug) - 根因:`diffOf` 只比较 title/isAllDay/start/end/description/location, **不比较 reminderTime** → 用户改了提醒时间被判"无差异",永不触发 updateEvent → 系统日历侧提醒一直不更新。 - 改法:`diffOf` 纳入 `reminderTime` 比较,用规范化 key(排序去重)避免因顺序/重复误判, 且与修复二同口径(只比"会被写入的"提醒),不会导致每轮都重写。 验证: - 编译通过(BUILD SUCCESSFUL)。 - 离线用例全部通过: · 修复二:已过去/提醒刚过/昨天全天 → 不写提醒;未来日程/明天全天 → 保留; · 修复三:提醒改了/新增 → 触发覆写;相同集合(顺序不同、含重复)→ 不覆写。 - 手机未连接 → 未装机(待用户插线测试)。 --- entry/src/main/ets/common/ReminderService.ets | 58 ++++++++++++++++++ .../main/ets/common/SystemCalendarMirror.ets | 61 ++++++++++++++++++- entry/src/main/ets/pages/SettingsPage.ets | 11 ++++ 3 files changed, 128 insertions(+), 2 deletions(-) diff --git a/entry/src/main/ets/common/ReminderService.ets b/entry/src/main/ets/common/ReminderService.ets index 5248c51..5557c0d 100644 --- a/entry/src/main/ets/common/ReminderService.ets +++ b/entry/src/main/ets/common/ReminderService.ets @@ -12,6 +12,7 @@ import { BusinessError } from '@kit.BasicServicesKit'; import { EventDb, LocalEvent } from './EventDb'; import { AppSettings } from './AppSettings'; import { AccountStore } from './AccountStore'; +import { SystemCalendarMirror, BookRef } from './SystemCalendarMirror'; import { LogUtil } from './LogUtil'; export class ReminderService { @@ -26,6 +27,50 @@ export class ReminderService { private static calNameCache: Map = new Map(); private static calNameLoadedAt: number = 0; + /** 已镜像到系统日历的 calKey 集合缓存(避免重复排提醒),30 秒刷新一次 */ + private static mirroredCalKeys: Set = new Set(); + private static mirroredLoadedAt: number = 0; + + /** + * ⭐⭐ 取"已镜像到系统日历"的日历本 calKey 集合(0.0.8 修复双通道重复提醒)。 + * + * 背景:镜像到系统日历后,**系统日历自己会提醒**(条目的 reminderTime)。 + * 若 App 再为同一个本排代理提醒 → 同一条日程提醒两次。 + * 因此这里返回"该由系统日历负责提醒"的本,App 侧跳过它们。 + * + * ⚠️ 返回空集 = 不跳过任何本(保守:宁可多提醒,不可漏提醒)。 + * 仅在「镜像开关已开 + 有系统日历权限 + 有选中本」时才真正跳过。 + */ + private static async mirroredKeys(context: common.Context): Promise> { + if (Date.now() - ReminderService.mirroredLoadedAt < 30000 + && ReminderService.mirroredCalKeys.size > 0) { + return ReminderService.mirroredCalKeys; + } + const out: Set = new Set(); + try { + const on: boolean = await AppSettings.getMirrorEnabled(context); + if (on && await SystemCalendarMirror.hasPermission()) { + const refs: BookRef[] = await SystemCalendarMirror.selectedBookRefs(context); + for (const r of refs) { + if (r.calKey !== '') { + out.add(r.calKey); + } + } + } + } catch (err) { + // 判定失败 → 返回空集(不跳过,避免漏提醒) + } + ReminderService.mirroredCalKeys = out; + ReminderService.mirroredLoadedAt = Date.now(); + return out; + } + + /** 镜像开关变化后调用,让缓存立即失效(如用户关闭镜像 → 立刻恢复 App 提醒) */ + static invalidateMirrorCache(): void { + ReminderService.mirroredLoadedAt = 0; + ReminderService.mirroredCalKeys = new Set(); + } + /** 日历本名称(通知文案用):缓存 60 秒,'local' = 本机日历 */ private static async calName(context: common.Context, calKey: string): Promise { if (Date.now() - ReminderService.calNameLoadedAt > 60000 @@ -131,10 +176,18 @@ export class ReminderService { await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW); // 提醒静音的日历本(如别人分享的本):其中所有日程都不再提醒 const muted: string[] = await AppSettings.getMutedReminderKeys(context); + // ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 不再重复排(消除"提醒两次") + const mirrored: Set = await ReminderService.mirroredKeys(context); + if (mirrored.size > 0) { + LogUtil.write(`提醒跳过 ${mirrored.size} 个已镜像到系统日历的日历本(交由系统日历提醒)`); + } for (const e of rows) { if (muted.includes(e.calKey)) { continue; } + if (mirrored.has(e.calKey)) { + continue; // ⭐ 已镜像 → 系统日历会提醒,这里跳过避免重复 + } if (published >= ReminderService.MAX_REMINDERS) { break; } @@ -211,11 +264,16 @@ export class ReminderService { const rows: LocalEvent[] = await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW); const muted: string[] = await AppSettings.getMutedReminderKeys(context); + // ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 侧不再补发(消除"提醒两次") + const mirrored: Set = await ReminderService.mirroredKeys(context); let fired: number = 0; for (const e of rows) { if (muted.includes(e.calKey)) { continue; } + if (mirrored.has(e.calKey)) { + continue; // ⭐ 已镜像 → 交由系统日历提醒 + } const rems: number[] = e.reminders.length > 0 ? e.reminders : (e.reminder > 0 ? [e.reminder] : []); for (const rem of rems) { diff --git a/entry/src/main/ets/common/SystemCalendarMirror.ets b/entry/src/main/ets/common/SystemCalendarMirror.ets index 9da9816..2b50ff4 100644 --- a/entry/src/main/ets/common/SystemCalendarMirror.ets +++ b/entry/src/main/ets/common/SystemCalendarMirror.ets @@ -1268,14 +1268,62 @@ export class SystemCalendarMirror { ev.location = { location: e.location }; } const reminders: number[] = SystemCalendarMirror.remindersOf(e); - if (reminders.length > 0) { - ev.reminderTime = reminders; + // ⭐⭐ 只写"提醒时刻尚未到"的提醒(0.0.8 修复"已过日程仍提醒")。 + // 原因:写进系统日历的 reminderTime 一旦其时刻已过,系统会在**任何一次覆写**时 + // 立即补发一条通知 → 表现为"日程都过去了还提醒"。这类提醒已无意义,索性不写。 + const future: number[] = SystemCalendarMirror.futureReminders( + reminders, startTs, e.isAllDay, Date.now()); + if (future.length > 0) { + ev.reminderTime = future; } // ⚠️ 这里**故意不设 recurrenceRule**:重复日程由我们自己按窗口展开成多条实例, // 交给系统日历展开会因 RecurrenceRule 表达不全而出现错误的重复(09-19 事故)。 return ev; } + /** + * 提醒时刻的"基准时刻"(系统口径): + * - 普通日程:以**开始时刻**为基准(提前 N 分钟 = start - N); + * - 全天日程:系统文档规定以**当日 09:00**为基准(提前 N 分钟 = 当天 09:00 - N)。 + */ + private static reminderBaseMs(startTs: number, isAllDay: boolean): number { + if (isAllDay) { + const d: Date = new Date(startTs); + d.setHours(9, 0, 0, 0); + return d.getTime(); + } + return startTs; + } + + /** 过滤出"提醒时刻还没到"的提醒(用于避免写入已过期的提醒而触发补发) */ + private static futureReminders( + reminders: number[], + startTs: number, + isAllDay: boolean, + now: number + ): number[] { + const base: number = SystemCalendarMirror.reminderBaseMs(startTs, isAllDay); + const kept: number[] = []; + for (const r of reminders) { + if (r > 0 && base - r * 60000 > now) { + kept.push(r); + } + } + return kept; + } + + /** 提醒数组的规范化字符串(排序去重后拼接),用于比较 */ + private static reminderKey(reminders: number[]): string { + const s: number[] = reminders.slice().sort((a: number, b: number): number => a - b); + const out: string[] = []; + for (const n of s) { + if (out.indexOf(String(n)) < 0) { + out.push(String(n)); + } + } + return out.join(','); + } + /** 本地时区当天 0 点的时间戳 */ private static dayStart(ts: number): number { const d: Date = new Date(ts); @@ -1338,6 +1386,15 @@ export class SystemCalendarMirror { if ((cur.location?.location ?? '') !== (want.location?.location ?? '')) { return 'location'; } + // ⭐ 0.0.8:提醒也要比较 —— 否则用户改了提醒时间,系统日历侧**永远不会更新** + // (旧版 diffOf 不比 reminderTime,改动被判定为"无差异" → 不触发 updateEvent)。 + // ⚠️ 写入侧已过滤掉"已过期"的提醒(见 toSystemEvent / futureReminders), + // 所以这里的比较也用同一口径,避免"每轮都因提醒不同而重写"。 + const curRem: number[] = cur.reminderTime ?? []; + const wantRem: number[] = want.reminderTime ?? []; + if (SystemCalendarMirror.reminderKey(curRem) !== SystemCalendarMirror.reminderKey(wantRem)) { + return `reminder ${SystemCalendarMirror.reminderKey(curRem) || '(无)'} != ${SystemCalendarMirror.reminderKey(wantRem) || '(无)'}`; + } return ''; } diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index a0fe9a4..3ce37aa 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -110,6 +110,8 @@ struct SettingsPage { if (!ok && this.context !== undefined) { this.mirrorEnabled = false; AppSettings.setMirrorEnabled(this.context, false); + // 权限被回收 → 镜像失效 → 提醒应交回 App 负责 + ReminderService.invalidateMirrorCache(); } }); } @@ -518,13 +520,19 @@ struct SettingsPage { } this.mirrorEnabled = isOn; await AppSettings.setMirrorEnabled(this.context, isOn); + // ⭐ 镜像开关变化 → 立即失效提醒侧的"已镜像本"缓存,并重排提醒 + // (开启:这些本改由系统日历提醒,App 不再重复;关闭:必须立刻恢复 App 提醒,避免漏提醒) + ReminderService.invalidateMirrorCache(); if (isOn) { this.getUIContext().getPromptAction().showToast({ message: '已开启镜像:勾选要同步的日历本后点「立即执行镜像」' }); + await ReminderService.refreshReminders(this.context as common.UIAbilityContext); return; } const n: number = await SystemCalendarMirror.removeAll(this.context); + // 关闭镜像后,这些本的提醒要交回 App 负责 + await ReminderService.refreshReminders(this.context as common.UIAbilityContext); this.getUIContext().getPromptAction().showToast({ message: n > 0 ? `已关闭镜像,并清除系统日历中的 ${n} 个镜像账户` : '已关闭镜像' }); @@ -545,6 +553,9 @@ struct SettingsPage { return; } await AppSettings.setMirrorBookIds(this.context, this.mirrorKeys); + // ⭐ 勾选本变化 → 提醒侧"已镜像本"集合变化:失效缓存并重排提醒 + ReminderService.invalidateMirrorCache(); + await ReminderService.refreshReminders(this.context as common.UIAbilityContext); } /**