Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed26da8dc4 | ||
|
|
68329c57a0 | ||
|
|
2b29f197e8 | ||
|
|
da0249d9f4 | ||
|
|
17d7602d7d |
+2
-2
@@ -2,8 +2,8 @@
|
||||
"app": {
|
||||
"bundleName": "synccalendar.yangyq.net",
|
||||
"vendor": "yangyq",
|
||||
"versionCode": 500,
|
||||
"versionName": "0.0.5",
|
||||
"versionCode": 700,
|
||||
"versionName": "0.0.7",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// entry/src/main/ets/common/BackgroundSyncRunner.ets
|
||||
// ⭐ 后台同步的**唯一**执行体:延迟任务 / 短时任务 / 网络恢复 / 回到前台 都调它。
|
||||
//
|
||||
// 设计要点(2026-10-10 定时同步方案调研结论):
|
||||
// - 不再依赖"进程常驻"(系统不允许,dataTransfer 长时任务会被强制取消)
|
||||
// - 而是让"最合适的机制在最合适的时机把数据对齐"
|
||||
// - 本类只负责"跑一次完整同步",幂等(基于 etag 全量比对 + dirty 标记)
|
||||
//
|
||||
// ⚠️ 与 SyncEngine.syncAccount 的分工:本类不关心超时与互斥锁(由 SyncEngine 内部处理)。
|
||||
import { common } from '@kit.AbilityKit';
|
||||
import { BusinessError } from '@kit.BasicServicesKit';
|
||||
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 {
|
||||
/**
|
||||
* 跑一次完整同步。全流程 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();
|
||||
try {
|
||||
const accounts: DavAccount[] = await AccountStore.loadAll(context);
|
||||
if (accounts.length === 0) {
|
||||
LogUtil.write(`后台同步[${tag}]:无账号,跳过`);
|
||||
return r;
|
||||
}
|
||||
// 备份模式:先导入系统本地日历(幂等),再随同步推送
|
||||
try {
|
||||
await SystemCalendarImport.importIfNeeded(context);
|
||||
} catch (err) {
|
||||
// 导入失败不影响正常同步
|
||||
}
|
||||
// 入站对账:把用户在系统日历里的改动读回来置 dirty(必须在同步之前)
|
||||
try {
|
||||
await MirrorInbound.reconcile(context);
|
||||
} catch (err) {
|
||||
// 对账失败不影响正常同步
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
// 全部账号同步成功才结束"一次性全量重拉"状态
|
||||
if (r.attempted > 0 && r.ok === r.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(`后台同步[${tag}] 自动镜像:本=${mr.books} 新增=${mr.added} 更新=${mr.updated} 删除=${mr.deleted}`);
|
||||
}
|
||||
} catch (err) {
|
||||
// 镜像失败不影响同步
|
||||
}
|
||||
// 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见
|
||||
try {
|
||||
await CardDataService.pushToAllForms(context);
|
||||
await ReminderService.refreshReminders(context as common.UIAbilityContext);
|
||||
} catch (err) {
|
||||
// 卡片/提醒刷新失败不影响同步结果
|
||||
}
|
||||
LogUtil.write(`后台同步[${tag}] 完成:${r.ok}/${r.attempted} 个账号`);
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`后台同步[${tag}] 流程异常: ${e.message}`);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +1,152 @@
|
||||
// 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,
|
||||
abilityName: 'SyncWorkAbility',
|
||||
networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY,
|
||||
isRepeat: true,
|
||||
isPersisted: true,
|
||||
repeatCycleTime: cycleMs
|
||||
};
|
||||
return info;
|
||||
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 {
|
||||
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) {
|
||||
try {
|
||||
await BackgroundSyncService.startContinuousTask(context);
|
||||
} catch (err) {
|
||||
// 启动时恢复失败不阻塞 App:下次开关切换或重启再试
|
||||
}
|
||||
/** 取消延迟任务(用户关闭"后台自动同步"时调用) */
|
||||
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 {
|
||||
workScheduler.stopWork(info, true);
|
||||
LogUtil.write('延迟任务已取消(后台自动同步关闭)');
|
||||
} catch (err) {
|
||||
const e = err as BusinessError;
|
||||
LogUtil.write(`延迟任务取消失败: ${e.code} - ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置页开关切换:失败抛错由页面提示并回滚 UI */
|
||||
static async setBackgroundSync(context: common.UIAbilityContext, enabled: boolean): Promise<void> {
|
||||
/**
|
||||
* ⭐ 退到后台时申请**短时任务**:让"刚退后台的这一次同步"能跑完。
|
||||
* 必须在**前台或退后台 5 秒内**调用,否则申请失败。
|
||||
* 单次最长 3 分钟(实际申请到的时长以返回值为准),单日总配额 10 分钟。
|
||||
*/
|
||||
static requestTransientSync(reason: string): void {
|
||||
if (BackgroundSyncService.transientId !== 0) {
|
||||
return; // 已有短时任务在跑,不重复申请(重复申请会浪费配额)
|
||||
}
|
||||
try {
|
||||
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) {
|
||||
// 先申请任务,成功才落盘设置(失败时设置页回滚开关)
|
||||
await BackgroundSyncService.startContinuousTask(context);
|
||||
await AppSettings.setBackgroundSync(context, true);
|
||||
BackgroundSyncService.scheduleDeferredSync(context);
|
||||
} else {
|
||||
await AppSettings.setBackgroundSync(context, false);
|
||||
try {
|
||||
await BackgroundSyncService.stopContinuousTask(context);
|
||||
} catch (err) {
|
||||
// 停止失败(如任务本就不在运行)不影响设置保存
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -12,8 +14,11 @@ export default class EntryAbility extends UIAbility {
|
||||
// 文件日志初始化:所有页面的同步日志统一写入 filesDir/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('正在同步日历数据');
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
this.statusMsg = '服务器连接成功';
|
||||
// ⭐ 即使可连,也顺手提示"地址看起来不完整",让用户提前知道下一步会自动补全
|
||||
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 {
|
||||
// 已勾选的日历本按账号当前配置预选
|
||||
|
||||
@@ -44,6 +44,19 @@ struct EventEditPage {
|
||||
@State statusMsg: string = '';
|
||||
@State isExisting: boolean = false;
|
||||
@State repeatMode: string = 'none'; // none|daily|workday|weekly|monthly|yearly|custom
|
||||
/**
|
||||
* ⭐ 重复的"结束条件"(新建与编辑都可用):
|
||||
* never 永不结束
|
||||
* until 重复到某个日期为止(界面上选具体日期)
|
||||
* count 重复固定次数
|
||||
*/
|
||||
@State repeatEnd: string = 'never';
|
||||
@State repeatUntil: number = 0; // 截止日期(当天 0 点毫秒)
|
||||
@State repeatCount: number = 10; // 重复次数
|
||||
/** ⭐ 编辑时载入的原始 RRULE:用于判断"新建的结束条件是否会覆盖已有规则" */
|
||||
private originalRrule: string = '';
|
||||
/** ⭐ 发生时刻是否由调用方传入(true=用户点的是某一次,false=用户点的是主事件本身) */
|
||||
private occProvided: boolean = false;
|
||||
@State reminderList: number[] = []; // 提前提醒分钟数(多选),空 = 不提醒
|
||||
/**
|
||||
* ⭐ 底部弹层:**整个页面只能挂一个 `bindSheet`**。
|
||||
@@ -106,13 +119,18 @@ struct EventEditPage {
|
||||
const loaded = await EventDb.getById(context, pendingId);
|
||||
if (loaded !== null) {
|
||||
this.event = loaded;
|
||||
// ⭐ 原始行快照:series 范围计算必须用"载入时"的值(doSave 会改写 this.event)
|
||||
// ⭐ 原始行快照:series 范围计算必须用"载入时"的值(doSave 会改写 this.event)。
|
||||
// ⚠️ 必须是**完整**拷贝 —— 曾经漏了 title/description/location/reminders,
|
||||
// 导致"只改这一次"时把这一行还原成空标题(BUG:已有标题被删没)。
|
||||
const snap = new LocalEvent();
|
||||
snap.id = loaded.id;
|
||||
snap.uid = loaded.uid;
|
||||
snap.calKey = loaded.calKey;
|
||||
snap.href = loaded.href;
|
||||
snap.remotePath = loaded.remotePath;
|
||||
snap.title = loaded.title;
|
||||
snap.description = loaded.description;
|
||||
snap.location = loaded.location;
|
||||
snap.startTime = loaded.startTime;
|
||||
snap.endTime = loaded.endTime;
|
||||
snap.recurrenceId = loaded.recurrenceId;
|
||||
@@ -121,7 +139,10 @@ struct EventEditPage {
|
||||
snap.isAllDay = loaded.isAllDay;
|
||||
snap.recurring = loaded.recurring;
|
||||
snap.deleted = loaded.deleted;
|
||||
snap.reminder = loaded.reminder;
|
||||
snap.reminders = loaded.reminders.slice();
|
||||
this.original = snap;
|
||||
this.originalRrule = loaded.rrule;
|
||||
this.isExisting = true;
|
||||
this.title = loaded.title;
|
||||
this.location = loaded.location;
|
||||
@@ -152,10 +173,32 @@ struct EventEditPage {
|
||||
this.statusMsg = '这条日程原先只存在「本机」(不同步)。请选一个 CalDAV 日历本,保存后即会同步到服务器。';
|
||||
}
|
||||
this.repeatMode = loaded.rrule !== '' ? this.modeFromRrule(loaded.rrule) : 'none';
|
||||
this.parseRepeatEnd(loaded.rrule);
|
||||
this.reminderList = loaded.reminders.length > 0 ? loaded.reminders
|
||||
: (loaded.reminder > 0 ? [loaded.reminder] : []);
|
||||
// ⭐ 目标发生时刻:优先用 RECURRENCE-ID(覆盖行=被改写的原始时刻),否则用行本身开始时间
|
||||
this.occStartMs = loaded.recurrenceId > 0 ? loaded.recurrenceId : loaded.startTime;
|
||||
// ⭐ 目标发生时刻("仅这一次"要排除/覆盖的那一次):
|
||||
// ① 覆盖行 → 用 RECURRENCE-ID(原始发生时刻)
|
||||
// ② 调用方显式传入了"用户点的是哪一次" → 用它(列表里点开的某次发生,行的 id 是主事件 id,
|
||||
// 若不传,就会把"主事件的第一次发生"当成目标 → 表现出来就是"选仅这一次却改了全部")
|
||||
// ③ 兜底才用行本身的开始时间
|
||||
const occFromCaller: number | undefined = AppStorage.get<number>('pendingOccurrenceTs');
|
||||
if (loaded.recurrenceId > 0) {
|
||||
this.occStartMs = loaded.recurrenceId;
|
||||
} else if (occFromCaller !== undefined && occFromCaller > 0) {
|
||||
this.occStartMs = occFromCaller;
|
||||
this.occProvided = true;
|
||||
} else {
|
||||
this.occStartMs = loaded.startTime;
|
||||
}
|
||||
// ⭐ 用户点开的是"某一次发生"时,编辑页必须显示**那一次**的实际时间,
|
||||
// 而不是重复主事件第一次发生的时间(否则时间框显示错误,用户改时间也改错基准)。
|
||||
if (this.occProvided && this.occStartMs > 0) {
|
||||
const dur: number = Math.max(0, loaded.endTime - loaded.startTime);
|
||||
this.startMs = this.occStartMs;
|
||||
this.endMs = this.allDay ? this.occStartMs + 86399999 : this.occStartMs + dur;
|
||||
}
|
||||
// 消费后立即清掉,避免影响下一次新建/编辑
|
||||
AppStorage.setOrCreate<number>('pendingOccurrenceTs', 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -242,6 +285,106 @@ struct EventEditPage {
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
/** 从已有 RRULE 解析出"结束条件"(用于编辑时回显"重复到某天/共几次") */
|
||||
private parseRepeatEnd(rrule: string): void {
|
||||
if (rrule === '') {
|
||||
this.repeatEnd = 'never';
|
||||
return;
|
||||
}
|
||||
const u: string = rrule.toUpperCase();
|
||||
// COUNT=n
|
||||
const cm = /COUNT=(\d+)/.exec(u);
|
||||
if (cm !== null) {
|
||||
this.repeatEnd = 'count';
|
||||
this.repeatCount = Number(cm[1]) > 0 ? Number(cm[1]) : 10;
|
||||
return;
|
||||
}
|
||||
// UNTIL=yyyymmddThhmmssZ 或 yyyymmdd
|
||||
const um = /UNTIL=(\d{8})(T\d{6}Z?)?/.exec(u);
|
||||
if (um !== null) {
|
||||
this.repeatEnd = 'until';
|
||||
const y: number = Number(um[1].substring(0, 4));
|
||||
const mo: number = Number(um[1].substring(4, 6));
|
||||
const d: number = Number(um[1].substring(6, 8));
|
||||
this.repeatUntil = new Date(y, mo - 1, d).getTime();
|
||||
return;
|
||||
}
|
||||
this.repeatEnd = 'never';
|
||||
}
|
||||
|
||||
/** 把当前"重复模式 + 结束条件"合成 RRULE 字符串 */
|
||||
private buildRrule(): string {
|
||||
const base: string = this.rruleForMode(this.repeatMode);
|
||||
if (base === '' || this.repeatMode === 'custom') {
|
||||
return ''; // custom 由调用方保留原规则
|
||||
}
|
||||
if (this.repeatEnd === 'count') {
|
||||
return `${base};COUNT=${Math.max(1, Math.round(this.repeatCount))}`;
|
||||
}
|
||||
if (this.repeatEnd === 'until' && this.repeatUntil > 0) {
|
||||
// UNTIL 用"截止日当天 23:59:59"(本地时区),避免把当天那次排除掉
|
||||
const d = new Date(this.repeatUntil);
|
||||
const end: number = new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59).getTime();
|
||||
return `${base};UNTIL=${IcsUtil.fmtIcsTime(end, false)}`;
|
||||
}
|
||||
return base; // 永不结束
|
||||
}
|
||||
|
||||
/**
|
||||
* ⭐ 编辑既有重复日程时:用户在界面上调整了"结束条件"才覆盖原 RRULE;
|
||||
* 没动过就原样保留(防止把服务器上的复杂规则简单化掉)。
|
||||
*/
|
||||
private effectiveRrule(): string {
|
||||
if (this.repeatMode === 'custom') {
|
||||
return this.originalRrule;
|
||||
}
|
||||
const built: string = this.buildRrule();
|
||||
if (this.originalRrule === '') {
|
||||
return built;
|
||||
}
|
||||
// 原有规则的 FREQ 与当前模式一致、且用户没有显式改结束条件 → 保留原规则
|
||||
const freqOf = (m: string): string => this.modeFromRrule(m);
|
||||
if (freqOf(this.originalRrule) === this.repeatMode
|
||||
&& this.repeatEndMatches(this.originalRrule)) {
|
||||
return this.originalRrule;
|
||||
}
|
||||
return built;
|
||||
}
|
||||
|
||||
/** 原 RRULE 的结束条件是否与界面当前选择一致 */
|
||||
private repeatEndMatches(rrule: string): boolean {
|
||||
const u: string = rrule.toUpperCase();
|
||||
if (this.repeatEnd === 'never') {
|
||||
return !u.includes('COUNT=') && !u.includes('UNTIL=');
|
||||
}
|
||||
if (this.repeatEnd === 'count') {
|
||||
const m = /COUNT=(\d+)/.exec(u);
|
||||
return m !== null && Number(m[1]) === Math.round(this.repeatCount);
|
||||
}
|
||||
if (this.repeatEnd === 'until') {
|
||||
const m = /UNTIL=(\d{8})/.exec(u);
|
||||
if (m === null) {
|
||||
return false;
|
||||
}
|
||||
const y = Number(m[1].substring(0, 4));
|
||||
const mo = Number(m[1].substring(4, 6));
|
||||
const d = Number(m[1].substring(6, 8));
|
||||
const a = new Date(y, mo - 1, d);
|
||||
const b = new Date(this.repeatUntil);
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth()
|
||||
&& a.getDate() === b.getDate();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 打开"重复结束条件"日期选择弹层(复用唯一的底部弹层) */
|
||||
private openRepeatUntilPicker(): void {
|
||||
const base = this.repeatUntil > 0 ? new Date(this.repeatUntil) : new Date(this.startMs);
|
||||
this.pickDate = new Date(base.getFullYear(), base.getMonth(), base.getDate());
|
||||
this.sheetMode = 'repeatEnd';
|
||||
this.sheetShow = true;
|
||||
}
|
||||
|
||||
private reminderLabel(m: number): string {
|
||||
if (m === 0) {
|
||||
return '不提醒';
|
||||
@@ -336,13 +479,17 @@ struct EventEditPage {
|
||||
return src;
|
||||
}
|
||||
|
||||
/** 该次发生之前一次发生与之后一次发生的边界(用于 EXDATE / UNTIL / 拆分) */
|
||||
/** 该次发生之前一次发生与之后一次发生的边界(用于 EXDATE / UNTIL / 拆分)。
|
||||
* ⭐ 会用展开结果**校正 occStartMs**:调用方传来的时刻可能带秒/毫秒偏差
|
||||
* (列表里点开的那次发生时间是精确的,但主事件运行到卡片等路径可能有偏差),
|
||||
* 必须吸附到"真实存在的那一次",否则 EXDATE 写出的时刻匹配不上任何发生 →
|
||||
* 表现为"选了仅这一次,却把所有次都改了"。 */
|
||||
private async seriesBoundaries(context: common.Context): Promise<SeriesBounds> {
|
||||
const b = new SeriesBounds();
|
||||
const occ: number = this.occStartMs;
|
||||
let occ: number = this.occStartMs;
|
||||
b.thisOcc = occ;
|
||||
const master: LocalEvent | null = await this.loadMaster(context);
|
||||
if (master === null) {
|
||||
if (master === null || master.rrule === '') {
|
||||
b.beforeThis = occ - 1000;
|
||||
b.nextOcc = 0;
|
||||
return b;
|
||||
@@ -356,10 +503,30 @@ struct EventEditPage {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 展开窗口:主事件开始 ~ "该次"之后一年的发生,用于找"前后相邻"的两次
|
||||
const winEnd: number = occ + 366 * 86400000;
|
||||
// 展开窗口:主事件开始 ~ "该次"之后一年(覆盖主事件的首次发生)
|
||||
const winEnd: number = Math.max(occ, master.startTime) + 366 * 86400000;
|
||||
const occs: number[] = RruleUtil.expand(master.rrule, master.startTime,
|
||||
master.startTime, winEnd, exNums, 3000);
|
||||
// ⭐ 吸附:找与 occ 最接近的那一次真实发生(容差 12 小时,足以跨时区/跨日误差)
|
||||
let snapped: number = 0;
|
||||
let bestDiff: number = -1;
|
||||
for (const o of occs) {
|
||||
const d: number = Math.abs(o - occ);
|
||||
if (bestDiff < 0 || d < bestDiff) {
|
||||
bestDiff = d;
|
||||
snapped = o;
|
||||
}
|
||||
}
|
||||
if (snapped > 0 && bestDiff <= 12 * 3600000) {
|
||||
occ = snapped;
|
||||
b.thisOcc = snapped;
|
||||
this.occStartMs = snapped; // 回写,保证后续 EXDATE / 覆盖行用同一时刻
|
||||
} else if (!this.occProvided && master.startTime > 0) {
|
||||
// 没有可信的"点选时刻"时,退回主事件的首次发生(至少是真实存在的一次)
|
||||
occ = master.startTime;
|
||||
b.thisOcc = occ;
|
||||
this.occStartMs = occ;
|
||||
}
|
||||
let prev: number = 0;
|
||||
let next: number = 0;
|
||||
for (const o of occs) {
|
||||
@@ -390,6 +557,10 @@ struct EventEditPage {
|
||||
// ⚠️ 用**原始快照**判据:doSave 已把 e(= this.event)的 calKey/href/startTime 改成新值,
|
||||
// 直接用 this.event 会把"改后的日期/日历本"当成原系列,导致 EXDATE / UNTIL 算错。
|
||||
const cur: LocalEvent | null = this.original;
|
||||
// 用户点开的是"某一次发生"(occProvided=true)时,list/周视图里的行 id 是**主事件 id**,
|
||||
// 所以"改全部"也必须给用户明确的确认——但语义上就是改整条系列:
|
||||
// · 点开的是主事件行本身(occProvided=false)→ 直接改这一行就是改整个系列,无需特殊处理
|
||||
// · 点开的是某一次发生 且选了"全部" → 把改动写到主事件行上(整系列平移/改名)
|
||||
if (isNew || cur === null || !this.isSeriesOccurrence()) {
|
||||
if (isNew) {
|
||||
await EventDb.insertLocal(context, target);
|
||||
@@ -400,8 +571,24 @@ struct EventEditPage {
|
||||
}
|
||||
const bounds: SeriesBounds = await this.seriesBoundaries(context);
|
||||
if (scope === 'all') {
|
||||
// 全部:直接更新当前行。若当前行是某次覆盖实例 → 保留其 recurrenceId 改内容;
|
||||
// 若当前行是主事件 → 更新主事件(DTSTART 若被改 → 整系列平移,符合"修改全部"直觉)
|
||||
// 全部:改到**主事件行**上(整系列一起改)。
|
||||
// ⚠️ 不能直接 updateLocal(target):target 是"用户点开的那一行",
|
||||
// 当用户点的是某次发生、而该行其实是主事件行时没问题;
|
||||
// 但若这一行是覆盖实例(rrule=''),把整系列的属性写进覆盖行只会改到那一次。
|
||||
const masterForAll: LocalEvent | null = await this.loadMaster(context);
|
||||
if (masterForAll !== null && masterForAll.rrule !== '') {
|
||||
// 把编辑内容写到主事件行(保留主事件的 uid / rrule / exdate)
|
||||
masterForAll.title = target.title;
|
||||
masterForAll.description = target.description;
|
||||
masterForAll.location = target.location;
|
||||
masterForAll.startTime = target.startTime;
|
||||
masterForAll.endTime = target.endTime;
|
||||
masterForAll.isAllDay = target.isAllDay;
|
||||
masterForAll.reminders = target.reminders;
|
||||
masterForAll.reminder = target.reminder;
|
||||
await EventDb.updateLocal(context, masterForAll);
|
||||
return;
|
||||
}
|
||||
await EventDb.updateLocal(context, target);
|
||||
return;
|
||||
}
|
||||
@@ -417,17 +604,18 @@ struct EventEditPage {
|
||||
if (master === null) {
|
||||
return;
|
||||
}
|
||||
const exList: string[] = master.exdate === '' ? [] : master.exdate.split(';');
|
||||
// ⚠️ **顺序很重要**:先把主事件行还原为原值(用户在编辑页改的标题/时间只属于"这一次",
|
||||
// 不能污染主事件),**再**写 EXDATE。反过来会被还原操作覆盖掉 EXDATE
|
||||
// (快照里存的是旧的 exdate),导致排除失效 → 原时间残留一条、看起来"没删掉/改错了"。
|
||||
if (cur.rrule !== '' && master.id === cur.id) {
|
||||
await EventDb.restoreRowById(context, master.id, cur);
|
||||
}
|
||||
const exList: string[] = cur.exdate === '' ? [] : cur.exdate.split(';');
|
||||
const occIcs: string = IcsUtil.fmtIcsTime(bounds.thisOcc, master.isAllDay);
|
||||
if (!exList.includes(occIcs)) {
|
||||
exList.push(occIcs);
|
||||
}
|
||||
await EventDb.setExdate(context, master.id, exList.join(';'));
|
||||
// ⚠️ 主事件行本身的内容保持不变("仅这一次"只影响这一次);
|
||||
// 如果用户是在主事件行上改的,需要把主事件改回原值(target 已被写成新时间)
|
||||
if (cur.rrule !== '') {
|
||||
await EventDb.restoreRowById(context, master.id, cur);
|
||||
}
|
||||
// 覆盖行:uid 同主事件、DTSTART=改到的新时间、RECURRENCE-ID=原始发生时刻
|
||||
const ov = new LocalEvent();
|
||||
ov.uid = master.uid;
|
||||
@@ -455,6 +643,8 @@ struct EventEditPage {
|
||||
}
|
||||
// 主事件本身被改期 → "此次及以后"里包含主事件(第一次发生)本身,
|
||||
// 需要让新系列从"改到的时间"开始(否则原 DTSTART 会被原系列继续展开)。
|
||||
// ⚠️ 判据要用**原始主事件的开始时间**(cur.startTime 是"载入时的行时间",
|
||||
// 对主事件行而言就是它的第一次发生时刻)。
|
||||
const startedAtThis: boolean =
|
||||
(cur.rrule !== '' && Math.abs(cur.startTime - bounds.thisOcc) < 1000);
|
||||
// 1) 先把"此次及以后"的覆盖实例行搬到新 uid(主事件行 recurrenceId=0,不受影响)
|
||||
@@ -475,7 +665,8 @@ struct EventEditPage {
|
||||
newMaster.endTime = target.endTime;
|
||||
newMaster.isAllDay = target.isAllDay;
|
||||
newMaster.recurring = true;
|
||||
// 新的重复规则:沿用原系列(用户没显式改规则时),保持"此次及以后"的重复节奏
|
||||
// 新的重复规则:优先用界面上设置的(含"重复到某天 / 共几次"),
|
||||
// 否则沿用原系列的重复节奏,保持"此次及以后"不变
|
||||
newMaster.rrule = target.rrule !== '' ? target.rrule : master.rrule;
|
||||
newMaster.reminders = target.reminders;
|
||||
newMaster.reminder = target.reminder;
|
||||
@@ -486,13 +677,13 @@ struct EventEditPage {
|
||||
if (startedAtThis) {
|
||||
await EventDb.markDeleted(context, master.id);
|
||||
} else {
|
||||
const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis);
|
||||
await EventDb.setRruleForUid(context, master.calKey, master.uid, truncated);
|
||||
// ⚠️ 若用户编辑的正是"主事件这一行",target(= this.event)已被 doSave 写成新值,
|
||||
// 而它属于被截断的旧系列 → 必须还原为原值,否则旧系列末尾会多出一条被改过的幽灵行。
|
||||
if (cur.rrule !== '') {
|
||||
// ⚠️ 顺序:先还原主事件行(本次编辑只属于"此次及以后"的新系列,
|
||||
// 不能留在原主事件上),**再**截断 RRULE;反过来会被还原覆盖掉截断结果。
|
||||
if (cur.rrule !== '' && master.id === cur.id) {
|
||||
await EventDb.restoreRowById(context, master.id, cur);
|
||||
}
|
||||
const truncated: string = IcsUtil.rruleWithUntil(master.rrule, bounds.beforeThis);
|
||||
await EventDb.setRruleForUid(context, master.calKey, master.uid, truncated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -564,13 +755,8 @@ struct EventEditPage {
|
||||
// 一律写入所选 CalDAV 日历本(新建/编辑/从「本机」迁过来都一样)
|
||||
e.calKey = book.calKey;
|
||||
e.href = book.href;
|
||||
// 重复规则与提醒
|
||||
if (this.repeatMode === 'custom') {
|
||||
// 无法识别的既有规则原样保留
|
||||
e.rrule = this.event !== null ? this.event.rrule : '';
|
||||
} else {
|
||||
e.rrule = this.rruleForMode(this.repeatMode);
|
||||
}
|
||||
// 重复规则与提醒(含"重复到某天 / 共几次"的结束条件)
|
||||
e.rrule = this.effectiveRrule();
|
||||
e.recurring = e.rrule !== '';
|
||||
e.reminders = EventDb.normalizeReminders(this.reminderList);
|
||||
e.reminder = e.reminders.length > 0 ? e.reminders[0] : 0;
|
||||
@@ -845,6 +1031,71 @@ struct EventEditPage {
|
||||
}
|
||||
}
|
||||
.width('100%')
|
||||
// ⭐ 结束条件:永不结束 / 重复到某天 / 共重复几次
|
||||
// (新建与编辑都可用;编辑时会回显服务器上已有的 COUNT / UNTIL)
|
||||
if (this.repeatMode !== 'none' && this.repeatMode !== 'custom') {
|
||||
Divider().color($r('app.color.shadow_color'))
|
||||
Text('结束条件')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
|
||||
this.repeatEndChip('never', '永不结束')
|
||||
this.repeatEndChip('until', '重复到某天')
|
||||
this.repeatEndChip('count', '共几次')
|
||||
}
|
||||
.width('100%')
|
||||
if (this.repeatEnd === 'until') {
|
||||
Row({ space: 8 }) {
|
||||
Text('截止日期')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Text(this.repeatUntil > 0 ? this.fmtDate(this.repeatUntil) : '请选择')
|
||||
.fontSize(15)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.layoutWeight(1)
|
||||
Text('›')
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_hint'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 8, right: 8, top: 10, bottom: 10 })
|
||||
.onClick(() => {
|
||||
this.openRepeatUntilPicker();
|
||||
})
|
||||
}
|
||||
if (this.repeatEnd === 'count') {
|
||||
Row({ space: 8 }) {
|
||||
Text('重复次数')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
Blank()
|
||||
Text('−')
|
||||
.fontSize(20)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.padding({ left: 14, right: 14 })
|
||||
.onClick(() => {
|
||||
this.repeatCount = Math.max(1, Math.round(this.repeatCount) - 1);
|
||||
})
|
||||
Text(String(Math.round(this.repeatCount)))
|
||||
.fontSize(16)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width(44)
|
||||
.textAlign(TextAlign.Center)
|
||||
Text('+')
|
||||
.fontSize(18)
|
||||
.fontColor($r('app.color.brand'))
|
||||
.padding({ left: 14, right: 14 })
|
||||
.onClick(() => {
|
||||
this.repeatCount = Math.min(999, Math.round(this.repeatCount) + 1);
|
||||
})
|
||||
Text('次')
|
||||
.fontSize(14)
|
||||
.fontColor($r('app.color.text_secondary'))
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
|
||||
}
|
||||
}
|
||||
}
|
||||
.alignItems(HorizontalAlign.Start)
|
||||
.width('100%')
|
||||
@@ -1002,23 +1253,69 @@ struct EventEditPage {
|
||||
// 表现为"时间选择框白框一闪而过"(范围弹层顶掉了它)。
|
||||
// 这里用 sheetMode 在同一个弹层里切换内容。
|
||||
.bindSheet($$this.sheetShow, this.bottomSheet(), {
|
||||
height: this.sheetMode === 'scope' ? 320 : 380,
|
||||
height: this.sheetMode === 'scope' ? 320 : (this.sheetMode === 'repeatEnd' ? 340 : 380),
|
||||
showClose: false,
|
||||
dragBar: true,
|
||||
backgroundColor: $r('app.color.card_bg')
|
||||
})
|
||||
}
|
||||
|
||||
/** ⭐ 唯一的底部弹层:按 sheetMode 切换显示"时间选择"或"重复范围选择" */
|
||||
/** ⭐ 唯一的底部弹层:按 sheetMode 切换显示"时间选择"/"重复范围选择"/"重复截止日期" */
|
||||
@Builder
|
||||
bottomSheet() {
|
||||
if (this.sheetMode === 'scope') {
|
||||
this.scopeSheet()
|
||||
} else if (this.sheetMode === 'repeatEnd') {
|
||||
this.repeatEndSheet()
|
||||
} else {
|
||||
this.dateTimePickerSheet()
|
||||
}
|
||||
}
|
||||
|
||||
/** 重复"截止日期"选择弹层(只选日期) */
|
||||
@Builder
|
||||
repeatEndSheet() {
|
||||
Column({ space: 14 }) {
|
||||
Text('重复到哪一天')
|
||||
.fontSize(16)
|
||||
.fontWeight(FontWeight.Bold)
|
||||
.fontColor($r('app.color.text_primary'))
|
||||
.width('100%')
|
||||
.textAlign(TextAlign.Center)
|
||||
.padding({ top: 12 })
|
||||
DatePicker({
|
||||
start: new Date(2000, 0, 1),
|
||||
end: new Date(2060, 11, 31),
|
||||
selected: this.pickDate
|
||||
})
|
||||
.onDateChange((value: Date) => {
|
||||
this.pickDate = value;
|
||||
})
|
||||
.textStyle({ color: $r('app.color.text_secondary'), font: { size: 14 } })
|
||||
.selectedTextStyle({
|
||||
color: $r('app.color.text_primary'),
|
||||
font: { size: 16, weight: FontWeight.Medium }
|
||||
})
|
||||
.disappearTextStyle({ color: $r('app.color.text_hint'), font: { size: 14 } })
|
||||
.layoutWeight(1)
|
||||
.height(210)
|
||||
.width('100%')
|
||||
Button('确定')
|
||||
.width('100%')
|
||||
.height(44)
|
||||
.borderRadius(12)
|
||||
.fontSize(15)
|
||||
.backgroundColor($r('app.color.brand'))
|
||||
.onClick(() => {
|
||||
const d = this.pickDate;
|
||||
this.repeatUntil = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
this.sheetShow = false;
|
||||
})
|
||||
}
|
||||
.width('100%')
|
||||
.padding({ left: 16, right: 16, bottom: 24 })
|
||||
}
|
||||
|
||||
/** ⭐ 重复日程"作用范围"选择弹层:保存/删除前让用户明确改哪几次 */
|
||||
@Builder
|
||||
scopeSheet() {
|
||||
@@ -1135,6 +1432,31 @@ struct EventEditPage {
|
||||
.backgroundColor(this.repeatMode === mode ? $r('app.color.brand') : $r('app.color.chip_off_bg'))
|
||||
.onClick(() => {
|
||||
this.repeatMode = mode;
|
||||
// 切换到"重复"时给个合理默认:永不结束(用户可按需改为到某天/共几次)
|
||||
if (mode !== 'none' && this.repeatEnd === '') {
|
||||
this.repeatEnd = 'never';
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 结束条件 chip:永不结束 / 重复到某天 / 共几次 */
|
||||
@Builder
|
||||
repeatEndChip(mode: string, label: string) {
|
||||
Text(label)
|
||||
.fontSize(12)
|
||||
.fontColor(this.repeatEnd === mode
|
||||
? $r('app.color.button_text') : $r('app.color.text_primary'))
|
||||
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||
.borderRadius(14)
|
||||
.margin({ right: 8, bottom: 8 })
|
||||
.backgroundColor(this.repeatEnd === mode ? $r('app.color.brand') : $r('app.color.chip_off_bg'))
|
||||
.onClick(() => {
|
||||
this.repeatEnd = mode;
|
||||
if (mode === 'until' && this.repeatUntil <= 0) {
|
||||
// 默认给"从开始时间起 3 个月后"
|
||||
const s = new Date(this.startMs);
|
||||
this.repeatUntil = new Date(s.getFullYear(), s.getMonth() + 3, s.getDate()).getTime();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1030,9 +1030,13 @@ struct Index {
|
||||
.borderRadius(12)
|
||||
.onClick(() => {
|
||||
const id: number = this.detailEvent !== null ? this.detailEvent.id : 0;
|
||||
// ⭐ 把"用户点的是哪一次发生"一起带过去:重复日程展开后所有次共用主事件的 id,
|
||||
// 不带发生时刻的话,编辑页无法知道用户点的是哪一次 → "仅这一次"会算错。
|
||||
const occ: number = this.detailEvent !== null ? this.detailEvent.startTime : 0;
|
||||
this.detailShow = false;
|
||||
if (id > 0) {
|
||||
AppStorage.setOrCreate<number>('pendingEventId', id);
|
||||
AppStorage.setOrCreate<number>('pendingOccurrenceTs', occ);
|
||||
router.pushUrl({ url: 'pages/EventEditPage' });
|
||||
}
|
||||
})
|
||||
|
||||
@@ -566,23 +566,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}` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1197,15 +1191,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'))
|
||||
}
|
||||
|
||||
@@ -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