5 Commits
Author SHA1 Message Date
laoyang 29898d5dff 修复:改了日历本颜色,系统日历镜像颜色不跟变
根因:SyncEngine.refreshCalendarColors 每轮同步都 PROPFIND 服务器把新
calendar-color 写进**内存**里的账号对象,但从不 saveAll(唯一落盘点是
removeDeadBooks 的"服务器删本"分支)。而镜像 listBooks 是重新
AccountStore.loadAll 从磁盘读 → 拿到的永远是旧颜色 → ensureCalendar
setConfig(旧色) → 系统日历颜色永远不变。
用户在「账号设置」页看到的是**当场 PROPFIND** 的新颜色,所以表现为
"设置页颜色对、系统日历颜色错"。

修法:
1) refreshCalendarColors 返回 boolean(颜色/写权限是否有实际变化,
   含数组长度补齐);syncAccountInner 在有变化时立刻 loadAll+saveAll
   落盘(同 removeDeadBooks 的既有模式),镜像同轮即可读到新色;
2) SystemCalendarMirror.ensureCalendar 的 setConfig 失败由静默改为
   写日志(新建/复用两处),"颜色不变"从此可排查。

验证:assembleHap BUILD SUCCESSFUL;已 install -r 到真机。
2026-10-21 07:11:23 +08:00
laoyang c711cdb7d5 版本号 0.0.8 -> 0.0.9(versionCode 900) 2026-10-20 22:34:12 +08:00
laoyang 2f124eada0 镜像到系统日历:新增「全选/全不选」日历本
设置页镜像勾选网格上方加一行「全选(已选 x/N)」:
· 整行可点;勾选框半选时显示为关,点按 = 全选,再次点按 = 全不选;
· 全选:一次性把全部候选本(已排除"系统日历备份目标本"防回灌)写入
  mirror_book_ids —— preferences 只落盘一次、提醒缓存只失效一次,
  与逐个 toggle 的关键区别;
· 全不选:逐本 removeById 删对应系统日历账户(与取消单个勾选同语义),
  逐个独立 try,失败不影响其余;
· onChange 加同值守卫,过滤系统重复回调导致的二次翻转。

验证:assembleHap BUILD SUCCESSFUL;已 install -r 到真机。
2026-10-20 22:31:29 +08:00
laoyang 942f71bfdf 0.0.8 上架收尾:移除同步记录落盘 + 镜像空清单防护 + 前台默认间隔 15 分钟
三处改动(均来自 2026-10-16 真机日志实测):

1) 移除「同步记录」落盘
   · LogUtil 顶部新增 ENABLE_FILE_LOG=false(发行版只输出 console),
     真机排查时改 true 即可恢复落盘,所有调用点无需改动;
   · init() 里顺手 unlink 历史版本遗留的 sync.log / mirror_diag.txt,
     保证升级上来的设备沙箱也干净;
   · SystemCalendarMirror 删掉 dumpDiagnosis 整个方法(不再导出 mirror_diag.txt),
     连带去掉已无引用的 fs 依赖;
   · SettingsPage「清理残留镜像账户」提示语由"见同步日志"改为"见下方"(清单本就在页面上)。

2) 镜像防护:系统日历清单为 0 时跳过创建
   · 依据:2026-10-16 07:55:00 日志 `系统日历账户清单(共 0)` →
     `镜像完成:本=0 … 失败=12`,12 个 bookId 连续 `201 CreateCalendar failed!`。
   · 真因:设备重启后尚未解锁(direct boot),日历数据在加密存储里读不到,
     getAllCalendars() 返回空列表 ⇒ createCalendar 必然失败,还可能留下半创建账户。
   · 正常设备至少有一个本机日历,故"清单为 0"只可能是服务未就绪 ⇒ syncNow()
     在 plan/syncBook 之前直接跳过本轮,等解锁后的下一次同步自然恢复。

3) 前台自动同步默认间隔 1 分钟 → 15 分钟
   · 依据:2026-10-16 17:16–17:20 日志,5 分钟内连跑 5 轮
     "12 本全量比对 + 出站镜像 + 提醒刷新",随后
     `17:20:43 短时任务超时(配额用尽),后台同步将被挂起`
     —— 前台把短时任务配额烧光,反而拖垮了真正的后台同步通道。
   · 设备 preferences 里并无 sync_interval_minutes 键 ⇒ 走的就是这个默认值。
   · AppSettings.getSyncIntervalMinutes 默认值 + Index.startAutoSync 的
     setOrCreate 与 ?? 兜底 + SettingsPage 的 @State 初值,四处一并改为 15。
   · 用户仍可在设置页选 1/5/15/30 分钟 或 1 小时。

验证:assembleHap 与 assembleApp 均 BUILD SUCCESSFUL。
2026-10-16 19:38:21 +08:00
laoyang 24d8117ce7 v51c 卡片后台同步:修复"每轮全量重拉",单账号 236 秒 → 10 秒
真机实测根因:服务端 CalDav.php:253 用 preg_replace('/[^A-Za-z0-9._-]/','_',$uid)
派生资源文件名(UID movie-188@familybook → 资源 movie-188_familybook.ics),
而客户端 SyncEngine.syncAccountInner 拿 REPORT 给的**资源名当 uid** 去本地行里查
⇒ byUid 永远查不到 ⇒ 每轮判"变了" ⇒ 全量 GET。ETag 明明一样,比较压根执行不到。

改动:
· SyncEngine —— 加 byEtag 兜底(uid 查不到就用内容哈希认领本地行)+ 复用判定计数日志
  (uid 命中 / etag 兜底命中 / 需补拉);抽 groupPush() 去重。
· EventDb.applyRemote —— existing.find() 的 O(n·m) → keyed.get() 的 O(n)。
· BackgroundSyncRunner —— runOnce 每阶段计时(+Xms)+ pid;预算可由 preferences
  card_budget_ms 覆盖(诊断开关,默认走内置 9 秒)。
· AppSettings —— getCardBudgetMs()。
· EntryFormAbility —— onUpdateForm 日志带 pid。
· MirrorInbound —— 顺带修一个已死的功能:getEvents(undefined, 字段) 在 7.0.0.109 上
  必抛 401 type error!,导致**入站对账 12/12 全失败**;改成"先无参、失败再带字段"
  (照抄 SystemCalendarMirror.queryEvents 的绕法)。

真机验证(DEL-AL20,卡片 onUpdateForm 触发,卡片进程 pid=27411):
  [21:33:06] 卡片驱动同步:开始(可用窗口 9 秒)
  [21:33:09] 「观影」复用判定:uid 命中 0、etag 兜底命中 193、需补拉 0(共 193)
  [21:33:09] 「观影」落库完成:不变193,耗时 1 秒     ← 修复前 124 秒
  [21:33:17] 同步账号完成:拉取 2182 条,耗时 10 秒   ← 修复前 215~236 秒
  [21:33:22] 后台同步[cardTick] 完成:1/1 个账号,总耗时 16 秒
12 个日历本全部跑完 + 出站镜像,9 秒窗口不再"连第一个本都过不去"。

复算(真库快照 + 真服务器 PROPFIND,_tooling/_sc_etag_sim.py):
  旧逻辑补拉 1384 个 → 新逻辑 80 个(降 94.2%);观影 193→0、旅行 23→0、工作 362→5。
  另证服务端 ETag == md5(正文)、三次 PROPFIND 逐字节一致 ⇒ etag 兜底语义正确、非碰巧。
