From 5b6be08d9a04fd10c05d27e32b96aa3ab2ecf324 Mon Sep 17 00:00:00 2001 From: Yang Yongquan Date: Mon, 14 Sep 2026 07:55:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E4=BA=86=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E5=8A=A0=E5=AF=86=EF=BC=8C=E6=9B=B4=E6=94=B9?= =?UTF-8?q?=E4=BA=86=E5=8F=AA=E8=AF=BB=E6=97=A5=E7=A8=8B=E7=9A=84=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E6=A0=B7=E5=BC=8F=EF=BC=9B=E6=97=A5=E7=A8=8B=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E5=9C=B0=E5=9D=80=EF=BC=8C=E5=8F=AF=E4=BB=A5=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E9=AB=98=E5=BE=B7=E5=9C=B0=E5=9B=BE=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E4=BA=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- entry/src/main/ets/common/AccountStore.ets | 286 +++++++++++---- entry/src/main/ets/common/AppSettings.ets | 26 ++ entry/src/main/ets/pages/AccountsPage.ets | 6 +- entry/src/main/ets/pages/Index.ets | 333 ++++++++++++++++-- entry/src/main/module.json5 | 20 ++ .../main/resources/base/element/string.json | 8 + 6 files changed, 588 insertions(+), 91 deletions(-) diff --git a/entry/src/main/ets/common/AccountStore.ets b/entry/src/main/ets/common/AccountStore.ets index 53d52e9..ff0b1f3 100644 --- a/entry/src/main/ets/common/AccountStore.ets +++ b/entry/src/main/ets/common/AccountStore.ets @@ -1,5 +1,12 @@ // 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 时自动迁移并清除。 import { preferences } from '@kit.ArkData'; +import { cryptoFramework } from '@kit.CryptoArchitectureKit'; +import { util } from '@kit.ArkTS'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; @@ -11,6 +18,15 @@ export const TYPE_KEYS: string[] = [TYPE_CALDAV, TYPE_CARDDAV, TYPE_WEBDAV]; /** 本机事件所属的虚拟日历 key */ export const LOCAL_CAL_KEY: string = 'local'; +/** 旧版明文存储(迁移后清除) */ +const LEGACY_STORE: string = 'caldav_account'; +const LEGACY_COUNT_KEY: string = 'accountCount'; + +/** 加密存储 */ +const VAULT_STORE: string = 'caldav_vault'; +const VAULT_KEY: string = 'key_b64'; +const VAULT_DATA: string = 'data_b64'; + /** * DAV 账号(@Observed 使同步状态变化能刷新列表 UI) */ @@ -52,13 +68,100 @@ export class BookPalette { } } +function bytesToB64(u: Uint8Array): string { + return new util.Base64Helper().encodeToStringSync(u); +} + +function b64ToBytes(s: string): Uint8Array { + return new util.Base64Helper().decodeSync(s); +} + +function strToBytes(s: string): Uint8Array { + return new util.TextEncoder().encodeInto(s); +} + +function bytesToStr(u: Uint8Array): string { + return new util.TextDecoder('utf-8').decodeToString(u); +} + +/** 账号加密存储实现(AES-256-GCM,密钥持久化) */ +class Vault { + private static keyB64: string = ''; // 进程内缓存,避免每轮同步重复读偏好 + + static async prefs(context: common.Context): Promise { + return preferences.getPreferences(context, VAULT_STORE); + } + + /** 获取(或首次生成)AES-256 密钥 */ + private static async aesKey(context: common.Context): Promise { + if (Vault.keyB64 === '') { + const store: preferences.Preferences = await Vault.prefs(context); + Vault.keyB64 = await store.get(VAULT_KEY, '') as string; + } + 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); + const store: preferences.Preferences = await Vault.prefs(context); + await store.put(VAULT_KEY, Vault.keyB64); + await store.flush(); + return key; + } + + private static gcmParams(iv: Uint8Array, authTag: Uint8Array): cryptoFramework.GcmParamsSpec { + return { + algName: 'GcmParamsSpec', + iv: { data: iv }, + aad: { data: new Uint8Array(0) }, + authTag: { data: authTag } + }; + } + + /** 加密:返回 base64(iv) + ':' + base64(密文+authTag) */ + static async encrypt(context: common.Context, plain: string): Promise { + const key: cryptoFramework.SymKey = await Vault.aesKey(context); + const iv: Uint8Array = + cryptoFramework.createRandom().generateRandomSync(12).data; + const cipher: cryptoFramework.Cipher = + cryptoFramework.createCipher('AES256|GCM|NoPadding'); + await cipher.init(cryptoFramework.CryptoMode.ENCRYPT_MODE, key, + Vault.gcmParams(iv, new Uint8Array(16))); + const out: cryptoFramework.DataBlob = + await cipher.doFinal({ data: strToBytes(plain) }); + return `${bytesToB64(iv)}:${bytesToB64(out.data)}`; + } + + /** 解密:失败返回 null(调用方按"无账号"或迁移路径处理) */ + static async decrypt(context: common.Context, stored: string): Promise { + const sep: number = stored.indexOf(':'); + if (sep <= 0) { + return null; + } + const iv: Uint8Array = b64ToBytes(stored.substring(0, sep)); + const blob: Uint8Array = b64ToBytes(stored.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 cipher: cryptoFramework.Cipher = + cryptoFramework.createCipher('AES256|GCM|NoPadding'); + await cipher.init(cryptoFramework.CryptoMode.DECRYPT_MODE, key, + Vault.gcmParams(iv, tag)); + const out: cryptoFramework.DataBlob = await cipher.doFinal({ data: ct }); + return bytesToStr(out.data); + } +} + /** - * 账号持久化:每个账号编码为一个分隔符字符串存储(acc_0、acc_1…),避免 JSON 结构化类型问题 + * 账号持久化:加密 JSON 存储 + 旧版明文自动迁移 */ export class AccountStore { - private static readonly STORE: string = 'caldav_account'; - private static readonly COUNT_KEY: string = 'accountCount'; - /** 规范化颜色:#RRGGBBAA → #RRGGBB;非法返回空串 */ static normalizeColor(raw: string): string { const v: string = raw.trim(); @@ -71,25 +174,6 @@ export class AccountStore { return ''; } - private static encodeAccount(acc: DavAccount): string { - const safe = (s: string): string => s.split('|').join('∥'); - const parts: string[] = [ - safe(acc.type), - safe(acc.name), - safe(acc.serverUrl), - safe(acc.username), - safe(acc.password), - String(acc.itemCount), - safe(acc.lastSyncTime), - acc.calendarHrefs.join(';'), - acc.calendarNames.join(';'), - acc.calendarColors.join(';'), - safe(acc.id), - acc.calendarWritable.join(';') - ]; - return parts.join('|'); - } - private static decodeAccount(raw: string): DavAccount | null { const parts: string[] = raw.split('|'); if (parts.length < 9) { @@ -119,69 +203,153 @@ export class AccountStore { return acc; } - static async loadAll(context: common.Context): Promise { - const result: DavAccount[] = []; - let migrated: boolean = false; + /** 旧版明文账号编码(迁移旧数据时使用) */ + private static encodeLegacyAccount(acc: DavAccount): string { + const safe = (s: string): string => s.split('|').join('∥'); + const parts: string[] = [ + safe(acc.type), + safe(acc.name), + safe(acc.serverUrl), + safe(acc.username), + safe(acc.password), + String(acc.itemCount), + safe(acc.lastSyncTime), + acc.calendarHrefs.join(';'), + acc.calendarNames.join(';'), + acc.calendarColors.join(';'), + safe(acc.id), + acc.calendarWritable.join(';') + ]; + return parts.join('|'); + } + + /** 从旧版明文存储读取(无则返回 null) */ + private static async loadLegacy(context: common.Context): Promise { try { const store: preferences.Preferences = - await preferences.getPreferences(context, AccountStore.STORE); - const count: number = await store.get(AccountStore.COUNT_KEY, 0) as number; - console.info(`[AccountStore] loadAll: count=${count}`); + await preferences.getPreferences(context, LEGACY_STORE); + const count: number = await store.get(LEGACY_COUNT_KEY, 0) as number; + if (count <= 0) { + return null; + } + const result: DavAccount[] = []; for (let i = 0; i < count; i++) { const raw = await store.get(`acc_${i}`, '') as string; if (raw === '') { - console.warn(`[AccountStore] loadAll: acc_${i} 为空`); continue; } const acc = AccountStore.decodeAccount(raw); if (acc !== null) { if (acc.id === '') { - // 旧版本存储没有 id:补发一个并标记需要回写,保证 id 跨启动稳定 acc.id = `acc${Date.now()}_${i}`; - migrated = true; } result.push(acc); - } else { - console.error(`[AccountStore] loadAll: acc_${i} 解码失败,raw 长度=${raw.length}`); } } + return result; + } catch (err) { + return null; + } + } + + /** 清除旧版明文存储(迁移完成后调用) */ + private static async clearLegacy(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, LEGACY_STORE); + const count: number = await store.get(LEGACY_COUNT_KEY, 0) as number; + for (let i = 0; i < count; i++) { + store.delete(`acc_${i}`); + } + store.delete(LEGACY_COUNT_KEY); + await store.flush(); + } catch (err) { + // 清理失败不影响使用,下次迁移再试 + } + } + + static async loadAll(context: common.Context): Promise { + // 1) 新加密存储 + try { + const store: preferences.Preferences = await Vault.prefs(context); + const data: string = await store.get(VAULT_DATA, '') as string; + if (data !== '') { + const plain: string | null = await Vault.decrypt(context, data); + if (plain !== null) { + const arr: Array> = + JSON.parse(plain) as Array>; + const result: DavAccount[] = []; + for (const o of arr) { + const a = new DavAccount(); + a.id = o['id'] !== undefined ? o['id'] as string : ''; + a.type = o['type'] !== undefined ? o['type'] as string : TYPE_CALDAV; + a.name = o['name'] !== undefined ? o['name'] as string : ''; + a.serverUrl = o['serverUrl'] !== undefined ? o['serverUrl'] as string : ''; + a.username = o['username'] !== undefined ? o['username'] as string : ''; + a.password = o['password'] !== undefined ? o['password'] as string : ''; + a.calendarHrefs = o['calendarHrefs'] !== undefined ? o['calendarHrefs'] as string[] : []; + a.calendarNames = o['calendarNames'] !== undefined ? o['calendarNames'] as string[] : []; + a.calendarColors = o['calendarColors'] !== undefined ? o['calendarColors'] as string[] : []; + a.calendarWritable = o['calendarWritable'] !== undefined ? o['calendarWritable'] as string[] : []; + a.itemCount = o['itemCount'] !== undefined ? o['itemCount'] as number : 0; + a.lastSyncTime = o['lastSyncTime'] !== undefined ? o['lastSyncTime'] as string : ''; + if (a.id === '') { + a.id = `acc${Date.now()}_${result.length}`; + } + result.push(a); + } + console.info(`[AccountStore] loadAll: ${result.length} 个账号(加密存储)`); + return result; + } + console.error('[AccountStore] 加密账号数据解密失败'); + } } catch (err) { const e = err as BusinessError; - console.error(`读取账号失败: ${e.message}`); + console.error(`[AccountStore] 读取加密账号失败: ${e.message}`); } - if (migrated) { - try { - await AccountStore.saveAll(context, result); - console.info('旧版账号数据已迁移:补充持久化账号 id'); - } catch (err) { - const e = err as BusinessError; - console.error(`账号 id 迁移回写失败: ${e.message}`); - } + + // 2) 旧版明文存储 → 迁移 + const legacy: DavAccount[] | null = await AccountStore.loadLegacy(context); + if (legacy !== null) { + console.info(`[AccountStore] 迁移 ${legacy.length} 个明文账号到加密存储`); + await AccountStore.saveAll(context, legacy, true); + await AccountStore.clearLegacy(context); + return legacy; } - return result; + return []; } /** - * 保存全部账号。 - * 防御:存储里已有账号时,禁止用空列表覆盖(调用方若因读取异常拿到空列表再回写, + * 保存全部账号(整体加密写入)。 + * 防御:当前已有账号时,禁止用空列表覆盖(调用方因读取异常拿到空列表再回写, * 会把所有账号抹掉)。仅删除账号的合法场景通过 force=true 放行。 */ static async saveAll(context: common.Context, accounts: DavAccount[], force: boolean = false): Promise { - const store: preferences.Preferences = - await preferences.getPreferences(context, AccountStore.STORE); - const oldCount: number = await store.get(AccountStore.COUNT_KEY, 0) as number; - if (accounts.length === 0 && oldCount > 0 && !force) { - console.error(`[AccountStore] 拒绝用空列表覆盖账号存储(原有 ${oldCount} 个账号)`); + // 防御:先看加密存储里现有数量(空列表覆盖保护) + let existing: number = -1; + try { + const store: preferences.Preferences = await Vault.prefs(context); + const data: string = await store.get(VAULT_DATA, '') as string; + if (data !== '') { + const plain: string | null = await Vault.decrypt(context, data); + if (plain !== null) { + existing = (JSON.parse(plain) as Array>).length; + } + } + } catch (err) { + // 读取失败按"未知"处理,不拦截 + existing = -1; + } + if (accounts.length === 0 && existing > 0 && !force) { + console.error(`[AccountStore] 拒绝用空列表覆盖账号存储(原有 ${existing} 个账号)`); throw new Error('账号列表为空,已阻止覆盖存储(保护原有账号数据)'); } - for (let i = 0; i < oldCount; i++) { - store.delete(`acc_${i}`); - } - for (let i = 0; i < accounts.length; i++) { - await store.put(`acc_${i}`, AccountStore.encodeAccount(accounts[i])); - } - await store.put(AccountStore.COUNT_KEY, accounts.length); + const plain: string = JSON.stringify(accounts); + const enc: string = await Vault.encrypt(context, plain); + const store: preferences.Preferences = await Vault.prefs(context); + await store.put(VAULT_DATA, enc); await store.flush(); + console.info(`[AccountStore] saveAll: ${accounts.length} 个账号已加密写入`); } static async addAccount(context: common.Context, acc: DavAccount): Promise { @@ -189,4 +357,4 @@ export class AccountStore { list.push(acc); await AccountStore.saveAll(context, list); } -} \ No newline at end of file +} diff --git a/entry/src/main/ets/common/AppSettings.ets b/entry/src/main/ets/common/AppSettings.ets index ea33ac4..8cb8881 100644 --- a/entry/src/main/ets/common/AppSettings.ets +++ b/entry/src/main/ets/common/AppSettings.ets @@ -138,6 +138,32 @@ export class AppSettings { } } + /** + * 删除账号后的设置清理:移除该账号名下所有日历本的 + * 手动只读标记、提醒静音标记,并清空指向它的备份目标。 + */ + static async cleanupAccountSettings(context: common.Context, accId: string): Promise { + try { + const manual: string[] = await AppSettings.getManualReadonlyKeys(context); + const manual2: string[] = manual.filter((k: string): boolean => !k.startsWith(`${accId}_`)); + if (manual2.length !== manual.length) { + await AppSettings.setManualReadonlyKeys(context, manual2); + } + const muted: string[] = await AppSettings.getMutedReminderKeys(context); + const muted2: string[] = muted.filter((k: string): boolean => !k.startsWith(`${accId}_`)); + if (muted2.length !== muted.length) { + await AppSettings.setMutedReminderKeys(context, muted2); + } + const backupKey: string = await AppSettings.getBackupCalKey(context); + if (backupKey.startsWith(`${accId}_`)) { + await AppSettings.setBackupCalKey(context, ''); + } + } catch (err) { + const e = err as BusinessError; + console.error(`清理账号设置失败: ${e.message}`); + } + } + /** 系统日历模式:display = 仅混合显示;backup = 本地系统日程备份到选定 CalDAV 日历本 */ static async getSysCalMode(context: common.Context): Promise { try { diff --git a/entry/src/main/ets/pages/AccountsPage.ets b/entry/src/main/ets/pages/AccountsPage.ets index a95350c..187cfad 100644 --- a/entry/src/main/ets/pages/AccountsPage.ets +++ b/entry/src/main/ets/pages/AccountsPage.ets @@ -8,6 +8,7 @@ import { SyncEngine } from '../common/SyncEngine'; import { EditNavParams } from './EditAccountPage'; import { EventDb } from '../common/EventDb'; import { LogUtil } from '../common/LogUtil'; +import { AppSettings } from '../common/AppSettings'; import { ScreenKeeper } from '../common/ScreenKeeper'; @Entry @@ -150,8 +151,11 @@ struct AccountsPage { } try { this.accounts = this.accounts.filter((a: DavAccount): boolean => a.id !== acc.id); - await AccountStore.saveAll(context, this.accounts); + // force=true:删除最后一个账号时列表为空是合法场景,允许覆盖 + await AccountStore.saveAll(context, this.accounts, true); + // 删除该账号全部本地日程/待办,并清理只读/静音/备份目标等关联设置 await EventDb.deleteAccountEvents(context, acc.id); + await AppSettings.cleanupAccountSettings(context, acc.id); this.getUIContext().getPromptAction() .showToast({ message: `「${acc.name}」已删除` }); } catch (err) { diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 966b13c..5c5f7ab 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -2,7 +2,8 @@ // 同步日历主界面:月视图(左右滑动翻月)/ 周视图 / 日视图 / 日程列表 // 混合展示 DAV 与系统日历;每分钟自动同步;可"回到今天" import { mediaquery, router } from '@kit.ArkUI'; -import { common } from '@kit.AbilityKit'; +import { common, abilityAccessCtrl, bundleManager } from '@kit.AbilityKit'; +import { geoLocationManager } from '@kit.LocationKit'; import { BusinessError, pasteboard } from '@kit.BasicServicesKit'; import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore'; import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService'; @@ -55,6 +56,8 @@ struct Index { private autoSyncTimer: number = -1; private lastSyncTime: number = 0; private permissionAsked: boolean = false; + @State detailShow: boolean = false; // 只读日程详情半屏弹层 + @State detailEvent: DisplayEvent | null = null; aboutToAppear(): void { const ctx = this.getUIContext().getHostContext(); @@ -433,42 +436,304 @@ struct Index { router.pushUrl({ url: 'pages/EventEditPage' }); } - /** 只读日程详情弹窗 */ + /** 只读日程详情:改为卡片式半屏弹层(与编辑页风格一致) */ private showEventDetail(e: DisplayEvent): void { - const lines: string[] = []; + this.detailEvent = e; + this.detailShow = true; + } + + private detailStartText(e: DisplayEvent): string { if (e.isAllDay) { - lines.push(`时间:全天 ${this.fmtDateCn(e.startTime)}`); - if (this.spansDays(e)) { - lines.push(` ~ ${this.fmtDateCn(e.endTime)}`); - } - } else if (this.spansDays(e)) { - lines.push(`时间:${this.fmtDateCn(e.startTime)} ${this.fmtTime(e.startTime)}`); - lines.push(` ~ ${this.fmtDateCn(e.endTime)} ${this.fmtTime(e.endTime)}`); + return `全天 ${this.fmtDateCn(e.startTime)}`; + } + return `${this.fmtDateCn(e.startTime)} ${this.fmtTime(e.startTime)}`; + } + + private detailEndText(e: DisplayEvent): string { + const sameDay: boolean = this.startOfDay(e.startTime) === this.startOfDay(e.endTime); + if (e.isAllDay) { + return `全天 ${this.fmtDateCn(e.endTime)}`; + } + if (sameDay) { + return this.fmtTime(e.endTime); + } + return `${this.fmtDateCn(e.endTime)} ${this.fmtTime(e.endTime)}`; + } + + /** 点击地点:拉起高德 App 路线规划,地址经系统地理编码转为坐标后传入目的地 */ + private async openInAmap(address: string): Promise { + const context = this.getUIContext().getHostContext(); + if (context === undefined || address === '') { + return; + } + const ctx = context as common.UIAbilityContext; + const enc: string = encodeURIComponent(address); + // 高德鸿蒙深链 dlat/dlon 为必填(dname 仅作名称显示),先地理编码拿坐标 + let coords: number[] | null = null; + if (await this.ensureLocationPermission()) { + coords = await this.geocodeAddress(address); + } + const base: string = 'amapuri://route/plan/?sourceApplication=SyncCalendar'; + let link: string; + if (coords !== null) { + // 系统地理编码返回 WGS84,高德要求 GCJ02(dev=0),做本地转换 + const gcj: number[] = this.wgs84ToGcj02(coords[0], coords[1]); + link = `${base}&dlat=${gcj[0].toFixed(6)}&dlon=${gcj[1].toFixed(6)}&dname=${enc}&dev=0&t=0`; } else { - lines.push(`时间:${this.fmtDateCn(e.startTime)} ${this.fmtTime(e.startTime)} ~ ${this.fmtTime(e.endTime)}`); + // 无坐标时尽力而为:部分版本只认坐标,目的地可能为空 + link = `${base}&dname=${enc}&dev=0&t=0`; } - if (e.location !== '') { - lines.push(`地点:${e.location}`); + try { + await ctx.openLink(link); + LogUtil.write(`已拉起高德导航:${address}(坐标=${coords !== null ? '有' : '无'})`); + return; + } catch (err) { + LogUtil.write(`高德深链打开失败:${(err as BusinessError).message}`); } - if (e.recurring) { - lines.push('重复:是'); - } - if (e.calName !== '') { - lines.push(`日历本:${e.calName}${e.isSystem ? '' : '(只读)'}`); - } - if (e.description !== '') { - lines.push(`备注:${e.description}`); - } - this.getUIContext().showAlertDialog({ - title: e.title === '' ? '(无标题)' : e.title, - message: lines.join('\n'), - autoCancel: true, - alignment: DialogAlignment.Center, - primaryButton: { - value: '关闭', - action: (): void => {} + this.getUIContext().getPromptAction().showToast({ message: '未安装高德地图或无法打开' }); + } + + /** 确认定位权限(地理编码依赖),未授权时向用户申请一次 */ + private async ensureLocationPermission(): Promise { + try { + const atManager = abilityAccessCtrl.createAtManager(); + const bundleInfo = bundleManager.getBundleInfoForSelfSync( + bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION); + const tokenId = bundleInfo.appInfo.accessTokenId; + const status = await atManager.checkAccessToken(tokenId, 'ohos.permission.LOCATION'); + if (status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) { + return true; } - }); + } catch (err) { + // 未授权会走申请流程 + } + try { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + const atManager = abilityAccessCtrl.createAtManager(); + // LOCATION 与 APPROXIMATELY_LOCATION 必须一起申请 + const result = await atManager.requestPermissionsFromUser(context, + ['ohos.permission.LOCATION', 'ohos.permission.APPROXIMATELY_LOCATION']); + return result.authResults.every((r: number): boolean => r === 0); + } catch (err) { + return false; + } + } + + /** 正向地理编码:地址 → 坐标(系统服务,无需高德 key) */ + private async geocodeAddress(address: string): Promise { + try { + if (!geoLocationManager.isGeocoderAvailable()) { + LogUtil.write('系统地理编码服务不可用'); + return null; + } + const req: geoLocationManager.GeoCodeRequest = { description: address, maxItems: 1 }; + const list: geoLocationManager.GeoAddress[] = + await geoLocationManager.getAddressesFromLocationName(req); + if (list.length > 0 && list[0].latitude !== undefined && list[0].longitude !== undefined) { + return [list[0].latitude, list[0].longitude]; + } + LogUtil.write(`地理编码无结果:${address}`); + } catch (err) { + LogUtil.write(`地理编码失败:${(err as BusinessError).message}`); + } + return null; + } + + /** WGS84 → GCJ02 火星坐标转换(中国境内;标准偏移算法) */ + private wgs84ToGcj02(wlat: number, wlon: number): number[] { + const a: number = 6378245.0; + const ee: number = 0.00669342162296594323; + const transformLat = (x: number, y: number): number => { + let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + + 0.2 * Math.sqrt(Math.abs(x)); + ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0; + ret += (20.0 * Math.sin(y * Math.PI) + 40.0 * Math.sin(y / 3.0 * Math.PI)) * 2.0 / 3.0; + ret += (160.0 * Math.sin(y / 12.0 * Math.PI) + 320.0 * Math.sin(y * Math.PI / 30.0)) * 2.0 / 3.0; + return ret; + }; + const transformLon = (x: number, y: number): number => { + let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x)); + ret += (20.0 * Math.sin(6.0 * x * Math.PI) + 20.0 * Math.sin(2.0 * x * Math.PI)) * 2.0 / 3.0; + ret += (20.0 * Math.sin(x * Math.PI) + 40.0 * Math.sin(x / 3.0 * Math.PI)) * 2.0 / 3.0; + ret += (150.0 * Math.sin(x / 12.0 * Math.PI) + 300.0 * Math.sin(x / 30.0 * Math.PI)) * 2.0 / 3.0; + return ret; + }; + if (wlon < 72.004 || wlon > 137.8347 || wlat < 0.8293 || wlat > 55.8271) { + return [wlat, wlon]; // 中国境外无偏移 + } + let dLat: number = transformLat(wlon - 105.0, wlat - 35.0); + let dLon: number = transformLon(wlon - 105.0, wlat - 35.0); + const radLat: number = wlat / 180.0 * Math.PI; + let magic: number = Math.sin(radLat); + magic = 1 - ee * magic * magic; + const sqrtMagic: number = Math.sqrt(magic); + dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * Math.PI); + dLon = (dLon * 180.0) / (a / sqrtMagic * Math.cos(radLat) * Math.PI); + return [wlat + dLat, wlon + dLon]; + } + + @Builder + detailTag(text: string) { + Text(text) + .fontSize(11) + .fontColor($r('app.color.brand')) + .padding({ left: 8, right: 8, top: 3, bottom: 3 }) + .borderRadius(9) + .border({ width: 1, color: $r('app.color.brand') }) + } + + @Builder + detailRow(label: string, value: string) { + Row({ space: 12 }) { + Text(label) + .fontSize(14) + .fontColor($r('app.color.text_secondary')) + .width(56) + Text(value) + .fontSize(15) + .fontColor($r('app.color.text_primary')) + .textAlign(TextAlign.End) + .layoutWeight(1) + } + .width('100%') + } + + @Builder + detailSheet() { + Column({ space: 12 }) { + if (this.detailEvent !== null) { + // 标题区:色点 + 标题 + 徽标紧跟标题 + 自绘关闭按钮(与标题同行) + Row({ space: 8 }) { + Circle() + .fill(this.detailEvent.color) + .width(12) + .height(12) + Text(this.detailEvent.title === '' ? '(无标题)' : this.detailEvent.title) + .fontSize(19) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .constraintSize({ maxWidth: '58%' }) + if (this.detailEvent.isSystem) { + this.detailTag('系统') + } else if (!this.detailEvent.writable) { + this.detailTag('只读') + } + Blank() + Text('✕') + .fontSize(16) + .fontColor($r('app.color.text_secondary')) + .width(30) + .height(30) + .textAlign(TextAlign.Center) + .borderRadius(15) + .backgroundColor($r('app.color.card_bg')) + .onClick(() => { + this.detailShow = false; + }) + } + .width('100%') + + Scroll() { + Column({ space: 12 }) { + // 时间卡片 + Column({ space: 12 }) { + if (this.detailEvent.isAllDay && !this.spansDays(this.detailEvent)) { + this.detailRow('时间', `全天 ${this.fmtDateCn(this.detailEvent.startTime)}`) + } else { + this.detailRow('开始', this.detailStartText(this.detailEvent)) + Divider().color($r('app.color.shadow_color')) + this.detailRow('结束', this.detailEndText(this.detailEvent)) + } + } + .width('100%') + .padding(14) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + + // 日历本 / 重复 + Column({ space: 12 }) { + Row({ space: 12 }) { + Text('日历本') + .fontSize(14) + .fontColor($r('app.color.text_secondary')) + .width(56) + Text(this.detailEvent.calName === '' ? '-' : this.detailEvent.calName) + .fontSize(15) + .fontColor($r('app.color.text_primary')) + .textAlign(TextAlign.End) + .layoutWeight(1) + } + .width('100%') + if (this.detailEvent.recurring) { + Divider().color($r('app.color.shadow_color')) + this.detailRow('重复', '重复日程') + } + } + .width('100%') + .padding(14) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + + // 地点:点击打开高德地图导航 + if (this.detailEvent.location !== '') { + Row({ space: 8 }) { + Column({ space: 4 }) { + Text('地点') + .fontSize(14) + .fontColor($r('app.color.text_secondary')) + Text(this.detailEvent.location) + .fontSize(15) + .fontColor($r('app.color.brand')) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + Text('导航 ›') + .fontSize(14) + .fontColor($r('app.color.brand')) + .fontWeight(FontWeight.Medium) + } + .width('100%') + .padding(14) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + .onClick(() => { + if (this.detailEvent !== null) { + this.openInAmap(this.detailEvent.location); + } + }) + } + + // 备注 + if (this.detailEvent.description !== '') { + Column({ space: 8 }) { + Text('备注') + .fontSize(14) + .fontColor($r('app.color.text_secondary')) + Text(this.detailEvent.description) + .fontSize(15) + .fontColor($r('app.color.text_primary')) + .width('100%') + } + .alignItems(HorizontalAlign.Start) + .width('100%') + .padding(14) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + } + } + .width('100%') + } + .scrollBar(BarState.Auto) + .align(Alignment.Top) + .layoutWeight(1) + } + } + .width('100%') + .height('100%') + .padding({ left: 16, right: 16, top: 16, bottom: 16 }) + .alignItems(HorizontalAlign.Start) } private addEvent(): void { @@ -520,6 +785,12 @@ struct Index { } .width('100%') .height('100%') + .bindSheet($$this.detailShow, this.detailSheet(), { + height: 540, + dragBar: true, + showClose: false, // 关闭按钮自绘在标题行右侧,避免系统按钮压住徽标 + backgroundColor: $r('app.color.page_bg') + }) } @Builder diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index 527a1d2..522d7b5 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -47,6 +47,26 @@ ], "when": "inuse" } + }, + { + "name": "ohos.permission.LOCATION", + "reason": "$string:perm_location", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "inuse" + } + }, + { + "name": "ohos.permission.APPROXIMATELY_LOCATION", + "reason": "$string:perm_location_approx", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "inuse" + } } ], "deliveryWithInstall": true, diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index 2c0f745..fcd287e 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -24,6 +24,14 @@ "name": "perm_read_whole_calendar", "value": "读取所有日历账户的日程,用于在日历中统一展示" }, + { + "name": "perm_location", + "value": "将日程中的地址转换为坐标,用于发起高德地图导航" + }, + { + "name": "perm_location_approx", + "value": "将日程中的地址转换为坐标,用于发起高德地图导航" + }, { "name": "card_ability_desc", "value": "同步日历服务卡片"