From 6899a5074b7116d2d05581338fc990b9a90b3ef9 Mon Sep 17 00:00:00 2001 From: Yang Yongquan Date: Sun, 13 Sep 2026 23:35:52 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E7=9A=84=E5=8A=9F=E8=83=BD=EF=BC=8C=E8=83=BD?= =?UTF-8?q?=E5=A4=9F=E6=AD=A3=E5=B8=B8=E9=80=9A=E7=9F=A5=E4=BA=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- entry/src/main/ets/common/AppSettings.ets | 24 ++ entry/src/main/ets/common/EventDb.ets | 43 ++- entry/src/main/ets/common/IcsUtil.ets | 26 +- entry/src/main/ets/common/LogUtil.ets | 96 ++++- entry/src/main/ets/common/ReminderService.ets | 345 ++++++++++++++++-- entry/src/main/ets/common/SyncEngine.ets | 3 +- .../main/ets/entryability/EntryAbility.ets | 4 + entry/src/main/ets/pages/EventEditPage.ets | 72 +++- entry/src/main/ets/pages/Index.ets | 6 + entry/src/main/ets/pages/SettingsPage.ets | 195 ++++++++++ 10 files changed, 752 insertions(+), 62 deletions(-) diff --git a/entry/src/main/ets/common/AppSettings.ets b/entry/src/main/ets/common/AppSettings.ets index f054da4..ea33ac4 100644 --- a/entry/src/main/ets/common/AppSettings.ets +++ b/entry/src/main/ets/common/AppSettings.ets @@ -14,6 +14,7 @@ export class AppSettings { private static readonly KEY_BACKUP_KEY: string = 'sys_backup_cal_key'; // 备份目标 DAV 日历本 private static readonly KEY_MANUAL_READONLY: string = 'manual_readonly_keys'; // 手动标记只读的 calKey private static readonly KEY_MUTED_REMINDER: string = 'muted_reminder_keys'; // 提醒静音的 calKey + private static readonly KEY_REMINDER_TICK: string = 'reminder_tick'; // 应用内提醒上次检查时间戳 private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成 /** @@ -114,6 +115,29 @@ export class AppSettings { } } + /** 应用内提醒模式的上次检查时间戳(毫秒,0 = 未初始化) */ + static async getReminderTick(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + return await store.get(AppSettings.KEY_REMINDER_TICK, 0) as number; + } catch (err) { + return 0; + } + } + + static async setReminderTick(context: common.Context, tick: number): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_REMINDER_TICK, tick); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存提醒检查时间戳失败: ${e.message}`); + } + } + /** 系统日历模式:display = 仅混合显示;backup = 本地系统日程备份到选定 CalDAV 日历本 */ static async getSysCalMode(context: common.Context): Promise { try { diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index 1ffb904..0ea55fb 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -25,7 +25,8 @@ export class LocalEvent { completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED) rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生 exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔 - reminder: number = 0; // 提醒提前分钟数(来自 VALARM,0 = 不提醒) + reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒 + reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序 } /** 远端事件条目(REPORT 解析结果) */ @@ -43,7 +44,8 @@ export class RemoteEvent { completed: boolean = false; // VTODO STATUS:COMPLETED rrule: string = ''; // 原始 RRULE exdate: string = ''; // 原始 EXDATE(分号分隔) - reminder: number = 0; // 提醒提前分钟数(VALARM) + reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段) + reminders: number[] = []; // 全部提醒提前分钟数(多选) } export class EventDb { @@ -97,10 +99,31 @@ export class EventDb { } catch (err) { // 列已存在 } + try { + await store.executeSql("ALTER TABLE events ADD COLUMN reminders TEXT DEFAULT ''"); + } catch (err) { + // 列已存在 + } EventDb.db = store; return store; } + /** 提醒数组规范化:去掉 0/非法值并去重升序 */ + static normalizeReminders(list: number[]): number[] { + const set = new Set(); + for (const n of list) { + if (n > 0) { + set.add(n); + } + } + return Array.from(set).sort((a: number, b: number): number => a - b); + } + + /** 提醒数组比较键(规范化后逗号连接,用于增量比对) */ + static remKey(list: number[]): string { + return EventDb.normalizeReminders(list).join(','); + } + private static fromRow(rs: relationalStore.ResultSet): LocalEvent { const e = new LocalEvent(); e.id = rs.getLong(rs.getColumnIndex('id')); @@ -124,6 +147,14 @@ export class EventDb { e.rrule = rs.getString(rs.getColumnIndex('rrule')); e.exdate = rs.getString(rs.getColumnIndex('exdate')); e.reminder = rs.getLong(rs.getColumnIndex('reminder')); + // 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒 + const remStr: string = rs.getString(rs.getColumnIndex('reminders')); + if (remStr !== '') { + e.reminders = remStr.split(',').map((s: string): number => Number(s)) + .filter((n: number): boolean => !Number.isNaN(n) && n > 0); + } else if (e.reminder > 0) { + e.reminders = [e.reminder]; + } return e; } @@ -147,7 +178,8 @@ export class EventDb { 'completed': e.completed ? 1 : 0, 'rrule': e.rrule, 'exdate': e.exdate, - 'reminder': e.reminder + 'reminder': e.reminders.length > 0 ? e.reminders[0] : 0, + 'reminders': e.reminders.map((n: number): string => String(n)).join(',') }; return bucket; } @@ -365,10 +397,12 @@ export class EventDb { e.rrule = r.rrule; e.exdate = r.exdate; e.reminder = r.reminder; + e.reminders = r.reminders; await store.insert('events', EventDb.toBucket(e)); added++; } else if (found.etag !== r.etag || found.rrule !== r.rrule || found.exdate !== r.exdate - || found.reminder !== r.reminder || found.recurring !== r.recurring) { + || found.reminder !== r.reminder || found.recurring !== r.recurring + || EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)) { // 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填) found.title = r.title; found.description = r.description; @@ -385,6 +419,7 @@ export class EventDb { found.rrule = r.rrule; found.exdate = r.exdate; found.reminder = r.reminder; + found.reminders = r.reminders; const up = new relationalStore.RdbPredicates('events'); up.equalTo('id', found.id); await store.update(EventDb.toBucket(found), up); diff --git a/entry/src/main/ets/common/IcsUtil.ets b/entry/src/main/ets/common/IcsUtil.ets index 3a8b09b..f563a59 100644 --- a/entry/src/main/ets/common/IcsUtil.ets +++ b/entry/src/main/ets/common/IcsUtil.ets @@ -16,7 +16,8 @@ class ParsedEvent { inAlarm: boolean = false; // 是否处于 VALARM 子组件内(内部属性不参与解析) rrule: string = ''; // 原始 RRULE 值 exdates: string[] = []; // 原始 EXDATE 值列表 - reminder: number = 0; // 提醒提前分钟数(来自 VALARM TRIGGER,0 = 不提醒) + reminder: number = 0; // 主提醒提前分钟数(第一个 VALARM,0 = 不提醒) + reminders: number[] = []; // 全部提醒提前分钟数(多 VALARM) } /** iCal 时间解析结果 */ @@ -71,7 +72,13 @@ export class IcsUtil { const av: string = line.substring(ac + 1).trim(); const an: string = ap.split(';')[0].toUpperCase(); if (an === 'TRIGGER') { - current.reminder = IcsUtil.triggerMinutes(av, ap); + const m: number = IcsUtil.triggerMinutes(av, ap); + if (m > 0) { + current.reminders.push(m); + if (current.reminder === 0) { + current.reminder = m; // 第一个提醒作为主提醒(兼容旧字段) + } + } } } continue; @@ -134,6 +141,7 @@ export class IcsUtil { r.rrule = p.rrule; r.exdate = p.exdates.join(';'); r.reminder = p.reminder; + r.reminders = p.reminders; return r; }); } @@ -244,11 +252,23 @@ export class IcsUtil { `SUMMARY:${IcsUtil.escape(e.title)}\r\n` + (e.location !== '' ? `LOCATION:${IcsUtil.escape(e.location)}\r\n` : '') + (e.description !== '' ? `DESCRIPTION:${IcsUtil.escape(e.description)}\r\n` : '') + - (e.reminder > 0 ? IcsUtil.valarm(e.reminder) : '') + + IcsUtil.valarms(e.reminders.length > 0 ? e.reminders + : (e.reminder > 0 ? [e.reminder] : [])) + 'END:VEVENT\r\n' + 'END:VCALENDAR\r\n'; } + /** 由提醒分钟数组生成多个 VALARM(已规范化去重) */ + private static valarms(minutes: number[]): string { + let out: string = ''; + for (const m of minutes) { + if (m > 0) { + out += IcsUtil.valarm(m); + } + } + return out; + } + /** 由提前分钟数生成 VALARM(TRIGGER 负时长 = 提前触发) */ private static valarm(minutes: number): string { let trigger: string; diff --git a/entry/src/main/ets/common/LogUtil.ets b/entry/src/main/ets/common/LogUtil.ets index 5435a4b..474fc64 100644 --- a/entry/src/main/ets/common/LogUtil.ets +++ b/entry/src/main/ets/common/LogUtil.ets @@ -1,25 +1,107 @@ // entry/src/main/ets/common/LogUtil.ets -// 日志功能已按需求移除:保留空实现以兼容既有调用点(不写文件、不再缓冲) +// 同步日志:写入应用 filesDir/sync.log(含时间戳),超 512KB 自动截断保留后半。 +// 设置页「同步日志」卡片可查看/复制/清空,方便真机测试时诊断同步与提醒问题。 import { common } from '@kit.AbilityKit'; +import { fileIo as fs } from '@kit.CoreFileKit'; +import { util } from '@kit.ArkTS'; +import { BusinessError } from '@kit.BasicServicesKit'; export class LogUtil { - static logFilePath(): string { - return ''; - } + private static path: string = ''; + private static readonly MAX_BYTES: number = 512 * 1024; - /** 空实现(日志功能已移除) */ static init(context: common.Context): void { + try { + LogUtil.path = `${context.filesDir}/sync.log`; + } catch (err) { + LogUtil.path = ''; + } } - /** 仅输出到 console(hilog),不再写文件 */ + static logFilePath(): string { + return LogUtil.path; + } + + /** 写一行日志:console + 文件(文件失败静默,不影响业务) */ static write(msg: string): void { console.info(`SyncLog ${msg}`); + if (LogUtil.path === '') { + return; + } + try { + const d = new Date(); + const p = (n: number): string => n < 10 ? '0' + n : String(n); + const ts: string = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}` + + ` ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; + // 超限截断:保留最后 256KB + try { + const st: fs.Stat = fs.statSync(LogUtil.path); + if (st.size > LogUtil.MAX_BYTES) { + LogUtil.truncate(); + } + } catch (err) { + // 文件不存在属正常,直接创建写入 + } + const file: fs.File = fs.openSync(LogUtil.path, + fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.APPEND); + fs.writeSync(file.fd, `[${ts}] ${msg}\n`); + fs.closeSync(file.fd); + } catch (err) { + // 写日志失败不影响业务 + } + } + + /** 截断:只保留文件末尾 256KB */ + private static truncate(): void { + try { + const keep: number = 256 * 1024; + const st: fs.Stat = fs.statSync(LogUtil.path); + const size: number = Math.min(st.size, keep); + const file = fs.openSync(LogUtil.path, fs.OpenMode.READ_ONLY); + const buf: ArrayBuffer = new ArrayBuffer(size); + fs.readSync(file.fd, buf, { offset: st.size - size }); + fs.closeSync(file.fd); + const out = fs.openSync(LogUtil.path, + fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC); + fs.writeSync(out.fd, `…(日志过长,已截断保留末尾)\n`); + fs.writeSync(out.fd, new Uint8Array(buf)); + fs.closeSync(out.fd); + } catch (err) { + // 截断失败忽略,下次再试 + } } static readAll(context: common.Context): string { - return '(日志功能已移除)'; + try { + if (LogUtil.path === '') { + LogUtil.init(context); + } + if (LogUtil.path === '') { + return '(无法确定日志路径)'; + } + const st: fs.Stat = fs.statSync(LogUtil.path); + if (st.size === 0) { + return '(日志为空)'; + } + const file = fs.openSync(LogUtil.path, fs.OpenMode.READ_ONLY); + const buf: ArrayBuffer = new ArrayBuffer(st.size); + fs.readSync(file.fd, buf); + fs.closeSync(file.fd); + const decoder: util.TextDecoder = util.TextDecoder.create('utf-8'); + return decoder.decodeToString(new Uint8Array(buf)); + } catch (err) { + return `(读取日志失败: ${(err as BusinessError).message})`; + } } static clear(context: common.Context): void { + try { + if (LogUtil.path === '') { + LogUtil.init(context); + } + fs.unlinkSync(LogUtil.path); + } catch (err) { + // 文件不存在等,忽略 + } } } diff --git a/entry/src/main/ets/common/ReminderService.ets b/entry/src/main/ets/common/ReminderService.ets index 1126ca7..5248c51 100644 --- a/entry/src/main/ets/common/ReminderService.ets +++ b/entry/src/main/ets/common/ReminderService.ets @@ -1,27 +1,136 @@ // entry/src/main/ets/common/ReminderService.ets -// 日程提醒:基于后台代理提醒(reminderAgentManager), -// 在日程开始前 N 分钟(来自服务器 VALARM TRIGGER)发系统通知,可右滑删除 +// 日程提醒,双模式: +// 1) 系统代理提醒(reminderAgentManager):需要华为审批的"代理提醒使用权限", +// 未申请的普通应用配额为 0(发布报 1700002 且池内为 0) +// 2) 应用内通知(notificationManager):配额为 0 时的降级方案。 +// 依赖应用进程存活(前台或"后台持续同步"长时任务),由自动同步定时器驱动, +// 每次检查把"提醒时刻刚好已到"的日程用本地通知发出。 import { reminderAgentManager } from '@kit.BackgroundTasksKit'; +import { notificationManager } from '@kit.NotificationKit'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { EventDb, LocalEvent } from './EventDb'; import { AppSettings } from './AppSettings'; +import { AccountStore } from './AccountStore'; import { LogUtil } from './LogUtil'; export class ReminderService { - private static readonly MAX_REMINDERS: number = 30; // 系统对单应用代理提醒数量有限制 + // 系统对单应用代理提醒上限 30 条,留 5 个余量给测试提醒等临时请求 + private static readonly MAX_REMINDERS: number = 25; private static readonly WINDOW: number = 7 * 86400000; // 只排未来 7 天 - /** 全量重建提醒:先取消本应用全部代理提醒,再按当前数据重新发布 */ - static async refreshReminders(context: common.Context): Promise { + /** 代理提醒可用性:null=未探测(每次进程启动重新探测),false=配额为0(走应用内通知),true=可用 */ + static agentAvailable: boolean | null = null; + + /** calKey → 日历本名称缓存(通知文案用),60 秒刷新一次 */ + private static calNameCache: Map = new Map(); + private static calNameLoadedAt: number = 0; + + /** 日历本名称(通知文案用):缓存 60 秒,'local' = 本机日历 */ + private static async calName(context: common.Context, calKey: string): Promise { + if (Date.now() - ReminderService.calNameLoadedAt > 60000 + || ReminderService.calNameCache.size === 0) { + const map: Map = new Map(); + map.set('local', '本机日历'); + try { + const accounts = await AccountStore.loadAll(context); + for (const acc of accounts) { + for (let i: number = 0; i < acc.calendarHrefs.length; i++) { + const bookName: string = i < acc.calendarNames.length + ? acc.calendarNames[i] : `日历本${i + 1}`; + map.set(`${acc.id}_${i}`, `${acc.name}·${bookName}`); + } + } + } catch (err) { + // 读取失败用旧缓存 + } + ReminderService.calNameCache = map; + ReminderService.calNameLoadedAt = Date.now(); + } + const name: string | undefined = ReminderService.calNameCache.get(calKey); + return name !== undefined ? name : '日历'; + } + + private static delay(ms: number): Promise { + return new Promise((resolve: () => void) => { + setTimeout(resolve, ms); + }); + } + + /** 当前系统里本应用的有效提醒条数 */ + private static async poolCount(): Promise { + try { + const all = await reminderAgentManager.getValidReminders(); + return all.length; + } catch (err) { + return -1; + } + } + + /** + * 发布单条代理提醒,1700002(池满/配额0)时清空后等待释放再重试,最多 3 轮。 + * 3 轮后仍满且池内为 0 → 判定配额为 0(需华为邮件申请权限),抛出标记错误。 + */ + private static async publishWithRetry(request: reminderAgentManager.ReminderRequestCalendar): Promise { + try { + await reminderAgentManager.publishReminder(request); + ReminderService.agentAvailable = true; + return; + } catch (err) { + const e = err as BusinessError; + if (e.code !== 1700002) { + // ArkTS 只允许抛 Error:错误码编入消息,调用方从 message 解析 + throw new Error(`${e.code}:${e.message}`); + } + } + for (let attempt: number = 1; attempt <= 3; attempt++) { + // 已触发的代理提醒会残留为通知,一并清理 + try { + await notificationManager.cancelAll(); + } catch (err) { + // 忽略 + } + await reminderAgentManager.cancelAllReminders(); + await ReminderService.delay(1500); + const remain: number = await ReminderService.poolCount(); + LogUtil.write(`提醒池满(第 ${attempt} 次腾位):清空后剩余 ${remain} 条,重试发布`); + try { + await reminderAgentManager.publishReminder(request); + ReminderService.agentAvailable = true; + return; + } catch (err) { + const e = err as BusinessError; + if (e.code !== 1700002) { + throw new Error(`${e.code}:${e.message}`); + } + } + } + throw new Error('1700002:提醒池腾位 3 轮后仍满'); + } + + /** 判定"配额为 0":1700002 且系统内有效提醒为 0(没有超额却报超额 = 没有配额) */ + private static async isQuotaZero(): Promise { + const remain: number = await ReminderService.poolCount(); + return remain <= 0; + } + + /** 全量重建代理提醒(仅在代理提醒可用时调用)。返回发布条数 */ + static async refreshReminders(context: common.Context): Promise { + let published: number = 0; + if (ReminderService.agentAvailable === false) { + return 0; // 应用内通知模式接管,不再请求代理提醒 + } try { await reminderAgentManager.cancelAllReminders(); + const before: number = await ReminderService.poolCount(); + if (before > 0) { + LogUtil.write(`取消全部提醒后系统仍剩 ${before} 条`); + } const now: number = Date.now(); const rows: LocalEvent[] = await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW); // 提醒静音的日历本(如别人分享的本):其中所有日程都不再提醒 const muted: string[] = await AppSettings.getMutedReminderKeys(context); - let published: number = 0; for (const e of rows) { if (muted.includes(e.calKey)) { continue; @@ -29,37 +138,207 @@ export class ReminderService { if (published >= ReminderService.MAX_REMINDERS) { break; } - const remindAt: number = e.startTime - e.reminder * 60000; - if (remindAt <= now) { - continue; // 提醒时间已过 + // 多提醒:每个提前量各发一条(含旧单提醒数据回退) + const rems: number[] = e.reminders.length > 0 ? e.reminders + : (e.reminder > 0 ? [e.reminder] : []); + for (const rem of rems) { + if (published >= ReminderService.MAX_REMINDERS) { + break; + } + const remindAt: number = e.startTime - rem * 60000; + if (remindAt <= now) { + continue; // 提醒时间已过 + } + const d = new Date(remindAt); + const calName: string = await ReminderService.calName(context, e.calKey); + const request: reminderAgentManager.ReminderRequestCalendar = { + reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR, + dateTime: { + year: d.getFullYear(), + month: d.getMonth() + 1, + day: d.getDate(), + hour: d.getHours(), + minute: d.getMinutes(), + second: 0 + }, + title: '日程提醒', + content: `「${calName}」中的「${e.title}」将在 ${rem} 分钟后开始`, + notificationId: (e.id * 10 + rem) % 100000, + expiredContent: '该日程已开始', + ringDuration: 5, + snoozeTimes: 0, + timeInterval: 0 + }; + await ReminderService.publishWithRetry(request); + published++; } - const d = new Date(remindAt); - const startTimeText: string = ReminderService.fmt(e.startTime); - const request: reminderAgentManager.ReminderRequestCalendar = { - reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR, - dateTime: { - year: d.getFullYear(), - month: d.getMonth() + 1, - day: d.getDate(), - hour: d.getHours(), - minute: d.getMinutes(), - second: 0 - }, - title: '日程提醒', - content: `「${e.title}」将于 ${startTimeText} 开始(提前 ${e.reminder} 分钟)`, - notificationId: e.id % 100000, - expiredContent: '该日程已开始', - ringDuration: 5, - snoozeTimes: 0, - timeInterval: 0 - }; - await reminderAgentManager.publishReminder(request); - published++; } - LogUtil.write(`提醒刷新完成:发布 ${published} 个提醒(窗口 7 天,静音日历本 ${muted.length} 个)`); + const after: number = await ReminderService.poolCount(); + LogUtil.write(`提醒刷新完成:发布 ${published} 个提醒(窗口 7 天,静音日历本 ${muted.length} 个,池内现有 ${after} 条)`); + return published; + } catch (err) { + const e = err as Error; + if (e.message.startsWith('1700002') && await ReminderService.isQuotaZero()) { + // 配额为 0:普通应用未获代理提醒权限,降级为应用内通知模式 + ReminderService.agentAvailable = false; + await AppSettings.setReminderTick(context, Date.now()); + LogUtil.write('代理提醒配额为 0(需向华为邮件申请权限),已自动切换为应用内通知模式'); + return 0; + } + LogUtil.write(`提醒刷新失败: ${e.message}`); + return published; + } + } + + /** + * 应用内提醒检查(降级模式):由自动同步定时器高频调用。 + * 把"提醒时刻落在上次检查~现在之间"的日程用本地通知发出。 + * App 进程被杀期间的提醒会错过(超过 2 分钟不补发),长时任务可避免。 + */ + static async tickReminders(context: common.Context): Promise { + if (ReminderService.agentAvailable !== false) { + return 0; // 代理提醒可用或未判定,不需要降级 + } + try { + const now: number = Date.now(); + const last: number = await AppSettings.getReminderTick(context); + if (last <= 0) { + await AppSettings.setReminderTick(context, now); // 首次运行:只记基线不补发 + return 0; + } + const from: number = Math.max(last, now - 2 * 60000); // 最多补发 2 分钟内的错过提醒 + await AppSettings.setReminderTick(context, now); + const rows: LocalEvent[] = + await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW); + const muted: string[] = await AppSettings.getMutedReminderKeys(context); + let fired: number = 0; + for (const e of rows) { + if (muted.includes(e.calKey)) { + continue; + } + const rems: number[] = e.reminders.length > 0 ? e.reminders + : (e.reminder > 0 ? [e.reminder] : []); + for (const rem of rems) { + const remindAt: number = e.startTime - rem * 60000; + if (remindAt <= from || remindAt > now) { + continue; // 不在本次检查窗口内 + } + const calName: string = await ReminderService.calName(context, e.calKey); + const minLeft: number = Math.max(1, + Math.ceil((e.startTime - Date.now()) / 60000)); + const fired2: number = await ReminderService.notifyNow( + '日程提醒', `「${calName}」中的「${e.title}」将在 ${minLeft} 分钟后开始`, + (e.id * 10 + rem) % 100000); + if (fired2 === 0) { + fired++; + } + } + } + if (fired > 0) { + LogUtil.write(`应用内提醒:本次发出 ${fired} 条到点提醒`); + } + return fired; + } catch (err) { + const e = err as Error; + LogUtil.write(`应用内提醒检查失败: ${e.message}`); + return 0; + } + } + + /** 日历本通知渠道(SOCIAL_COMMUNICATION 默认横幅+锁屏+响铃振动),只注册一次 */ + private static slotReady: boolean = false; + + private static async ensureSlot(): Promise { + if (ReminderService.slotReady) { + return; + } + try { + // 本 SDK 无锁屏可见性 API(已废弃),锁屏显示由系统"锁屏通知"设置 + 渠道默认行为决定 + await notificationManager.addSlot(notificationManager.SlotType.SOCIAL_COMMUNICATION); + } catch (err) { + // 渠道已存在等情况忽略 + } + ReminderService.slotReady = true; + } + + /** 立即发一条本地通知。返回 0 表示成功 */ + private static async notifyNow(title: string, text: string, id: number): Promise { + await ReminderService.ensureSlot(); + try { + const request: notificationManager.NotificationRequest = { + id: id, + notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION, + content: { + notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT, + normal: { + title: title, + text: text, + additionalText: '' + } + } + }; + await notificationManager.publish(request); + return 0; } catch (err) { const e = err as BusinessError; - LogUtil.write(`提醒刷新失败: ${e.code} - ${e.message}`); + LogUtil.write(`本地通知发布失败: ${e.code} - ${e.message}`); + return -1; + } + } + + /** 测试入口:代理可用时发 1 分钟后的代理提醒;降级模式发立即本地通知 */ + static async publishTestReminder(context: common.Context): Promise { + if (ReminderService.agentAvailable === false) { + const r: number = await ReminderService.notifyNow( + '测试通知(应用内模式)', '如果你看到这条通知,说明应用内提醒链路正常', 99998); + return r === 0 + ? '已发出测试通知(应用内模式:代理提醒配额为 0,由 App 进程在提醒时刻发通知)' + : '本地通知发布失败,详见日志'; + } + // request 构造放在 try 外:腾位重试也要用到 + const t: number = Date.now() + 60 * 1000; + const d = new Date(t); + const request: reminderAgentManager.ReminderRequestCalendar = { + reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_CALENDAR, + dateTime: { + year: d.getFullYear(), + month: d.getMonth() + 1, + day: d.getDate(), + hour: d.getHours(), + minute: d.getMinutes(), + second: 0 + }, + title: '测试提醒', + content: '这是一条测试提醒:如果你看到它,说明提醒链路正常', + notificationId: 99999, + expiredContent: '测试提醒已过期', + ringDuration: 5, + snoozeTimes: 0, + timeInterval: 0 + }; + const before: number = await ReminderService.poolCount(); + LogUtil.write(`测试提醒发布前池内提醒 ${before} 条`); + try { + await ReminderService.publishWithRetry(request); + LogUtil.write('测试提醒已发布:1 分钟后应弹出通知'); + return `已发布(发布前池内 ${before} 条),1 分钟后应弹出通知`; + } catch (err) { + const e = err as Error; + if (e.message.startsWith('1700002') && await ReminderService.isQuotaZero()) { + ReminderService.agentAvailable = false; + await AppSettings.setReminderTick(context, Date.now()); + LogUtil.write('代理提醒配额为 0(需向华为邮件申请权限),已切换为应用内通知模式'); + const r: number = await ReminderService.notifyNow( + '测试通知(应用内模式)', '如果你看到这条通知,说明应用内提醒链路正常', 99998); + return r === 0 + ? '代理提醒配额为 0(需邮件向华为申请),已切换为应用内通知模式并发出测试通知' + : '代理提醒配额为 0,已切换应用内模式,但测试通知发布失败(详见日志)'; + } + LogUtil.write(`测试提醒发布失败: ${e.message}`); + const sep: number = e.message.indexOf(':'); + return sep > 0 + ? `发布失败:错误码 ${e.message.substring(0, sep)}(${e.message.substring(sep + 1)})` + : `发布失败:${e.message}`; } } diff --git a/entry/src/main/ets/common/SyncEngine.ets b/entry/src/main/ets/common/SyncEngine.ets index bc2874d..6f4e967 100644 --- a/entry/src/main/ets/common/SyncEngine.ets +++ b/entry/src/main/ets/common/SyncEngine.ets @@ -277,6 +277,7 @@ export class SyncEngine { r.rrule = e.rrule; r.exdate = e.exdate; r.reminder = e.reminder; + r.reminders = e.reminders.slice(); return r; } @@ -319,7 +320,7 @@ export class SyncEngine { const ics: string = IcsUtil.build(e); const etag: string = await DavClient.putEvent(url, auth, ics); await EventDb.clearDirty(context, e.id, etag); - LogUtil.write(`推送保存「${e.title}」→ ${url}(${ics.length} 字节)`); + LogUtil.write(`推送保存「${e.title}」→ ${url}(${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'})`); } } } diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 3b39239..b4aa187 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -4,12 +4,16 @@ import { window } from '@kit.ArkUI'; import { notificationManager } from '@kit.NotificationKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { BackgroundSyncService } from '../common/BackgroundSyncService'; +import { LogUtil } from '../common/LogUtil'; const DOMAIN = 0x0000; export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); + // 文件日志初始化:所有页面的同步日志统一写入 filesDir/sync.log + LogUtil.init(this.context); + LogUtil.write('---- App 启动 ----'); // 后台同步:注册延迟任务兜底 + 按设置恢复长时任务 BackgroundSyncService.initOnLaunch(this.context); // 申请通知权限:日程提醒、后台同步常驻通知都依赖它(系统只弹一次授权框) diff --git a/entry/src/main/ets/pages/EventEditPage.ets b/entry/src/main/ets/pages/EventEditPage.ets index 5c01a67..84b3e83 100644 --- a/entry/src/main/ets/pages/EventEditPage.ets +++ b/entry/src/main/ets/pages/EventEditPage.ets @@ -8,6 +8,8 @@ import { EventDb, LocalEvent } from '../common/EventDb'; import { AppSettings } from '../common/AppSettings'; import { DavClient } from '../common/DavClient'; import { SyncEngine } from '../common/SyncEngine'; +import { ReminderService } from '../common/ReminderService'; +import { LogUtil } from '../common/LogUtil'; /** 可选的日历本 */ class BookChoice { @@ -32,7 +34,7 @@ struct EventEditPage { @State statusMsg: string = ''; @State isExisting: boolean = false; @State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom - @State reminderMin: number = 0; // 提前提醒分钟数,0 = 不提醒 + @State reminderList: number[] = []; // 提前提醒分钟数(多选),空 = 不提醒 @State pickerShow: boolean = false; // 开始/结束时间选择底部弹层(日期+时间一次选完) @State pickerIsEnd: boolean = false; @State pickDate: Date = new Date(); @@ -77,16 +79,18 @@ struct EventEditPage { this.endMs = loaded.endTime; this.chosenKey = loaded.calKey; this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none'; - this.reminderMin = loaded.reminder; + this.reminderList = loaded.reminders.length > 0 ? loaded.reminders + : (loaded.reminder > 0 ? [loaded.reminder] : []); return; } } - // 新建:默认时间 = 所选日期 9:00-10:00 + // 新建:日期取所选日(未选则今天),时间取当前时刻;结束 = 开始 + 1 小时 const base: number = AppStorage.get('pendingEventDate') ?? Date.now(); const dayStart = new Date(new Date(base).getFullYear(), new Date(base).getMonth(), new Date(base).getDate()).getTime(); - this.startMs = dayStart + 9 * 3600000; - this.endMs = dayStart + 10 * 3600000; + const now = new Date(); + this.startMs = dayStart + now.getHours() * 3600000 + now.getMinutes() * 60000; + this.endMs = this.startMs + 3600000; if (choices.length > 0) { this.chosenKey = choices[0].calKey; } @@ -179,9 +183,9 @@ struct EventEditPage { // 全天日程的结束存为"当天 23:59:59.999"(排他日期前 1 毫秒) this.endMs = this.allDay ? picked + 86399999 : picked; } else { - const dur: number = Math.max(0, this.endMs - this.startMs); + // 设定开始时间后,结束时间自动跟随 = 开始 + 1 小时 this.startMs = picked; - this.endMs = this.allDay ? picked + 86399999 : picked + dur; + this.endMs = this.allDay ? picked + 86399999 : picked + 3600000; } } @@ -246,7 +250,8 @@ struct EventEditPage { e.rrule = this.rruleForMode(this.repeatMode); } e.recurring = e.rrule !== ''; - e.reminder = this.reminderMin; + e.reminders = EventDb.normalizeReminders(this.reminderList); + e.reminder = e.reminders.length > 0 ? e.reminders[0] : 0; if (isNew) { e.uid = `syncal-${Date.now()}-${Math.floor(Math.random() * 1000000)}`; e.remotePath = encodeURIComponent(e.uid) + '.ics'; @@ -265,11 +270,18 @@ struct EventEditPage { } else { await SyncEngine.settleLocalEvents(context); } + LogUtil.write(`本地保存日程「${e.title}」提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`); + // 立即刷新提醒(不等下一轮同步),保证刚保存的提醒马上生效 + try { + await ReminderService.refreshReminders(context as common.UIAbilityContext); + } catch (err) { + // 刷新失败不影响保存 + } this.getUIContext().getPromptAction().showToast({ message: '日程已保存' }); router.back(); } catch (err) { const ex = err as BusinessError; - console.error(`保存日程失败: ${ex.message}`); + LogUtil.write(`保存日程流程失败(数据已存本地待重试):${ex.message}`); this.statusMsg = `保存失败:${ex.message}(已保存到本地,稍后同步会重试)`; // 数据仍在本地且带 dirty 标记,不会丢 router.back(); @@ -302,6 +314,13 @@ struct EventEditPage { } else { await SyncEngine.settleLocalEvents(context); } + LogUtil.write(`本地删除日程「${this.event?.title ?? ''}」`); + // 立即刷新提醒(取消已发布但日程已删的提醒) + try { + await ReminderService.refreshReminders(context as common.UIAbilityContext); + } catch (err) { + // 刷新失败不影响删除 + } this.getUIContext().getPromptAction().showToast({ message: '日程已删除' }); router.back(); } catch (err) { @@ -407,7 +426,7 @@ struct EventEditPage { // 提醒 Column({ space: 8 }) { - Text('提醒') + Text('提醒(可多选)') .fontSize(14) .fontColor($r('app.color.text_secondary')) Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) { @@ -541,7 +560,8 @@ struct EventEditPage { .width('100%') .padding({ left: 8, right: 8, top: 10, bottom: 10 }) .onClick(() => { - this.openPicker(isStart); + // 注意:timeRow 的参数是 isStart,openPicker 的参数是 isEnd,语义相反必须取反 + this.openPicker(!isStart); }) } @@ -560,18 +580,42 @@ struct EventEditPage { }) } + /** 提醒 chip 是否选中:0 = 不提醒(列表为空时选中);其余按是否已选 */ + private chipOn(minutes: number): boolean { + if (minutes === 0) { + return this.reminderList.length === 0; + } + return this.reminderList.includes(minutes); + } + + /** 切换提醒:0 = 清空全部;其余多选切换 */ + private toggleReminder(minutes: number): void { + if (minutes === 0) { + this.reminderList = []; + return; + } + const list: number[] = [...this.reminderList]; + const idx: number = list.indexOf(minutes); + if (idx >= 0) { + list.splice(idx, 1); + } else { + list.push(minutes); + } + this.reminderList = list; + } + @Builder reminderChip(minutes: number) { Text(this.reminderLabel(minutes)) .fontSize(12) - .fontColor(this.reminderMin === minutes + .fontColor(this.chipOn(minutes) ? $r('app.color.button_text') : $r('app.color.text_primary')) .padding({ left: 10, right: 10, top: 6, bottom: 6 }) .borderRadius(14) .margin({ right: 8, bottom: 8 }) - .backgroundColor(this.reminderMin === minutes ? $r('app.color.brand') : $r('app.color.chip_off_bg')) + .backgroundColor(this.chipOn(minutes) ? $r('app.color.brand') : $r('app.color.chip_off_bg')) .onClick(() => { - this.reminderMin = minutes; + this.toggleReminder(minutes); }) } diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 9204180..966b13c 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -181,12 +181,18 @@ struct Index { }); } this.autoSyncTimer = setInterval((): void => { + // 应用内提醒模式(代理提醒配额为 0 的降级):每 15 秒检查到点的提醒 + const tickCtx = this.getUIContext().getHostContext(); + if (tickCtx !== undefined && !this.syncing) { + ReminderService.tickReminders(tickCtx as common.UIAbilityContext); + } const minutes: number = AppStorage.get('syncIntervalMinutes') ?? 1; if (Date.now() - this.lastSyncTime < minutes * 60000) { return; // 未到设置的同步间隔 } if (!this.syncing && this.accounts.length > 0) { this.lastSyncTime = Date.now(); + LogUtil.write(`自动同步触发(间隔 ${minutes} 分钟,账号 ${this.accounts.length} 个)`); this.syncAll(false); } }, 15000); diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index 0c6545d..4ec3165 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -2,6 +2,8 @@ // 设置页:系统日历模式(仅显示/备份到 CalDAV)+ 混合显示开关 + 自动同步间隔 + 后台同步 import { router } from '@kit.ArkUI'; import { common } from '@kit.AbilityKit'; +import { pasteboard } from '@kit.BasicServicesKit'; +import { notificationManager } from '@kit.NotificationKit'; import { AppSettings } from '../common/AppSettings'; import { BackgroundSyncService } from '../common/BackgroundSyncService'; import { AccountStore, DavAccount } from '../common/AccountStore'; @@ -29,6 +31,10 @@ struct SettingsPage { @State allBooks: BackupTarget[] = []; // 全部 DAV 日历本(只读标记管理用) @State manualKeys: string[] = []; // 手动标记只读的 calKey @State mutedKeys: string[] = []; // 提醒静音的 calKey + @State showLogSheet: boolean = false; // 同步日志查看弹层 + @State logText: string = ''; + @State notifyEnabled: boolean = true; // 通知权限状态(提醒依赖) + @State lastTestResult: string = ''; private context?: common.Context; aboutToAppear(): void { @@ -56,6 +62,13 @@ struct SettingsPage { AppSettings.getMutedReminderKeys(ctx).then((v: string[]): void => { this.mutedKeys = v; }); + notificationManager.isNotificationEnabled() + .then((v: boolean): void => { + this.notifyEnabled = v; + }) + .catch((): void => { + this.notifyEnabled = false; + }); this.loadBackupSettings(); this.loadAllBooks(); } @@ -274,6 +287,65 @@ struct SettingsPage { }); } + /** 打开同步日志查看弹层 */ + private openLogSheet(): void { + if (this.context === undefined) { + return; + } + this.logText = LogUtil.readAll(this.context); + this.showLogSheet = true; + } + + /** 复制全部日志到剪贴板(方便粘贴给我诊断) */ + private copyLog(): void { + if (this.context === undefined) { + return; + } + try { + const data: pasteboard.PasteData = pasteboard.createData( + pasteboard.MIMETYPE_TEXT_PLAIN, LogUtil.readAll(this.context)); + pasteboard.getSystemPasteboard().setData(data) + .then((): void => { + this.getUIContext().getPromptAction().showToast({ message: '日志已复制到剪贴板' }); + }) + .catch((): void => { + this.getUIContext().getPromptAction().showToast({ message: '复制失败' }); + }); + } catch (err) { + this.getUIContext().getPromptAction().showToast({ message: '复制失败' }); + } + } + + private clearLog(): void { + if (this.context === undefined) { + return; + } + LogUtil.clear(this.context); + this.logText = '(日志已清空)'; + this.getUIContext().getPromptAction().showToast({ message: '日志已清空' }); + } + + /** 发布 1 分钟后的测试提醒,验证提醒链路 */ + private async testReminder(): Promise { + if (this.context === undefined) { + return; + } + this.lastTestResult = await ReminderService.publishTestReminder( + this.context as common.UIAbilityContext); + } + + /** 按当前数据立即重建提醒,显示发布条数 */ + private async refreshNow(): Promise { + if (this.context === undefined) { + return; + } + const n: number = await ReminderService.refreshReminders( + this.context as common.UIAbilityContext); + this.getUIContext().getPromptAction().showToast({ + message: n > 0 ? `已发布 ${n} 个提醒` : '没有发布任何提醒(可能都过了提醒时间、被静音或通知未开启,详见日志)' + }); + } + build() { Column() { // 顶部 @@ -581,6 +653,85 @@ struct SettingsPage { .borderRadius(12) .backgroundColor($r('app.color.card_bg')) .border({ width: 1, color: $r('app.color.shadow_color') }) + // 提醒诊断:通知权限状态 + 测试提醒 + 立即刷新 + Column({ space: 8 }) { + Row({ space: 10 }) { + Column({ space: 2 }) { + Row({ space: 6 }) { + Text('提醒诊断') + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + Text(this.notifyEnabled ? '通知已开启' : '通知未开启!') + .fontSize(11) + .fontColor(this.notifyEnabled ? $r('app.color.success') : $r('app.color.error')) + .padding({ left: 8, right: 8, top: 3, bottom: 3 }) + .borderRadius(10) + .backgroundColor(this.notifyEnabled ? $r('app.color.success_bg') : $r('app.color.error_bg')) + } + Text(this.notifyEnabled + ? '点"测试提醒"验证链路:1 分钟后应收到通知' + : '系统设置 → 通知管理 → 找到本应用 → 允许通知,日程提醒才能弹出') + .fontSize(12) + .fontColor($r('app.color.text_secondary')) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + } + .width('100%') + Row({ space: 10 }) { + Button('测试提醒') + .fontSize(13) + .backgroundColor($r('app.color.brand')) + .onClick(() => { + this.testReminder(); + }) + Button('立即刷新提醒') + .fontSize(13) + .backgroundColor($r('app.color.text_secondary')) + .onClick(() => { + this.refreshNow(); + }) + } + if (this.lastTestResult !== '') { + Text(this.lastTestResult) + .fontSize(12) + .fontColor($r('app.color.text_secondary')) + .width('100%') + } + } + .alignItems(HorizontalAlign.Start) + .width('100%') + .padding(14) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + .border({ width: 1, color: $r('app.color.shadow_color') }) + + // 同步日志:查看/复制/清空(真机测试诊断用) + 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) + Button('查看') + .fontSize(13) + .backgroundColor($r('app.color.brand')) + .onClick(() => { + this.openLogSheet(); + }) + } + .width('100%') + .padding(14) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + .border({ width: 1, color: $r('app.color.shadow_color') }) } .width('100%') .padding({ left: 20, right: 20, top: 8, bottom: 20 }) @@ -588,9 +739,53 @@ struct SettingsPage { .layoutWeight(1) .align(Alignment.Top) .scrollBar(BarState.Off) + .bindSheet($$this.showLogSheet, this.logSheetBuilder(), { + height: 560, + dragBar: true, + title: { title: '同步日志' } + }) } .width('100%') .height('100%') .backgroundColor($r('app.color.page_bg')) } + + @Builder + logSheetBuilder() { + Column({ space: 10 }) { + Row({ space: 10 }) { + Button('复制全部') + .fontSize(13) + .backgroundColor($r('app.color.brand')) + .onClick(() => { + this.copyLog(); + }) + Button('清空') + .fontSize(13) + .backgroundColor($r('app.color.error')) + .onClick(() => { + this.clearLog(); + }) + Button('刷新') + .fontSize(13) + .backgroundColor($r('app.color.text_secondary')) + .onClick(() => { + this.openLogSheet(); + }) + } + Scroll() { + Text(this.logText === '' ? '(日志为空)' : this.logText) + .fontSize(11) + .fontColor($r('app.color.text_primary')) + .width('100%') + } + .layoutWeight(1) + .align(Alignment.Top) + .scrollBar(BarState.On) + } + .width('100%') + .height('100%') + .padding(16) + .backgroundColor($r('app.color.page_bg')) + } }