2026-10-15 21:39:54 +08:00
12 changed files with 316 additions and 188 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
"app": { "app": {
"bundleName": "synccalendar.yangyq.net", "bundleName": "synccalendar.yangyq.net",
"vendor": "yangyq", "vendor": "yangyq",
"versionCode": 800, "versionCode": 900,
"versionName": "0.0.8", "versionName": "0.0.9",
"buildVersion": "1", "buildVersion": "1",
"icon": "$media:layered_image", "icon": "$media:layered_image",
"label": "$string:app_name" "label": "$string:app_name"
+26 -4
View File
@@ -24,6 +24,9 @@ export class AppSettings {
// 服务卡片每 30 分钟被系统刷新一次(form_config 的 updateDuration=1), // 服务卡片每 30 分钟被系统刷新一次(form_config 的 updateDuration=1),
// 借这个时机顺带跑一次后台同步 —— 得到"约 30 分钟一次"的定期同步。 // 借这个时机顺带跑一次后台同步 —— 得到"约 30 分钟一次"的定期同步。
private static readonly KEY_CARD_SYNC_AT: string = 'card_sync_at'; private static readonly KEY_CARD_SYNC_AT: string = 'card_sync_at';
/** ⭐ 诊断开关:卡片驱动同步的时间预算(毫秒)。0/未设置 = 用内置默认(见 BackgroundSyncRunner)。
* 留给真机排查用 —— 不用重编译就能做"9 秒 vs 长窗口"对照实验。 */
private static readonly KEY_CARD_BUDGET_MS: string = 'card_budget_ms';
private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成 private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成
private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图 private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图
private static readonly KEY_DISPLAY_STYLE: string = 'display_style'; // 日程显示方式:'timeline' | 'list' private static readonly KEY_DISPLAY_STYLE: string = 'display_style'; // 日程显示方式:'timeline' | 'list'
@@ -350,6 +353,17 @@ export class AppSettings {
} }
} }
/** ⭐ 诊断用:卡片驱动同步的时间预算(毫秒)。0 = 未设置(用内置默认值)。 */
static async getCardBudgetMs(context: common.Context): Promise<number> {
try {
const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE);
return await store.get(AppSettings.KEY_CARD_BUDGET_MS, 0) as number;
} catch (err) {
return 0;
}
}
/** /**
* 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。 * 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。
* 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。 * 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。
@@ -634,15 +648,23 @@ export class AppSettings {
} }
} }
/** 自动同步间隔(分钟),默认 1 分钟 */ /**
* 自动同步间隔(分钟),默认 15 分钟。
*
* ⭐ 0.0.8:默认值由 1 分钟改为 15 分钟。原因(真机日志 2026-10-16 17:16–17:20 实测):
* 1 分钟间隔会让「App 打开期间」几乎不间断地跑"12 个日历本全量比对 + 出站镜像 + 提醒刷新",
* 5 分钟内连跑 5 轮后触发系统提示「短时任务超时(配额用尽),后台同步将被挂起」——
* 反而把真正的后台同步通道一起拖垮。改为 15 分钟后,前台同步仍足够及时,且不再烧配额。
* 用户仍可在设置页自行改成 1 / 5 / 15 / 30 分钟 或 1 小时。
*/
static async getSyncIntervalMinutes(context: common.Context): Promise<number> { static async getSyncIntervalMinutes(context: common.Context): Promise<number> {
try { try {
const store: preferences.Preferences = const store: preferences.Preferences =
await preferences.getPreferences(context, AppSettings.STORE); await preferences.getPreferences(context, AppSettings.STORE);
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 1) as number; const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 15) as number;
return v > 0 ? v : 1; return v > 0 ? v : 15;
} catch (err) { } catch (err) {
return 1; return 15;
} }
} }
@@ -10,6 +10,7 @@
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { backgroundTaskManager } from '@kit.BackgroundTasksKit'; import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import process from '@ohos.process';
import { DavAccount, AccountStore, TYPE_CALDAV } from './AccountStore'; import { DavAccount, AccountStore, TYPE_CALDAV } from './AccountStore';
import { SyncEngine } from './SyncEngine'; import { SyncEngine } from './SyncEngine';
import { CardDataService } from './CardDataService'; import { CardDataService } from './CardDataService';
@@ -57,6 +58,15 @@ export class BackgroundSyncRunner {
/** 本次卡片同步申请的短时任务 requestId(0 = 未申请到) */ /** 本次卡片同步申请的短时任务 requestId(0 = 未申请到) */
private static cardTransientId: number = 0; private static cardTransientId: number = 0;
/** 当前进程 pid(取不到返回 -1)。用来判断卡片回调是否与主 App 同进程。 */
private static pidOf(): number {
try {
return process.pid;
} catch (err) {
return -1;
}
}
/** /**
* ⭐⭐ 由**服务卡片定时刷新**(`onUpdateForm`)驱动的后台同步(0.0.8)。 * ⭐⭐ 由**服务卡片定时刷新**(`onUpdateForm`)驱动的后台同步(0.0.8)。
* *
@@ -88,8 +98,16 @@ export class BackgroundSyncRunner {
// ⚠️ 配额:短时任务单日总配额 10 分钟、单次最长 3 分钟(低电量 1 分钟), // ⚠️ 配额:短时任务单日总配额 10 分钟、单次最长 3 分钟(低电量 1 分钟),
// 且**被挂起后才计时**(10 秒内跑完不消耗)→ 对我们很划算。 // 且**被挂起后才计时**(10 秒内跑完不消耗)→ 对我们很划算。
const windowMs: number = await BackgroundSyncRunner.acquireCardWindow(); const windowMs: number = await BackgroundSyncRunner.acquireCardWindow();
const budget: number = Math.min(windowMs, BackgroundSyncRunner.CARD_MAX_TOTAL_MS); let budget: number = Math.min(windowMs, BackgroundSyncRunner.CARD_MAX_TOTAL_MS);
LogUtil.write(`卡片驱动同步:开始(卡片 30 分钟定时刷新触发,可用窗口 ${Math.round(budget / 1000)} 秒)`); // ⭐ 诊断开关(preferences:card_budget_ms):>0 时**覆盖**上面算出的可用窗口。
// 用途:在真机上回答"卡片进程究竟能活多久""一次完整同步要多久",
// 从而决定卡片这条路该做多少事 —— 不必为此重新编译。
const overrideMs: number = await AppSettings.getCardBudgetMs(context);
if (overrideMs > 0) {
budget = overrideMs;
LogUtil.write(`卡片驱动同步:预算被诊断开关覆盖为 ${Math.round(budget / 1000)} 秒`);
}
LogUtil.write(`卡片驱动同步:开始(卡片 30 分钟定时刷新触发,pid=${BackgroundSyncRunner.pidOf()},可用窗口 ${Math.round(budget / 1000)} 秒)`);
// 软超时:预算内没跑完就主动放弃(幂等同步,中断不损坏数据,下轮会重试) // 软超时:预算内没跑完就主动放弃(幂等同步,中断不损坏数据,下轮会重试)
const worker: Promise<void> = const worker: Promise<void> =
BackgroundSyncRunner.runOnce(context, 'cardTick').then((): void => {}); BackgroundSyncRunner.runOnce(context, 'cardTick').then((): void => {});
@@ -164,8 +182,12 @@ export class BackgroundSyncRunner {
*/ */
static async runOnce(context: common.Context, tag: string): Promise<BackgroundSyncResult> { static async runOnce(context: common.Context, tag: string): Promise<BackgroundSyncResult> {
const r = new BackgroundSyncResult(); const r = new BackgroundSyncResult();
// ⭐ 阶段计时:后台同步被时间预算切断时,这些 `+Xms` 就是"钱花在哪了"的直接证据。
const t0: number = Date.now();
const el = (): number => Date.now() - t0;
try { try {
const accounts: DavAccount[] = await AccountStore.loadAll(context); const accounts: DavAccount[] = await AccountStore.loadAll(context);
LogUtil.write(`后台同步[${tag}] 阶段:载入账号 ${accounts.length} 个 +${el()}ms`);
if (accounts.length === 0) { if (accounts.length === 0) {
LogUtil.write(`后台同步[${tag}]:无账号,跳过`); LogUtil.write(`后台同步[${tag}]:无账号,跳过`);
return r; return r;
@@ -176,12 +198,14 @@ export class BackgroundSyncRunner {
} catch (err) { } catch (err) {
// 导入失败不影响正常同步 // 导入失败不影响正常同步
} }
LogUtil.write(`后台同步[${tag}] 阶段:系统日历导入 +${el()}ms`);
// 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前) // 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前)
try { try {
await MirrorInbound.reconcile(context); await MirrorInbound.reconcile(context);
} catch (err) { } catch (err) {
// 对账失败不影响正常同步 // 对账失败不影响正常同步
} }
LogUtil.write(`后台同步[${tag}] 阶段:入站对账 +${el()}ms`);
for (const acc of accounts) { for (const acc of accounts) {
if (acc.type !== TYPE_CALDAV) { if (acc.type !== TYPE_CALDAV) {
continue; continue;
@@ -196,13 +220,16 @@ export class BackgroundSyncRunner {
r.failed++; r.failed++;
LogUtil.write(`后台同步[${tag}] id=${acc.id} 失败: ${e.message}`); LogUtil.write(`后台同步[${tag}] id=${acc.id} 失败: ${e.message}`);
} }
LogUtil.write(`后台同步[${tag}] 阶段:账号 ${acc.id} 同步 +${el()}ms(ok=${r.ok} failed=${r.failed})`);
} }
// 全部账号同步成功才结束"一次性全量重拉"状态 // 全部账号同步成功才结束"一次性全量重拉"状态
if (r.attempted > 0 && r.ok === r.attempted) { if (r.attempted > 0 && r.ok === r.attempted) {
await AppSettings.markFullRefetchDone(context); await AppSettings.markFullRefetchDone(context);
} }
await SyncEngine.settleLocalEvents(context); await SyncEngine.settleLocalEvents(context);
LogUtil.write(`后台同步[${tag}] 阶段:本地收尾 +${el()}ms`);
await SyncEngine.pruneOrphanRows(context, accounts); await SyncEngine.pruneOrphanRows(context, accounts);
LogUtil.write(`后台同步[${tag}] 阶段:孤儿行清理 +${el()}ms`);
// ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。 // ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。
// 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失。 // 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失。
await AccountStore.saveAll(context, accounts); await AccountStore.saveAll(context, accounts);
@@ -215,6 +242,7 @@ export class BackgroundSyncRunner {
} catch (err) { } catch (err) {
// 镜像失败不影响同步 // 镜像失败不影响同步
} }
LogUtil.write(`后台同步[${tag}] 阶段:出站镜像 +${el()}ms`);
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见 // 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
try { try {
await CardDataService.pushToAllForms(context); await CardDataService.pushToAllForms(context);
@@ -222,10 +250,10 @@ export class BackgroundSyncRunner {
} catch (err) { } catch (err) {
// 卡片/提醒刷新失败不影响同步结果 // 卡片/提醒刷新失败不影响同步结果
} }
LogUtil.write(`后台同步[${tag}] 完成:${r.ok}/${r.attempted} 个账号`); LogUtil.write(`后台同步[${tag}] 完成:${r.ok}/${r.attempted} 个账号,总耗时 ${Math.round(el() / 1000)} 秒`);
} catch (err) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
LogUtil.write(`后台同步[${tag}] 流程异常: ${e.message}`); LogUtil.write(`后台同步[${tag}] 流程异常: ${e.message}(已耗时 ${Math.round(el() / 1000)} 秒)`);
} }
return r; return r;
} }
+19 -6
View File
@@ -425,7 +425,22 @@ export class EventDb {
rs.close(); rs.close();
} }
const remoteKeys: string[] = []; // ⭐ 2226(2026-10-15 提速):下方逐条要判"这条远端事件本地有没有对应行"。
// 原先对每条都 `existing.find(...)` ⇒ O(n·m)(工作本 735 行 → 54 万次比较,
// 每次还要现拼 `uid_startTime_recId` 字符串),2184 行全库累计近 100 万次。
// 卡片后台同步的时间预算只有 9 秒,这笔开销吃不起 → 预先建 Map,改成 O(1) 查。
// 语义与 `existing.find(x => !x.dirty && x.uid===r.uid && x.startTime===r.startTime
// && x.recurrenceId===r.recurrenceId)` 完全一致:dirty 行不入索引、同键取**最先**出现的那行。
const keyed: Map<string, LocalEvent> = new Map<string, LocalEvent>();
for (const e of existing) {
if (e.dirty) {
continue;
}
const k: string = `${e.uid}_${e.startTime}_${e.recurrenceId}`;
if (!keyed.has(k)) {
keyed.set(k, e);
}
}
// ⭐ 同一次解析里可能出现同一资源的多条相同 VEVENT(同 uid + 同 DTSTART)。 // ⭐ 同一次解析里可能出现同一资源的多条相同 VEVENT(同 uid + 同 DTSTART)。
// 旧版用循环前的 existing 快照判重,插入后不回写 → 第二条仍判为"新增"又插一行, // 旧版用循环前的 existing 快照判重,插入后不回写 → 第二条仍判为"新增"又插一行,
// 在本地库留下 href/etag/remote_path 完全相同的冗余行(2026-09-19 实测 10 组)。 // 在本地库留下 href/etag/remote_path 完全相同的冗余行(2026-09-19 实测 10 组)。
@@ -438,10 +453,7 @@ export class EventDb {
continue; // 同一资源内的重复条目 → 只保留一条 continue; // 同一资源内的重复条目 → 只保留一条
} }
seenKeys.add(key); seenKeys.add(key);
remoteKeys.push(key); const found: LocalEvent | undefined = keyed.get(key);
const found = existing.find((x: LocalEvent): boolean =>
!x.dirty && x.uid === r.uid && x.startTime === r.startTime
&& x.recurrenceId === r.recurrenceId);
if (found === undefined) { if (found === undefined) {
// 新增 // 新增
const e = new LocalEvent(); const e = new LocalEvent();
@@ -517,7 +529,8 @@ export class EventDb {
continue; continue;
} }
const localKey: string = `${local.uid}_${local.startTime}_${local.recurrenceId}`; const localKey: string = `${local.uid}_${local.startTime}_${local.recurrenceId}`;
if (!remoteKeys.includes(localKey)) { // ⭐ 同样从 O(m) 的数组 includes 换成 Set 查询(见上方 keyed 的说明)
if (!seenKeys.has(localKey)) {
const del = new relationalStore.RdbPredicates('events'); const del = new relationalStore.RdbPredicates('events');
del.equalTo('id', local.id); del.equalTo('id', local.id);
await store.delete(del); await store.delete(del);
+31 -4
View File
@@ -1,30 +1,57 @@
// entry/src/main/ets/common/LogUtil.ets // entry/src/main/ets/common/LogUtil.ets
// 同步日志:写入应用 filesDir/sync.log(含时间戳),超 512KB 自动截断保留后半。 // 日志工具:默认**只输出到 console(hilog)**,不落盘。
// 设置页「同步日志」卡片可查看/复制/清空,方便真机测试时诊断同步与提醒问题。 //
// ⭐ 0.0.8 起:发行版**不再写 sync.log**(原「同步记录」/诊断落盘功能已移除)。
// 需要真机排查同步/提醒问题时,把下面的 ENABLE_FILE_LOG 改成 true 重新编译即可恢复
// 落盘(含 512KB 自动截断保留后半),其余调用点一行都不用动。
// 关闭状态下会在 init 时顺手删掉历史版本遗留的 sync.log 与 mirror_diag.txt,
// 保证升级上来的设备沙箱也是干净的。
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { fileIo as fs } from '@kit.CoreFileKit'; import { fileIo as fs } from '@kit.CoreFileKit';
import { util } from '@kit.ArkTS'; import { util } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
export class LogUtil { export class LogUtil {
/** ⭐ 落盘开关:发行版置 false(仅 console);真机排查时改 true 恢复文件日志 */
private static readonly ENABLE_FILE_LOG: boolean = false;
private static dir: string = '';
private static path: string = ''; private static path: string = '';
private static readonly MAX_BYTES: number = 512 * 1024; private static readonly MAX_BYTES: number = 512 * 1024;
static init(context: common.Context): void { static init(context: common.Context): void {
try { try {
LogUtil.path = `${context.filesDir}/sync.log`; LogUtil.dir = context.filesDir;
LogUtil.path = `${LogUtil.dir}/sync.log`;
} catch (err) { } catch (err) {
LogUtil.dir = '';
LogUtil.path = ''; LogUtil.path = '';
} }
if (!LogUtil.ENABLE_FILE_LOG && LogUtil.dir !== '') {
LogUtil.removeLegacyDebugFiles();
}
}
/** 删除历史版本遗留的调试落盘文件(sync.log / mirror_diag.txt) */
private static removeLegacyDebugFiles(): void {
for (const name of ['sync.log', 'mirror_diag.txt']) {
try {
fs.unlinkSync(`${LogUtil.dir}/${name}`);
} catch (err) {
// 文件不存在属正常,忽略
}
}
} }
static logFilePath(): string { static logFilePath(): string {
return LogUtil.path; return LogUtil.path;
} }
/** 写一行日志:console + 文件(文件失败静默,不影响业务) */ /** 写一行日志:console + 文件(发行版不落盘;文件失败静默,不影响业务) */
static write(msg: string): void { static write(msg: string): void {
console.info(`SyncLog ${msg}`); console.info(`SyncLog ${msg}`);
if (!LogUtil.ENABLE_FILE_LOG) {
return;
}
if (LogUtil.path === '') { if (LogUtil.path === '') {
return; return;
} }
+20 -2
View File
@@ -82,6 +82,25 @@ export class MirrorInbound {
/* ==================== 单个日历本 ==================== */ /* ==================== 单个日历本 ==================== */
/**
* 取回该账户下已有日程。
*
* 🔴 2026-10-15 实测(DEL-AL20 / 7.0.0.109):`getEvents(undefined, 字段数组)` 在这个
* 系统版本上**必抛** `401 type error!`。而本文件原先正是这么调的 ⇒ "入站对账"对
* **每一个**日历本都失败(真机日志 12/12 全是 `401 type error!`),
* 整个"把系统日历里的改动回写 CalDAV"等于死的(还白费 12 次调用)。
* 出站镜像(SystemCalendarMirror.queryEvents)早就踩过同一个坑,改成"先无参、失败再带字段"。
* 这里沿用同一策略:无参调用返回的 Event 里 identifier 同样有值。
*/
private static async queryEvents(cal: calendarManager.Calendar): Promise<calendarManager.Event[]> {
try {
return await cal.getEvents();
} catch (err) {
LogUtil.write(`入站对账 getEvents() 失败,退回带字段:${(err as BusinessError).message}`);
return await cal.getEvents(undefined, MirrorInbound.QUERY_FIELDS);
}
}
private static async reconcileBook( private static async reconcileBook(
context: common.Context, context: common.Context,
cm: calendarManager.CalendarManager, cm: calendarManager.CalendarManager,
@@ -109,8 +128,7 @@ export class MirrorInbound {
} }
const snaps: Map<string, MirrorSnapshotRow> = await MirrorSnapshot.loadMap(context, bucket); const snaps: Map<string, MirrorSnapshotRow> = await MirrorSnapshot.loadMap(context, bucket);
const events: calendarManager.Event[] = await cal.getEvents( const events: calendarManager.Event[] = await MirrorInbound.queryEvents(cal);
undefined, MirrorInbound.QUERY_FIELDS);
// 分成两类:带我们 identifier 的(可判定改/删) vs 不是我们的(用户新建) // 分成两类:带我们 identifier 的(可判定改/删) vs 不是我们的(用户新建)
const mine: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>(); const mine: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
+69 -12
View File
@@ -122,7 +122,23 @@ export class SyncEngine {
private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> { private static async syncAccountInner(context: common.Context, acc: DavAccount): Promise<number> {
const auth: string = DavClient.authHeader(acc.username, acc.password); const auth: string = DavClient.authHeader(acc.username, acc.password);
// 0) 刷新服务器端日历本颜色(每次同步都校正) // 0) 刷新服务器端日历本颜色(每次同步都校正)
await SyncEngine.refreshCalendarColors(acc, auth); // ⭐ 有变化就立刻落盘:镜像 listBooks 从磁盘重新 loadAll,
// 不落盘的话镜像 setConfig 的永远是旧颜色(系统日历颜色不跟服务器变)。
const colorChanged: boolean = await SyncEngine.refreshCalendarColors(acc, auth);
if (colorChanged) {
try {
const all: DavAccount[] = await AccountStore.loadAll(context);
const idx: number = all.findIndex((a: DavAccount): boolean => a.id === acc.id);
if (idx >= 0) {
all[idx] = acc;
await AccountStore.saveAll(context, all);
LogUtil.write('日历本颜色/权限有变化,已落盘(镜像将使用新颜色)');
}
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`颜色/权限落盘失败(不影响同步): ${e.message}`);
}
}
// 1) 推送该账号日历本下的本地修改 // 1) 推送该账号日历本下的本地修改
await SyncEngine.pushDirtyForAccount(context, acc, auth); await SyncEngine.pushDirtyForAccount(context, acc, auth);
// 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。 // 2) 拉取远端变更:REPORT 仅取 etag;新增/变化的资源用 GET 补拉完整 ICS。
@@ -160,12 +176,12 @@ export class SyncEngine {
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag) // 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event'); const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
const byUid: Map<string, LocalEvent[]> = new Map(); const byUid: Map<string, LocalEvent[]> = new Map();
// ⭐ 再按 **etag** 分组,给下面"要不要 GET 这个资源"做兜底(见 §复用判定 注释)
const byEtag: Map<string, LocalEvent[]> = new Map();
for (const row of existingRows) { for (const row of existingRows) {
const arr: LocalEvent[] | undefined = byUid.get(row.uid); SyncEngine.groupPush(byUid, row.uid, row);
if (arr === undefined) { if (row.etag !== '') {
byUid.set(row.uid, [row]); SyncEngine.groupPush(byEtag, row.etag, row);
} else {
arr.push(row);
} }
} }
const originMatch = /https?:\/\/[^/]+/i.exec(href); const originMatch = /https?:\/\/[^/]+/i.exec(href);
@@ -174,9 +190,27 @@ export class SyncEngine {
let fetched: number = 0; let fetched: number = 0;
let parseFail: number = 0; let parseFail: number = 0;
const needFetch: RemoteItem[] = []; const needFetch: RemoteItem[] = [];
let hitUid: number = 0; // 按"资源名 = uid"命中
let hitEtag: number = 0; // 按 etag 内容哈希兜底命中
for (const it of items) { for (const it of items) {
const resUid: string = SyncEngine.resUidFromHref(it.href); const resUid: string = SyncEngine.resUidFromHref(it.href);
const group: LocalEvent[] | undefined = byUid.get(resUid); let group: LocalEvent[] | undefined = byUid.get(resUid);
if (group !== undefined) {
hitUid++;
} else if (it.etag !== '') {
// ⭐ 复用判定(2026-10-15 修复):**服务器不保证"资源文件名 = ICS 里的 UID"**。
// 家书 CalDav.php:253 用 preg_replace('/[^A-Za-z0-9._-]/', '_', $uid) 生成文件名:
// UID `movie-188@familybook` 的资源叫 `movie-188_familybook.ics`。
// 旧版只按资源名去 `byUid` 查 → 永远查不到 → 该资源**每轮都被判成"变了"**,
// 于是每次都全量 GET(实测 2184 个资源 → 单次同步 200~300 秒:卡片后台同步
// 那 9 秒窗口连一个日历本都跑不完)。
// etag 是**内容哈希**(服务端由正文 md5 得出):相等即内容一致,
// 与"资源叫什么名字"无关 —— 用它兜底既安全又与服务器实现解耦。
group = byEtag.get(it.etag);
if (group !== undefined) {
hitEtag++;
}
}
if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty
&& group[0].etag === it.etag) { && group[0].etag === it.etag) {
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段) // etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
@@ -187,6 +221,7 @@ export class SyncEngine {
} }
needFetch.push(it); needFetch.push(it);
} }
LogUtil.write(`日历本[${i}]「${calName}」复用判定:uid 命中 ${hitUid}、etag 兜底命中 ${hitEtag}、需补拉 ${needFetch.length}(共 ${items.length})`);
// 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时 // 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时
const batchSize: number = 5; const batchSize: number = 5;
for (let b: number = 0; b < needFetch.length; b += batchSize) { for (let b: number = 0; b < needFetch.length; b += batchSize) {
@@ -344,8 +379,13 @@ export class SyncEngine {
/** /**
* 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors * 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
* 失败静默(颜色不影响数据正确性) * 失败静默(颜色不影响数据正确性)
* ⭐ 返回**是否有实际变化**(颜色或写权限)——调用方据此落盘。
* (0.0.9 修复:此前变化只留在内存、从不 saveAll ⇒ 镜像 listBooks 从磁盘读到的
* 永远是旧颜色 ⇒ setConfig 旧色 ⇒ 系统日历颜色永远不跟服务器变;
* 而账号设置页是当场 PROPFIND 的,所以"页面上是新色、系统日历是旧色"。)
*/ */
static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<void> { static async refreshCalendarColors(acc: DavAccount, auth: string): Promise<boolean> {
let changed: boolean = false;
let entries: DavColorEntry[] = []; let entries: DavColorEntry[] = [];
try { try {
entries = await DavClient.propfindColors(acc.serverUrl, auth); entries = await DavClient.propfindColors(acc.serverUrl, auth);
@@ -363,9 +403,11 @@ export class SyncEngine {
if (found !== undefined && found.color !== '') { if (found !== undefined && found.color !== '') {
while (acc.calendarColors.length <= i) { while (acc.calendarColors.length <= i) {
acc.calendarColors.push(''); acc.calendarColors.push('');
changed = true; // 长度补齐也视作变化(旧账号可能还没这一位)
} }
if (acc.calendarColors[i] !== found.color) { if (acc.calendarColors[i] !== found.color) {
LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`); LogUtil.write(`日历本[${i}] 颜色更新:${acc.calendarColors[i] === '' ? '(空)' : acc.calendarColors[i]} → ${found.color}`);
changed = true;
} }
acc.calendarColors[i] = found.color; acc.calendarColors[i] = found.color;
} else if (found === undefined) { } else if (found === undefined) {
@@ -377,16 +419,32 @@ export class SyncEngine {
acc.calendarWritable.push('1'); acc.calendarWritable.push('1');
} }
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`; const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
let writable: string;
if (found !== undefined && found.privilegeKnown) { if (found !== undefined && found.privilegeKnown) {
// 服务器声明了权限:直接采用 // 服务器声明了权限:直接采用
acc.calendarWritable[i] = found.writable ? '1' : '0'; writable = found.writable ? '1' : '0';
LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`); LogUtil.write(`日历本[${i}]「${calName}」写权限(privilege):${found.writable ? '可写' : '只读'}`);
} else { } else {
// 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测 // 服务器未声明权限(如部分 Synology 配置)或 PROPFIND 未匹配到该本:真实写探测
const w: boolean = await DavClient.probeWritable(target, auth); const w: boolean = await DavClient.probeWritable(target, auth);
acc.calendarWritable[i] = w ? '1' : '0'; writable = w ? '1' : '0';
LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`); LogUtil.write(`日历本[${i}]「${calName}」写权限(探测):${w ? '可写' : '只读'}`);
} }
if (acc.calendarWritable[i] !== writable) {
changed = true;
}
acc.calendarWritable[i] = writable;
}
return changed;
}
/** Map<key, 行数组> 追加一行(没有该 key 就建数组) */
private static groupPush(m: Map<string, LocalEvent[]>, key: string, row: LocalEvent): void {
const arr: LocalEvent[] | undefined = m.get(key);
if (arr === undefined) {
m.set(key, [row]);
} else {
arr.push(row);
} }
} }
@@ -401,8 +459,7 @@ export class SyncEngine {
} }
/** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */ /** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */
private static resUidFromHref(href: string): string { private static resUidFromHref(href: string): string { const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
if (segs.length === 0) { if (segs.length === 0) {
return String(Date.now()); return String(Date.now());
} }
@@ -37,7 +37,6 @@
import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit'; import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { calendarManager } from '@kit.CalendarKit'; import { calendarManager } from '@kit.CalendarKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { AccountStore, BookPalette, DavAccount } from './AccountStore'; import { AccountStore, BookPalette, DavAccount } from './AccountStore';
import { EventDb, LocalEvent } from './EventDb'; import { EventDb, LocalEvent } from './EventDb';
import { IcsTime, IcsUtil } from './IcsUtil'; import { IcsTime, IcsUtil } from './IcsUtil';
@@ -594,16 +593,31 @@ export class SystemCalendarMirror {
LogUtil.write( LogUtil.write(
`镜像前清理:删除 ${purge.removed} 个孤儿镜像账户,失败 ${purge.failed} 个,清理后仍剩余 ${purge.remaining} 个`); `镜像前清理:删除 ${purge.removed} 个孤儿镜像账户,失败 ${purge.failed} 个,清理后仍剩余 ${purge.remaining} 个`);
// ⭐ 0.0.8 防护:设备「重启后尚未解锁」时(direct boot),日历数据在加密存储里读不到,
// getAllCalendars() 会返回空列表,此时 createCalendar 必然失败——真机实测表现为
// 12 个镜像账户连续 `201 CreateCalendar failed!`(2026-10-16 07:55),还可能留下半创建账户。
// 正常设备至少有一个本机日历,所以「清单为 0」只可能是日历服务未就绪 ⇒ 本轮直接跳过创建,
// 等下一次同步(解锁后)自然恢复。
let calCount: number = 0;
try {
calCount = (await cm.getAllCalendars()).length;
} catch (err) {
LogUtil.write(`镜像跳过:读不到系统日历账户列表 ${(err as BusinessError).message}`);
return res;
}
if (calCount === 0) {
LogUtil.write('镜像跳过:系统日历账户清单为 0(日历服务未就绪,如设备重启后尚未解锁),本轮不创建镜像账户');
return res;
}
const winStart: number = SystemCalendarMirror.windowStart(); const winStart: number = SystemCalendarMirror.windowStart();
const winEnd: number = SystemCalendarMirror.windowEnd(); const winEnd: number = SystemCalendarMirror.windowEnd();
// ⭐ 0.0.8:读取用户所选的显示名格式(仅影响**新建**账户的名字;已有账户不在此处改名) // ⭐ 0.0.8:读取用户所选的显示名格式(仅影响**新建**账户的名字;已有账户不在此处改名)
const format: number = await AppSettings.getMirrorDisplayFormat(context); const format: number = await AppSettings.getMirrorDisplayFormat(context);
const plans: Map<string, MirrorOcc[]> = new Map<string, MirrorOcc[]>();
for (const ref of refs) { for (const ref of refs) {
try { try {
const plan: MirrorOcc[] = const plan: MirrorOcc[] =
await SystemCalendarMirror.planFor(context, ref, winStart, winEnd, res); await SystemCalendarMirror.planFor(context, ref, winStart, winEnd, res);
plans.set(ref.bookId, plan);
await SystemCalendarMirror.syncBook(context, cm, ref, plan, winStart, winEnd, res, format); await SystemCalendarMirror.syncBook(context, cm, ref, plan, winStart, winEnd, res, format);
res.books++; res.books++;
} catch (err) { } catch (err) {
@@ -612,8 +626,7 @@ export class SystemCalendarMirror {
LogUtil.write(`镜像失败 bookId=${ref.bookId}: ${e.code ?? ''} ${e.message}`); LogUtil.write(`镜像失败 bookId=${ref.bookId}: ${e.code ?? ''} ${e.message}`);
} }
} }
// ⭐ 每次镜像后自动导出一份对照诊断,便于"系统日历里有、App 里没有"这类问题逐条比对 // ⭐ 0.0.8:不再导出 mirror_diag.txt(诊断落盘功能已随「同步记录」一并移除)
await SystemCalendarMirror.dumpDiagnosis(context, cm, refs, plans, winStart, winEnd);
LogUtil.write( LogUtil.write(
`镜像完成:本=${res.books} 新增=${res.added} 更新=${res.updated} 删除=${res.deleted} 失败=${res.failed} ` + `镜像完成:本=${res.books} 新增=${res.added} 更新=${res.updated} 删除=${res.deleted} 失败=${res.failed} ` +
`跳过=${res.skipped}(窗口外单点=${res.winOut} 已删除=${res.markDel} 系统备份=${res.syscal})` + `跳过=${res.skipped}(窗口外单点=${res.winOut} 已删除=${res.markDel} 系统备份=${res.syscal})` +
@@ -854,7 +867,7 @@ export class SystemCalendarMirror {
/** /**
* ⭐ 算出"某个日历本在窗口内应该有哪些发生" —— **唯一的口径来源**。 * ⭐ 算出"某个日历本在窗口内应该有哪些发生" —— **唯一的口径来源**。
* 实际写入(syncBook)与诊断导出(dumpDiagnosis)都用它,保证诊断看到的就是真正写进去的。 * 实际写入(syncBook)以它为准,保证"计划"与"写入"完全一致。
*/ */
private static async planFor( private static async planFor(
context: common.Context, context: common.Context,
@@ -1015,145 +1028,6 @@ export class SystemCalendarMirror {
`窗口=${SystemCalendarMirror.fmt(winStart)}~${SystemCalendarMirror.fmt(winEnd)}`); `窗口=${SystemCalendarMirror.fmt(winStart)}~${SystemCalendarMirror.fmt(winEnd)}`);
} }
/**
* ⭐ 诊断导出:把"App 侧计划写入"与"系统日历里实际存在"的完整对照写到
* `${filesDir}/mirror_diag.txt`(可用 hdc 直接取出分析)。
*
* 为什么需要它:用户反馈"系统日历里有、App 里没有"这种问题靠描述很难定位,
* 必须把两侧的清单原样摆在一起逐条比。每次镜像后自动重写一份。
*/
private static async dumpDiagnosis(
context: common.Context,
cm: calendarManager.CalendarManager,
refs: BookRef[],
plans: Map<string, MirrorOcc[]>,
winStart: number,
winEnd: number
): Promise<void> {
try {
const lines: string[] = [];
lines.push(`镜像诊断 ${new Date().toLocaleString()}`);
lines.push(`窗口 ${SystemCalendarMirror.fmt(winStart)} ~ ${SystemCalendarMirror.fmt(winEnd)} ` +
`(${winStart} ~ ${winEnd})`);
const cals: calendarManager.Calendar[] = await cm.getAllCalendars();
// ⭐ 全设备日历账户清单:一眼看出有没有"本应用建的、但不属于当前选中本"的残留账户
// (历史 calKey 命名的账户、重复账户)。每次镜像都导出一份,省得再靠按钮抓。
const keepNames: Set<string> = new Set<string>();
for (const r of refs) {
keepNames.add(r.sysAccountName);
}
let mineN: number = 0;
let keepN: number = 0;
lines.push('');
lines.push(`[全设备日历账户] 共 ${cals.length} 个日历本:`);
for (const c of cals) {
let accName: string = '';
let disp: string = '';
let n: number = -1;
try {
accName = c.getAccount().name;
disp = c.getAccount().displayName ?? '';
} catch (err) {
// 忽略
}
try {
n = (await SystemCalendarMirror.queryEvents(c)).length;
} catch (err) {
// 忽略
}
let tag: string = 'other';
if (accName.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX)) {
if (keepNames.has(accName)) {
tag = 'KEEP';
keepN++;
} else {
tag = 'MINE(残留)';
mineN++;
}
}
lines.push(` [${tag}] #${c.id} acc=${accName} display=${disp} 条目=${n}`);
}
lines.push(` 小结:本应用账户 ${keepN + mineN} 个(本次保留 ${keepN},残留 ${mineN})`);
for (const ref of refs) {
const plan: MirrorOcc[] | undefined = plans.get(ref.bookId);
const list: MirrorOcc[] = plan !== undefined ? plan : [];
let cal: calendarManager.Calendar | undefined = undefined;
for (const c of cals) {
if (c.getAccount().name === ref.sysAccountName) {
cal = c;
break;
}
}
lines.push('');
lines.push(`[本] ${ref.name} | bookId=${ref.bookId} | calKey=${ref.calKey} | 账户=${ref.sysAccountName} | 账户存在=${cal !== undefined}`);
if (cal === undefined) {
continue;
}
const sysEvents: calendarManager.Event[] = await SystemCalendarMirror.queryEvents(cal);
const sysMap: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
for (const ev of sysEvents) {
const id: string = ev.identifier ?? '';
if (id !== '') {
sysMap.set(id, ev);
}
}
lines.push(` 计划写入=${list.length} 系统日历实际=${sysEvents.length}(其中带 identifier=${sysMap.size})`);
// ① 系统日历里有、计划里没有 → 多出来的(正常会被删掉;删不掉就是残留)
const planIds: Set<string> = new Set<string>();
for (const o of list) {
planIds.add(o.identifier);
}
const extra: string[] = [];
for (const id of sysMap.keys()) {
if (planIds.has(id)) {
continue;
}
const ev: calendarManager.Event | undefined = sysMap.get(id);
if (ev === undefined) {
continue;
}
extra.push(` ${SystemCalendarMirror.stamp(ev.startTime)} ${ev.title ?? ''} [${id}]`);
}
lines.push(` ① 系统多出(${extra.length}):`);
for (let i: number = 0; i < extra.length && i < 120; i++) {
lines.push(extra[i]);
}
// ② 计划里有、系统日历没有 → 漏写
const miss: string[] = [];
for (const o of list) {
if (!sysMap.has(o.identifier)) {
miss.push(` ${SystemCalendarMirror.stamp(o.start)} ${o.title} [${o.identifier}]`);
}
}
lines.push(` ② 漏写(${miss.length}):`);
for (let i: number = 0; i < miss.length && i < 60; i++) {
lines.push(miss[i]);
}
// ③ 系统日历里该账户的**全部**条目(供逐条核对,最多 400 行)
lines.push(` ③ 系统日历条目(${sysEvents.length},最多列 400):`);
const sorted: calendarManager.Event[] = sysEvents.slice().sort(
(a: calendarManager.Event, b: calendarManager.Event): number => a.startTime - b.startTime);
for (let i: number = 0; i < sorted.length && i < 400; i++) {
const ev: calendarManager.Event = sorted[i];
lines.push(` ${SystemCalendarMirror.stamp(ev.startTime)}~${SystemCalendarMirror.stamp(ev.endTime)} ` +
`${ev.isAllDay === true ? '[全天]' : ''}${ev.title ?? ''} [${ev.identifier ?? ''}]`);
}
}
const path: string = `${context.filesDir}/mirror_diag.txt`;
const text: string = lines.join('\n') + '\n';
const file: fs.File = fs.openSync(path,
fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
fs.writeSync(file.fd, text);
fs.closeSync(file.fd);
LogUtil.write(`镜像诊断已导出:${path}(${lines.length} 行)`);
} catch (err) {
LogUtil.write(`导出镜像诊断失败: ${(err as BusinessError).message}`);
}
}
/** 时间戳 → `MM-DD HH:mm` */ /** 时间戳 → `MM-DD HH:mm` */
private static stamp(ts: number): string { private static stamp(ts: number): string {
const d: Date = new Date(ts); const d: Date = new Date(ts);
@@ -1272,7 +1146,9 @@ export class SystemCalendarMirror {
try { try {
await cal.setConfig({ enableReminder: true, color: ref.color }); await cal.setConfig({ enableReminder: true, color: ref.color });
} catch (err) { } catch (err) {
// 设色失败不影响镜像本身 // 设色失败不影响镜像本身,但要留痕(否则"颜色不变"无从排查)
const e = err as BusinessError;
LogUtil.write(`镜像账户设色失败 ${accountName}: ${e.message}`);
} }
return cal; return cal;
} }
@@ -1285,7 +1161,8 @@ export class SystemCalendarMirror {
try { try {
await cal.setConfig({ enableReminder: true, color: ref.color }); await cal.setConfig({ enableReminder: true, color: ref.color });
} catch (err) { } catch (err) {
// 同上 const e = err as BusinessError;
LogUtil.write(`镜像账户设色失败(新建) ${accountName}: ${e.message}`);
} }
return cal; return cal;
} }
@@ -11,7 +11,7 @@ const DOMAIN = 0x0000;
export default class EntryAbility extends UIAbility { export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
// 文件日志初始化:所有页面的同步日志统一写入 filesDir/sync.log // 日志初始化:发行版只输出 console(不再落盘 sync.log),同时清理历史遗留调试文件
LogUtil.init(this.context); LogUtil.init(this.context);
LogUtil.write('---- App 启动 ----'); LogUtil.write('---- App 启动 ----');
// 后台同步:注册延迟任务兜底(2 小时)+ 网络恢复补同步监听 // 后台同步:注册延迟任务兜底(2 小时)+ 网络恢复补同步监听
@@ -3,11 +3,21 @@
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit'; import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
import { Want } from '@kit.AbilityKit'; import { Want } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import process from '@ohos.process';
import { CardDataService, CardData } from '../common/CardDataService'; import { CardDataService, CardData } from '../common/CardDataService';
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner'; import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
import { LogUtil } from '../common/LogUtil'; import { LogUtil } from '../common/LogUtil';
export default class EntryFormAbility extends FormExtensionAbility { export default class EntryFormAbility extends FormExtensionAbility {
/** 当前进程 pid(取不到返回 -1):用于判断卡片回调是否与主 App 同进程 */
private static pidOf(): number {
try {
return process.pid;
} catch (err) {
return -1;
}
}
/** 组装更新数据并推送到指定卡片 */ /** 组装更新数据并推送到指定卡片 */
private pushData(formId: string, dayOffset: number = 0): void { private pushData(formId: string, dayOffset: number = 0): void {
CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => { CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => {
@@ -41,7 +51,7 @@ export default class EntryFormAbility extends FormExtensionAbility {
onUpdateForm(formId: string): void { onUpdateForm(formId: string): void {
LogUtil.init(this.context); LogUtil.init(this.context);
LogUtil.write(`系统触发卡片更新: formId=${formId}`); LogUtil.write(`系统触发卡片更新: formId=${formId}(进程 pid=${EntryFormAbility.pidOf()})`);
CardDataService.registerForm(this.context, formId); CardDataService.registerForm(this.context, formId);
// ⭐ ① 先用**本地缓存**立刻刷新卡片(几毫秒,不依赖网络) // ⭐ ① 先用**本地缓存**立刻刷新卡片(几毫秒,不依赖网络)
// 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天) // 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天)
+3 -2
View File
@@ -399,7 +399,8 @@ struct Index {
/** 自动同步:每 15 秒检查一次,到达设置的间隔(AppStorage.syncIntervalMinutes)才真正同步 */ /** 自动同步:每 15 秒检查一次,到达设置的间隔(AppStorage.syncIntervalMinutes)才真正同步 */
private startAutoSync(): void { private startAutoSync(): void {
if (this.autoSyncTimer === -1) { if (this.autoSyncTimer === -1) {
AppStorage.setOrCreate('syncIntervalMinutes', 1); // ⭐ 0.0.8:默认间隔 1 分钟 → 15 分钟(见 AppSettings.getSyncIntervalMinutes 注释)
AppStorage.setOrCreate('syncIntervalMinutes', 15);
const ctx = this.getUIContext().getHostContext(); const ctx = this.getUIContext().getHostContext();
if (ctx !== undefined) { if (ctx !== undefined) {
AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => { AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => {
@@ -412,7 +413,7 @@ struct Index {
if (tickCtx !== undefined && !this.syncing) { if (tickCtx !== undefined && !this.syncing) {
ReminderService.tickReminders(tickCtx as common.UIAbilityContext); ReminderService.tickReminders(tickCtx as common.UIAbilityContext);
} }
const minutes: number = AppStorage.get<number>('syncIntervalMinutes') ?? 1; const minutes: number = AppStorage.get<number>('syncIntervalMinutes') ?? 15;
if (Date.now() - this.lastSyncTime < minutes * 60000) { if (Date.now() - this.lastSyncTime < minutes * 60000) {
return; // 未到设置的同步间隔 return; // 未到设置的同步间隔
} }
+78 -3
View File
@@ -28,7 +28,7 @@ class BackupTarget {
@Component @Component
struct SettingsPage { struct SettingsPage {
@State showSystem: boolean = true; @State showSystem: boolean = true;
@State intervalMinutes: number = 1; @State intervalMinutes: number = 15; // ⭐ 0.0.8:默认 15 分钟(原 1 分钟会烧光短时任务配额)
@State backgroundSync: boolean = false; @State backgroundSync: boolean = false;
@State sysMode: string = 'display'; // display | backup @State sysMode: string = 'display'; // display | backup
@State backupKey: string = ''; // ⭐ 备份目标本的**稳定标识** bookId(不再用会漂移的 calKey) @State backupKey: string = ''; // ⭐ 备份目标本的**稳定标识** bookId(不再用会漂移的 calKey)
@@ -293,6 +293,51 @@ struct SettingsPage {
return this.allBooks.length - this.mirrorCandidates.length; return this.allBooks.length - this.mirrorCandidates.length;
} }
/** 已勾选的可镜像本数量(mirrorKeys 里可能残留已不在候选里的 bookId,按候选口径统计) */
private mirrorSelectedCount(): number {
return this.mirrorCandidates.filter((b: BackupTarget): boolean =>
this.mirrorKeys.indexOf(b.bookId) >= 0).length;
}
/**
* 「全选 / 全不选」要镜像的日历本。
* ⭐ 批量落盘只写一次 preferences、缓存只失效一次(与逐个 toggle 的关键区别);
* 全不选时逐本删除对应系统日历账户(与取消单个勾选同一语义)。
* 半选状态下点按 = 全选。
*/
private async toggleMirrorAll(): Promise<void> {
if (this.context === undefined || this.mirrorCandidates.length === 0) {
return;
}
const selectAll: boolean = this.mirrorSelectedCount() < this.mirrorCandidates.length;
if (selectAll) {
const ids: string[] = this.mirrorCandidates.map((b: BackupTarget): string => b.bookId);
this.mirrorKeys = ids;
await AppSettings.setMirrorBookIds(this.context, ids);
this.getUIContext().getPromptAction().showToast({
message: `已全选 ${ids.length} 个日历本,点「立即执行镜像」写入系统日历`
});
} else {
// 逐本删除系统日历账户;逐个独立 try,失败不影响其余
for (const k of this.mirrorKeys) {
try {
await SystemCalendarMirror.removeById(this.context, k);
} catch (err) {
const e = err as BusinessError;
console.error(`全不选:删除镜像账户失败 bookId=${k}: ${e.message}`);
}
}
this.mirrorKeys = [];
await AppSettings.setMirrorBookIds(this.context, []);
this.getUIContext().getPromptAction().showToast({
message: '已取消全部勾选,并清除对应的系统日历账户'
});
}
// ⭐ 勾选本集合变化 → 提醒侧"已镜像本"缓存失效并重排提醒
ReminderService.invalidateMirrorCache();
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
}
/** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */ /** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */
private isBookReadonly(b: BackupTarget): boolean { private isBookReadonly(b: BackupTarget): boolean {
return !b.serverWritable || this.manualKeys.includes(b.calKey); return !b.serverWritable || this.manualKeys.includes(b.calKey);
@@ -604,7 +649,7 @@ struct SettingsPage {
/** /**
* 「清理残留镜像账户(诊断)」按钮: * 「清理残留镜像账户(诊断)」按钮:
* ① 把系统日历里**全部**账户(含 id / name / displayName / type)写进同步日志; * ① 读系统日历里**全部**账户(含 id / name / displayName / type)并显示在下方清单里;
* ② 把"我们建的、但不属于当前选中本"的账户全部删掉(逐个独立 try,失败也继续); * ② 把"我们建的、但不属于当前选中本"的账户全部删掉(逐个独立 try,失败也继续);
* ③ Toast 汇总成功/失败/残留数量,残留账户名直接显示出来。 * ③ Toast 汇总成功/失败/残留数量,残留账户名直接显示出来。
*/ */
@@ -623,7 +668,8 @@ struct SettingsPage {
if (r.remaining > 0) { if (r.remaining > 0) {
msg += `;仍残留 ${r.remaining} 个:${r.remainingNames.join(' , ')}`; msg += `;仍残留 ${r.remaining} 个:${r.remainingNames.join(' , ')}`;
} }
msg += '(完整账户清单见同步日志)'; // ⭐ 0.0.8:不再写同步日志 → 提示语改为指向页面下方那份清单(accountDump)
msg += '(完整账户清单见下方)';
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 6000 }); this.getUIContext().getPromptAction().showToast({ message: msg, duration: 6000 });
} catch (err) { } catch (err) {
const e = err as BusinessError; const e = err as BusinessError;
@@ -977,6 +1023,35 @@ struct SettingsPage {
.fontColor($r('app.color.text_hint')) .fontColor($r('app.color.text_hint'))
.width('100%') .width('100%')
} }
// ⭐ 全选 / 全不选:半选状态下点按 = 全选
Row({ space: 6 }) {
Text(`全选(已选 ${this.mirrorSelectedCount()}/${this.mirrorCandidates.length})`)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
Toggle({
type: ToggleType.Checkbox,
isOn: this.mirrorCandidates.length > 0
&& this.mirrorSelectedCount() === this.mirrorCandidates.length
})
.selectedColor($r('app.color.brand'))
.onChange((on: boolean): void => {
// 半选时勾选框显示为关,onChange(on=true) 走全选;全选时取消走全不选
if (on === (this.mirrorSelectedCount() === this.mirrorCandidates.length)) {
return; // 状态未变化(个别系统会重复回调),忽略
}
this.toggleMirrorAll();
})
}
.width('100%')
.padding({ top: 2, bottom: 2 })
.onClick(() => {
// 整行可点:直接按"是否已全选"翻转,比依赖 Checkbox 的 onChange 更可靠
this.toggleMirrorAll();
})
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.mirrorCandidates, (b: BackupTarget): void => { ForEach(this.mirrorCandidates, (b: BackupTarget): void => {
Row({ space: 6 }) { Row({ space: 6 }) {