diff --git a/entry/src/main/ets/common/AccountStore.ets b/entry/src/main/ets/common/AccountStore.ets index 80c46a8..53d52e9 100644 --- a/entry/src/main/ets/common/AccountStore.ets +++ b/entry/src/main/ets/common/AccountStore.ets @@ -126,9 +126,11 @@ export class AccountStore { const store: preferences.Preferences = await preferences.getPreferences(context, AccountStore.STORE); const count: number = await store.get(AccountStore.COUNT_KEY, 0) as number; + console.info(`[AccountStore] loadAll: count=${count}`); for (let i = 0; i < count; i++) { const raw = await store.get(`acc_${i}`, '') as string; if (raw === '') { + console.warn(`[AccountStore] loadAll: acc_${i} 为空`); continue; } const acc = AccountStore.decodeAccount(raw); @@ -139,6 +141,8 @@ export class AccountStore { migrated = true; } result.push(acc); + } else { + console.error(`[AccountStore] loadAll: acc_${i} 解码失败,raw 长度=${raw.length}`); } } } catch (err) { @@ -157,10 +161,19 @@ export class AccountStore { return result; } - static async saveAll(context: common.Context, accounts: DavAccount[]): Promise { + /** + * 保存全部账号。 + * 防御:存储里已有账号时,禁止用空列表覆盖(调用方若因读取异常拿到空列表再回写, + * 会把所有账号抹掉)。仅删除账号的合法场景通过 force=true 放行。 + */ + static async saveAll(context: common.Context, accounts: DavAccount[], force: boolean = false): Promise { const store: preferences.Preferences = await preferences.getPreferences(context, AccountStore.STORE); const oldCount: number = await store.get(AccountStore.COUNT_KEY, 0) as number; + if (accounts.length === 0 && oldCount > 0 && !force) { + console.error(`[AccountStore] 拒绝用空列表覆盖账号存储(原有 ${oldCount} 个账号)`); + throw new Error('账号列表为空,已阻止覆盖存储(保护原有账号数据)'); + } for (let i = 0; i < oldCount; i++) { store.delete(`acc_${i}`); } diff --git a/entry/src/main/ets/common/AppSettings.ets b/entry/src/main/ets/common/AppSettings.ets index 567b76e..ef9bdee 100644 --- a/entry/src/main/ets/common/AppSettings.ets +++ b/entry/src/main/ets/common/AppSettings.ets @@ -3,6 +3,7 @@ import { preferences } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; +import { LogUtil } from './LogUtil'; export class AppSettings { private static readonly STORE: string = 'sync_settings'; @@ -12,6 +13,50 @@ export class AppSettings { private static readonly KEY_SYS_MODE: string = 'sys_cal_mode'; // 'display' | 'backup' 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_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成 + + /** + * 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。 + * 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。 + */ + static async isFullRefetchPending(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + return !(await store.get(AppSettings.KEY_FULL_REFETCH, false) as boolean); + } catch (err) { + return true; + } + } + + static async markFullRefetchDone(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_FULL_REFETCH, true); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`标记全量重拉完成失败: ${e.message}`); + } + } + + /** + * 手动重置全量重拉标记:下次同步忽略 etag 复用,所有资源 GET 完整 ICS 重建本地数据。 + * 用于修复历史落库的残缺字段(如 reminder=0 但 etag 从未变化,增量同步永远无法纠正)。 + */ + static async resetFullRefetch(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_FULL_REFETCH, false); + await store.flush(); + LogUtil.write('已重置全量重拉标记:下次同步将 GET 全部资源重建本地数据'); + } catch (err) { + const e = err as BusinessError; + console.error(`重置全量重拉标记失败: ${e.message}`); + } + } /** * 手动标记为只读的日历本 calKey 列表。 diff --git a/entry/src/main/ets/common/DavClient.ets b/entry/src/main/ets/common/DavClient.ets index 1ceb878..50264f7 100644 --- a/entry/src/main/ets/common/DavClient.ets +++ b/entry/src/main/ets/common/DavClient.ets @@ -110,6 +110,28 @@ export class DavClient { return 'Basic ' + buffer.from(`${username}:${password}`).toString('base64'); } + /** GET 拉取单个资源的原始 ICS(调试诊断用) */ + static async getRaw(url: string, auth: string): Promise { + const httpRequest = http.createHttp(); + try { + const resp: http.HttpResponse = await httpRequest.request(url, { + method: http.RequestMethod.GET, + header: { + 'Authorization': auth, + 'User-Agent': 'SyncCalendar/1.0' + }, + connectTimeout: 10000, + readTimeout: 15000 + }); + if (resp.responseCode >= 200 && resp.responseCode < 300) { + return resp.result as string; + } + return `HTTP ${resp.responseCode}`; + } finally { + httpRequest.destroy(); + } + } + /** * 写权限探测:向日历本 PUT 一个探测资源—— * 2xx → 可写(随后删除探测资源);403/401 等 → 只读;网络异常 → 乐观按可写。 @@ -170,6 +192,58 @@ export class DavClient { return DavClient.reportComponents(href, auth, 'VEVENT', ''); } + /** + * REPORT calendar-query:仅拉取 href + getetag(不含 calendar-data)。 + * 部分服务器(如群晖)的 REPORT calendar-data 会剥离 VALARM 导致提醒丢失, + * 因此数据改为对变更资源逐个 GET 补拉(GET 返回完整 ICS)。 + */ + static async reportEtags(href: string, auth: string): Promise { + const body: string = '' + + '' + + '' + + '' + + '' + + ''; + const httpRequest = http.createHttp(); + try { + const resp: http.HttpResponse = await httpRequest.request(href, { + method: 'REPORT' as http.RequestMethod, + header: { + 'Authorization': auth, + 'Content-Type': 'application/xml; charset=utf-8', + 'Depth': '1', + 'User-Agent': 'SyncCalendar/1.0' + }, + extraData: body, + connectTimeout: 10000, + readTimeout: 30000 + }); + LogUtil.write(`HTTP REPORT(etag) ${href} → ${resp.responseCode}`); + if (resp.responseCode < 200 || resp.responseCode >= 300) { + throw new Error(`服务器返回状态码 ${resp.responseCode}`); + } + const xml: string = resp.result as string; + const items: RemoteItem[] = []; + const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i); + for (const block of blocks) { + if (!/<[\w-]*:?response[\s>]/i.test(block)) { + continue; + } + const resHref: string = DavClient.extractTag(block, 'href'); + if (resHref === '') { + continue; + } + const item = new RemoteItem(); + item.href = resHref; + item.etag = DavClient.extractTag(block, 'getetag').replace(/"/g, ''); + items.push(item); + } + return items; + } finally { + httpRequest.destroy(); + } + } + /** REPORT calendar-query:拉取某日历本内所有 VTODO 待办(不限时间范围,量小) */ static async reportTodos(href: string, auth: string): Promise { return DavClient.reportComponents(href, auth, 'VTODO', ''); diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index e08aea1..1ffb904 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -409,6 +409,23 @@ export class EventDb { return `新增${added} 更新${updated} 删除${removed} 不变${unchanged}`; } + /** 查询某日历本下同类型的全部行(同步 etag 比对用,含 dirty 行) */ + static async queryByCalKey(context: common.Context, calKey: string, kind: string): Promise { + const store = await EventDb.getDb(context); + const predicates = new relationalStore.RdbPredicates('events'); + predicates.equalTo('cal_key', calKey).and().equalTo('kind', kind); + const rs = await store.query(predicates); + const list: LocalEvent[] = []; + try { + while (rs.goToNextRow()) { + list.push(EventDb.fromRow(rs)); + } + } finally { + rs.close(); + } + return list; + } + /** 按 UID 判断事件是否已存在(系统日历备份导入的幂等判重) */ static async uidExists(context: common.Context, uid: string): Promise { const store = await EventDb.getDb(context); diff --git a/entry/src/main/ets/common/ScreenKeeper.ets b/entry/src/main/ets/common/ScreenKeeper.ets new file mode 100644 index 0000000..1cf83c5 --- /dev/null +++ b/entry/src/main/ets/common/ScreenKeeper.ets @@ -0,0 +1,34 @@ +// entry/src/main/ets/common/ScreenKeeper.ets +// 同步期间保持屏幕常亮:鸿蒙熄屏后应用进程会被挂起,网络请求停摆导致同步超时。 +// 引用计数支持多处同步(首页/账号页/后台入口)叠加,最后一个释放时才恢复熄屏。 +import { window } from '@kit.ArkUI'; +import { common } from '@kit.AbilityKit'; + +export class ScreenKeeper { + private static count: number = 0; + + /** 同步开始时调用:保持屏幕常亮(失败静默,不影响同步) */ + static async acquire(context: common.UIAbilityContext): Promise { + ScreenKeeper.count++; + try { + const win = await window.getLastWindow(context); + await win.setWindowKeepScreenOn(true); + } catch (err) { + // 获取窗口失败(如页面已销毁)不影响同步 + } + } + + /** 同步结束时调用:恢复系统默认熄屏策略 */ + static async release(context: common.UIAbilityContext): Promise { + ScreenKeeper.count = Math.max(0, ScreenKeeper.count - 1); + if (ScreenKeeper.count > 0) { + return; // 还有其他同步在进行,保持常亮 + } + try { + const win = await window.getLastWindow(context); + await win.setWindowKeepScreenOn(false); + } catch (err) { + // 忽略 + } + } +} diff --git a/entry/src/main/ets/common/SyncEngine.ets b/entry/src/main/ets/common/SyncEngine.ets index 698fc63..bc2874d 100644 --- a/entry/src/main/ets/common/SyncEngine.ets +++ b/entry/src/main/ets/common/SyncEngine.ets @@ -5,10 +5,16 @@ import { BusinessError } from '@kit.BasicServicesKit'; import { DavAccount } from './AccountStore'; import { EventDb, LocalEvent, RemoteEvent } from './EventDb'; import { IcsUtil } from './IcsUtil'; -import { DavClient, DavColorEntry } from './DavClient'; +import { DavClient, DavColorEntry, RemoteItem } from './DavClient'; import { LogUtil } from './LogUtil'; +import { AppSettings } from './AppSettings'; export class SyncEngine { + /** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */ + private static activeSyncs: Map = new Map(); + /** 互斥锁最长持有时间:超过视为异常残留(如进程挂起后网络停摆),允许抢占 */ + private static readonly LOCK_STALE_MS: number = 15 * 60 * 1000; + /** * 给异步操作加超时保护,防止网络挂起导致界面一直转圈 */ @@ -26,11 +32,28 @@ export class SyncEngine { * 返回远端事件总数(拉取侧)。带 120 秒超时保护。 */ static async syncAccount(context: common.Context, acc: DavAccount): Promise { + // 同一账号互斥:超时被掐断后内层任务仍在后台运行,期间不允许再次同步该账号。 + // 锁超过 LOCK_STALE_MS 未释放(如熄屏挂起导致任务冻结)则视为残留,允许抢占 + const heldSince: number | undefined = SyncEngine.activeSyncs.get(acc.id); + if (heldSince !== undefined) { + if (Date.now() - heldSince < SyncEngine.LOCK_STALE_MS) { + throw new Error('该账号正在同步中,请稍后再试'); + } + LogUtil.write(`同步互斥锁超时残留(${acc.name}),强制释放并重新同步`); + } + SyncEngine.activeSyncs.set(acc.id, Date.now()); const t0: number = Date.now(); LogUtil.write(`========== 同步账号「${acc.name}」开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`); try { - const r: number = await SyncEngine.withTimeout( - SyncEngine.syncAccountInner(context, acc), 120000); + // 全量重拉(升级后首次)要逐个 GET 所有资源,放宽超时到 10 分钟;常规 5 分钟 + const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context); + const timeoutMs: number = fullRefetch ? 600000 : 300000; + // 内层任务真正结束(无论成败)才释放互斥锁;超时后它仍会在后台跑完 + const inner: Promise = SyncEngine.syncAccountInner(context, acc); + inner.catch((): void => {}).finally((): void => { + SyncEngine.activeSyncs.delete(acc.id); + }); + const r: number = await SyncEngine.withTimeout(inner, timeoutMs); LogUtil.write(`同步账号「${acc.name}」完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`); return r; } catch (err) { @@ -46,7 +69,14 @@ export class SyncEngine { await SyncEngine.refreshCalendarColors(acc, auth); // 1) 推送该账号日历本下的本地修改 await SyncEngine.pushDirtyForAccount(context, acc, auth); - // 2) 拉取远端变更(全量 REPORT,etag 增量落库) + // 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。 + // 原因:群晖等服务器的 REPORT calendar-data 会剥离 VALARM(提醒丢失), + // 而 GET 返回完整 ICS;etag 未变的资源直接复用本地完整数据,几乎零开销。 + // 首次升级后执行一次全量重拉,修复旧模式落库的残缺数据(如 reminder=0)。 + const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context); + if (fullRefetch) { + LogUtil.write('一次性全量重拉:忽略 etag 复用,全部资源 GET 完整 ICS'); + } let changed: number = 0; for (let i = 0; i < acc.calendarHrefs.length; i++) { const href: string = acc.calendarHrefs[i]; @@ -54,36 +84,82 @@ export class SyncEngine { const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`; LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`); const t1: number = Date.now(); - const items = await DavClient.reportCalendar(href, auth); + const items: RemoteItem[] = await DavClient.reportEtags(href, auth); LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`); + // 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag) + const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event'); + const byUid: Map = new Map(); + for (const row of existingRows) { + const arr: LocalEvent[] | undefined = byUid.get(row.uid); + if (arr === undefined) { + byUid.set(row.uid, [row]); + } else { + arr.push(row); + } + } + const originMatch = /https?:\/\/[^/]+/i.exec(href); + const origin: string = originMatch !== null ? originMatch[0] : ''; const remote: RemoteEvent[] = []; + let fetched: number = 0; let parseFail: number = 0; - let failSample: string = ''; + const needFetch: RemoteItem[] = []; for (const it of items) { - const parsed: RemoteEvent[] = IcsUtil.parse(it.ics); - if (parsed.length === 0) { - parseFail++; - if (failSample === '') { - failSample = it.ics.replace(/\r?\n/g, ' ⏎ ').substring(0, 600); + const resUid: string = SyncEngine.resUidFromHref(it.href); + const group: LocalEvent[] | undefined = byUid.get(resUid); + if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty + && group[0].etag === it.etag) { + // etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段) + for (const row of group) { + remote.push(SyncEngine.rowToRemote(row, it.etag)); } continue; } - // 一个资源可能包含主事件 + 单次覆盖实例(RECURRENCE-ID),全部入库 - for (const r of parsed) { - if (r.uid === 'syncprobe') { - continue; // 写权限探测资源(万一删除失败),不入库展示 + needFetch.push(it); + } + // 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时 + const batchSize: number = 5; + for (let b: number = 0; b < needFetch.length; b += batchSize) { + const batch: RemoteItem[] = needFetch.slice(b, b + batchSize); + const results: RemoteEvent[][] = await Promise.all(batch.map(async (it: RemoteItem): Promise => { + const url: string = it.href.startsWith('http') ? it.href : origin + it.href; + try { + const raw: string = await DavClient.getRaw(url, auth); + if (!raw.startsWith('BEGIN:VCALENDAR')) { + return []; + } + const resUid: string = SyncEngine.resUidFromHref(it.href); + const parsed: RemoteEvent[] = IcsUtil.parse(raw); + const out: RemoteEvent[] = []; + for (const r of parsed) { + if (r.uid === 'syncprobe') { + continue; // 写权限探测资源(万一删除失败),不入库展示 + } + if (r.uid === '') { + r.uid = resUid; + } + r.etag = it.etag; + out.push(r); + } + return out; + } catch (err) { + return []; } - if (r.uid === '') { - r.uid = SyncEngine.uidFromHref(it.href); + })); + for (const arr of results) { + if (arr.length === 0) { + parseFail++; + } else { + fetched++; + for (const r of arr) { + remote.push(r); + } } - r.etag = it.etag; - remote.push(r); } } if (parseFail > 0) { - LogUtil.write(`日历本[${i}]「${calName}」有 ${parseFail} 个资源解析出 0 条日程,首个样本: ${failSample}`); + LogUtil.write(`日历本[${i}]「${calName}」有 ${parseFail} 个资源拉取/解析失败`); } - LogUtil.write(`日历本[${i}]「${calName}」解析出 ${remote.length} 条日程,开始落库`); + LogUtil.write(`日历本[${i}]「${calName}」补拉变更资源 ${fetched} 个,共 ${remote.length} 条日程,开始落库`); const stat: string = await EventDb.applyRemote(context, calKey, href, remote, false); LogUtil.write(`日历本[${i}]「${calName}」日程落库完成:${stat},耗时 ${Math.round((Date.now() - t1) / 1000)} 秒`); changed += remote.length; @@ -167,6 +243,43 @@ export class SyncEngine { return last.endsWith('.ics') ? last.substring(0, last.length - 4) : last; } + /** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */ + private static resUidFromHref(href: string): string { + const segs: string[] = href.split('/').filter((s: string): boolean => s !== ''); + if (segs.length === 0) { + return String(Date.now()); + } + let last: string = segs[segs.length - 1]; + if (last.endsWith('.ics')) { + last = last.substring(0, last.length - 4); + } + try { + last = decodeURIComponent(last); + } catch (err) { + // 解码失败保持原样 + } + return last; + } + + /** 本地行 → 远端事件(etag 未变的资源复用本地完整数据,含 reminder) */ + private static rowToRemote(e: LocalEvent, etag: string): RemoteEvent { + const r = new RemoteEvent(); + r.uid = e.uid; + r.etag = etag; + r.title = e.title; + r.description = e.description; + r.location = e.location; + r.startTime = e.startTime; + r.endTime = e.endTime; + r.isAllDay = e.isAllDay; + r.recurring = e.recurring; + r.completed = e.completed; + r.rrule = e.rrule; + r.exdate = e.exdate; + r.reminder = e.reminder; + return r; + } + /** * 推送指定账号日历本下的待同步事件(新建/修改 → PUT;删除 → DELETE) */ @@ -237,6 +350,11 @@ export class SyncEngine { */ static async pruneOrphanRows(context: common.Context, accounts: DavAccount[]): Promise { try { + // 防御:账号列表为空时(如同步中途读取异常),绝不能把所有 CalDAV 日程当孤儿清理 + if (accounts.length === 0) { + LogUtil.write('清理孤儿日程行跳过:账号列表为空(防御保护)'); + return; + } const accIds: string[] = accounts.map((a: DavAccount): string => a.id); const removed: number = await EventDb.pruneOrphanCalKeys(context, accIds); if (removed > 0) { diff --git a/entry/src/main/ets/pages/AccountsPage.ets b/entry/src/main/ets/pages/AccountsPage.ets index b4e990b..a95350c 100644 --- a/entry/src/main/ets/pages/AccountsPage.ets +++ b/entry/src/main/ets/pages/AccountsPage.ets @@ -8,6 +8,7 @@ import { SyncEngine } from '../common/SyncEngine'; import { EditNavParams } from './EditAccountPage'; import { EventDb } from '../common/EventDb'; import { LogUtil } from '../common/LogUtil'; +import { ScreenKeeper } from '../common/ScreenKeeper'; @Entry @Component @@ -68,14 +69,19 @@ struct AccountsPage { } this.syncing = true; this.syncingId = acc.id; + // 同步期间保持屏幕常亮:熄屏会挂起进程导致请求停摆、同步超时 + const uiCtx = this.getUIContext().getHostContext(); + if (uiCtx !== undefined) { + await ScreenKeeper.acquire(uiCtx as common.UIAbilityContext); + } try { const context = this.getUIContext().getHostContext(); if (context === undefined) { return; } if (acc.type === TYPE_CALDAV) { - await SyncEngine.withTimeout( - SyncEngine.syncAccount(context as common.UIAbilityContext, acc), 120000); + // 超时保护由 syncAccount 内部处理(全量重拉 10 分钟 / 常规 2 分钟),不再套外层超时 + await SyncEngine.syncAccount(context as common.UIAbilityContext, acc); await SyncEngine.pruneOrphanRows(context, this.accounts); } acc.itemCount = acc.calendarHrefs.length; @@ -90,6 +96,9 @@ struct AccountsPage { } finally { this.syncing = false; this.syncingId = ''; + if (uiCtx !== undefined) { + await ScreenKeeper.release(uiCtx as common.UIAbilityContext); + } } } diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index f446ab8..9204180 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -12,10 +12,12 @@ import { LunarUtil } from '../common/LunarUtil'; import { CardDataService } from '../common/CardDataService'; import { ReminderService } from '../common/ReminderService'; import { AppSettings } from '../common/AppSettings'; -import { EventDb, LocalEvent } from '../common/EventDb'; +import { EventDb, LocalEvent, RemoteEvent } from '../common/EventDb'; import { RruleUtil } from '../common/RruleUtil'; import { IcsUtil } from '../common/IcsUtil'; +import { DavClient, RemoteItem } from '../common/DavClient'; import { SystemCalendarImport } from '../common/SystemCalendarImport'; +import { ScreenKeeper } from '../common/ScreenKeeper'; /** 月视图单元格 */ class MonthCell { @@ -92,11 +94,9 @@ struct Index { /** 从设置页/账号页返回时刷新(同步间隔、系统日历开关立即生效),并处理编辑账号后的待同步 */ onPageShow(): void { - if (this.lastSyncTime > 0 || this.accounts.length > 0) { - this.reloadAll().then((): Promise => this.handlePendingSync()); - } else { - this.handlePendingSync(); - } + // 无条件刷新:之前"列表为空就跳过"的条件会导致账号列表卡死在空状态, + // 同步时误报"没有可同步的账号" + this.reloadAll().then((): Promise => this.handlePendingSync()); } private async handlePendingSync(): Promise { @@ -298,13 +298,17 @@ struct Index { return; } this.syncing = true; + // 同步期间保持屏幕常亮:熄屏会挂起进程导致请求停摆、同步超时 + const uiCtx = this.getUIContext().getHostContext(); + if (uiCtx !== undefined) { + await ScreenKeeper.acquire(uiCtx as common.UIAbilityContext); + } try { const context = this.getUIContext().getHostContext(); if (context === undefined) { return; } - await SyncEngine.withTimeout( - SyncEngine.syncAccount(context as common.UIAbilityContext, acc), 120000); + await SyncEngine.syncAccount(context as common.UIAbilityContext, acc); await SyncEngine.pruneOrphanRows(context, this.accounts); acc.lastSyncTime = this.formatNow(); await AccountStore.saveAll(context, this.accounts); @@ -316,6 +320,9 @@ struct Index { .showToast({ message: `同步失败:${e.message}` }); } finally { this.syncing = false; + if (uiCtx !== undefined) { + await ScreenKeeper.release(uiCtx as common.UIAbilityContext); + } await this.reloadAll(); // 同步后刷新服务卡片 try { @@ -339,12 +346,22 @@ struct Index { if (this.syncing) { return; } + // 防御:账号列表为空时不执行同步、不清理孤儿行、不回写账号存储, + // 防止异常状态下把所有 CalDAV 日程和账号配置抹掉 + if (this.accounts.length === 0) { + if (manual) { + this.getUIContext().getPromptAction().showToast({ message: '没有可同步的账号' }); + } + return; + } this.syncing = true; const context = this.getUIContext().getHostContext(); if (context === undefined) { this.syncing = false; return; } + // 同步期间保持屏幕常亮:熄屏会挂起进程导致请求停摆、同步超时 + await ScreenKeeper.acquire(context as common.UIAbilityContext); let ok: number = 0; let failMsg: string = ''; // 备份模式:先把系统本地日历导入目标日历本(幂等),再随正常同步推送上服务器 @@ -358,8 +375,7 @@ struct Index { continue; } try { - await SyncEngine.withTimeout( - SyncEngine.syncAccount(context as common.UIAbilityContext, acc), 120000); + await SyncEngine.syncAccount(context as common.UIAbilityContext, acc); acc.lastSyncTime = this.formatNow(); ok++; } catch (err) { @@ -371,11 +387,16 @@ struct Index { await SyncEngine.settleLocalEvents(context); await SyncEngine.pruneOrphanRows(context, this.accounts); await AccountStore.saveAll(context, this.accounts); + // 全部账号同步成功才结束"一次性全量重拉"状态(修复旧模式落库的残缺数据) + if (failMsg === '') { + await AppSettings.markFullRefetchDone(context); + } } catch (err) { const e = err as BusinessError; failMsg = e.message; } this.syncing = false; + await ScreenKeeper.release(context as common.UIAbilityContext); if (failMsg !== '') { this.getUIContext().getPromptAction() .showToast({ message: ok > 0 ? `同步完成 ${ok} 个,失败:${failMsg}` : `同步失败:${failMsg}` }); @@ -910,6 +931,7 @@ struct Index { }; const lines: string[] = [`调试 ${this.fmtDateCn(dateMs)}`, `visibleKeys=${visibleKeys.join(',')}`]; let idx: number = 0; + let netChecked: boolean = false; for (const m of masters.values()) { if (idx >= 6) { lines.push('…(更多系列省略)'); @@ -939,13 +961,54 @@ struct Index { lines.push(` rrule=${m.rrule === '' ? '(空!)' : m.rrule}`); lines.push(` exdate=${m.exdate === '' ? '无' : m.exdate}`); lines.push(` 展开条数=${occs.length} 含首次=${hasFirst ? '是' : '否'} 首次=${occs.length > 0 ? fmt(occs[0]) : '无'} 该日发生=${hitsDay ? '是' : '否'}`); + lines.push(` 数据库提醒=${m.reminder}分钟 DB同UID行数=${rows.filter((r: LocalEvent): boolean => r.uid === m.uid).length}`); + // 网络诊断(仅第一个命中该日的系列):GET 服务器原始 ICS,看 VALARM 是否还在 + if (hitsDay && !netChecked) { + netChecked = true; + try { + const accounts2: DavAccount[] = await AccountStore.loadAll(context); + const acc2 = accounts2.find((a: DavAccount): boolean => a.calendarHrefs.includes(m.href)); + if (acc2 !== undefined) { + const auth2: string = DavClient.authHeader(acc2.username, acc2.password); + const url: string = m.href.endsWith('/') + ? m.href + m.remotePath : `${m.href}/${m.remotePath}`; + const raw: string = await DavClient.getRaw(url, auth2); + const upper: string = raw.toUpperCase(); + const trigIdx: number = upper.indexOf('TRIGGER'); + lines.push(` 服务器ICS:${raw.length}字节 VALARM=${upper.includes('BEGIN:VALARM') ? '有' : '无'}` + + (trigIdx >= 0 ? ` TRIGGER=${raw.substring(trigIdx, trigIdx + 40).replace(/\r?\n/g, ' ')}` : '')); + // 用解析函数当场重解析这份 ICS,验证解析是否丢提醒 + const reParsed: RemoteEvent[] = IcsUtil.parse(raw); + const hit = reParsed.find((p: RemoteEvent): boolean => p.uid === m.uid); + lines.push(` 重新解析:提醒=${hit !== undefined ? hit.reminder : '?'}分钟 rec=${hit !== undefined ? (hit.recurring ? 1 : 0) : '?'} rrule=${hit !== undefined && hit.rrule !== '' ? hit.rrule : '无'}`); + // 模拟同步路径:REPORT 拉取(与 SyncEngine 完全一致)再解析 + try { + const repItems: RemoteItem[] = await DavClient.reportCalendar(m.href, auth2); + const repIt = repItems.find((x: RemoteItem): boolean => x.ics.includes(m.uid)); + if (repIt !== undefined) { + const rp: RemoteEvent[] = IcsUtil.parse(repIt.ics); + const hit2 = rp.find((p: RemoteEvent): boolean => p.uid === m.uid); + lines.push(` REPORT重解析:提醒=${hit2 !== undefined ? hit2.reminder : '?'}分钟 VALARM=${repIt.ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'} 字节=${repIt.ics.length}`); + lines.push(` etag对比:DB=${m.etag === '' ? '(空)' : m.etag.substring(0, 16)} REPORT=${repIt.etag === '' ? '(空)' : repIt.etag.substring(0, 16)}`); + } else { + lines.push(` REPORT重解析:未找到该UID(共${repItems.length}条)`); + } + } catch (err2) { + lines.push(' REPORT拉取失败:同步可能一直在此失败!'); + } + lines.push(` ICS原文:${raw.substring(0, 380).replace(/\r?\n/g, ' ⏎ ')}`); + } + } catch (err) { + lines.push(' 服务器ICS获取失败(网络)'); + } + } const ovs = overrides.get(m.uid) ?? []; if (ovs.length === 0) { lines.push(' 覆盖实例: 无'); } else { for (const o of ovs.slice(0, 5)) { const oVis: boolean = visibleKeys.includes(o.calKey); - lines.push(` 覆盖: ${fmt(o.startTime)}~${fmt(o.endTime)} rec=${o.recurring ? 1 : 0} calKey=${o.calKey} vis=${oVis ? 1 : 0} dirty=${o.dirty ? 1 : 0}`); + lines.push(` 覆盖: ${fmt(o.startTime)}~${fmt(o.endTime)} rec=${o.recurring ? 1 : 0} calKey=${o.calKey} vis=${oVis ? 1 : 0} dirty=${o.dirty ? 1 : 0} 提醒=${o.reminder}分钟`); } if (ovs.length > 5) { lines.push(` 覆盖共 ${ovs.length} 条`); @@ -955,6 +1018,18 @@ struct Index { if (lines.length <= 2) { lines.push('(该日期附近没有重复主事件)'); } + // 当日非重复日程(含覆盖实例):核对 DB 中实际存储的提醒值,定位提醒丢失环节 + const plain: LocalEvent[] = rows.filter((r: LocalEvent): boolean => + r.rrule === '' && r.startTime < dayEnd && r.endTime >= dayStart); + if (plain.length > 0) { + lines.push(`—— 当日非重复日程 ${plain.length} 条 ——`); + for (const r of plain.slice(0, 8)) { + lines.push(`◇ ${r.title === '' ? '(无标题)' : r.title} 提醒=${r.reminder}分钟 rec=${r.recurring ? 1 : 0} calKey=${r.calKey}`); + } + if (plain.length > 8) { + lines.push(`…(其余 ${plain.length - 8} 条省略)`); + } + } const text: string = lines.join('\n').substring(0, 3500); this.getUIContext().showAlertDialog({ title: '重复日程调试', diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index b9c56a9..7796a9e 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -210,6 +210,36 @@ struct SettingsPage { return minutes >= 60 ? `${minutes / 60} 小时` : `${minutes} 分钟`; } + /** 确认后重置全量重拉标记,下次同步重建全部日程数据 */ + private askRebuildData(): void { + this.getUIContext().showAlertDialog({ + title: '重建日程数据', + message: '将重新从服务器拉取所有日程的完整数据(包括提醒),本地已修改未同步的内容不受影响。\n\n全量拉取可能需要几分钟,期间请保持 App 在前台、不要熄屏。', + autoCancel: true, + alignment: DialogAlignment.Center, + primaryButton: { + value: '取消', + action: (): void => {} + }, + secondaryButton: { + value: '开始重建', + action: (): void => { + this.doRebuildData(); + } + } + }); + } + + private async doRebuildData(): Promise { + if (this.context === undefined) { + return; + } + await AppSettings.resetFullRefetch(this.context); + this.getUIContext().getPromptAction().showToast({ + message: '已开启,返回首页后点一次同步即可,请保持屏幕常亮' + }); + } + build() { Column() { // 顶部 @@ -349,6 +379,32 @@ struct SettingsPage { .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('同步时重新从服务器拉取全部日程的完整数据,修复提醒丢失等本地数据问题。耗时段落请保持 App 在前台、屏幕常亮') + .fontSize(12) + .fontColor($r('app.color.text_secondary')) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + Button('重建') + .fontSize(13) + .backgroundColor($r('app.color.brand')) + .onClick(() => { + this.askRebuildData(); + }) + } + .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 }) { diff --git a/entry/src/main/ets/syncwork/SyncWorkAbility.ets b/entry/src/main/ets/syncwork/SyncWorkAbility.ets index 677c3f8..d1f2b00 100644 --- a/entry/src/main/ets/syncwork/SyncWorkAbility.ets +++ b/entry/src/main/ets/syncwork/SyncWorkAbility.ets @@ -8,6 +8,7 @@ import { SyncEngine } from '../common/SyncEngine'; import { CardDataService } from '../common/CardDataService'; import { ReminderService } from '../common/ReminderService'; import { SystemCalendarImport } from '../common/SystemCalendarImport'; +import { AppSettings } from '../common/AppSettings'; import { LogUtil } from '../common/LogUtil'; export default class SyncWorkAbility extends WorkSchedulerExtensionAbility { @@ -32,19 +33,25 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility { // 导入失败不影响正常同步 } let ok: number = 0; + let attempted: number = 0; for (const acc of accounts) { if (acc.type !== TYPE_CALDAV) { continue; } + attempted++; try { - await SyncEngine.withTimeout( - SyncEngine.syncAccount(context, acc), 100000); + // 超时保护由 syncAccount 内部处理(全量重拉 10 分钟 / 常规 5 分钟),不再套外层超时 + await SyncEngine.syncAccount(context, acc); ok++; } catch (err) { const e = err as BusinessError; LogUtil.write(`延迟任务同步「${acc.name}」失败: ${e.message}`); } } + // 全部账号同步成功才结束"一次性全量重拉"状态 + if (attempted > 0 && ok === attempted) { + await AppSettings.markFullRefetchDone(context); + } await SyncEngine.settleLocalEvents(context); await SyncEngine.pruneOrphanRows(context, accounts); // 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见