From 5eaeeb0f4c9f3017c0f224ed29ae9417f3177719 Mon Sep 17 00:00:00 2001 From: Yang Yongquan Date: Tue, 15 Sep 2026 12:51:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E4=BA=86=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Yang Yongquan --- .gitignore | 5 +- entry/src/main/ets/common/AccountStore.ets | 372 ++++++++++++++++-- entry/src/main/ets/common/CardDataService.ets | 3 - entry/src/main/ets/common/DavClient.ets | 107 ++++- entry/src/main/ets/common/EventDb.ets | 2 + entry/src/main/ets/common/IcsUtil.ets | 8 +- entry/src/main/ets/common/SyncEngine.ets | 21 +- entry/src/main/ets/common/TimelineUtil.ets | 8 + entry/src/main/ets/pages/AddAccountPage.ets | 35 ++ entry/src/main/ets/pages/CalendarListPage.ets | 111 ++---- entry/src/main/ets/pages/EditAccountPage.ets | 94 +---- entry/src/main/ets/pages/EventEditPage.ets | 38 +- entry/src/main/ets/pages/Index.ets | 179 +++++++-- entry/src/main/ets/pages/SettingsPage.ets | 98 ++++- entry/src/main/ets/pages/widget/Widget4x4.ets | 113 +++++- entry/src/main/ets/pages/widget/Widget6x4.ets | 113 +++++- .../src/main/ets/syncwork/SyncWorkAbility.ets | 2 +- entry/src/main/module.json5 | 10 - .../resources/base/profile/backup_config.json | 10 +- 19 files changed, 1062 insertions(+), 267 deletions(-) diff --git a/.gitignore b/.gitignore index 6946cd3..9fe4b91 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,7 @@ password.txt # 注意 /hvigor/hvigor-config.json5 是项目构建配置,仍需入库 /hvigor/* !/hvigor/hvigor-config.json5 -/hvigor-ohos-plugin/ \ No newline at end of file +/hvigor-ohos-plugin/ + +# 项目文档(本地维护,不入公共仓库) +/docs/ \ No newline at end of file diff --git a/entry/src/main/ets/common/AccountStore.ets b/entry/src/main/ets/common/AccountStore.ets index ff0b1f3..77c7c87 100644 --- a/entry/src/main/ets/common/AccountStore.ets +++ b/entry/src/main/ets/common/AccountStore.ets @@ -1,11 +1,17 @@ // entry/src/main/ets/common/AccountStore.ets -// DAV 账号持久化(加密版): -// - 账号列表(含密码)序列化为 JSON 后用 AES-256-GCM 整体加密, -// 存储为 caldav_vault 偏好中的 data_b64(iv:密文+tag); -// - AES 密钥首次随机生成后存于同一偏好(key_b64); -// - 旧版明文存储(caldav_account 的 acc_0..n)在首次 loadAll 时自动迁移并清除。 +// DAV 账号持久化(加密): +// - 账号列表(含密码)序列化为 JSON 后整体加密,存储为 caldav_vault 偏好中的 data_b64。 +// - **首选 HUKS 硬件密钥库**:AES-256-GCM 密钥在 TEE 内生成、**不可导出、不落盘**(只留 keyAlias), +// 密文格式 `huks1::`。这样"整机备份被导出"也解不开 —— 密钥不在备份里。 +// 写入前做**自检**: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::` | `aes1::` | `:`(更早旧格式) + */ 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 { return preferences.getPreferences(context, VAULT_STORE); } - /** 获取(或首次生成)AES-256 密钥 */ - private static async aesKey(context: common.Context): Promise { + // ==================== 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 { + 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 { + 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 { + 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 { + 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 { + 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 { 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 { + 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 { - const key: cryptoFramework.SymKey = await Vault.aesKey(context); + /** 软件 AES 加密:返回 `b64(iv):b64(密文+tag)`(不含前缀) */ + private static async softEncrypt(context: common.Context, plain: string): Promise { + 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 { - const sep: number = stored.indexOf(':'); + /** 软件 AES 解密(body = `b64(iv):b64(密文+tag)`);格式不符返回 null */ + private static async softDecrypt(context: common.Context, body: string): Promise { + 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 { + 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 { + 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>).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 { + 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 { + await Vault.clearVault(context); + console.info('[AccountStore] 账号库已重置,可重新添加账号'); + } + + /** + * 探测"现有账号存储是否可读",供写前加固使用。 + * - 无密文(全新安装 / 已清空)→ true:可以安全写入 + * - 有密文且能解密 → true + * - 有密文但解密失败 / 读取异常 → false:**绝不能回写**(否则会整体覆盖、丢失全部账号) + */ + private static async canReadExisting(context: common.Context): Promise { + 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; + } + } } diff --git a/entry/src/main/ets/common/CardDataService.ets b/entry/src/main/ets/common/CardDataService.ets index c9f13c3..2486a11 100644 --- a/entry/src/main/ets/common/CardDataService.ets +++ b/entry/src/main/ets/common/CardDataService.ets @@ -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; diff --git a/entry/src/main/ets/common/DavClient.ets b/entry/src/main/ets/common/DavClient.ets index 50264f7..162b6e3 100644 --- a/entry/src/main/ets/common/DavClient.ets +++ b/entry/src/main/ets/common/DavClient.ets @@ -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 { + const requestBody: string = '' + + '' + + '' + + ''; + 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:getcolor(CalendarServer)/ ical:calendar-color(Apple) + 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 { } } - /** 提取任意命名空间前缀标签的内容 */ + /** + * 提取任意命名空间前缀标签的文本内容(如 `` / ``)。 + * + * 安全说明:此处**刻意用正则而不是 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'); diff --git a/entry/src/main/ets/common/EventDb.ets b/entry/src/main/ets/common/EventDb.ets index 0ea55fb..d450f98 100644 --- a/entry/src/main/ets/common/EventDb.ets +++ b/entry/src/main/ets/common/EventDb.ets @@ -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 { diff --git a/entry/src/main/ets/common/IcsUtil.ets b/entry/src/main/ets/common/IcsUtil.ets index f563a59..1abb681 100644 --- a/entry/src/main/ets/common/IcsUtil.ets +++ b/entry/src/main/ets/common/IcsUtil.ets @@ -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 => { diff --git a/entry/src/main/ets/common/SyncEngine.ets b/entry/src/main/ets/common/SyncEngine.ets index 6f4e967..94a9dc4 100644 --- a/entry/src/main/ets/common/SyncEngine.ets +++ b/entry/src/main/ets/common/SyncEngine.ets @@ -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(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 { 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') ? '有' : '无'})`); } } } diff --git a/entry/src/main/ets/common/TimelineUtil.ets b/entry/src/main/ets/common/TimelineUtil.ets index 17d7f57..2e5a484 100644 --- a/entry/src/main/ets/common/TimelineUtil.ets +++ b/entry/src/main/ets/common/TimelineUtil.ets @@ -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); diff --git a/entry/src/main/ets/pages/AddAccountPage.ets b/entry/src/main/ets/pages/AddAccountPage.ets index 98a946e..a59e8b5 100644 --- a/entry/src/main/ets/pages/AddAccountPage.ets +++ b/entry/src/main/ets/pages/AddAccountPage.ets @@ -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 { + if (this.isLoading) { + return; + } this.isLoading = true; this.statusMsg = '正在连接服务器…'; this.statusOk = false; diff --git a/entry/src/main/ets/pages/CalendarListPage.ets b/entry/src/main/ets/pages/CalendarListPage.ets index 568879f..ab0418d 100644 --- a/entry/src/main/ets/pages/CalendarListPage.ets +++ b/entry/src/main/ets/pages/CalendarListPage.ets @@ -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('pendingDavUrl') ?? ''; this.username = AppStorage.get('pendingDavUsername') ?? ''; this.password = AppStorage.get('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('pendingDavPassword', ''); + AppStorage.setOrCreate('pendingDavUsername', ''); + AppStorage.setOrCreate('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 = '' + - '' + - '' + - ''; - 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 { 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('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; diff --git a/entry/src/main/ets/pages/EditAccountPage.ets b/entry/src/main/ets/pages/EditAccountPage.ets index 808ae86..21dff01 100644 --- a/entry/src/main/ets/pages/EditAccountPage.ets +++ b/entry/src/main/ets/pages/EditAccountPage.ets @@ -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 = '' + - '' + - '' + - ''; - 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 { 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%') diff --git a/entry/src/main/ets/pages/EventEditPage.ets b/entry/src/main/ets/pages/EventEditPage.ets index 84b3e83..85047aa 100644 --- a/entry/src/main/ets/pages/EventEditPage.ets +++ b/entry/src/main/ets/pages/EventEditPage.ets @@ -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 { 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(); }) } } diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 8d56f9d..501edf9 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -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%') diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index ea79c4f..590010b 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -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 { 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 { + 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 { if (this.context === undefined) { @@ -313,12 +356,20 @@ struct SettingsPage { } } - /** 打开外部链接(如开源仓库) */ + /** + * 打开外部链接(隐私政策 / 用户服务协议 / 开源仓库)。 + * 仅放行 http(s):避免 URL 被篡改或误传为 file://、自定义 scheme、intent 类 URI 时, + * 拉起本地文件或任意第三方应用 —— 把"打开网页"严格限定在预期范围内。 + */ private async openUrl(url: string): Promise { 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%') diff --git a/entry/src/main/ets/pages/widget/Widget4x4.ets b/entry/src/main/ets/pages/widget/Widget4x4.ets index 3797458..7e1fb52 100644 --- a/entry/src/main/ets/pages/widget/Widget4x4.ets +++ b/entry/src/main/ets/pages/widget/Widget4x4.ets @@ -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) diff --git a/entry/src/main/ets/pages/widget/Widget6x4.ets b/entry/src/main/ets/pages/widget/Widget6x4.ets index b7148b4..b06144a 100644 --- a/entry/src/main/ets/pages/widget/Widget6x4.ets +++ b/entry/src/main/ets/pages/widget/Widget6x4.ets @@ -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) diff --git a/entry/src/main/ets/syncwork/SyncWorkAbility.ets b/entry/src/main/ets/syncwork/SyncWorkAbility.ets index d1f2b00..8ad61fa 100644 --- a/entry/src/main/ets/syncwork/SyncWorkAbility.ets +++ b/entry/src/main/ets/syncwork/SyncWorkAbility.ets @@ -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}`); } } // 全部账号同步成功才结束"一次性全量重拉"状态 diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index 522d7b5..3f7fc40 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -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", diff --git a/entry/src/main/resources/base/profile/backup_config.json b/entry/src/main/resources/base/profile/backup_config.json index 78f40ae..7b41816 100644 --- a/entry/src/main/resources/base/profile/backup_config.json +++ b/entry/src/main/resources/base/profile/backup_config.json @@ -1,3 +1,9 @@ { - "allowToBackupRestore": true -} \ No newline at end of file + "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" + ] +}