Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32162b8f04 | ||
|
|
b83ce83d43 | ||
|
|
d0b8720c41 | ||
|
|
8b5519bc31 | ||
|
|
2f4657ced3 | ||
|
|
736a0a6587 | ||
|
|
165740fe9e | ||
|
|
69a4a86a04 | ||
|
|
d7c49db304 | ||
|
|
353473127a |
+2
-2
@@ -2,8 +2,8 @@
|
||||
"app": {
|
||||
"bundleName": "synccalendar.yangyq.net",
|
||||
"vendor": "yangyq",
|
||||
"versionCode": 100,
|
||||
"versionName": "0.0.1",
|
||||
"versionCode": 400,
|
||||
"versionName": "0.0.4",
|
||||
"buildVersion": "1",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:app_name"
|
||||
|
||||
@@ -1,48 +1,179 @@
|
||||
# SyncCalendar(同步日历)
|
||||
|
||||
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
|
||||
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale、Baikal 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
|
||||
|
||||
- 应用市场送审版本:**0.0.2** | 仓库当前:**0.0.3**(versionCode 300)
|
||||
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://synccalendar.yangyq.net/feedback/>
|
||||
- 开源协议:Apache License 2.0
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
### CalDAV 同步
|
||||
- 支持 CalDAV 协议的完整同步:PROPFIND 发现日历本、REPORT 全量拉取、ETag 增量比对、PUT/DELETE 双向推送
|
||||
- 一个账号可挂载多个日历本,按需勾选同步范围,支持在线编辑账号、重选日历本、删除账号
|
||||
- 全链路同步引擎:拉取 → 解析(VEVENT / VTODO / VALARM / RRULE / EXDATE / RECURRENCE-ID)→ 本地库落库 → 推送变更
|
||||
|
||||
### 日历视图
|
||||
- **月 / 周 / 日 / 列表 / 待办** 五种视图自由切换
|
||||
- 月视图支持左右滑动翻月、农历显示(初一显示月名)、点击查看当日日程
|
||||
- 重复日程(RRULE)按规则完整展开,支持 DAILY / WEEKLY / MONTHLY / YEARLY 及 INTERVAL、BYDAY、UNTIL、EXDATE 等
|
||||
- 列表视图按天分组、今日置顶,配合 List 虚拟化保证流畅滚动
|
||||
- VTODO 待办独立展示,未完成 / 已完成分组,勾选框颜色跟随日历本
|
||||
- 完整协议链路:**PROPFIND** 发现日历本 → **REPORT** 全量拉取 → **ETag** 增量比对 → **PUT / DELETE** 双向推送
|
||||
- 一个账号可挂载多个日历本,按需勾选同步范围;支持在线编辑账号、重选日历本、删除账号
|
||||
- 解析 **VEVENT / VTODO / VALARM / RRULE / EXDATE / RECURRENCE-ID**,重复日程的**单次覆盖实例**也能正确还原
|
||||
- 支持 VTODO 待办同步(只读展示,不参与推送)
|
||||
- 「重建日程数据」可强制整表重拉,用于修复提醒丢失等本地数据问题
|
||||
|
||||
### 日历视图与交互
|
||||
|
||||
- **月 / 周 / 列表** 三种视图(顶部标签切换,也支持左右滑动手势);默认视图可在设置里指定
|
||||
- 月视图三页 Swiper 左右翻月、显示**农历**(初一显示月名),点击查看当日日程
|
||||
- 周视图支持按天左右滑动切换日期,跨月自动重载
|
||||
- 列表视图按天分组、今日置顶,配合 List 虚拟化保证长列表流畅
|
||||
- 日程显示方式两种:**竖向时间轴**(按时间刻度看一天的占用与冲突,带当前时间红线)/**列表**(快速扫读,适合长标题)
|
||||
- 重复日程(RRULE)按规则完整展开:DAILY / WEEKLY / MONTHLY / YEARLY 及 INTERVAL、BYDAY、UNTIL、EXDATE 等
|
||||
- 可选**混合显示系统日历**,并与 DAV 数据自动去重
|
||||
- 点击日程里的「地点」可**拉起高德 App 路线规划**(系统地理编码 + WGS84→GCJ02 坐标转换,地址文本不落日志)
|
||||
|
||||
### 服务卡片
|
||||
- 四种规格:**2x2 / 2x4 / 4x4 / 6x4**,时间轴样式设计
|
||||
### 日程编辑与提醒
|
||||
|
||||
- 新建 / 编辑 / 删除日程并推送回服务器,支持全天日程、地点、描述、重复规则、多级提前提醒
|
||||
- 自动读取服务器端 **VALARM** 提醒(如"开始前 10 分钟")
|
||||
- 基于**系统级代理提醒**(`reminderAgentManager`)发送通知,App 被杀、手机重启后依然生效,通知可右滑删除
|
||||
- 保存带提醒的日程时会**在那一刻**申请通知权限;被拒绝也不影响日程保存,之后可在设置页再开启
|
||||
|
||||
### 系统日历镜像(可选增强,默认关闭)
|
||||
|
||||
镜像是**可选项**,App 本体不依赖它也能完整使用。开启后:
|
||||
|
||||
- **镜像到系统日历**:把选中的 CalDAV 日历本写入系统日历,这样**小艺语音、桌面日历卡片和手表**也能看到你的日程。每个日历本在系统日历里是**独立账户**,可单独设色与隐藏
|
||||
- **把系统日历的改动回写**:你在系统日历里新建、修改、删除的日程,同步回指定的 CalDAV 日历本(相当于把本机日程备份到服务器)
|
||||
- 采用**星型架构**:服务器 ↔ 本地库、本地库 ↔ 系统日历各做一次两方同步,不会互相污染
|
||||
- 日历本用**稳定的 bookId**(`账号ID-资源路径哈希`)标识,不随勾选顺序变化;取消勾选、目标失效时会自动关闭备份并把相关账户清理干净,绝不把日程写进你没选过的日历本
|
||||
- 重复日程不写入系统 `recurrenceRule`,而是按时间窗口自行展开(过去 1 年 ~ 未来 2 年),保证 `RECURRENCE-ID` 覆盖与 `EXDATE` 排除精确对应
|
||||
|
||||
### 桌面服务卡片
|
||||
|
||||
- 四种规格:**2×2 / 2×4 / 4×4 / 6×4**,时间轴样式设计
|
||||
- 按天分组展示未来日程,组内全天事件置顶;跨天日程智能归入"今天",标注"全天"
|
||||
- 每条日程左侧竖条颜色与日历本一致,右侧显示所属日历本名
|
||||
- 同步完成后自动刷新,formId 持久化保证 App 重启后推送不丢
|
||||
- 同步完成后自动刷新,`formId` 持久化保证 App 重启后推送不丢
|
||||
|
||||
### 日程提醒
|
||||
- 自动读取服务器端 VALARM 提醒(如"开始前 10 分钟")
|
||||
- 基于**系统级代理提醒**(reminderAgentManager)发送通知,App 被杀、手机重启后依然生效,通知可右滑删除
|
||||
### 后台同步与数据备份
|
||||
|
||||
- **自动同步间隔**:App 在前台时按设定间隔自动同步
|
||||
- **后台持续同步**:申请后台长时任务(`dataTransfer`),退到后台也按间隔继续同步,通知栏显示常驻通知
|
||||
- **系统级定时同步**:通过 WorkScheduler 扩展能力在条件满足时触发一轮同步
|
||||
- **应用数据备份/恢复**:接入 Backup 扩展能力,换机时可恢复账号与本地数据
|
||||
|
||||
### 界面与适配
|
||||
|
||||
- **深色模式**:全程使用资源引用(`$r('app.color.*')`),深浅两套色板一一对应,跟随系统自动切换
|
||||
- **沉浸式**:系统状态栏与导航栏全透明,每页(含内置文档页)都做了安全区扩展
|
||||
- **大屏与横竖屏**:以**窗口短边是否达到 840vp** 作为大屏判据(而非屏幕方向),因此折叠屏展开态、平板可自由横竖屏切换,而在屏幕比例接近 1:1 的设备上旋转时布局保持一致
|
||||
- **转场动效**:所有页面使用系统转场,淡入淡出 + 弹簧曲线(`curves.springMotion`),不做单帧直切、左右平移或上下位移
|
||||
|
||||
### 设置项一览
|
||||
|
||||
系统日历处理方式(仅显示 / 备份到 CalDAV)· 镜像到系统日历 · 选择要镜像的日历本 · 回写开关 · 默认视图 · 日程显示方式 · 自动同步间隔 · 后台持续同步 · 通知权限 · 混合显示系统日历 · 日历本只读 / 静音 · 重建日程数据 · 重置账号库 · 软件特性 · 使用帮助 · 隐私政策与用户协议
|
||||
|
||||
---
|
||||
|
||||
## 🔒 隐私
|
||||
|
||||
我们遵循"**数据自持、最小必要**"原则:
|
||||
|
||||
- 开发者**不保存**你的日程与账号数据(账号密码只存在本机)
|
||||
- **不收集**通讯录、相册、剪贴板等信息,**不含**任何第三方统计或广告 SDK
|
||||
- 页面从不展示他人邮箱;日志对日程标题、地点等敏感字段做**脱敏**处理
|
||||
- 权限**按需申请**:只有当你主动使用某个功能(如开启镜像、设置提醒、点击地点导航)时才弹窗申请,启动、首启、同意隐私政策时都**不会**提前申请
|
||||
- 权限被拒绝后,对应功能默认关闭,可随时在系统设置或本应用设置页重新开启
|
||||
|
||||
---
|
||||
|
||||
## 🛠 技术要点
|
||||
|
||||
- **架构**:SyncEngine 同步引擎 + DavClient(HTTP/WebDAV)+ EventDb(relationalStore 本地库)+ CalendarDataService(多源合并展示)
|
||||
- **服务卡片**:FormExtensionAbility + FormBindingData,JSON 数据驱动卡片渲染
|
||||
- **农历算法**:1901–2099 压缩表推算
|
||||
- **权限**:INTERNET / READ_CALENDAR / WRITE_CALENDAR / PUBLISH_AGENT_REMINDER
|
||||
- **架构**:`SyncEngine` 同步引擎 + `DavClient`(HTTP / WebDAV)+ `EventDb`(relationalStore 本地库)+ `CalendarDataService`(多源合并展示)
|
||||
- **镜像**:`SystemCalendarMirror`(出站写入)+ `MirrorInbound`(入站回写)+ `MirrorSnapshot`(变更快照)
|
||||
- **卡片**:FormExtensionAbility + FormBindingData,JSON 数据驱动渲染
|
||||
- **农历**:`LunarUtil`,1901–2099 压缩表推算
|
||||
- **提醒**:`ReminderService` 基于 `reminderAgentManager` 代理提醒
|
||||
- **权限**:INTERNET / READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(仅镜像)/ PUBLISH_AGENT_REMINDER / KEEP_BACKGROUND_RUNNING / LOCATION(仅地点导航时)
|
||||
|
||||
## 📦 快速开始
|
||||
### 目录结构
|
||||
|
||||
1. 使用 DevEco Studio 打开项目,连接鸿蒙真机或模拟器
|
||||
2. 编译安装后进入「≡ → 账号管理」,添加你的 CalDAV 服务器地址、用户名和密码
|
||||
3. 选择要同步的日历本,等待首次同步完成
|
||||
4. 长按桌面空白处 → 添加服务卡片,选择 SyncCalendar 的卡片规格
|
||||
```
|
||||
SyncCalendar/
|
||||
├─ AppScope/ 应用级配置(包名、版本号)
|
||||
└─ entry/src/main/
|
||||
├─ ets/
|
||||
│ ├─ common/ 核心能力
|
||||
│ │ ├─ SyncEngine.ets 同步引擎(拉取 → 解析 → 落库 → 推送)
|
||||
│ │ ├─ DavClient.ets CalDAV / WebDAV 协议客户端
|
||||
│ │ ├─ EventDb.ets relationalStore 本地库
|
||||
│ │ ├─ IcsUtil.ets ICS 解析与生成
|
||||
│ │ ├─ RruleUtil.ets 重复规则展开
|
||||
│ │ ├─ CalendarDataService.ets 多源合并、系统日历读取
|
||||
│ │ ├─ SystemCalendarMirror.ets / MirrorInbound.ets / MirrorSnapshot.ets
|
||||
│ │ ├─ ReminderService.ets 代理提醒
|
||||
│ │ ├─ CardDataService.ets 卡片数据
|
||||
│ │ ├─ AppSettings.ets / AccountStore.ets
|
||||
│ │ └─ LunarUtil.ets / TimelineUtil.ets / DocViewer.ets / TipsPanel.ets …
|
||||
│ ├─ pages/ Index / Accounts / AddAccount / CalendarList /
|
||||
│ │ EditAccount / EventEdit / Settings + widget/*
|
||||
│ ├─ entryability/ 主能力
|
||||
│ ├─ entrybackupability/ 应用数据备份恢复
|
||||
│ ├─ entryformability/ 桌面卡片
|
||||
│ └─ syncwork/ 后台定时同步(WorkScheduler)
|
||||
└─ resources/ base / dark 两套资源与颜色
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 构建与运行
|
||||
|
||||
1. 使用 **DevEco Studio** 打开工程根目录
|
||||
2. 连接鸿蒙真机(需 HarmonyOS NEXT)或模拟器
|
||||
3. 编译安装后进入「≡ → 账号管理」,添加 CalDAV 服务器地址、用户名和密码
|
||||
4. 选择要同步的日历本,等待首次同步完成
|
||||
5. 长按桌面空白处 → 添加服务卡片,选择 SyncCalendar 的卡片规格
|
||||
|
||||
> 上架包构建:`hvigorw assembleApp --mode project -p product=default -p buildMode=release`
|
||||
> (需要在 `build-profile.json5` 里配置自己的签名,该文件不入库)
|
||||
|
||||
---
|
||||
|
||||
## 📷 截图
|
||||
|
||||
| 月视图 | 周视图 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| 列表 / 时间轴 | 日程详情 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| 设置 | 账号管理 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| 桌面卡片 | 深色模式 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关链接
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 官方网站 | <https://synccalendar.yangyq.net> |
|
||||
| 使用帮助 | <https://synccalendar.yangyq.net/uh.html> |
|
||||
| 软件特性 | <https://synccalendar.yangyq.net/sf.html> |
|
||||
| 隐私政策 | <https://synccalendar.yangyq.net/upa.html> |
|
||||
| 用户服务协议 | <https://synccalendar.yangyq.net/usa.html> |
|
||||
| 问题反馈 | <https://synccalendar.yangyq.net/feedback/> |
|
||||
|
||||
网站备案:**鲁ICP备17014997号-6A**
|
||||
|
||||
---
|
||||
|
||||
## 📄 开源说明
|
||||
|
||||
本项目仅供学习交流使用。
|
||||
本项目仅供学习交流使用,采用 **Apache License 2.0** 协议,详见 [LICENSE](LICENSE)。
|
||||
|
||||
该项目使用人工智能辅助完成。
|
||||
|
||||
@@ -11,13 +11,22 @@ export class AppSettings {
|
||||
private static readonly KEY_SYNC_INTERVAL: string = 'sync_interval_minutes';
|
||||
private static readonly KEY_BACKGROUND_SYNC: string = 'background_sync';
|
||||
private static readonly KEY_SYS_MODE: string = 'sys_cal_mode'; // 'display' | 'backup'
|
||||
private static readonly KEY_BACKUP_KEY: string = 'sys_backup_cal_key'; // 备份目标 DAV 日历本
|
||||
// ⚠️ 备份目标**必须**用稳定标识 bookId 存(由服务器 href 派生,见 SystemCalendarMirror.listBooks)。
|
||||
// 老键存的是 calKey(`accId_序号`),序号会随"取消勾选某个日历本/服务器新增日历本"整体漂移
|
||||
// → 目标本会**悄悄变成另一个本**,把系统日历导进用户没选过的本(用户实测发现)。
|
||||
// KEY_BACKUP_KEY 仅保留用于老值一次性迁移,读到后立即换存 KEY_BACKUP_BOOK_ID 并清空。
|
||||
private static readonly KEY_BACKUP_KEY: string = 'sys_backup_cal_key'; // 【已废弃】备份目标 calKey(仅迁移用)
|
||||
private static readonly KEY_BACKUP_BOOK_ID: string = 'sys_backup_book_id'; // ⭐ 备份目标稳定标识 bookId
|
||||
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'; // 一次性全量重拉已完成
|
||||
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_MIRROR_ENABLED: string = 'mirror_enabled'; // 是否把选中 CalDAV 日历本镜像到系统日历(默认关)
|
||||
private static readonly KEY_MIRROR_KEYS: string = 'mirror_cal_keys'; // 要镜像的 DAV 日历本 calKey 列表
|
||||
private static readonly KEY_MIRROR_BOOK_IDS: string = 'mirror_book_ids'; // ⭐ 要镜像的日历本**稳定标识** bookId(由服务器 href 派生,不随序号变)
|
||||
private static readonly KEY_MIRROR_INBOUND: string = 'mirror_inbound'; // 是否把系统日历的改动回写 CalDAV(默认关)
|
||||
private static readonly KEY_POLICY_AGREED: string = 'policy_agreed'; // 是否已同意隐私政策与用户协议
|
||||
// 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。
|
||||
// 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true,**不再"显示前就置 true"**——
|
||||
@@ -149,6 +158,141 @@ export class AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否把选中的 CalDAV 日历本镜像到系统日历(默认关)。
|
||||
* ⚠️ 该开关必须与 WRITE_CALENDAR 授权状态一致:未授权即为关(权限按需申请约束)。
|
||||
*/
|
||||
static async getMirrorEnabled(context: common.Context): Promise<boolean> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
return await store.get(AppSettings.KEY_MIRROR_ENABLED, false) as boolean;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async setMirrorEnabled(context: common.Context, value: boolean): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_MIRROR_ENABLED, value);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存镜像开关失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **已废弃**:早期用 calKey 存"要镜像哪些本",但 calKey 是 `<账号ID>_<序号>`,
|
||||
* 日历本重排后序号会变 → 选中的本串成另一个本、镜像账户名也变 → 系统日历出现重复账户与串本。
|
||||
* 新代码一律用 `getMirrorBookIds()`。这里只为读取历史值做迁移。
|
||||
*/
|
||||
static async getMirrorKeys(context: common.Context): Promise<string[]> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
const raw: string = await store.get(AppSettings.KEY_MIRROR_KEYS, '') as string;
|
||||
if (raw === '') {
|
||||
return [];
|
||||
}
|
||||
const arr: string[] = JSON.parse(raw) as string[];
|
||||
if (!Array.isArray(arr)) {
|
||||
return [];
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (let i: number = 0; i < arr.length; i++) {
|
||||
const v: string = arr[i];
|
||||
if (typeof v === 'string' && v !== '') {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async setMirrorKeys(context: common.Context, keys: string[]): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_MIRROR_KEYS, JSON.stringify(keys));
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存镜像日历本失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 要镜像到系统日历的日历本**稳定标识** bookId 列表,空 = 未选择。
|
||||
* bookId = `<账号ID>-<href哈希>`,由服务器分配的 href 派生 —— 日历本增删改序都不会变。
|
||||
*/
|
||||
static async getMirrorBookIds(context: common.Context): Promise<string[]> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
const raw: string = await store.get(AppSettings.KEY_MIRROR_BOOK_IDS, '') as string;
|
||||
if (raw === '') {
|
||||
return [];
|
||||
}
|
||||
const arr: string[] = JSON.parse(raw) as string[];
|
||||
if (!Array.isArray(arr)) {
|
||||
return [];
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (let i: number = 0; i < arr.length; i++) {
|
||||
const v: string = arr[i];
|
||||
if (typeof v === 'string' && v !== '') {
|
||||
out.push(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static async setMirrorBookIds(context: common.Context, ids: string[]): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_MIRROR_BOOK_IDS, JSON.stringify(ids));
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存镜像日历本(bookId)失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否把"用户在系统日历里的改动"回写到 CalDAV(**默认关**)。
|
||||
* ⚠️ 默认关是有意的:这个功能会写用户的服务器数据,首次上线必须先单向观察一轮再开。
|
||||
*/
|
||||
static async getMirrorInbound(context: common.Context): Promise<boolean> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
return await store.get(AppSettings.KEY_MIRROR_INBOUND, false) as boolean;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async setMirrorInbound(context: common.Context, value: boolean): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_MIRROR_INBOUND, value);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存回写开关失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。
|
||||
* 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。
|
||||
@@ -286,9 +430,14 @@ export class AppSettings {
|
||||
if (muted2.length !== muted.length) {
|
||||
await AppSettings.setMutedReminderKeys(context, muted2);
|
||||
}
|
||||
const backupKey: string = await AppSettings.getBackupCalKey(context);
|
||||
if (backupKey.startsWith(`${accId}_`)) {
|
||||
await AppSettings.setBackupCalKey(context, '');
|
||||
// 备份目标指向该账号名下某个本 → 目标必失效:清空目标**并关闭备份功能**
|
||||
// (只清空目标会让"备份到 CalDAV"开关停在开着却什么都不做的状态,状态与实际不符)
|
||||
const bookId: string = await AppSettings.getBackupBookId(context);
|
||||
const legacyKey: string = await AppSettings.getBackupCalKey(context);
|
||||
if (bookId.startsWith(`${accId}-`) || legacyKey.startsWith(`${accId}_`)) {
|
||||
await AppSettings.clearBackupTarget(context);
|
||||
await AppSettings.setSysCalMode(context, 'display');
|
||||
console.log(`删除账号 ${accId}:其名下的系统日历备份目标已失效,备份功能自动关闭`);
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
@@ -319,7 +468,12 @@ export class AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/** 备份目标日历本 calKey(accId_序号),空 = 未选择 */
|
||||
/**
|
||||
* 【已废弃,仅供老值迁移】备份目标 calKey(`accId_序号`)。
|
||||
* ⚠️ 不要再用它做判断!序号会漂移(见 KEY_BACKUP_KEY 处注释)。
|
||||
* 新代码一律用 `SystemCalendarMirror.backupTargetRef()` / `checkBackupTarget()`,
|
||||
* 它内部会把这个老值翻译成 bookId 并清空本键。
|
||||
*/
|
||||
static async getBackupCalKey(context: common.Context): Promise<string> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
@@ -330,18 +484,49 @@ export class AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/** 【已废弃】写老键。新代码请用 setBackupBookId()。 */
|
||||
static async setBackupCalKey(context: common.Context, calKey: string): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_BACKUP_KEY, calKey);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存备份目标(老键)失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** ⭐ 备份目标日历本的稳定标识 bookId(`<accId>-<href哈希>`),空 = 未选择 */
|
||||
static async getBackupBookId(context: common.Context): Promise<string> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
return await store.get(AppSettings.KEY_BACKUP_BOOK_ID, '') as string;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** ⭐ 保存备份目标(bookId)。传空字符串 = 取消选择。 */
|
||||
static async setBackupBookId(context: common.Context, bookId: string): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_BACKUP_BOOK_ID, bookId);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存备份目标失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 一次清空备份目标(新键 + 老键一起清,避免迁移残留又复活) */
|
||||
static async clearBackupTarget(context: common.Context): Promise<void> {
|
||||
await AppSettings.setBackupBookId(context, '');
|
||||
await AppSettings.setBackupCalKey(context, '');
|
||||
}
|
||||
|
||||
/** 是否开启后台持续同步(长时任务,默认关) */
|
||||
static async getBackgroundSync(context: common.Context): Promise<boolean> {
|
||||
try {
|
||||
@@ -365,14 +550,18 @@ export class AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否混合显示系统日历日程(默认开) */
|
||||
/**
|
||||
* 是否混合显示系统日历日程。
|
||||
* ⚠️ 默认**关闭**:读取系统日历需要"读取全部日程"敏感权限,按《审核指南》7.17
|
||||
* 只能在用户主动打开该开关时才申请,因此默认值必须与实际授权状态一致(无授权 = 关)。
|
||||
*/
|
||||
static async getShowSystemCalendar(context: common.Context): Promise<boolean> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
return await store.get(AppSettings.KEY_SHOW_SYSTEM, true) as boolean;
|
||||
return await store.get(AppSettings.KEY_SHOW_SYSTEM, false) as boolean;
|
||||
} catch (err) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// entry/src/main/ets/common/CalendarDataService.ets
|
||||
// 合并数据源:本地库(DAV 同步 + 本机事件) + 系统日历(只读展示)
|
||||
import { common, abilityAccessCtrl, Permissions } from '@kit.AbilityKit';
|
||||
import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { calendarManager } from '@kit.CalendarKit';
|
||||
import { AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY, DavAccount } from './AccountStore';
|
||||
@@ -29,15 +29,52 @@ export class DisplayEvent {
|
||||
}
|
||||
|
||||
export class CalendarDataService {
|
||||
/** 申请读取全部日历权限(混合展示系统日历需要) */
|
||||
static async ensureSystemCalendarPermission(context: common.UIAbilityContext): Promise<boolean> {
|
||||
try {
|
||||
const atManager = abilityAccessCtrl.createAtManager();
|
||||
const permissions: Permissions[] = [
|
||||
/** 本功能依赖的日历权限:读取本应用日历 + 读取全部日历(即"读取全部日程") */
|
||||
private static readonly CALENDAR_PERMISSIONS: Permissions[] = [
|
||||
'ohos.permission.READ_CALENDAR',
|
||||
'ohos.permission.READ_WHOLE_CALENDAR'
|
||||
];
|
||||
const result = await atManager.requestPermissionsFromUser(context, permissions);
|
||||
|
||||
/**
|
||||
* 是否已获得"读取全部日程"权限。
|
||||
* **只检查、绝不弹窗** —— 供界面判断状态、决定是否需要引导用户主动授权。
|
||||
* 注意:checkAccessToken 只反映本应用授权状态,不会触发任何系统弹窗。
|
||||
*/
|
||||
static async hasSystemCalendarPermission(): Promise<boolean> {
|
||||
try {
|
||||
const atManager = abilityAccessCtrl.createAtManager();
|
||||
const info = bundleManager.getBundleInfoForSelfSync(
|
||||
bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION);
|
||||
const tokenId: number = info.appInfo.accessTokenId;
|
||||
for (const p of CalendarDataService.CALENDAR_PERMISSIONS) {
|
||||
const status = await atManager.checkAccessToken(tokenId, p);
|
||||
if (status !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请"读取全部日程"权限(READ_CALENDAR + READ_WHOLE_CALENDAR)。
|
||||
*
|
||||
* ⚠️ 合规约束(《审核指南》7.17):**只能在用户主动点击使用对应功能时调用**,
|
||||
* 例如在设置页打开"混合显示系统日历"开关、把系统日历模式切到"备份到 CalDAV 日历本"。
|
||||
* 严禁在 App 启动 / 首次打开 / 未同意隐私政策等用户未主动操作的时机调用。
|
||||
*
|
||||
* 已授权时直接返回 true,不会再弹窗(先 checkAccessToken,避免多余弹窗)。
|
||||
*/
|
||||
static async requestSystemCalendarPermission(context: common.UIAbilityContext): Promise<boolean> {
|
||||
if (await CalendarDataService.hasSystemCalendarPermission()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const atManager = abilityAccessCtrl.createAtManager();
|
||||
const result = await atManager.requestPermissionsFromUser(
|
||||
context, CalendarDataService.CALENDAR_PERMISSIONS);
|
||||
for (const r of result.authResults) {
|
||||
if (r !== 0) {
|
||||
return false;
|
||||
@@ -165,12 +202,14 @@ export class CalendarDataService {
|
||||
}
|
||||
// 单次覆盖实例(RECURRENCE-ID 独立行):展开时跳过对应发生,避免重复。
|
||||
// 注意:①排除键必须带 calKey(同一系列可能出现在多个日历本);
|
||||
// ②只有"可见日历本"的覆盖行才参与排除——历史孤儿行(旧账号迁移残留)
|
||||
// ②键必须用 **recurrenceId(原始发生时刻)**——覆盖行的 startTime 是"被改到的新时间",
|
||||
// 用它做键时主事件在原时间的那次发生不会被排除 → 同一日程显示两条(幽灵日程);
|
||||
// ③只有"可见日历本"的覆盖行才参与排除——历史孤儿行(旧账号迁移残留)
|
||||
// 自己不显示,却会把主事件的对应发生排除掉,导致"第一次不显示"
|
||||
const overrideKeys: string[] = [];
|
||||
for (const e of rows) {
|
||||
if (e.rrule === '' && e.recurring && visibleKeys.includes(e.calKey)) {
|
||||
overrideKeys.push(`${e.calKey}_${e.uid}_${e.startTime}`);
|
||||
overrideKeys.push(EventDb.overrideKey(e.calKey, e.uid, e.recurrenceId, e.startTime));
|
||||
}
|
||||
}
|
||||
let occTotal: number = 0;
|
||||
@@ -195,7 +234,7 @@ export class CalendarDataService {
|
||||
const occs: number[] = RruleUtil.expand(e.rrule, e.startTime, start, end, exNums, 1500);
|
||||
if (occs.length > 0) {
|
||||
times = occs.filter((occ: number): boolean =>
|
||||
!overrideKeys.includes(`${e.calKey}_${e.uid}_${occ}`));
|
||||
!overrideKeys.includes(EventDb.overrideKey(e.calKey, e.uid, 0, occ)));
|
||||
}
|
||||
}
|
||||
for (const occ of times) {
|
||||
|
||||
@@ -36,6 +36,20 @@ export class DavCalendarDiscovery {
|
||||
entries: DavCalendarEntry[] = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 带 HTTP 状态码的 DAV 请求错误。
|
||||
* 同步引擎据此区分「404 日历本/资源在服务器上已不存在」(可降级处理:
|
||||
* 单本跳过、自动从账号移除)与其它网络/服务器故障(整个账号同步失败)。
|
||||
*/
|
||||
export class DavHttpError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export class DavClient {
|
||||
/**
|
||||
* PROPFIND 拉取某路径下所有集合的 calendar-color
|
||||
@@ -346,7 +360,7 @@ export class DavClient {
|
||||
});
|
||||
LogUtil.write(`HTTP REPORT(etag) ${href} → ${resp.responseCode}`);
|
||||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||||
throw new Error(`服务器返回状态码 ${resp.responseCode}`);
|
||||
throw new DavHttpError(resp.responseCode, `服务器返回状态码 ${resp.responseCode}`);
|
||||
}
|
||||
const xml: string = resp.result as string;
|
||||
const items: RemoteItem[] = [];
|
||||
@@ -403,11 +417,11 @@ export class DavClient {
|
||||
LogUtil.write(`HTTP REPORT(${compName}) ${href} → ${resp.responseCode}`);
|
||||
if (resp.responseCode === 401) {
|
||||
LogUtil.write(`HTTP REPORT(${compName}) 401 拒绝凭据`);
|
||||
throw new Error('服务器拒绝凭据(401)');
|
||||
throw new DavHttpError(401, '服务器拒绝凭据(401)');
|
||||
}
|
||||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||||
LogUtil.write(`HTTP REPORT(${compName}) 异常状态码 ${resp.responseCode}`);
|
||||
throw new Error(`服务器返回状态码 ${resp.responseCode}`);
|
||||
throw new DavHttpError(resp.responseCode, `服务器返回状态码 ${resp.responseCode}`);
|
||||
}
|
||||
const xml: string = resp.result as string;
|
||||
LogUtil.write(`HTTP REPORT(${compName}) 响应体 ${xml.length} 字符`);
|
||||
@@ -459,7 +473,7 @@ export class DavClient {
|
||||
console.info(`PUT ${url} 响应码: ${resp.responseCode}`);
|
||||
LogUtil.write(`HTTP PUT ${url} → ${resp.responseCode}`);
|
||||
if (resp.responseCode < 200 || resp.responseCode >= 300) {
|
||||
throw new Error(`推送失败,服务器返回 ${resp.responseCode}`);
|
||||
throw new DavHttpError(resp.responseCode, `推送失败,服务器返回 ${resp.responseCode}`);
|
||||
}
|
||||
const headers = resp.header as Record<string, string>;
|
||||
if (headers !== undefined && headers !== null) {
|
||||
@@ -489,7 +503,7 @@ export class DavClient {
|
||||
LogUtil.write(`HTTP DELETE ${url} → ${resp.responseCode}`);
|
||||
// 404 视为已删除,成功
|
||||
if ((resp.responseCode < 200 || resp.responseCode >= 300) && resp.responseCode !== 404) {
|
||||
throw new Error(`删除失败,服务器返回 ${resp.responseCode}`);
|
||||
throw new DavHttpError(resp.responseCode, `删除失败,服务器返回 ${resp.responseCode}`);
|
||||
}
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
|
||||
@@ -52,5 +52,7 @@ export struct DocViewer {
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制,Web 与标题栏仍在安全区内)
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ export class LocalEvent {
|
||||
completed: boolean = false; // 待办是否已完成(STATUS:COMPLETED)
|
||||
rrule: string = ''; // 原始 RRULE(空 = 非重复);显示时按规则展开多次发生
|
||||
exdate: string = ''; // 原始 EXDATE 排除日期,分号分隔
|
||||
/** ⭐ RECURRENCE-ID:本行"覆盖"的是主事件的哪一次发生(毫秒,0 = 无/旧数据未回填)。
|
||||
* 只改标题时它等于 startTime;**把某次发生改期**时它才是原始时刻——
|
||||
* 排除主事件对应发生时必须用它,否则原时间会残留成"幽灵日程"。 */
|
||||
recurrenceId: number = 0;
|
||||
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段),0 = 不提醒
|
||||
reminders: number[] = []; // 全部提醒提前分钟数(多选),按添加顺序
|
||||
}
|
||||
@@ -44,6 +48,7 @@ export class RemoteEvent {
|
||||
completed: boolean = false; // VTODO STATUS:COMPLETED
|
||||
rrule: string = ''; // 原始 RRULE
|
||||
exdate: string = ''; // 原始 EXDATE(分号分隔)
|
||||
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒;0 = 无)
|
||||
reminder: number = 0; // 主提醒提前分钟数(第一个提醒,兼容旧字段)
|
||||
reminders: number[] = []; // 全部提醒提前分钟数(多选)
|
||||
}
|
||||
@@ -51,6 +56,36 @@ export class RemoteEvent {
|
||||
export class EventDb {
|
||||
private static db: relationalStore.RdbStore | null = null;
|
||||
|
||||
/**
|
||||
* ⭐ 统计"是覆盖实例(RECURRENCE-ID 行)但还没回填 recurrence_id"的行数。
|
||||
*
|
||||
* 语义:`recurring=1 且 rrule=''` ⟺ 这一行的 .ics 里有 RECURRENCE-ID。
|
||||
* 老库升级后这些行的 recurrence_id 一律是 0(字段以前被丢弃,无法从本地反推),
|
||||
* 必须靠一次全量重拉(GET 每个 .ics)才能填上。
|
||||
* **用数据本身当标志,而不是内存变量** —— 卡片进程/进程重启会把内存标志吃掉
|
||||
*(实测 2026-09-19 18:30/18:36 两次同步都没触发全量重拉)。
|
||||
* 回填完成后返回值自然变 0,不会反复触发。
|
||||
*/
|
||||
static async countOverridesMissingRecurrenceId(context: common.Context): Promise<number> {
|
||||
try {
|
||||
const store = await EventDb.getDb(context);
|
||||
const rs = await store.querySql(
|
||||
"select count(*) c from events " +
|
||||
"where deleted = 0 and recurring = 1 and rrule = '' and recurrence_id = 0");
|
||||
let n: number = 0;
|
||||
try {
|
||||
if (rs.goToNextRow()) {
|
||||
n = rs.getLong(rs.getColumnIndex('c'));
|
||||
}
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
return n;
|
||||
} catch (err) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static async getDb(context: common.Context): Promise<relationalStore.RdbStore> {
|
||||
if (EventDb.db !== null) {
|
||||
return EventDb.db;
|
||||
@@ -104,6 +139,12 @@ export class EventDb {
|
||||
} catch (err) {
|
||||
// 列已存在
|
||||
}
|
||||
// ⭐ 覆盖实例的"原始发生时刻"(RECURRENCE-ID)。老库升级后该列一律为 0,需要一次全量重拉回填
|
||||
try {
|
||||
await store.executeSql('ALTER TABLE events ADD COLUMN recurrence_id INTEGER DEFAULT 0');
|
||||
} catch (err) {
|
||||
// 列已存在
|
||||
}
|
||||
EventDb.db = store;
|
||||
return store;
|
||||
}
|
||||
@@ -124,6 +165,18 @@ export class EventDb {
|
||||
return EventDb.normalizeReminders(list).join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 覆盖实例(RECURRENCE-ID 独立行)"排除主事件哪一次发生"用的键。
|
||||
*
|
||||
* 必须优先用 **recurrenceId**(原始发生时刻):覆盖行的 startTime 是**被改到的新时间**,
|
||||
* 用它做键时主事件在**原时间**的那次发生不会被排除 → App 与系统日历里同一日程出现两条
|
||||
* (2026-09-19 真机实测:窗口内 63 条这种"幽灵日程")。
|
||||
* recurrenceId = 0 表示旧库数据(还没回填)→ 回退到 startTime,保持老行为不变。
|
||||
*/
|
||||
static overrideKey(calKey: string, uid: string, recurrenceId: number, startTime: number): string {
|
||||
return `${calKey}_${uid}_${recurrenceId > 0 ? recurrenceId : startTime}`;
|
||||
}
|
||||
|
||||
private static fromRow(rs: relationalStore.ResultSet): LocalEvent {
|
||||
const e = new LocalEvent();
|
||||
e.id = rs.getLong(rs.getColumnIndex('id'));
|
||||
@@ -146,6 +199,7 @@ export class EventDb {
|
||||
e.completed = rs.getLong(rs.getColumnIndex('completed')) === 1;
|
||||
e.rrule = rs.getString(rs.getColumnIndex('rrule'));
|
||||
e.exdate = rs.getString(rs.getColumnIndex('exdate'));
|
||||
e.recurrenceId = rs.getLong(rs.getColumnIndex('recurrence_id'));
|
||||
e.reminder = rs.getLong(rs.getColumnIndex('reminder'));
|
||||
// 多提醒(逗号分隔分钟数);旧数据无该列时回退单提醒
|
||||
const remStr: string = rs.getString(rs.getColumnIndex('reminders'));
|
||||
@@ -178,6 +232,7 @@ export class EventDb {
|
||||
'completed': e.completed ? 1 : 0,
|
||||
'rrule': e.rrule,
|
||||
'exdate': e.exdate,
|
||||
'recurrence_id': e.recurrenceId,
|
||||
'reminder': e.reminders.length > 0 ? e.reminders[0] : 0,
|
||||
'reminders': e.reminders.map((n: number): string => String(n)).join(',')
|
||||
};
|
||||
@@ -371,12 +426,22 @@ export class EventDb {
|
||||
}
|
||||
|
||||
const remoteKeys: string[] = [];
|
||||
// ⭐ 同一次解析里可能出现同一资源的多条相同 VEVENT(同 uid + 同 DTSTART)。
|
||||
// 旧版用循环前的 existing 快照判重,插入后不回写 → 第二条仍判为"新增"又插一行,
|
||||
// 在本地库留下 href/etag/remote_path 完全相同的冗余行(2026-09-19 实测 10 组)。
|
||||
const seenKeys: Set<string> = new Set<string>();
|
||||
for (const r of remote) {
|
||||
// 重复日程实例共享 UID,唯一标识 = uid + 开始时间
|
||||
const key: string = `${r.uid}_${r.startTime}`;
|
||||
// 重复日程实例共享 UID,唯一标识 = uid + 开始时间 + RECURRENCE-ID
|
||||
//(同一时刻可能同时存在"主事件"与"覆盖该次发生的实例行",只带上 recurrenceId 才唯一)
|
||||
const key: string = `${r.uid}_${r.startTime}_${r.recurrenceId}`;
|
||||
if (seenKeys.has(key)) {
|
||||
continue; // 同一资源内的重复条目 → 只保留一条
|
||||
}
|
||||
seenKeys.add(key);
|
||||
remoteKeys.push(key);
|
||||
const found = existing.find((x: LocalEvent): boolean =>
|
||||
!x.dirty && x.uid === r.uid && x.startTime === r.startTime);
|
||||
!x.dirty && x.uid === r.uid && x.startTime === r.startTime
|
||||
&& x.recurrenceId === r.recurrenceId);
|
||||
if (found === undefined) {
|
||||
// 新增
|
||||
const e = new LocalEvent();
|
||||
@@ -398,14 +463,20 @@ export class EventDb {
|
||||
e.completed = r.completed;
|
||||
e.rrule = r.rrule;
|
||||
e.exdate = r.exdate;
|
||||
e.recurrenceId = r.recurrenceId;
|
||||
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
|
||||
|| EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)) {
|
||||
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填)
|
||||
|| EventDb.remKey(found.reminders) !== EventDb.remKey(r.reminders)
|
||||
|| found.href !== href) {
|
||||
// 更新(rrule/exdate/recurring 变化也更新,兼容老数据回填;
|
||||
// href 变化也要更新——服务器上日历本被删除重建后集合 URL 变了,
|
||||
// 行里若残留旧 URL,用户下次编辑该日程会 PUT 到已不存在的集合(404),
|
||||
// 且 pushDirty 按 href 过滤会漏掉它导致永远推不出去)
|
||||
found.href = href;
|
||||
found.title = r.title;
|
||||
found.description = r.description;
|
||||
found.location = r.location;
|
||||
@@ -420,6 +491,7 @@ export class EventDb {
|
||||
found.completed = r.completed;
|
||||
found.rrule = r.rrule;
|
||||
found.exdate = r.exdate;
|
||||
found.recurrenceId = r.recurrenceId;
|
||||
found.reminder = r.reminder;
|
||||
found.reminders = r.reminders;
|
||||
const up = new relationalStore.RdbPredicates('events');
|
||||
@@ -428,6 +500,15 @@ export class EventDb {
|
||||
updated++;
|
||||
} else {
|
||||
unchanged++;
|
||||
// etag 未变也要校正 href:日历本被删除重建后集合 URL 变化,etag 比对仍然全部命中,
|
||||
// 不在这里刷新的话旧 URL 会一直残留在行上(见上方更新分支的注释)
|
||||
if (found.href !== href) {
|
||||
found.href = href;
|
||||
const hp = new relationalStore.RdbPredicates('events');
|
||||
hp.equalTo('id', found.id);
|
||||
const hb: relationalStore.ValuesBucket = { 'href': href };
|
||||
await store.update(hb, hp);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 删除远端已不存在的(排除本地修改未推送的)
|
||||
@@ -435,7 +516,7 @@ export class EventDb {
|
||||
if (local.dirty) {
|
||||
continue;
|
||||
}
|
||||
const localKey: string = `${local.uid}_${local.startTime}`;
|
||||
const localKey: string = `${local.uid}_${local.startTime}_${local.recurrenceId}`;
|
||||
if (!remoteKeys.includes(localKey)) {
|
||||
const del = new relationalStore.RdbPredicates('events');
|
||||
del.equalTo('id', local.id);
|
||||
@@ -446,11 +527,72 @@ export class EventDb {
|
||||
return `新增${added} 更新${updated} 删除${removed} 不变${unchanged}`;
|
||||
}
|
||||
|
||||
/** 查询某日历本下同类型的全部行(同步 etag 比对用,含 dirty 行) */
|
||||
/**
|
||||
* 清理"同一资源"的冗余重复行(**纯本地去重,绝不触碰服务器**)。
|
||||
*
|
||||
* 起因:旧版 applyRemote 用循环前的一次性 existing 快照判重,同一个 .ics 里若含
|
||||
* 多条相同 (UID, DTSTART) 的 VEVENT,第二条仍被判为"新增"再插一行 →
|
||||
* 本地库出现 href / etag / remote_path 完全相同的重复行(2026-09-19 实测 10 组)。
|
||||
* 影响:① App 里同一日程显示两条;② 镜像到系统日历后同一 identifier 互相覆盖,
|
||||
* 出现"计划 984 条、系统只写出 983 条"的口径差。
|
||||
*
|
||||
* 口径:同 (cal_key, uid, start_time, recurrence_id) 且在**同一资源**(href 非空)下,
|
||||
* 只保留 id 最小的一行。
|
||||
* 含本地未推送修改(dirty=1)的行整个不参与,绝不会连带删掉待推送数据。
|
||||
* 被删行**不置 dirty/deleted**,因此不会向服务器推送任何删除。
|
||||
* ⚠️ 分组必须带上 recurrence_id:主事件与"覆盖该次发生的实例行"可能同 uid 同 start_time,
|
||||
* 它们是两条**合法**的不同发生,不能被当成冗余行删掉。
|
||||
* @returns 删除的冗余行数
|
||||
*/
|
||||
static async dedupeDuplicateRows(context: common.Context): Promise<number> {
|
||||
let removed: number = 0;
|
||||
try {
|
||||
const store = await EventDb.getDb(context);
|
||||
// 只处理"远端资源、已删标记为 0、且没有本地未推送修改(dirty=0)"的行。
|
||||
// 某个组里只要有一行是 dirty,它就不参与分组 → 组内只剩 1 行 → count(*)>1 不成立 → 不动。
|
||||
// 这样绝不会把"待推送的本地修改"连带删掉。
|
||||
const rs = await store.querySql(
|
||||
"select group_concat(id) ids from events " +
|
||||
"where deleted = 0 and dirty = 0 and href <> '' " +
|
||||
"group by cal_key, uid, start_time, recurrence_id having count(*) > 1");
|
||||
const groups: string[] = [];
|
||||
try {
|
||||
while (rs.goToNextRow()) {
|
||||
const v: string = rs.getString(rs.getColumnIndex('ids'));
|
||||
if (v !== undefined && v !== null && v !== '') {
|
||||
groups.push(v);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
for (const g of groups) {
|
||||
const ids: number[] = g.split(',')
|
||||
.map((s: string): number => Number(s.trim()))
|
||||
.filter((n: number): boolean => !Number.isNaN(n));
|
||||
ids.sort((a: number, b: number): number => a - b);
|
||||
for (let i: number = 1; i < ids.length; i++) { // 0 号(最小 id)保留
|
||||
const del = new relationalStore.RdbPredicates('events');
|
||||
del.equalTo('id', ids[i]);
|
||||
await store.delete(del);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// 去重失败不影响主流程
|
||||
return removed;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** 查询某日历本下同类型的全部行(同步 etag 比对用,含 dirty 行)
|
||||
* ⭐ 固定按 id 升序:镜像/展示遇到"同 uid 多行"时谁先谁后必须稳定,
|
||||
* 否则同一 identifier 每次换一行 → 系统日历在两处之间反复翻转(实测过)。 */
|
||||
static async queryByCalKey(context: common.Context, calKey: string, kind: string): Promise<LocalEvent[]> {
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.equalTo('cal_key', calKey).and().equalTo('kind', kind);
|
||||
predicates.orderByAsc('id');
|
||||
const rs = await store.query(predicates);
|
||||
const list: LocalEvent[] = [];
|
||||
try {
|
||||
@@ -517,6 +659,46 @@ export class EventDb {
|
||||
await store.delete(predicates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除某账号下、属于指定日历本集合(按 href 匹配)的全部本地日程/待办。
|
||||
*
|
||||
* 用途:日历本在服务器上被删除(同步 REPORT 返回 404)或用户取消勾选时,
|
||||
* 必须在 calKey 序号迁移**之前**先删掉这些行——否则序号收窄后,被移除日历本的
|
||||
* 旧行会与新搬到该序号下的其它日历本行混在同一个 calKey 里(2026-09-29 实测:
|
||||
* 「读书」被服务器删除后重选,其 988 行混进「轻舟」的 calKey)。
|
||||
* 行上自带 href(日历本集合 URL),按 href 删比按 calKey 删更稳——
|
||||
* 历史原因(旧版迁移缺陷)行可能挂在错误的 calKey 下,href 才是行的真实归属。
|
||||
*/
|
||||
static async pruneByHrefs(context: common.Context, accId: string, hrefs: string[]): Promise<number> {
|
||||
if (hrefs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.like('cal_key', `${accId}_%`);
|
||||
predicates.and().in('href', hrefs);
|
||||
return await store.delete(predicates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理「href 已不属于任何已选日历本」的残留行(跨账号兜底清扫)。
|
||||
* 典型:服务器删除日历本后,旧版把其残留行混进其它日历本的 calKey(见 pruneByHrefs 注释),
|
||||
* 这类行 calKey 看似有效、pruneOrphanCalKeys 抓不到,但 href 暴露了真实归属。
|
||||
* ⚠️ 跳过 dirty 行(用户未推送的本地修改绝不能被清扫掉)与 local 行。
|
||||
*/
|
||||
static async pruneStaleHrefs(context: common.Context, validHrefs: string[]): Promise<number> {
|
||||
if (validHrefs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const store = await EventDb.getDb(context);
|
||||
const predicates = new relationalStore.RdbPredicates('events');
|
||||
predicates.notEqualTo('cal_key', 'local');
|
||||
predicates.and().notEqualTo('href', '');
|
||||
predicates.and().notIn('href', validHrefs);
|
||||
predicates.and().equalTo('dirty', 0);
|
||||
return await store.delete(predicates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移因「日历本序号变动」而错位的日程行(calKey = `${accId}_${序号}`)。
|
||||
*
|
||||
|
||||
@@ -16,6 +16,7 @@ class ParsedEvent {
|
||||
inAlarm: boolean = false; // 是否处于 VALARM 子组件内(内部属性不参与解析)
|
||||
rrule: string = ''; // 原始 RRULE 值
|
||||
exdates: string[] = []; // 原始 EXDATE 值列表
|
||||
recurrenceId: number = 0; // 原始 RECURRENCE-ID(毫秒)——本行覆盖的是主事件的哪一次发生
|
||||
reminder: number = 0; // 主提醒提前分钟数(第一个 VALARM,0 = 不提醒)
|
||||
reminders: number[] = []; // 全部提醒提前分钟数(多 VALARM)
|
||||
}
|
||||
@@ -102,6 +103,16 @@ export class IcsUtil {
|
||||
current.recurring = true;
|
||||
if (propName === 'RRULE') {
|
||||
current.rrule = value;
|
||||
} else {
|
||||
// ⭐ 必须**保留 RECURRENCE-ID 的值**:它指出本行覆盖的是主事件哪一次发生。
|
||||
// 覆盖行的 DTSTART 是"被改到的新时间",只改标题时两者才相同;
|
||||
// 一旦把某次发生改期,用 startTime 排除主事件那次发生就会漏掉 →
|
||||
// App 与系统日历里同一日程出现两条(2026-09-19 真机事故)。
|
||||
const recDateOnly: boolean = propPart.toUpperCase().includes('VALUE=DATE');
|
||||
const rt = IcsUtil.parseTime(value, recDateOnly);
|
||||
if (rt !== null) {
|
||||
current.recurrenceId = rt.time;
|
||||
}
|
||||
}
|
||||
} else if (propName === 'EXDATE') {
|
||||
const exValues: string[] = value.split(',');
|
||||
@@ -140,6 +151,7 @@ export class IcsUtil {
|
||||
r.recurring = p.recurring;
|
||||
r.rrule = p.rrule;
|
||||
r.exdate = p.exdates.join(';');
|
||||
r.recurrenceId = p.recurrenceId;
|
||||
r.reminder = p.reminder;
|
||||
r.reminders = p.reminders;
|
||||
return r;
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// entry/src/main/ets/common/MirrorInbound.ets
|
||||
// 入站对账:把"用户在系统日历里做的改动"读回来,写回本地库并置 dirty,随下一次同步推送到 CalDAV。
|
||||
//
|
||||
// 这是「系统日历 ↔ 同步日历 App ↔ CalDAV 服务器」闭环的**回程**。
|
||||
// 出站(CalDAV → 系统日历)在 SystemCalendarMirror,本文件负责反向。
|
||||
//
|
||||
// ⚠️ 防回灌是这里唯一的核心难题:系统日历里既有"我们写进去的",也有"用户改的/新建的/删的",
|
||||
// 靠 `MirrorSnapshot`(上次写入值的内容指纹)区分:
|
||||
//
|
||||
// | 系统日历当前状态 | 判定 | 动作 |
|
||||
// |-----------------------------------|------------------|---------------------------|
|
||||
// | 有 identifier,指纹 == 快照 | 我们自己写的 | 跳过 |
|
||||
// | 有 identifier,指纹 != 快照 | 用户改了 | 写回本地库 → 置 dirty 推送 |
|
||||
// | 有 identifier,但**没有快照** | 认作是我们写的 | **补快照后跳过**(不误判) |
|
||||
// | 快照有,系统里没了 | 用户删了 | markDeleted → 推送 DELETE |
|
||||
// | identifier 不是我们的格式 | 用户在系统日历新建 | 新建 LocalEvent → 推送 |
|
||||
//
|
||||
// ⚠️ "没有快照就补快照后跳过"这条非常关键:首次启用回写时快照是空的,
|
||||
// 若按"无快照=用户改了"处理,会把全部日程标脏、全量重推服务器。
|
||||
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { calendarManager } from '@kit.CalendarKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { EventDb, LocalEvent } from './EventDb';
|
||||
import { LogUtil } from './LogUtil';
|
||||
import { AppSettings } from './AppSettings';
|
||||
import { MirrorSnapshot, MirrorSnapshotRow } from './MirrorSnapshot';
|
||||
import { BookRef, SystemCalendarMirror } from './SystemCalendarMirror';
|
||||
|
||||
/** 一次入站对账的统计 */
|
||||
export class InboundResult {
|
||||
created: number = 0; // 系统日历新建 → 加进 CalDAV
|
||||
updated: number = 0; // 系统日历修改 → 回写 CalDAV
|
||||
deleted: number = 0; // 系统日历删除 → 从 CalDAV 删
|
||||
skipped: number = 0;
|
||||
failed: number = 0;
|
||||
}
|
||||
|
||||
export class MirrorInbound {
|
||||
private static readonly DAY: number = 86400000;
|
||||
private static readonly QUERY_FIELDS: (keyof calendarManager.Event)[] = [
|
||||
'id', 'type', 'title', 'startTime', 'endTime', 'isAllDay',
|
||||
'description', 'location', 'identifier', 'reminderTime', 'recurrenceRule'
|
||||
];
|
||||
|
||||
/**
|
||||
* 入站对账主入口。
|
||||
* ⚠️ 必须在 `SyncEngine.syncAccount()` **之前**调用 —— 它产生的 dirty 行要借本次同步一起推送。
|
||||
*/
|
||||
static async reconcile(context: common.Context): Promise<InboundResult> {
|
||||
const res: InboundResult = new InboundResult();
|
||||
try {
|
||||
const on: boolean = await AppSettings.getMirrorInbound(context);
|
||||
if (!on) {
|
||||
return res;
|
||||
}
|
||||
if (!await SystemCalendarMirror.hasPermission()) {
|
||||
return res;
|
||||
}
|
||||
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
|
||||
// ⭐ 与出站镜像用**同一套**稳定引用(bookId 由服务器 href 派生),否则两边对不上号
|
||||
const refs: BookRef[] = await SystemCalendarMirror.selectedBookRefs(context);
|
||||
for (const ref of refs) {
|
||||
try {
|
||||
await MirrorInbound.reconcileBook(context, cm, ref, res);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
res.failed++;
|
||||
LogUtil.write(`入站对账失败 bookId=${ref.bookId}: ${e.code ?? ''} ${e.message}`);
|
||||
}
|
||||
}
|
||||
if (res.created + res.updated + res.deleted > 0) {
|
||||
LogUtil.write(
|
||||
`入站对账:新建=${res.created} 修改=${res.updated} 删除=${res.deleted} 跳过=${res.skipped}`);
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`入站对账异常: ${e.message}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/* ==================== 单个日历本 ==================== */
|
||||
|
||||
private static async reconcileBook(
|
||||
context: common.Context,
|
||||
cm: calendarManager.CalendarManager,
|
||||
ref: BookRef,
|
||||
res: InboundResult
|
||||
): Promise<void> {
|
||||
const calKey: string = ref.calKey; // 仅用于查本地库
|
||||
const bucket: string = ref.bookId; // ⭐ 系统日历侧的一切标识都用 bookId
|
||||
const accountName: string = ref.sysAccountName;
|
||||
let cal: calendarManager.Calendar | undefined = undefined;
|
||||
const calendars: calendarManager.Calendar[] = await cm.getAllCalendars();
|
||||
for (const c of calendars) {
|
||||
if (c.getAccount().name === accountName) {
|
||||
cal = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cal === undefined) {
|
||||
return; // 还没镜像过,无从对账
|
||||
}
|
||||
const href: string = ref.href;
|
||||
if (href === '') {
|
||||
LogUtil.write(`入站对账跳过 bookId=${bucket}:找不到对应的日历本集合 URL`);
|
||||
return;
|
||||
}
|
||||
|
||||
const snaps: Map<string, MirrorSnapshotRow> = await MirrorSnapshot.loadMap(context, bucket);
|
||||
const events: calendarManager.Event[] = await cal.getEvents(
|
||||
undefined, MirrorInbound.QUERY_FIELDS);
|
||||
|
||||
// 分成两类:带我们 identifier 的(可判定改/删) vs 不是我们的(用户新建)
|
||||
const mine: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
|
||||
const others: calendarManager.Event[] = [];
|
||||
for (const ev of events) {
|
||||
const id: string = ev.identifier ?? '';
|
||||
if (id !== '' && id.startsWith(`${bucket}|`)) {
|
||||
mine.set(id, ev);
|
||||
} else {
|
||||
others.push(ev);
|
||||
}
|
||||
}
|
||||
|
||||
// 本地库现有事件:uid -> LocalEvent
|
||||
const locals: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
|
||||
const byUid: Map<string, LocalEvent> = new Map<string, LocalEvent>();
|
||||
for (const e of locals) {
|
||||
if (e.uid !== '') {
|
||||
byUid.set(e.uid, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ① 修改
|
||||
for (const id of mine.keys()) {
|
||||
const ev: calendarManager.Event | undefined = mine.get(id);
|
||||
if (ev === undefined) {
|
||||
continue;
|
||||
}
|
||||
const fp: string = MirrorSnapshot.fingerprintOf(ev);
|
||||
const snap: MirrorSnapshotRow | undefined = snaps.get(id);
|
||||
if (snap === undefined) {
|
||||
// ⚠️ 没有快照:认作是我们自己写的,补一份后跳过 —— 绝不误判成"用户改了"
|
||||
await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp));
|
||||
res.skipped++;
|
||||
continue;
|
||||
}
|
||||
snaps.delete(id); // 还活着,稍后剩下的才是"被删了"
|
||||
if (snap.fingerprint === fp) {
|
||||
res.skipped++; // 没动
|
||||
continue;
|
||||
}
|
||||
// ⚠️ 重复日程的**某一次发生**(identifier 形如 `<bookId>|<uid>@<发生时间戳>`):
|
||||
// 它是我们从一条重复日程展开出来的,不是独立事件 —— 改它没法映射回服务器上的
|
||||
// 那一条 VEVENT,直接跳过,避免把服务器数据搞乱。
|
||||
if (MirrorInbound.isOccurrence(id)) {
|
||||
res.skipped++;
|
||||
await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp));
|
||||
continue;
|
||||
}
|
||||
const uid: string = MirrorSnapshot.uidOf(id);
|
||||
const local: LocalEvent | undefined = byUid.get(uid);
|
||||
if (local === undefined) {
|
||||
res.skipped++; // 本地已无此条(服务器删了/还没落库)→ 交给出站镜像处理
|
||||
continue;
|
||||
}
|
||||
if (local.dirty || local.deleted) {
|
||||
res.skipped++; // 本地本来就有待推送的改动 → 不拿系统值覆盖,避免两边打架
|
||||
continue;
|
||||
}
|
||||
MirrorInbound.applyToLocal(local, ev);
|
||||
await EventDb.updateLocal(context, local);
|
||||
await MirrorSnapshot.put(context, MirrorInbound.rowOf(bucket, id, ev, fp));
|
||||
res.updated++;
|
||||
LogUtil.write(`入站修改回写:${id}(${local.title})`);
|
||||
}
|
||||
|
||||
// ② 删除:快照里剩下的(系统日历里已经没有了)
|
||||
for (const id of snaps.keys()) {
|
||||
const snap: MirrorSnapshotRow | undefined = snaps.get(id);
|
||||
if (snap === undefined) {
|
||||
continue;
|
||||
}
|
||||
// 同上:展开出来的"某一次发生"消失(多半是滑出镜像窗口),不能当成用户删除
|
||||
if (MirrorInbound.isOccurrence(id)) {
|
||||
res.skipped++;
|
||||
await MirrorSnapshot.remove(context, id);
|
||||
continue;
|
||||
}
|
||||
const local: LocalEvent | undefined = byUid.get(snap.uid);
|
||||
if (local !== undefined && !local.deleted) {
|
||||
await EventDb.markDeleted(context, local.id);
|
||||
res.deleted++;
|
||||
LogUtil.write(`入站删除回写:${id}(${local.title})`);
|
||||
} else {
|
||||
res.skipped++;
|
||||
}
|
||||
await MirrorSnapshot.remove(context, id);
|
||||
}
|
||||
|
||||
// ③ 新建:系统日历里 identifier 不是我们格式的条目(用户在系统日历里手动加的)
|
||||
for (const ev of others) {
|
||||
if (ev.id === undefined) {
|
||||
continue;
|
||||
}
|
||||
const uid: string = `sc-${bucket}-${ev.id}`;
|
||||
if (byUid.has(uid)) {
|
||||
continue;
|
||||
}
|
||||
// ⚠️ 先把 identifier 写回系统日历,让它变成"我们的"条目。
|
||||
// 否则下一次出站镜像会把同一条再加一遍 → 系统日历里出现重复。
|
||||
const identifier: string = `${bucket}|${uid}`;
|
||||
const stamped: calendarManager.Event = ev;
|
||||
stamped.identifier = identifier;
|
||||
try {
|
||||
await cal.updateEvent(stamped);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
res.skipped++;
|
||||
LogUtil.write(`入站新建跳过:无法写回 identifier(${e.message}),避免重复`);
|
||||
continue;
|
||||
}
|
||||
const e: LocalEvent = new LocalEvent();
|
||||
e.uid = uid;
|
||||
e.calKey = calKey;
|
||||
e.href = href;
|
||||
e.remotePath = encodeURIComponent(uid) + '.ics';
|
||||
e.kind = 'event';
|
||||
MirrorInbound.applyToLocal(e, ev);
|
||||
await EventDb.insertLocal(context, e);
|
||||
await MirrorSnapshot.put(context, MirrorInbound.rowOf(
|
||||
bucket, identifier, ev, MirrorSnapshot.fingerprintOf(ev)));
|
||||
res.created++;
|
||||
LogUtil.write(`入站新建回写:${identifier}(${e.title})`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 辅助 ==================== */
|
||||
|
||||
/**
|
||||
* 是不是"重复系列里的某一次发生"?identifier 形如 `<bookId>|<uid>@<发生时间戳>`。
|
||||
*
|
||||
* ⚠️ 不能用 `includes('@')` 判断:**uid 本身就常含 @**(如 `20260906T163329-d13e0cb6@172.17.0.1`),
|
||||
* 会误判。只有"最后一段是纯数字"才是我们加的发生时间戳。
|
||||
*/
|
||||
private static isOccurrence(id: string): boolean {
|
||||
const pos: number = id.lastIndexOf('@');
|
||||
if (pos < 0) {
|
||||
return false;
|
||||
}
|
||||
const tail: string = id.substring(pos + 1);
|
||||
if (tail === '') {
|
||||
return false;
|
||||
}
|
||||
for (let i: number = 0; i < tail.length; i++) {
|
||||
const c: string = tail[i];
|
||||
if (c < '0' || c > '9') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static rowOf(
|
||||
bucket: string,
|
||||
identifier: string,
|
||||
ev: calendarManager.Event,
|
||||
fp: string
|
||||
): MirrorSnapshotRow {
|
||||
const row: MirrorSnapshotRow = new MirrorSnapshotRow();
|
||||
row.identifier = identifier;
|
||||
row.calKey = bucket;
|
||||
row.uid = MirrorSnapshot.uidOf(identifier);
|
||||
row.sysId = ev.id ?? -1;
|
||||
row.fingerprint = fp;
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 系统日历 Event → 本工程 LocalEvent(只覆盖可映射字段) */
|
||||
private static applyToLocal(e: LocalEvent, ev: calendarManager.Event): void {
|
||||
e.title = ev.title ?? '';
|
||||
e.description = ev.description ?? '';
|
||||
e.location = ev.location?.location ?? '';
|
||||
e.startTime = ev.startTime;
|
||||
e.isAllDay = ev.isAllDay === true;
|
||||
// 系统全天 endTime 可能是"次日 00:00",也可能是"当日 23:59:59.999"
|
||||
// → 统一成本工程约定:"排他结束日前一毫秒"
|
||||
e.endTime = e.isAllDay
|
||||
? MirrorInbound.dayStart(ev.endTime - 1) + MirrorInbound.DAY - 1
|
||||
: ev.endTime;
|
||||
e.rrule = MirrorInbound.rruleOf(ev);
|
||||
e.recurring = e.rrule !== '';
|
||||
if (ev.reminderTime !== undefined && ev.reminderTime.length > 0) {
|
||||
e.reminders = [...ev.reminderTime];
|
||||
e.reminder = ev.reminderTime[0];
|
||||
}
|
||||
}
|
||||
|
||||
private static dayStart(ts: number): number {
|
||||
const d: Date = new Date(ts);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
/** 系统 RecurrenceRule → RRULE 字符串(仅 FREQ/INTERVAL;复杂规则以服务器端为准) */
|
||||
private static rruleOf(ev: calendarManager.Event): string {
|
||||
try {
|
||||
const rr = ev.recurrenceRule;
|
||||
if (rr === undefined) {
|
||||
return '';
|
||||
}
|
||||
const freqMap: string[] = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY'];
|
||||
const fi: number = rr.recurrenceFrequency as number;
|
||||
if (Number.isNaN(fi) || fi < 0 || fi >= freqMap.length) {
|
||||
return '';
|
||||
}
|
||||
let rrule: string = `FREQ=${freqMap[fi]}`;
|
||||
if (rr.interval !== undefined && rr.interval > 1) {
|
||||
rrule += `;INTERVAL=${rr.interval}`;
|
||||
}
|
||||
return rrule;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// entry/src/main/ets/common/MirrorSnapshot.ets
|
||||
// 镜像快照:记录"上一次我们把什么写进了系统日历"。
|
||||
//
|
||||
// 为什么必须有它(防回灌 / 防死循环):
|
||||
// 流程是 CalDAV → 本地库 → 写进系统日历。下一轮同步如果直接读系统日历当"用户改动",
|
||||
// 就会把我们刚写进去的当成用户改的 → 回写服务器 → 服务器变了 → 又镜像回系统日历 …… 死循环。
|
||||
// 有了快照就能三方比对:
|
||||
// · 系统当前值 == 快照 → 没动,是我们自己写的,跳过
|
||||
// · 系统当前值 != 快照 → 用户在系统日历改了 → 回写 CalDAV
|
||||
// · 快照有、系统里没了 → 用户在系统日历删了 → 从 CalDAV 删
|
||||
// · 系统里有、快照没有且 identifier 不是我们的格式 → 用户在系统日历新建 → 加进 CalDAV
|
||||
//
|
||||
// ⚠️ 只存"状态指纹",**不存日程内容**(内容仍在系统日历与本地库各一份,这是阶段 2 的取舍)。
|
||||
// 日程内容只有一份的形态(阶段 3)需要配合"影子表 + 系统日历为内容源",那是另一件事。
|
||||
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { relationalStore } from '@kit.ArkData';
|
||||
import { calendarManager } from '@kit.CalendarKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
/**
|
||||
* 快照行。
|
||||
* ⚠️ `calKey` 这一列实际存的是**镜像桶标识 = bookId**(由服务器 href 派生的稳定标识),
|
||||
* 与系统日历侧 identifier 前缀、账户名后缀完全一致。列名沿用未改,别再传会变的 calKey。
|
||||
*/
|
||||
export class MirrorSnapshotRow {
|
||||
identifier: string = ''; // `<bookId>|<uid>`
|
||||
calKey: string = '';
|
||||
uid: string = '';
|
||||
sysId: number = -1; // 系统日历里的 event id(删除时用)
|
||||
fingerprint: string = ''; // 写入值的内容指纹
|
||||
}
|
||||
|
||||
export class MirrorSnapshot {
|
||||
private static readonly TABLE: string = 'mirror_snapshot';
|
||||
private static ready: boolean = false;
|
||||
|
||||
/* ==================== 表 ==================== */
|
||||
|
||||
private static async db(context: common.Context): Promise<relationalStore.RdbStore> {
|
||||
const store: relationalStore.RdbStore = await relationalStore.getRdbStore(context, {
|
||||
name: 'sync_calendar.db',
|
||||
securityLevel: relationalStore.SecurityLevel.S1
|
||||
});
|
||||
if (!MirrorSnapshot.ready) {
|
||||
await store.executeSql(
|
||||
'CREATE TABLE IF NOT EXISTS mirror_snapshot (' +
|
||||
'identifier TEXT PRIMARY KEY, cal_key TEXT, uid TEXT, ' +
|
||||
'sys_id INTEGER, fingerprint TEXT, updated_at INTEGER)'
|
||||
);
|
||||
MirrorSnapshot.ready = true;
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
/* ==================== 读写 ==================== */
|
||||
|
||||
/** 取某个日历本的全部快照,key = identifier。**bucket 必须是 bookId** */
|
||||
static async loadMap(context: common.Context, bucket: string): Promise<Map<string, MirrorSnapshotRow>> {
|
||||
const out: Map<string, MirrorSnapshotRow> = new Map<string, MirrorSnapshotRow>();
|
||||
try {
|
||||
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
|
||||
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
|
||||
predicates.equalTo('cal_key', bucket);
|
||||
const rs = await store.query(predicates);
|
||||
try {
|
||||
while (rs.goToNextRow()) {
|
||||
const row: MirrorSnapshotRow = new MirrorSnapshotRow();
|
||||
row.identifier = rs.getString(rs.getColumnIndex('identifier'));
|
||||
row.calKey = bucket;
|
||||
row.uid = rs.getString(rs.getColumnIndex('uid'));
|
||||
row.sysId = rs.getLong(rs.getColumnIndex('sys_id'));
|
||||
row.fingerprint = rs.getString(rs.getColumnIndex('fingerprint'));
|
||||
out.set(row.identifier, row);
|
||||
}
|
||||
} finally {
|
||||
rs.close();
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`读取镜像快照失败 bucket=${bucket}: ${e.message}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* **整体重建**某个日历本的快照:先删光旧行,再写入新行。
|
||||
* 为什么不用增量:出站镜像后直接把系统日历里该账户的实际内容重新读一遍再落快照最稳,
|
||||
* 不用在写入过程中追踪 addEvent 返回的 id(批量 addEvents 还不返回 id)。
|
||||
*/
|
||||
static async replaceAll(
|
||||
context: common.Context,
|
||||
bucket: string,
|
||||
events: calendarManager.Event[]
|
||||
): Promise<number> {
|
||||
let n: number = 0;
|
||||
try {
|
||||
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
|
||||
const del = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
|
||||
del.equalTo('cal_key', bucket);
|
||||
await store.delete(del);
|
||||
const now: number = Date.now();
|
||||
for (const ev of events) {
|
||||
const id: string = ev.identifier ?? '';
|
||||
if (id === '') {
|
||||
continue; // 用户在系统日历自己加的、没有我们 identifier 的 → 不进快照(由入站对账新建)
|
||||
}
|
||||
const vb: relationalStore.ValuesBucket = {
|
||||
'identifier': id,
|
||||
'cal_key': bucket,
|
||||
'uid': MirrorSnapshot.uidOf(id),
|
||||
'sys_id': ev.id ?? -1,
|
||||
'fingerprint': MirrorSnapshot.fingerprintOf(ev),
|
||||
'updated_at': now
|
||||
};
|
||||
await store.insert(MirrorSnapshot.TABLE, vb);
|
||||
n++;
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`重建镜像快照失败 bucket=${bucket}: ${e.message}`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 写入/更新单条快照(入站回写成功后同步指纹,避免下次重复判定为"用户改了") */
|
||||
static async put(context: common.Context, row: MirrorSnapshotRow): Promise<void> {
|
||||
try {
|
||||
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
|
||||
const bucket: relationalStore.ValuesBucket = {
|
||||
'identifier': row.identifier,
|
||||
'cal_key': row.calKey,
|
||||
'uid': row.uid,
|
||||
'sys_id': row.sysId,
|
||||
'fingerprint': row.fingerprint,
|
||||
'updated_at': Date.now()
|
||||
};
|
||||
await store.insert(MirrorSnapshot.TABLE, bucket);
|
||||
} catch (err) {
|
||||
// 主键冲突时改成 update
|
||||
try {
|
||||
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
|
||||
const bucket: relationalStore.ValuesBucket = {
|
||||
'sys_id': row.sysId,
|
||||
'fingerprint': row.fingerprint,
|
||||
'updated_at': Date.now()
|
||||
};
|
||||
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
|
||||
predicates.equalTo('identifier', row.identifier);
|
||||
await store.update(bucket, predicates);
|
||||
} catch (err2) {
|
||||
const e = err2 as BusinessError;
|
||||
LogUtil.write(`写入镜像快照失败 ${row.identifier}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除单条(用户删了 / 本地已不存在) */
|
||||
static async remove(context: common.Context, identifier: string): Promise<void> {
|
||||
try {
|
||||
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
|
||||
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
|
||||
predicates.equalTo('identifier', identifier);
|
||||
await store.delete(predicates);
|
||||
} catch (err) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除整个日历本的快照(取消勾选 / 关闭镜像时调用)。bucket = bookId */
|
||||
static async removeByCalKey(context: common.Context, bucket: string): Promise<void> {
|
||||
try {
|
||||
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
|
||||
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
|
||||
predicates.equalTo('cal_key', bucket);
|
||||
await store.delete(predicates);
|
||||
} catch (err) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空全部快照(关闭镜像总开关时用,顺带清掉历史遗留的 calKey 键) */
|
||||
static async clearAll(context: common.Context): Promise<void> {
|
||||
try {
|
||||
const store: relationalStore.RdbStore = await MirrorSnapshot.db(context);
|
||||
const predicates = new relationalStore.RdbPredicates(MirrorSnapshot.TABLE);
|
||||
await store.delete(predicates);
|
||||
} catch (err) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 指纹 ==================== */
|
||||
|
||||
/**
|
||||
* 事件内容指纹。用于判断"系统日历里这条,是不是还是我们上次写进去的样子"。
|
||||
* ⚠️ 全天日程的 endTime 会被系统归一化(我们写次日 00:00,可能读回 23:59:59.999),
|
||||
* 所以全天一律折成"日"再算指纹,否则每次都判定成"用户改了"。
|
||||
*/
|
||||
static fingerprintOf(ev: calendarManager.Event): string {
|
||||
const allDay: boolean = ev.isAllDay === true;
|
||||
let start: number = ev.startTime;
|
||||
let end: number = ev.endTime;
|
||||
if (allDay) {
|
||||
start = MirrorSnapshot.dayStart(start);
|
||||
end = MirrorSnapshot.dayStart(end - 1);
|
||||
}
|
||||
const raw: string = [
|
||||
ev.title ?? '',
|
||||
`${start}`,
|
||||
`${end}`,
|
||||
allDay ? '1' : '0',
|
||||
ev.location?.location ?? '',
|
||||
ev.description ?? '',
|
||||
MirrorSnapshot.hash(ev.recurrenceRule !== undefined ? JSON.stringify(ev.recurrenceRule) : '')
|
||||
].join('\u0001');
|
||||
return `${MirrorSnapshot.hash(raw)}:${MirrorSnapshot.hash(raw, 5381)}`;
|
||||
}
|
||||
|
||||
/** identifier (`<calKey>|<uid>`) → uid */
|
||||
static uidOf(identifier: string): string {
|
||||
const pos: number = identifier.indexOf('|');
|
||||
return pos < 0 ? '' : identifier.substring(pos + 1);
|
||||
}
|
||||
|
||||
private static dayStart(ts: number): number {
|
||||
const d: Date = new Date(ts);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
/** 简单字符串哈希(djb2 变体)。两个不同种子各算一次拼起来,降低碰撞概率 */
|
||||
private static hash(s: string, seed?: number): string {
|
||||
let h: number = seed === undefined ? 0 : seed;
|
||||
for (let i: number = 0; i < s.length; i++) {
|
||||
h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return (h >>> 0).toString(36);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,13 @@
|
||||
// 双向同步引擎:先推本地修改(PUT/DELETE),再拉远端变更(REPORT + etag 增量)
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount } from './AccountStore';
|
||||
import { DavAccount, AccountStore } from './AccountStore';
|
||||
import { EventDb, LocalEvent, RemoteEvent } from './EventDb';
|
||||
import { IcsUtil } from './IcsUtil';
|
||||
import { DavClient, DavColorEntry, RemoteItem } from './DavClient';
|
||||
import { DavClient, DavColorEntry, DavHttpError, RemoteItem } from './DavClient';
|
||||
import { LogUtil } from './LogUtil';
|
||||
import { AppSettings } from './AppSettings';
|
||||
import { SystemCalendarMirror } from './SystemCalendarMirror';
|
||||
|
||||
export class SyncEngine {
|
||||
/** 正在同步中的账号 id → 开始时间:防止超时后的"僵尸同步"与新一轮同步并发写库 */
|
||||
@@ -46,6 +47,18 @@ export class SyncEngine {
|
||||
// 日志不记录账号名(用户自定义,可能含个人信息),以 id + 服务器地址定位
|
||||
LogUtil.write(`========== 同步账号 id=${acc.id} 开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`);
|
||||
try {
|
||||
// ⭐ 库结构升级(recurrence_id 列):老库里覆盖实例的该列全是 0,而被丢弃的
|
||||
// RECURRENCE-ID 无法从本地任何字段反推 → 必须靠一次全量重拉(GET 每个 .ics)回填。
|
||||
// 不回填的后果:把某次发生改期的覆盖实例无法排除主事件在原时间的那次发生,
|
||||
// App 与系统日历里同一日程会显示两条(幽灵日程)。
|
||||
// ⚠️ 判据必须是**数据库里的真实状态**:内存标志会被卡片进程/进程重启吃掉
|
||||
// (2026-09-19 实测两次同步都没触发)。回填完成后计数自然归 0,不会反复重拉。
|
||||
await EventDb.getDb(context);
|
||||
const ridMissing: number = await EventDb.countOverridesMissingRecurrenceId(context);
|
||||
if (ridMissing > 0) {
|
||||
LogUtil.write(`recurrence_id 待回填 ${ridMissing} 条覆盖实例 → 重置"一次性全量重拉"`);
|
||||
await AppSettings.resetFullRefetch(context);
|
||||
}
|
||||
// 全量重拉(升级后首次)要逐个 GET 所有资源,放宽超时到 10 分钟;常规 5 分钟
|
||||
const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context);
|
||||
const timeoutMs: number = fullRefetch ? 600000 : 300000;
|
||||
@@ -79,13 +92,28 @@ export class SyncEngine {
|
||||
LogUtil.write('一次性全量重拉:忽略 etag 复用,全部资源 GET 完整 ICS');
|
||||
}
|
||||
let changed: number = 0;
|
||||
// 服务器上已不存在的日历本(REPORT 404):先跳过继续同步其它日历本,
|
||||
// 循环结束后统一从账号移除(见 removeDeadBooks)。
|
||||
// 2026-09-29 实测:服务器上删掉一个日历本后,本地仍勾选它 → 每次同步 404 →
|
||||
// 旧版直接抛错让整个账号同步失败,且后台同步每轮都失败(一天半没同步成功过)。
|
||||
const deadIdxs: number[] = [];
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
const href: string = acc.calendarHrefs[i];
|
||||
const calKey: string = `${acc.id}_${i}`;
|
||||
const calName: string = i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`;
|
||||
LogUtil.write(`日历本[${i}]「${calName}」开始同步:${href}`);
|
||||
const t1: number = Date.now();
|
||||
const items: RemoteItem[] = await DavClient.reportEtags(href, auth);
|
||||
let items: RemoteItem[] = [];
|
||||
try {
|
||||
items = await DavClient.reportEtags(href, auth);
|
||||
} catch (err) {
|
||||
if (err instanceof DavHttpError && (err as DavHttpError).status === 404) {
|
||||
LogUtil.write(`日历本[${i}]「${calName}」在服务器上已不存在(404),本轮跳过,收尾时自动从账号移除`);
|
||||
deadIdxs.push(i);
|
||||
continue;
|
||||
}
|
||||
throw err as Error;
|
||||
}
|
||||
LogUtil.write(`日历本[${i}]「${calName}」REPORT 返回 ${items.length} 个资源`);
|
||||
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
|
||||
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
|
||||
@@ -185,9 +213,92 @@ export class SyncEngine {
|
||||
LogUtil.write(`日历本[${i}]「${calName}」拉取待办失败(忽略):${te.message}`);
|
||||
}
|
||||
}
|
||||
// 收尾:把本轮发现的"服务器上已删除"的日历本从账号移除并清理本地数据。
|
||||
// 放在循环后(而非循环中)是因为移除会让后续序号前移,循环中途改数组会打乱 calKey。
|
||||
if (deadIdxs.length > 0) {
|
||||
await SyncEngine.removeDeadBooks(context, acc, deadIdxs);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从账号移除「服务器上已不存在(404)」的日历本:收窄配置、迁移序号、清理本地数据并持久化。
|
||||
* 只要有至少一个日历本保留就自动移除;若全部失效则不动配置(避免把账号清空),
|
||||
* 抛错交给上层提示用户去服务器/编辑账号处理。
|
||||
*/
|
||||
private static async removeDeadBooks(context: common.Context, acc: DavAccount,
|
||||
deadIdxs: number[]): Promise<void> {
|
||||
if (deadIdxs.length >= acc.calendarHrefs.length) {
|
||||
throw new Error('该账号下所有日历本在服务器上均已不存在(404),请检查服务器或重新编辑账号');
|
||||
}
|
||||
const deadSet: Set<number> = new Set<number>();
|
||||
for (const d of deadIdxs) {
|
||||
deadSet.add(d);
|
||||
}
|
||||
const removedHrefs: string[] = [];
|
||||
const newHrefs: string[] = [];
|
||||
const newNames: string[] = [];
|
||||
const newColors: string[] = [];
|
||||
const newWritable: string[] = [];
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
if (deadSet.has(i)) {
|
||||
removedHrefs.push(acc.calendarHrefs[i]);
|
||||
continue;
|
||||
}
|
||||
newHrefs.push(acc.calendarHrefs[i]);
|
||||
newNames.push(i < acc.calendarNames.length ? acc.calendarNames[i] : `日历本${i}`);
|
||||
newColors.push(i < acc.calendarColors.length ? acc.calendarColors[i] : '');
|
||||
newWritable.push(i < acc.calendarWritable.length ? acc.calendarWritable[i] : '1');
|
||||
}
|
||||
LogUtil.write(`自动移除 ${removedHrefs.length} 个在服务器上不存在的日历本,保留 ${newHrefs.length} 个`);
|
||||
// 1) 先按 href 删掉被移除日历本的本地行——必须在序号迁移之前,
|
||||
// 否则序号收窄后这些行会混进接替该序号的其它日历本(pruneByHrefs 注释详述)
|
||||
await EventDb.pruneByHrefs(context, acc.id, removedHrefs);
|
||||
// 2) 序号迁移:保留的日历本按原顺序收紧(旧序号 → 新序号)
|
||||
const oldHrefs: string[] = acc.calendarHrefs.slice();
|
||||
const pairs: Array<[string, string]> = [];
|
||||
let ni: number = 0;
|
||||
for (let oi = 0; oi < oldHrefs.length; oi++) {
|
||||
if (deadSet.has(oi)) {
|
||||
continue;
|
||||
}
|
||||
if (ni !== oi) {
|
||||
pairs.push([`${acc.id}_${oi}`, `${acc.id}_${ni}`]);
|
||||
}
|
||||
ni++;
|
||||
}
|
||||
if (pairs.length > 0) {
|
||||
const moved: number = await EventDb.remapCalKeys(context, pairs);
|
||||
LogUtil.write(`移除失效日历本后迁移日程 ${moved} 行(${pairs.length} 个日历本序号前移)`);
|
||||
}
|
||||
// 3) 收窄账号配置并持久化
|
||||
acc.calendarHrefs = newHrefs;
|
||||
acc.calendarNames = newNames;
|
||||
acc.calendarColors = newColors;
|
||||
acc.calendarWritable = newWritable;
|
||||
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);
|
||||
}
|
||||
// 4) 兜底:该账号下 calKey 不在有效列表的行全部清掉
|
||||
const validKeys: string[] = [];
|
||||
for (let i = 0; i < newHrefs.length; i++) {
|
||||
validKeys.push(`${acc.id}_${i}`);
|
||||
}
|
||||
await EventDb.pruneAccountEvents(context, acc.id, validKeys);
|
||||
// 5) 若被移除的日历本正是「系统日历 → CalDAV」的备份目标,当场判失效并关闭备份
|
||||
try {
|
||||
const backupCheck = await SystemCalendarMirror.checkBackupTarget(context);
|
||||
if (backupCheck.closed) {
|
||||
LogUtil.write('备份目标日历本已在服务器上删除,备份功能已自动关闭');
|
||||
}
|
||||
} catch (err) {
|
||||
// 检查失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新服务器端日历本颜色:PROPFIND getcolor → 按集合路径匹配更新 acc.calendarColors
|
||||
* 失败静默(颜色不影响数据正确性)
|
||||
@@ -280,6 +391,7 @@ export class SyncEngine {
|
||||
r.completed = e.completed;
|
||||
r.rrule = e.rrule;
|
||||
r.exdate = e.exdate;
|
||||
r.recurrenceId = e.recurrenceId;
|
||||
r.reminder = e.reminder;
|
||||
r.reminders = e.reminders.slice();
|
||||
return r;
|
||||
@@ -316,6 +428,7 @@ export class SyncEngine {
|
||||
LogUtil.write(`推送重复主事件(标题已脱敏)uid=${e.uid}`);
|
||||
}
|
||||
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
|
||||
try {
|
||||
if (e.deleted) {
|
||||
await DavClient.deleteRemote(url, auth);
|
||||
await EventDb.purge(context, e.id);
|
||||
@@ -326,6 +439,15 @@ export class SyncEngine {
|
||||
await EventDb.clearDirty(context, e.id, etag);
|
||||
LogUtil.write(`推送保存(标题已脱敏)uid=${e.uid} → ${url}(${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'})`);
|
||||
}
|
||||
} catch (err) {
|
||||
// 单条推送失败不再中断整个账号(旧行为:一条 404 就让整个账号同步失败)。
|
||||
// 401(凭据失效)例外:后续每条也必然失败,直接抛出让上层提示。
|
||||
if (err instanceof DavHttpError && (err as DavHttpError).status === 401) {
|
||||
throw err as Error;
|
||||
}
|
||||
const pe = err as Error;
|
||||
LogUtil.write(`推送单条失败(跳过,保留待推送):uid=${e.uid} → ${pe.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +467,16 @@ export class SyncEngine {
|
||||
await EventDb.clearDirty(context, e.id, '');
|
||||
}
|
||||
}
|
||||
// ⭐ 顺带清理"同一资源被重复插入"的冗余行(旧版 applyRemote 判重缺陷遗留的脏数据)。
|
||||
// 纯本地去重、不推服务器;放在这里是因为它是每次同步都会经过的收尾步骤。
|
||||
try {
|
||||
const dup: number = await EventDb.dedupeDuplicateRows(context);
|
||||
if (dup > 0) {
|
||||
LogUtil.write(`清理本地重复行:${dup} 条(同一资源被插了多次)`);
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略:去重失败不影响同步
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,6 +497,19 @@ export class SyncEngine {
|
||||
if (removed > 0) {
|
||||
LogUtil.write(`清理孤儿日程行:${removed} 条(calKey 不属于任何现有账号)`);
|
||||
}
|
||||
// 兜底清扫:行上 href 已不属于任何已选日历本的残留行(calKey 可能因历史迁移
|
||||
// 缺陷混在有效日历本名下,calKey 维度抓不到,href 才暴露真实归属)。
|
||||
// 典型如服务器删除日历本后残留的"幽灵"数据——不清掉的话镜像会一直把它写进系统日历。
|
||||
const validHrefs: string[] = [];
|
||||
for (const a of accounts) {
|
||||
for (const h of a.calendarHrefs) {
|
||||
validHrefs.push(h);
|
||||
}
|
||||
}
|
||||
const stale: number = await EventDb.pruneStaleHrefs(context, validHrefs);
|
||||
if (stale > 0) {
|
||||
LogUtil.write(`清理失效日历本残留行:${stale} 条(href 已不属于任何已选日历本)`);
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`清理孤儿日程行失败(忽略):${e.message}`);
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { calendarManager } from '@kit.CalendarKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { AccountStore, DavAccount } from './AccountStore';
|
||||
import { EventDb, LocalEvent } from './EventDb';
|
||||
import { AppSettings } from './AppSettings';
|
||||
import { LogUtil } from './LogUtil';
|
||||
import { BookRef, SystemCalendarMirror } from './SystemCalendarMirror';
|
||||
|
||||
export class SystemCalendarImport {
|
||||
/**
|
||||
@@ -16,6 +16,15 @@ export class SystemCalendarImport {
|
||||
* 幂等:uid = syscal-<系统日历id>-<事件id>,已存在则跳过,因此每次同步都可安全执行。
|
||||
* 只读取 calendarType = LOCAL 的系统日历(手机本地日历/应用创建的日程),
|
||||
* 系统 CalDAV 同步产生的账户日历一律跳过,避免死循环回灌。
|
||||
*
|
||||
* ⚠️ 另有两层防重复(09-18 补):
|
||||
* 1. **跳过本应用镜像出去的账户**(`synccalendar.mirror.*`)——它们的 type 也是 LOCAL,
|
||||
* 但内容是我们从 CalDAV 写进去的,再导进备份本会让同一条日程在服务器出现两份。
|
||||
* 2. 导进备份本的 uid 统一带 `syscal-` 前缀,出站镜像会跳过该前缀 → 备份本不会被镜像回系统日历。
|
||||
*
|
||||
* ⚠️ 目标本用**稳定标识 bookId** 解析(`SystemCalendarMirror.backupTargetRef`):
|
||||
* 用户「取消勾选」目标本后序号会整体前移,老版本按 `accId_序号` 定位会**导进另一个本**。
|
||||
* 现在解析不到即视为失效,并把「备份到 CalDAV」模式**自动关回仅显示**。
|
||||
*/
|
||||
static async importIfNeeded(context: common.Context): Promise<number> {
|
||||
try {
|
||||
@@ -23,30 +32,42 @@ export class SystemCalendarImport {
|
||||
if (mode !== 'backup') {
|
||||
return 0;
|
||||
}
|
||||
const calKey: string = await AppSettings.getBackupCalKey(context);
|
||||
if (calKey === '') {
|
||||
LogUtil.write('系统日历备份:未选择目标日历本,跳过导入');
|
||||
// ⭐ 目标本一律走 `backupTargetRef()`:内部用**稳定标识 bookId** 解析,
|
||||
// 并兼做「老 calKey 值 → bookId」的一次性迁移。
|
||||
// 解析不到时它会**自动把 sys_cal_mode 关回 display**(用户明确要求的行为):
|
||||
// 目标本被取消同步/账号被删后,绝不能"随便挑一个本"把系统日历导进去。
|
||||
const ref: BookRef | undefined = await SystemCalendarMirror.backupTargetRef(context);
|
||||
if (ref === undefined) {
|
||||
// 未选择目标本(或刚被判失效并关闭)→ 跳过。原因已由 backupTargetRef 写进日志。
|
||||
LogUtil.write('系统日历备份:当前无有效目标日历本,跳过导入');
|
||||
return 0;
|
||||
}
|
||||
// 定位目标 CalDAV 日历本
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
let targetHref: string = '';
|
||||
for (const acc of accounts) {
|
||||
for (let i = 0; i < acc.calendarHrefs.length; i++) {
|
||||
if (`${acc.id}_${i}` === calKey) {
|
||||
targetHref = acc.calendarHrefs[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
const calKey: string = ref.calKey; // 仅用于查本地库 / 落库
|
||||
const targetHref: string = ref.href;
|
||||
if (targetHref === '') {
|
||||
LogUtil.write(`系统日历备份:目标日历本 ${calKey} 不存在(账号可能已删除),跳过导入`);
|
||||
LogUtil.write(`系统日历备份:目标日历本 ${ref.bookId} 缺少集合 URL,跳过导入`);
|
||||
return 0;
|
||||
}
|
||||
const uiContext = context as common.UIAbilityContext;
|
||||
const mgr: calendarManager.CalendarManager = calendarManager.getCalendarManager(uiContext);
|
||||
const calendars: calendarManager.Calendar[] = await mgr.getAllCalendars();
|
||||
// 诊断:把 getAllCalendars() 实际能读到的账户全列出来。
|
||||
// 用于确认"是否只能读到本应用创建的 + 系统默认账户",还是能读到其他应用自建的日历账户。
|
||||
const brief: string[] = [];
|
||||
for (const cal of calendars) {
|
||||
let acc: calendarManager.CalendarAccount;
|
||||
try {
|
||||
acc = cal.getAccount();
|
||||
} catch (err) {
|
||||
continue;
|
||||
}
|
||||
const tag: string = SystemCalendarMirror.isMirrorAccountName(acc.name) ? '(我们的镜像)' : '';
|
||||
brief.push(`${acc.name}[type=${acc.type}]${tag}`);
|
||||
}
|
||||
LogUtil.write(`系统日历账户清单:共 ${calendars.length} 个 → ${brief.join(' | ')}`);
|
||||
let imported: number = 0;
|
||||
let skippedCal: number = 0;
|
||||
let skippedMirror: number = 0; // 本应用镜像出去的账户(不能导进备份本,否则重复)
|
||||
for (const cal of calendars) {
|
||||
try {
|
||||
// 只处理"本地"类型系统日历;CalDAV/订阅等账户日历的数据源本来就在服务器上,跳过防回灌
|
||||
@@ -55,6 +76,13 @@ export class SystemCalendarImport {
|
||||
skippedCal++;
|
||||
continue;
|
||||
}
|
||||
// ⚠️ 关键:跳过**本应用自己镜像出去的账户**(type 同样 LOCAL,但内容是我们从 CalDAV 写进去的)。
|
||||
// 若一并导入备份本,同一条日程会在服务器里出现两份(原始本一份、备份本一份)→ 数据非常乱。
|
||||
// 这些账户的改动由 MirrorInbound 负责回写到它**原本所属**的日历本,不走备份通道。
|
||||
if (SystemCalendarMirror.isMirrorAccountName(account.name)) {
|
||||
skippedMirror++;
|
||||
continue;
|
||||
}
|
||||
const events: calendarManager.Event[] = await cal.getEvents();
|
||||
for (const ev of events) {
|
||||
if (ev.id === undefined) {
|
||||
@@ -86,8 +114,9 @@ export class SystemCalendarImport {
|
||||
// 单个日历读取失败不影响其余
|
||||
}
|
||||
}
|
||||
if (imported > 0 || skippedCal > 0) {
|
||||
LogUtil.write(`系统日历备份:新增 ${imported} 条 → ${calKey}(跳过账户日历 ${skippedCal} 个)`);
|
||||
if (imported > 0 || skippedCal > 0 || skippedMirror > 0) {
|
||||
LogUtil.write(
|
||||
`系统日历备份:新增 ${imported} 条 → ${calKey}(跳过非本地账户 ${skippedCal} 个、镜像账户 ${skippedMirror} 个)`);
|
||||
}
|
||||
return imported;
|
||||
} catch (err) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,8 +28,8 @@ export struct TipsPanel {
|
||||
{
|
||||
icon: $r('app.media.tip_calendar'),
|
||||
title: '系统日历,也能一起管',
|
||||
desc: '手机系统日历可与 CalDAV 日历混合显示在同一时间轴;还能把系统日历本通过 CalDAV 同步备份,' +
|
||||
'一处编辑、处处更新。'
|
||||
desc: '手机系统日历可与 CalDAV 日历混合显示在同一时间轴,也能把系统日历本通过 CalDAV 同步备份,' +
|
||||
'一处编辑、处处更新。到「设置 → 混合显示系统日历」开启并授权即可。'
|
||||
},
|
||||
{
|
||||
icon: $r('app.media.tip_mute'),
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
|
||||
import { AbilityConstant, ConfigurationConstant, UIAbility, Want, Configuration, EnvironmentCallback } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
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';
|
||||
import { AppSettings } from '../common/AppSettings';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
|
||||
@@ -19,24 +16,11 @@ export default class EntryAbility extends UIAbility {
|
||||
BackgroundSyncService.initOnLaunch(this.context);
|
||||
// 服务卡片"添加"按钮跳转:把 widgetAction=add 透传给主页面(在 AppStorage 中暂存)
|
||||
this.handleWidgetParam(want);
|
||||
// 通知权限:日程提醒、后台同步常驻通知都依赖它(系统只弹一次授权框)。
|
||||
// 必须在用户同意《隐私政策》之后才申请 —— 同意前不得申请任何权限;
|
||||
// 首次安装此处跳过,用户点"同意并继续"后由 Index.initPermissionAndLoad() 补申请。
|
||||
AppSettings.getPolicyAgreed(this.context)
|
||||
.then((agreed: boolean): Promise<void> => {
|
||||
if (!agreed) {
|
||||
LogUtil.write('未同意隐私政策:跳过通知权限申请');
|
||||
return Promise.resolve();
|
||||
}
|
||||
return notificationManager.isNotificationEnabled()
|
||||
.then((enabled: boolean): Promise<void> => {
|
||||
if (!enabled) {
|
||||
return notificationManager.requestEnableNotification(this.context).catch((): void => {});
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
})
|
||||
.catch((): void => {});
|
||||
// ⚠️ 这里**不再申请任何权限**(含通知权限)。
|
||||
// 依据《审核指南》7.17:应用不得在用户未主动点击对应功能时提前弹窗申请权限。
|
||||
// 通知权限改为在用户主动操作时申请:
|
||||
// ① 保存带提醒的日程(EventEditPage)② 开启「后台持续同步」(SettingsPage)
|
||||
// ③ 设置页「通知权限」→「去开启通知」按钮
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
@@ -82,38 +66,120 @@ export default class EntryAbility extends UIAbility {
|
||||
|
||||
onWindowStageCreate(windowStage: window.WindowStage): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
|
||||
// ① 先把应用固定为「跟随系统」深浅色。必须在取窗口/刷系统栏之前调用,
|
||||
// 否则会读到尚未更新到深色的色彩配置(曾导致深色下状态栏仍是浅灰)。
|
||||
try {
|
||||
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
|
||||
}
|
||||
|
||||
// ② 沉浸式配色:状态栏/导航条背景**透明**,透出「主题化」的窗口底色,
|
||||
// 消除顶部通知栏与底部灰条;前景(时间/图标)颜色按实际底色亮度取反。
|
||||
try {
|
||||
const win: window.Window = windowStage.getMainWindowSync();
|
||||
this.mainWindow = win;
|
||||
this.applySystemBars(win);
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to apply immersive bars. Cause: %{public}s', JSON.stringify(err));
|
||||
}
|
||||
|
||||
windowStage.loadContent('pages/Index', (err) => {
|
||||
if (err.code) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
|
||||
return;
|
||||
}
|
||||
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
|
||||
// 沉浸式配色:状态栏/导航条与页面背景同色(app.color.page_bg = #F1F3F5),
|
||||
// 消除顶部通知栏和底部的白色条
|
||||
windowStage.getMainWindow((werr: BusinessError, win: window.Window) => {
|
||||
if (werr.code) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to get main window. Cause: %{public}s', JSON.stringify(werr));
|
||||
return;
|
||||
});
|
||||
|
||||
// ③ 系统深浅色切换时重刷配色。用 applicationContext 的 environment 回调,
|
||||
// 它比 UIAbility.onConfigurationUpdate 更可靠(后者在部分场景下不回调,
|
||||
// 会出现"页面已变深色但状态栏/底部仍是浅灰"的现象)。
|
||||
try {
|
||||
const cb: EnvironmentCallback = {
|
||||
onConfigurationUpdated: (config: Configuration): void => {
|
||||
if (this.mainWindow !== undefined) {
|
||||
this.applySystemBars(this.mainWindow);
|
||||
}
|
||||
},
|
||||
onMemoryLevel: (level: AbilityConstant.MemoryLevel): void => {
|
||||
}
|
||||
};
|
||||
this.context.getApplicationContext().on('environment', cb);
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to watch environment. Cause: %{public}s', JSON.stringify(err));
|
||||
}
|
||||
}
|
||||
|
||||
/** 主窗口引用:系统深浅色切换时需要重新刷状态栏/导航栏配色 */
|
||||
private mainWindow?: window.Window;
|
||||
|
||||
/** 当前是否处于系统深色模式(兜底用) */
|
||||
private isDarkMode(): boolean {
|
||||
try {
|
||||
return this.context.config.colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前主题下 app.color.page_bg 的真实取值(经 dark/ 限定符解析):
|
||||
* 浅色 #F1F3F5 / 深色 #000000。以"资源系统实际解析出的颜色"为准,
|
||||
* 这样才能与页面真正渲染出来的背景色一致,不受 config 更新时机影响。
|
||||
*/
|
||||
private themePageBg(): string {
|
||||
try {
|
||||
const c: number = this.context.resourceManager.getColorSync($r('app.color.page_bg').id) >>> 0;
|
||||
return '#' + c.toString(16).padStart(8, '0');
|
||||
} catch (err) {
|
||||
return this.isDarkMode() ? '#000000' : '#F1F3F5';
|
||||
}
|
||||
}
|
||||
|
||||
/** 背景是深色时需要浅色前景,反之用深色前景 */
|
||||
private isDarkBg(bg: string): boolean {
|
||||
const s: string = bg.startsWith('#') ? bg.substring(1) : bg;
|
||||
if (s.length < 6) {
|
||||
return this.isDarkMode();
|
||||
}
|
||||
const off: number = s.length >= 8 ? 2 : 0;
|
||||
const r: number = parseInt(s.substring(off, off + 2), 16);
|
||||
const g: number = parseInt(s.substring(off + 2, off + 4), 16);
|
||||
const b: number = parseInt(s.substring(off + 4, off + 6), 16);
|
||||
if (isNaN(r) || isNaN(g) || isNaN(b)) {
|
||||
return this.isDarkMode();
|
||||
}
|
||||
return (0.2126 * r + 0.7152 * g + 0.0722 * b) < 128;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前主题设置状态栏/导航条与窗口底色。
|
||||
* 背景取 0x00000000(全透明)——官方推荐的背景沉浸方案:系统栏透明,
|
||||
* 透传应用自身背景,因此底色天然跟随深浅色,不会再出现恒定灰条。
|
||||
*/
|
||||
private applySystemBars(win: window.Window): void {
|
||||
const bgColor: string = this.themePageBg();
|
||||
const fgColor: string = this.isDarkBg(bgColor) ? '#FFFFFF' : '#182431';
|
||||
try {
|
||||
win.setWindowSystemBarProperties({
|
||||
statusBarColor: '#F1F3F5',
|
||||
statusBarContentColor: '#182431',
|
||||
navigationBarColor: '#F1F3F5',
|
||||
navigationBarContentColor: '#182431'
|
||||
statusBarColor: '#00000000',
|
||||
statusBarContentColor: fgColor,
|
||||
navigationBarColor: '#00000000',
|
||||
navigationBarContentColor: fgColor
|
||||
});
|
||||
// 鸿蒙 NEXT 导航条背景跟随窗口背景色:设为页面背景灰,消除底部白条
|
||||
win.setWindowBackgroundColor('#F1F3F5');
|
||||
// 窗口底色=页面背景色,供透明的状态栏/导航条透出
|
||||
win.setWindowBackgroundColor(bgColor);
|
||||
} catch (barErr) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to set system bar properties. Cause: %{public}s',
|
||||
JSON.stringify(barErr));
|
||||
}
|
||||
});
|
||||
});
|
||||
try {
|
||||
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
||||
} catch (err) {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
|
||||
}
|
||||
|
||||
/** 兜底:部分设备/场景只回调 UIAbility.onConfigurationUpdate */
|
||||
onConfigurationUpdate(): void {
|
||||
if (this.mainWindow !== undefined) {
|
||||
this.applySystemBars(this.mainWindow);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// entry/src/main/ets/pages/AccountsPage.ets
|
||||
// DAV 账号管理(由原 CalDAVSync 首页移植)
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { curves, router } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore, TYPE_KEYS, TYPE_CALDAV, TYPE_CARDDAV } from '../common/AccountStore';
|
||||
@@ -247,8 +247,24 @@ struct AccountsPage {
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面转场:淡入淡出。
|
||||
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
|
||||
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
|
||||
*/
|
||||
pageTransition() {
|
||||
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
}
|
||||
|
||||
|
||||
@Builder
|
||||
emptyState() {
|
||||
Column({ space: 12 }) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// entry/src/main/ets/pages/AddAccountPage.ets
|
||||
// 添加账号第一页:URL / 用户名 / 密码 → 连接(凭据经 AppStorage 传给日历本选择页)
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { curves, router } from '@kit.ArkUI';
|
||||
import { buffer } from '@kit.ArkTS';
|
||||
import url from '@ohos.url';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
@@ -292,9 +292,24 @@ struct AddAccountPage {
|
||||
.height('100%')
|
||||
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面转场:淡入淡出。
|
||||
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
|
||||
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
|
||||
*/
|
||||
pageTransition() {
|
||||
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
}
|
||||
|
||||
|
||||
@Builder
|
||||
formField(label: string, placeholder: string, value: string,
|
||||
onChange: (value: string) => void, isPassword: boolean) {
|
||||
@@ -310,6 +325,10 @@ struct AddAccountPage {
|
||||
.fontSize(15)
|
||||
.backgroundColor($r('app.color.input_bg'))
|
||||
.borderRadius(8)
|
||||
// 显式指定文字/提示/光标颜色:默认取系统主题色,深色模式下会与底色撞色而"看不清"
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.placeholderColor($r('app.color.text_hint'))
|
||||
.caretColor($r('app.color.brand'))
|
||||
.onChange(onChange)
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// entry/src/main/ets/pages/CalendarListPage.ets
|
||||
// 添加账号第二页:PROPFIND 列出日历本 → 勾选 → 命名 → 保存账号
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { curves, router } from '@kit.ArkUI';
|
||||
import { buffer } from '@kit.ArkTS';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
|
||||
@@ -249,6 +249,7 @@ struct CalendarListPage {
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.placeholderColor($r('app.color.text_hint'))
|
||||
.caretColor($r('app.color.brand'))
|
||||
.backgroundColor($r('app.color.input_bg'))
|
||||
.borderRadius(10)
|
||||
.border({ width: 1.5, color: $r('app.color.brand') })
|
||||
@@ -364,8 +365,23 @@ struct CalendarListPage {
|
||||
.height('100%')
|
||||
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面转场:淡入淡出。
|
||||
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
|
||||
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
|
||||
*/
|
||||
pageTransition() {
|
||||
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Component
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
// entry/src/main/ets/pages/EditAccountPage.ets
|
||||
// 编辑账号:查看/重选该账号下的日历本、修改账户名
|
||||
// 保存后清理失效日历本的本地数据,并触发一次重新同步
|
||||
import { router } from '@kit.ArkUI';
|
||||
//
|
||||
// ⚠️ 取消勾选某个日历本会改变**后面所有本的序号**(calKey = accId_序号)。因此保存时还要检查:
|
||||
// 被取消的那个本是不是「系统日历 → CalDAV」的备份目标 —— 是的话当场把该功能关闭,
|
||||
// 否则用户会以为备份还在工作,实际却可能把系统日程导进别的本(用户实测反馈)。
|
||||
import { curves, router } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { buffer } from '@kit.ArkTS';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore } from '../common/AccountStore';
|
||||
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
|
||||
import { EventDb } from '../common/EventDb';
|
||||
import { BackupTargetCheck, SystemCalendarMirror } from '../common/SystemCalendarMirror';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
/**
|
||||
@@ -219,6 +224,24 @@ struct EditAccountPage {
|
||||
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
|
||||
LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
|
||||
// 按 href 求出「旧序号 → 新序号」,迁移已落库日程到正确的 calKey
|
||||
// ⭐ 迁移前必须先删掉被取消勾选日历本的行(按 href):
|
||||
// 序号收窄后,被取消本的旧 calKey 会被前面位移下来的本"接管",
|
||||
// 不先删的话旧行会与新主的行混在同一个 calKey 里(2026-09-29 实测:
|
||||
// 服务器删除「读书」后重选,其 988 行混进「轻舟」的 calKey,镜像一直复活幽灵数据)。
|
||||
const newHrefSet: Set<string> = new Set<string>();
|
||||
for (const c of newHrefs) {
|
||||
newHrefSet.add(c);
|
||||
}
|
||||
const removedHrefs: string[] = [];
|
||||
for (const h of oldHrefs) {
|
||||
if (!newHrefSet.has(h)) {
|
||||
removedHrefs.push(h);
|
||||
}
|
||||
}
|
||||
if (removedHrefs.length > 0) {
|
||||
const pruned: number = await EventDb.pruneByHrefs(context, target.id, removedHrefs);
|
||||
LogUtil.write(`编辑账号保存:取消勾选/已失效 ${removedHrefs.length} 个日历本,清理本地日程 ${pruned} 行`);
|
||||
}
|
||||
const remapPairs: Array<[string, string]> = [];
|
||||
for (let oi: number = 0; oi < oldHrefs.length; oi++) {
|
||||
const oldNorm: string = DavClient.normalizeHref(oldHrefs[oi]);
|
||||
@@ -243,8 +266,15 @@ struct EditAccountPage {
|
||||
selectedItems.map((c: EditCalendarItem, i: number): string => `${target.id}_${i}`);
|
||||
await EventDb.pruneAccountEvents(context, target.id, validKeys);
|
||||
AppStorage.setOrCreate<string>('pendingSyncAccountId', target.id);
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: `已保存,同步 ${selectedItems.length} 个日历本` });
|
||||
// ⭐ 若刚被取消勾选的本正是「系统日历 → CalDAV」的备份目标,**当场**判它失效并关闭备份功能。
|
||||
// 不这么做的话,要等下一次同步才会发现目标没了;这期间开关还显示"备份到 CalDAV",
|
||||
// 用户会以为备份仍在正常工作,而实际已经导不进去(或更糟:导进了别的本)。
|
||||
const backupCheck: BackupTargetCheck = await SystemCalendarMirror.checkBackupTarget(context);
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: backupCheck.closed
|
||||
? '原系统日历备份目标已被取消同步,备份功能已自动关闭'
|
||||
: `已保存,同步 ${selectedItems.length} 个日历本`
|
||||
});
|
||||
router.back();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
@@ -318,6 +348,7 @@ struct EditAccountPage {
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.placeholderColor($r('app.color.text_hint'))
|
||||
.caretColor($r('app.color.brand'))
|
||||
.backgroundColor($r('app.color.input_bg'))
|
||||
.borderRadius(10)
|
||||
.border({ width: 1.5, color: $r('app.color.brand') })
|
||||
@@ -434,8 +465,23 @@ struct EditAccountPage {
|
||||
.height('100%')
|
||||
.padding({ left: 24, right: 24, top: 16, bottom: 24 })
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.alignItems(HorizontalAlign.Center)
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面转场:淡入淡出。
|
||||
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
|
||||
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
|
||||
*/
|
||||
pageTransition() {
|
||||
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Component
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// entry/src/main/ets/pages/EventEditPage.ets
|
||||
// 日程编辑页:新建 / 修改 / 删除本地(DAV 或本机)日程
|
||||
import { router } from '@kit.ArkUI';
|
||||
// 日程编辑页:新建 / 修改 / 删除日程(一律属于某个 CalDAV 日历本,不再支持"本机"日程)
|
||||
import { curves, router } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore, CalSource, BookPalette } from '../common/AccountStore';
|
||||
import { notificationManager } from '@kit.NotificationKit';
|
||||
import { DavAccount, AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY } from '../common/AccountStore';
|
||||
import { EventDb, LocalEvent } from '../common/EventDb';
|
||||
import { AppSettings } from '../common/AppSettings';
|
||||
import { DavClient } from '../common/DavClient';
|
||||
@@ -51,8 +52,9 @@ struct EventEditPage {
|
||||
if (context === undefined) {
|
||||
return;
|
||||
}
|
||||
// 收集可写入的日历本(DAV 可写日历本 + 本机);只读日历本不出现在新建/编辑选择中
|
||||
const sources: CalSourceWithHref[] = await CalendarDataBridge.loadWritableSources(context);
|
||||
// 收集可写入的日历本:只取 **CalDAV** 可写日历本("本机"不再出现在选择里);
|
||||
// 只读日历本同样不出现
|
||||
const sources: CalSourceWithHref[] = await CalendarDataBridge.loadWritableDavSources(context);
|
||||
const choices: BookChoice[] = [];
|
||||
for (const s of sources) {
|
||||
const b = new BookChoice();
|
||||
@@ -63,6 +65,10 @@ struct EventEditPage {
|
||||
choices.push(b);
|
||||
}
|
||||
this.books = choices;
|
||||
if (choices.length === 0) {
|
||||
// 没有可写入的 CalDAV 日历本 → 日程无处可写,给明确指引(保存时也会再拦一次)
|
||||
this.statusMsg = '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号';
|
||||
}
|
||||
|
||||
// 编辑既有事件
|
||||
const pendingId: number | undefined = AppStorage.get<number>('pendingEventId');
|
||||
@@ -78,6 +84,11 @@ struct EventEditPage {
|
||||
this.startMs = loaded.startTime;
|
||||
this.endMs = loaded.endTime;
|
||||
this.chosenKey = loaded.calKey;
|
||||
// 历史遗留的"本机"日程:本应用已不再支持本机日程,改为引导用户挑一个 CalDAV 日历本
|
||||
// (保存时 calKey/href 会改写为该日历本,事件随后被推送到服务器)
|
||||
if (loaded.calKey === LOCAL_CAL_KEY) {
|
||||
this.statusMsg = '这条日程原先只存在「本机」(不同步)。请选一个 CalDAV 日历本,保存后即会同步到服务器。';
|
||||
}
|
||||
this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none';
|
||||
this.reminderList = loaded.reminders.length > 0 ? loaded.reminders
|
||||
: (loaded.reminder > 0 ? [loaded.reminder] : []);
|
||||
@@ -224,6 +235,14 @@ struct EventEditPage {
|
||||
this.statusMsg = '重复日程的单次修改暂不支持,请到服务器端调整重复规则';
|
||||
return;
|
||||
}
|
||||
// 日程必须落在某个 CalDAV 日历本上("本机"不再是可选目标)——没选到就直接拦下
|
||||
const book = this.chosenBook();
|
||||
if (book === null) {
|
||||
this.statusMsg = this.books.length === 0
|
||||
? '还没有可写入的 CalDAV 日历本,请先到「更多 → 账号管理」添加账号'
|
||||
: '请选择要同步到的 CalDAV 日历本';
|
||||
return;
|
||||
}
|
||||
this.isSaving = true;
|
||||
this.statusMsg = '';
|
||||
try {
|
||||
@@ -231,7 +250,6 @@ struct EventEditPage {
|
||||
if (context === undefined) {
|
||||
return;
|
||||
}
|
||||
const book = this.chosenBook();
|
||||
const e = this.event ?? new LocalEvent();
|
||||
const isNew: boolean = this.event === null;
|
||||
e.title = this.title.trim();
|
||||
@@ -240,8 +258,9 @@ struct EventEditPage {
|
||||
e.startTime = this.startMs;
|
||||
e.endTime = this.allDay ? this.startMs + 86399999 : this.endMs;
|
||||
e.isAllDay = this.allDay;
|
||||
e.calKey = book !== null ? book.calKey : 'local';
|
||||
e.href = book !== null ? book.href : '';
|
||||
// 一律写入所选 CalDAV 日历本(新建/编辑/从「本机」迁过来都一样)
|
||||
e.calKey = book.calKey;
|
||||
e.href = book.href;
|
||||
// 重复规则与提醒
|
||||
if (this.repeatMode === 'custom') {
|
||||
// 无法识别的既有规则原样保留
|
||||
@@ -272,6 +291,11 @@ struct EventEditPage {
|
||||
}
|
||||
// 日志仅记录 uid 与提醒/重复设置,不落盘日程标题(避免隐私内容进入可备份的 sync.log)
|
||||
LogUtil.write(`本地保存日程(标题已脱敏)uid=${e.uid} 提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`);
|
||||
// 设置了提醒 → 此刻才申请通知权限(用户刚主动使用了依赖通知的功能,
|
||||
// 符合《审核指南》7.17"在用户主动点击对应功能时申请",不随 App 启动自动弹窗)
|
||||
if (e.reminders.length > 0) {
|
||||
await this.ensureNotifyPermission();
|
||||
}
|
||||
// 立即刷新提醒(不等下一轮同步),保证刚保存的提醒马上生效
|
||||
try {
|
||||
await ReminderService.refreshReminders(context as common.UIAbilityContext);
|
||||
@@ -290,6 +314,27 @@ struct EventEditPage {
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认通知权限已开启 —— 仅"用户保存了带提醒的日程"这条路径会调用。
|
||||
* 日程提醒依赖系统通知,用户刚主动设置了提醒,此时申请权限与其使用目的完全一致。
|
||||
* ⚠️ 严禁在 App 启动 / 首次打开时调用(《审核指南》7.17:不得提前弹窗申请权限)。
|
||||
*/
|
||||
private async ensureNotifyPermission(): Promise<void> {
|
||||
try {
|
||||
const enabled: boolean = await notificationManager.isNotificationEnabled();
|
||||
if (enabled) {
|
||||
return;
|
||||
}
|
||||
const context = this.getUIContext().getHostContext();
|
||||
if (context === undefined) {
|
||||
return;
|
||||
}
|
||||
await notificationManager.requestEnableNotification(context as common.UIAbilityContext);
|
||||
} catch (err) {
|
||||
// 用户拒绝或未弹窗:日程照常保存,只是提醒可能不弹出(设置页可再开启)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除前二次确认。
|
||||
* 删除日程不可撤销,且会同时从本地库与服务器(DAV)移除,故破坏性操作前必须显式确认,
|
||||
@@ -398,6 +443,11 @@ struct EventEditPage {
|
||||
.fontSize(16)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.borderRadius(12)
|
||||
// 显式指定文字/提示/光标颜色:TextInput 默认取系统主题色,
|
||||
// 深色模式下会变成浅色文字 → 与卡片底色撞色而"看不清"
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.placeholderColor($r('app.color.text_hint'))
|
||||
.caretColor($r('app.color.brand'))
|
||||
.onChange((v: string) => {
|
||||
this.title = v;
|
||||
})
|
||||
@@ -477,23 +527,51 @@ struct EventEditPage {
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
|
||||
// 日历本选择
|
||||
// 日历本选择:两列等宽对齐(每项固定 48% 宽 + 两端对齐),只列 CalDAV 可写日历本
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 6 }) {
|
||||
Text('日历本')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
|
||||
Text('同步到 CalDAV')
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.books.length === 0) {
|
||||
// 没有可写入的 CalDAV 日历本:说明原因 + 给替代方案(本机日程请用系统「日历」)
|
||||
Text('没有可写入的 CalDAV 日历本。请先到「更多 → 账号管理」添加账号;'
|
||||
+ '这条日程需要同步到服务器。若只想记在本机,请使用系统「日历」应用。')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.error'))
|
||||
.width('100%')
|
||||
} else {
|
||||
if (this.chosenBook() === null) {
|
||||
// 未选中任何日历本(如历史遗留的"本机"日程)→ 就地提示,别让用户找不到原因
|
||||
Text(this.chosenKey === LOCAL_CAL_KEY
|
||||
? '这条日程原先只存在「本机」(不同步)。请选一个日历本,保存后即会同步到服务器。'
|
||||
: '请选择一个日历本,日程会同步到服务器。')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.brand_text'))
|
||||
.width('100%')
|
||||
}
|
||||
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
|
||||
ForEach(this.books, (b: BookChoice) => {
|
||||
Row({ space: 5 }) {
|
||||
Row({ space: 6 }) {
|
||||
Circle().width(8).height(8).fill(b.color)
|
||||
Text(b.name)
|
||||
.fontSize(12)
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
.fontColor(this.chosenKey === b.calKey
|
||||
? $r('app.color.button_text') : $r('app.color.text_primary'))
|
||||
}
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.width('48%')
|
||||
.padding({ left: 10, right: 10, top: 8, bottom: 8 })
|
||||
.borderRadius(14)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
.margin({ bottom: 8 })
|
||||
.backgroundColor(this.chosenKey === b.calKey ? b.color : $r('app.color.chip_off_bg'))
|
||||
.onClick(() => {
|
||||
this.chosenKey = b.calKey;
|
||||
@@ -502,6 +580,7 @@ struct EventEditPage {
|
||||
}
|
||||
.width('100%')
|
||||
}
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
.padding(14)
|
||||
@@ -514,6 +593,9 @@ struct EventEditPage {
|
||||
.fontSize(14)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.borderRadius(12)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.placeholderColor($r('app.color.text_hint'))
|
||||
.caretColor($r('app.color.brand'))
|
||||
.onChange((v: string) => {
|
||||
this.location = v;
|
||||
})
|
||||
@@ -524,6 +606,9 @@ struct EventEditPage {
|
||||
.fontSize(14)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.borderRadius(12)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.placeholderColor($r('app.color.text_hint'))
|
||||
.caretColor($r('app.color.brand'))
|
||||
.onChange((v: string) => {
|
||||
this.description = v;
|
||||
})
|
||||
@@ -564,6 +649,8 @@ struct EventEditPage {
|
||||
.height('100%')
|
||||
.padding({ top: 12 })
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.bindSheet($$this.pickerShow, this.dateTimePickerSheet(), {
|
||||
height: 380,
|
||||
showClose: false,
|
||||
@@ -572,6 +659,19 @@ struct EventEditPage {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面转场:淡入淡出。
|
||||
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
|
||||
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
|
||||
*/
|
||||
pageTransition() {
|
||||
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
}
|
||||
|
||||
|
||||
@Builder
|
||||
timeRow(label: string, isStart: boolean) {
|
||||
Row({ space: 8 }) {
|
||||
@@ -671,6 +771,13 @@ struct EventEditPage {
|
||||
.onDateChange((value: Date) => {
|
||||
this.pickDate = value;
|
||||
})
|
||||
// 滚轮文字显式指定颜色:默认取系统主题色,深色模式下与弹层底色撞色会"看不清"
|
||||
.textStyle({ color: $r('app.color.text_secondary'), font: { size: 14 } })
|
||||
.selectedTextStyle({
|
||||
color: $r('app.color.text_primary'),
|
||||
font: { size: 16, weight: FontWeight.Medium }
|
||||
})
|
||||
.disappearTextStyle({ color: $r('app.color.text_hint'), font: { size: 14 } })
|
||||
.layoutWeight(1)
|
||||
.height(210)
|
||||
if (!this.allDay) {
|
||||
@@ -681,6 +788,12 @@ struct EventEditPage {
|
||||
this.pickHour = value.hour;
|
||||
this.pickMin = value.minute;
|
||||
})
|
||||
.textStyle({ color: $r('app.color.text_secondary'), font: { size: 14 } })
|
||||
.selectedTextStyle({
|
||||
color: $r('app.color.text_primary'),
|
||||
font: { size: 16, weight: FontWeight.Medium }
|
||||
})
|
||||
.disappearTextStyle({ color: $r('app.color.text_hint'), font: { size: 14 } })
|
||||
.layoutWeight(1)
|
||||
.height(210)
|
||||
}
|
||||
@@ -703,9 +816,15 @@ struct EventEditPage {
|
||||
}
|
||||
}
|
||||
|
||||
/** 桥接:从账号存储拿可写来源(DAV 日历本 + 本机),附上 href */
|
||||
/**
|
||||
* 桥接:从账号存储拿**可写入且能同步**的日历本(只取 CalDAV 日历本),并附上 href。
|
||||
*
|
||||
* ⚠️ 刻意**不返回"本机"(calKey='local')**:本应用新建/编辑的日程一律写入某个 CalDAV
|
||||
* 日历本并同步到服务器;纯本机日程请用户改用系统「日历」应用完成
|
||||
* (本应用也没有申请写系统日历本地的权限,做不到)。
|
||||
*/
|
||||
class CalendarDataBridge {
|
||||
static async loadWritableSources(context: common.Context): Promise<CalSourceWithHref[]> {
|
||||
static async loadWritableDavSources(context: common.Context): Promise<CalSourceWithHref[]> {
|
||||
const result: CalSourceWithHref[] = [];
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
const manualKeys: string[] = await AppSettings.getManualReadonlyKeys(context);
|
||||
@@ -731,11 +850,6 @@ class CalendarDataBridge {
|
||||
result.push(s);
|
||||
}
|
||||
}
|
||||
const local = new CalSourceWithHref();
|
||||
local.calKey = 'local';
|
||||
local.name = '本机(不同步)';
|
||||
local.color = '#5A6068';
|
||||
result.push(local);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// entry/src/main/ets/pages/Index.ets
|
||||
// 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表
|
||||
// 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天"
|
||||
import { mediaquery, router } from '@kit.ArkUI';
|
||||
import { curves, display, router, window } from '@kit.ArkUI';
|
||||
import { common, abilityAccessCtrl, bundleManager } from '@kit.AbilityKit';
|
||||
import { geoLocationManager } from '@kit.LocationKit';
|
||||
import { BusinessError, pasteboard } from '@kit.BasicServicesKit';
|
||||
import { notificationManager } from '@kit.NotificationKit';
|
||||
import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore';
|
||||
import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService';
|
||||
import { SyncEngine } from '../common/SyncEngine';
|
||||
@@ -21,6 +20,8 @@ import { RruleUtil } from '../common/RruleUtil';
|
||||
import { IcsUtil } from '../common/IcsUtil';
|
||||
import { DavClient, RemoteItem } from '../common/DavClient';
|
||||
import { SystemCalendarImport } from '../common/SystemCalendarImport';
|
||||
import { SystemCalendarMirror } from '../common/SystemCalendarMirror';
|
||||
import { MirrorInbound } from '../common/MirrorInbound';
|
||||
import { ScreenKeeper } from '../common/ScreenKeeper';
|
||||
import { TimelineUtil, TimelineBlock, TimelineGroup, DayTimeline, ViewRange } from '../common/TimelineUtil';
|
||||
|
||||
@@ -53,8 +54,16 @@ struct Index {
|
||||
@State syncing: boolean = false;
|
||||
@State loading: boolean = true;
|
||||
@State menuOpen: boolean = false; // 顶部 ≡ 下拉菜单
|
||||
@State isLandscape: boolean = false; // 横屏:月视图切左右双栏(左月历/右当日日程)
|
||||
private landscapeListener: mediaquery.MediaQueryListener | null = null;
|
||||
// 宽屏(双栏:月视图左月历 / 右当日日程,周视图同理)判据,两条满足其一即双栏:
|
||||
// ① 窗口短边 ≥ 840vp —— 折叠屏展开态 / 平板这类"大屏"。短边在旋转时不变,
|
||||
// 故横竖屏布局天然一致,满足"屏幕比例接近 1:1 的设备,横竖屏布局需保持一致";
|
||||
// ② 横屏且宽高比 ≥ 1.5 —— 长条形屏幕(普通手机、折叠屏**合上后的外屏**)。
|
||||
// 这类设备比例远离 1:1,旋转应当切换布局(0.0.2 起即如此,用户已习惯)。
|
||||
// 加比例下限是为了兜底"短边不足 840 但比例接近 1:1"的小方屏:旋转依旧不变。
|
||||
// 2026-09-29 修复:上一版只留了判据 ①,导致 Mate X7 合盖后旋转屏幕布局不再变化。
|
||||
@State wideLayout: boolean = false;
|
||||
private winObj: window.Window | null = null;
|
||||
private winSizeCallback: ((size: window.Size) => void) | null = null;
|
||||
private accounts: DavAccount[] = [];
|
||||
private sources: CalSource[] = [];
|
||||
private swiperController: SwiperController = new SwiperController();
|
||||
@@ -84,7 +93,6 @@ struct Index {
|
||||
// ---- 隐私政策与用户协议首启同意(未同意前不申请任何权限、不加载数据) ----
|
||||
@State policyChecked: boolean = false; // 是否已读取过同意状态(避免首帧闪烁)
|
||||
@State policyAgreed: boolean = false; // 是否已同意《隐私政策》与《用户协议》
|
||||
private notifAsked: boolean = false; // 通知权限是否已申请(同意后才申请)
|
||||
@State showTips: boolean = false; // 首启功能引导(整页覆盖层:遮罩 + 底部卡片 + Swiper 三页)
|
||||
// ---- 内置文档浏览器(《隐私政策》/《用户服务协议》,整页 Web 覆盖层,不跳出应用) ----
|
||||
@State showDoc: boolean = false;
|
||||
@@ -112,7 +120,7 @@ struct Index {
|
||||
}
|
||||
}
|
||||
this.rebuildPages();
|
||||
this.initLandscapeListener();
|
||||
this.initLayoutListener();
|
||||
// 首启同意门禁:未同意《隐私政策》与《用户协议》前,不申请任何系统权限、不加载/同步数据(AGC 硬性要求)
|
||||
if (ctx === undefined) {
|
||||
this.policyChecked = true;
|
||||
@@ -142,19 +150,57 @@ struct Index {
|
||||
clearInterval(this.nowTimer);
|
||||
this.nowTimer = -1;
|
||||
}
|
||||
if (this.landscapeListener !== null) {
|
||||
this.landscapeListener.off('change');
|
||||
this.landscapeListener = null;
|
||||
if (this.winObj !== null && this.winSizeCallback !== null) {
|
||||
try {
|
||||
this.winObj.off('windowSizeChange', this.winSizeCallback);
|
||||
} catch (err) {
|
||||
// 窗口可能已销毁(页面退出),忽略
|
||||
}
|
||||
this.winSizeCallback = null;
|
||||
this.winObj = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 监听横竖屏:旋转后 isLandscape 驱动月视图在上下/左右布局间切换 */
|
||||
private initLandscapeListener(): void {
|
||||
this.landscapeListener =
|
||||
this.getUIContext().getMediaQuery().matchMediaSync('(orientation: landscape)');
|
||||
this.isLandscape = this.landscapeListener.matches;
|
||||
this.landscapeListener.on('change', (result: mediaquery.MediaQueryResult) => {
|
||||
this.isLandscape = result.matches;
|
||||
/**
|
||||
* 由窗口实际尺寸(px → vp)计算单栏/双栏。判据见 wideLayout 字段注释:
|
||||
* 短边 ≥ 840vp(大屏,旋转不变)或 横屏且宽高比 ≥ 1.5(长条形屏幕,旋转切换)。
|
||||
*/
|
||||
private applyLayoutFromWindow(win: window.Window): void {
|
||||
try {
|
||||
const rect = win.getWindowProperties().windowRect;
|
||||
const density: number = display.getDefaultDisplaySync().densityPixels;
|
||||
if (density <= 0) {
|
||||
return;
|
||||
}
|
||||
const w: number = rect.width / density;
|
||||
const h: number = rect.height / density;
|
||||
const shortEdge: number = Math.min(w, h);
|
||||
const ratio: number = h > 0 ? w / h : 1;
|
||||
const wide: boolean = shortEdge >= 840 || (w > h && ratio >= 1.5);
|
||||
LogUtil.write(`窗口尺寸 ${Math.round(w)}x${Math.round(h)}vp(短边 ${Math.round(shortEdge)},宽高比 ${ratio.toFixed(2)})→ ${wide ? '双栏' : '单栏'}`);
|
||||
if (wide !== this.wideLayout) {
|
||||
this.wideLayout = wide;
|
||||
}
|
||||
} catch (err) {
|
||||
// 取窗口信息失败时保持现状(罕见:窗口已销毁)
|
||||
}
|
||||
}
|
||||
|
||||
private initLayoutListener(): void {
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
if (ctx === undefined) {
|
||||
return;
|
||||
}
|
||||
window.getLastWindow(ctx).then((win: window.Window): void => {
|
||||
this.winObj = win;
|
||||
this.applyLayoutFromWindow(win);
|
||||
this.winSizeCallback = (_size: window.Size): void => {
|
||||
// size 参数为 px;重新读取属性并换算(折叠/展开、旋转都会触发)
|
||||
this.applyLayoutFromWindow(win);
|
||||
};
|
||||
win.on('windowSizeChange', this.winSizeCallback);
|
||||
}).catch((err: BusinessError): void => {
|
||||
LogUtil.write(`窗口尺寸监听初始化失败(保持默认单栏):${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,40 +257,17 @@ struct Index {
|
||||
}
|
||||
}
|
||||
|
||||
private initPermissionAndLoad(): void {
|
||||
const context = this.getUIContext().getHostContext();
|
||||
if (context === undefined) {
|
||||
return;
|
||||
}
|
||||
this.reloadAll();
|
||||
this.ensureNotificationPermission(context as common.UIAbilityContext);
|
||||
if (!this.permissionAsked) {
|
||||
this.permissionAsked = true;
|
||||
// 申请读取全部日历权限(用于混合展示系统日程)
|
||||
CalendarDataService.ensureSystemCalendarPermission(context as common.UIAbilityContext)
|
||||
.then((): void => {
|
||||
this.reloadAll();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请通知权限(日程提醒、后台同步常驻通知都依赖它)。
|
||||
* 必须在用户同意《隐私政策》之后才调用 —— 同意前不得申请任何权限。
|
||||
* 启动/同意隐私政策后的初始化:**只加载本地库与 DAV 数据**。
|
||||
*
|
||||
* ⚠️ 合规约束(《审核指南》7.17):这里**不申请任何权限**。
|
||||
* 权限必须由用户主动点击对应功能时申请,否则会被判定为"未主动点击即提前弹窗申请":
|
||||
* - 读取全部日程(日历)→ 设置页「混合显示系统日历」开关 / 「备份到 CalDAV 日历本」
|
||||
* - 通知 → 保存带提醒的日程 / 开启「后台持续同步」/ 设置页「去开启通知」
|
||||
* 本方法只负责加载数据;系统日历来源由 CalendarDataService.loadSources 按开关与授权状态自行决定是否读取。
|
||||
*/
|
||||
private ensureNotificationPermission(context: common.UIAbilityContext): void {
|
||||
if (this.notifAsked) {
|
||||
return;
|
||||
}
|
||||
this.notifAsked = true;
|
||||
notificationManager.isNotificationEnabled()
|
||||
.then((enabled: boolean): Promise<void> => {
|
||||
if (!enabled) {
|
||||
return notificationManager.requestEnableNotification(context).catch((): void => {});
|
||||
}
|
||||
return Promise.resolve();
|
||||
})
|
||||
.catch((): void => {});
|
||||
private initPermissionAndLoad(): void {
|
||||
this.reloadAll();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -616,6 +639,12 @@ struct Index {
|
||||
} catch (err) {
|
||||
// 导入失败不影响正常同步
|
||||
}
|
||||
// 入站对账:把用户在系统日历里的改动读回来置 dirty,**必须在同步之前**,才能借本次同步一起推送
|
||||
try {
|
||||
await MirrorInbound.reconcile(context);
|
||||
} catch (err) {
|
||||
// 对账失败不影响正常同步
|
||||
}
|
||||
for (const acc of this.accounts) {
|
||||
if (acc.type !== TYPE_CALDAV) {
|
||||
continue;
|
||||
@@ -641,6 +670,15 @@ struct Index {
|
||||
const e = err as BusinessError;
|
||||
failMsg = e.message;
|
||||
}
|
||||
// 出站镜像:把服务器最新状态写进系统日历(自动化,无需手点)
|
||||
try {
|
||||
if (await AppSettings.getMirrorEnabled(context) && await SystemCalendarMirror.hasPermission()) {
|
||||
const mr = await SystemCalendarMirror.syncNow(context);
|
||||
LogUtil.write(`同步后自动镜像:本=${mr.books} 新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// 镜像失败不影响同步结果
|
||||
}
|
||||
this.syncing = false;
|
||||
await ScreenKeeper.release(context as common.UIAbilityContext);
|
||||
if (failMsg !== '') {
|
||||
@@ -1067,6 +1105,10 @@ struct Index {
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
// 沉浸式:根容器背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件),
|
||||
// 配合 EntryAbility 里"系统栏透明"的设置,彻底消除顶部通知栏与底部灰条。
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
.bindSheet($$this.detailShow, this.detailSheet(), {
|
||||
height: 540,
|
||||
dragBar: true,
|
||||
@@ -1075,6 +1117,19 @@ struct Index {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面转场:淡入淡出。
|
||||
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
|
||||
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
|
||||
*/
|
||||
pageTransition() {
|
||||
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 首启隐私政策同意门禁(整页渲染,不用弹窗 —— 弹窗在本环境实测无法弹出)。
|
||||
* AGC 要求:明确同意前不得申请权限、不得收集/处理个人信息,故未同意时整页挡住主界面。
|
||||
@@ -1324,7 +1379,7 @@ struct Index {
|
||||
.borderRadius(12)
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||
.shadow({ radius: 12, color: '#22000000', offsetY: 4 })
|
||||
.shadow({ radius: 12, color: $r('app.color.shadow_color'), offsetY: 4 })
|
||||
.margin({ top: 56, right: 20 })
|
||||
}
|
||||
.width('100%')
|
||||
@@ -1540,8 +1595,12 @@ struct Index {
|
||||
Column({ space: 2 }) {
|
||||
Text(WEEK_LABELS[(new Date(cell.dateMs).getDay() + 6) % 7])
|
||||
.fontSize(11)
|
||||
// ⚠️ 这里原来用 button_text(纯白),但该标签**没有背景**、坐在页面底色上,
|
||||
// 浅色模式下是"白字压浅灰底"→ 对比度仅 1.11:1(应用市场 UX 检测被判不合格)。
|
||||
// 改用 brand_text(品牌色文字版):浅 #0057C2 → 6.01:1,深 #4DA3FF → 8.0:1,
|
||||
// 既保留"选中"的强调含义,又满足对比度要求。
|
||||
.fontColor(cell.dateMs === this.selectedDate
|
||||
? $r('app.color.button_text') : $r('app.color.text_hint'))
|
||||
? $r('app.color.brand_text') : $r('app.color.text_hint'))
|
||||
Text(String(cell.day))
|
||||
.fontSize(14)
|
||||
.fontWeight(cell.dateMs === this.selectedDate || cell.isToday
|
||||
@@ -1598,8 +1657,8 @@ struct Index {
|
||||
|
||||
@Builder
|
||||
monthBody() {
|
||||
if (this.isLandscape) {
|
||||
// 横屏(平板适配):左右双栏——左侧月历,右侧当日日程
|
||||
if (this.wideLayout) {
|
||||
// 宽屏(平板 / 折叠屏展开态):左右双栏——左侧月历,右侧当日日程
|
||||
Row() {
|
||||
Column() {
|
||||
this.monthWeekHeader()
|
||||
@@ -1667,7 +1726,7 @@ struct Index {
|
||||
}, (cell: MonthCell) => `${cell.dateMs}_${cell.dateMs === this.selectedDate}`)
|
||||
}
|
||||
.width('100%')
|
||||
.layoutWeight(this.isLandscape ? 1 : 0)
|
||||
.layoutWeight(this.wideLayout ? 1 : 0)
|
||||
}, (week: MonthCell[], idx: number) => String(idx))
|
||||
}
|
||||
.width('100%')
|
||||
@@ -1679,7 +1738,7 @@ struct Index {
|
||||
.loop(false)
|
||||
.indicator(false)
|
||||
.width('100%')
|
||||
.layoutWeight(this.isLandscape ? 1 : 0)
|
||||
.layoutWeight(this.wideLayout ? 1 : 0)
|
||||
.onChange((index: number) => {
|
||||
this.handleSwiperChange(index);
|
||||
})
|
||||
@@ -1890,7 +1949,7 @@ struct Index {
|
||||
|
||||
@Builder
|
||||
weekBody() {
|
||||
if (this.isLandscape) {
|
||||
if (this.wideLayout) {
|
||||
// 横屏(平板适配):左侧一周日期,右侧选中日日程
|
||||
Row() {
|
||||
Column() {
|
||||
@@ -1992,8 +2051,9 @@ struct Index {
|
||||
Row({ space: 10 }) {
|
||||
Text(WEEK_LABELS[(new Date(cell.dateMs).getDay() + 6) % 7])
|
||||
.fontSize(12)
|
||||
// 同上:选中态改用高对比的 brand_text(原 text_secondary 在浅色下仅 2.85:1)
|
||||
.fontColor(cell.dateMs === this.selectedDate
|
||||
? $r('app.color.text_secondary') : $r('app.color.text_hint'))
|
||||
? $r('app.color.brand_text') : $r('app.color.text_hint'))
|
||||
.width(18)
|
||||
.textAlign(TextAlign.Center)
|
||||
Text(String(cell.day))
|
||||
@@ -2524,20 +2584,20 @@ struct Index {
|
||||
dayCell(cell: MonthCell) {
|
||||
Column({ space: 2 }) {
|
||||
Text(String(cell.day))
|
||||
.fontSize(this.isLandscape ? 12 : 13)
|
||||
.fontSize(this.wideLayout ? 12 : 13)
|
||||
.fontWeight(cell.isToday || cell.dateMs === this.selectedDate ? FontWeight.Bold : FontWeight.Normal)
|
||||
.fontColor(!cell.inMonth
|
||||
? $r('app.color.text_hint')
|
||||
: (cell.dateMs === this.selectedDate ? $r('app.color.button_text') : $r('app.color.text_primary')))
|
||||
.width(this.isLandscape ? 22 : 26)
|
||||
.height(this.isLandscape ? 22 : 26)
|
||||
.width(this.wideLayout ? 22 : 26)
|
||||
.height(this.wideLayout ? 22 : 26)
|
||||
.textAlign(TextAlign.Center)
|
||||
.borderRadius(this.isLandscape ? 11 : 13)
|
||||
.borderRadius(this.wideLayout ? 11 : 13)
|
||||
.backgroundColor(cell.dateMs === this.selectedDate
|
||||
? $r('app.color.selected_bg')
|
||||
: (cell.isToday ? $r('app.color.today_bg') : Color.Transparent))
|
||||
if (!this.isLandscape) {
|
||||
// 横屏高度有限,省略农历腾出空间
|
||||
if (!this.wideLayout) {
|
||||
// 宽屏下月历格子变窄,省略农历腾出空间
|
||||
Text(cell.lunar)
|
||||
.fontSize(8)
|
||||
.fontColor(cell.dateMs === this.selectedDate
|
||||
@@ -2950,7 +3010,7 @@ struct DayTimelineView {
|
||||
.height(this.hourUnit)
|
||||
.textAlign(TextAlign.End)
|
||||
.padding({ right: 4, top: 2 })
|
||||
.border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } })
|
||||
.border({ width: { bottom: 0.5 }, color: { bottom: $r('app.color.grid_line') } })
|
||||
}, (h: number) => `h${h}`)
|
||||
}
|
||||
.width(36)
|
||||
@@ -2966,7 +3026,7 @@ struct DayTimelineView {
|
||||
.layoutWeight(1)
|
||||
.height(this.viewH())
|
||||
.alignContent(Alignment.TopStart)
|
||||
.border({ width: { left: 0.5 }, color: { left: '#14000000' } })
|
||||
.border({ width: { left: 0.5 }, color: { left: $r('app.color.grid_line') } })
|
||||
}
|
||||
.width('100%')
|
||||
.alignItems(VerticalAlign.Top)
|
||||
@@ -2984,7 +3044,7 @@ struct DayTimelineView {
|
||||
Column()
|
||||
.width('100%')
|
||||
.height(this.hourUnit)
|
||||
.border({ width: { bottom: 0.5 }, color: { bottom: '#14000000' } })
|
||||
.border({ width: { bottom: 0.5 }, color: { bottom: $r('app.color.grid_line') } })
|
||||
.hitTestBehavior(HitTestMode.None) // 装饰层子节点同样不参与命中测试
|
||||
}, (h: number) => `gd_${h}`)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
// entry/src/main/ets/pages/SettingsPage.ets
|
||||
// 设置页:系统日历模式(仅显示/备份到 CalDAV)+ 混合显示开关 + 自动同步间隔 + 后台同步
|
||||
import { router } from '@kit.ArkUI';
|
||||
import { curves, router } from '@kit.ArkUI';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { notificationManager } from '@kit.NotificationKit';
|
||||
import { AppSettings } from '../common/AppSettings';
|
||||
import { CardDataService } from '../common/CardDataService';
|
||||
import { CalendarDataService } from '../common/CalendarDataService';
|
||||
import { BackgroundSyncService } from '../common/BackgroundSyncService';
|
||||
import { AccountStore, DavAccount } from '../common/AccountStore';
|
||||
import { ReminderService } from '../common/ReminderService';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { BackupTargetCheck, BookRef, MirrorResult, PurgeResult, SystemCalendarMirror } from '../common/SystemCalendarMirror';
|
||||
import { DocViewer } from '../common/DocViewer';
|
||||
|
||||
const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
|
||||
@@ -15,6 +18,7 @@ const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
|
||||
/** 日历本选项(备份目标 / 只读标记 / 静音标记共用) */
|
||||
class BackupTarget {
|
||||
calKey: string = '';
|
||||
bookId: string = ''; // ⭐ 稳定标识(由服务器 href 派生):镜像选中项用它存,不随序号变
|
||||
label: string = '';
|
||||
serverWritable: boolean = true; // 服务器探测结果
|
||||
}
|
||||
@@ -26,8 +30,9 @@ struct SettingsPage {
|
||||
@State intervalMinutes: number = 1;
|
||||
@State backgroundSync: boolean = false;
|
||||
@State sysMode: string = 'display'; // display | backup
|
||||
@State backupKey: string = '';
|
||||
@State backupKey: string = ''; // ⭐ 备份目标本的**稳定标识** bookId(不再用会漂移的 calKey)
|
||||
@State backupTargets: BackupTarget[] = [];
|
||||
@State backupNotice: string = ''; // 备份目标失效/未选择时的提示文案
|
||||
@State allBooks: BackupTarget[] = []; // 全部 DAV 日历本(只读/静音标记管理用)
|
||||
@State manualKeys: string[] = []; // 手动标记只读的 calKey
|
||||
@State mutedKeys: string[] = []; // 提醒静音的 calKey
|
||||
@@ -39,6 +44,13 @@ struct SettingsPage {
|
||||
@State docUrl: string = '';
|
||||
@State defaultView: string = 'month'; // 打开 App 默认视图:month | week | agenda
|
||||
@State displayStyle: string = 'timeline'; // 日程显示方式:timeline(时间轴)| list(列表)
|
||||
@State mirrorEnabled: boolean = false; // 是否把选中 CalDAV 日历本镜像到系统日历(默认关)
|
||||
@State mirrorKeys: string[] = []; // 要镜像的 DAV 日历本 **bookId**(稳定标识,不用会变的 calKey)
|
||||
@State mirrorRunning: boolean = false; // 正在执行镜像
|
||||
@State purgeRunning: boolean = false; // 正在清理残留镜像账户
|
||||
@State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌)
|
||||
@State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关)
|
||||
@State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充)
|
||||
private context?: common.Context;
|
||||
|
||||
aboutToAppear(): void {
|
||||
@@ -49,6 +61,15 @@ struct SettingsPage {
|
||||
this.context = ctx;
|
||||
AppSettings.getShowSystemCalendar(ctx).then((v: boolean): void => {
|
||||
this.showSystem = v;
|
||||
// 状态自洽:若记录为"开",但权限已被用户在系统设置里回收 → 自动回落为"关",避免开关与实际不符
|
||||
if (v) {
|
||||
CalendarDataService.hasSystemCalendarPermission().then((granted: boolean): void => {
|
||||
if (!granted) {
|
||||
this.showSystem = false;
|
||||
AppSettings.setShowSystemCalendar(ctx, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => {
|
||||
this.intervalMinutes = v;
|
||||
@@ -74,6 +95,29 @@ struct SettingsPage {
|
||||
this.refreshNotifyState();
|
||||
this.loadBackupSettings();
|
||||
this.loadAllBooks();
|
||||
AppSettings.getMirrorEnabled(ctx).then((v: boolean): void => {
|
||||
this.mirrorEnabled = v;
|
||||
// 状态自洽:记录为"开"但权限已被系统回收 → 自动回落为"关"
|
||||
if (v) {
|
||||
SystemCalendarMirror.hasPermission().then((ok: boolean): void => {
|
||||
if (!ok && this.context !== undefined) {
|
||||
this.mirrorEnabled = false;
|
||||
AppSettings.setMirrorEnabled(this.context, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// ⭐ 走 selectedBookRefs:它兼做历史迁移(老版本存的是 calKey)与"排除备份目标本"过滤
|
||||
SystemCalendarMirror.selectedBookRefs(ctx).then((refs: BookRef[]): void => {
|
||||
const ids: string[] = [];
|
||||
for (const r of refs) {
|
||||
ids.push(r.bookId);
|
||||
}
|
||||
this.mirrorKeys = ids;
|
||||
});
|
||||
AppSettings.getMirrorInbound(ctx).then((v: boolean): void => {
|
||||
this.mirrorInbound = v;
|
||||
});
|
||||
}
|
||||
|
||||
/** 重新检测通知权限状态 */
|
||||
@@ -87,6 +131,27 @@ struct SettingsPage {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认通知权限已开启 —— **只在用户主动使用依赖通知的功能时调用**
|
||||
* (保存带提醒的日程 / 开启后台持续同步 / 点击设置页"去开启通知")。
|
||||
* ⚠️ 严禁在 App 启动、首次打开等用户未操作的时机调用(《审核指南》7.17)。
|
||||
*/
|
||||
private async ensureNotifyPermission(): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const enabled: boolean = await notificationManager.isNotificationEnabled();
|
||||
if (!enabled) {
|
||||
await notificationManager.requestEnableNotification(
|
||||
this.context as common.UIAbilityContext);
|
||||
}
|
||||
} catch (err) {
|
||||
// 用户拒绝或系统未弹窗:不阻断功能,仅状态仍显示为未开启
|
||||
}
|
||||
this.refreshNotifyState();
|
||||
}
|
||||
|
||||
/** 加载全部 DAV 日历本(含探测到的写权限,只读/静音标记管理用) */
|
||||
private async loadAllBooks(): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
@@ -107,7 +172,51 @@ struct SettingsPage {
|
||||
books.push(b);
|
||||
}
|
||||
}
|
||||
// ⭐ 补上稳定标识 bookId(由服务器 href 派生)。镜像的"选中状态"必须按它存,
|
||||
// 否则日历本重排后序号一变,选中的本就串成另一个本。
|
||||
if (this.context !== undefined) {
|
||||
const refs: BookRef[] = await SystemCalendarMirror.listBooks(this.context);
|
||||
for (const b of books) {
|
||||
for (const ref of refs) {
|
||||
if (ref.calKey === b.calKey) {
|
||||
b.bookId = ref.bookId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.allBooks = books;
|
||||
await this.refreshMirrorCandidates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 可镜像的日历本 = 全部 DAV 本 **去掉「系统日历备份目标本」**。
|
||||
* 备份目标本里的日程本来就来自系统日历(uid 前缀 syscal-),再镜像回系统日历就是自我复制。
|
||||
*/
|
||||
private async refreshMirrorCandidates(): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
const ctx: common.Context = this.context;
|
||||
const blocked: string[] = await SystemCalendarMirror.blockedBookIds(ctx);
|
||||
this.mirrorCandidates =
|
||||
this.allBooks.filter((b: BackupTarget): boolean => b.bookId === '' || !blocked.includes(b.bookId));
|
||||
// 已选中的本若被改成备份目标 → 自动剔除并删掉它在系统日历里的镜像账户
|
||||
const kept: string[] = this.mirrorKeys.filter((k: string): boolean => !blocked.includes(k));
|
||||
if (kept.length !== this.mirrorKeys.length) {
|
||||
for (const k of this.mirrorKeys) {
|
||||
if (blocked.includes(k)) {
|
||||
await SystemCalendarMirror.removeById(ctx, k);
|
||||
}
|
||||
}
|
||||
this.mirrorKeys = kept;
|
||||
await AppSettings.setMirrorBookIds(ctx, kept);
|
||||
}
|
||||
}
|
||||
|
||||
/** 因"是备份目标本"而被隐藏、不能镜像的本的数量 */
|
||||
private mirrorHiddenCount(): number {
|
||||
return this.allBooks.length - this.mirrorCandidates.length;
|
||||
}
|
||||
|
||||
/** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */
|
||||
@@ -162,12 +271,21 @@ struct SettingsPage {
|
||||
});
|
||||
}
|
||||
|
||||
/** 加载备份目标候选(可写 DAV 日历本)+ 当前选择 */
|
||||
/**
|
||||
* 加载备份目标候选(可写 DAV 日历本)+ 当前选择。
|
||||
*
|
||||
* ⭐ 当前选择用**稳定标识 bookId** 记录(老版本存 calKey,序号会漂移 → 曾把系统日历
|
||||
* 导进用户没选过的本)。这里统一走 `checkBackupTarget()`:
|
||||
* - 老值自动迁移为 bookId;
|
||||
* - 目标本已不存在(被取消勾选/账号删除)→ 它会把备份模式**自动关回 display**,
|
||||
* 这里只负责把结果反映到 UI 并给出提示。
|
||||
*/
|
||||
private async loadBackupSettings(): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(this.context);
|
||||
const ctx: common.Context = this.context;
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(ctx);
|
||||
const targets: BackupTarget[] = [];
|
||||
for (const acc of accounts) {
|
||||
if (acc.type !== 'caldav') {
|
||||
@@ -188,49 +306,252 @@ struct SettingsPage {
|
||||
}
|
||||
}
|
||||
// 手动标记只读的本不可作为备份目标
|
||||
const manual: string[] = await AppSettings.getManualReadonlyKeys(this.context);
|
||||
const manual: string[] = await AppSettings.getManualReadonlyKeys(ctx);
|
||||
this.backupTargets = targets.filter((t: BackupTarget): boolean => !manual.includes(t.calKey));
|
||||
const saved: string = await AppSettings.getBackupCalKey(this.context);
|
||||
this.backupKey = targets.some((t: BackupTarget): boolean => t.calKey === saved) ? saved : '';
|
||||
// 补 bookId(稳定标识)—— 选择状态必须按它存
|
||||
const refs: BookRef[] = await SystemCalendarMirror.listBooks(ctx);
|
||||
for (const t of this.backupTargets) {
|
||||
for (const r of refs) {
|
||||
if (r.calKey === t.calKey) {
|
||||
t.bookId = r.bookId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 解析当前目标(可能触发失效清理,并在功能开着时自动关闭)
|
||||
const check: BackupTargetCheck = await SystemCalendarMirror.checkBackupTarget(ctx);
|
||||
this.backupKey = check.ref === undefined ? '' : check.ref.bookId;
|
||||
if (check.reason !== '') {
|
||||
this.backupNotice = check.closed
|
||||
? `${check.reason},已自动关闭系统日历备份。如需继续备份,请在下方重新选择目标日历本。`
|
||||
: `${check.reason}。请重新选择备份目标日历本后再开启备份。`;
|
||||
} else {
|
||||
this.backupNotice = '';
|
||||
}
|
||||
// 最后再以"落盘值"为准刷新一次选择器:aboutToAppear 里另有一条 getSysCalMode 的异步读取,
|
||||
// 若 checkBackupTarget 刚刚把模式关成了 display,就以这里读到的为准(避免旧值把 UI 覆盖回去)
|
||||
this.sysMode = await AppSettings.getSysCalMode(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换「系统日历日程的处理方式」。
|
||||
*
|
||||
* ⚠️ 顺序很重要:**先校验备份目标、再申请权限、最后才落盘**。
|
||||
* - 没有目标本就不允许开启备份(绝不替用户"随便挑一个本"——
|
||||
* 那样会把系统日程导进他没选过的日历本,用户实测反馈的问题);
|
||||
* - 校验不过就不申请「读取全部日程」权限,避免为一次注定失败的开启弹权限框。
|
||||
*/
|
||||
private async saveSysMode(mode: string): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
if (mode !== 'backup') {
|
||||
this.sysMode = 'display';
|
||||
await AppSettings.setSysCalMode(this.context, 'display');
|
||||
this.backupNotice = '';
|
||||
await this.refreshMirrorCandidates(); // 备份模式变化 → 重算可镜像的本(防回灌)
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: '已切换为仅显示:不再把系统日程备份到 CalDAV'
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ① 必须先有明确的备份目标本(见上方 loadBackupSettings / saveBackupTarget)
|
||||
if (this.backupKey === '') {
|
||||
this.sysMode = 'display'; // 回弹,且**不写 preferences**:宁可不开启,也不默认挑一个本
|
||||
this.backupNotice = this.backupTargets.length > 0
|
||||
? '请先选择备份目标日历本:未选定目标前不会开启备份,也不会导入任何系统日程。'
|
||||
: '没有可写的 CalDAV 日历本,请先添加账号或检查日历本权限。';
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: '请先选择备份目标日历本,再开启备份'
|
||||
});
|
||||
return;
|
||||
}
|
||||
// ② "备份到 CalDAV" 需要读取手机系统(本地)日历 → 只在用户主动选择该模式时申请权限
|
||||
const granted: boolean = await CalendarDataService.requestSystemCalendarPermission(
|
||||
this.context as common.UIAbilityContext);
|
||||
if (!granted) {
|
||||
this.sysMode = 'display'; // 回弹选择,保持与实际授权状态一致
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: '未获得"读取全部日程"权限,无法开启系统日历备份'
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.sysMode = mode;
|
||||
await AppSettings.setSysCalMode(this.context, mode);
|
||||
if (mode === 'backup' && this.backupKey === '') {
|
||||
if (this.backupTargets.length > 0) {
|
||||
this.backupKey = this.backupTargets[0].calKey;
|
||||
await AppSettings.setBackupCalKey(this.context, this.backupKey);
|
||||
}
|
||||
}
|
||||
this.backupNotice = '';
|
||||
await this.refreshMirrorCandidates(); // 备份模式/目标变化 → 重算可镜像的本(防回灌)
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: mode === 'backup'
|
||||
? '已开启备份:下次同步时把系统本地日程导入所选日历本'
|
||||
: '已切换为仅显示:不再把系统日程备份到 CalDAV'
|
||||
message: '已开启备份:下次同步时把系统本地日程导入所选日历本'
|
||||
});
|
||||
}
|
||||
|
||||
private async saveBackupTarget(calKey: string): Promise<void> {
|
||||
/** 保存备份目标(入参为 **bookId**,稳定标识,不再是会漂移的 calKey) */
|
||||
private async saveBackupTarget(bookId: string): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
this.backupKey = calKey;
|
||||
await AppSettings.setBackupCalKey(this.context, calKey);
|
||||
this.backupKey = bookId;
|
||||
await AppSettings.setBackupBookId(this.context, bookId);
|
||||
this.backupNotice = ''; // 用户显式选定了目标 → 清掉"失效/未选择"提示
|
||||
await this.refreshMirrorCandidates(); // 备份目标变了 → 可镜像的本也要跟着变(防回灌)
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: '备份目标已更新,下次同步生效' });
|
||||
}
|
||||
|
||||
private async saveShowSystem(value: boolean): Promise<void> { if (this.context === undefined) {
|
||||
/**
|
||||
* 保存「混合显示系统日历」开关。
|
||||
* ⚠️ 合规约束(《审核指南》7.17):读取系统日历依赖"读取全部日程"敏感权限,
|
||||
* **只有在用户主动打开该开关时才申请**;未授权则开关回弹、不保存设置。
|
||||
*/
|
||||
private async saveShowSystem(value: boolean): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
if (value) {
|
||||
const granted: boolean = await CalendarDataService.requestSystemCalendarPermission(
|
||||
this.context as common.UIAbilityContext);
|
||||
if (!granted) {
|
||||
this.showSystem = false; // 回弹开关,保持与实际授权状态一致
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: '未获得"读取全部日程"权限,无法混合显示系统日历'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await AppSettings.setShowSystemCalendar(this.context, value);
|
||||
this.getUIContext().getPromptAction()
|
||||
.showToast({ message: value ? '已开启系统日历混合显示,返回首页生效' : '已关闭系统日历混合显示,返回首页生效' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 「镜像到系统日历」开关。
|
||||
* ⚠️ 合规约束(《审核指南》7.17):写入系统日历依赖 WRITE_CALENDAR 权限,
|
||||
* **只在用户主动打开这个开关时申请**;未授权则开关回弹、不保存设置。
|
||||
* 关闭时会把之前镜像出去的账户一并删掉,避免系统日历里留下"幽灵日程"。
|
||||
*/
|
||||
private async saveMirrorEnabled(isOn: boolean): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
if (isOn) {
|
||||
const granted: boolean = await SystemCalendarMirror.requestPermission(
|
||||
this.context as common.UIAbilityContext);
|
||||
if (!granted) {
|
||||
this.mirrorEnabled = false; // 回弹开关,保持与实际授权状态一致
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: '未获得"写入系统日历"权限,无法开启镜像'
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.mirrorEnabled = isOn;
|
||||
await AppSettings.setMirrorEnabled(this.context, isOn);
|
||||
if (isOn) {
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: '已开启镜像:勾选要同步的日历本后点「立即执行镜像」'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const n: number = await SystemCalendarMirror.removeAll(this.context);
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: n > 0 ? `已关闭镜像,并清除系统日历中的 ${n} 个镜像账户` : '已关闭镜像'
|
||||
});
|
||||
}
|
||||
|
||||
/** 勾选/取消勾选要镜像的日历本;取消勾选会顺手删掉对应的系统日历账户。⚠️ 传的是 bookId */
|
||||
private async toggleMirrorKey(bookId: string, on: boolean): Promise<void> {
|
||||
if (this.context === undefined || bookId === '') {
|
||||
return;
|
||||
}
|
||||
const idx: number = this.mirrorKeys.indexOf(bookId);
|
||||
if (on && idx < 0) {
|
||||
this.mirrorKeys = [...this.mirrorKeys, bookId];
|
||||
} else if (!on && idx >= 0) {
|
||||
this.mirrorKeys = this.mirrorKeys.filter((k: string): boolean => k !== bookId);
|
||||
await SystemCalendarMirror.removeById(this.context, bookId);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
await AppSettings.setMirrorBookIds(this.context, this.mirrorKeys);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「把系统日历的改动回写」开关(双向闭环的回程)。
|
||||
* 依赖镜像快照(MirrorSnapshot)防回灌:只把"和上次写入值不一样"的当成用户改动。
|
||||
* ⚠️ 默认关,因为它会写用户的服务器数据 —— 建议先单向观察一轮再打开。
|
||||
*/
|
||||
private async saveMirrorInbound(isOn: boolean): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
this.mirrorInbound = isOn;
|
||||
await AppSettings.setMirrorInbound(this.context, isOn);
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: isOn
|
||||
? '已开启回写:系统日历里的新建/修改/删除,下次同步时传回服务器'
|
||||
: '已关闭回写:系统日历的改动不再传回服务器'
|
||||
});
|
||||
}
|
||||
|
||||
/** 手动执行一次镜像(幂等,可反复点) */
|
||||
private async runMirrorNow(): Promise<void> {
|
||||
if (this.context === undefined || this.mirrorRunning) {
|
||||
return;
|
||||
}
|
||||
if (this.mirrorKeys.length === 0) {
|
||||
this.getUIContext().getPromptAction().showToast({ message: '请先勾选要镜像的日历本' });
|
||||
return;
|
||||
}
|
||||
this.mirrorRunning = true;
|
||||
try {
|
||||
const res: MirrorResult = await SystemCalendarMirror.syncNow(this.context);
|
||||
const skip: string = res.skipped > 0 ? ` / 跳过 ${res.skipped}` : '';
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: `镜像完成:${res.books} 个本,新增 ${res.added} / 更新 ${res.updated} / 删除 ${res.deleted}${skip}`
|
||||
});
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
const msg: string = e.message !== undefined && e.message !== ''
|
||||
? e.message : '请确认已授予"写入系统日历"权限';
|
||||
this.getUIContext().getPromptAction().showToast({ message: `镜像失败:${msg}` });
|
||||
} finally {
|
||||
this.mirrorRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「清理残留镜像账户(诊断)」按钮:
|
||||
* ① 把系统日历里**全部**账户(含 id / name / displayName / type)写进同步日志;
|
||||
* ② 把"我们建的、但不属于当前选中本"的账户全部删掉(逐个独立 try,失败也继续);
|
||||
* ③ Toast 汇总成功/失败/残留数量,残留账户名直接显示出来。
|
||||
*/
|
||||
private async runPurgeOrphans(): Promise<void> {
|
||||
if (this.context === undefined || this.purgeRunning) {
|
||||
return;
|
||||
}
|
||||
this.purgeRunning = true;
|
||||
try {
|
||||
const r: PurgeResult = await SystemCalendarMirror.forceCleanup(this.context);
|
||||
this.accountDump = r.detail.join('\n');
|
||||
let msg: string = `已清理 ${r.removed} 个残留账户`;
|
||||
if (r.failed > 0) {
|
||||
msg += `,失败 ${r.failed} 个`;
|
||||
}
|
||||
if (r.remaining > 0) {
|
||||
msg += `;仍残留 ${r.remaining} 个:${r.remainingNames.join(' , ')}`;
|
||||
}
|
||||
msg += '(完整账户清单见同步日志)';
|
||||
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 6000 });
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: `清理失败:${e.message ?? '请确认已授予"写入系统日历"权限'}`
|
||||
});
|
||||
} finally {
|
||||
this.purgeRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async saveInterval(minutes: number): Promise<void> {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
@@ -245,6 +566,10 @@ struct SettingsPage {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
// 后台持续同步依赖常驻通知 → 在用户主动开启时才申请通知权限(不随启动自动弹窗)
|
||||
if (value) {
|
||||
await this.ensureNotifyPermission();
|
||||
}
|
||||
try {
|
||||
await BackgroundSyncService.setBackgroundSync(
|
||||
this.context as common.UIAbilityContext, value);
|
||||
@@ -447,7 +772,7 @@ struct SettingsPage {
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('选择手机系统日历日程的处理方式')
|
||||
Text('选择手机系统日历日程的处理方式(需授权读取系统日历)')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
@@ -469,7 +794,8 @@ struct SettingsPage {
|
||||
this.saveSysMode(mode);
|
||||
}
|
||||
})
|
||||
if (this.sysMode === 'backup') {
|
||||
// ⭐ 备份目标**始终可配置**(不再只在 backup 模式下显示):
|
||||
// 否则"先选目标才能开启备份"会变成死锁(Select 只在 backup 模式下渲染)。
|
||||
Text(this.backupTargets.length > 0
|
||||
? '备份目标(可写日历本):导入后随同步上传服务器,换机/丢失也有备份'
|
||||
: '没有可写的 CalDAV 日历本,请先添加账号或检查日历本权限')
|
||||
@@ -481,8 +807,8 @@ struct SettingsPage {
|
||||
Select(this.backupTargets.map((t: BackupTarget): SelectOption => {
|
||||
return { value: t.label } as SelectOption;
|
||||
}) as SelectOption[])
|
||||
.selected(this.backupTargets.findIndex((t: BackupTarget): boolean => t.calKey === this.backupKey))
|
||||
.value(this.backupTargets.find((t: BackupTarget): boolean => t.calKey === this.backupKey)?.label
|
||||
.selected(this.backupTargets.findIndex((t: BackupTarget): boolean => t.bookId === this.backupKey))
|
||||
.value(this.backupTargets.find((t: BackupTarget): boolean => t.bookId === this.backupKey)?.label
|
||||
?? '请选择日历本')
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.font({ size: 14 })
|
||||
@@ -491,10 +817,162 @@ struct SettingsPage {
|
||||
.width('100%')
|
||||
.onSelect((index: number) => {
|
||||
if (index >= 0 && index < this.backupTargets.length) {
|
||||
this.saveBackupTarget(this.backupTargets[index].calKey);
|
||||
this.saveBackupTarget(this.backupTargets[index].bookId);
|
||||
}
|
||||
})
|
||||
}
|
||||
// 失效/未选择提示:目标本没了时功能已被自动关闭,必须让用户看得见原因
|
||||
if (this.backupNotice !== '') {
|
||||
Row({ space: 6 }) {
|
||||
Text('⚠️')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.error'))
|
||||
Text(this.backupNotice)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.error'))
|
||||
.layoutWeight(1)
|
||||
}
|
||||
.width('100%')
|
||||
.padding(8)
|
||||
.borderRadius(8)
|
||||
.backgroundColor($r('app.color.error_bg'))
|
||||
} else if (this.backupTargets.length > 0 && this.backupKey === '') {
|
||||
Text('尚未选择备份目标:请先选择要把系统日历备份到哪个 CalDAV 日历本,否则无法开启备份')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.error'))
|
||||
.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') })
|
||||
|
||||
// 镜像到系统日历:把选中的 CalDAV 日历本写入系统日历,让小艺 / 桌面卡片 / 手表也能看到
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 10 }) {
|
||||
Column({ space: 2 }) {
|
||||
Text('镜像到系统日历')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('把选中的 CalDAV 日历本写入系统日历,这样小艺、桌面日历卡片和手表也能看到你的日程(需授权写入系统日历)')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
Toggle({ type: ToggleType.Switch, isOn: this.mirrorEnabled })
|
||||
.selectedColor($r('app.color.brand'))
|
||||
.onChange((isOn: boolean) => {
|
||||
this.saveMirrorEnabled(isOn);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
if (this.mirrorEnabled) {
|
||||
if (this.mirrorCandidates.length === 0) {
|
||||
Text('没有可镜像的 CalDAV 日历本,请先添加账号')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.error'))
|
||||
.width('100%')
|
||||
} else {
|
||||
Text('选择要镜像的日历本:每个本在系统日历里是独立账户,可单独设色与隐藏')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
if (this.mirrorHiddenCount() > 0) {
|
||||
Text(`另有 ${this.mirrorHiddenCount()} 个本被设为「系统日历备份目标」,其日程本就来自系统日历,已自动排除以免重复`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
.width('100%')
|
||||
}
|
||||
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
|
||||
ForEach(this.mirrorCandidates, (b: BackupTarget): void => {
|
||||
Row({ space: 6 }) {
|
||||
Text(b.label)
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.layoutWeight(1)
|
||||
.maxLines(1)
|
||||
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||
Toggle({ type: ToggleType.Checkbox, isOn: this.mirrorKeys.indexOf(b.bookId) >= 0 })
|
||||
.selectedColor($r('app.color.brand'))
|
||||
.onChange((on: boolean) => {
|
||||
this.toggleMirrorKey(b.bookId, on);
|
||||
})
|
||||
}
|
||||
.width('48%')
|
||||
.padding({ left: 2, right: 2, top: 4, bottom: 4 })
|
||||
}, (b: BackupTarget): string => b.calKey)
|
||||
}
|
||||
.width('100%')
|
||||
|
||||
Row({ space: 10 }) {
|
||||
Column({ space: 2 }) {
|
||||
Text('把系统日历的改动回写')
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('你在系统日历里新建、修改、删除的日程,同步回 CalDAV 服务器')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.layoutWeight(1)
|
||||
Toggle({ type: ToggleType.Switch, isOn: this.mirrorInbound })
|
||||
.selectedColor($r('app.color.brand'))
|
||||
.onChange((isOn: boolean) => {
|
||||
this.saveMirrorInbound(isOn);
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 4 })
|
||||
|
||||
Button(this.mirrorRunning ? '正在执行…' : '立即执行镜像')
|
||||
.fontSize(14)
|
||||
.width('100%')
|
||||
.enabled(!this.mirrorRunning)
|
||||
.onClick(() => {
|
||||
this.runMirrorNow();
|
||||
})
|
||||
|
||||
Button(this.purgeRunning ? '正在清理…' : '清理残留镜像账户(诊断)')
|
||||
.fontSize(13)
|
||||
.fontColor($r('app.color.brand_text'))
|
||||
.backgroundColor(Color.Transparent)
|
||||
.width('100%')
|
||||
.enabled(!this.purgeRunning)
|
||||
.onClick(() => {
|
||||
this.runPurgeOrphans();
|
||||
})
|
||||
|
||||
if (this.accountDump !== '') {
|
||||
Column({ space: 4 }) {
|
||||
Text('系统日历账户清单(MINE=我们建的,KEEP=本次保留;长按可复制):')
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
Scroll() {
|
||||
Text(this.accountDump)
|
||||
.fontSize(10)
|
||||
.lineHeight(14)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.copyOption(CopyOptions.LocalDevice)
|
||||
.width('100%')
|
||||
}
|
||||
.height(150)
|
||||
.width('100%')
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
.borderRadius(8)
|
||||
.padding(6)
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ top: 4 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
@@ -984,6 +1462,19 @@ struct SettingsPage {
|
||||
.height('100%')
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面转场:淡入淡出。
|
||||
* 不做左右平移 / 上下位移(不调用 .slide() / .translate()),只做透明度变化,
|
||||
* 避免单帧直接切换;曲线使用弹簧曲线(springMotion)。
|
||||
*/
|
||||
pageTransition() {
|
||||
PageTransitionEnter({ type: RouteType.None, duration: 300, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
PageTransitionExit({ type: RouteType.None, duration: 250, curve: curves.springMotion(0.35, 0.75) })
|
||||
.opacity(0);
|
||||
}
|
||||
|
||||
|
||||
/** 隐私政策与用户协议弹层 */
|
||||
@Builder
|
||||
legalSheet() {
|
||||
@@ -1019,5 +1510,7 @@ struct SettingsPage {
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.backgroundColor($r('app.color.page_bg'))
|
||||
// 沉浸式:背景延伸到状态栏/导航条区域(只扩绘制、不移动子组件)
|
||||
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { SyncEngine } from '../common/SyncEngine';
|
||||
import { CardDataService } from '../common/CardDataService';
|
||||
import { ReminderService } from '../common/ReminderService';
|
||||
import { SystemCalendarImport } from '../common/SystemCalendarImport';
|
||||
import { SystemCalendarMirror } from '../common/SystemCalendarMirror';
|
||||
import { MirrorInbound } from '../common/MirrorInbound';
|
||||
import { AppSettings } from '../common/AppSettings';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
@@ -32,6 +34,12 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
|
||||
} catch (err) {
|
||||
// 导入失败不影响正常同步
|
||||
}
|
||||
// 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前)
|
||||
try {
|
||||
await MirrorInbound.reconcile(context);
|
||||
} catch (err) {
|
||||
// 对账失败不影响正常同步
|
||||
}
|
||||
let ok: number = 0;
|
||||
let attempted: number = 0;
|
||||
for (const acc of accounts) {
|
||||
@@ -58,6 +66,15 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
|
||||
// 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失,
|
||||
// 界面下次冷启动仍用旧颜色(表现为"同步之后颜色还是不对")。
|
||||
await AccountStore.saveAll(context, accounts);
|
||||
// 出站镜像:把服务器最新状态写进系统日历(自动化,无需手点)
|
||||
try {
|
||||
if (await AppSettings.getMirrorEnabled(context) && await SystemCalendarMirror.hasPermission()) {
|
||||
const mr = await SystemCalendarMirror.syncNow(context);
|
||||
LogUtil.write(`延迟任务自动镜像:本=${mr.books} 新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// 镜像失败不影响同步
|
||||
}
|
||||
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
|
||||
await CardDataService.pushToAllForms(context);
|
||||
await ReminderService.refreshReminders(context);
|
||||
|
||||
@@ -38,6 +38,16 @@
|
||||
"when": "inuse"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.WRITE_CALENDAR",
|
||||
"reason": "$string:perm_write_calendar",
|
||||
"usedScene": {
|
||||
"abilities": [
|
||||
"EntryAbility"
|
||||
],
|
||||
"when": "inuse"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.LOCATION",
|
||||
"reason": "$string:perm_location",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
{ "name": "text_secondary", "value": "#66000000" },
|
||||
{ "name": "text_hint", "value": "#4D000000" },
|
||||
{ "name": "brand", "value": "#007DFF" },
|
||||
{ "name": "brand_text", "value": "#0057C2" },
|
||||
{ "name": "brand_disabled", "value": "#99007DFF" },
|
||||
{ "name": "button_text", "value": "#FFFFFF" },
|
||||
{ "name": "success", "value": "#00B34A" },
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
},
|
||||
{
|
||||
"name": "perm_write_calendar",
|
||||
"value": "写入系统日历,用于保存您创建的日程"
|
||||
"value": "写入系统日历,用于把您选择的 CalDAV 日历本镜像到系统日历,让小艺、桌面日历卡片和手表也能看到您的日程"
|
||||
},
|
||||
{
|
||||
"name": "perm_read_whole_calendar",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"color": [
|
||||
{ "name": "start_window_background", "value": "#000000" },
|
||||
{ "name": "page_bg", "value": "#000000" },
|
||||
{ "name": "card_bg", "value": "#1C1C1E" },
|
||||
{ "name": "input_bg", "value": "#2C2C2E" },
|
||||
{ "name": "text_primary", "value": "#E5E5E7" },
|
||||
{ "name": "text_secondary", "value": "#99FFFFFF" },
|
||||
{ "name": "text_hint", "value": "#66FFFFFF" },
|
||||
{ "name": "brand", "value": "#0A84FF" },
|
||||
{ "name": "brand_text", "value": "#4DA3FF" },
|
||||
{ "name": "brand_disabled", "value": "#660A84FF" },
|
||||
{ "name": "button_text", "value": "#FFFFFF" },
|
||||
{ "name": "success", "value": "#34C759" },
|
||||
{ "name": "success_bg", "value": "#1F34C759" },
|
||||
{ "name": "error", "value": "#FF453A" },
|
||||
{ "name": "error_bg", "value": "#1FFF453A" },
|
||||
{ "name": "shadow_color", "value": "#1FFFFFFF" },
|
||||
{ "name": "grid_line", "value": "#1FFFFFFF" },
|
||||
{ "name": "today_bg", "value": "#330A84FF" },
|
||||
{ "name": "selected_bg", "value": "#0A84FF" },
|
||||
{ "name": "chip_off_bg", "value": "#1FFFFFFF" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user