增强了安全配置。

Signed-off-by: Yang Yongquan <i@yangyq.net>
This commit is contained in:
2026-09-15 12:51:58 +08:00
parent 088f7b3773
commit 5eaeeb0f4c
19 changed files with 1062 additions and 267 deletions
+4 -1
View File
@@ -36,4 +36,7 @@ password.txt
# 注意 /hvigor/hvigor-config.json5 是项目构建配置,仍需入库
/hvigor/*
!/hvigor/hvigor-config.json5
/hvigor-ohos-plugin/
/hvigor-ohos-plugin/
# 项目文档(本地维护,不入公共仓库)
/docs/
+349 -23
View File
@@ -1,11 +1,17 @@
// entry/src/main/ets/common/AccountStore.ets
// DAV 账号持久化(加密):
// - 账号列表(含密码)序列化为 JSON 后用 AES-256-GCM 整体加密,
// 存储为 caldav_vault 偏好中的 data_b64iv:密文+tag);
// - AES 密钥首次随机生成后存于同一偏好(key_b64);
// - 旧版明文存储(caldav_account 的 acc_0..n)在首次 loadAll 时自动迁移并清除
// DAV 账号持久化(加密):
// - 账号列表(含密码)序列化为 JSON 后整体加密,存储为 caldav_vault 偏好中的 data_b64。
// - **首选 HUKS 硬件密钥库**AES-256-GCM 密钥在 TEE 内生成、**不可导出、不落盘**(只留 keyAlias),
// 密文格式 `huks1:<b64(nonce)>:<b64(密文+tag)>`。这样"整机备份被导出"也解不开 —— 密钥不在备份里。
// 写入前做**自检**:HUKS 密文必须能自解回原文才允许落盘,否则本进程直接回落软件 AES
// - **回落**:个别设备 HUKS 不可用(初始化/权限异常/加解密不一致)时退回"AES 密钥存偏好"的软件方案
// (`aes1:` 前缀),宁可安全性降级也不能让"账号存不上"。探测结果在进程内缓存,避免每轮同步重复试错。
// ⚠️ 清密钥(purgeSoftKey**必须确认密文已是 `huks1:`**,否则会把仍依赖软件密钥的 `aes1:` 密文解坏。
// - **旧数据迁移**:更早的无前缀软件 AES 密文、旧版明文存储(caldav_account 的 acc_0..n
// 在首次 loadAll 时读出并就地升级为 HUKS 密文,随后清除偏好中的明文密钥与旧存储。
import { preferences } from '@kit.ArkData';
import { cryptoFramework } from '@kit.CryptoArchitectureKit';
import { huks } from '@kit.UniversalKeystoreKit';
import { util } from '@kit.ArkTS';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
@@ -24,9 +30,16 @@ const LEGACY_COUNT_KEY: string = 'accountCount';
/** 加密存储 */
const VAULT_STORE: string = 'caldav_vault';
/** 旧版"软件 AES 密钥"(明文存偏好);升级到 HUKS 后会被清除 */
const VAULT_KEY: string = 'key_b64';
const VAULT_DATA: string = 'data_b64';
/** HUKS 密钥别名:固定不变;密钥本体在 TEE 内,应用永远拿不到明文 */
const HUKS_ALIAS: string = 'synccalendar_vault_aes256_gcm';
/** 密文前缀:区分 HUKS 密文 / 软件 AES 密文(无前缀 = 更早的旧格式,按软件 AES 解) */
const PREFIX_HUKS: string = 'huks1:';
const PREFIX_AES: string = 'aes1:';
/**
* DAV 账号(@Observed 使同步状态变化能刷新列表 UI)
*/
@@ -84,28 +97,214 @@ function bytesToStr(u: Uint8Array): string {
return new util.TextDecoder('utf-8').decodeToString(u);
}
/** 账号加密存储实现(AES-256-GCM,密钥持久化) */
/**
* 账号加密存储实现。
*
* **首选 HUKS(硬件密钥库)**AES-256-GCM 密钥在 TEE 内生成、**不可导出、不落盘**,
* 应用侧只持有 keyAlias。因此「整机备份被导出」到其他设备也无法解密(密钥不在备份中、且与设备绑定)。
*
* **回落**:HUKS 初始化/调用异常时退回软件 AES(密钥存偏好,`aes1:` 前缀)——
* 本环境无法真机验证 HUKS,留此安全网可确保"最坏情况 = 与改造前一致",绝不出现存不上账号。
* 探测结果缓存在 `huksReady`,避免每轮同步反复试错。
*
* 密文自描述(前缀区分算法),便于日后识别与再迁移:
* `huks1:<b64(nonce)>:<b64(密文+tag)>` | `aes1:<b64(iv)>:<b64(密文+tag)>` | `<b64(iv)>:<b64(...)>`(更早旧格式)
*/
class Vault {
private static keyB64: string = ''; // 进程内缓存,避免每轮同步重复读偏好
/** 软件方案:进程内缓存,避免每轮同步重复读偏好 */
private static keyB64: string = '';
/** HUKS 可用性:null=未探测;true=可用;false=已判定不可用(本进程不再重试) */
private static huksReady: boolean | null = null;
static async prefs(context: common.Context): Promise<preferences.Preferences> {
return preferences.getPreferences(context, VAULT_STORE);
}
/** 获取(或首次生成)AES-256 密钥 */
private static async aesKey(context: common.Context): Promise<cryptoFramework.SymKey> {
// ==================== HUKS(首选路径) ====================
/** 生成密钥所用属性(AES-256-GCM,同时具备加密与解密用途) */
private static genProperties(): huks.HuksParam[] {
return [
{ tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_AES },
{ tag: huks.HuksTag.HUKS_TAG_PURPOSE,
value: huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_ENCRYPT | huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_DECRYPT },
{ tag: huks.HuksTag.HUKS_TAG_KEY_SIZE, value: huks.HuksKeySize.HUKS_AES_KEY_SIZE_256 },
{ tag: huks.HuksTag.HUKS_TAG_BLOCK_MODE, value: huks.HuksCipherMode.HUKS_MODE_GCM },
{ tag: huks.HuksTag.HUKS_TAG_PADDING, value: huks.HuksKeyPadding.HUKS_PADDING_NONE },
{ tag: huks.HuksTag.HUKS_TAG_KEY_ALIAS, value: strToBytes(HUKS_ALIAS) },
{ tag: huks.HuksTag.HUKS_TAG_IS_KEY_ALIAS, value: true }
];
}
/** 单次加/解密操作属性(NONCE 由调用方追加;GCM 无需 digest/padding 之外参数) */
private static opProperties(purpose: number): huks.HuksParam[] {
return [
{ tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_AES },
{ tag: huks.HuksTag.HUKS_TAG_PURPOSE, value: purpose },
{ tag: huks.HuksTag.HUKS_TAG_KEY_SIZE, value: huks.HuksKeySize.HUKS_AES_KEY_SIZE_256 },
{ tag: huks.HuksTag.HUKS_TAG_BLOCK_MODE, value: huks.HuksCipherMode.HUKS_MODE_GCM },
{ tag: huks.HuksTag.HUKS_TAG_PADDING, value: huks.HuksKeyPadding.HUKS_PADDING_NONE }
];
}
/**
* 确保 HUKS 密钥存在(不存在则生成)。返回当前是否可用。
* 任何异常都视为"HUKS 不可用"并缓存,后续直接走软件回落,不再反复抛错。
*/
private static async ensureHuksKey(): Promise<boolean> {
if (Vault.huksReady === false) {
return false;
}
try {
const exists: boolean = await huks.isKeyItemExist(HUKS_ALIAS, { properties: Vault.genProperties() });
if (exists) {
Vault.huksReady = true;
return true;
}
try {
await huks.generateKeyItem(HUKS_ALIAS, { properties: Vault.genProperties() });
console.info('[Vault] 已在 HUKS 中生成账号库密钥');
} catch (genErr) {
// 生成失败也可能只是"密钥其实已存在"isKeyItemExist 属性校验差异 / 并发):
// 再确认一次,确认存在就视为可用 —— 否则会误判为"不可用"而静默降级成软件加密。
const ge = genErr as BusinessError;
const again: boolean = await huks.isKeyItemExist(HUKS_ALIAS, { properties: Vault.genProperties() });
if (!again) {
throw new Error(`HUKS 生成密钥失败(code=${ge.code}): ${ge.message}`);
}
console.info('[Vault] HUKS 密钥已存在,直接使用');
}
Vault.huksReady = true;
return true;
} catch (err) {
const e = err as BusinessError;
console.error(`[Vault] HUKS 不可用(code=${e.code}),本次回落软件加密: ${e.message}`);
Vault.huksReady = false;
return false;
}
}
/** GCM 附加认证数据(固定常量):把密文绑定到"账号库"这一用途,防密文被挪作他用 */
private static aad(): Uint8Array {
return strToBytes('synccalendar-vault-v1');
}
/**
* HUKS 加密(initSession → finishSession 一次性完成)。
* 本 SDK **没有** `huks.encryptItem` 一次性接口,必须走会话式 API。
* 返回 `b64(nonce):b64(密文+GCM tag)`(不含前缀)——nonce 在加密时显式指定,
* 故返回数据为「密文 + AEAD(16B)」,nonce 由我们单独保存。
*/
private static async huksEncrypt(plain: string): Promise<string> {
const nonce: Uint8Array = cryptoFramework.createRandom().generateRandomSync(12).data;
const props: huks.HuksParam[] = Vault.opProperties(huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_ENCRYPT);
props.push({ tag: huks.HuksTag.HUKS_TAG_NONCE, value: nonce });
props.push({ tag: huks.HuksTag.HUKS_TAG_ASSOCIATED_DATA, value: Vault.aad() });
const session: huks.HuksSessionHandle = await huks.initSession(HUKS_ALIAS, { properties: props });
const result: huks.HuksReturnResult =
await huks.finishSession(session.handle, { properties: props, inData: strToBytes(plain) });
const buf: Uint8Array | undefined = result.outData;
if (buf === undefined || buf.length === 0) {
throw new Error('HUKS 加密返回空数据');
}
return `${bytesToB64(nonce)}:${bytesToB64(buf)}`;
}
/** HUKS 解密(nonce 与密文分开传递);失败抛异常由上层统一兜底 */
private static async huksDecrypt(nonceB64: string, cipherB64: string): Promise<string | null> {
const props: huks.HuksParam[] = Vault.opProperties(huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_DECRYPT);
props.push({ tag: huks.HuksTag.HUKS_TAG_NONCE, value: b64ToBytes(nonceB64) });
props.push({ tag: huks.HuksTag.HUKS_TAG_ASSOCIATED_DATA, value: Vault.aad() });
const session: huks.HuksSessionHandle = await huks.initSession(HUKS_ALIAS, { properties: props });
const result: huks.HuksReturnResult =
await huks.finishSession(session.handle, { properties: props, inData: b64ToBytes(cipherB64) });
const buf: Uint8Array | undefined = result.outData;
return buf === undefined ? null : bytesToStr(buf);
}
/**
* 清除偏好里的旧"软件 AES 密钥"(密文已升级为 HUKS 后调用)。
* 目的:不再让可解密的密钥留在偏好(也就不会随备份外泄)。
*/
static async purgeSoftKey(context: common.Context): Promise<void> {
try {
const store: preferences.Preferences = await Vault.prefs(context);
const had: string = await store.get(VAULT_KEY, '') as string;
if (had === '') {
return;
}
store.delete(VAULT_KEY);
await store.flush();
Vault.keyB64 = '';
console.info('[Vault] 已清除偏好中的旧 AES 密钥(密文已由 HUKS 保护)');
} catch (err) {
// 清理失败不影响功能,下次启动再试
}
}
/**
* 【逃生舱 · 内部实现】清空账号库:删除偏好中的账号密文与软件密钥,并尽力删除 HUKS 密钥。
*
* 用途:账号库**存在但解不开**时(例如密钥丢失、HUKS 密钥失效),`canReadExisting()` 会拒绝
* 任何写入 —— 用户既看不到账号,也加不了新账号,只能"清除整个应用数据"才能恢复。
* 本方法提供一条更窄的出路:**只清账号,不动设置与日程**。
*
* ⚠️ 仅供 `AccountStore.resetVault()` 在**用户显式二次确认后**调用,**绝不自动触发**
* (自动清理会破坏 `canReadExisting` 的防覆盖保护)。
*/
static async clearVault(context: common.Context): Promise<void> {
const store: preferences.Preferences = await Vault.prefs(context);
store.delete(VAULT_DATA);
store.delete(VAULT_KEY);
await store.flush();
Vault.keyB64 = '';
try {
await huks.deleteKeyItem(HUKS_ALIAS, { properties: Vault.genProperties() });
console.info('[Vault] 已删除 HUKS 账号库密钥');
} catch (err) {
// 密钥不存在或删除失败都不影响"重置"的目的(密文已删,重新添加时会重新生成)
const e = err as BusinessError;
console.error(`[Vault] 删除 HUKS 密钥失败(code=${e.code}): ${e.message}`);
}
Vault.huksReady = null; // 让后续重新探测
console.info('[Vault] 账号库已清空');
}
// ==================== 软件 AES(回落路径) ====================
/**
* 读取**已存在**的软件 AES 密钥;不存在返回 null。
*
* ⚠️ 解密路径必须用这个:早期 `softKey()` 在解密时若发现密钥缺失,会"顺手生成一把新密钥",
* 于是旧密文被一把错误的新密钥去解 → 表现为"账号突然全没了,而且再也加不上"。
*/
private static async softKeyExisting(context: common.Context): Promise<cryptoFramework.SymKey | null> {
if (Vault.keyB64 === '') {
const store: preferences.Preferences = await Vault.prefs(context);
Vault.keyB64 = await store.get(VAULT_KEY, '') as string;
}
if (Vault.keyB64 === '') {
return null;
}
return cryptoFramework.createSymKeyGenerator('AES256').convertKey({ data: b64ToBytes(Vault.keyB64) });
}
/**
* 获取(或首次生成)软件 AES-256 密钥;进程内缓存 keyB64。
* **仅加密路径可调用**(只有加密时才允许"没有就新建一把")。
*
* ⚠️ 仅作为 HUKS 不可用时的回落:此方案下密钥与密文同存 caldav_vault
* 对"备份被导出"无防护(`backup_config.json` 已把 preferences 排除出备份以缓解)。
*/
private static async softKey(context: common.Context): Promise<cryptoFramework.SymKey> {
const existing: cryptoFramework.SymKey | null = await Vault.softKeyExisting(context);
if (existing !== null) {
return existing;
}
const gen: cryptoFramework.SymKeyGenerator =
cryptoFramework.createSymKeyGenerator('AES256');
if (Vault.keyB64 !== '') {
return gen.convertKey({ data: b64ToBytes(Vault.keyB64) });
}
const key: cryptoFramework.SymKey = await gen.generateSymKey();
const raw: cryptoFramework.DataBlob = key.getEncoded();
Vault.keyB64 = bytesToB64(raw.data);
Vault.keyB64 = bytesToB64(key.getEncoded().data);
const store: preferences.Preferences = await Vault.prefs(context);
await store.put(VAULT_KEY, Vault.keyB64);
await store.flush();
@@ -121,9 +320,9 @@ class Vault {
};
}
/** 加密:返回 base64(iv) + ':' + base64(密文+authTag) */
static async encrypt(context: common.Context, plain: string): Promise<string> {
const key: cryptoFramework.SymKey = await Vault.aesKey(context);
/** 软件 AES 加密:返回 `b64(iv):b64(密文+tag)`(不含前缀) */
private static async softEncrypt(context: common.Context, plain: string): Promise<string> {
const key: cryptoFramework.SymKey = await Vault.softKey(context);
const iv: Uint8Array =
cryptoFramework.createRandom().generateRandomSync(12).data;
const cipher: cryptoFramework.Cipher =
@@ -135,20 +334,24 @@ class Vault {
return `${bytesToB64(iv)}:${bytesToB64(out.data)}`;
}
/** 解密:失败返回 null(调用方按"无账号"或迁移路径处理) */
static async decrypt(context: common.Context, stored: string): Promise<string | null> {
const sep: number = stored.indexOf(':');
/** 软件 AES 解密(body = `b64(iv):b64(密文+tag)`);格式不符返回 null */
private static async softDecrypt(context: common.Context, body: string): Promise<string | null> {
const sep: number = body.indexOf(':');
if (sep <= 0) {
return null;
}
const iv: Uint8Array = b64ToBytes(stored.substring(0, sep));
const blob: Uint8Array = b64ToBytes(stored.substring(sep + 1));
const iv: Uint8Array = b64ToBytes(body.substring(0, sep));
const blob: Uint8Array = b64ToBytes(body.substring(sep + 1));
if (blob.length <= 16) {
return null;
}
const tag: Uint8Array = blob.slice(blob.length - 16);
const ct: Uint8Array = blob.slice(0, blob.length - 16);
const key: cryptoFramework.SymKey = await Vault.aesKey(context);
const key: cryptoFramework.SymKey | null = await Vault.softKeyExisting(context);
if (key === null) {
console.error('[Vault] 软件 AES 密钥缺失,无法解密(不重新生成,避免把可用密文彻底解坏)');
return null;
}
const cipher: cryptoFramework.Cipher =
cryptoFramework.createCipher('AES256|GCM|NoPadding');
await cipher.init(cryptoFramework.CryptoMode.DECRYPT_MODE, key,
@@ -156,6 +359,61 @@ class Vault {
const out: cryptoFramework.DataBlob = await cipher.doFinal({ data: ct });
return bytesToStr(out.data);
}
// ==================== 对外统一入口 ====================
/**
* 加密:优先 HUKS,失败回落软件 AES;返回带前缀的自描述密文。
*
* ⚠️ **落盘前自检**:HUKS 写出的密文必须先能自己解回来,才允许以 `huks1:` 写到偏好。
* 否则某些设备上 HUKS 行为不一致(能加密却解不开),会把"读不回来的密文"写进账号库 ——
* 下次启动即表现为"账号全部消失且再也加不上"。自检不通过 → 本进程永久回落软件 AES。
*/
static async encrypt(context: common.Context, plain: string): Promise<string> {
if (await Vault.ensureHuksKey()) {
try {
const body: string = await Vault.huksEncrypt(plain);
const sep: number = body.indexOf(':');
const back: string | null =
await Vault.huksDecrypt(body.substring(0, sep), body.substring(sep + 1));
if (back === plain) {
return PREFIX_HUKS + body;
}
console.error('[Vault] HUKS 自检失败(加密后解不回原文),本进程回落软件加密');
Vault.huksReady = false;
} catch (err) {
const e = err as BusinessError;
console.error(`[Vault] HUKS 加密失败(code=${e.code}),回落软件加密: ${e.message}`);
Vault.huksReady = false;
}
}
return PREFIX_AES + await Vault.softEncrypt(context, plain);
}
/**
* 解密:按前缀分派(`huks1:` / `aes1:` / 无前缀旧格式)。
* **任何失败都返回 null**(调用方按"无账号 / 迁移路径"处理,绝不静默清空账号)。
*/
static async decrypt(context: common.Context, stored: string): Promise<string | null> {
try {
if (stored.startsWith(PREFIX_HUKS)) {
const parts: string[] = stored.substring(PREFIX_HUKS.length).split(':');
if (parts.length !== 2 || !(await Vault.ensureHuksKey())) {
return null;
}
return await Vault.huksDecrypt(parts[0], parts[1]);
}
if (stored.startsWith(PREFIX_AES)) {
return await Vault.softDecrypt(context, stored.substring(PREFIX_AES.length));
}
// 更早的旧格式:无前缀软件 AES 密文
return await Vault.softDecrypt(context, stored);
} catch (err) {
const e = err as BusinessError;
console.error(`[Vault] 解密失败: ${e.message}`);
return null;
}
}
}
/**
@@ -299,6 +557,25 @@ export class AccountStore {
result.push(a);
}
console.info(`[AccountStore] loadAll: ${result.length} 个账号(加密存储)`);
// 旧格式密文(软件 AES / 更早的无前缀格式):HUKS 可用时就地升级为 huks1: 密文,
// 随后清除偏好里的明文密钥 —— 让已有账号也获得"密钥不落盘"的保护。
// ⚠️ 清密钥必须**以"回读到的密文确实是 huks1:"为前提**
// 若 HUKS 不可用,saveAll 写回的仍然是 aes1: 密文,此时清掉 key_b64
// 会把刚写的密文连同旧账号一起变成永久解不开(账号凭空消失 + 再也存不上)。
if (!data.startsWith(PREFIX_HUKS)) {
try {
await AccountStore.saveAll(context, result, true);
const now: string = await store.get(VAULT_DATA, '') as string;
if (now.startsWith(PREFIX_HUKS)) {
await Vault.purgeSoftKey(context);
console.info('[AccountStore] 账号密文已升级为 HUKS 保护');
} else {
console.info('[AccountStore] HUKS 不可用,维持软件 AES 加密(保留密钥,不清除)');
}
} catch (err) {
// 升级失败不阻断本次读取,下次启动再试
}
}
return result;
}
console.error('[AccountStore] 加密账号数据解密失败');
@@ -334,6 +611,9 @@ export class AccountStore {
const plain: string | null = await Vault.decrypt(context, data);
if (plain !== null) {
existing = (JSON.parse(plain) as Array<Record<string, Object>>).length;
} else {
// 有密文但解不开:按"存在数据"处理,宁可拦住空写,也不把账号整体清掉
existing = 1;
}
}
} catch (err) {
@@ -352,9 +632,55 @@ export class AccountStore {
console.info(`[AccountStore] saveAll: ${accounts.length} 个账号已加密写入`);
}
/**
* 追加一个账号(读全量 → push → 整体加密回写)。
*
* 防覆盖保护:回写前先探测"现有存储是否可读"canReadExisting)。
* 若存储里有密文却解不开(例如 key_b64 丢失或被重新生成),loadAll 会回落成空数组,
* 此时 push 一个账号后长度 = 1,会绕过 saveAll 的"空列表覆盖保护"→ 其余账号连同密码全部丢失。
* 因此遇到该情况直接抛错中止:宁可让用户看到失败提示,也不能静默清空账号。
*/
static async addAccount(context: common.Context, acc: DavAccount): Promise<void> {
if (!(await AccountStore.canReadExisting(context))) {
console.error('[AccountStore] 现有账号存储不可读,已中止 addAccount 以防覆盖');
throw new Error('读取已有账号失败,为避免覆盖已保存的账号,已中止本次添加');
}
const list: DavAccount[] = await AccountStore.loadAll(context);
list.push(acc);
await AccountStore.saveAll(context, list);
}
/**
* 【逃生舱】重置账号库:清空本地保存的全部账号、服务器地址与密码。
*
* 场景:账号库存在但**解不开**(密钥丢失 / HUKS 密钥失效等),此时 `canReadExisting()`
* 会拒绝任何写入 —— 表现为"账号消失且再也加不上",而唯一的原生出路是清除**整个**应用数据
* (会一并丢掉设置偏好、功能引导标记、本地日程库)。本方法只清账号那一块。
*
* ⚠️ **必须由用户在界面上二次确认后调用**;日程数据在 CalDAV 服务器上,重新添加账号
* 并同步即可恢复,但本地保存的密码会被删除且不可撤销。
*/
static async resetVault(context: common.Context): Promise<void> {
await Vault.clearVault(context);
console.info('[AccountStore] 账号库已重置,可重新添加账号');
}
/**
* 探测"现有账号存储是否可读",供写前加固使用。
* - 无密文(全新安装 / 已清空)→ true:可以安全写入
* - 有密文且能解密 → true
* - 有密文但解密失败 / 读取异常 → false:**绝不能回写**(否则会整体覆盖、丢失全部账号)
*/
private static async canReadExisting(context: common.Context): Promise<boolean> {
try {
const store: preferences.Preferences = await Vault.prefs(context);
const data: string = await store.get(VAULT_DATA, '') as string;
if (data === '') {
return true;
}
return (await Vault.decrypt(context, data)) !== null;
} catch (err) {
return false;
}
}
}
@@ -16,7 +16,6 @@ export class CardItem {
endTime: string = ''; // 结束时间 '10:00'(全天事件为空)
date: string = ''; // '9月15日'
showDate: boolean = false; // 是否为当天分组的第一条(卡片上渲染日期头)
calName: string = ''; // 所属日历本名(右侧显示,颜色同日历色)
color: string = '#007DFF';
// 当前时间红线:start/end 为日程实际起止毫秒(用于定位"现在"位置);
// showNowLine 表示该日程上方应绘制红线,isNow 表示该日程正在进行中
@@ -252,7 +251,6 @@ export class CardDataService {
}
item.date = dateLabel;
item.showDate = i === 0; // 当天分组第一条 → 卡片上显示日期头
item.calName = e.calName;
item.color = e.color;
// 红线定位:记录实际起止毫秒(用于卡片渲染时判断"现在"位置)
item.startMs = e.startTime;
@@ -316,7 +314,6 @@ export class CardDataService {
oi.title = e.title === '' ? '(无标题)' : e.title;
oi.time = dayLong ? '全天' : `${p(ds.getHours())}:${p(ds.getMinutes())}`;
oi.endTime = dayLong ? '' : `${p(de.getHours())}:${p(de.getMinutes())}`;
oi.calName = e.calName;
oi.color = e.color;
oi.startMs = e.startTime;
oi.endMs = e.endTime;
+106 -1
View File
@@ -20,6 +20,22 @@ export class DavColorEntry {
privilegeKnown: boolean = false; // 服务器是否返回了 current-user-privilege-set(未返回时需要写探测)
}
/** 日历本集合条目("添加/编辑账号"页列日历本用) */
export class DavCalendarEntry {
href: string = ''; // 完整 URL(已补全域名)
displayName: string = ''; // 显示名(服务器未给 displayname 时回退为路径末段)
color: string = ''; // 规范化后的 #RRGGBB,可能为空
}
/**
* 日历本发现结果。
* 带 statusCode —— 调用方需要据此区分「401 凭据失效」与「非 207 异常」,故不能只返回列表。
*/
export class DavCalendarDiscovery {
statusCode: number = 0;
entries: DavCalendarEntry[] = [];
}
export class DavClient {
/**
* PROPFIND 拉取某路径下所有集合的 calendar-color
@@ -93,6 +109,88 @@ export class DavClient {
}
}
/**
* PROPFIND 列出某账号下所有「日历集合」(resourcetype 含 calendar)。
*
* 供「添加账号 / 编辑账号」两页复用,取代它们各自维护的一份同逻辑拷贝 ——
* 解析逻辑集中在此,避免多处正则各自漂移。
* 解析用正则而非 XML 解析器(见 extractTag 的 XXE 说明)。
* 网络/解析异常向上抛出由调用方决定提示文案;仅"HTTP 状态码非成功"经 statusCode 返回。
*/
static async listCalendars(serverUrl: string, auth: string): Promise<DavCalendarDiscovery> {
const requestBody: string = '<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'</d:prop></d:propfind>';
const httpRequest = http.createHttp();
const out = new DavCalendarDiscovery();
try {
const resp: http.HttpResponse = await httpRequest.request(serverUrl, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': auth,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '1',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: requestBody,
connectTimeout: 10000,
readTimeout: 15000
});
out.statusCode = resp.responseCode;
LogUtil.write(`HTTP PROPFIND(日历本) ${serverUrl} → ${resp.responseCode}`);
if (resp.responseCode !== 207 && resp.responseCode !== 200) {
return out;
}
const xml: string = resp.result as string;
const originMatch = /https?:\/\/[^/]+/i.exec(serverUrl);
const origin: string = originMatch !== null ? originMatch[0] : '';
const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i);
for (const block of blocks) {
if (!/<[\w-]*:?response[\s>]/i.test(block)) {
continue;
}
const href: string = DavClient.extractTag(block, 'href');
if (href === '') {
continue;
}
const resourcetype: string = DavClient.extractTag(block, 'resourcetype');
if (!/calendar/i.test(resourcetype)) {
continue;
}
let name: string = DavClient.extractTag(block, 'displayname');
if (name === '') {
// 服务器没给 displayname → 回退为 href 路径末段
const segs: string[] = href.split('/').filter((s: string) => s !== '');
if (segs.length > 0) {
const last: string = segs[segs.length - 1];
try {
name = decodeURIComponent(last);
} catch (err) {
name = last;
}
} else {
name = href;
}
}
// 两个命名空间都试:cs:getcolorCalendarServer/ ical:calendar-colorApple
let color: string = DavClient.normalizeHex(DavClient.extractTag(block, 'getcolor'));
if (color === '') {
color = DavClient.normalizeHex(DavClient.extractTag(block, 'calendar-color'));
}
const entry = new DavCalendarEntry();
entry.href = href.startsWith('http') ? href : origin + href;
entry.displayName = name;
entry.color = color;
out.entries.push(entry);
}
return out;
} finally {
httpRequest.destroy();
}
}
/** 颜色规范化:#RRGGBBAA → #RRGGBB */
static normalizeHex(raw: string): string {
const v: string = raw.trim();
@@ -370,7 +468,14 @@ export class DavClient {
}
}
/** 提取任意命名空间前缀标签的内容 */
/**
* 提取任意命名空间前缀标签的文本内容(如 `<d:href>` / `<cs:getcolor>`)。
*
* 安全说明:此处**刻意用正则而不是 XML 解析器**——DAV 响应来自用户自填的远端服务器,
* 属不可信输入;正则提取不构建 DOM、不解析实体,从根上规避了 XXE(外部实体扩展)
* 与「十亿笑声」实体炸弹这类解析器层面的攻击面。
* 另外 `tag` 只由本文件内的代码常量传入,不来自远端数据,故无需防注入。
*/
static extractTag(xml: string, tag: string): string {
const pattern: string = '<([\\w-]+:)?' + tag + '(?:\\s[^>]*)?>([\\s\\S]*?)<\\/([\\w-]+:)?' + tag + '>';
const regex: RegExp = new RegExp(pattern, 'i');
+2
View File
@@ -346,6 +346,8 @@ export class EventDb {
* 用远端数据刷新某个日历本(增量):
* - etag 未变的跳过;变化的更新;远端没有的本地图删掉(排除本地待推送的新事件)
* - 返回统计描述:"新增X 更新Y 删除Z 不变W"
* - 实例唯一键用 `${uid}_${startTime}`:同一 uid 的多日展开各自成行,
* 因此**改期会表现为"旧实例删除 + 新实例新增"**,属预期行为。
*/
static async applyRemote(context: common.Context, calKey: string, href: string,
remote: RemoteEvent[], isTodo: boolean): Promise<string> {
+7 -1
View File
@@ -216,7 +216,13 @@ export class IcsUtil {
return parsed;
}
/** 由本地事件构建 VCALENDAR 文本(时间统一转 UTC,保证服务器端时区正确) */
/**
* 由本地事件构建 VCALENDAR 文本(时间统一转 UTC,保证服务器端时区正确)。
*
* 已知取舍:摘要 / 地点 / 备注等文本字段**未做 ICS 转义**(`,` `;` `\` 与换行)。
* 含这些字符的标题在服务器端可能被截断或串行;
* 若线上出现"标题被截断"反馈,需在此补 escapeIcsText()。
*/
static build(e: LocalEvent): string {
const pad = (n: number): string => n < 10 ? '0' + n : String(n);
const fmtUtc = (ms: number): string => {
+11 -10
View File
@@ -39,11 +39,12 @@ export class SyncEngine {
if (Date.now() - heldSince < SyncEngine.LOCK_STALE_MS) {
throw new Error('该账号正在同步中,请稍后再试');
}
LogUtil.write(`同步互斥锁超时残留(${acc.name}),强制释放并重新同步`);
LogUtil.write(`同步互斥锁超时残留(账号 id=${acc.id}),强制释放并重新同步`);
}
SyncEngine.activeSyncs.set(acc.id, Date.now());
const t0: number = Date.now();
LogUtil.write(`========== 同步账号「${acc.name}」开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`);
// 日志不记录账号名(用户自定义,可能含个人信息),以 id + 服务器地址定位
LogUtil.write(`========== 同步账号 id=${acc.id} 开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`);
try {
// 全量重拉(升级后首次)要逐个 GET 所有资源,放宽超时到 10 分钟;常规 5 分钟
const fullRefetch: boolean = await AppSettings.isFullRefetchPending(context);
@@ -54,11 +55,11 @@ export class SyncEngine {
SyncEngine.activeSyncs.delete(acc.id);
});
const r: number = await SyncEngine.withTimeout<number>(inner, timeoutMs);
LogUtil.write(`同步账号${acc.name}」完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`);
LogUtil.write(`同步账号 id=${acc.id} 完成:拉取 ${r} 条日程,耗时 ${Math.round((Date.now() - t0) / 1000)} 秒`);
return r;
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`同步账号${acc.name}」失败:${e.message}(耗时 ${Math.round((Date.now() - t0) / 1000)} 秒)`);
LogUtil.write(`同步账号 id=${acc.id} 失败:${e.message}(耗时 ${Math.round((Date.now() - t0) / 1000)} 秒)`);
throw new Error(e.message !== '' ? e.message : `错误码 ${e.code}`);
}
}
@@ -287,13 +288,13 @@ export class SyncEngine {
static async pushDirtyForAccount(context: common.Context, acc: DavAccount, auth: string): Promise<void> {
const dirty: LocalEvent[] = await EventDb.getDirty(context);
const mine: LocalEvent[] = dirty.filter((e: LocalEvent): boolean => acc.calendarHrefs.includes(e.href));
LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号${acc.name}」的 ${mine.length} 条`);
LogUtil.write(`推送本地修改:全部待推送 ${dirty.length} 条,属于账号 id=${acc.id} 的 ${mine.length} 条`);
for (const e of mine) {
// 只读日历本:推送必然 403,跳过并保留 dirty(权限恢复后可再推)
const bookIdx: number = acc.calendarHrefs.indexOf(e.href);
if (bookIdx >= 0 && acc.calendarWritable.length > bookIdx
&& acc.calendarWritable[bookIdx] === '0') {
LogUtil.write(`推送跳过只读日历本事件「${e.title}」(uid=${e.uid})`);
LogUtil.write(`推送跳过只读日历本事件(标题已脱敏)uid=${e.uid}`);
continue;
}
if (e.kind === 'todo') {
@@ -304,23 +305,23 @@ export class SyncEngine {
if (e.recurring && e.rrule === '') {
// 重复日程的"单次覆盖实例"RECURRENCE-ID)推送会破坏服务器整个序列,暂不支持
await EventDb.clearDirty(context, e.id, e.etag);
LogUtil.write(`推送跳过重复日程实例「${e.title}」(uid=${e.uid})`);
LogUtil.write(`推送跳过重复日程实例(标题已脱敏)uid=${e.uid}`);
continue;
}
if (e.recurring) {
// 重复主事件(含 RRULE,含本机新建的重复日程):整条 PUT 覆盖推送
LogUtil.write(`推送重复主事件「${e.title}」(uid=${e.uid})`);
LogUtil.write(`推送重复主事件(标题已脱敏)uid=${e.uid}`);
}
const url: string = e.href.endsWith('/') ? e.href + e.remotePath : `${e.href}/${e.remotePath}`;
if (e.deleted) {
await DavClient.deleteRemote(url, auth);
await EventDb.purge(context, e.id);
LogUtil.write(`推送删除「${e.title}」→ ${url}`);
LogUtil.write(`推送删除(标题已脱敏)uid=${e.uid} → ${url}`);
} else {
const ics: string = IcsUtil.build(e);
const etag: string = await DavClient.putEvent(url, auth, ics);
await EventDb.clearDirty(context, e.id, etag);
LogUtil.write(`推送保存「${e.title}」→ ${url}${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'}`);
LogUtil.write(`推送保存(标题已脱敏)uid=${e.uid} → ${url}${ics.length} 字节,提醒=${e.reminders.map((n: number): string => String(n)).join('/')}分钟,VALARM=${ics.toUpperCase().includes('BEGIN:VALARM') ? '有' : '无'}`);
}
}
}
@@ -16,7 +16,9 @@ export class TimelineBlock {
eventKey: string = ''; // 与视图 ForEach key 一致
title: string = '';
timeText: string = ''; // '09:00 - 10:30'
location: string = ''; // 地点(第 2/3 行显示)
color: string = '#007DFF';
writable: boolean = true; // 所属日历本是否可写(false → 色块标题后挂白框「只读」小标)
topRatio: number = 0; // 距 0 点的比例 0~1(乘时间轴总高)
heightRatio: number = 0; // 高度比例(最小高度由视图侧兜底)
leftRatio: number = 0; // 横向起始比例 0~1(冲突平分)
@@ -85,6 +87,10 @@ export class TimelineUtil {
return `${p(d.getHours())}:${p(d.getMinutes())}`;
}
// ---- 只读外观 ------------------------------------------------------------
// 只读不再用"灰蒙版底色"(观感不好,已撤),改为在色块标题后挂一个**白框白字**的「只读」小标
// (见 Index 的 roTag()/卡片内的同名小标)。这里只负责把可写标记带出来。
/**
* 构建某一天的时间轴布局。
* @param events 与 dateMs 这一天相交的全部日程
@@ -165,6 +171,8 @@ export class TimelineUtil {
b.groupIndex = groupIdx;
b.title = e.title === '' ? '(无标题)' : e.title;
b.timeText = `${TimelineUtil.fmtTime(e.startTime)} - ${TimelineUtil.fmtTime(e.endTime)}`;
b.location = e.location;
b.writable = e.writable;
b.color = e.color;
// 与今天的交集(跨天日程已排除,这里都是日内)
const s: number = Math.max(e.startTime, dayStart);
@@ -151,6 +151,41 @@ struct AddAccountPage {
return;
}
// 明文 HTTP 属于"已知不安全"的传输方式:Basic 凭据(用户名/密码)会以未加密形式
// 在网络上传输,可被同网络中间人直接读取。
// 不直接拒绝(局域网自建 NAS/测试环境常用 http),但必须让用户明确知情并二次确认,
// 避免用户在不知情下把凭据发到明文信道。
if (targetUrl.startsWith('http://')) {
this.getUIContext().showAlertDialog({
title: '不安全连接',
message: '该地址使用 http:// 明文连接,用户名与密码将以未加密方式在网络中传输,存在被窃听的风险。\n\n建议改用 https://。是否仍要继续?',
autoCancel: true,
alignment: DialogAlignment.Center,
primaryButton: {
value: '取消',
action: (): void => {
this.statusMsg = '已取消:建议改用 https:// 地址';
this.statusOk = false;
}
},
secondaryButton: {
value: '仍要继续',
fontColor: $r('app.color.error'),
action: (): void => {
this.doConnect(targetUrl);
}
}
});
return;
}
await this.doConnect(targetUrl);
}
/** 实际连接并(成功时)把凭据经 AppStorage 交给日历本选择页 */
private async doConnect(targetUrl: string): Promise<void> {
if (this.isLoading) {
return;
}
this.isLoading = true;
this.statusMsg = '正在连接服务器…';
this.statusOk = false;
+30 -81
View File
@@ -1,10 +1,10 @@
// entry/src/main/ets/pages/CalendarListPage.ets
// 添加账号第二页:PROPFIND 列出日历本 → 勾选 → 命名 → 保存账号
import { http } from '@kit.NetworkKit';
import { router } from '@kit.ArkUI';
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 { LogUtil } from '../common/LogUtil';
/**
@@ -52,7 +52,8 @@ struct CalendarListPage {
this.serverUrl = AppStorage.get<string>('pendingDavUrl') ?? '';
this.username = AppStorage.get<string>('pendingDavUsername') ?? '';
this.password = AppStorage.get<string>('pendingDavPassword') ?? '';
LogUtil.write(`添加账号流程开始:服务器=${this.serverUrl} 用户名=${this.username}`);
// 日志不记录用户名(可能是邮箱等个人标识),只留服务器地址以便定位问题
LogUtil.write(`添加账号流程开始:服务器=${this.serverUrl}`);
if (this.serverUrl === '') {
this.isLoading = false;
this.statusMsg = '尚未连接服务器,请先返回重新连接';
@@ -62,6 +63,23 @@ struct CalendarListPage {
await this.fetchCalendars();
}
/**
* 清除跨页传递的明文凭据。
* 这些值原本通过 AppStorage 在页面间传递,AppStorage 是全局单例,若不清除,
* 明文密码会在**整个进程生命周期**内一直可读 —— 属于不必要的凭据驻留。
*/
private clearPendingCredentials(): void {
AppStorage.setOrCreate<string>('pendingDavPassword', '');
AppStorage.setOrCreate<string>('pendingDavUsername', '');
AppStorage.setOrCreate<string>('pendingDavUrl', '');
this.password = '';
}
/** 离开页面即清理凭据(覆盖用户按返回键放弃添加这条路径) */
aboutToDisappear(): void {
this.clearPendingCredentials();
}
private encodeBasicAuth(): string {
try {
return buffer.from(`${this.username}:${this.password}`).toString('base64');
@@ -80,45 +98,25 @@ struct CalendarListPage {
this.statusOk = false;
return;
}
const requestBody: string = '<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'</d:prop></d:propfind>';
const httpRequest = http.createHttp();
// PROPFIND 与解析统一走 DavClient.listCalendars(与「编辑账号」页共用同一实现,避免重复正则逻辑)
try {
const resp: http.HttpResponse = await httpRequest.request(this.serverUrl, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': 'Basic ' + token,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '1',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: requestBody,
connectTimeout: 10000,
readTimeout: 15000
});
console.info(`PROPFIND 响应码: ${resp.responseCode}`);
if (resp.responseCode === 401) {
this.isLoading = false;
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.serverUrl, 'Basic ' + token);
this.isLoading = false;
if (disc.statusCode === 401) {
this.statusMsg = '登录已失效,请返回重新连接';
this.statusOk = false;
return;
}
if (resp.responseCode !== 207 && resp.responseCode !== 200) {
this.isLoading = false;
this.statusMsg = `获取日历列表失败,服务器返回:${resp.responseCode}`;
if (disc.statusCode !== 207 && disc.statusCode !== 200) {
this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`;
this.statusOk = false;
return;
}
const xml: string = resp.result as string;
LogUtil.write(`添加账号 PROPFIND → ${resp.responseCode},响应体 ${xml.length} 字符`);
const list: CalendarItem[] = this.parseCalendarList(xml);
const list: CalendarItem[] = disc.entries.map((e: DavCalendarEntry): CalendarItem =>
new CalendarItem(e.href, e.displayName, e.color));
for (const item of list) {
LogUtil.write(`发现日历本:「${item.name}」${item.href} 颜色=${item.color === '' ? '(无)' : item.color}`);
}
this.isLoading = false;
if (list.length === 0) {
this.statusMsg = '该路径下未发现日历本(没有包含 calendar 资源类型的集合)';
this.statusOk = false;
@@ -134,59 +132,9 @@ struct CalendarListPage {
this.isLoading = false;
this.statusMsg = `获取日历列表失败:${e.message}`;
this.statusOk = false;
} finally {
httpRequest.destroy();
}
}
private extractTag(xml: string, tag: string): string {
const pattern: string = '<([\\w-]+:)?' + tag + '(?:\\s[^>]*)?>([\\s\\S]*?)<\\/([\\w-]+:)?' + tag + '>';
const regex: RegExp = new RegExp(pattern, 'i');
const match = regex.exec(xml);
return match !== null ? match[2].trim() : '';
}
private parseCalendarList(xml: string): CalendarItem[] {
const items: CalendarItem[] = [];
const originMatch = /https?:\/\/[^/]+/i.exec(this.serverUrl);
const origin: string = originMatch !== null ? originMatch[0] : '';
const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i);
for (const block of blocks) {
if (!/<[\w-]*:?response[\s>]/i.test(block)) {
continue;
}
const href: string = this.extractTag(block, 'href');
if (href === '') {
continue;
}
const resourcetype: string = this.extractTag(block, 'resourcetype');
if (!/calendar/i.test(resourcetype)) {
continue;
}
let name: string = this.extractTag(block, 'displayname');
if (name === '') {
const segs: string[] = href.split('/').filter((s: string) => s !== '');
if (segs.length > 0) {
try {
name = decodeURIComponent(segs[segs.length - 1]);
} catch (err) {
name = segs[segs.length - 1];
}
} else {
name = href;
}
}
// 服务器端颜色:cs:getcolor 或 ical:calendar-color,带 Alpha 时转成 #RRGGBB
let color: string = AccountStore.normalizeColor(this.extractTag(block, 'getcolor'));
if (color === '') {
color = AccountStore.normalizeColor(this.extractTag(block, 'calendar-color'));
}
const fullHref: string = href.startsWith('http') ? href : origin + href;
items.push(new CalendarItem(fullHref, name, color));
}
return items;
}
private async saveSelection(): Promise<void> {
if (this.isSaving) {
return;
@@ -222,13 +170,14 @@ struct CalendarListPage {
acc.calendarHrefs = selectedItems.map((c: CalendarItem): string => c.href);
acc.calendarNames = selectedItems.map((c: CalendarItem): string => c.name);
acc.calendarColors = selectedItems.map((c: CalendarItem): string => c.color);
LogUtil.write(`保存账号「${acc.name}」:id=${acc.id}勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
LogUtil.write(`保存账号 id=${acc.id}勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
await AccountStore.addAccount(context, acc);
AppStorage.setOrCreate<string>('pendingSyncAccountId', acc.id);
this.getUIContext().getPromptAction()
.showToast({ message: `账号已保存,共 ${selectedItems.length} 个日历本` });
this.statusMsg = '保存成功';
this.statusOk = true;
this.clearPendingCredentials();
router.back({ url: 'pages/AccountsPage' });
} catch (err) {
const e = err as BusinessError;
+13 -81
View File
@@ -1,12 +1,12 @@
// entry/src/main/ets/pages/EditAccountPage.ets
// 编辑账号:查看/重选该账号下的日历本、修改账户名
// 保存后清理失效日历本的本地数据,并触发一次重新同步
import { http } from '@kit.NetworkKit';
import { router } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { buffer } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit';
import { DavAccount, AccountStore } from '../common/AccountStore';
import { DavClient, DavCalendarDiscovery, DavCalendarEntry } from '../common/DavClient';
import { EventDb } from '../common/EventDb';
import { LogUtil } from '../common/LogUtil';
@@ -102,7 +102,7 @@ struct EditAccountPage {
this.accountName = foundAcc.name;
this.serverUrl = foundAcc.serverUrl;
this.username = foundAcc.username;
LogUtil.write(`编辑账号「${foundAcc.name}」:id=${foundAcc.id}当前已选 ${foundAcc.calendarHrefs.length} 个日历本`);
LogUtil.write(`编辑账号 id=${foundAcc.id}当前已选 ${foundAcc.calendarHrefs.length} 个日历本`);
for (let i = 0; i < foundAcc.calendarHrefs.length; i++) {
const nm: string = i < foundAcc.calendarNames.length ? foundAcc.calendarNames[i] : '';
LogUtil.write(` 已选日历本[${i}]「${nm}」${foundAcc.calendarHrefs[i]}`);
@@ -135,42 +135,22 @@ struct EditAccountPage {
this.statusOk = false;
return;
}
const requestBody: string = '<?xml version="1.0" encoding="utf-8"?>' +
'<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" ' +
'xmlns:ical="http://apple.com/ns/ical/"><d:prop>' +
'<d:displayname/><d:resourcetype/><cs:getcolor/><ical:calendar-color/>' +
'</d:prop></d:propfind>';
const httpRequest = http.createHttp();
// PROPFIND 与解析统一走 DavClient.listCalendars(与「添加账号」页共用同一实现)
try {
const resp: http.HttpResponse = await httpRequest.request(this.acc.serverUrl, {
method: 'PROPFIND' as http.RequestMethod,
header: {
'Authorization': 'Basic ' + token,
'Content-Type': 'application/xml; charset=utf-8',
'Depth': '1',
'User-Agent': 'SyncCalendar/1.0'
},
extraData: requestBody,
connectTimeout: 10000,
readTimeout: 15000
});
console.info(`编辑账号 PROPFIND 响应码: ${resp.responseCode}`);
if (resp.responseCode === 401) {
this.isLoading = false;
const disc: DavCalendarDiscovery = await DavClient.listCalendars(this.acc.serverUrl, 'Basic ' + token);
this.isLoading = false;
if (disc.statusCode === 401) {
this.statusMsg = '登录已失效,请检查账号密码';
this.statusOk = false;
return;
}
if (resp.responseCode !== 207 && resp.responseCode !== 200) {
this.isLoading = false;
this.statusMsg = `获取日历列表失败,服务器返回:${resp.responseCode}`;
if (disc.statusCode !== 207 && disc.statusCode !== 200) {
this.statusMsg = `获取日历列表失败,服务器返回:${disc.statusCode}`;
this.statusOk = false;
return;
}
const xml: string = resp.result as string;
LogUtil.write(`编辑账号 PROPFIND → ${resp.responseCode},响应体 ${xml.length} 字符`);
const list: EditCalendarItem[] = this.parseCalendarList(xml);
this.isLoading = false;
const list: EditCalendarItem[] = disc.entries.map((e: DavCalendarEntry): EditCalendarItem =>
new EditCalendarItem(e.href, e.displayName, e.color));
if (list.length === 0) {
LogUtil.write('编辑账号:未发现任何日历本');
this.statusMsg = '该路径下未发现日历本';
@@ -192,58 +172,9 @@ struct EditAccountPage {
this.isLoading = false;
this.statusMsg = `获取日历列表失败:${e.message}`;
this.statusOk = false;
} finally {
httpRequest.destroy();
}
}
private extractTag(xml: string, tag: string): string {
const pattern: string = '<([\\w-]+:)?' + tag + '(?:\\s[^>]*)?>([\\s\\S]*?)<\\/([\\w-]+:)?' + tag + '>';
const regex: RegExp = new RegExp(pattern, 'i');
const match = regex.exec(xml);
return match !== null ? match[2].trim() : '';
}
private parseCalendarList(xml: string): EditCalendarItem[] {
const items: EditCalendarItem[] = [];
const originMatch = /https?:\/\/[^/]+/i.exec(this.acc.serverUrl);
const origin: string = originMatch !== null ? originMatch[0] : '';
const blocks: string[] = xml.split(/<\/[\w-]*:?response>/i);
for (const block of blocks) {
if (!/<[\w-]*:?response[\s>]/i.test(block)) {
continue;
}
const href: string = this.extractTag(block, 'href');
if (href === '') {
continue;
}
const resourcetype: string = this.extractTag(block, 'resourcetype');
if (!/calendar/i.test(resourcetype)) {
continue;
}
let name: string = this.extractTag(block, 'displayname');
if (name === '') {
const segs: string[] = href.split('/').filter((s: string) => s !== '');
if (segs.length > 0) {
try {
name = decodeURIComponent(segs[segs.length - 1]);
} catch (err) {
name = segs[segs.length - 1];
}
} else {
name = href;
}
}
let color: string = AccountStore.normalizeColor(this.extractTag(block, 'getcolor'));
if (color === '') {
color = AccountStore.normalizeColor(this.extractTag(block, 'calendar-color'));
}
const fullHref: string = href.startsWith('http') ? href : origin + href;
items.push(new EditCalendarItem(fullHref, name, color));
}
return items;
}
/** 保存:更新账号的日历本选择与名称,清理失效数据,触发重新同步 */
private async saveSelection(): Promise<void> {
if (this.isSaving || !this.found) {
@@ -281,7 +212,7 @@ struct EditAccountPage {
target.calendarHrefs = selectedItems.map((c: EditCalendarItem): string => c.href);
target.calendarNames = selectedItems.map((c: EditCalendarItem): string => c.name);
target.calendarColors = selectedItems.map((c: EditCalendarItem): string => c.color);
LogUtil.write(`编辑账号保存:「${target.name}」id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
LogUtil.write(`编辑账号保存:id=${target.id},新勾选 ${selectedItems.length}/${this.calendarList.length} 个日历本`);
await AccountStore.saveAll(context, accounts);
// 重选后 calKey(accId_序号)会变化,清理已取消勾选的日历本数据
const validKeys: string[] =
@@ -428,7 +359,8 @@ struct EditAccountPage {
this.handleItemToggle(selectedItem);
}
})
}, (item: EditCalendarItem) => `${item.href}_${item.selected}`)
// key 只用 href(稳定标识):若把选中态也拼进 key,勾选一次就会导致整行销毁重建
}, (item: EditCalendarItem) => item.href)
}
}
.width('100%')
+35 -3
View File
@@ -270,7 +270,8 @@ struct EventEditPage {
} else {
await SyncEngine.settleLocalEvents(context);
}
LogUtil.write(`本地保存日程「${e.title}」提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`);
// 日志仅记录 uid 与提醒/重复设置,不落盘日程标题(避免隐私内容进入可备份的 sync.log)
LogUtil.write(`本地保存日程(标题已脱敏)uid=${e.uid} 提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`);
// 立即刷新提醒(不等下一轮同步),保证刚保存的提醒马上生效
try {
await ReminderService.refreshReminders(context as common.UIAbilityContext);
@@ -289,6 +290,35 @@ struct EventEditPage {
this.isSaving = false;
}
/**
* 删除前二次确认。
* 删除日程不可撤销,且会同时从本地库与服务器(DAV)移除,故破坏性操作前必须显式确认,
* 避免"删除日程"按钮点击即删的误触。
*/
private askRemoveEvent(): void {
if (this.event === null || this.isSaving) {
return;
}
const label: string = this.title.trim() === '' ? '该日程' : `「${this.title.trim()}」`;
this.getUIContext().showAlertDialog({
title: '删除日程',
message: `确定删除${label}吗?\n\n删除后该日程将从本地与服务器日历中一并移除,且不可撤销。`,
autoCancel: true,
alignment: DialogAlignment.Center,
primaryButton: {
value: '取消',
action: (): void => {}
},
secondaryButton: {
value: '删除',
fontColor: $r('app.color.error'),
action: (): void => {
this.removeEvent();
}
}
});
}
private async removeEvent(): Promise<void> {
if (this.event === null || this.isSaving) {
return;
@@ -314,7 +344,8 @@ struct EventEditPage {
} else {
await SyncEngine.settleLocalEvents(context);
}
LogUtil.write(`本地删除日程「${this.event?.title ?? ''}」`);
// 日志不落盘日程标题,仅以 uid 追踪
LogUtil.write(`本地删除日程(标题已脱敏)uid=${this.event?.uid ?? ''}`);
// 立即刷新提醒(取消已发布但日程已删的提醒)
try {
await ReminderService.refreshReminders(context as common.UIAbilityContext);
@@ -515,7 +546,8 @@ struct EventEditPage {
.borderRadius(12)
.enabled(!this.isSaving)
.onClick(() => {
this.removeEvent();
// 二次确认后再执行删除
this.askRemoveEvent();
})
}
}
+157 -22
View File
@@ -681,7 +681,8 @@ struct Index {
}
try {
await ctx.openLink(link);
LogUtil.write(`已拉起高德导航:${address}(坐标=${coords !== null ? '有' : '无'}`);
// 日志不落盘地点文本(可能包含家庭/公司等敏感地址),仅记录是否拿到坐标
LogUtil.write(`已拉起高德导航(地点已脱敏,坐标=${coords !== null ? '有' : '无'}`);
return;
} catch (err) {
LogUtil.write(`高德深链打开失败:${(err as BusinessError).message}`);
@@ -728,7 +729,8 @@ struct Index {
if (list.length > 0 && list[0].latitude !== undefined && list[0].longitude !== undefined) {
return [list[0].latitude, list[0].longitude];
}
LogUtil.write(`地理编码无结果:${address}`);
// 日志不落盘地点文本(可能含家庭/公司等敏感地址)
LogUtil.write('地理编码无结果(地点已脱敏)');
} catch (err) {
LogUtil.write(`地理编码失败:${(err as BusinessError).message}`);
}
@@ -2351,6 +2353,9 @@ struct DayTimelineView {
@Watch('onShowNowLine') @Prop showNowLine: boolean = false; // 仅"今天"显示红线 + 触发自动定位
@Prop scrollable: boolean = false; // 是否自带可滚动容器(月/周视图日时间轴=true;列表视图卡片=false
hourUnit: number = TimelineUtil.HOUR_UNIT;
private minBlockVp: number = 16; // 极短日程色块的可读高度下限(一行"标题 + 时间"
private twoLineVp: number = 30; // 能放下"标题 + 时间/地点"两行的高度门槛
private threeLineVp: number = 46; // 能放下"标题 / 时间 / 地址"三行的高度门槛
onPick: (e: DisplayEvent) => void = (e: DisplayEvent): void => {};
private scroller: Scroller = new Scroller();
@State private viewportH: number = 0; // 滚动视口高度(由 Scroll 实测,用于把"当前时刻"居中)
@@ -2483,10 +2488,62 @@ struct DayTimelineView {
const d: number = (top - prevEnd) * this.totalH();
return d < 0 ? 0 : d;
}
/** 色块高度(vp):按实时长换算,至少 14vp 保证短日程也可见(超出组行的部分由列裁切) */
private blockH(b: TimelineBlock): number {
const h: number = b.heightRatio * this.totalH();
return h < 14 ? 14 : h;
/** 色块实际渲染高度(vp):按实时长换算
* "特别短"的日程(如 15 分钟)原始高度只有几 vp,放不下一行字 → 在不越过**同列下一个色块**的前提下
* 尽量垫到 minBlockVp,保证至少能显示一行"时刻 + 题目"。垫不满时按实际可用量给,绝不重叠。 */
private blockVp(r: TimeRow, lane: TimelineBlock[], index: number): number {
const arr: TimelineBlock[] = this.laneBlocks(r, lane);
const b: TimelineBlock = arr[index];
const real: number = b.heightRatio * this.totalH();
if (real >= this.minBlockVp) {
return real;
}
let room: number = 0; // 可以借用的下方空隙(同列下一块之前 / 本行末尾)
if (index + 1 < arr.length) {
room = this.lanePadH(r, lane, index + 1);
} else {
const tail: number = (r.botRatio - (b.topRatio + b.heightRatio)) * this.totalH();
room = tail > 0 ? tail : 0;
}
const h: number = real + room;
return h > this.minBlockVp ? this.minBlockVp : h;
}
/** 色块信息行数(按**渲染高度**判定,标题永远独占第一行的开头):
* 1 = 一行放下 → 标题 · 时间 · 地址(时间用短格式);
* 2 = 标题一行 + "时间 · 地点"一行;
* 3 = 标题 / 时间 / 地址 各一行(没有地址时退化为 2 行,不空占一行)。 */
private blockLines(r: TimeRow, lane: TimelineBlock[], index: number, b: TimelineBlock): number {
const h: number = this.blockVp(r, lane, index);
if (h >= this.threeLineVp && b.location !== '') {
return 3;
}
if (h >= this.twoLineVp) {
return 2;
}
return 1;
}
/** 两行色块的第二行:'09:00 - 10:30 · 地点'(无地点时只剩时间) */
private blockMeta(b: TimelineBlock): string {
if (b.location !== '') {
return `${b.timeText} · ${b.location}`;
}
return b.timeText;
}
/** 色块 ForEach key 的内容签名:只读标记 / 地点 / 渲染高度 / 标题长度任一变化都必须强制重建,
* 否则 ArkUI 会按旧 key 复用子组件 → "改了样式界面不更新"。 */
private blockSig(r: TimeRow, lane: TimelineBlock[], index: number, b: TimelineBlock): string {
return `${b.writable ? 1 : 0}_${b.location.length}_${Math.round(this.blockVp(r, lane, index))}_${b.title.length}`;
}
/** 「只读」小标:圆角矩形白框 + 白字,紧跟在日程标题后面,字号比标题小一号 */
@Builder
private roTag() {
Text('只读')
.fontSize(9)
.fontColor('#FFFFFF')
.border({ width: 0.5, color: '#FFFFFF' })
.borderRadius(4)
.padding({ left: 4, right: 4, top: 0, bottom: 0 })
.maxLines(1)
}
// 整点灰线由第 1 层(网格层)画、红线由第 3 层画 → 日程层不再为它们切段。
/** 左侧刻度:当前小时是否高亮(仅今天) */
@@ -2588,6 +2645,15 @@ struct DayTimelineView {
.backgroundColor('#26000000')
.borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
if (!e.writable) {
// 只读标记:圆角矩形白框 + 白字(与色块上的「只读」小标同一套样式)
Text('只读')
.fontSize(9)
.fontColor('#FFFFFF')
.border({ width: 0.5, color: '#FFFFFF' })
.borderRadius(4)
.padding({ left: 4, right: 4, top: 0, bottom: 0 })
}
Text(e.title === '' ? '(无标题)' : e.title)
.fontSize(12)
.fontWeight(FontWeight.Medium)
@@ -2595,15 +2661,24 @@ struct DayTimelineView {
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
if (e.location !== '') {
Text(e.location)
.fontSize(10)
.fontColor('#D9FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 120 })
}
}
.alignItems(VerticalAlign.Center)
.width('100%')
.height(26)
.padding({ left: 8, right: 8 })
.borderRadius(8)
// 只读不再用灰蒙版(已撤),只靠标题后的「只读」白框小标区分
.backgroundColor(e.color)
.onClick(() => this.onPick(e))
}, (e: DisplayEvent) => `al_${this.timeline.dateKey}_${TimelineUtil.keyOf(e)}`)
}, (e: DisplayEvent) => `al_${this.timeline.dateKey}_${TimelineUtil.keyOf(e)}_${e.writable ? 1 : 0}_${e.location.length}`)
}
.width('100%')
.padding({ bottom: 6 })
@@ -2683,20 +2758,80 @@ struct DayTimelineView {
ForEach(this.laneBlocks(r, lane), (b: TimelineBlock, index: number) => {
Blank().height(this.lanePadH(r, lane, index))
Column({ space: 1 }) {
Text(b.title)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.blockLines(r, lane, index, b) === 1) {
// 一行:标题 · [只读] · 时间 · 地址
// 时间用**完整起止**09:00 - 10:30):宽度够就整段显示,不够由 maxWidth + 省略号自然截断;
// 三者的让位顺序是"地址先没 → 时间省略 → 标题只保 50%",保证标题与开始时间一定看得到。
Row({ space: 4 }) {
Text(b.title)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '50%' })
.lineHeight(12)
if (!b.writable) {
this.roTag()
}
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '55%' })
.lineHeight(12)
if (b.location !== '') {
Text(b.location)
.fontSize(9)
.fontColor('#B3FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.lineHeight(12)
.layoutWeight(1)
}
}
.width('100%')
if (b.heightRatio * 86400000 >= 40 * 60000) {
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.alignItems(VerticalAlign.Center)
} else {
// 第一行:标题 · [只读](标题永远打头)
Row({ space: 4 }) {
Text(b.title)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '70%' })
if (!b.writable) {
this.roTag()
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
if (this.blockLines(r, lane, index, b) >= 3) {
// 三行:第二行时间、第三行地址
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(b.location)
.fontSize(9)
.fontColor('#B3FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
} else {
// 两行:第二行"时间 · 地点"
Text(this.blockMeta(b))
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
}
}
.alignItems(HorizontalAlign.Start)
@@ -2707,13 +2842,13 @@ struct DayTimelineView {
.opacity(b.isNow ? 1 : 0.92)
.clip(true)
.width('100%')
.height(this.blockH(b))
.height(this.blockVp(r, lane, index))
.onClick(() => {
if (b.ev !== null) {
this.onPick(b.ev);
}
})
}, (b: TimelineBlock) => `blk_${r.key}_${b.eventKey}`)
}, (b: TimelineBlock, index: number) => `blk_${r.key}_${b.eventKey}_${this.blockSig(r, lane, index, b)}`)
}
.layoutWeight(1)
.height('100%')
+94 -4
View File
@@ -104,6 +104,11 @@ struct SettingsPage {
this.allBooks = books;
}
/** 该日历本是否只读:服务器无写权限(探测结果)或用户手动标记只读 —— 与首页色块/只读标记同一口径 */
private isBookReadonly(b: BackupTarget): boolean {
return !b.serverWritable || this.manualKeys.includes(b.calKey);
}
/** 手动标记/取消只读 */
private async toggleManualBook(b: BackupTarget): Promise<void> {
if (this.context === undefined) {
@@ -300,6 +305,44 @@ struct SettingsPage {
});
}
/** 确认后清空本地账号库(密码一并删除不可恢复;服务器日程不受影响) */
private askResetVault(): void {
this.getUIContext().showAlertDialog({
title: '重置账号库',
message: '将删除本机保存的全部账号、服务器地址与密码,回到"还没添加过账号"的状态。\n\n'
+ '日历数据保存在你的 CalDAV 服务器上,不受影响:重新添加账号并同步一次即可恢复。\n\n'
+ '本操作不可撤销(本地密码会一并删除),仅在账号读不出来、也无法新增账号时使用。',
autoCancel: true,
alignment: DialogAlignment.Center,
primaryButton: {
value: '取消',
action: (): void => {}
},
secondaryButton: {
value: '确认重置',
action: (): void => {
this.doResetVault();
}
}
});
}
private async doResetVault(): Promise<void> {
if (this.context === undefined) {
return;
}
try {
await AccountStore.resetVault(this.context);
this.getUIContext().getPromptAction().showToast({
message: '账号库已重置,请重新添加账号'
});
} catch (err) {
this.getUIContext().getPromptAction().showToast({
message: '重置失败,请稍后重试'
});
}
}
/** 去系统设置开启本应用通知 */
private async openNotifySettings(): Promise<void> {
if (this.context === undefined) {
@@ -313,12 +356,20 @@ struct SettingsPage {
}
}
/** 打开外部链接(如开源仓库) */
/**
* 打开外部链接(隐私政策 / 用户服务协议 / 开源仓库)。
* 仅放行 http(s):避免 URL 被篡改或误传为 file://、自定义 scheme、intent 类 URI 时,
* 拉起本地文件或任意第三方应用 —— 把"打开网页"严格限定在预期范围内。
*/
private async openUrl(url: string): Promise<void> {
const context = this.getUIContext().getHostContext();
if (context === undefined) {
return;
}
if (!url.startsWith('https://') && !url.startsWith('http://')) {
this.getUIContext().getPromptAction().showToast({ message: '仅支持打开 http(s) 链接' });
return;
}
try {
await (context as common.UIAbilityContext).openLink(url);
} catch (err) {
@@ -479,6 +530,36 @@ 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('重置账号库')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text('清空本机保存的账号、服务器地址与密码。服务器上的日程不受影响,重新添加账号并同步即可恢复。仅在账号读不出来、也无法新增账号时使用')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Button('重置')
.fontSize(13)
.backgroundColor($r('app.color.error'))
.onClick(() => {
this.askResetVault();
})
}
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor($r('app.color.card_bg'))
.border({ width: 1, color: $r('app.color.shadow_color') })
// 混合显示系统日历
Column({ space: 8 }) {
Row({ space: 10 }) {
@@ -791,9 +872,17 @@ struct SettingsPage {
}
ForEach(this.allBooks, (b: BackupTarget) => {
Row({ space: 8 }) {
// 日历本色点:只读本变中性灰 —— 相当于给这个本盖了一层灰蒙板
Column()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor(this.isBookReadonly(b)
? '#B0B4BA' : $r('app.color.brand'))
Text(b.label)
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.fontColor(this.isBookReadonly(b)
? $r('app.color.text_hint') : $r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
@@ -827,8 +916,9 @@ struct SettingsPage {
})
}
.width('100%')
.padding({ top: 6, bottom: 6 })
}, (b: BackupTarget) => `book_${b.calKey}_r${this.manualKeys.includes(b.calKey) ? 1 : 0}_m${this.mutedKeys.includes(b.calKey) ? 1 : 0}`)
.padding({ left: 6, right: 6, top: 6, bottom: 6 })
.borderRadius(8)
}, (b: BackupTarget) => `book_${b.calKey}_r${this.manualKeys.includes(b.calKey) ? 1 : 0}_m${this.mutedKeys.includes(b.calKey) ? 1 : 0}_w${b.serverWritable ? 1 : 0}`)
}
.alignItems(HorizontalAlign.Start)
.width('100%')
+101 -12
View File
@@ -14,6 +14,8 @@ class TBlock {
eventKey: string = '';
title: string = '';
timeText: string = '';
location: string = ''; // 地点(第 2/3 行显示)
writable: boolean = true; // false → 标题后挂白框「只读」小标
color: string = '#007DFF';
topRatio: number = 0;
heightRatio: number = 0;
@@ -143,9 +145,11 @@ struct Widget4x4Card {
rg.e = e;
return rg;
}
/** 视窗起始小时(取 w4Range 的起点,向下取整) */
private w4StartHour(): number {
return this.w4Range().s;
}
/** 视窗结束小时(取 w4Range 的终点,向上取整) */
private w4EndHour(): number {
return this.w4Range().e;
}
@@ -306,6 +310,21 @@ struct Widget4x4Card {
private w4BlockVp(b: TBlock): number {
return b.heightRatio * 24 * W4_HOUR;
}
/** 色块信息行数(标题永远打头):1=标题+时间+地址同行;2=标题 + 时间·地点;3=标题 / 时间 / 地址 */
private w4Lines(b: TBlock): number {
const h: number = this.w4BlockVp(b);
if (h >= 40 && b.location !== '') {
return 3;
}
if (h >= 26) {
return 2;
}
return 1;
}
/** 两行色块的第二行:'09:00 - 10:30 · 地点'(无地点时只剩时间) */
private w4Meta(b: TBlock): string {
return b.location !== '' ? `${b.timeText} · ${b.location}` : b.timeText;
}
/** 今天定时日程是否已全部结束(最晚结束比例 <= 当前时刻比例);只剩全天 / 无定时日程也算已结束。
* 非今天(r<0)返回 false,交由原条件判断(非今天本来就不显示红线)。 */
private w4AllTimedEnded(): boolean {
@@ -585,19 +604,89 @@ struct Widget4x4Card {
ForEach(lane, (b: TBlock, index: number) => {
Blank().height(this.w4LanePadVp(r.group, lane, index))
Column({ space: 1 }) {
Text(b.title)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.w4Lines(b) === 1) {
// 一行:标题 · [只读] · 时间 · 地址(时间用完整起止,宽度不够自然省略;
// 让位顺序:地址先没 → 时间省略 → 标题只保 50%)
Row({ space: 3 }) {
Text(b.title)
.fontSize(9)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '50%' })
.lineHeight(11)
if (!b.writable) {
Text('只读')
.fontSize(8)
.fontColor('#FFFFFF')
.border({ width: 0.5, color: '#FFFFFF' })
.borderRadius(3)
.padding({ left: 3, right: 3, top: 0, bottom: 0 })
.maxLines(1)
}
Text(b.timeText)
.fontSize(8)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '56%' })
.lineHeight(11)
if (b.location !== '') {
Text(b.location)
.fontSize(8)
.fontColor('#B3FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.lineHeight(11)
.layoutWeight(1)
}
}
.width('100%')
if (b.heightRatio * 86400000 >= 60 * 60000) {
Text(b.timeText)
.fontSize(8)
.fontColor('#E6FFFFFF')
.maxLines(1)
.width('100%')
.alignItems(VerticalAlign.Center)
} else {
// 第一行:标题 · [只读]
Row({ space: 3 }) {
Text(b.title)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '70%' })
if (!b.writable) {
Text('只读')
.fontSize(8)
.fontColor('#FFFFFF')
.border({ width: 0.5, color: '#FFFFFF' })
.borderRadius(3)
.padding({ left: 3, right: 3, top: 0, bottom: 0 })
.maxLines(1)
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
if (this.w4Lines(b) >= 3) {
Text(b.timeText)
.fontSize(8)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(b.location)
.fontSize(8)
.fontColor('#B3FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
} else {
Text(this.w4Meta(b))
.fontSize(8)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
}
}
.alignItems(HorizontalAlign.Start)
+101 -12
View File
@@ -14,6 +14,8 @@ class TBlock6 {
eventKey: string = '';
title: string = '';
timeText: string = '';
location: string = ''; // 地点(第 2/3 行显示)
writable: boolean = true; // false → 标题后挂白框「只读」小标
color: string = '#007DFF';
topRatio: number = 0;
heightRatio: number = 0;
@@ -142,9 +144,11 @@ struct Widget6x4Card {
rg.e = e;
return rg;
}
/** 视窗起始小时(取 w6Range 的起点,向下取整) */
private w6StartHour(): number {
return this.w6Range().s;
}
/** 视窗结束小时(取 w6Range 的终点,向上取整) */
private w6EndHour(): number {
return this.w6Range().e;
}
@@ -305,6 +309,21 @@ struct Widget6x4Card {
private w6BlockVp(b: TBlock6): number {
return b.heightRatio * 24 * W6_HOUR;
}
/** 色块信息行数(标题永远打头):1=标题+时间+地址同行;2=标题 + 时间·地点;3=标题 / 时间 / 地址 */
private w6Lines(b: TBlock6): number {
const h: number = this.w6BlockVp(b);
if (h >= 44 && b.location !== '') {
return 3;
}
if (h >= 30) {
return 2;
}
return 1;
}
/** 两行色块的第二行:'09:00 - 10:30 · 地点'(无地点时只剩时间) */
private w6Meta(b: TBlock6): string {
return b.location !== '' ? `${b.timeText} · ${b.location}` : b.timeText;
}
/** 今天定时日程是否已全部结束(最晚结束比例 <= 当前时刻比例);只剩全天 / 无定时日程也算已结束。
* 非今天(r<0)返回 false,交由原条件判断(非今天本来就不显示红线)。 */
private w6AllTimedEnded(): boolean {
@@ -584,19 +603,89 @@ struct Widget6x4Card {
ForEach(lane, (b: TBlock6, index: number) => {
Blank().height(this.w6LanePadVp(r.group, lane, index))
Column({ space: 1 }) {
Text(b.title)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (this.w6Lines(b) === 1) {
// 一行:标题 · [只读] · 时间 · 地址(时间用完整起止,宽度不够自然省略;
// 让位顺序:地址先没 → 时间省略 → 标题只保 50%)
Row({ space: 4 }) {
Text(b.title)
.fontSize(10)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '50%' })
.lineHeight(12)
if (!b.writable) {
Text('只读')
.fontSize(8)
.fontColor('#FFFFFF')
.border({ width: 0.5, color: '#FFFFFF' })
.borderRadius(3)
.padding({ left: 4, right: 4, top: 0, bottom: 0 })
.maxLines(1)
}
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '56%' })
.lineHeight(12)
if (b.location !== '') {
Text(b.location)
.fontSize(9)
.fontColor('#B3FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.lineHeight(12)
.layoutWeight(1)
}
}
.width('100%')
if (b.heightRatio * 86400000 >= 60 * 60000) {
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.width('100%')
.alignItems(VerticalAlign.Center)
} else {
// 第一行:标题 · [只读]
Row({ space: 4 }) {
Text(b.title)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: '70%' })
if (!b.writable) {
Text('只读')
.fontSize(9)
.fontColor('#FFFFFF')
.border({ width: 0.5, color: '#FFFFFF' })
.borderRadius(4)
.padding({ left: 4, right: 4, top: 0, bottom: 0 })
.maxLines(1)
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
if (this.w6Lines(b) >= 3) {
Text(b.timeText)
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(b.location)
.fontSize(9)
.fontColor('#B3FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
} else {
Text(this.w6Meta(b))
.fontSize(9)
.fontColor('#E6FFFFFF')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
}
}
.alignItems(HorizontalAlign.Start)
@@ -45,7 +45,7 @@ export default class SyncWorkAbility extends WorkSchedulerExtensionAbility {
ok++;
} catch (err) {
const e = err as BusinessError;
LogUtil.write(`延迟任务同步${acc.name}」失败: ${e.message}`);
LogUtil.write(`延迟任务同步 id=${acc.id} 失败: ${e.message}`);
}
}
// 全部账号同步成功才结束"一次性全量重拉"状态
-10
View File
@@ -28,16 +28,6 @@
"when": "inuse"
}
},
{
"name": "ohos.permission.WRITE_CALENDAR",
"reason": "$string:perm_write_calendar",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
},
{
"name": "ohos.permission.READ_WHOLE_CALENDAR",
"reason": "$string:perm_read_whole_calendar",
@@ -1,3 +1,9 @@
{
"allowToBackupRestore": true
}
"allowToBackupRestore": true,
"excludes": [
"/data/storage/el2/base/preferences/",
"/data/storage/el2/base/haps/entry/preferences/",
"/data/storage/el2/base/files/sync.log",
"/data/storage/el2/base/haps/entry/files/sync.log"
]
}