Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
942f71bfdf | ||
|
|
24d8117ce7 | ||
|
|
8ae4e11507 | ||
|
|
c4dc27ef3a | ||
|
|
bd3afb7bd1 | ||
|
|
3025f92cb6 | ||
|
|
d890a2b053 | ||
|
|
19cfe3cf34 | ||
|
|
a49e7d03b1 | ||
|
|
ed26da8dc4 | ||
|
|
68329c57a0 | ||
|
|
2b29f197e8 |
+2
-2
@@ -2,8 +2,8 @@
|
||||
"app": {
|
||||
"bundleName": "synccalendar.yangyq.net",
|
||||
"vendor": "yangyq",
|
||||
"versionCode": 600,
|
||||
"versionName": "0.0.6",
|
||||
"versionCode": 800,
|
||||
"versionName": "0.0.8",
|
||||
"buildVersion": "1",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:app_name"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
一款以**同步为核心**的鸿蒙原生日历应用,基于 HarmonyOS NEXT(ArkTS / Stage 模型)开发。对接标准 CalDAV 服务器(群晖、Nextcloud、Radicale、Baikal 等),让你的日程真正掌握在自己手里——不经过第三方云服务,数据始终同步在你自己的 NAS 上。
|
||||
|
||||
- 应用市场送审版本:**0.0.2** | 仓库当前:**0.0.3**(versionCode 300)
|
||||
- 应用市场送审版本:**0.0.6** | 仓库当前:**0.0.6**(versionCode 600)
|
||||
- 官网:<https://synccalendar.yangyq.net> | 问题反馈:<https://synccalendar.yangyq.net/feedback/>
|
||||
- 开源协议:Apache License 2.0
|
||||
|
||||
@@ -55,11 +55,15 @@
|
||||
|
||||
### 后台同步与数据备份
|
||||
|
||||
- **自动同步间隔**:App 在前台时按设定间隔自动同步
|
||||
- **后台持续同步**:申请后台长时任务(`dataTransfer`),退到后台也按间隔继续同步,通知栏显示常驻通知
|
||||
- **系统级定时同步**:通过 WorkScheduler 扩展能力在条件满足时触发一轮同步
|
||||
- **前台自动同步**:App 在前台时按设定间隔自动同步(可设 1 / 5 / 15 / 30 分钟)
|
||||
- **后台自动同步**:开启后由系统定时唤起同步,退到后台也会继续(受系统省电策略限制,**最短约 2 小时一次**)
|
||||
- **回到前台 / 网络恢复补同步**:切回应用、或从断网恢复到有网时,**立即补同步一次**,保证你打开日历时数据总是最新的
|
||||
- **应用数据备份/恢复**:接入 Backup 扩展能力,换机时可恢复账号与本地数据
|
||||
|
||||
> 说明:HarmonyOS 出于省电考虑,**不允许普通应用在后台常驻并高频轮询**
|
||||
> (长时任务 `dataTransfer` 超过 10 分钟无进度即被系统取消,延迟任务存在小时级下限)。
|
||||
> 本应用遵循系统机制,采用「前台按间隔同步 + 后台定时唤起 + 回前台/网络恢复即时补同步」的分层组合。
|
||||
|
||||
### 界面与适配
|
||||
|
||||
- **深色模式**:全程使用资源引用(`$r('app.color.*')`),深浅两套色板一一对应,跟随系统自动切换
|
||||
@@ -69,7 +73,7 @@
|
||||
|
||||
### 设置项一览
|
||||
|
||||
系统日历处理方式(仅显示 / 备份到 CalDAV)· 镜像到系统日历 · 选择要镜像的日历本 · 回写开关 · 默认视图 · 日程显示方式 · 自动同步间隔 · 后台持续同步 · 通知权限 · 混合显示系统日历 · 日历本只读 / 静音 · 重建日程数据 · 重置账号库 · 软件特性 · 使用帮助 · 隐私政策与用户协议
|
||||
系统日历处理方式(仅显示 / 备份到 CalDAV)· 镜像到系统日历 · 选择要镜像的日历本 · 回写开关 · 默认视图 · 日程显示方式 · 自动同步间隔 · 后台自动同步 · 通知权限 · 混合显示系统日历 · 日历本只读 / 静音 · 重建日程数据 · 重置账号库 · 软件特性 · 使用帮助 · 隐私政策与用户协议
|
||||
|
||||
---
|
||||
|
||||
@@ -89,10 +93,11 @@
|
||||
|
||||
- **架构**:`SyncEngine` 同步引擎 + `DavClient`(HTTP / WebDAV)+ `EventDb`(relationalStore 本地库)+ `CalendarDataService`(多源合并展示)
|
||||
- **镜像**:`SystemCalendarMirror`(出站写入)+ `MirrorInbound`(入站回写)+ `MirrorSnapshot`(变更快照)
|
||||
- **后台调度**:`BackgroundSyncRunner`(后台同步唯一执行体)+ `BackgroundSyncService`(延迟任务/短时任务)+ `NetworkSyncWatcher`(网络恢复补同步)
|
||||
- **卡片**:FormExtensionAbility + FormBindingData,JSON 数据驱动渲染
|
||||
- **农历**:`LunarUtil`,1901–2099 压缩表推算
|
||||
- **提醒**:`ReminderService` 基于 `reminderAgentManager` 代理提醒
|
||||
- **权限**:INTERNET / READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(仅镜像)/ PUBLISH_AGENT_REMINDER / KEEP_BACKGROUND_RUNNING / LOCATION(仅地点导航时)
|
||||
- **权限**:INTERNET / READ_CALENDAR / READ_WHOLE_CALENDAR / WRITE_CALENDAR(仅镜像)/ PUBLISH_AGENT_REMINDER / LOCATION(仅地点导航时)
|
||||
|
||||
### 目录结构
|
||||
|
||||
@@ -118,7 +123,7 @@ SyncCalendar/
|
||||
│ ├─ entryability/ 主能力
|
||||
│ ├─ entrybackupability/ 应用数据备份恢复
|
||||
│ ├─ entryformability/ 桌面卡片
|
||||
│ └─ syncwork/ 后台定时同步(WorkScheduler)
|
||||
│ └─ syncwork/ 后台定时同步(WorkScheduler 扩展能力)
|
||||
└─ resources/ base / dark 两套资源与颜色
|
||||
```
|
||||
|
||||
@@ -126,14 +131,169 @@ SyncCalendar/
|
||||
|
||||
## 📦 构建与运行
|
||||
|
||||
1. 使用 **DevEco Studio** 打开工程根目录
|
||||
2. 连接鸿蒙真机(需 HarmonyOS NEXT)或模拟器
|
||||
3. 编译安装后进入「≡ → 账号管理」,添加 CalDAV 服务器地址、用户名和密码
|
||||
4. 选择要同步的日历本,等待首次同步完成
|
||||
5. 长按桌面空白处 → 添加服务卡片,选择 SyncCalendar 的卡片规格
|
||||
### 一、准备工程
|
||||
|
||||
> 上架包构建:`hvigorw assembleApp --mode project -p product=default -p buildMode=release`
|
||||
> (需要在 `build-profile.json5` 里配置自己的签名,该文件不入库)
|
||||
1. 安装 **DevEco Studio**(建议 5.0 及以上,需配套 HarmonyOS NEXT SDK)
|
||||
2. 用 DevEco Studio 打开工程根目录,等待自动同步(`ohpm install`)完成
|
||||
3. 连接鸿蒙真机(需 HarmonyOS NEXT)或模拟器
|
||||
|
||||
### 二、首次构建:先补上签名配置(重要)
|
||||
|
||||
> **为什么需要自己配?**
|
||||
> 鸿蒙应用在编译安装与上架时都必须签名。工程根目录的 **`build-profile.json5`** 里记录了
|
||||
> 签名文件的**绝对路径与加密口令**,属于机器相关且敏感的凭据,因此该文件**被 `.gitignore` 排除、不在仓库中**。
|
||||
> 你从仓库克隆下来的代码默认**不含任何签名信息**,直接编译会失败。下面是补全方法。
|
||||
|
||||
#### 1. 需要准备哪些文件
|
||||
|
||||
签名一共涉及 **4 个文件**,全部放在仓库**之外**的某个目录里(例如 `D:\DevEcoSign\`):
|
||||
|
||||
| 文件 | 格式 | 说明 |
|
||||
|---|---|---|
|
||||
| 密钥库 | `.p12` | 存放你的私钥与证书链,**最关键、绝不能泄露** |
|
||||
| 证书 | `.cer` | 与密钥库配套的应用证书 |
|
||||
| Profile | `.p7b` | 授权档案,决定该包能装到哪些设备 / 能否上架 |
|
||||
| (口令) | — | 密钥库口令、私钥口令(在生成时自己设定,请务必记牢) |
|
||||
|
||||
#### 2. 这些文件怎么申请、怎么得到
|
||||
|
||||
有两种途径,按你的目的选一种:
|
||||
|
||||
**途径 A:调试用(自用真机安装)—— 全自动,推荐**
|
||||
|
||||
DevEco Studio 可以一键生成并自动完成签名配置,无需手工操作:
|
||||
|
||||
1. 打开 `File → Project Structure → Project → Signing Configs`
|
||||
2. 勾选 **`Automatically generate signature`**(自动生成签名)
|
||||
3. 按提示登录你的**华为开发者账号**(需先在 [AppGallery Connect](https://developer.huawei.com/consumer/cn/) 完成实名认证)
|
||||
4. 等待自动完成——DevEco 会替你:生成 `.p12` 密钥库与 `.cer` 证书 → 向华为**申请调试 Profile(`.p7b`)**
|
||||
并自动注册当前设备 → 把 `signingConfigs` 写入 `build-profile.json5`
|
||||
5. 生成的文件默认落在 `C:\Users\<你>\.ohos\config\` 下
|
||||
|
||||
> 这是最省事的方式,**只要一个已实名认证的华为开发者账号**即可,不需要额外审批。
|
||||
|
||||
**途径 B:上架用(发布到应用市场)—— 需在 AGC 后台申请**
|
||||
|
||||
若要打**上架包**(`.app`),必须使用**发布证书 + 发布 Profile**,需手动申请:
|
||||
|
||||
1. 登录 [AppGallery Connect](https://developer.huawei.com/consumer/cn/),进入
|
||||
`用户与访问 → 证书管理`,创建并下载:
|
||||
- **发布证书(`.cer`)**——需先在本机用 `keytool` 或 DevEco 生成一个 `.p12` 密钥库,
|
||||
再上传其生成的 **CSR 文件**申请证书;
|
||||
- **发布 Profile(`.p7b`)**——在 `证书、APP ID 和 Profile → Profile` 中新建,
|
||||
类型选 **发布(Release)**,关联上面的证书与你的 App ID。
|
||||
2. 记住创建密钥库时设定的**密钥库口令**与**私钥口令**。
|
||||
|
||||
> 官方文档入口:AppGallery Connect → 帮助中心 → **「配置应用签名」** / 「证书管理」。
|
||||
|
||||
#### 3. 在哪个文件里填写
|
||||
|
||||
在**仓库根目录**新建 `build-profile.json5`(就是被忽略的那份),填入你的签名信息。
|
||||
可直接复制仓库中的模板 **`build-profile.example.json5`** 改名为 `build-profile.json5` 后使用,格式如下:
|
||||
|
||||
```json5
|
||||
{
|
||||
"app": {
|
||||
"signingConfigs": [
|
||||
{
|
||||
// 调试配置:真机安装用这项
|
||||
"name": "default",
|
||||
"type": "HarmonyOS",
|
||||
"material": {
|
||||
"storeFile": "D:/DevEcoSign/your.p12", // ← 改成你的密钥库绝对路径
|
||||
"storePassword": "<你的密钥库口令>", // ← 自动生成时是一段密文
|
||||
"keyAlias": "your_key_alias", // ← 密钥别名(生成时指定)
|
||||
"keyPassword": "<你的私钥口令>", // ← 同上
|
||||
"signAlg": "SHA256withECDSA",
|
||||
"profile": "D:/DevEcoSign/yourDebug.p7b", // ← 调试 Profile
|
||||
"certpath": "D:/DevEcoSign/yourDebug.cer" // ← 配套证书
|
||||
}
|
||||
},
|
||||
{
|
||||
// 上架配置:打 .app 包用这项(真机安装勿用)
|
||||
"name": "release",
|
||||
"type": "HarmonyOS",
|
||||
"material": {
|
||||
"storeFile": "D:/DevEcoSign/your.p12",
|
||||
"storePassword": "<你的密钥库口令>",
|
||||
"keyAlias": "your_key_alias",
|
||||
"keyPassword": "<你的私钥口令>",
|
||||
"signAlg": "SHA256withECDSA",
|
||||
"profile": "D:/DevEcoSign/yourRelease.p7b", // ← 发布 Profile
|
||||
"certpath": "D:/DevEcoSign/yourRelease.cer" // ← 发布证书
|
||||
}
|
||||
}
|
||||
],
|
||||
"products": [
|
||||
{
|
||||
"name": "default",
|
||||
"signingConfig": "default", // 用哪套签名:debug 用 "default",上架改 "release"
|
||||
"targetSdkVersion": "26.0.0",
|
||||
"compatibleSdkVersion": "6.1.1(24)",
|
||||
"runtimeOS": "HarmonyOS",
|
||||
"buildOption": {
|
||||
"strictMode": { "caseSensitiveCheck": true, "useNormalizedOHMUrl": true }
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildModeSet": [ { "name": "debug" }, { "name": "release" } ]
|
||||
},
|
||||
"modules": [
|
||||
{ "name": "entry", "srcPath": "./entry",
|
||||
"targets": [ { "name": "default", "applyToProducts": [ "default" ] } ] }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**关键位置说明:**
|
||||
|
||||
| 位置 | 作用 |
|
||||
|---|---|
|
||||
| `signingConfigs[].material.*` | 四个签名文件的**绝对路径**与两个口令 |
|
||||
| `products[0].signingConfig` | 选哪套签名:真机安装填 `"default"`,打上架包填 `"release"` |
|
||||
|
||||
> ⚠️ **`build-profile.json5` 已在 `.gitignore` 中,请勿提交**。
|
||||
> 若用「途径 A」自动生成,DevEco 会自动创建并填好该文件,你通常无需手写。
|
||||
> 注意:`entry/build-profile.json5` 是**模块级**普通构建配置(不含密钥),它在仓库中是**需要保留**的,两者不要混淆。
|
||||
|
||||
#### 4. 一个更省事的替代做法
|
||||
|
||||
如果不想手写,也可以直接在 DevEco Studio 里操作:
|
||||
`File → Project Structure → Project → Signing Configs`,勾选 `Automatically generate signature`
|
||||
并登录华为开发者账号——DevEco 会自动把 `build-profile.json5` 生成好,随后直接编译即可。
|
||||
|
||||
### 三、编译运行
|
||||
|
||||
1. 点击 DevEco Studio 的 **Run ▶**,或执行:
|
||||
|
||||
```bash
|
||||
hvigorw assembleHap --mode module -p product=default
|
||||
```
|
||||
|
||||
2. 安装后进入「≡ → 账号管理」,添加 CalDAV 服务器地址、用户名和密码
|
||||
3. 选择要同步的日历本,等待首次同步完成
|
||||
4. 长按桌面空白处 → 添加服务卡片,选择 SyncCalendar 的卡片规格
|
||||
|
||||
### 四、打上架包(App Pack)
|
||||
|
||||
应用市场上架需要提交 **`.app`(App Pack)**,而不是 `.hap`(`.hap` 仅用于真机侧载):
|
||||
|
||||
```bash
|
||||
hvigorw assembleApp --mode project -p product=default -p buildMode=release
|
||||
```
|
||||
|
||||
- 产物:`build/outputs/default/SyncCalendar-default-signed.app`
|
||||
- **前提**:请先把 `products[0].signingConfig` 改为 `"release"`(用发布证书 + 发布 Profile),
|
||||
否则会因签名不匹配而无法通过审核。
|
||||
- 校验签名(可选,`-inForm zip` 不可省,否则会误报 `can not find codesign block`):
|
||||
|
||||
```bash
|
||||
java -jar hap-sign-tool.jar verify-app -inForm zip \
|
||||
-inFile SyncCalendar-default-signed.app \
|
||||
-outCertChain out.cer -outProfile out.p7b
|
||||
```
|
||||
|
||||
出现 `verify-app success` 即表示签名有效。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* build-profile.json5 模板(示例)
|
||||
*
|
||||
* 用法:
|
||||
* 1. 把本文件复制为同目录下的 build-profile.json5
|
||||
* 2. 按注释把 <尖括号> 内容替换成你自己的签名信息
|
||||
* 3. 不要提交生成后的 build-profile.json5(它已在 .gitignore 中)
|
||||
*
|
||||
* 4 个签名文件(.p12 / .cer / .p7b ×2)需要放在仓库之外的任意目录,
|
||||
* 这里用 D:/DevEcoSign/ 作为示例路径,请改成你自己的真实路径。
|
||||
*
|
||||
* 如何获取这些文件、在哪里填写,详见 README.md 的「📦 构建与运行 → 二、首次构建:先补上签名配置」。
|
||||
*
|
||||
* 最省事的替代方案:用 DevEco Studio 打开工程 →
|
||||
* File → Project Structure → Project → Signing Configs →
|
||||
* 勾选 "Automatically generate signature" 并登录华为开发者账号,
|
||||
* IDE 会自动生成并填好本文件,无需手工编辑。
|
||||
*/
|
||||
{
|
||||
"app": {
|
||||
"signingConfigs": [
|
||||
{
|
||||
// ── 调试配置:真机安装 / 本地运行用这一项 ──
|
||||
"name": "default",
|
||||
"type": "HarmonyOS",
|
||||
"material": {
|
||||
"storeFile": "D:/DevEcoSign/<你的密钥库>.p12",
|
||||
"storePassword": "<密钥库口令>",
|
||||
"keyAlias": "<密钥别名>",
|
||||
"keyPassword": "<私钥口令>",
|
||||
"signAlg": "SHA256withECDSA",
|
||||
"profile": "D:/DevEcoSign/<你的调试Profile>.p7b",
|
||||
"certpath": "D:/DevEcoSign/<你的调试证书>.cer"
|
||||
}
|
||||
},
|
||||
{
|
||||
// ── 上架配置:打 .app 上架包用这一项(真机安装请勿使用)──
|
||||
"name": "release",
|
||||
"type": "HarmonyOS",
|
||||
"material": {
|
||||
"storeFile": "D:/DevEcoSign/<你的密钥库>.p12",
|
||||
"storePassword": "<密钥库口令>",
|
||||
"keyAlias": "<密钥别名>",
|
||||
"keyPassword": "<私钥口令>",
|
||||
"signAlg": "SHA256withECDSA",
|
||||
"profile": "D:/DevEcoSign/<你的发布Profile>.p7b",
|
||||
"certpath": "D:/DevEcoSign/<你的发布证书>.cer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"products": [
|
||||
{
|
||||
"name": "default",
|
||||
// 用哪套签名:真机安装填 "default";打上架包前改成 "release"
|
||||
"signingConfig": "default",
|
||||
"targetSdkVersion": "26.0.0",
|
||||
"compatibleSdkVersion": "6.1.1(24)",
|
||||
"runtimeOS": "HarmonyOS",
|
||||
"buildOption": {
|
||||
"strictMode": {
|
||||
"caseSensitiveCheck": true,
|
||||
"useNormalizedOHMUrl": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildModeSet": [
|
||||
{
|
||||
"name": "debug"
|
||||
},
|
||||
{
|
||||
"name": "release"
|
||||
}
|
||||
]
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"name": "entry",
|
||||
"srcPath": "./entry",
|
||||
"targets": [
|
||||
{
|
||||
"name": "default",
|
||||
"applyToProducts": [
|
||||
"default"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,6 +20,13 @@ export class AppSettings {
|
||||
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'; // 应用内提醒上次检查时间戳
|
||||
// ⭐ 卡片刷新驱动的后台同步:上次执行时间戳(用于节流)。
|
||||
// 服务卡片每 30 分钟被系统刷新一次(form_config 的 updateDuration=1),
|
||||
// 借这个时机顺带跑一次后台同步 —— 得到"约 30 分钟一次"的定期同步。
|
||||
private static readonly KEY_CARD_SYNC_AT: string = 'card_sync_at';
|
||||
/** ⭐ 诊断开关:卡片驱动同步的时间预算(毫秒)。0/未设置 = 用内置默认(见 BackgroundSyncRunner)。
|
||||
* 留给真机排查用 —— 不用重编译就能做"9 秒 vs 长窗口"对照实验。 */
|
||||
private static readonly KEY_CARD_BUDGET_MS: string = 'card_budget_ms';
|
||||
private static readonly KEY_FULL_REFETCH: string = 'full_refetch_done'; // 一次性全量重拉已完成
|
||||
private static readonly KEY_DEFAULT_VIEW: string = 'default_view'; // 打开 App 默认视图
|
||||
private static readonly KEY_DISPLAY_STYLE: string = 'display_style'; // 日程显示方式:'timeline' | 'list'
|
||||
@@ -27,6 +34,9 @@ export class AppSettings {
|
||||
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(默认关)
|
||||
// ⭐ 系统日历账户的**显示名格式**(1/2/3,默认 3=`<账号>·<本名>`)。
|
||||
// 因平台无"改账户名"API,切换格式需删账户重建(用户显式点「应用」才执行)。
|
||||
private static readonly KEY_MIRROR_DISPLAY_FORMAT: string = 'mirror_display_format';
|
||||
private static readonly KEY_POLICY_AGREED: string = 'policy_agreed'; // 是否已同意隐私政策与用户协议
|
||||
// 首启功能引导"用户已看过并关闭"的标记。键名带版本号:改动引导内容后把 vN 加 1,用户即可再看一次。
|
||||
// 注意:只在用户主动关闭("开始使用" / ✕ / 点遮罩)时才置 true,**不再"显示前就置 true"**——
|
||||
@@ -293,6 +303,67 @@ export class AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 系统日历账户显示名格式:1=`同步日历·本名`,2=`同步日历·账号·本名`,3=`账号·本名`(默认)。
|
||||
* 只接受 1/2/3,其它值一律回落到默认 3(防止脏数据导致拼出意外名字)。
|
||||
*/
|
||||
static async getMirrorDisplayFormat(context: common.Context): Promise<number> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
const v = await store.get(AppSettings.KEY_MIRROR_DISPLAY_FORMAT, 3) as number;
|
||||
return (v === 1 || v === 2 || v === 3) ? v : 3;
|
||||
} catch (err) {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
static async setMirrorDisplayFormat(context: common.Context, value: number): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_MIRROR_DISPLAY_FORMAT, value);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存显示名格式失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** ⭐ 卡片刷新驱动的后台同步:上次执行时间戳(0 = 从未执行) */
|
||||
static async getCardSyncAt(context: common.Context): Promise<number> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
return await store.get(AppSettings.KEY_CARD_SYNC_AT, 0) as number;
|
||||
} catch (err) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
static async setCardSyncAt(context: common.Context, ts: number): Promise<void> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
await store.put(AppSettings.KEY_CARD_SYNC_AT, ts);
|
||||
await store.flush();
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
console.error(`保存卡片同步时间失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** ⭐ 诊断用:卡片驱动同步的时间预算(毫秒)。0 = 未设置(用内置默认值)。 */
|
||||
static async getCardBudgetMs(context: common.Context): Promise<number> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
return await store.get(AppSettings.KEY_CARD_BUDGET_MS, 0) as number;
|
||||
} catch (err) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否还需要一次性全量重拉:修复历史同步(REPORT 剥离 VALARM 时期)落库的残缺数据。
|
||||
* 全量重拉期间忽略 etag 复用,所有资源 GET 完整 ICS;成功完成后标记,恢复增量模式。
|
||||
@@ -577,15 +648,23 @@ export class AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/** 自动同步间隔(分钟),默认 1 分钟 */
|
||||
/**
|
||||
* 自动同步间隔(分钟),默认 15 分钟。
|
||||
*
|
||||
* ⭐ 0.0.8:默认值由 1 分钟改为 15 分钟。原因(真机日志 2026-10-16 17:16–17:20 实测):
|
||||
* 1 分钟间隔会让「App 打开期间」几乎不间断地跑"12 个日历本全量比对 + 出站镜像 + 提醒刷新",
|
||||
* 5 分钟内连跑 5 轮后触发系统提示「短时任务超时(配额用尽),后台同步将被挂起」——
|
||||
* 反而把真正的后台同步通道一起拖垮。改为 15 分钟后,前台同步仍足够及时,且不再烧配额。
|
||||
* 用户仍可在设置页自行改成 1 / 5 / 15 / 30 分钟 或 1 小时。
|
||||
*/
|
||||
static async getSyncIntervalMinutes(context: common.Context): Promise<number> {
|
||||
try {
|
||||
const store: preferences.Preferences =
|
||||
await preferences.getPreferences(context, AppSettings.STORE);
|
||||
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 1) as number;
|
||||
return v > 0 ? v : 1;
|
||||
const v: number = await store.get(AppSettings.KEY_SYNC_INTERVAL, 15) as number;
|
||||
return v > 0 ? v : 15;
|
||||
} catch (err) {
|
||||
return 1;
|
||||
return 15;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// entry/src/main/ets/common/BackgroundSyncRunner.ets
|
||||
// ⭐ 后台同步的**唯一**执行体:延迟任务 / 短时任务 / 网络恢复 / 回到前台 都调它。
|
||||
//
|
||||
// 设计要点(2026-10-10 定时同步方案调研结论):
|
||||
// - 不再依赖"进程常驻"(系统不允许,dataTransfer 长时任务会被强制取消)
|
||||
// - 而是让"最合适的机制在最合适的时机把数据对齐"
|
||||
// - 本类只负责"跑一次完整同步",幂等(基于 etag 全量比对 + dirty 标记)
|
||||
//
|
||||
// ⚠️ 与 SyncEngine.syncAccount 的分工:本类不关心超时与互斥锁(由 SyncEngine 内部处理)。
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import process from '@ohos.process';
|
||||
import { DavAccount, AccountStore, TYPE_CALDAV } from './AccountStore';
|
||||
import { SyncEngine } from './SyncEngine';
|
||||
import { CardDataService } from './CardDataService';
|
||||
import { ReminderService } from './ReminderService';
|
||||
import { SystemCalendarImport } from './SystemCalendarImport';
|
||||
import { SystemCalendarMirror } from './SystemCalendarMirror';
|
||||
import { MirrorInbound } from './MirrorInbound';
|
||||
import { AppSettings } from './AppSettings';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
/** 一次后台同步的结果(供日志/埋点,不做 UI) */
|
||||
export class BackgroundSyncResult {
|
||||
ok: number = 0;
|
||||
attempted: number = 0;
|
||||
failed: number = 0;
|
||||
}
|
||||
|
||||
export class BackgroundSyncRunner {
|
||||
/**
|
||||
* ⭐ 卡片的系统定时刷新间隔(与 form_config.json 的 updateDuration=1 保持一致,单位 30 分钟)。
|
||||
* ⚠️ 两处必须同步修改:改这里也要改 form_config.json,否则节流窗口对不上。
|
||||
*/
|
||||
static readonly CARD_TICK_MS: number = 30 * 60 * 1000;
|
||||
/** 距上次同步不足该时长则跳过(略小于卡片周期,避免系统刷新漂移导致被误跳过) */
|
||||
private static readonly CARD_MIN_GAP_MS: number = 25 * 60 * 1000;
|
||||
/**
|
||||
* 卡片进程的软超时(**未申请到短时任务时**):官方规定 FormExtensionAbility
|
||||
* 生命周期回调后进程**仅存续 10 秒**,超过就被回收(异步任务被强制中止)。
|
||||
* 这里主动在 9 秒收手,让"这一次同步"**干净地放弃**而不是被硬杀在半途。
|
||||
*/
|
||||
private static readonly CARD_SOFT_TIMEOUT_MS: number = 9 * 1000;
|
||||
/**
|
||||
* 申请到短时任务后的软超时:短时任务单次最长 3 分钟(低电量 1 分钟),
|
||||
* 但实际时长以 `actualDelayTime` 为准 → 留 15 秒余量提前收手,保证能干净地
|
||||
* `cancelSuspendDelay`(否则超时不取消会被系统强制终止进程、并浪费当日配额)。
|
||||
*/
|
||||
private static readonly CARD_EXT_SOFT_RESERVE_MS: number = 15 * 1000;
|
||||
/**
|
||||
* 卡片驱动同步可用的**总时长上限**:即使申请到短时任务也不超过这个值。
|
||||
* 理由:短时任务**单日总配额仅 10 分钟**,30 分钟一次的话每次平均只能分到约 20 秒,
|
||||
* 全用光会让"退后台那一次同步"(EntryAbility.onBackground 申请)无配额可用。
|
||||
*/
|
||||
private static readonly CARD_MAX_TOTAL_MS: number = 60 * 1000;
|
||||
|
||||
/** 本次卡片同步申请的短时任务 requestId(0 = 未申请到) */
|
||||
private static cardTransientId: number = 0;
|
||||
|
||||
/** 当前进程 pid(取不到返回 -1)。用来判断卡片回调是否与主 App 同进程。 */
|
||||
private static pidOf(): number {
|
||||
try {
|
||||
return process.pid;
|
||||
} catch (err) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐⭐ 由**服务卡片定时刷新**(`onUpdateForm`)驱动的后台同步(0.0.8)。
|
||||
*
|
||||
* 为什么用它:系统给普通应用的后台调度很受限 ——
|
||||
* `workScheduler` 的周期被"应用活跃分组"压制到 **≥2 小时**;
|
||||
* 而服务卡片配置了 `updateDuration: 1`(**单位 30 分钟**),系统会**每 30 分钟**
|
||||
* 触发一次 `onUpdateForm()`,且不受活跃分组压制(每天上限 50 次)。
|
||||
* 借这个时机跑一次完整同步,即可得到"约 30 分钟一次"的定期后台同步。
|
||||
*
|
||||
* ⚠️ 节流:系统刷新本身可能有漂移、也可能因故重入 → 用时间戳挡一道,
|
||||
* 保证两次真实同步之间至少间隔 `CARD_MIN_GAP_MS`。
|
||||
* ⚠️ 本方法**永不抛错**(后台路径没有 UI 承担异常)。
|
||||
*/
|
||||
static async runOnceThrottledByCard(context: common.Context): Promise<void> {
|
||||
try {
|
||||
const last: number = await AppSettings.getCardSyncAt(context);
|
||||
const now: number = Date.now();
|
||||
if (last > 0 && now - last < BackgroundSyncRunner.CARD_MIN_GAP_MS) {
|
||||
const mins: number = Math.round((now - last) / 60000);
|
||||
LogUtil.write(`卡片驱动同步:距上次仅 ${mins} 分钟,跳过(节流 ${BackgroundSyncRunner.CARD_MIN_GAP_MS / 60000} 分钟)`);
|
||||
return;
|
||||
}
|
||||
// 先写时间戳:避免同步执行期间卡片再次刷新导致并发重入
|
||||
await AppSettings.setCardSyncAt(context, now);
|
||||
// ⭐ 尝试申请**短时任务**,把 10 秒窗口延长到最多 3 分钟。
|
||||
// ⚠️ 官方规定 requestSuspendDelay 应在"应用处于前台"或"onBackground 回调"里调用;
|
||||
// 卡片进程(FormExtensionAbility)是否符合**未知** → 故此处**容错尝试**:
|
||||
// 成功则按实际时长延长,失败(抛错/时长为 0)则退回 9 秒软超时。
|
||||
// ⚠️ 配额:短时任务单日总配额 10 分钟、单次最长 3 分钟(低电量 1 分钟),
|
||||
// 且**被挂起后才计时**(10 秒内跑完不消耗)→ 对我们很划算。
|
||||
const windowMs: number = await BackgroundSyncRunner.acquireCardWindow();
|
||||
let budget: number = Math.min(windowMs, BackgroundSyncRunner.CARD_MAX_TOTAL_MS);
|
||||
// ⭐ 诊断开关(preferences:card_budget_ms):>0 时**覆盖**上面算出的可用窗口。
|
||||
// 用途:在真机上回答"卡片进程究竟能活多久""一次完整同步要多久",
|
||||
// 从而决定卡片这条路该做多少事 —— 不必为此重新编译。
|
||||
const overrideMs: number = await AppSettings.getCardBudgetMs(context);
|
||||
if (overrideMs > 0) {
|
||||
budget = overrideMs;
|
||||
LogUtil.write(`卡片驱动同步:预算被诊断开关覆盖为 ${Math.round(budget / 1000)} 秒`);
|
||||
}
|
||||
LogUtil.write(`卡片驱动同步:开始(卡片 30 分钟定时刷新触发,pid=${BackgroundSyncRunner.pidOf()},可用窗口 ${Math.round(budget / 1000)} 秒)`);
|
||||
// 软超时:预算内没跑完就主动放弃(幂等同步,中断不损坏数据,下轮会重试)
|
||||
const worker: Promise<void> =
|
||||
BackgroundSyncRunner.runOnce(context, 'cardTick').then((): void => {});
|
||||
const guard: Promise<void> = new Promise<void>((_res: () => void, rej: (e: Error) => void) => {
|
||||
setTimeout((): void => rej(new Error('本次同步时间预算用尽,主动收手')),
|
||||
budget);
|
||||
});
|
||||
try {
|
||||
await Promise.race<void>([worker, guard]);
|
||||
} catch (err) {
|
||||
const e = err as Error;
|
||||
LogUtil.write(`卡片驱动同步提前收手:${e.message}(幂等,下轮会重试)`);
|
||||
} finally {
|
||||
// 无论成败都释放短时任务:① 归还当日配额 ② 避免超时不取消被系统强杀进程
|
||||
BackgroundSyncRunner.releaseCardWindow();
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`卡片驱动同步异常: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为卡片同步申请短时任务,返回**实际可用窗口(毫秒)**。
|
||||
* 申请失败/异常/时长为 0 → 返回 0,由调用方退回默认的 9 秒软超时(不阻塞流程)。
|
||||
*/
|
||||
private static async acquireCardWindow(): Promise<number> {
|
||||
try {
|
||||
const info: backgroundTaskManager.DelaySuspendInfo =
|
||||
backgroundTaskManager.requestSuspendDelay('正在同步日历数据', (): void => {
|
||||
// 超时回调:配额即将用尽,清掉本地记录(系统会自行处理挂起)
|
||||
LogUtil.write('卡片同步的短时任务即将超时');
|
||||
BackgroundSyncRunner.cardTransientId = 0;
|
||||
});
|
||||
BackgroundSyncRunner.cardTransientId = info.requestId;
|
||||
const actual: number = info.actualDelayTime ?? 0;
|
||||
if (actual <= 0) {
|
||||
// 拿到了 requestId 但没有实际时长 → 视为不可用
|
||||
BackgroundSyncRunner.releaseCardWindow();
|
||||
LogUtil.write('卡片同步:短时任务时长为 0,退回 9 秒软超时');
|
||||
return BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS;
|
||||
}
|
||||
// 留 15 秒余量提前收手,保证能干净地取消
|
||||
const usable: number = Math.max(actual - BackgroundSyncRunner.CARD_EXT_SOFT_RESERVE_MS,
|
||||
BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS);
|
||||
LogUtil.write(`卡片同步:短时任务已获批,实际时长 ${Math.round(actual / 1000)} 秒`);
|
||||
return usable;
|
||||
} catch (err) {
|
||||
// 例如"未在前台/退后台时机申请"被拒 —— 退回 9 秒软超时,不影响流程
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`卡片同步:短时任务申请失败(${e.code ?? ''} ${e.message}),退回 9 秒软超时`);
|
||||
return BackgroundSyncRunner.CARD_SOFT_TIMEOUT_MS;
|
||||
}
|
||||
}
|
||||
|
||||
/** 释放卡片同步的短时任务(幂等) */
|
||||
private static releaseCardWindow(): void {
|
||||
if (BackgroundSyncRunner.cardTransientId === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
backgroundTaskManager.cancelSuspendDelay(BackgroundSyncRunner.cardTransientId);
|
||||
} catch (err) {
|
||||
// 取消失败无需处理
|
||||
}
|
||||
BackgroundSyncRunner.cardTransientId = 0;
|
||||
}
|
||||
/**
|
||||
* 跑一次完整同步。全流程 try-catch,**永不抛错**(后台路径没有 UI 承担异常)。
|
||||
* @param context 应用上下文
|
||||
* @param tag 日志标签,用于区分触发来源(deferred/transient/netRecover/foreground)
|
||||
*/
|
||||
static async runOnce(context: common.Context, tag: string): Promise<BackgroundSyncResult> {
|
||||
const r = new BackgroundSyncResult();
|
||||
// ⭐ 阶段计时:后台同步被时间预算切断时,这些 `+Xms` 就是"钱花在哪了"的直接证据。
|
||||
const t0: number = Date.now();
|
||||
const el = (): number => Date.now() - t0;
|
||||
try {
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
LogUtil.write(`后台同步[${tag}] 阶段:载入账号 ${accounts.length} 个 +${el()}ms`);
|
||||
if (accounts.length === 0) {
|
||||
LogUtil.write(`后台同步[${tag}]:无账号,跳过`);
|
||||
return r;
|
||||
}
|
||||
// 备份模式:先导入系统本地日历(幂等),再随同步推送
|
||||
try {
|
||||
await SystemCalendarImport.importIfNeeded(context);
|
||||
} catch (err) {
|
||||
// 导入失败不影响正常同步
|
||||
}
|
||||
LogUtil.write(`后台同步[${tag}] 阶段:系统日历导入 +${el()}ms`);
|
||||
// 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前)
|
||||
try {
|
||||
await MirrorInbound.reconcile(context);
|
||||
} catch (err) {
|
||||
// 对账失败不影响正常同步
|
||||
}
|
||||
LogUtil.write(`后台同步[${tag}] 阶段:入站对账 +${el()}ms`);
|
||||
for (const acc of accounts) {
|
||||
if (acc.type !== TYPE_CALDAV) {
|
||||
continue;
|
||||
}
|
||||
r.attempted++;
|
||||
try {
|
||||
await SyncEngine.syncAccount(context, acc);
|
||||
r.ok++;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
// "已有同步进行中"不算失败(前台可能正在同步)
|
||||
r.failed++;
|
||||
LogUtil.write(`后台同步[${tag}] id=${acc.id} 失败: ${e.message}`);
|
||||
}
|
||||
LogUtil.write(`后台同步[${tag}] 阶段:账号 ${acc.id} 同步 +${el()}ms(ok=${r.ok} failed=${r.failed})`);
|
||||
}
|
||||
// 全部账号同步成功才结束"一次性全量重拉"状态
|
||||
if (r.attempted > 0 && r.ok === r.attempted) {
|
||||
await AppSettings.markFullRefetchDone(context);
|
||||
}
|
||||
await SyncEngine.settleLocalEvents(context);
|
||||
LogUtil.write(`后台同步[${tag}] 阶段:本地收尾 +${el()}ms`);
|
||||
await SyncEngine.pruneOrphanRows(context, accounts);
|
||||
LogUtil.write(`后台同步[${tag}] 阶段:孤儿行清理 +${el()}ms`);
|
||||
// ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。
|
||||
// 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失。
|
||||
await AccountStore.saveAll(context, accounts);
|
||||
// 出站镜像:把服务器最新状态写进系统日历(自动化,无需手点)
|
||||
try {
|
||||
if (await AppSettings.getMirrorEnabled(context) && await SystemCalendarMirror.hasPermission()) {
|
||||
const mr = await SystemCalendarMirror.syncNow(context);
|
||||
LogUtil.write(`后台同步[${tag}] 自动镜像:本=${mr.books} 新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// 镜像失败不影响同步
|
||||
}
|
||||
LogUtil.write(`后台同步[${tag}] 阶段:出站镜像 +${el()}ms`);
|
||||
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
|
||||
try {
|
||||
await CardDataService.pushToAllForms(context);
|
||||
await ReminderService.refreshReminders(context as common.UIAbilityContext);
|
||||
} catch (err) {
|
||||
// 卡片/提醒刷新失败不影响同步结果
|
||||
}
|
||||
LogUtil.write(`后台同步[${tag}] 完成:${r.ok}/${r.attempted} 个账号,总耗时 ${Math.round(el() / 1000)} 秒`);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`后台同步[${tag}] 流程异常: ${e.message}(已耗时 ${Math.round(el() / 1000)} 秒)`);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,46 @@
|
||||
// entry/src/main/ets/common/BackgroundSyncService.ets
|
||||
// 后台同步管理:
|
||||
// 1) 长时任务(DATA_TRANSFER):进程不被挂起,前台定时器在后台继续跑(需用户在设置中开启)
|
||||
// 2) 延迟任务(workScheduler):App 被杀/重启后由系统按条件拉起兜底同步(最小周期 20 分钟)
|
||||
// ⭐ 后台同步调度(2026-10-10 重构,依据《定时同步方案调研.md》)
|
||||
//
|
||||
// 为什么不再用长时任务(重要):
|
||||
// 旧版申请 `dataTransfer` 长时任务想让进程常驻,但系统对 DATA_TRANSFER 有**一致性校验**——
|
||||
// "超过 10 分钟未更新传输进度 → 任务被强制取消"。日历同步是"间歇跑、平时无进度",
|
||||
// 本质不符合"持续传输中"的语义 → 必被取消(常驻通知消失就是这个原因),
|
||||
// 且频繁触发校验失败可能被系统列入管控名单。故**彻底移除**。
|
||||
//
|
||||
// 现在的分层调度(每层各司其职):
|
||||
// ① 前台:Index 里的 setInterval 按用户设定间隔同步(最准,已在前台实现)
|
||||
// ② 退后台瞬间:**短时任务**(requestSuspendDelay)兜住"退后台后那一次"
|
||||
// ③ 长期后台:**延迟任务**(workScheduler,≥2 小时,对齐系统活跃分组下限)
|
||||
// ④ 网络恢复 / 回到前台:各补一次(性价比最高,见 NetworkSyncWatcher / Index.onForeground)
|
||||
//
|
||||
// ⚠️ 关键约束(踩过的坑,勿改):
|
||||
// - workScheduler 的 repeatCycleTime **不得低于 2 小时**:系统按"应用活跃分组"压制
|
||||
// (活跃≥2h / 经常使用≥4h / 常用≥24h / 极少使用≥48h / 受限·从未使用禁止调度),
|
||||
// 请求更短不报错但会被压制,形同虚设。
|
||||
// - WorkSchedulerExtensionAbility 单次回调**上限 2 分钟**,超时进程被杀。
|
||||
// - 短时任务配额:单日 10 分钟、单次最长 3 分钟(低电量 1 分钟)→ 只做短收尾,用完必须 cancel。
|
||||
import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
|
||||
import { wantAgent, common } from '@kit.AbilityKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { workScheduler } from '@kit.BackgroundTasksKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { AppSettings } from './AppSettings';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
const WORK_ID: number = 1001;
|
||||
/** 延迟任务周期:2 小时(系统"活跃分组"下限,不可再短) */
|
||||
const DEFERRED_CYCLE_MS: number = 2 * 60 * 60 * 1000;
|
||||
|
||||
export class BackgroundSyncService {
|
||||
/** 申请长时任务(DATA_TRANSFER):申请成功后进程在后台不被挂起,失败抛错由调用方提示用户 */
|
||||
static async startContinuousTask(context: common.UIAbilityContext): Promise<void> {
|
||||
try {
|
||||
const info: wantAgent.WantAgentInfo = {
|
||||
wants: [{
|
||||
bundleName: context.abilityInfo.bundleName,
|
||||
abilityName: 'EntryAbility'
|
||||
}],
|
||||
operationType: wantAgent.OperationType.START_ABILITY,
|
||||
requestCode: 0
|
||||
};
|
||||
const agent = await wantAgent.getWantAgent(info);
|
||||
await backgroundTaskManager.startBackgroundRunning(
|
||||
context, backgroundTaskManager.BackgroundMode.DATA_TRANSFER, agent);
|
||||
LogUtil.write('长时任务已申请:后台持续同步开启');
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`长时任务申请失败: ${e.code} - ${e.message}`);
|
||||
throw new Error(`长时任务申请失败(错误码 ${e.code})`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止长时任务 */
|
||||
static async stopContinuousTask(context: common.UIAbilityContext): Promise<void> {
|
||||
try {
|
||||
await backgroundTaskManager.stopBackgroundRunning(context);
|
||||
LogUtil.write('长时任务已停止');
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`长时任务停止失败: ${e.code} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
/** 当前短时任务的 requestId(0 = 无);退后台申请、回前台取消 */
|
||||
private static transientId: number = 0;
|
||||
|
||||
/**
|
||||
* 注册延迟任务兜底同步:无论长时任务开关与否都注册。
|
||||
* 条件:有任意网络即执行;周期重复;重启后自动恢复。
|
||||
* 注:repeatCycleTime 各版本下限不同(20 分钟 ~ 2 小时),先用 20 分钟,
|
||||
* 被系统拒绝(9700003 条件无效)时降级为 2 小时再试。
|
||||
* 注册延迟任务兜底同步:周期 2 小时、重启后自动恢复(isPersisted)。
|
||||
* 条件:有任意网络即执行。
|
||||
* ⚠️ 周期**不得低于 2 小时**(系统按"应用活跃分组"压制:活跃≥2h / 经常使用≥4h / 常用≥24h /
|
||||
* 极少使用≥48h / 受限·从未使用禁止调度)。请求更短不报错但会被压制,形同虚设。
|
||||
*/
|
||||
static scheduleDeferredSync(context: common.UIAbilityContext): void {
|
||||
const build = (cycleMs: number): workScheduler.WorkInfo => {
|
||||
const info: workScheduler.WorkInfo = {
|
||||
workId: WORK_ID,
|
||||
bundleName: context.abilityInfo.bundleName,
|
||||
@@ -60,56 +48,105 @@ export class BackgroundSyncService {
|
||||
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
|
||||
isRepeat: true,
|
||||
isPersisted: true,
|
||||
repeatCycleTime: cycleMs
|
||||
};
|
||||
return info;
|
||||
repeatCycleTime: DEFERRED_CYCLE_MS
|
||||
};
|
||||
try {
|
||||
workScheduler.startWork(build(20 * 60 * 1000));
|
||||
LogUtil.write('延迟任务已注册:20 分钟兜底同步');
|
||||
workScheduler.startWork(info);
|
||||
LogUtil.write('延迟任务已注册:2 小时兜底同步(系统活跃分组下限)');
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
if (e.code === 9700003) {
|
||||
try {
|
||||
workScheduler.startWork(build(2 * 60 * 60 * 1000));
|
||||
LogUtil.write('延迟任务降级注册:2 小时兜底同步');
|
||||
return;
|
||||
} catch (err2) {
|
||||
const e2 = err2 as BusinessError;
|
||||
LogUtil.write(`延迟任务注册失败(2h): ${e2.code} - ${e2.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
LogUtil.write(`延迟任务注册失败: ${e.code} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** App 启动时统一入口:注册延迟任务 + 按设置恢复长时任务(恢复失败静默,不影响启动) */
|
||||
static async initOnLaunch(context: common.UIAbilityContext): Promise<void> {
|
||||
BackgroundSyncService.scheduleDeferredSync(context);
|
||||
const enabled: boolean = await AppSettings.getBackgroundSync(context);
|
||||
if (enabled) {
|
||||
/** 取消延迟任务(用户关闭"后台自动同步"时调用) */
|
||||
static cancelDeferredSync(context: common.UIAbilityContext): void {
|
||||
const info: workScheduler.WorkInfo = {
|
||||
workId: WORK_ID,
|
||||
bundleName: context.abilityInfo.bundleName,
|
||||
abilityName: 'SyncWorkAbility',
|
||||
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
|
||||
isRepeat: true,
|
||||
isPersisted: true,
|
||||
repeatCycleTime: DEFERRED_CYCLE_MS
|
||||
};
|
||||
try {
|
||||
await BackgroundSyncService.startContinuousTask(context);
|
||||
workScheduler.stopWork(info, true);
|
||||
LogUtil.write('延迟任务已取消(后台自动同步关闭)');
|
||||
} catch (err) {
|
||||
// 启动时恢复失败不阻塞 App:下次开关切换或重启再试
|
||||
}
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`延迟任务取消失败: ${e.code} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置页开关切换:失败抛错由页面提示并回滚 UI */
|
||||
static async setBackgroundSync(context: common.UIAbilityContext, enabled: boolean): Promise<void> {
|
||||
if (enabled) {
|
||||
// 先申请任务,成功才落盘设置(失败时设置页回滚开关)
|
||||
await BackgroundSyncService.startContinuousTask(context);
|
||||
await AppSettings.setBackgroundSync(context, true);
|
||||
} else {
|
||||
await AppSettings.setBackgroundSync(context, false);
|
||||
/**
|
||||
* ⭐ 退到后台时申请**短时任务**:让"刚退后台的这一次同步"能跑完。
|
||||
* 必须在**前台或退后台 5 秒内**调用,否则申请失败。
|
||||
* 单次最长 3 分钟(实际申请到的时长以返回值为准),单日总配额 10 分钟。
|
||||
*/
|
||||
static requestTransientSync(reason: string): void {
|
||||
if (BackgroundSyncService.transientId !== 0) {
|
||||
return; // 已有短时任务在跑,不重复申请(重复申请会浪费配额)
|
||||
}
|
||||
try {
|
||||
await BackgroundSyncService.stopContinuousTask(context);
|
||||
const info: backgroundTaskManager.DelaySuspendInfo =
|
||||
backgroundTaskManager.requestSuspendDelay(reason, (): void => {
|
||||
// 超时回调:配额用完,系统即将挂起;清状态,等下次前台/退后台再申请
|
||||
LogUtil.write('短时任务超时(配额用尽),后台同步将被挂起');
|
||||
BackgroundSyncService.transientId = 0;
|
||||
});
|
||||
BackgroundSyncService.transientId = info.requestId;
|
||||
LogUtil.write(`短时任务已申请:requestId=${info.requestId},可用 ${Math.round(info.actualDelayTime / 1000)} 秒`);
|
||||
} catch (err) {
|
||||
// 停止失败(如任务本就不在运行)不影响设置保存
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`短时任务申请失败: ${e.code} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 回到前台时取消短时任务:不占用当日配额(很重要,配额只有 10 分钟) */
|
||||
static cancelTransientSync(): void {
|
||||
if (BackgroundSyncService.transientId === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
backgroundTaskManager.cancelSuspendDelay(BackgroundSyncService.transientId);
|
||||
LogUtil.write('短时任务已取消(回到前台,归还配额)');
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`短时任务取消失败: ${e.code} - ${e.message}`);
|
||||
}
|
||||
BackgroundSyncService.transientId = 0;
|
||||
}
|
||||
|
||||
/** 查询短时任务剩余时间(毫秒);无任务或无配额返回 0 */
|
||||
static async remainingTransientMs(): Promise<number> {
|
||||
if (BackgroundSyncService.transientId === 0) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
return await backgroundTaskManager.getRemainingDelayTime(BackgroundSyncService.transientId);
|
||||
} catch (err) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** App 启动时统一入口:按用户设置注册/不注册延迟任务兜底同步 */
|
||||
static async initOnLaunch(context: common.UIAbilityContext): Promise<void> {
|
||||
const enabled: boolean = await AppSettings.getBackgroundSync(context);
|
||||
if (enabled) {
|
||||
BackgroundSyncService.scheduleDeferredSync(context);
|
||||
} else {
|
||||
LogUtil.write('后台自动同步未开启:不注册延迟任务');
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置页开关切换:控制"后台自动同步"(延迟任务注册与否) */
|
||||
static async setBackgroundSync(context: common.UIAbilityContext, enabled: boolean): Promise<void> {
|
||||
await AppSettings.setBackgroundSync(context, enabled);
|
||||
if (enabled) {
|
||||
BackgroundSyncService.scheduleDeferredSync(context);
|
||||
} else {
|
||||
BackgroundSyncService.cancelDeferredSync(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
// entry/src/main/ets/common/DavDiscovery.ets
|
||||
// ⭐ CalDAV 地址自动发现与智能补全(0.0.7)
|
||||
//
|
||||
// 解决的用户问题(见《CalDAV排查指南-405与读不出日历.md》):
|
||||
// 用户习惯只填域名或 DAV 根,而 Nextcloud 等要求填「日历家目录」
|
||||
// (/remote.php/dav/calendars/<用户名>/)。填错表现为"读不出日历"或 405。
|
||||
// DAVx5 能用是因为它内置 .well-known 自动发现 —— 本模块补上这一能力。
|
||||
//
|
||||
// 发现顺序(逐级降级,任一成功即停):
|
||||
// ① 用户填的地址原样探测(保持既有行为,兼容已能用的用户)
|
||||
// ② .well-known/caldav(RFC 6764 标准;Nextcloud/Radicale/Baïkal 均支持)
|
||||
// ③ 常见候选路径拼接(remote.php/dav/calendars/<用户名>/ 等)
|
||||
//
|
||||
// ⚠️ 设计约束:
|
||||
// - 全部探测都是 PROPFIND + Depth:0,只判断「是不是能列出日历的端点」,不做副作用;
|
||||
// - 单次探测失败不抛错(网络异常吞掉继续试下一个),保证不会因某个候选不可达而整体失败;
|
||||
// - 最终由调用方用 listCalendars 校验(能列出 calendar 集合才算真成功)。
|
||||
import { http } from '@kit.NetworkKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
/** 发现结果:拿到一个可用的"日历集合列表"端点 */
|
||||
export class DiscoverResult {
|
||||
ok: boolean = false;
|
||||
url: string = ''; // 最终可用的端点 URL
|
||||
via: string = ''; // 来源:direct / well-known / candidate / public
|
||||
readOnly: boolean = false; // 是否只读(公开日历分享);本版本仅识别,不做写入
|
||||
hint: string = ''; // 失败时给用户的可操作提示
|
||||
}
|
||||
|
||||
/** 415/405 之外:用于判断"不是 DAV 端点"的状态码集合 */
|
||||
const NOT_DAV_CODES: number[] = [404, 405, 501];
|
||||
|
||||
export class DavDiscovery {
|
||||
// ───────────────────────── 公开工具:URL 形态识别 ─────────────────────────
|
||||
|
||||
/** 取 origin(scheme://host[:port]) */
|
||||
static originOf(url: string): string {
|
||||
const m = /^https?:\/\/[^/]+/i.exec(url.trim());
|
||||
return m !== null ? m[0] : '';
|
||||
}
|
||||
|
||||
/** 规范化:去掉末尾多余斜杠、去空白 */
|
||||
static tidy(url: string): string {
|
||||
let s: string = url.trim();
|
||||
// 去掉查询与片段(发现阶段不保留)
|
||||
s = s.replace(/[?#].*$/, '');
|
||||
while (s.endsWith('/') && s.length > DavDiscovery.originOf(s).length + 1) {
|
||||
s = s.substring(0, s.length - 1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 识别「网页分享链接」——这类地址对 PROPFIND 一律 405,必须提前拦下并提示。
|
||||
* 覆盖:
|
||||
* - Nextcloud 日历分享页:/apps/calendar/p/<token>
|
||||
* - Nextcloud 文件分享页:/s/<token>、/index.php/s/<token>
|
||||
* - Nextcloud 公开日历(只读,可用):/remote.php/dav/public-calendars/<token>
|
||||
*/
|
||||
static classify(url: string): string {
|
||||
const u: string = url.trim();
|
||||
const path = u.replace(/^https?:\/\/[^/]+/i, '');
|
||||
if (/^\/apps\/calendar\/p\//i.test(path)) {
|
||||
return 'share_calendar'; // 日历分享页面(网页)
|
||||
}
|
||||
if (/^\/(index\.php\/)?s\//i.test(path)) {
|
||||
return 'share_file'; // 文件分享页面(网页)
|
||||
}
|
||||
if (/^\/remote\.php\/dav\/public-calendars\//i.test(path)) {
|
||||
return 'public_caldav'; // 公开日历(DAV,只读)
|
||||
}
|
||||
if (/^\/remote\.php\/dav\/public-calendars$/i.test(path.replace(/\/$/, ''))) {
|
||||
return 'public_caldav';
|
||||
}
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
/** 分享链接 → 给用户的人话解释 */
|
||||
static shareHint(kind: string): string {
|
||||
if (kind === 'share_calendar') {
|
||||
return '这是 Nextcloud 的「日历分享链接」(网页地址),CalDAV 客户端不能直接使用。'
|
||||
+ '请在 Nextcloud 网页版「日历 → 左下角设置 → 目标日历 → 复制私有链接」,'
|
||||
+ '把得到的私有链接填到「服务器地址」。';
|
||||
}
|
||||
if (kind === 'share_file') {
|
||||
return '这是文件分享链接(网页地址),不是 CalDAV 地址。'
|
||||
+ '请改用日历的「私有链接」:Nextcloud 网页版「日历 → 设置 → 复制私有链接」。';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为"只可能返回 405/404 的网页地址"——用于在探测前直接拦截,省一次请求。
|
||||
*/
|
||||
static isWebPage(url: string): boolean {
|
||||
const k: string = DavDiscovery.classify(url);
|
||||
return k === 'share_calendar' || k === 'share_file';
|
||||
}
|
||||
|
||||
/**
|
||||
* DAV 根 / 站点根 → 需要补全。用于判断是否值得走候选路径。
|
||||
* `/remote.php/dav` 或仅 origin 时,多半缺"日历家目录"这一层。
|
||||
*/
|
||||
static looksIncomplete(url: string): boolean {
|
||||
const path: string = DavDiscovery.tidy(url).replace(/^https?:\/\/[^/]+/i, '').replace(/\/$/, '');
|
||||
if (path === '' || path === '/') {
|
||||
return true; // 只填了域名
|
||||
}
|
||||
if (/\/remote\.php\/dav$/i.test(path)) {
|
||||
return true; // DAV 根
|
||||
}
|
||||
if (/\/remote\.php\/dav\/(calendars|addressbooks)$/i.test(path)) {
|
||||
return true; // 少了用户名一层
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ───────────────────────── 探测原语 ─────────────────────────
|
||||
|
||||
/**
|
||||
* PROPFIND Depth:0 探测端点:只关心"能不能列、是不是 DAV"。
|
||||
* 不抛错:网络异常/状态码异常都返回可判定的结果。
|
||||
*/
|
||||
static async probe(url: string, auth: string): Promise<number> {
|
||||
const body: string =
|
||||
'<?xml version="1.0" encoding="utf-8"?>' +
|
||||
'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>';
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||||
method: 'PROPFIND' as http.RequestMethod,
|
||||
header: {
|
||||
'Authorization': auth,
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Depth': '0',
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
extraData: body,
|
||||
connectTimeout: 8000,
|
||||
readTimeout: 12000
|
||||
});
|
||||
LogUtil.write(`HTTP PROPFIND(发现) ${url} → ${resp.responseCode}`);
|
||||
return resp.responseCode;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`PROPFIND(发现) ${url} 异常: ${e.code} - ${e.message}`);
|
||||
return 0; // 0 = 网络层失败
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ OPTIONS 探测服务器的 DAV 能力:
|
||||
* - 返回 `DAV:` 响应头 → 标准 WebDAV
|
||||
* - 返回 `Allow` 里含 PROPFIND/REPORT → 支持 CalDAV 所需方法
|
||||
* 用于区分"这不是 DAV 端点"(如网页路由)与"服务端把方法挡了"。
|
||||
*/
|
||||
static async optionsCapability(url: string, auth: string): Promise<DavCapability> {
|
||||
const cap = new DavCapability();
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||||
method: http.RequestMethod.OPTIONS,
|
||||
header: {
|
||||
'Authorization': auth,
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
connectTimeout: 8000,
|
||||
readTimeout: 12000
|
||||
});
|
||||
cap.status = resp.responseCode;
|
||||
const headers = resp.header as Record<string, string>;
|
||||
if (headers !== undefined && headers !== null) {
|
||||
// 头名大小写不统一,逐个大小写变体试
|
||||
const dav = headers['DAV'] ?? headers['Dav'] ?? headers['dav'] ?? '';
|
||||
const allow = headers['Allow'] ?? headers['allow'] ?? '';
|
||||
cap.davHeader = typeof dav === 'string' ? dav : '';
|
||||
cap.allow = typeof allow === 'string' ? allow : '';
|
||||
cap.isDav = cap.davHeader !== '' || /PROPFIND/i.test(cap.allow);
|
||||
cap.supportsPropfind = /PROPFIND/i.test(cap.allow) || cap.isDav;
|
||||
cap.supportsReport = /REPORT/i.test(cap.allow);
|
||||
// Allow 明确列出但两者都没有 → 大概率不是 DAV
|
||||
if (cap.allow !== '' && !cap.supportsPropfind) {
|
||||
cap.isDav = false;
|
||||
}
|
||||
}
|
||||
LogUtil.write(`OPTIONS ${url} → ${resp.responseCode},DAV=${cap.davHeader || '(无)'},Allow=${cap.allow || '(无)'}`);
|
||||
return cap;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`OPTIONS ${url} 异常: ${e.code} - ${e.message}`);
|
||||
return cap;
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── 候选路径生成 ─────────────────────────
|
||||
|
||||
/**
|
||||
* 生成候选「日历家目录 / 集合列表」端点。
|
||||
* @param base 用户填入并规范化后的地址
|
||||
* @param username 用户名(用于 Nextcloud 的 calendars/<用户名>/ 等模式)
|
||||
*/
|
||||
static candidates(base: string, username: string): string[] {
|
||||
const origin: string = DavDiscovery.originOf(base);
|
||||
const out: string[] = [];
|
||||
if (origin === '') {
|
||||
return out;
|
||||
}
|
||||
const path: string = DavDiscovery.tidy(base).substring(origin.length);
|
||||
// 站点可能部署在子目录(如 /nextcloud)——保留用户填的前缀,只补 DAV 相关部分
|
||||
// 例:填 https://h/nextcloud → 前缀 /nextcloud
|
||||
let prefix: string = '';
|
||||
const idx: number = path.toLowerCase().indexOf('/remote.php');
|
||||
if (idx >= 0) {
|
||||
prefix = path.substring(0, idx);
|
||||
} else if (path !== '' && path !== '/') {
|
||||
prefix = path.replace(/\/$/, '');
|
||||
}
|
||||
const u: string = encodeURIComponent(username);
|
||||
const push = (p: string): void => {
|
||||
const full: string = origin + p;
|
||||
if (out.indexOf(full) < 0 && DavDiscovery.tidy(full) !== DavDiscovery.tidy(base)) {
|
||||
out.push(full);
|
||||
}
|
||||
};
|
||||
// Nextcloud / ownCloud
|
||||
push(`${prefix}/remote.php/dav/calendars/${u}/`);
|
||||
push(`${prefix}/remote.php/dav`);
|
||||
// Radicale(通常直接就是 /<用户名>/)
|
||||
push(`${prefix}/${u}/`);
|
||||
// Baïkal
|
||||
push(`${prefix}/dav.php/calendars/${u}/`);
|
||||
// 通用:直接在根下找
|
||||
push(`${prefix}/`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 主入口:在给定端点集合上依次探测,返回第一个"能列出日历"的端点。
|
||||
* 判据=PROPFIND 得 207/200。真正的"日历集合筛选"仍由 DavClient.listCalendars 完成
|
||||
* (本方法只负责找到"哪个地址对",避免与既有解析逻辑重复)。
|
||||
*
|
||||
* @param rawUrl 用户填入的原始地址(未规范化也可)
|
||||
* @param username 用户名
|
||||
* @param auth Authorization 头
|
||||
* @param verify 可选的校验函数:传入端点,返回是否真的能列出日历(由调用方注入 listCalendars)
|
||||
*/
|
||||
static async resolve(rawUrl: string, username: string, auth: string,
|
||||
verify?: (url: string) => Promise<boolean>): Promise<DiscoverResult> {
|
||||
const res = new DiscoverResult();
|
||||
const base: string = DavDiscovery.tidy(rawUrl);
|
||||
|
||||
// ① 网页分享链接:直接拦下,不浪费请求
|
||||
const kind: string = DavDiscovery.classify(base);
|
||||
if (kind === 'share_calendar' || kind === 'share_file') {
|
||||
res.ok = false;
|
||||
res.hint = DavDiscovery.shareHint(kind);
|
||||
LogUtil.write(`发现:识别为网页分享链接,已拦截(${kind})`);
|
||||
return res;
|
||||
}
|
||||
|
||||
const check = async (url: string, via: string): Promise<boolean> => {
|
||||
const code: number = await DavDiscovery.probe(url, auth);
|
||||
if (code !== 207 && code !== 200) {
|
||||
return false;
|
||||
}
|
||||
if (verify !== undefined) {
|
||||
const good: boolean = await verify(url);
|
||||
if (!good) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
res.ok = true;
|
||||
res.url = url;
|
||||
res.via = via;
|
||||
res.readOnly = (kind === 'public_caldav');
|
||||
LogUtil.write(`发现成功(${via}):${url}`);
|
||||
return true;
|
||||
};
|
||||
|
||||
// ① 用户填的地址优先(保持既有行为)
|
||||
if (await check(base, 'direct')) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// ①b ⭐ 公开日历(只读订阅):Nextcloud 的 public-calendars 链接本身即端点。
|
||||
// 此类端点通常无需认证,且只能读——后续由 SyncEngine 的写权限探测自动标记为只读。
|
||||
if (kind === 'public_caldav') {
|
||||
// 若用户填的是 public-calendars 根(少了 token),无法继续推断,直接给提示
|
||||
res.ok = false;
|
||||
res.hint = '这是「公开日历」链接但似乎不完整。请使用完整的公开日历地址'
|
||||
+ '(形如 https://你的域名/remote.php/dav/public-calendars/<令牌>/),'
|
||||
+ '或改用日历的「私有链接」以获得可读可写体验。';
|
||||
return res;
|
||||
}
|
||||
|
||||
// ② .well-known/caldav(RFC 6764)——标准自动发现
|
||||
const origin: string = DavDiscovery.originOf(base);
|
||||
if (origin !== '') {
|
||||
const wk: string = await DavDiscovery.resolveWellKnown(origin, auth);
|
||||
if (wk !== '') {
|
||||
if (await check(wk, 'well-known')) {
|
||||
return res;
|
||||
}
|
||||
// .well-known 给了地址但直接探测不通过 → 也纳入候选由 ③ 继续尝试其下级
|
||||
LogUtil.write(`.well-known 指向 ${wk},但该地址未通过日历校验,继续尝试常见路径`);
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 常见候选路径
|
||||
const cands: string[] = DavDiscovery.candidates(base, username);
|
||||
for (const c of cands) {
|
||||
if (await check(c, 'candidate')) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
// 全部失败 → 给可操作提示(区分"不是 DAV 端点"与"能连但列不出日历")
|
||||
res.ok = false;
|
||||
const cap: DavCapability = await DavDiscovery.optionsCapability(base, auth);
|
||||
if (cap.status === 405 || (!cap.isDav && cap.allow !== '' && !cap.supportsPropfind)) {
|
||||
res.hint = '该地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。'
|
||||
+ '常见原因是填了网页分享链接、或只填了站点的 DAV 根。'
|
||||
+ 'Nextcloud 请填「日历 → 设置 → 复制私有链接」得到的地址,例如:'
|
||||
+ '`https://你的域名/remote.php/dav/calendars/你的用户名/`';
|
||||
} else if (cap.status === 401) {
|
||||
res.hint = '服务器拒绝凭据(401),请检查用户名与密码。'
|
||||
+ 'Nextcloud 若开启了二次验证,需要使用「应用密码」。';
|
||||
} else if (cap.status === 301 || cap.status === 302) {
|
||||
res.hint = '服务器发生了跳转,通常说明 `.well-known/caldav` 未正确配置'
|
||||
+ '(常见于反向代理或子目录部署)。可请 NAS 管理员把 `.well-known/caldav` '
|
||||
+ '指向 `/remote.php/dav`,或直接填写完整的「日历家目录」地址。';
|
||||
} else {
|
||||
res.hint = '已连接上服务器,但在该地址下未发现任何日历本。'
|
||||
+ '请确认填的是「日历家目录」而不是 DAV 根,例如:'
|
||||
+ '`https://你的域名/remote.php/dav/calendars/你的用户名/`';
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 .well-known/caldav:
|
||||
* - 直接返回 207/200 → 该 URL 本身即端点
|
||||
* - 返回 301/302/307/308 → 读 Location 头作为端点
|
||||
* - 返回 401 时也读 Location(Nextcloud 常在认证后跳转)
|
||||
*/
|
||||
static async resolveWellKnown(origin: string, auth: string): Promise<string> {
|
||||
const url: string = `${origin}/.well-known/caldav`;
|
||||
const httpRequest = http.createHttp();
|
||||
try {
|
||||
const resp: http.HttpResponse = await httpRequest.request(url, {
|
||||
method: 'PROPFIND' as http.RequestMethod,
|
||||
header: {
|
||||
'Authorization': auth,
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Depth': '0',
|
||||
'User-Agent': 'SyncCalendar/1.0'
|
||||
},
|
||||
extraData: '<?xml version="1.0" encoding="utf-8"?>' +
|
||||
'<d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>',
|
||||
connectTimeout: 8000,
|
||||
readTimeout: 12000
|
||||
});
|
||||
LogUtil.write(`HTTP PROPFIND(.well-known) ${url} → ${resp.responseCode}`);
|
||||
if (resp.responseCode === 207 || resp.responseCode === 200) {
|
||||
return url;
|
||||
}
|
||||
if (resp.responseCode === 301 || resp.responseCode === 302
|
||||
|| resp.responseCode === 307 || resp.responseCode === 308 || resp.responseCode === 401) {
|
||||
const headers = resp.header as Record<string, string>;
|
||||
if (headers !== undefined && headers !== null) {
|
||||
const loc = headers['Location'] ?? headers['location'] ?? '';
|
||||
if (typeof loc === 'string' && loc !== '') {
|
||||
const full: string = loc.startsWith('http') ? loc : origin + (loc.startsWith('/') ? '' : '/') + loc;
|
||||
LogUtil.write(`.well-known 跳转到:${full}`);
|
||||
return DavDiscovery.tidy(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`.well-known 探测异常: ${e.code} - ${e.message}`);
|
||||
return '';
|
||||
} finally {
|
||||
httpRequest.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** 状态码是否表示"该地址不是 DAV 端点" */
|
||||
static isNotDav(code: number): boolean {
|
||||
return NOT_DAV_CODES.indexOf(code) >= 0;
|
||||
}
|
||||
|
||||
// ───────────────────────── 给用户的"人话提示"(两个页面共用) ─────────────────────────
|
||||
|
||||
/**
|
||||
* ⭐ 把 HTTP 状态码翻译成"人话 + 可操作指引"。
|
||||
* 用户看到"服务器返回 405"无从下手,这里直接告诉他下一步做什么。
|
||||
*/
|
||||
static statusHint(code: number): string {
|
||||
if (code === 405) {
|
||||
return '该地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。\n\n'
|
||||
+ '常见原因:填了「分享链接」(网页地址),或只填到 DAV 根。\n'
|
||||
+ '请填「日历家目录」,例如:\n'
|
||||
+ 'https://你的域名/remote.php/dav/calendars/你的用户名/\n\n'
|
||||
+ 'Nextcloud 可在网页版「日历 → 左下角设置 → 目标日历 → 复制私有链接」拿到这个地址。';
|
||||
}
|
||||
if (code === 401) {
|
||||
return '服务器拒绝凭据(401):请检查用户名与密码。\n'
|
||||
+ '若服务器开启了二次验证(2FA),需要使用「应用密码」而不是登录密码。';
|
||||
}
|
||||
if (code === 403) {
|
||||
return '服务器拒绝访问(403):账号可能无权限,或 IP 被限制。请检查账号权限与服务器访问限制。';
|
||||
}
|
||||
if (code === 404) {
|
||||
return '地址不存在(404):请检查路径是否写错。\n'
|
||||
+ '正确形式通常为 https://你的域名/remote.php/dav/calendars/你的用户名/';
|
||||
}
|
||||
if (code === 301 || code === 302 || code === 307 || code === 308) {
|
||||
return '服务器发生跳转:通常说明 `.well-known/caldav` 未正确配置(常见于反向代理或子目录部署)。\n'
|
||||
+ '请直接填写完整的「日历家目录」地址,或请 NAS 管理员配置 `.well-known/caldav`。';
|
||||
}
|
||||
if (code === 0) {
|
||||
return '无法连接服务器:请确认地址、端口与网络(内网地址需在同一局域网)。';
|
||||
}
|
||||
return `获取日历列表失败,服务器返回:${code}`;
|
||||
}
|
||||
|
||||
/** ⭐ 能连上但"未发现日历本"时的提示:区分"地址差一层"与"确实是空的" */
|
||||
static emptyHint(url: string, username: string): string {
|
||||
if (DavDiscovery.looksIncomplete(url)) {
|
||||
const cands: string[] = DavDiscovery.candidates(url, username);
|
||||
const example: string = cands.length > 0 ? cands[0] : '';
|
||||
return '该地址下没有日历本 —— 多半是地址「少了一层」。\n\n'
|
||||
+ '你填的是服务器根路径,但 CalDAV 需要填到「日历家目录」。请试试:\n'
|
||||
+ (example !== '' ? example + '\n\n' : '\n')
|
||||
+ '(Nextcloud 可在网页版「日历 → 设置 → 复制私有链接」直接拿到)';
|
||||
}
|
||||
return '该地址下未发现日历本。请确认:\n'
|
||||
+ '1) 服务器上确实已创建日历;\n'
|
||||
+ '2) 填的是「日历家目录」(形如 .../remote.php/dav/calendars/用户名/)而不是 DAV 根。';
|
||||
}
|
||||
}
|
||||
|
||||
/** OPTIONS 探测结果 */
|
||||
export class DavCapability {
|
||||
status: number = 0;
|
||||
davHeader: string = ''; // `DAV:` 响应头(含 "1", "2", "calendar-access" 等)
|
||||
allow: string = ''; // `Allow` 头
|
||||
isDav: boolean = false;
|
||||
supportsPropfind: boolean = false;
|
||||
supportsReport: boolean = false;
|
||||
}
|
||||
@@ -425,7 +425,22 @@ export class EventDb {
|
||||
rs.close();
|
||||
}
|
||||
|
||||
const remoteKeys: string[] = [];
|
||||
// ⭐ 2226(2026-10-15 提速):下方逐条要判"这条远端事件本地有没有对应行"。
|
||||
// 原先对每条都 `existing.find(...)` ⇒ O(n·m)(工作本 735 行 → 54 万次比较,
|
||||
// 每次还要现拼 `uid_startTime_recId` 字符串),2184 行全库累计近 100 万次。
|
||||
// 卡片后台同步的时间预算只有 9 秒,这笔开销吃不起 → 预先建 Map,改成 O(1) 查。
|
||||
// 语义与 `existing.find(x => !x.dirty && x.uid===r.uid && x.startTime===r.startTime
|
||||
// && x.recurrenceId===r.recurrenceId)` 完全一致:dirty 行不入索引、同键取**最先**出现的那行。
|
||||
const keyed: Map<string, LocalEvent> = new Map<string, LocalEvent>();
|
||||
for (const e of existing) {
|
||||
if (e.dirty) {
|
||||
continue;
|
||||
}
|
||||
const k: string = `${e.uid}_${e.startTime}_${e.recurrenceId}`;
|
||||
if (!keyed.has(k)) {
|
||||
keyed.set(k, e);
|
||||
}
|
||||
}
|
||||
// ⭐ 同一次解析里可能出现同一资源的多条相同 VEVENT(同 uid + 同 DTSTART)。
|
||||
// 旧版用循环前的 existing 快照判重,插入后不回写 → 第二条仍判为"新增"又插一行,
|
||||
// 在本地库留下 href/etag/remote_path 完全相同的冗余行(2026-09-19 实测 10 组)。
|
||||
@@ -438,10 +453,7 @@ export class EventDb {
|
||||
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.recurrenceId === r.recurrenceId);
|
||||
const found: LocalEvent | undefined = keyed.get(key);
|
||||
if (found === undefined) {
|
||||
// 新增
|
||||
const e = new LocalEvent();
|
||||
@@ -517,7 +529,8 @@ export class EventDb {
|
||||
continue;
|
||||
}
|
||||
const localKey: string = `${local.uid}_${local.startTime}_${local.recurrenceId}`;
|
||||
if (!remoteKeys.includes(localKey)) {
|
||||
// ⭐ 同样从 O(m) 的数组 includes 换成 Set 查询(见上方 keyed 的说明)
|
||||
if (!seenKeys.has(localKey)) {
|
||||
const del = new relationalStore.RdbPredicates('events');
|
||||
del.equalTo('id', local.id);
|
||||
await store.delete(del);
|
||||
|
||||
@@ -229,7 +229,10 @@ export class IcsUtil {
|
||||
}
|
||||
|
||||
/**
|
||||
* 由本地事件构建 VCALENDAR 文本(时间统一转 UTC,保证服务器端时区正确)。
|
||||
* 由本地事件构建 VCALENDAR 文本。
|
||||
*
|
||||
* ⚠️ 事件时间写的是**浮动本地时间**(不是 UTC),原因见 `vevent()` 里的详细说明
|
||||
* (服务器会删掉 `Z`/`TZID` 且不做时移)。`DTSTAMP` 仍按 RFC 规定写 UTC+`Z`。
|
||||
*
|
||||
* 已知取舍:摘要 / 地点 / 备注等文本字段**未做 ICS 转义**(`,` `;` `\` 与换行)。
|
||||
* 含这些字符的标题在服务器端可能被截断或串行;
|
||||
@@ -273,10 +276,25 @@ export class IcsUtil {
|
||||
/** 单个 VEVENT 块(主事件带 RRULE/EXDATE,覆盖行带 RECURRENCE-ID) */
|
||||
private static vevent(e: LocalEvent, now: string): string {
|
||||
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
|
||||
const fmtUtc = (ms: number): string => {
|
||||
/**
|
||||
* ⭐⭐ 事件时间一律写**浮动本地时间**(`YYYYMMDDTHHMMSS`,**不带 `Z`、不带 `TZID`**)。
|
||||
*
|
||||
* 为什么不写 RFC 最正统的 UTC+`Z` —— 因为服务器会把它弄坏:
|
||||
* 本项目的服务器(家书日历 / family.yangyq.net)会**直接删掉** `DTSTART`/`DTEND`
|
||||
* 上的 `Z` 与 `TZID` 参数,**且不对数值做任何时移**
|
||||
* (2026-10-15 实测:PUT `DTSTART:20261020T060000Z` → 存回 `DTSTART:20261020T060000`;
|
||||
* PUT `DTSTART;TZID=Asia/Shanghai:20261020T140000` → 存回 `20261020T140000`)。
|
||||
* 于是"UTC 00:00"被当成"**本地** 00:00"。而本地 08:00 的正确 UTC 恰好就是 00:00,
|
||||
* 所以症状正好是**阅读端少了 8 小时**(用户实测:"8:00 的日程同步后变成 0:00")。
|
||||
*
|
||||
* 该服务器上全部计时事件(1163 条)的 `DTSTART` **无一例外都是浮动本地时间**
|
||||
* (带 `Z` 的数量为 **0**、带 `TZID` 为 **0**)→ 这就是它的事实约定,我们随它。
|
||||
* 浮动时间在任何阅读端都按"当地墙钟"解析,本机 CST 往返完全一致。
|
||||
*/
|
||||
const fmtLocal = (ms: number): string => {
|
||||
const d = new Date(ms);
|
||||
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
||||
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
||||
`T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
};
|
||||
const fmtDate = (ms: number): string => {
|
||||
const d = new Date(ms);
|
||||
@@ -289,8 +307,8 @@ export class IcsUtil {
|
||||
// 存储的 endTime 为排他日前一毫秒,+1s 得到排他日期
|
||||
dtend = 'DTEND;VALUE=DATE:' + fmtDate(e.endTime + 1000);
|
||||
} else {
|
||||
dtstart = 'DTSTART:' + fmtUtc(e.startTime);
|
||||
dtend = 'DTEND:' + fmtUtc(e.endTime);
|
||||
dtstart = 'DTSTART:' + fmtLocal(e.startTime);
|
||||
dtend = 'DTEND:' + fmtLocal(e.endTime);
|
||||
}
|
||||
// ⭐ 覆盖实例(RECURRENCE-ID 行):必须写出 RECURRENCE-ID,否则服务器无法把它
|
||||
// 关联到主事件的哪一次发生 → 要么整系列被破坏,要么原时间残留成"幽灵日程"。
|
||||
@@ -298,7 +316,7 @@ export class IcsUtil {
|
||||
if (e.rrule === '' && e.recurrenceId > 0) {
|
||||
recId = e.isAllDay
|
||||
? `RECURRENCE-ID;VALUE=DATE:${fmtDate(e.recurrenceId)}\r\n`
|
||||
: `RECURRENCE-ID:${fmtUtc(e.recurrenceId)}\r\n`;
|
||||
: `RECURRENCE-ID:${fmtLocal(e.recurrenceId)}\r\n`;
|
||||
}
|
||||
// ⭐ EXDATE:主事件排除掉的若干次发生("仅删除这一次"就是往这里加)
|
||||
let exdate: string = '';
|
||||
@@ -307,7 +325,7 @@ export class IcsUtil {
|
||||
for (const raw of e.exdate.split(';')) {
|
||||
const t = IcsUtil.parseTime(raw, !raw.includes('T'));
|
||||
if (t !== null) {
|
||||
vals.push(t.allDay ? fmtDate(t.time) : fmtUtc(t.time));
|
||||
vals.push(t.allDay ? fmtDate(t.time) : fmtLocal(t.time));
|
||||
}
|
||||
}
|
||||
if (vals.length > 0) {
|
||||
@@ -341,8 +359,9 @@ export class IcsUtil {
|
||||
}
|
||||
|
||||
/**
|
||||
* 毫秒时间戳 → 用于 EXDATE / RECURRENCE-ID 的 ICS 时间串。
|
||||
* @param dateOnly 全天日程用 `YYYYMMDD`,否则用 UTC 的 `YYYYMMDDTHHMMSSZ`
|
||||
* 毫秒时间戳 → 用于 EXDATE / RECURRENCE-ID / UNTIL 的 ICS 时间串。
|
||||
* @param dateOnly 全天日程用 `YYYYMMDD`,否则用**浮动本地时间** `YYYYMMDDTHHMMSS`
|
||||
* (不带 `Z`:必须与 `DTSTART` 的形态一致,见 `vevent()` 里的说明)
|
||||
*/
|
||||
static fmtIcsTime(ms: number, dateOnly: boolean): string {
|
||||
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
|
||||
@@ -350,20 +369,19 @@ export class IcsUtil {
|
||||
if (dateOnly) {
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
|
||||
}
|
||||
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
||||
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
||||
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}` +
|
||||
`T${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 给 RRULE 加上/替换 UNTIL("此次及以后"改期/删除时,把原系列截断到指定时刻之前)。
|
||||
* - 会**移除 COUNT**(RFC 5545:UNTIL 与 COUNT 不可同时出现)
|
||||
* - UNTIL 用 UTC 形式;`untilMs` 一般是"该次发生的开始时刻 − 1 秒"
|
||||
* - UNTIL 用**浮动本地时间**(RFC 5545:`DTSTART` 是本地时间时,`UNTIL` **必须**也是本地时间;
|
||||
* 而我们把所有事件时间都写成浮动本地 —— 二者必须一致,否则不同阅读端的展开会错位)
|
||||
* - `untilMs` 一般是"该次发生的开始时刻 − 1 秒"
|
||||
*/
|
||||
static rruleWithUntil(rrule: string, untilMs: number): string {
|
||||
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
|
||||
const d = new Date(untilMs);
|
||||
const until: string = `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
|
||||
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
|
||||
const until: string = IcsUtil.fmtIcsTime(untilMs, false);
|
||||
const kept: string[] = [];
|
||||
for (const part of rrule.split(';')) {
|
||||
const k: string = part.split('=')[0].trim().toUpperCase();
|
||||
|
||||
@@ -1,30 +1,57 @@
|
||||
// entry/src/main/ets/common/LogUtil.ets
|
||||
// 同步日志:写入应用 filesDir/sync.log(含时间戳),超 512KB 自动截断保留后半。
|
||||
// 设置页「同步日志」卡片可查看/复制/清空,方便真机测试时诊断同步与提醒问题。
|
||||
// 日志工具:默认**只输出到 console(hilog)**,不落盘。
|
||||
//
|
||||
// ⭐ 0.0.8 起:发行版**不再写 sync.log**(原「同步记录」/诊断落盘功能已移除)。
|
||||
// 需要真机排查同步/提醒问题时,把下面的 ENABLE_FILE_LOG 改成 true 重新编译即可恢复
|
||||
// 落盘(含 512KB 自动截断保留后半),其余调用点一行都不用动。
|
||||
// 关闭状态下会在 init 时顺手删掉历史版本遗留的 sync.log 与 mirror_diag.txt,
|
||||
// 保证升级上来的设备沙箱也是干净的。
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { fileIo as fs } from '@kit.CoreFileKit';
|
||||
import { util } from '@kit.ArkTS';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
|
||||
export class LogUtil {
|
||||
/** ⭐ 落盘开关:发行版置 false(仅 console);真机排查时改 true 恢复文件日志 */
|
||||
private static readonly ENABLE_FILE_LOG: boolean = false;
|
||||
private static dir: string = '';
|
||||
private static path: string = '';
|
||||
private static readonly MAX_BYTES: number = 512 * 1024;
|
||||
|
||||
static init(context: common.Context): void {
|
||||
try {
|
||||
LogUtil.path = `${context.filesDir}/sync.log`;
|
||||
LogUtil.dir = context.filesDir;
|
||||
LogUtil.path = `${LogUtil.dir}/sync.log`;
|
||||
} catch (err) {
|
||||
LogUtil.dir = '';
|
||||
LogUtil.path = '';
|
||||
}
|
||||
if (!LogUtil.ENABLE_FILE_LOG && LogUtil.dir !== '') {
|
||||
LogUtil.removeLegacyDebugFiles();
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除历史版本遗留的调试落盘文件(sync.log / mirror_diag.txt) */
|
||||
private static removeLegacyDebugFiles(): void {
|
||||
for (const name of ['sync.log', 'mirror_diag.txt']) {
|
||||
try {
|
||||
fs.unlinkSync(`${LogUtil.dir}/${name}`);
|
||||
} catch (err) {
|
||||
// 文件不存在属正常,忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static logFilePath(): string {
|
||||
return LogUtil.path;
|
||||
}
|
||||
|
||||
/** 写一行日志:console + 文件(文件失败静默,不影响业务) */
|
||||
/** 写一行日志:console + 文件(发行版不落盘;文件失败静默,不影响业务) */
|
||||
static write(msg: string): void {
|
||||
console.info(`SyncLog ${msg}`);
|
||||
if (!LogUtil.ENABLE_FILE_LOG) {
|
||||
return;
|
||||
}
|
||||
if (LogUtil.path === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,25 @@ export class MirrorInbound {
|
||||
|
||||
/* ==================== 单个日历本 ==================== */
|
||||
|
||||
/**
|
||||
* 取回该账户下已有日程。
|
||||
*
|
||||
* 🔴 2026-10-15 实测(DEL-AL20 / 7.0.0.109):`getEvents(undefined, 字段数组)` 在这个
|
||||
* 系统版本上**必抛** `401 type error!`。而本文件原先正是这么调的 ⇒ "入站对账"对
|
||||
* **每一个**日历本都失败(真机日志 12/12 全是 `401 type error!`),
|
||||
* 整个"把系统日历里的改动回写 CalDAV"等于死的(还白费 12 次调用)。
|
||||
* 出站镜像(SystemCalendarMirror.queryEvents)早就踩过同一个坑,改成"先无参、失败再带字段"。
|
||||
* 这里沿用同一策略:无参调用返回的 Event 里 identifier 同样有值。
|
||||
*/
|
||||
private static async queryEvents(cal: calendarManager.Calendar): Promise<calendarManager.Event[]> {
|
||||
try {
|
||||
return await cal.getEvents();
|
||||
} catch (err) {
|
||||
LogUtil.write(`入站对账 getEvents() 失败,退回带字段:${(err as BusinessError).message}`);
|
||||
return await cal.getEvents(undefined, MirrorInbound.QUERY_FIELDS);
|
||||
}
|
||||
}
|
||||
|
||||
private static async reconcileBook(
|
||||
context: common.Context,
|
||||
cm: calendarManager.CalendarManager,
|
||||
@@ -109,8 +128,7 @@ export class MirrorInbound {
|
||||
}
|
||||
|
||||
const snaps: Map<string, MirrorSnapshotRow> = await MirrorSnapshot.loadMap(context, bucket);
|
||||
const events: calendarManager.Event[] = await cal.getEvents(
|
||||
undefined, MirrorInbound.QUERY_FIELDS);
|
||||
const events: calendarManager.Event[] = await MirrorInbound.queryEvents(cal);
|
||||
|
||||
// 分成两类:带我们 identifier 的(可判定改/删) vs 不是我们的(用户新建)
|
||||
const mine: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// entry/src/main/ets/common/MirrorRenamer.ets
|
||||
// ⭐ 一键重命名镜像账户的显示名(0.0.8)
|
||||
//
|
||||
// 为什么需要这个模块:HarmonyOS 的 `@ohos.calendarManager` **没有"修改账户"的 API**
|
||||
// (只有 create/delete/get/getAll;CalendarConfig 仅 enableReminder+color;
|
||||
// CalendarAccount.name 是 readonly)→ `displayName` 只能在创建时定,之后改不了。
|
||||
// ⇒ 想换显示名,只能"**删掉旧账户 → 按新名字重建**"。
|
||||
//
|
||||
// ⭐⭐ 安全顺序(不可调换):先回读 → 再删 → 重建 → 全量重写
|
||||
// ① `MirrorInbound.reconcile()`:把用户在系统日历里**手动新建/修改**的日程先收进本地库(置 dirty)
|
||||
// —— 因为 `deleteCalendar` 会删掉该账户下**所有**日程(含用户手动加的),不回读就会丢数据。
|
||||
// ② 删除旧镜像账户(按 bookId 精确定位)
|
||||
// ③ 立即 `syncNow()`:按用户所选的新格式重建账户并全量写回(含刚回读的 dirty 项)
|
||||
//
|
||||
// 为什么放在独立模块:`MirrorInbound` 已经 import 了 `SystemCalendarMirror`,
|
||||
// 若再把 `MirrorInbound` 反向 import 进 `SystemCalendarMirror` 会形成循环依赖;
|
||||
// 本模块作为"编排层"同时依赖二者,方向单一、无环。
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { SystemCalendarMirror, BookRef, MirrorResult } from './SystemCalendarMirror';
|
||||
import { MirrorInbound, InboundResult } from './MirrorInbound';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
/** 重命名结果(供 UI 提示) */
|
||||
export class RenameResult {
|
||||
ok: boolean = false;
|
||||
readBack: number = 0; // 回读到的用户改动条数(created+updated+deleted)
|
||||
removed: number = 0; // 删除的旧账户数
|
||||
mirrored: number = 0; // 重建后镜像的日历本数
|
||||
message: string = '';
|
||||
}
|
||||
|
||||
export class MirrorRenamer {
|
||||
/**
|
||||
* 按当前(已落盘的)显示名格式,重建全部镜像账户的显示名。
|
||||
*
|
||||
* ⚠️ 前提:调用方已把用户所选格式写入 `AppSettings`(`setMirrorDisplayFormat`);
|
||||
* 本方法只负责"按现有配置重新应用"。
|
||||
* ⚠️ 需要系统日历读写权限(调用方先校验 / 申请)。
|
||||
*/
|
||||
static async reapplyDisplayNames(context: common.Context): Promise<RenameResult> {
|
||||
const res = new RenameResult();
|
||||
if (!await SystemCalendarMirror.hasPermission()) {
|
||||
res.message = '未获得系统日历读写权限';
|
||||
return res;
|
||||
}
|
||||
|
||||
// ① 先回读:把用户在系统日历里的手动新建/修改收进本地库(置 dirty),避免删除时丢数据
|
||||
try {
|
||||
const inbound: InboundResult = await MirrorInbound.reconcile(context);
|
||||
res.readBack = inbound.created + inbound.updated + inbound.deleted;
|
||||
LogUtil.write(`重命名-回读完成:新增=${inbound.created} 修改=${inbound.updated} 删除=${inbound.deleted}`);
|
||||
} catch (err) {
|
||||
// 回读失败 → **中止重命名**(宁可不改,也不能冒着丢数据的风险删账户)
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`重命名中止:回读失败 ${e.message}`);
|
||||
res.message = '未能先同步系统日历中的改动,已取消操作(避免数据丢失)。请稍后重试。';
|
||||
return res;
|
||||
}
|
||||
|
||||
// ② 取出当前选中的镜像本(当前配置)
|
||||
let refs: BookRef[] = [];
|
||||
try {
|
||||
refs = await SystemCalendarMirror.selectedBookRefs(context);
|
||||
} catch (err) {
|
||||
res.message = '读取镜像日历本失败';
|
||||
return res;
|
||||
}
|
||||
if (refs.length === 0) {
|
||||
res.message = '当前没有选中的镜像日历本';
|
||||
return res;
|
||||
}
|
||||
|
||||
// ③ 删除这些本的旧镜像账户(按 bookId,与名字无关)
|
||||
const bookIds: string[] = refs.map((r: BookRef): string => r.bookId);
|
||||
res.removed = await SystemCalendarMirror.removeAccountsByBookIds(context, bookIds);
|
||||
LogUtil.write(`重命名:已删除 ${res.removed} 个旧账户,准备按新格式重建`);
|
||||
|
||||
// ④ 立即按新格式重建并全量写回(含刚回读的 dirty 项)
|
||||
try {
|
||||
const mr: MirrorResult = await SystemCalendarMirror.syncNow(context);
|
||||
res.mirrored = mr.books;
|
||||
res.ok = true;
|
||||
res.message = '已按新的显示方式重新写入系统日历';
|
||||
LogUtil.write(`重命名完成:重建 ${mr.books} 个本(新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted})`);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
res.ok = false;
|
||||
res.message = `重建失败:${e.message}`;
|
||||
LogUtil.write(`重命名重建失败:${e.message}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否还有"旧格式"(`同步日历 · xxx`)的镜像账户 —— 用于设置页给出"可更换"的弱提示。
|
||||
* 只读,不做任何修改。
|
||||
*/
|
||||
static async hasLegacyNamedAccounts(context: common.Context): Promise<boolean> {
|
||||
return await SystemCalendarMirror.hasLegacyDisplayName(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// entry/src/main/ets/common/NetworkSyncWatcher.ets
|
||||
// ⭐ 网络恢复补同步:从"无网"变为"有网"时立刻同步一次。
|
||||
//
|
||||
// 为什么这条最有价值(《定时同步方案调研.md》第 ④ 层):
|
||||
// 延迟任务最短 2 小时、还会随应用活跃度降级,长时间在外(飞行模式/地铁/弱网)期间
|
||||
// 基本不会跑。用户一旦回到有网环境,**立刻对齐一次**,才能做到
|
||||
// "我要看的时候数据是最新的"。这比追求"进程常驻"实际得多。
|
||||
//
|
||||
// 实现:net.connection 的 netAvailable / netLost 事件 + 去抖,避免网络抖动时连打多次。
|
||||
import { connection } from '@kit.NetworkKit';
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BackgroundSyncRunner } from './BackgroundSyncRunner';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
/** 网络恢复后的去抖间隔:避免弱网下反复"断开→恢复"导致连续同步 */
|
||||
const DEBOUNCE_MS: number = 20 * 1000;
|
||||
|
||||
export class NetworkSyncWatcher {
|
||||
private static netConn: connection.NetConnection | null = null;
|
||||
/** 上一次触发同步的时间,用于去抖 */
|
||||
private static lastTriggerAt: number = 0;
|
||||
/** 当前是否处于"无网"状态(只有从无网恢复才触发,避免频繁切换网络时误触发) */
|
||||
private static offline: boolean = false;
|
||||
|
||||
/** 开始监听网络变化(幂等:重复调用不会重复注册) */
|
||||
static start(context: common.UIAbilityContext): void {
|
||||
if (NetworkSyncWatcher.netConn !== null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const conn: connection.NetConnection = connection.createNetConnection();
|
||||
conn.on('netLost', (): void => {
|
||||
NetworkSyncWatcher.offline = true;
|
||||
LogUtil.write('网络断开:标记为无网状态');
|
||||
});
|
||||
conn.on('netAvailable', (): void => {
|
||||
// ⭐ 只有"从无网恢复"才补同步:单纯切换 Wi-Fi/蜂窝时不重复触发
|
||||
if (!NetworkSyncWatcher.offline) {
|
||||
return;
|
||||
}
|
||||
NetworkSyncWatcher.offline = false;
|
||||
NetworkSyncWatcher.triggerDebounced(context, 'netRecover');
|
||||
});
|
||||
conn.register((): void => {
|
||||
LogUtil.write('网络监听已注册(网络恢复补同步)');
|
||||
});
|
||||
NetworkSyncWatcher.netConn = conn;
|
||||
} catch (err) {
|
||||
LogUtil.write(`网络监听注册失败:${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止监听(一般不需要,App 存活期间持续有效) */
|
||||
static stop(): void {
|
||||
if (NetworkSyncWatcher.netConn === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
NetworkSyncWatcher.netConn.unregister((err: object): void => {
|
||||
// 忽略取消失败
|
||||
});
|
||||
} catch (err) {
|
||||
// 忽略
|
||||
}
|
||||
NetworkSyncWatcher.netConn = null;
|
||||
}
|
||||
|
||||
/** 去抖后触发一次同步 */
|
||||
private static triggerDebounced(context: common.UIAbilityContext, tag: string): void {
|
||||
const now: number = Date.now();
|
||||
if (now - NetworkSyncWatcher.lastTriggerAt < DEBOUNCE_MS) {
|
||||
LogUtil.write(`网络恢复补同步:距上次过快(<20s),跳过`);
|
||||
return;
|
||||
}
|
||||
NetworkSyncWatcher.lastTriggerAt = now;
|
||||
LogUtil.write('网络已恢复 → 触发补同步');
|
||||
// 不 await:事件回调里不能阻塞;BackgroundSyncRunner 内部全 try-catch,不会抛出
|
||||
BackgroundSyncRunner.runOnce(context, tag);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { EventDb, LocalEvent } from './EventDb';
|
||||
import { AppSettings } from './AppSettings';
|
||||
import { AccountStore } from './AccountStore';
|
||||
import { SystemCalendarMirror } from './SystemCalendarMirror';
|
||||
import { LogUtil } from './LogUtil';
|
||||
|
||||
export class ReminderService {
|
||||
@@ -26,6 +27,39 @@ export class ReminderService {
|
||||
private static calNameCache: Map<string, string> = new Map();
|
||||
private static calNameLoadedAt: number = 0;
|
||||
|
||||
/** 已镜像到系统日历的 calKey 集合缓存(避免重复排提醒),30 秒刷新一次 */
|
||||
private static mirroredCalKeys: Set<string> = new Set();
|
||||
private static mirroredLoadedAt: number = 0;
|
||||
|
||||
/**
|
||||
* ⭐⭐ 取"已镜像到系统日历"的日历本 calKey 集合(0.0.8 修复双通道重复提醒)。
|
||||
*
|
||||
* 镜像到系统日历后,**系统日历自己会提醒**(条目的 reminderTime);
|
||||
* 若 App 再为同一个本排代理提醒 → 同一条日程提醒两次。
|
||||
* 因此这里返回"该由系统日历负责提醒"的本,App 侧**完全不再提醒**它们。
|
||||
*
|
||||
* ⚠️ 判据是"**镜像账户真实存在**"(`SystemCalendarMirror.mirroredCalKeys`),
|
||||
* 而不是"用户勾选了要镜像"—— 后者只是意图,本可能还没被真正写进系统日历;
|
||||
* 按意图跳过会导致"两边都不提醒"(漏提醒)。
|
||||
* ⚠️ 返回空集 = 不跳过任何本(保守:宁可多提醒,不可漏提醒)。
|
||||
*/
|
||||
private static async mirroredKeys(context: common.Context): Promise<Set<string>> {
|
||||
if (Date.now() - ReminderService.mirroredLoadedAt < 30000
|
||||
&& ReminderService.mirroredCalKeys.size > 0) {
|
||||
return ReminderService.mirroredCalKeys;
|
||||
}
|
||||
const out: Set<string> = await SystemCalendarMirror.mirroredCalKeys(context);
|
||||
ReminderService.mirroredCalKeys = out;
|
||||
ReminderService.mirroredLoadedAt = Date.now();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 镜像开关变化后调用,让缓存立即失效(如用户关闭镜像 → 立刻恢复 App 提醒) */
|
||||
static invalidateMirrorCache(): void {
|
||||
ReminderService.mirroredLoadedAt = 0;
|
||||
ReminderService.mirroredCalKeys = new Set();
|
||||
}
|
||||
|
||||
/** 日历本名称(通知文案用):缓存 60 秒,'local' = 本机日历 */
|
||||
private static async calName(context: common.Context, calKey: string): Promise<string> {
|
||||
if (Date.now() - ReminderService.calNameLoadedAt > 60000
|
||||
@@ -131,10 +165,18 @@ export class ReminderService {
|
||||
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
|
||||
// 提醒静音的日历本(如别人分享的本):其中所有日程都不再提醒
|
||||
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
|
||||
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 不再重复排(消除"提醒两次")
|
||||
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
|
||||
if (mirrored.size > 0) {
|
||||
LogUtil.write(`提醒跳过 ${mirrored.size} 个已镜像到系统日历的日历本(交由系统日历提醒)`);
|
||||
}
|
||||
for (const e of rows) {
|
||||
if (muted.includes(e.calKey)) {
|
||||
continue;
|
||||
}
|
||||
if (mirrored.has(e.calKey)) {
|
||||
continue; // ⭐ 已镜像 → 系统日历会提醒,这里跳过避免重复
|
||||
}
|
||||
if (published >= ReminderService.MAX_REMINDERS) {
|
||||
break;
|
||||
}
|
||||
@@ -211,11 +253,16 @@ export class ReminderService {
|
||||
const rows: LocalEvent[] =
|
||||
await EventDb.queryRemindable(context, now, now + ReminderService.WINDOW);
|
||||
const muted: string[] = await AppSettings.getMutedReminderKeys(context);
|
||||
// ⭐⭐ 已镜像到系统日历的本:由系统日历负责提醒,App 侧不再补发(消除"提醒两次")
|
||||
const mirrored: Set<string> = await ReminderService.mirroredKeys(context);
|
||||
let fired: number = 0;
|
||||
for (const e of rows) {
|
||||
if (muted.includes(e.calKey)) {
|
||||
continue;
|
||||
}
|
||||
if (mirrored.has(e.calKey)) {
|
||||
continue; // ⭐ 已镜像 → 交由系统日历提醒
|
||||
}
|
||||
const rems: number[] = e.reminders.length > 0 ? e.reminders
|
||||
: (e.reminder > 0 ? [e.reminder] : []);
|
||||
for (const rem of rems) {
|
||||
|
||||
@@ -160,12 +160,12 @@ export class SyncEngine {
|
||||
// 现有行按 uid 分组(同一资源的覆盖实例共享 uid 与 etag)
|
||||
const existingRows: LocalEvent[] = await EventDb.queryByCalKey(context, calKey, 'event');
|
||||
const byUid: Map<string, LocalEvent[]> = new Map();
|
||||
// ⭐ 再按 **etag** 分组,给下面"要不要 GET 这个资源"做兜底(见 §复用判定 注释)
|
||||
const byEtag: Map<string, LocalEvent[]> = new Map();
|
||||
for (const row of existingRows) {
|
||||
const arr: LocalEvent[] | undefined = byUid.get(row.uid);
|
||||
if (arr === undefined) {
|
||||
byUid.set(row.uid, [row]);
|
||||
} else {
|
||||
arr.push(row);
|
||||
SyncEngine.groupPush(byUid, row.uid, row);
|
||||
if (row.etag !== '') {
|
||||
SyncEngine.groupPush(byEtag, row.etag, row);
|
||||
}
|
||||
}
|
||||
const originMatch = /https?:\/\/[^/]+/i.exec(href);
|
||||
@@ -174,9 +174,27 @@ export class SyncEngine {
|
||||
let fetched: number = 0;
|
||||
let parseFail: number = 0;
|
||||
const needFetch: RemoteItem[] = [];
|
||||
let hitUid: number = 0; // 按"资源名 = uid"命中
|
||||
let hitEtag: number = 0; // 按 etag 内容哈希兜底命中
|
||||
for (const it of items) {
|
||||
const resUid: string = SyncEngine.resUidFromHref(it.href);
|
||||
const group: LocalEvent[] | undefined = byUid.get(resUid);
|
||||
let group: LocalEvent[] | undefined = byUid.get(resUid);
|
||||
if (group !== undefined) {
|
||||
hitUid++;
|
||||
} else if (it.etag !== '') {
|
||||
// ⭐ 复用判定(2026-10-15 修复):**服务器不保证"资源文件名 = ICS 里的 UID"**。
|
||||
// 家书 CalDav.php:253 用 preg_replace('/[^A-Za-z0-9._-]/', '_', $uid) 生成文件名:
|
||||
// UID `movie-188@familybook` 的资源叫 `movie-188_familybook.ics`。
|
||||
// 旧版只按资源名去 `byUid` 查 → 永远查不到 → 该资源**每轮都被判成"变了"**,
|
||||
// 于是每次都全量 GET(实测 2184 个资源 → 单次同步 200~300 秒:卡片后台同步
|
||||
// 那 9 秒窗口连一个日历本都跑不完)。
|
||||
// etag 是**内容哈希**(服务端由正文 md5 得出):相等即内容一致,
|
||||
// 与"资源叫什么名字"无关 —— 用它兜底既安全又与服务器实现解耦。
|
||||
group = byEtag.get(it.etag);
|
||||
if (group !== undefined) {
|
||||
hitEtag++;
|
||||
}
|
||||
}
|
||||
if (!fullRefetch && group !== undefined && group.length > 0 && !group[0].dirty
|
||||
&& group[0].etag === it.etag) {
|
||||
// etag 未变:直接用本地行还原远端数据(含 reminder 等完整字段)
|
||||
@@ -187,6 +205,7 @@ export class SyncEngine {
|
||||
}
|
||||
needFetch.push(it);
|
||||
}
|
||||
LogUtil.write(`日历本[${i}]「${calName}」复用判定:uid 命中 ${hitUid}、etag 兜底命中 ${hitEtag}、需补拉 ${needFetch.length}(共 ${items.length})`);
|
||||
// 并发 GET 补拉(每批 5 个),避免全量重拉时串行请求超时
|
||||
const batchSize: number = 5;
|
||||
for (let b: number = 0; b < needFetch.length; b += batchSize) {
|
||||
@@ -390,6 +409,16 @@ export class SyncEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/** Map<key, 行数组> 追加一行(没有该 key 就建数组) */
|
||||
private static groupPush(m: Map<string, LocalEvent[]>, key: string, row: LocalEvent): void {
|
||||
const arr: LocalEvent[] | undefined = m.get(key);
|
||||
if (arr === undefined) {
|
||||
m.set(key, [row]);
|
||||
} else {
|
||||
arr.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
/** 从资源 URL 提取 UID(解析失败时的兜底) */
|
||||
private static uidFromHref(href: string): string {
|
||||
const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
|
||||
@@ -401,8 +430,7 @@ export class SyncEngine {
|
||||
}
|
||||
|
||||
/** 从资源 URL 提取 UID(URL 解码后,用于与 DB 中 parse 出的真实 uid 匹配) */
|
||||
private static resUidFromHref(href: string): string {
|
||||
const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
|
||||
private static resUidFromHref(href: string): string { const segs: string[] = href.split('/').filter((s: string): boolean => s !== '');
|
||||
if (segs.length === 0) {
|
||||
return String(Date.now());
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@
|
||||
import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { calendarManager } from '@kit.CalendarKit';
|
||||
import { fileIo as fs } from '@kit.CoreFileKit';
|
||||
import { AccountStore, BookPalette, DavAccount } from './AccountStore';
|
||||
import { EventDb, LocalEvent } from './EventDb';
|
||||
import { IcsTime, IcsUtil } from './IcsUtil';
|
||||
@@ -80,8 +79,14 @@ export class SystemCalendarMirror {
|
||||
];
|
||||
/** 系统日历账户 name 前缀。name 是开发者定义且 readonly,必须稳定可复现才能找到已有账户 */
|
||||
private static readonly ACCOUNT_PREFIX: string = 'synccalendar.mirror.';
|
||||
/** 系统日历账户 displayName 前缀,便于用户在系统日历里识别来源 */
|
||||
private static readonly DISPLAY_PREFIX: string = '同步日历 · ';
|
||||
/**
|
||||
* 系统日历账户 displayName 中「同步日历」字面量 —— **仅供"兜底识别"使用**。
|
||||
* ⚠️ 0.0.8 起 displayName 变为可切换(三种格式),本常量**不再用于生成名字**,
|
||||
* 只用于判断"这个名字是否含我们的归属标识"(格式 1/2 含、格式 3 不含)。
|
||||
*/
|
||||
private static readonly DISPLAY_MARK: string = '同步日历';
|
||||
/** 旧版固定前缀(`同步日历 · `,半角点 U+00B7 带空格)——仅用于识别/迁移历史账户 */
|
||||
private static readonly LEGACY_DISPLAY_PREFIX: string = '同步日历 · ';
|
||||
private static readonly DAY: number = 86400000;
|
||||
/** getEvents() 显式指定返回字段:identifier 只在 API20+ 的默认字段里,写死更保险 */
|
||||
private static readonly QUERY_FIELDS: (keyof calendarManager.Event)[] = [
|
||||
@@ -148,6 +153,71 @@ export class SystemCalendarMirror {
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 账户显示名(0.0.8 可切换) ==================== */
|
||||
|
||||
/**
|
||||
* ⭐ 生成系统日历账户的 displayName。
|
||||
*
|
||||
* 三种格式(分隔符统一为**半角点 `·`(U+00B7)+ 无空格**,尽量短):
|
||||
* 1 = `同步日历·<本名>` —— 保留应用名
|
||||
* 2 = `同步日历·<账号>·<本名>` —— 平板/大屏,信息最全
|
||||
* 3 = `<账号>·<本名>` ★默认 —— 短、能区分账号
|
||||
*
|
||||
* ⚠️ **不提供"仅本名"**:用户原系统日历里可能有同名日历本,裸本名无法与之区分 → 有误删风险。
|
||||
* 每个镜像账户名**至少要带一个归属标识**(`同步日历` / `账号名` / 两者)。
|
||||
*
|
||||
* ⚠️ displayName 上限 64 字符(系统限制)→ 超长时截断,避免 createCalendar 失败。
|
||||
*/
|
||||
static displayNameFor(ref: BookRef, format: number): string {
|
||||
const sep: string = '·';
|
||||
const book: string = ref.name !== '' ? ref.name : '日历本';
|
||||
const acc: string = ref.accountName !== '' ? ref.accountName : '账号';
|
||||
let out: string;
|
||||
if (format === 1) {
|
||||
out = `${SystemCalendarMirror.DISPLAY_MARK}${sep}${book}`;
|
||||
} else if (format === 2) {
|
||||
out = `${SystemCalendarMirror.DISPLAY_MARK}${sep}${acc}${sep}${book}`;
|
||||
} else {
|
||||
out = `${acc}${sep}${book}`;
|
||||
}
|
||||
// displayName 系统上限 64 字符:超长截断(保留前面的归属标识,它比本名更重要)
|
||||
if (out.length > 64) {
|
||||
out = out.substring(0, 64);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 某个 displayName 是否**含我们的归属标识**(用于兜底识别;格式 1/2 含、格式 3 不含) */
|
||||
static displayNameHasMark(display: string): boolean {
|
||||
return display.startsWith(SystemCalendarMirror.DISPLAY_MARK);
|
||||
}
|
||||
|
||||
/** 某个 displayName 是否是**旧版固定格式**(`同步日历 · xxx`,用于提示用户可更换) */
|
||||
static isLegacyDisplayName(display: string): boolean {
|
||||
return display.startsWith(SystemCalendarMirror.LEGACY_DISPLAY_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否还存在"旧格式"镜像账户 —— 设置页据此给出"可更换显示名称"的弱提示(只读,不修改)。
|
||||
* 判据:`name` 是我们的前缀 + `displayName` 是旧格式。
|
||||
*/
|
||||
static async hasLegacyDisplayName(context: common.Context): Promise<boolean> {
|
||||
try {
|
||||
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
|
||||
const all: calendarManager.Calendar[] = await cm.getAllCalendars();
|
||||
for (const cal of all) {
|
||||
const acc: calendarManager.CalendarAccount = cal.getAccount();
|
||||
if (acc.name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX) &&
|
||||
SystemCalendarMirror.isLegacyDisplayName(acc.displayName ?? '')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== 主入口 ==================== */
|
||||
|
||||
/**
|
||||
@@ -168,6 +238,117 @@ export class SystemCalendarMirror {
|
||||
return name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐⭐ 取"**确实已经镜像到系统日历**"的日历本 calKey 集合(按镜像账户的**真实存在性**判定)。
|
||||
*
|
||||
* 与"用户勾选了要镜像的本"不同 —— 后者只是**意图**,本可能还没被真正写进系统日历
|
||||
* (如刚勾选但没点「立即执行镜像」、或根本没开镜像开关)。
|
||||
* 若按"意图"跳过 App 提醒,而系统日历里其实还没有 → **两边都不提醒 = 漏提醒**。
|
||||
* 故这里只认**镜像账户真实存在**这一条。
|
||||
*
|
||||
* ⚠️ 返回**空集**=不跳过任何本(读不到账户时保守处理,宁可多提醒)。
|
||||
* ⚠️ 未开启镜像开关时直接返回空集(省一次系统查询)。
|
||||
*/
|
||||
static async mirroredCalKeys(context: common.Context): Promise<Set<string>> {
|
||||
const out: Set<string> = new Set<string>();
|
||||
try {
|
||||
if (!await AppSettings.getMirrorEnabled(context)) {
|
||||
return out; // 未开启镜像 → 没有任何本由系统日历负责
|
||||
}
|
||||
if (!await SystemCalendarMirror.hasPermission()) {
|
||||
return out; // 无权限 → 系统日历里不可能有我们的条目
|
||||
}
|
||||
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
|
||||
const all: calendarManager.Calendar[] = [];
|
||||
const cals: calendarManager.Calendar[] = await cm.getAllCalendars();
|
||||
for (const c of cals) {
|
||||
all.push(c);
|
||||
}
|
||||
// 存在镜像账户 → 其 bookId 对应的本由系统日历负责
|
||||
const presentBookIds: Set<string> = new Set<string>();
|
||||
for (const c of all) {
|
||||
const name: string = c.getAccount().name;
|
||||
if (name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX)) {
|
||||
presentBookIds.add(name.substring(SystemCalendarMirror.ACCOUNT_PREFIX.length));
|
||||
}
|
||||
}
|
||||
if (presentBookIds.size === 0) {
|
||||
return out;
|
||||
}
|
||||
// bookId → calKey(从当前账号配置解析)
|
||||
const refs: BookRef[] = await SystemCalendarMirror.listBooks(context);
|
||||
for (const r of refs) {
|
||||
if (presentBookIds.has(r.bookId) && r.calKey !== '') {
|
||||
out.add(r.calKey);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
LogUtil.write(`判定"已镜像本"失败(按未镜像处理): ${(err as BusinessError).message}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ==================== 账户身份判定(0.0.8) ==================== */
|
||||
|
||||
/**
|
||||
* 取当前 App 里真实存在的账号 id 集合(用于识别"我们的镜像账户"的身份)。
|
||||
* ⚠️ 读取失败返回**空集**,调用方据此**跳过清理**(宁可不清,也不误删)。
|
||||
*/
|
||||
private static async currentAccountIds(context: common.Context): Promise<Set<string>> {
|
||||
const ids: Set<string> = new Set<string>();
|
||||
try {
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
for (const a of accounts) {
|
||||
if (a.id !== '') {
|
||||
ids.add(a.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
LogUtil.write(`读取账号列表失败(清理将保守跳过): ${(err as BusinessError).message}`);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 某个系统日历账户是不是**本应用镜像出去的**(身份级判定)。
|
||||
*
|
||||
* 两道闸:
|
||||
* ① `name` 以 `synccalendar.mirror.` 开头 —— 挡掉所有"别人家的账户";
|
||||
* ② 从 `name` 切出 `accId`(`<accId>-<href哈希>`,取**最后一个** `-` 之前的部分),
|
||||
* 并确认它在当前账号库里 —— 精准识别"账号已删的孤儿",**与显示名完全解耦**。
|
||||
*
|
||||
* ⚠️ 为什么不能只靠 displayName:显示名现在**可切换**(格式 3 不含"同步日历"),
|
||||
* 且格式 3(`账号·本名`)可能与别的 App 的账户同名 → 只按名字判定会**误删用户数据**。
|
||||
*
|
||||
* @param knownIds 当前真实账号 id 集合(**空集 = 读不到账号 → 一律返回 false**,保守)
|
||||
* @param allowDisplayMark 是否允许"仅 displayName 含归属标识"兜底(**仅格式 1/2 可用**)
|
||||
*/
|
||||
static isOurMirrorAccount(
|
||||
name: string,
|
||||
display: string,
|
||||
knownIds: Set<string>,
|
||||
allowDisplayMark: boolean
|
||||
): boolean {
|
||||
if (knownIds.size === 0) {
|
||||
return false; // 安全阀:拿不到账号身份就不认为任何账户是我们的
|
||||
}
|
||||
if (name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX)) {
|
||||
// ① name 前缀命中 → 切出 accId 做身份校验
|
||||
const bookId: string = name.substring(SystemCalendarMirror.ACCOUNT_PREFIX.length);
|
||||
const cut: number = bookId.lastIndexOf('-');
|
||||
const accId: string = cut > 0 ? bookId.substring(0, cut) : bookId;
|
||||
// ② accId 在库里 → 确认是我们的;
|
||||
// 不在库里 → 视作"账号已删的孤儿/历史脏数据"(仍需清理)→ 也返回 true
|
||||
if (knownIds.has(accId)) {
|
||||
return true;
|
||||
}
|
||||
LogUtil.write(`镜像账户 name=${name} 的 accId=${accId} 不在账号库 → 按孤儿处理`);
|
||||
return true;
|
||||
}
|
||||
// ③ name 不像我们 → 仅当 displayName 带归属标识、且允许兜底时,才认为是我们的
|
||||
return allowDisplayMark && SystemCalendarMirror.displayNameHasMark(display);
|
||||
}
|
||||
|
||||
/* ==================== 孤儿账户清理 ==================== */
|
||||
|
||||
/**
|
||||
@@ -196,9 +377,18 @@ export class SystemCalendarMirror {
|
||||
LogUtil.write(`清理镜像账户失败:读不到系统日历账户列表 ${(err as BusinessError).message}`);
|
||||
return out;
|
||||
}
|
||||
// ⭐ 0.0.8 身份校验:拿"当前真实账号 id"当判据 —— 读不到就保守跳过,绝不误删
|
||||
const knownIds: Set<string> = await SystemCalendarMirror.currentAccountIds(context);
|
||||
if (knownIds.size === 0) {
|
||||
LogUtil.write('⚠️ 清理跳过:读不到任何账号 id(安全阀,避免误删)');
|
||||
return out;
|
||||
}
|
||||
// displayName 兜底**仅格式 1/2 可用**(含"同步日历",可与他人区分);格式 3 不参与兜底
|
||||
const fmt: number = await AppSettings.getMirrorDisplayFormat(context);
|
||||
const allowDisplayMark: boolean = aggressive && (fmt === 1 || fmt === 2);
|
||||
// ① 诊断:把**全部**账户列一遍(不只是我们的),才能看出残留账户到底是什么名字
|
||||
for (const cal of all) {
|
||||
out.detail.push(SystemCalendarMirror.describeCal(cal, validNames, aggressive));
|
||||
out.detail.push(SystemCalendarMirror.describeCal(cal, validNames, allowDisplayMark));
|
||||
}
|
||||
LogUtil.write(`系统日历账户清单(共 ${all.length}):`);
|
||||
for (const line of out.detail) {
|
||||
@@ -211,10 +401,9 @@ export class SystemCalendarMirror {
|
||||
const name: string = acc.name;
|
||||
const display: string = acc.displayName ?? '';
|
||||
const mineByName: boolean = name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX);
|
||||
const mineByDisplay: boolean = aggressive &&
|
||||
display.startsWith(SystemCalendarMirror.DISPLAY_PREFIX);
|
||||
if (!mineByName && !mineByDisplay) {
|
||||
continue;
|
||||
// ⭐ 身份级判定:name 前缀 → 切出 accId → 必须匹配当前真实账号(详见 isOurMirrorAccount)
|
||||
if (!SystemCalendarMirror.isOurMirrorAccount(name, display, knownIds, allowDisplayMark)) {
|
||||
continue; // 身份不符 → 绝不碰(可能用户原有的日历本 / 别的 App 建的)
|
||||
}
|
||||
if (validNames.has(name)) {
|
||||
continue; // 本次还要用,保留
|
||||
@@ -275,18 +464,78 @@ export class SystemCalendarMirror {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 删除指定的若干镜像账户(按 bookId 精确定位,用于"一键重命名"前清空旧账户)。
|
||||
*
|
||||
* ⚠️ 调用方**必须**已先回读(`MirrorInbound.reconcile`),否则会丢掉用户手动加的日程。
|
||||
* ⚠️ 只需 `name` 精确匹配即可 —— 不需要 displayName 判定(bookId 是身份,名字已无关)。
|
||||
* 每个账户独立 try/catch + "清空日程后重试"降级,与 purgeOrphans 同策略。
|
||||
*/
|
||||
static async removeAccountsByBookIds(context: common.Context, bookIds: string[]): Promise<number> {
|
||||
if (bookIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const cm: calendarManager.CalendarManager = calendarManager.getCalendarManager(context);
|
||||
const targets: Set<string> = new Set<string>();
|
||||
for (const b of bookIds) {
|
||||
targets.add(SystemCalendarMirror.ACCOUNT_PREFIX + b);
|
||||
}
|
||||
let removed: number = 0;
|
||||
let all: calendarManager.Calendar[] = [];
|
||||
try {
|
||||
all = await cm.getAllCalendars();
|
||||
} catch (err) {
|
||||
LogUtil.write(`重命名-删除旧账户失败:读不到账户列表 ${(err as BusinessError).message}`);
|
||||
return 0;
|
||||
}
|
||||
for (const cal of all) {
|
||||
const acc: calendarManager.CalendarAccount = cal.getAccount();
|
||||
const name: string = acc.name;
|
||||
if (!targets.has(name)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await cm.deleteCalendar(cal);
|
||||
removed++;
|
||||
LogUtil.write(`重命名:已删除旧账户 id=${cal.id} name=${name}`);
|
||||
} catch (err) {
|
||||
const e1 = err as BusinessError;
|
||||
LogUtil.write(`重命名:删除账户失败,尝试清空后重试 name=${name}: ${e1.message}`);
|
||||
try {
|
||||
const evs: calendarManager.Event[] = await SystemCalendarMirror.queryEvents(cal);
|
||||
const ids: number[] = [];
|
||||
for (const ev of evs) {
|
||||
if (ev.id !== undefined) {
|
||||
ids.push(ev.id);
|
||||
}
|
||||
}
|
||||
if (ids.length > 0) {
|
||||
await cal.deleteEvents(ids);
|
||||
}
|
||||
await cm.deleteCalendar(cal);
|
||||
removed++;
|
||||
LogUtil.write(`重命名:清空后删除成功 name=${name}`);
|
||||
} catch (err2) {
|
||||
LogUtil.write(`重命名:删除账户最终失败 name=${name}: ${(err2 as BusinessError).message}`);
|
||||
}
|
||||
}
|
||||
// 快照按键是 bookId(与账户名无关),账户删了但快照应保留 → 重建后仍幂等,故不动快照
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** 一个账户的诊断行:`#id [KEEP/MINE/other] name=… display=… type=…` */
|
||||
private static describeCal(
|
||||
cal: calendarManager.Calendar,
|
||||
validNames: Set<string>,
|
||||
aggressive: boolean
|
||||
allowDisplayMark: boolean
|
||||
): string {
|
||||
const acc: calendarManager.CalendarAccount = cal.getAccount();
|
||||
const name: string = acc.name;
|
||||
const display: string = acc.displayName ?? '';
|
||||
const mineByName: boolean = name.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX);
|
||||
const mineByDisplay: boolean = aggressive &&
|
||||
display.startsWith(SystemCalendarMirror.DISPLAY_PREFIX);
|
||||
const mineByDisplay: boolean = allowDisplayMark &&
|
||||
display.startsWith(SystemCalendarMirror.DISPLAY_MARK);
|
||||
let tag: string = 'other ';
|
||||
if (mineByName || mineByDisplay) {
|
||||
tag = validNames.has(name) ? 'KEEP ' : 'MINE ';
|
||||
@@ -344,15 +593,32 @@ export class SystemCalendarMirror {
|
||||
LogUtil.write(
|
||||
`镜像前清理:删除 ${purge.removed} 个孤儿镜像账户,失败 ${purge.failed} 个,清理后仍剩余 ${purge.remaining} 个`);
|
||||
|
||||
// ⭐ 0.0.8 防护:设备「重启后尚未解锁」时(direct boot),日历数据在加密存储里读不到,
|
||||
// getAllCalendars() 会返回空列表,此时 createCalendar 必然失败——真机实测表现为
|
||||
// 12 个镜像账户连续 `201 CreateCalendar failed!`(2026-10-16 07:55),还可能留下半创建账户。
|
||||
// 正常设备至少有一个本机日历,所以「清单为 0」只可能是日历服务未就绪 ⇒ 本轮直接跳过创建,
|
||||
// 等下一次同步(解锁后)自然恢复。
|
||||
let calCount: number = 0;
|
||||
try {
|
||||
calCount = (await cm.getAllCalendars()).length;
|
||||
} catch (err) {
|
||||
LogUtil.write(`镜像跳过:读不到系统日历账户列表 ${(err as BusinessError).message}`);
|
||||
return res;
|
||||
}
|
||||
if (calCount === 0) {
|
||||
LogUtil.write('镜像跳过:系统日历账户清单为 0(日历服务未就绪,如设备重启后尚未解锁),本轮不创建镜像账户');
|
||||
return res;
|
||||
}
|
||||
|
||||
const winStart: number = SystemCalendarMirror.windowStart();
|
||||
const winEnd: number = SystemCalendarMirror.windowEnd();
|
||||
const plans: Map<string, MirrorOcc[]> = new Map<string, MirrorOcc[]>();
|
||||
// ⭐ 0.0.8:读取用户所选的显示名格式(仅影响**新建**账户的名字;已有账户不在此处改名)
|
||||
const format: number = await AppSettings.getMirrorDisplayFormat(context);
|
||||
for (const ref of refs) {
|
||||
try {
|
||||
const plan: MirrorOcc[] =
|
||||
await SystemCalendarMirror.planFor(context, ref, winStart, winEnd, res);
|
||||
plans.set(ref.bookId, plan);
|
||||
await SystemCalendarMirror.syncBook(context, cm, ref, plan, winStart, winEnd, res);
|
||||
await SystemCalendarMirror.syncBook(context, cm, ref, plan, winStart, winEnd, res, format);
|
||||
res.books++;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
@@ -360,8 +626,7 @@ export class SystemCalendarMirror {
|
||||
LogUtil.write(`镜像失败 bookId=${ref.bookId}: ${e.code ?? ''} ${e.message}`);
|
||||
}
|
||||
}
|
||||
// ⭐ 每次镜像后自动导出一份对照诊断,便于"系统日历里有、App 里没有"这类问题逐条比对
|
||||
await SystemCalendarMirror.dumpDiagnosis(context, cm, refs, plans, winStart, winEnd);
|
||||
// ⭐ 0.0.8:不再导出 mirror_diag.txt(诊断落盘功能已随「同步记录」一并移除)
|
||||
LogUtil.write(
|
||||
`镜像完成:本=${res.books} 新增=${res.added} 更新=${res.updated} 删除=${res.deleted} 失败=${res.failed} ` +
|
||||
`跳过=${res.skipped}(窗口外单点=${res.winOut} 已删除=${res.markDel} 系统备份=${res.syscal})` +
|
||||
@@ -602,7 +867,7 @@ export class SystemCalendarMirror {
|
||||
|
||||
/**
|
||||
* ⭐ 算出"某个日历本在窗口内应该有哪些发生" —— **唯一的口径来源**。
|
||||
* 实际写入(syncBook)与诊断导出(dumpDiagnosis)都用它,保证诊断看到的就是真正写进去的。
|
||||
* 实际写入(syncBook)以它为准,保证"计划"与"写入"完全一致。
|
||||
*/
|
||||
private static async planFor(
|
||||
context: common.Context,
|
||||
@@ -689,11 +954,12 @@ export class SystemCalendarMirror {
|
||||
plan: MirrorOcc[],
|
||||
winStart: number,
|
||||
winEnd: number,
|
||||
res: MirrorResult
|
||||
res: MirrorResult,
|
||||
format: number
|
||||
): Promise<void> {
|
||||
// ⭐ 账户名与 identifier 一律用 **bookId**(由 href 派生、稳定),绝不用 calKey
|
||||
const cal: calendarManager.Calendar =
|
||||
await SystemCalendarMirror.ensureCalendar(cm, ref.sysAccountName, ref);
|
||||
await SystemCalendarMirror.ensureCalendar(cm, ref.sysAccountName, ref, format);
|
||||
|
||||
// 现有镜像条目:identifier -> Event
|
||||
const existing: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
|
||||
@@ -762,145 +1028,6 @@ export class SystemCalendarMirror {
|
||||
`窗口=${SystemCalendarMirror.fmt(winStart)}~${SystemCalendarMirror.fmt(winEnd)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 诊断导出:把"App 侧计划写入"与"系统日历里实际存在"的完整对照写到
|
||||
* `${filesDir}/mirror_diag.txt`(可用 hdc 直接取出分析)。
|
||||
*
|
||||
* 为什么需要它:用户反馈"系统日历里有、App 里没有"这种问题靠描述很难定位,
|
||||
* 必须把两侧的清单原样摆在一起逐条比。每次镜像后自动重写一份。
|
||||
*/
|
||||
private static async dumpDiagnosis(
|
||||
context: common.Context,
|
||||
cm: calendarManager.CalendarManager,
|
||||
refs: BookRef[],
|
||||
plans: Map<string, MirrorOcc[]>,
|
||||
winStart: number,
|
||||
winEnd: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const lines: string[] = [];
|
||||
lines.push(`镜像诊断 ${new Date().toLocaleString()}`);
|
||||
lines.push(`窗口 ${SystemCalendarMirror.fmt(winStart)} ~ ${SystemCalendarMirror.fmt(winEnd)} ` +
|
||||
`(${winStart} ~ ${winEnd})`);
|
||||
const cals: calendarManager.Calendar[] = await cm.getAllCalendars();
|
||||
// ⭐ 全设备日历账户清单:一眼看出有没有"本应用建的、但不属于当前选中本"的残留账户
|
||||
// (历史 calKey 命名的账户、重复账户)。每次镜像都导出一份,省得再靠按钮抓。
|
||||
const keepNames: Set<string> = new Set<string>();
|
||||
for (const r of refs) {
|
||||
keepNames.add(r.sysAccountName);
|
||||
}
|
||||
let mineN: number = 0;
|
||||
let keepN: number = 0;
|
||||
lines.push('');
|
||||
lines.push(`[全设备日历账户] 共 ${cals.length} 个日历本:`);
|
||||
for (const c of cals) {
|
||||
let accName: string = '';
|
||||
let disp: string = '';
|
||||
let n: number = -1;
|
||||
try {
|
||||
accName = c.getAccount().name;
|
||||
disp = c.getAccount().displayName ?? '';
|
||||
} catch (err) {
|
||||
// 忽略
|
||||
}
|
||||
try {
|
||||
n = (await SystemCalendarMirror.queryEvents(c)).length;
|
||||
} catch (err) {
|
||||
// 忽略
|
||||
}
|
||||
let tag: string = 'other';
|
||||
if (accName.startsWith(SystemCalendarMirror.ACCOUNT_PREFIX)) {
|
||||
if (keepNames.has(accName)) {
|
||||
tag = 'KEEP';
|
||||
keepN++;
|
||||
} else {
|
||||
tag = 'MINE(残留)';
|
||||
mineN++;
|
||||
}
|
||||
}
|
||||
lines.push(` [${tag}] #${c.id} acc=${accName} display=${disp} 条目=${n}`);
|
||||
}
|
||||
lines.push(` 小结:本应用账户 ${keepN + mineN} 个(本次保留 ${keepN},残留 ${mineN})`);
|
||||
for (const ref of refs) {
|
||||
const plan: MirrorOcc[] | undefined = plans.get(ref.bookId);
|
||||
const list: MirrorOcc[] = plan !== undefined ? plan : [];
|
||||
let cal: calendarManager.Calendar | undefined = undefined;
|
||||
for (const c of cals) {
|
||||
if (c.getAccount().name === ref.sysAccountName) {
|
||||
cal = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(`[本] ${ref.name} | bookId=${ref.bookId} | calKey=${ref.calKey} | 账户=${ref.sysAccountName} | 账户存在=${cal !== undefined}`);
|
||||
if (cal === undefined) {
|
||||
continue;
|
||||
}
|
||||
const sysEvents: calendarManager.Event[] = await SystemCalendarMirror.queryEvents(cal);
|
||||
const sysMap: Map<string, calendarManager.Event> = new Map<string, calendarManager.Event>();
|
||||
for (const ev of sysEvents) {
|
||||
const id: string = ev.identifier ?? '';
|
||||
if (id !== '') {
|
||||
sysMap.set(id, ev);
|
||||
}
|
||||
}
|
||||
lines.push(` 计划写入=${list.length} 系统日历实际=${sysEvents.length}(其中带 identifier=${sysMap.size})`);
|
||||
|
||||
// ① 系统日历里有、计划里没有 → 多出来的(正常会被删掉;删不掉就是残留)
|
||||
const planIds: Set<string> = new Set<string>();
|
||||
for (const o of list) {
|
||||
planIds.add(o.identifier);
|
||||
}
|
||||
const extra: string[] = [];
|
||||
for (const id of sysMap.keys()) {
|
||||
if (planIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
const ev: calendarManager.Event | undefined = sysMap.get(id);
|
||||
if (ev === undefined) {
|
||||
continue;
|
||||
}
|
||||
extra.push(` ${SystemCalendarMirror.stamp(ev.startTime)} ${ev.title ?? ''} [${id}]`);
|
||||
}
|
||||
lines.push(` ① 系统多出(${extra.length}):`);
|
||||
for (let i: number = 0; i < extra.length && i < 120; i++) {
|
||||
lines.push(extra[i]);
|
||||
}
|
||||
|
||||
// ② 计划里有、系统日历没有 → 漏写
|
||||
const miss: string[] = [];
|
||||
for (const o of list) {
|
||||
if (!sysMap.has(o.identifier)) {
|
||||
miss.push(` ${SystemCalendarMirror.stamp(o.start)} ${o.title} [${o.identifier}]`);
|
||||
}
|
||||
}
|
||||
lines.push(` ② 漏写(${miss.length}):`);
|
||||
for (let i: number = 0; i < miss.length && i < 60; i++) {
|
||||
lines.push(miss[i]);
|
||||
}
|
||||
|
||||
// ③ 系统日历里该账户的**全部**条目(供逐条核对,最多 400 行)
|
||||
lines.push(` ③ 系统日历条目(${sysEvents.length},最多列 400):`);
|
||||
const sorted: calendarManager.Event[] = sysEvents.slice().sort(
|
||||
(a: calendarManager.Event, b: calendarManager.Event): number => a.startTime - b.startTime);
|
||||
for (let i: number = 0; i < sorted.length && i < 400; i++) {
|
||||
const ev: calendarManager.Event = sorted[i];
|
||||
lines.push(` ${SystemCalendarMirror.stamp(ev.startTime)}~${SystemCalendarMirror.stamp(ev.endTime)} ` +
|
||||
`${ev.isAllDay === true ? '[全天]' : ''}${ev.title ?? ''} [${ev.identifier ?? ''}]`);
|
||||
}
|
||||
}
|
||||
const path: string = `${context.filesDir}/mirror_diag.txt`;
|
||||
const text: string = lines.join('\n') + '\n';
|
||||
const file: fs.File = fs.openSync(path,
|
||||
fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE | fs.OpenMode.TRUNC);
|
||||
fs.writeSync(file.fd, text);
|
||||
fs.closeSync(file.fd);
|
||||
LogUtil.write(`镜像诊断已导出:${path}(${lines.length} 行)`);
|
||||
} catch (err) {
|
||||
LogUtil.write(`导出镜像诊断失败: ${(err as BusinessError).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 时间戳 → `MM-DD HH:mm` */
|
||||
private static stamp(ts: number): string {
|
||||
const d: Date = new Date(ts);
|
||||
@@ -998,16 +1125,24 @@ export class SystemCalendarMirror {
|
||||
}
|
||||
}
|
||||
|
||||
/** 幂等地取得本应用自己的镜像账户(已存在则复用,不存在则创建并设色) */
|
||||
/**
|
||||
* 幂等地取得本应用自己的镜像账户(已存在则复用,不存在则创建并设色)。
|
||||
*
|
||||
* ⭐ 0.0.8:displayName 按用户所选格式生成。
|
||||
* ⚠️ **已存在的账户不会被改名**(平台无"改账户名"API;改名是破坏性操作,
|
||||
* 必须由用户显式触发 `reapplyDisplayNames`)。此处仅保证**新建**时用对格式。
|
||||
*/
|
||||
private static async ensureCalendar(
|
||||
cm: calendarManager.CalendarManager,
|
||||
accountName: string,
|
||||
ref: BookRef
|
||||
ref: BookRef,
|
||||
format: number
|
||||
): Promise<calendarManager.Calendar> {
|
||||
const calendars: calendarManager.Calendar[] = await cm.getAllCalendars();
|
||||
for (const cal of calendars) {
|
||||
if (cal.getAccount().name === accountName) {
|
||||
// 每次都尝试同步一次显示名与颜色(用户可能改过日历本名/色)
|
||||
// 每次都尝试同步一次颜色(用户可能改过日历本色)
|
||||
// ⚠️ displayName 不在此处改:改账户名需删账户重建(破坏性),由用户显式触发
|
||||
try {
|
||||
await cal.setConfig({ enableReminder: true, color: ref.color });
|
||||
} catch (err) {
|
||||
@@ -1019,7 +1154,7 @@ export class SystemCalendarMirror {
|
||||
const cal: calendarManager.Calendar = await cm.createCalendar({
|
||||
name: accountName,
|
||||
type: calendarManager.CalendarType.LOCAL,
|
||||
displayName: SystemCalendarMirror.DISPLAY_PREFIX + ref.name
|
||||
displayName: SystemCalendarMirror.displayNameFor(ref, format)
|
||||
});
|
||||
try {
|
||||
await cal.setConfig({ enableReminder: true, color: ref.color });
|
||||
@@ -1057,14 +1192,62 @@ export class SystemCalendarMirror {
|
||||
ev.location = { location: e.location };
|
||||
}
|
||||
const reminders: number[] = SystemCalendarMirror.remindersOf(e);
|
||||
if (reminders.length > 0) {
|
||||
ev.reminderTime = reminders;
|
||||
// ⭐⭐ 只写"提醒时刻尚未到"的提醒(0.0.8 修复"已过日程仍提醒")。
|
||||
// 原因:写进系统日历的 reminderTime 一旦其时刻已过,系统会在**任何一次覆写**时
|
||||
// 立即补发一条通知 → 表现为"日程都过去了还提醒"。这类提醒已无意义,索性不写。
|
||||
const future: number[] = SystemCalendarMirror.futureReminders(
|
||||
reminders, startTs, e.isAllDay, Date.now());
|
||||
if (future.length > 0) {
|
||||
ev.reminderTime = future;
|
||||
}
|
||||
// ⚠️ 这里**故意不设 recurrenceRule**:重复日程由我们自己按窗口展开成多条实例,
|
||||
// 交给系统日历展开会因 RecurrenceRule 表达不全而出现错误的重复(09-19 事故)。
|
||||
return ev;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提醒时刻的"基准时刻"(系统口径):
|
||||
* - 普通日程:以**开始时刻**为基准(提前 N 分钟 = start - N);
|
||||
* - 全天日程:系统文档规定以**当日 09:00**为基准(提前 N 分钟 = 当天 09:00 - N)。
|
||||
*/
|
||||
private static reminderBaseMs(startTs: number, isAllDay: boolean): number {
|
||||
if (isAllDay) {
|
||||
const d: Date = new Date(startTs);
|
||||
d.setHours(9, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
return startTs;
|
||||
}
|
||||
|
||||
/** 过滤出"提醒时刻还没到"的提醒(用于避免写入已过期的提醒而触发补发) */
|
||||
private static futureReminders(
|
||||
reminders: number[],
|
||||
startTs: number,
|
||||
isAllDay: boolean,
|
||||
now: number
|
||||
): number[] {
|
||||
const base: number = SystemCalendarMirror.reminderBaseMs(startTs, isAllDay);
|
||||
const kept: number[] = [];
|
||||
for (const r of reminders) {
|
||||
if (r > 0 && base - r * 60000 > now) {
|
||||
kept.push(r);
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** 提醒数组的规范化字符串(排序去重后拼接),用于比较 */
|
||||
private static reminderKey(reminders: number[]): string {
|
||||
const s: number[] = reminders.slice().sort((a: number, b: number): number => a - b);
|
||||
const out: string[] = [];
|
||||
for (const n of s) {
|
||||
if (out.indexOf(String(n)) < 0) {
|
||||
out.push(String(n));
|
||||
}
|
||||
}
|
||||
return out.join(',');
|
||||
}
|
||||
|
||||
/** 本地时区当天 0 点的时间戳 */
|
||||
private static dayStart(ts: number): number {
|
||||
const d: Date = new Date(ts);
|
||||
@@ -1127,6 +1310,15 @@ export class SystemCalendarMirror {
|
||||
if ((cur.location?.location ?? '') !== (want.location?.location ?? '')) {
|
||||
return 'location';
|
||||
}
|
||||
// ⭐ 0.0.8:提醒也要比较 —— 否则用户改了提醒时间,系统日历侧**永远不会更新**
|
||||
// (旧版 diffOf 不比 reminderTime,改动被判定为"无差异" → 不触发 updateEvent)。
|
||||
// ⚠️ 写入侧已过滤掉"已过期"的提醒(见 toSystemEvent / futureReminders),
|
||||
// 所以这里的比较也用同一口径,避免"每轮都因提醒不同而重写"。
|
||||
const curRem: number[] = cur.reminderTime ?? [];
|
||||
const wantRem: number[] = want.reminderTime ?? [];
|
||||
if (SystemCalendarMirror.reminderKey(curRem) !== SystemCalendarMirror.reminderKey(wantRem)) {
|
||||
return `reminder ${SystemCalendarMirror.reminderKey(curRem) || '(无)'} != ${SystemCalendarMirror.reminderKey(wantRem) || '(无)'}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want, Configuration,
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { window } from '@kit.ArkUI';
|
||||
import { BackgroundSyncService } from '../common/BackgroundSyncService';
|
||||
import { NetworkSyncWatcher } from '../common/NetworkSyncWatcher';
|
||||
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
@@ -9,11 +11,14 @@ const DOMAIN = 0x0000;
|
||||
export default class EntryAbility extends UIAbility {
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
|
||||
// 文件日志初始化:所有页面的同步日志统一写入 filesDir/sync.log
|
||||
// 日志初始化:发行版只输出 console(不再落盘 sync.log),同时清理历史遗留调试文件
|
||||
LogUtil.init(this.context);
|
||||
LogUtil.write('---- App 启动 ----');
|
||||
// 后台同步:注册延迟任务兜底 + 按设置恢复长时任务
|
||||
// 后台同步:注册延迟任务兜底(2 小时)+ 网络恢复补同步监听
|
||||
// ⚠️ 不再申请"长时任务":dataTransfer 类型有 10 分钟无进度即被取消的一致性校验,
|
||||
// 日历同步不符合该语义、必被取消(详见 BackgroundSyncService 顶部注释)。
|
||||
BackgroundSyncService.initOnLaunch(this.context);
|
||||
NetworkSyncWatcher.start(this.context);
|
||||
// 服务卡片"添加"按钮跳转:把 widgetAction=add 透传给主页面(在 AppStorage 中暂存)
|
||||
this.handleWidgetParam(want);
|
||||
// ⚠️ 这里**不再申请任何权限**(含通知权限)。
|
||||
@@ -189,9 +194,15 @@ export default class EntryAbility extends UIAbility {
|
||||
|
||||
onForeground(): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground');
|
||||
// ⭐ 回到前台:① 归还短时任务配额 ② 立刻补同步一次(用户真正要看时数据必须最新)
|
||||
BackgroundSyncService.cancelTransientSync();
|
||||
BackgroundSyncRunner.runOnce(this.context, 'foreground');
|
||||
}
|
||||
|
||||
onBackground(): void {
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground');
|
||||
// ⭐ 退到后台:申请短时任务,兜住"退后台后那一次"同步能跑完
|
||||
// (必须在退后台 5 秒内申请;单次最长 3 分钟,单日配额 10 分钟)
|
||||
BackgroundSyncService.requestTransientSync('正在同步日历数据');
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,21 @@
|
||||
import { FormExtensionAbility, formBindingData, formProvider } from '@kit.FormKit';
|
||||
import { Want } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import process from '@ohos.process';
|
||||
import { CardDataService, CardData } from '../common/CardDataService';
|
||||
import { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
export default class EntryFormAbility extends FormExtensionAbility {
|
||||
/** 当前进程 pid(取不到返回 -1):用于判断卡片回调是否与主 App 同进程 */
|
||||
private static pidOf(): number {
|
||||
try {
|
||||
return process.pid;
|
||||
} catch (err) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** 组装更新数据并推送到指定卡片 */
|
||||
private pushData(formId: string, dayOffset: number = 0): void {
|
||||
CardDataService.buildCardData(this.context, dayOffset).then((data: CardData): void => {
|
||||
@@ -40,10 +51,30 @@ export default class EntryFormAbility extends FormExtensionAbility {
|
||||
|
||||
onUpdateForm(formId: string): void {
|
||||
LogUtil.init(this.context);
|
||||
LogUtil.write(`系统触发卡片更新: formId=${formId}`);
|
||||
LogUtil.write(`系统触发卡片更新: formId=${formId}(进程 pid=${EntryFormAbility.pidOf()})`);
|
||||
CardDataService.registerForm(this.context, formId);
|
||||
// ⭐ ① 先用**本地缓存**立刻刷新卡片(几毫秒,不依赖网络)
|
||||
// 保留用户翻到的日期(系统定时刷新不应把用户翻走的日期拽回今天)
|
||||
this.pushData(formId, CardDataService.getDayOffset(formId));
|
||||
// ⭐ ② 再借这次系统刷新顺带跑一次后台同步(见 runOnceThrottledByCard:带节流、永不抛错)
|
||||
// 同步成功后再推一次,让卡片显示刚同步回来的最新数据。
|
||||
//
|
||||
// ⚠️ 关键约束(依据官方 FormExtensionAbility 生命周期文档):
|
||||
// "FormExtensionAbility 进程不能常驻后台,生命周期回调中无法处理长时间的任务,
|
||||
// 生命周期调度完成后会继续存在 **10 秒**……可能需要 10 秒以上才能完成的业务逻辑,
|
||||
// 建议拉起主应用进行处理。"
|
||||
// ⇒ 本处同步**可能被进程回收中断**。这是**可接受**的,因为:
|
||||
// · 同步是幂等的(基于 etag 增量比对 + dirty 标记),中断不会损坏任何数据;
|
||||
// · 中断只是"这一次没同步成",下个 30 分钟卡片再刷新时会重试;
|
||||
// · 正常网络下 1~3 个账号的**增量**同步通常数秒内完成,落在 10 秒窗口内。
|
||||
// ⇒ 因此它是"**尽力而为地提高后台同步频率**",与 workScheduler 2 小时兜底、
|
||||
// 回前台/网络恢复补同步共同构成多层保障,**不承诺 30 分钟必然同步成功**。
|
||||
// (不采用"拉起主应用同步"是因为会每 30 分钟弹出一次界面,体验不可接受。)
|
||||
BackgroundSyncRunner.runOnceThrottledByCard(this.context).then((): void => {
|
||||
this.pushData(formId, CardDataService.getDayOffset(formId));
|
||||
}).catch((err: BusinessError): void => {
|
||||
LogUtil.write(`卡片驱动同步失败: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
onRemoveForm(formId: string): void {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { buffer } from '@kit.ArkTS';
|
||||
import url from '@ohos.url';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV } from '../common/AccountStore';
|
||||
import { DavDiscovery, DavCapability } from '../common/DavDiscovery';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
@Entry
|
||||
@Component
|
||||
@@ -107,12 +109,30 @@ struct AddAccountPage {
|
||||
code = await this.sendOnce(serverUrl, http.RequestMethod.GET, authHeader);
|
||||
}
|
||||
if (code === 401) {
|
||||
this.statusMsg = '服务器拒绝凭据(401),请检查用户名密码';
|
||||
this.statusMsg = '服务器拒绝凭据(401):请检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
|
||||
this.statusOk = false;
|
||||
return false;
|
||||
}
|
||||
// ⭐ 0.0.7:405 是"这里不是 DAV 端点"的典型信号 —— 直接给出可操作指引,
|
||||
// 而不是笼统地报一个状态码让用户去猜。
|
||||
if (code === 405) {
|
||||
const cap: DavCapability = await DavDiscovery.optionsCapability(serverUrl, authHeader);
|
||||
if (!cap.isDav) {
|
||||
this.statusMsg = '这个地址不是 CalDAV 端点(服务器不接受 WebDAV 方法,返回 405)。\n\n'
|
||||
+ '请填写「日历家目录」而不是 DAV 根或网页链接,例如:\n'
|
||||
+ 'https://你的域名/remote.php/dav/calendars/你的用户名/\n\n'
|
||||
+ '(也可以直接填域名,下一步会自动尝试发现)';
|
||||
this.statusOk = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (code >= 200 && code < 500) {
|
||||
// ⭐ 即使可连,也顺手提示"地址看起来不完整",让用户提前知道下一步会自动补全
|
||||
if (DavDiscovery.looksIncomplete(serverUrl)) {
|
||||
this.statusMsg = '服务器连接成功(该地址看起来是服务器根路径,下一步会自动尝试定位日历家目录)';
|
||||
} else {
|
||||
this.statusMsg = '服务器连接成功';
|
||||
}
|
||||
this.statusOk = true;
|
||||
return true;
|
||||
}
|
||||
@@ -186,6 +206,17 @@ struct AddAccountPage {
|
||||
if (this.isLoading) {
|
||||
return;
|
||||
}
|
||||
// ⭐ 0.0.7:先识别「网页分享链接」——这类地址对 PROPFIND 必定 405,
|
||||
// 提前拦下并给出人话指引,避免用户白跑一趟只看到"服务器返回 405"。
|
||||
const kind: string = DavDiscovery.classify(targetUrl);
|
||||
const shareMsg: string = DavDiscovery.shareHint(kind);
|
||||
if (shareMsg !== '') {
|
||||
this.statusMsg = shareMsg;
|
||||
this.statusOk = false;
|
||||
LogUtil.write(`添加账号:识别为网页分享链接(${kind}),已拦截`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.statusMsg = '正在连接服务器…';
|
||||
this.statusOk = false;
|
||||
@@ -229,7 +260,7 @@ struct AddAccountPage {
|
||||
.width('100%')
|
||||
|
||||
Column({ space: 16 }) {
|
||||
this.formField('服务器地址', '例如:https://nas.example.com/caldav/', this.serverUrl,
|
||||
this.formField('服务器地址', '例如:https://nas.example.com/remote.php/dav/calendars/用户名/(也可只填域名,下一步会自动识别)', this.serverUrl,
|
||||
(value: string) => {
|
||||
this.serverUrl = value;
|
||||
}, false)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { buffer } from '@kit.ArkTS';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
|
||||
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
|
||||
import { DavDiscovery, DiscoverResult, DavCapability } from '../common/DavDiscovery';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
/**
|
||||
@@ -98,17 +99,44 @@ struct CalendarListPage {
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
const auth: string = 'Basic ' + token;
|
||||
// ⭐ 0.0.7:先做地址自动发现/智能补全 —— 用户常只填域名或 DAV 根,
|
||||
// 而 Nextcloud 等要求填「日历家目录」。此处自动找对端点,避免"读不出日历"。
|
||||
try {
|
||||
const resolved: DiscoverResult = await DavDiscovery.resolve(
|
||||
this.serverUrl,
|
||||
this.username,
|
||||
auth,
|
||||
async (u: string): Promise<boolean> => {
|
||||
const d: DavCalendarDiscovery = await DavClient.listCalendars(u, auth);
|
||||
return d.entries !== undefined && d.entries.length > 0;
|
||||
});
|
||||
if (resolved.ok && resolved.url !== '' && resolved.url !== this.serverUrl) {
|
||||
LogUtil.write(`服务地址已自动修正:${this.serverUrl} → ${resolved.url}(来源 ${resolved.via})`);
|
||||
this.serverUrl = resolved.url;
|
||||
} else if (!resolved.ok && resolved.hint !== '') {
|
||||
// 自动发现彻底失败 → 直接把"人话原因"给用户,不再只报一个状态码
|
||||
this.isLoading = false;
|
||||
this.statusMsg = resolved.hint;
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// 发现阶段异常不阻塞:继续用原地址走既有流程(保持旧行为兜底)
|
||||
LogUtil.write(`地址发现阶段异常,回退原地址:${(err as BusinessError).message}`);
|
||||
}
|
||||
// PROPFIND 与解析统一走 DavClient.listCalendars(与「编辑账号」页共用同一实现,避免重复正则逻辑)
|
||||
try {
|
||||
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.serverUrl, 'Basic ' + token);
|
||||
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.serverUrl, auth);
|
||||
this.isLoading = false;
|
||||
if (disc.statusCode === 401) {
|
||||
this.statusMsg = '登录已失效,请返回重新连接';
|
||||
this.statusMsg = '服务器拒绝凭据(401):请返回检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
if (disc.statusCode !== 207 && disc.statusCode !== 200) {
|
||||
this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`;
|
||||
// ⭐ 0.0.7:把生硬的状态码换成人话 + 可操作指引
|
||||
this.statusMsg = DavDiscovery.statusHint(disc.statusCode);
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
@@ -118,7 +146,8 @@ struct CalendarListPage {
|
||||
LogUtil.write(`发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color}`);
|
||||
}
|
||||
if (list.length === 0) {
|
||||
this.statusMsg = '该路径下未发现日历本(没有包含 calendar 资源类型的集合)';
|
||||
// ⭐ 0.0.7:区分"地址层级不对"与"确实没有日历",并给出正确填法示例
|
||||
this.statusMsg = DavDiscovery.emptyHint(this.serverUrl, this.username);
|
||||
this.statusOk = false;
|
||||
} else {
|
||||
this.calendarList = list;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { common } from '@kit.AbilityKit';
|
||||
import { buffer } from '@kit.ArkTS';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore } from '../common/AccountStore';
|
||||
import { DavDiscovery, DiscoverResult } from '../common/DavDiscovery';
|
||||
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
|
||||
import { EventDb } from '../common/EventDb';
|
||||
import { BackupTargetCheck, SystemCalendarMirror } from '../common/SystemCalendarMirror';
|
||||
@@ -141,16 +142,39 @@ struct EditAccountPage {
|
||||
return;
|
||||
}
|
||||
// PROPFIND 与解析统一走 DavClient.listCalendars(与「添加账号」页共用同一实现)
|
||||
const auth: string = 'Basic ' + token;
|
||||
// ⭐ 0.0.7:地址自动发现/智能补全(老账号可能当初填的是 DAV 根或已失效的路径)
|
||||
try {
|
||||
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.acc.serverUrl, 'Basic ' + token);
|
||||
const resolved: DiscoverResult = await DavDiscovery.resolve(
|
||||
this.acc.serverUrl,
|
||||
this.acc.username,
|
||||
auth,
|
||||
async (u: string): Promise<boolean> => {
|
||||
const d: DavCalendarDiscovery = await DavClient.listCalendars(u, auth);
|
||||
return d.entries !== undefined && d.entries.length > 0;
|
||||
});
|
||||
if (resolved.ok && resolved.url !== '' && resolved.url !== this.acc.serverUrl) {
|
||||
LogUtil.write(`编辑账号:服务地址自动修正 ${this.acc.serverUrl} → ${resolved.url}(${resolved.via})`);
|
||||
this.acc.serverUrl = resolved.url;
|
||||
} else if (!resolved.ok && resolved.hint !== '') {
|
||||
this.isLoading = false;
|
||||
this.statusMsg = resolved.hint;
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
LogUtil.write(`编辑账号:地址发现异常,回退原地址 ${(err as BusinessError).message}`);
|
||||
}
|
||||
try {
|
||||
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.acc.serverUrl, auth);
|
||||
this.isLoading = false;
|
||||
if (disc.statusCode === 401) {
|
||||
this.statusMsg = '登录已失效,请检查账号密码';
|
||||
this.statusMsg = '服务器拒绝凭据(401):请检查用户名与密码。若服务器开启了二次验证,请使用「应用密码」';
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
if (disc.statusCode !== 207 && disc.statusCode !== 200) {
|
||||
this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`;
|
||||
this.statusMsg = DavDiscovery.statusHint(disc.statusCode);
|
||||
this.statusOk = false;
|
||||
return;
|
||||
}
|
||||
@@ -158,7 +182,7 @@ struct EditAccountPage {
|
||||
new EditCalendarItem(e.href, e.displayName, e.color));
|
||||
if (list.length === 0) {
|
||||
LogUtil.write('编辑账号:未发现任何日历本');
|
||||
this.statusMsg = '该路径下未发现日历本';
|
||||
this.statusMsg = DavDiscovery.emptyHint(this.acc.serverUrl, this.acc.username);
|
||||
this.statusOk = false;
|
||||
} else {
|
||||
// 已勾选的日历本按账号当前配置预选
|
||||
|
||||
@@ -399,7 +399,8 @@ struct Index {
|
||||
/** 自动同步:每 15 秒检查一次,到达设置的间隔(AppStorage.syncIntervalMinutes)才真正同步 */
|
||||
private startAutoSync(): void {
|
||||
if (this.autoSyncTimer === -1) {
|
||||
AppStorage.setOrCreate('syncIntervalMinutes', 1);
|
||||
// ⭐ 0.0.8:默认间隔 1 分钟 → 15 分钟(见 AppSettings.getSyncIntervalMinutes 注释)
|
||||
AppStorage.setOrCreate('syncIntervalMinutes', 15);
|
||||
const ctx = this.getUIContext().getHostContext();
|
||||
if (ctx !== undefined) {
|
||||
AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => {
|
||||
@@ -412,7 +413,7 @@ struct Index {
|
||||
if (tickCtx !== undefined && !this.syncing) {
|
||||
ReminderService.tickReminders(tickCtx as common.UIAbilityContext);
|
||||
}
|
||||
const minutes: number = AppStorage.get<number>('syncIntervalMinutes') ?? 1;
|
||||
const minutes: number = AppStorage.get<number>('syncIntervalMinutes') ?? 15;
|
||||
if (Date.now() - this.lastSyncTime < minutes * 60000) {
|
||||
return; // 未到设置的同步间隔
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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 { MirrorRenamer, RenameResult } from '../common/MirrorRenamer';
|
||||
import { DocViewer } from '../common/DocViewer';
|
||||
|
||||
const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60];
|
||||
@@ -27,7 +28,7 @@ class BackupTarget {
|
||||
@Component
|
||||
struct SettingsPage {
|
||||
@State showSystem: boolean = true;
|
||||
@State intervalMinutes: number = 1;
|
||||
@State intervalMinutes: number = 15; // ⭐ 0.0.8:默认 15 分钟(原 1 分钟会烧光短时任务配额)
|
||||
@State backgroundSync: boolean = false;
|
||||
@State sysMode: string = 'display'; // display | backup
|
||||
@State backupKey: string = ''; // ⭐ 备份目标本的**稳定标识** bookId(不再用会漂移的 calKey)
|
||||
@@ -51,6 +52,12 @@ struct SettingsPage {
|
||||
@State mirrorCandidates: BackupTarget[] = []; // 可镜像的本(已排除"系统日历备份目标本",防回灌)
|
||||
@State mirrorInbound: boolean = false; // 是否把系统日历的改动回写 CalDAV(默认关)
|
||||
@State accountDump: string = ''; // 系统日历账户清单(诊断用,清理后填充)
|
||||
// ⭐ 系统日历账户显示名格式:1=`同步日历·本名` 2=`同步日历·账号·本名` 3=`账号·本名`(默认)
|
||||
@State displayFormat: number = 3;
|
||||
@State displayFormatApplied: number = 3; // 已生效的格式(用于判断"应用"按钮是否可点)
|
||||
@State formatRunning: boolean = false; // 正在按新格式重建镜像
|
||||
@State hasLegacyNames: boolean = false; // 是否还有旧格式账户(给出"可更换"弱提示)
|
||||
@State formatPreview: string = ''; // 实时预览示例
|
||||
private context?: common.Context;
|
||||
|
||||
aboutToAppear(): void {
|
||||
@@ -103,6 +110,8 @@ struct SettingsPage {
|
||||
if (!ok && this.context !== undefined) {
|
||||
this.mirrorEnabled = false;
|
||||
AppSettings.setMirrorEnabled(this.context, false);
|
||||
// 权限被回收 → 镜像失效 → 提醒应交回 App 负责
|
||||
ReminderService.invalidateMirrorCache();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -118,6 +127,71 @@ struct SettingsPage {
|
||||
AppSettings.getMirrorInbound(ctx).then((v: boolean): void => {
|
||||
this.mirrorInbound = v;
|
||||
});
|
||||
// ⭐ 显示名格式:加载当前值 + 实时预览 + 检查是否还有旧格式账户
|
||||
AppSettings.getMirrorDisplayFormat(ctx).then((v: number): void => {
|
||||
this.displayFormat = v;
|
||||
this.displayFormatApplied = v;
|
||||
this.refreshFormatPreview();
|
||||
});
|
||||
MirrorRenamer.hasLegacyNamedAccounts(ctx).then((v: boolean): void => {
|
||||
this.hasLegacyNames = v;
|
||||
});
|
||||
}
|
||||
|
||||
/** 用用户**实际的第一个账号名 + 第一个本名**拼出示例,让他所见即所得 */
|
||||
private refreshFormatPreview(): void {
|
||||
const ctx = this.context;
|
||||
if (ctx === undefined) {
|
||||
return;
|
||||
}
|
||||
const f: number = this.displayFormat;
|
||||
SystemCalendarMirror.selectedBookRefs(ctx).then((refs: BookRef[]): void => {
|
||||
if (refs.length > 0) {
|
||||
this.formatPreview = SystemCalendarMirror.displayNameFor(refs[0], f);
|
||||
} else {
|
||||
// 没有可用本时用占位示例
|
||||
const dummy = new BookRef();
|
||||
dummy.name = '个人日程';
|
||||
dummy.accountName = '工作';
|
||||
this.formatPreview = SystemCalendarMirror.displayNameFor(dummy, f);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** ⭐ 按新格式重建镜像(回读→删→重建),期间日程短暂消失几秒 */
|
||||
private async applyDisplayFormat(): Promise<void> {
|
||||
const ctx = this.context;
|
||||
if (ctx === undefined || this.formatRunning) {
|
||||
return;
|
||||
}
|
||||
if (this.displayFormat === this.displayFormatApplied) {
|
||||
return; // 未变化,不需要重建
|
||||
}
|
||||
this.formatRunning = true;
|
||||
try {
|
||||
// 先落盘(reapplyDisplayNames 会按现有配置重建)
|
||||
await AppSettings.setMirrorDisplayFormat(ctx, this.displayFormat);
|
||||
const res: RenameResult = await MirrorRenamer.reapplyDisplayNames(
|
||||
ctx as common.UIAbilityContext);
|
||||
if (res.ok) {
|
||||
this.displayFormatApplied = this.displayFormat;
|
||||
this.hasLegacyNames = false;
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: `已更新系统日历中的名称(${res.mirrored} 个日历本)`
|
||||
});
|
||||
} else {
|
||||
// 失败 → 回滚选项与配置,避免"设置显示已改、实际没改"的错觉
|
||||
this.displayFormat = this.displayFormatApplied;
|
||||
await AppSettings.setMirrorDisplayFormat(ctx, this.displayFormatApplied);
|
||||
this.getUIContext().getPromptAction().showToast({ message: res.message });
|
||||
}
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
this.displayFormat = this.displayFormatApplied;
|
||||
this.getUIContext().getPromptAction().showToast({ message: `更新失败:${e.message}` });
|
||||
}
|
||||
this.formatRunning = false;
|
||||
this.refreshFormatPreview();
|
||||
}
|
||||
|
||||
/** 重新检测通知权限状态 */
|
||||
@@ -446,13 +520,19 @@ struct SettingsPage {
|
||||
}
|
||||
this.mirrorEnabled = isOn;
|
||||
await AppSettings.setMirrorEnabled(this.context, isOn);
|
||||
// ⭐ 镜像开关变化 → 立即失效提醒侧的"已镜像本"缓存,并重排提醒
|
||||
// (开启:这些本改由系统日历提醒,App 不再重复;关闭:必须立刻恢复 App 提醒,避免漏提醒)
|
||||
ReminderService.invalidateMirrorCache();
|
||||
if (isOn) {
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: '已开启镜像:勾选要同步的日历本后点「立即执行镜像」'
|
||||
});
|
||||
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
|
||||
return;
|
||||
}
|
||||
const n: number = await SystemCalendarMirror.removeAll(this.context);
|
||||
// 关闭镜像后,这些本的提醒要交回 App 负责
|
||||
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: n > 0 ? `已关闭镜像,并清除系统日历中的 ${n} 个镜像账户` : '已关闭镜像'
|
||||
});
|
||||
@@ -473,6 +553,9 @@ struct SettingsPage {
|
||||
return;
|
||||
}
|
||||
await AppSettings.setMirrorBookIds(this.context, this.mirrorKeys);
|
||||
// ⭐ 勾选本变化 → 提醒侧"已镜像本"集合变化:失效缓存并重排提醒
|
||||
ReminderService.invalidateMirrorCache();
|
||||
await ReminderService.refreshReminders(this.context as common.UIAbilityContext);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -521,7 +604,7 @@ struct SettingsPage {
|
||||
|
||||
/**
|
||||
* 「清理残留镜像账户(诊断)」按钮:
|
||||
* ① 把系统日历里**全部**账户(含 id / name / displayName / type)写进同步日志;
|
||||
* ① 读系统日历里**全部**账户(含 id / name / displayName / type)并显示在下方清单里;
|
||||
* ② 把"我们建的、但不属于当前选中本"的账户全部删掉(逐个独立 try,失败也继续);
|
||||
* ③ Toast 汇总成功/失败/残留数量,残留账户名直接显示出来。
|
||||
*/
|
||||
@@ -540,7 +623,8 @@ struct SettingsPage {
|
||||
if (r.remaining > 0) {
|
||||
msg += `;仍残留 ${r.remaining} 个:${r.remainingNames.join(' , ')}`;
|
||||
}
|
||||
msg += '(完整账户清单见同步日志)';
|
||||
// ⭐ 0.0.8:不再写同步日志 → 提示语改为指向页面下方那份清单(accountDump)
|
||||
msg += '(完整账户清单见下方)';
|
||||
this.getUIContext().getPromptAction().showToast({ message: msg, duration: 6000 });
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
@@ -566,23 +650,17 @@ struct SettingsPage {
|
||||
if (this.context === undefined) {
|
||||
return;
|
||||
}
|
||||
// 后台持续同步依赖常驻通知 → 在用户主动开启时才申请通知权限(不随启动自动弹窗)
|
||||
if (value) {
|
||||
await this.ensureNotifyPermission();
|
||||
}
|
||||
try {
|
||||
await BackgroundSyncService.setBackgroundSync(
|
||||
this.context as common.UIAbilityContext, value);
|
||||
this.getUIContext().getPromptAction().showToast({
|
||||
message: value
|
||||
? '后台持续同步已开启,通知栏将显示常驻通知'
|
||||
: '后台持续同步已关闭'
|
||||
message: value ? '后台自动同步已开启' : '后台自动同步已关闭'
|
||||
});
|
||||
} catch (err) {
|
||||
this.backgroundSync = !value; // 失败回滚 UI
|
||||
const msg: string = (err as BusinessError).message !== ''
|
||||
? (err as BusinessError).message : '请重试';
|
||||
this.getUIContext().getPromptAction().showToast({ message: `开启失败:${msg}` });
|
||||
this.getUIContext().getPromptAction().showToast({ message: `设置失败:${msg}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -942,6 +1020,67 @@ struct SettingsPage {
|
||||
.width('100%')
|
||||
.padding({ top: 4 })
|
||||
|
||||
// ⭐ 系统日历中的显示名称(0.0.8):三种格式,切换需重建镜像
|
||||
Column({ space: 6 }) {
|
||||
Text('系统日历中的显示名称')
|
||||
.fontSize(13)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
if (this.hasLegacyNames) {
|
||||
Text('当前名称是旧格式,你可以在下方更换显示方式')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.brand_text'))
|
||||
.width('100%')
|
||||
}
|
||||
Flex({ wrap: FlexWrap.Wrap }) {
|
||||
ForEach([
|
||||
{ v: 1, label: '同步日历·本名' },
|
||||
{ v: 2, label: '同步日历·账号·本名' },
|
||||
{ v: 3, label: '账号·本名' }
|
||||
], (opt: Record<string, string | number>): void => {
|
||||
Text(opt.label as string)
|
||||
.fontSize(12)
|
||||
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
|
||||
.margin({ right: 8, bottom: 6 })
|
||||
.borderRadius(14)
|
||||
.backgroundColor(this.displayFormat === (opt.v as number)
|
||||
? $r('app.color.brand') : $r('app.color.card_bg'))
|
||||
.fontColor(this.displayFormat === (opt.v as number)
|
||||
? $r('app.color.button_text') : $r('app.color.text_primary'))
|
||||
.border({
|
||||
width: 1,
|
||||
color: this.displayFormat === (opt.v as number)
|
||||
? $r('app.color.brand') : $r('app.color.shadow_color')
|
||||
})
|
||||
.onClick(() => {
|
||||
this.displayFormat = opt.v as number;
|
||||
this.refreshFormatPreview();
|
||||
})
|
||||
}, (opt: Record<string, string | number>): string => `fmt_${opt.v}`)
|
||||
}
|
||||
.width('100%')
|
||||
Text(`示例:${this.formatPreview}`)
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
.width('100%')
|
||||
Text('修改后需重新写入系统日历,期间日程会短暂消失几秒')
|
||||
.fontSize(11)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
.width('100%')
|
||||
Button(this.formatRunning ? '正在应用…'
|
||||
: (this.displayFormat === this.displayFormatApplied ? '已应用' : '应用'))
|
||||
.fontSize(13)
|
||||
.width('100%')
|
||||
.enabled(!this.formatRunning && this.displayFormat !== this.displayFormatApplied)
|
||||
.onClick(() => {
|
||||
this.applyDisplayFormat();
|
||||
})
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
.padding({ top: 8 })
|
||||
|
||||
Button(this.mirrorRunning ? '正在执行…' : '立即执行镜像')
|
||||
.fontSize(14)
|
||||
.width('100%')
|
||||
@@ -1197,15 +1336,15 @@ struct SettingsPage {
|
||||
.backgroundColor($r('app.color.card_bg'))
|
||||
.border({ width: 1, color: $r('app.color.shadow_color') })
|
||||
|
||||
// 后台持续同步(长时任务)
|
||||
// 后台自动同步
|
||||
Column({ space: 8 }) {
|
||||
Row({ space: 10 }) {
|
||||
Column({ space: 2 }) {
|
||||
Text('后台持续同步')
|
||||
Text('后台自动同步')
|
||||
.fontSize(15)
|
||||
.fontWeight(FontWeight.Medium)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
Text('开启后申请后台长时任务,App 退到后台也按设定间隔继续同步(通知栏会显示常驻通知)')
|
||||
Text('开启后,退到后台仍会定时同步(系统最短约 2 小时一次);回到前台或网络恢复时会立即补同步一次')
|
||||
.fontSize(12)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
|
||||
@@ -11,8 +11,12 @@ struct Widget2x2Card {
|
||||
@LocalStorageProp('dateMd') dateMd: string = '';
|
||||
@LocalStorageProp('weekday') weekday: string = '';
|
||||
@LocalStorageProp('todayCount') todayCount: number = 0;
|
||||
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
|
||||
@LocalStorageProp('selectedDate') selectedDate: number = 0;
|
||||
|
||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页(阻止冒泡,避免同时打开 App 首页) */
|
||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页。
|
||||
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
|
||||
* 故点 + 不会连带触发根容器的"打开首页"。 */
|
||||
@Builder
|
||||
addButton() {
|
||||
Button() {
|
||||
@@ -35,12 +39,12 @@ struct Widget2x2Card {
|
||||
})
|
||||
}
|
||||
|
||||
/** 卡片"打开 App":点击日期区或今日日程数区域触发;添加按钮是 header 行的兄弟节点,其点击不会冒泡到这里 */
|
||||
/** 打开 App 并**定位到卡片当前显示的那一天**(用户可能已翻到其他日期) */
|
||||
private openApp(): void {
|
||||
postCardAction(this, {
|
||||
action: 'router',
|
||||
abilityName: 'EntryAbility',
|
||||
params: {}
|
||||
params: { openDate: `${this.selectedDate}` }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,7 +65,6 @@ struct Widget2x2Card {
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.onClick(() => this.openApp())
|
||||
|
||||
this.addButton()
|
||||
}
|
||||
@@ -92,12 +95,13 @@ struct Widget2x2Card {
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
.width('100%')
|
||||
.height('100%')
|
||||
.padding(14)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日(原来只有日期区/条数区可点)
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ struct Widget4x2Card {
|
||||
@LocalStorageProp('lunarText') lunarText: string = '';
|
||||
@LocalStorageProp('todayCount') todayCount: number = 0;
|
||||
@LocalStorageProp('ongoingJson') ongoingJson: string = '[]';
|
||||
/** 卡片当前显示的那一天 0 点毫秒 —— 点卡片进 App 时定位到这一天 */
|
||||
@LocalStorageProp('selectedDate') selectedDate: number = 0;
|
||||
|
||||
/** 右侧最多渲染 2 条(数据侧已裁剪,这里再兜底截断) */
|
||||
private parseOngoing(): CardItem2x4[] {
|
||||
@@ -57,12 +59,12 @@ struct Widget4x2Card {
|
||||
})
|
||||
}
|
||||
|
||||
/** 卡片"打开 App":点击左侧日期区、今日条数区、右侧日程区触发;添加按钮是 header 行兄弟节点,不冒泡 */
|
||||
/** 打开 App 并**定位到卡片当前显示的那一天**(用户可能已翻到其他日期) */
|
||||
private openApp(): void {
|
||||
postCardAction(this, {
|
||||
action: 'router',
|
||||
abilityName: 'EntryAbility',
|
||||
params: {}
|
||||
params: { openDate: `${this.selectedDate}` }
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,7 +120,6 @@ struct Widget4x2Card {
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
|
||||
build() {
|
||||
@@ -141,7 +142,6 @@ struct Widget4x2Card {
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.onClick(() => this.openApp())
|
||||
|
||||
this.addButton()
|
||||
}
|
||||
@@ -173,7 +173,6 @@ struct Widget4x2Card {
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.alignItems(VerticalAlign.Center)
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.height('100%')
|
||||
@@ -197,7 +196,6 @@ struct Widget4x2Card {
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
}
|
||||
.layoutWeight(1)
|
||||
@@ -208,5 +206,7 @@ struct Widget4x2Card {
|
||||
.padding(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
// ⭐ 整卡可点:左右空白处也能进 App 并定位到当前显示日
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,7 +387,9 @@ struct Widget4x4Card {
|
||||
return h < 10 ? `0${h}` : `${h}`;
|
||||
}
|
||||
|
||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡到其它点击区) */
|
||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页。
|
||||
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
|
||||
* 故点 + 不会连带触发根容器的"打开首页"。 */
|
||||
@Builder
|
||||
addButton() {
|
||||
Button() {
|
||||
@@ -442,6 +444,7 @@ struct Widget4x4Card {
|
||||
.borderRadius(11)
|
||||
.padding(0)
|
||||
.backgroundColor('#F2F3F5')
|
||||
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开 App
|
||||
.onClick(() => this.page(act))
|
||||
}
|
||||
|
||||
@@ -462,6 +465,7 @@ struct Widget4x4Card {
|
||||
.borderRadius(11)
|
||||
.padding(0)
|
||||
.backgroundColor('#007DFF')
|
||||
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先
|
||||
.onClick(() => this.page('today'))
|
||||
}
|
||||
}
|
||||
@@ -554,7 +558,6 @@ struct Widget4x4Card {
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.onClick(() => this.openApp())
|
||||
} else {
|
||||
List({ space: 4 }) {
|
||||
ForEach(this.parseList(), (item: LItem4) => {
|
||||
@@ -568,7 +571,6 @@ struct Widget4x4Card {
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.cachedCount(8)
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,7 +591,6 @@ struct Widget4x4Card {
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.onClick(() => this.openApp())
|
||||
this.pageBtn('›', 'next')
|
||||
this.sideBtn()
|
||||
}
|
||||
@@ -630,7 +631,6 @@ struct Widget4x4Card {
|
||||
.padding({ left: 5, right: 5 })
|
||||
.borderRadius(5)
|
||||
.backgroundColor(a.color)
|
||||
.onClick(() => this.openApp())
|
||||
}, (a: TAllDay, idx: number) => `ad_${idx}_${a.eventKey}`)
|
||||
if (this.w4AllDayRest() > 0) {
|
||||
Text(`还有 ${this.w4AllDayRest()} 个全天日程`)
|
||||
@@ -690,6 +690,9 @@ struct Widget4x4Card {
|
||||
.padding(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日。
|
||||
// ⚠️ onClick 是**非冒泡**事件且**子组件优先** → 内部的 ‹ › 今 + 按钮不会被这里抢走
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
|
||||
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
|
||||
@@ -815,7 +818,6 @@ struct Widget4x4Card {
|
||||
.width('100%')
|
||||
.height(this.w4BlockVp(b))
|
||||
.constraintSize({ minHeight: 14 })
|
||||
.onClick(() => this.openApp())
|
||||
}, (b: TBlock) => `b_${r.key}_${b.eventKey}`)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
|
||||
@@ -384,7 +384,9 @@ struct Widget6x4Card {
|
||||
return h < 10 ? `0${h}:00` : `${h}:00`;
|
||||
}
|
||||
|
||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页(兄弟节点布局,不冒泡) */
|
||||
/** 右上角添加按钮:拉起 App 直接进入新建日程页。
|
||||
* ⭐ 无需阻止冒泡:ArkUI 的 `onClick` 是**非冒泡**事件,父子同绑时**子组件优先**,
|
||||
* 故点 + 不会连带触发根容器的"打开首页"。 */
|
||||
@Builder
|
||||
addButton() {
|
||||
Button() {
|
||||
@@ -439,6 +441,7 @@ struct Widget6x4Card {
|
||||
.borderRadius(11)
|
||||
.padding(0)
|
||||
.backgroundColor('#F2F3F5')
|
||||
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先 → 点翻页不会连带打开 App
|
||||
.onClick(() => this.page(act))
|
||||
}
|
||||
|
||||
@@ -457,6 +460,7 @@ struct Widget6x4Card {
|
||||
.borderRadius(11)
|
||||
.padding(0)
|
||||
.backgroundColor('#007DFF')
|
||||
// ⭐ 无需阻止冒泡:onClick 非冒泡、子组件优先
|
||||
.onClick(() => this.page('today'))
|
||||
}
|
||||
}
|
||||
@@ -549,7 +553,6 @@ struct Widget6x4Card {
|
||||
.width('100%')
|
||||
.layoutWeight(1)
|
||||
.justifyContent(FlexAlign.Center)
|
||||
.onClick(() => this.openApp())
|
||||
} else {
|
||||
List({ space: 4 }) {
|
||||
ForEach(this.parseList(), (item: LItem6) => {
|
||||
@@ -563,7 +566,6 @@ struct Widget6x4Card {
|
||||
.layoutWeight(1)
|
||||
.scrollBar(BarState.Auto)
|
||||
.cachedCount(8)
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,7 +586,6 @@ struct Widget6x4Card {
|
||||
}
|
||||
.layoutWeight(1)
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.onClick(() => this.openApp())
|
||||
Text(`${this.dayCount} 条`)
|
||||
.fontSize(10)
|
||||
.fontColor('#B0B0B0')
|
||||
@@ -629,7 +630,6 @@ struct Widget6x4Card {
|
||||
.padding({ left: 6, right: 6 })
|
||||
.borderRadius(6)
|
||||
.backgroundColor(a.color)
|
||||
.onClick(() => this.openApp())
|
||||
}, (a: TAllDay6, idx: number) => `ad_${idx}_${a.eventKey}`)
|
||||
if (this.w6AllDayRest() > 0) {
|
||||
Text(`还有 ${this.w6AllDayRest()} 个全天日程`)
|
||||
@@ -689,6 +689,9 @@ struct Widget6x4Card {
|
||||
.padding(12)
|
||||
.backgroundColor('#FFFFFF')
|
||||
.borderRadius(16)
|
||||
// ⭐ 整卡可点:空白处也能进 App 并定位到当前显示日。
|
||||
// ⚠️ onClick 是**非冒泡**事件且**子组件优先** → 内部的 ‹ › 今 + 按钮不会被这里抢走
|
||||
.onClick(() => this.openApp())
|
||||
}
|
||||
|
||||
/** 第 1 层:整点网格(每小时一格,格底一条灰线) */
|
||||
@@ -814,7 +817,6 @@ struct Widget6x4Card {
|
||||
.width('100%')
|
||||
.height(this.w6BlockVp(b))
|
||||
.constraintSize({ minHeight: 16 })
|
||||
.onClick(() => this.openApp())
|
||||
}, (b: TBlock6) => `b_${r.key}_${b.eventKey}`)
|
||||
}
|
||||
.layoutWeight(1)
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
// entry/src/main/ets/syncwork/SyncWorkAbility.ets
|
||||
// 延迟任务兜底同步:App 进程被杀/设备重启后,系统在满足条件(有网络)时拉起本扩展执行同步
|
||||
// 注意:必须幂等——同步本身基于 ETag 全量比对,天然幂等;执行完必须 stopWork,否则系统按超时处理
|
||||
// 延迟任务兜底同步:App 进程被杀/设备重启后,系统在满足条件(有网络)时拉起本扩展执行同步。
|
||||
//
|
||||
// ⚠️ 约束(勿改):
|
||||
// - 单次回调**上限 2 分钟**,超时进程被系统终止 → 逻辑必须轻量(本类只调 Runner,不做事后处理)。
|
||||
// - 周期由 BackgroundSyncService 设为 2 小时(系统活跃分组下限),此处不再重复设置。
|
||||
// - 必须幂等:同步基于 etag 全量比对 + dirty 标记,天然幂等。
|
||||
// - 必须主动 stopWork,否则系统按超时处理。
|
||||
import { WorkSchedulerExtensionAbility, workScheduler } from '@kit.BackgroundTasksKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore';
|
||||
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 { BackgroundSyncRunner } from '../common/BackgroundSyncRunner';
|
||||
import { LogUtil } from '../common/LogUtil';
|
||||
|
||||
export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
|
||||
@@ -26,62 +23,9 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
|
||||
|
||||
private async doSync(workInfo: workScheduler.WorkInfo): Promise<void> {
|
||||
try {
|
||||
const context = this.context;
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
// 备份模式:先导入系统本地日历(幂等),再随同步推送
|
||||
try {
|
||||
await SystemCalendarImport.importIfNeeded(context);
|
||||
} catch (err) {
|
||||
// 导入失败不影响正常同步
|
||||
}
|
||||
// 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前)
|
||||
try {
|
||||
await MirrorInbound.reconcile(context);
|
||||
} catch (err) {
|
||||
// 对账失败不影响正常同步
|
||||
}
|
||||
let ok: number = 0;
|
||||
let attempted: number = 0;
|
||||
for (const acc of accounts) {
|
||||
if (acc.type !== TYPE_CALDAV) {
|
||||
continue;
|
||||
}
|
||||
attempted++;
|
||||
try {
|
||||
// 超时保护由 syncAccount 内部处理(全量重拉 10 分钟 / 常规 5 分钟),不再套外层超时
|
||||
await SyncEngine.syncAccount(context, acc);
|
||||
ok++;
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`延迟任务同步 id=${acc.id} 失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
// 全部账号同步成功才结束"一次性全量重拉"状态
|
||||
if (attempted > 0 && ok === attempted) {
|
||||
await AppSettings.markFullRefetchDone(context);
|
||||
}
|
||||
await SyncEngine.settleLocalEvents(context);
|
||||
await SyncEngine.pruneOrphanRows(context, accounts);
|
||||
// ⚠️ 必须回写账号:syncAccount 会就地更新 acc(服务器日历本颜色、写权限等)。
|
||||
// 缺了这一步,后台同步刷到的颜色只留在内存里、进程退出即丢失,
|
||||
// 界面下次冷启动仍用旧颜色(表现为"同步之后颜色还是不对")。
|
||||
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);
|
||||
LogUtil.write(`延迟任务同步完成:${ok} 个账号`);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`延迟任务同步失败: ${e.message}`);
|
||||
// 完整的后台同步流程(导入/对账/逐账号同步/镜像/卡片/提醒)统一由 Runner 实现,
|
||||
// 与"网络恢复补同步"、"回到前台补同步"共用同一套逻辑,避免三处实现漂移。
|
||||
await BackgroundSyncRunner.runOnce(this.context, 'deferred');
|
||||
} finally {
|
||||
// 必须主动结束,否则系统按 120 秒超时处理
|
||||
workScheduler.stopWork(workInfo);
|
||||
|
||||
@@ -12,9 +12,6 @@
|
||||
{
|
||||
"name": "ohos.permission.INTERNET"
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.KEEP_BACKGROUND_RUNNING"
|
||||
},
|
||||
{
|
||||
"name": "ohos.permission.PUBLISH_AGENT_REMINDER"
|
||||
},
|
||||
@@ -83,9 +80,6 @@
|
||||
"startWindowBackground": "$color:start_window_background",
|
||||
"exported": true,
|
||||
"orientation": "auto_rotation_restricted",
|
||||
"backgroundModes": [
|
||||
"dataTransfer"
|
||||
],
|
||||
"skills": [
|
||||
{
|
||||
"entities": [
|
||||
|
||||
Reference in New Issue
Block a user