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:
@@ -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 '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user