0.0.8 修复:系统日历重复提醒 / 已过日程仍提醒

用户反馈:① 日程已开始/已过去,系统日历仍提醒;② 同一提前量(如提前 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)。
- 离线用例全部通过:
  · 修复二:已过去/提醒刚过/昨天全天 → 不写提醒;未来日程/明天全天 → 保留;
  · 修复三:提醒改了/新增 → 触发覆写;相同集合(顺序不同、含重复)→ 不覆写。
- 手机未连接 → 未装机(待用户插线测试)。
This commit is contained in:
2026-10-14 14:00:58 +08:00
parent a49e7d03b1
commit 19cfe3cf34
3 changed files with 128 additions and 2 deletions
@@ -12,6 +12,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
import { EventDb, LocalEvent } from './EventDb'; import { EventDb, LocalEvent } from './EventDb';
import { AppSettings } from './AppSettings'; import { AppSettings } from './AppSettings';
import { AccountStore } from './AccountStore'; import { AccountStore } from './AccountStore';
import { SystemCalendarMirror, BookRef } from './SystemCalendarMirror';
import { LogUtil } from './LogUtil'; import { LogUtil } from './LogUtil';
export class ReminderService { export class ReminderService {
@@ -26,6 +27,50 @@ export class ReminderService {
private static calNameCache: Map<string, string> = new Map(); private static calNameCache: Map<string, string> = new Map();
private static calNameLoadedAt: number = 0; private static calNameLoadedAt: number = 0;
/** 已镜像到系统日历的 calKey 集合缓存(避免重复排提醒),30 秒刷新一次 */
private static mirroredCalKeys: Set<string> = new Set();
private static mirroredLoadedAt: number = 0;
/**
* ⭐⭐ 取"已镜像到系统日历"的日历本 calKey 集合(0.0.8 修复双通道重复提醒)。
*
* 背景:镜像到系统日历后,**系统日历自己会提醒**(条目的 reminderTime)。
* 若 App 再为同一个本排代理提醒 → 同一条日程提醒两次。
* 因此这里返回"该由系统日历负责提醒"的本,App 侧跳过它们。
*
* ⚠️ 返回空集 = 不跳过任何本(保守:宁可多提醒,不可漏提醒)。
* 仅在「镜像开关已开 + 有系统日历权限 + 有选中本」时才真正跳过。
*/
private static async mirroredKeys(context: common.Context): Promise<Set<string>> {
if (Date.now() - ReminderService.mirroredLoadedAt < 30000
&& ReminderService.mirroredCalKeys.size > 0) {
return ReminderService.mirroredCalKeys;
}
const out: Set<string> = new Set<string>();
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' = 本机日历 */ /** 日历本名称(通知文案用):缓存 60 秒,'local' = 本机日历 */
private static async calName(context: common.Context, calKey: string): Promise<string> { private static async calName(context: common.Context, calKey: string): Promise<string> {
if (Date.now() - ReminderService.calNameLoadedAt > 60000 if (Date.now() - ReminderService.calNameLoadedAt > 60000
@@ -131,10 +176,18 @@ export class ReminderService {
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW); await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
// 提醒静音的日历本(如别人分享的本):其中所有日程都不再提醒 // 提醒静音的日历本(如别人分享的本):其中所有日程都不再提醒
const muted: string[] = await AppSettings.getMutedReminderKeys(context); const muted: string[] = await AppSettings.getMutedReminderKeys(context);
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 不再重复排(消除"提醒两次")
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
if (mirrored.size > 0) {
LogUtil.write(`提醒跳过 ${mirrored.size} 个已镜像到系统日历的日历本(交由系统日历提醒)`);
}
for (const e of rows) { for (const e of rows) {
if (muted.includes(e.calKey)) { if (muted.includes(e.calKey)) {
continue; continue;
} }
if (mirrored.has(e.calKey)) {
continue; // ⭐ 已镜像 → 系统日历会提醒,这里跳过避免重复
}
if (published >= ReminderService.MAX_REMINDERS) { if (published >= ReminderService.MAX_REMINDERS) {
break; break;
} }
@@ -211,11 +264,16 @@ export class ReminderService {
const rows: LocalEvent[] = const rows: LocalEvent[] =
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW); await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
const muted: string[] = await AppSettings.getMutedReminderKeys(context); const muted: string[] = await AppSettings.getMutedReminderKeys(context);
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 侧不再补发(消除"提醒两次")
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
let fired: number = 0; let fired: number = 0;
for (const e of rows) { for (const e of rows) {
if (muted.includes(e.calKey)) { if (muted.includes(e.calKey)) {
continue; continue;
} }
if (mirrored.has(e.calKey)) {
continue; // ⭐ 已镜像 → 交由系统日历提醒
}
const rems: number[] = e.reminders.length > 0 ? e.reminders const rems: number[] = e.reminders.length > 0 ? e.reminders
: (e.reminder > 0 ? [e.reminder] : []); : (e.reminder > 0 ? [e.reminder] : []);
for (const rem of rems) { for (const rem of rems) {
@@ -1268,14 +1268,62 @@ export class SystemCalendarMirror {
ev.location = { location: e.location }; ev.location = { location: e.location };
} }
const reminders: number[] = SystemCalendarMirror.remindersOf(e); const reminders: number[] = SystemCalendarMirror.remindersOf(e);
if (reminders.length > 0) { // ⭐⭐ 只写"提醒时刻尚未到"的提醒(0.0.8 修复"已过日程仍提醒")。
ev.reminderTime = reminders; // 原因:写进系统日历的 reminderTime 一旦其时刻已过,系统会在**任何一次覆写**时
// 立即补发一条通知 → 表现为"日程都过去了还提醒"。这类提醒已无意义,索性不写。
const future: number[] = SystemCalendarMirror.futureReminders(
reminders, startTs, e.isAllDay, Date.now());
if (future.length > 0) {
ev.reminderTime = future;
} }
// ⚠️ 这里**故意不设 recurrenceRule**:重复日程由我们自己按窗口展开成多条实例, // ⚠️ 这里**故意不设 recurrenceRule**:重复日程由我们自己按窗口展开成多条实例,
// 交给系统日历展开会因 RecurrenceRule 表达不全而出现错误的重复(09-19 事故)。 // 交给系统日历展开会因 RecurrenceRule 表达不全而出现错误的重复(09-19 事故)。
return ev; 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 点的时间戳 */ /** 本地时区当天 0 点的时间戳 */
private static dayStart(ts: number): number { private static dayStart(ts: number): number {
const d: Date = new Date(ts); const d: Date = new Date(ts);
@@ -1338,6 +1386,15 @@ export class SystemCalendarMirror {
if ((cur.location?.location ?? '') !== (want.location?.location ?? '')) { if ((cur.location?.location ?? '') !== (want.location?.location ?? '')) {
return '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 ''; return '';
} }
+11
View File
@@ -110,6 +110,8 @@ struct SettingsPage {
if (!ok && this.context !== undefined) { if (!ok && this.context !== undefined) {
this.mirrorEnabled = false; this.mirrorEnabled = false;
AppSettings.setMirrorEnabled(this.context, false); AppSettings.setMirrorEnabled(this.context, false);
// 权限被回收 → 镜像失效 → 提醒应交回 App 负责
ReminderService.invalidateMirrorCache();
} }
}); });
} }
@@ -518,13 +520,19 @@ struct SettingsPage {
} }
this.mirrorEnabled = isOn; this.mirrorEnabled = isOn;
await AppSettings.setMirrorEnabled(this.context, isOn); await AppSettings.setMirrorEnabled(this.context, isOn);
// ⭐ 镜像开关变化 → 立即失效提醒侧的"已镜像本"缓存,并重排提醒
// (开启:这些本改由系统日历提醒,App 不再重复;关闭:必须立刻恢复 App 提醒,避免漏提醒)
ReminderService.invalidateMirrorCache();
if (isOn) { if (isOn) {
this.getUIContext().getPromptAction().showToast({ this.getUIContext().getPromptAction().showToast({
message: '已开启镜像:勾选要同步的日历本后点「立即执行镜像」' message: '已开启镜像:勾选要同步的日历本后点「立即执行镜像」'
}); });
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
return; return;
} }
const n: number = await SystemCalendarMirror.removeAll(this.context); const n: number = await SystemCalendarMirror.removeAll(this.context);
// 关闭镜像后,这些本的提醒要交回 App 负责
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
this.getUIContext().getPromptAction().showToast({ this.getUIContext().getPromptAction().showToast({
message: n > 0 ? `已关闭镜像,并清除系统日历中的 ${n} 个镜像账户` : '已关闭镜像' message: n > 0 ? `已关闭镜像,并清除系统日历中的 ${n} 个镜像账户` : '已关闭镜像'
}); });
@@ -545,6 +553,9 @@ struct SettingsPage {
return; return;
} }
await AppSettings.setMirrorBookIds(this.context, this.mirrorKeys); await AppSettings.setMirrorBookIds(this.context, this.mirrorKeys);
// ⭐ 勾选本变化 → 提醒侧"已镜像本"集合变化:失效缓存并重排提醒
ReminderService.invalidateMirrorCache();
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
} }
/** /**