diff --git a/AppScope/app.json5 b/AppScope/app.json5 index bf6f90d..6169045 100644 --- a/AppScope/app.json5 +++ b/AppScope/app.json5 @@ -2,8 +2,8 @@ "app": { "bundleName": "synccalendar.yangyq.net", "vendor": "yangyq", - "versionCode": 100, - "versionName": "0.0.1", + "versionCode": 200, + "versionName": "0.0.2", "buildVersion": "1", "icon": "$media:layered_image", "label": "$string:app_name" diff --git a/entry/src/main/ets/common/AppSettings.ets b/entry/src/main/ets/common/AppSettings.ets index dba47c0..9a4b39c 100644 --- a/entry/src/main/ets/common/AppSettings.ets +++ b/entry/src/main/ets/common/AppSettings.ets @@ -365,14 +365,18 @@ export class AppSettings { } } - /** 是否混合显示系统日历日程(默认开) */ + /** + * 是否混合显示系统日历日程。 + * ⚠️ 默认**关闭**:读取系统日历需要"读取全部日程"敏感权限,按《审核指南》7.17 + * 只能在用户主动打开该开关时才申请,因此默认值必须与实际授权状态一致(无授权 = 关)。 + */ static async getShowSystemCalendar(context: common.Context): Promise { try { const store: preferences.Preferences = await preferences.getPreferences(context, AppSettings.STORE); - return await store.get(AppSettings.KEY_SHOW_SYSTEM, true) as boolean; + return await store.get(AppSettings.KEY_SHOW_SYSTEM, false) as boolean; } catch (err) { - return true; + return false; } } diff --git a/entry/src/main/ets/common/CalendarDataService.ets b/entry/src/main/ets/common/CalendarDataService.ets index 3204203..6505a0b 100644 --- a/entry/src/main/ets/common/CalendarDataService.ets +++ b/entry/src/main/ets/common/CalendarDataService.ets @@ -1,6 +1,6 @@ // entry/src/main/ets/common/CalendarDataService.ets // 合并数据源:本地库(DAV 同步 + 本机事件) + 系统日历(只读展示) -import { common, abilityAccessCtrl, Permissions } from '@kit.AbilityKit'; +import { common, abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { calendarManager } from '@kit.CalendarKit'; import { AccountStore, CalSource, BookPalette, LOCAL_CAL_KEY, DavAccount } from './AccountStore'; @@ -29,15 +29,52 @@ export class DisplayEvent { } export class CalendarDataService { - /** 申请读取全部日历权限(混合展示系统日历需要) */ - static async ensureSystemCalendarPermission(context: common.UIAbilityContext): Promise { + /** 本功能依赖的日历权限:读取本应用日历 + 读取全部日历(即"读取全部日程") */ + private static readonly CALENDAR_PERMISSIONS: Permissions[] = [ + 'ohos.permission.READ_CALENDAR', + 'ohos.permission.READ_WHOLE_CALENDAR' + ]; + + /** + * 是否已获得"读取全部日程"权限。 + * **只检查、绝不弹窗** —— 供界面判断状态、决定是否需要引导用户主动授权。 + * 注意:checkAccessToken 只反映本应用授权状态,不会触发任何系统弹窗。 + */ + static async hasSystemCalendarPermission(): Promise { try { const atManager = abilityAccessCtrl.createAtManager(); - const permissions: Permissions[] = [ - 'ohos.permission.READ_CALENDAR', - 'ohos.permission.READ_WHOLE_CALENDAR' - ]; - const result = await atManager.requestPermissionsFromUser(context, permissions); + const info = bundleManager.getBundleInfoForSelfSync( + bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION); + const tokenId: number = info.appInfo.accessTokenId; + for (const p of CalendarDataService.CALENDAR_PERMISSIONS) { + const status = await atManager.checkAccessToken(tokenId, p); + if (status !== abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) { + return false; + } + } + return true; + } catch (err) { + return false; + } + } + + /** + * 申请"读取全部日程"权限(READ_CALENDAR + READ_WHOLE_CALENDAR)。 + * + * ⚠️ 合规约束(《审核指南》7.17):**只能在用户主动点击使用对应功能时调用**, + * 例如在设置页打开"混合显示系统日历"开关、把系统日历模式切到"备份到 CalDAV 日历本"。 + * 严禁在 App 启动 / 首次打开 / 未同意隐私政策等用户未主动操作的时机调用。 + * + * 已授权时直接返回 true,不会再弹窗(先 checkAccessToken,避免多余弹窗)。 + */ + static async requestSystemCalendarPermission(context: common.UIAbilityContext): Promise { + if (await CalendarDataService.hasSystemCalendarPermission()) { + return true; + } + try { + const atManager = abilityAccessCtrl.createAtManager(); + const result = await atManager.requestPermissionsFromUser( + context, CalendarDataService.CALENDAR_PERMISSIONS); for (const r of result.authResults) { if (r !== 0) { return false; diff --git a/entry/src/main/ets/common/TipsPanel.ets b/entry/src/main/ets/common/TipsPanel.ets index b0bd617..4b390a0 100644 --- a/entry/src/main/ets/common/TipsPanel.ets +++ b/entry/src/main/ets/common/TipsPanel.ets @@ -28,8 +28,8 @@ export struct TipsPanel { { icon: $r('app.media.tip_calendar'), title: '系统日历,也能一起管', - desc: '手机系统日历可与 CalDAV 日历混合显示在同一时间轴;还能把系统日历本通过 CalDAV 同步备份,' + - '一处编辑、处处更新。' + desc: '手机系统日历可与 CalDAV 日历混合显示在同一时间轴,也能把系统日历本通过 CalDAV 同步备份,' + + '一处编辑、处处更新。到「设置 → 混合显示系统日历」开启并授权即可。' }, { icon: $r('app.media.tip_mute'), diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index e80d456..628de9d 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,11 +1,9 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { window } from '@kit.ArkUI'; -import { notificationManager } from '@kit.NotificationKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { BackgroundSyncService } from '../common/BackgroundSyncService'; import { LogUtil } from '../common/LogUtil'; -import { AppSettings } from '../common/AppSettings'; const DOMAIN = 0x0000; @@ -19,24 +17,11 @@ export default class EntryAbility extends UIAbility { BackgroundSyncService.initOnLaunch(this.context); // 服务卡片"添加"按钮跳转:把 widgetAction=add 透传给主页面(在 AppStorage 中暂存) this.handleWidgetParam(want); - // 通知权限:日程提醒、后台同步常驻通知都依赖它(系统只弹一次授权框)。 - // 必须在用户同意《隐私政策》之后才申请 —— 同意前不得申请任何权限; - // 首次安装此处跳过,用户点"同意并继续"后由 Index.initPermissionAndLoad() 补申请。 - AppSettings.getPolicyAgreed(this.context) - .then((agreed: boolean): Promise => { - if (!agreed) { - LogUtil.write('未同意隐私政策:跳过通知权限申请'); - return Promise.resolve(); - } - return notificationManager.isNotificationEnabled() - .then((enabled: boolean): Promise => { - if (!enabled) { - return notificationManager.requestEnableNotification(this.context).catch((): void => {}); - } - return Promise.resolve(); - }); - }) - .catch((): void => {}); + // ⚠️ 这里**不再申请任何权限**(含通知权限)。 + // 依据《审核指南》7.17:应用不得在用户未主动点击对应功能时提前弹窗申请权限。 + // 通知权限改为在用户主动操作时申请: + // ① 保存带提醒的日程(EventEditPage)② 开启「后台持续同步」(SettingsPage) + // ③ 设置页「通知权限」→「去开启通知」按钮 } onDestroy(): void { diff --git a/entry/src/main/ets/pages/EventEditPage.ets b/entry/src/main/ets/pages/EventEditPage.ets index 7481843..dc466b4 100644 --- a/entry/src/main/ets/pages/EventEditPage.ets +++ b/entry/src/main/ets/pages/EventEditPage.ets @@ -3,6 +3,7 @@ import { router } from '@kit.ArkUI'; import { common } from '@kit.AbilityKit'; import { BusinessError } from '@kit.BasicServicesKit'; +import { notificationManager } from '@kit.NotificationKit'; import { DavAccount, AccountStore, CalSource, BookPalette } from '../common/AccountStore'; import { EventDb, LocalEvent } from '../common/EventDb'; import { AppSettings } from '../common/AppSettings'; @@ -272,6 +273,11 @@ struct EventEditPage { } // 日志仅记录 uid 与提醒/重复设置,不落盘日程标题(避免隐私内容进入可备份的 sync.log) LogUtil.write(`本地保存日程(标题已脱敏)uid=${e.uid} 提醒=${e.reminders.join('/')}分钟 重复=${e.rrule === '' ? '否' : e.rrule}`); + // 设置了提醒 → 此刻才申请通知权限(用户刚主动使用了依赖通知的功能, + // 符合《审核指南》7.17"在用户主动点击对应功能时申请",不随 App 启动自动弹窗) + if (e.reminders.length > 0) { + await this.ensureNotifyPermission(); + } // 立即刷新提醒(不等下一轮同步),保证刚保存的提醒马上生效 try { await ReminderService.refreshReminders(context as common.UIAbilityContext); @@ -290,6 +296,27 @@ struct EventEditPage { this.isSaving = false; } + /** + * 确认通知权限已开启 —— 仅"用户保存了带提醒的日程"这条路径会调用。 + * 日程提醒依赖系统通知,用户刚主动设置了提醒,此时申请权限与其使用目的完全一致。 + * ⚠️ 严禁在 App 启动 / 首次打开时调用(《审核指南》7.17:不得提前弹窗申请权限)。 + */ + private async ensureNotifyPermission(): Promise { + try { + const enabled: boolean = await notificationManager.isNotificationEnabled(); + if (enabled) { + return; + } + const context = this.getUIContext().getHostContext(); + if (context === undefined) { + return; + } + await notificationManager.requestEnableNotification(context as common.UIAbilityContext); + } catch (err) { + // 用户拒绝或未弹窗:日程照常保存,只是提醒可能不弹出(设置页可再开启) + } + } + /** * 删除前二次确认。 * 删除日程不可撤销,且会同时从本地库与服务器(DAV)移除,故破坏性操作前必须显式确认, diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 2e1ee75..9f73b73 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -5,7 +5,6 @@ import { mediaquery, router } from '@kit.ArkUI'; import { common, abilityAccessCtrl, bundleManager } from '@kit.AbilityKit'; import { geoLocationManager } from '@kit.LocationKit'; import { BusinessError, pasteboard } from '@kit.BasicServicesKit'; -import { notificationManager } from '@kit.NotificationKit'; import { DavAccount, AccountStore, CalSource, TYPE_CALDAV } from '../common/AccountStore'; import { DisplayEvent, CalendarDataService } from '../common/CalendarDataService'; import { SyncEngine } from '../common/SyncEngine'; @@ -84,7 +83,6 @@ struct Index { // ---- 隐私政策与用户协议首启同意(未同意前不申请任何权限、不加载数据) ---- @State policyChecked: boolean = false; // 是否已读取过同意状态(避免首帧闪烁) @State policyAgreed: boolean = false; // 是否已同意《隐私政策》与《用户协议》 - private notifAsked: boolean = false; // 通知权限是否已申请(同意后才申请) @State showTips: boolean = false; // 首启功能引导(整页覆盖层:遮罩 + 底部卡片 + Swiper 三页) // ---- 内置文档浏览器(《隐私政策》/《用户服务协议》,整页 Web 覆盖层,不跳出应用) ---- @State showDoc: boolean = false; @@ -211,40 +209,17 @@ struct Index { } } - private initPermissionAndLoad(): void { - const context = this.getUIContext().getHostContext(); - if (context === undefined) { - return; - } - this.reloadAll(); - this.ensureNotificationPermission(context as common.UIAbilityContext); - if (!this.permissionAsked) { - this.permissionAsked = true; - // 申请读取全部日历权限(用于混合展示系统日程) - CalendarDataService.ensureSystemCalendarPermission(context as common.UIAbilityContext) - .then((): void => { - this.reloadAll(); - }); - } - } - /** - * 申请通知权限(日程提醒、后台同步常驻通知都依赖它)。 - * 必须在用户同意《隐私政策》之后才调用 —— 同意前不得申请任何权限。 + * 启动/同意隐私政策后的初始化:**只加载本地库与 DAV 数据**。 + * + * ⚠️ 合规约束(《审核指南》7.17):这里**不申请任何权限**。 + * 权限必须由用户主动点击对应功能时申请,否则会被判定为"未主动点击即提前弹窗申请": + * - 读取全部日程(日历)→ 设置页「混合显示系统日历」开关 / 「备份到 CalDAV 日历本」 + * - 通知 → 保存带提醒的日程 / 开启「后台持续同步」/ 设置页「去开启通知」 + * 本方法只负责加载数据;系统日历来源由 CalendarDataService.loadSources 按开关与授权状态自行决定是否读取。 */ - private ensureNotificationPermission(context: common.UIAbilityContext): void { - if (this.notifAsked) { - return; - } - this.notifAsked = true; - notificationManager.isNotificationEnabled() - .then((enabled: boolean): Promise => { - if (!enabled) { - return notificationManager.requestEnableNotification(context).catch((): void => {}); - } - return Promise.resolve(); - }) - .catch((): void => {}); + private initPermissionAndLoad(): void { + this.reloadAll(); } /** diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index 34ef9cd..33a299b 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -5,6 +5,7 @@ import { common } from '@kit.AbilityKit'; import { notificationManager } from '@kit.NotificationKit'; import { AppSettings } from '../common/AppSettings'; import { CardDataService } from '../common/CardDataService'; +import { CalendarDataService } from '../common/CalendarDataService'; import { BackgroundSyncService } from '../common/BackgroundSyncService'; import { AccountStore, DavAccount } from '../common/AccountStore'; import { ReminderService } from '../common/ReminderService'; @@ -49,6 +50,15 @@ struct SettingsPage { this.context = ctx; AppSettings.getShowSystemCalendar(ctx).then((v: boolean): void => { this.showSystem = v; + // 状态自洽:若记录为"开",但权限已被用户在系统设置里回收 → 自动回落为"关",避免开关与实际不符 + if (v) { + CalendarDataService.hasSystemCalendarPermission().then((granted: boolean): void => { + if (!granted) { + this.showSystem = false; + AppSettings.setShowSystemCalendar(ctx, false); + } + }); + } }); AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => { this.intervalMinutes = v; @@ -87,6 +97,27 @@ struct SettingsPage { }); } + /** + * 确认通知权限已开启 —— **只在用户主动使用依赖通知的功能时调用** + * (保存带提醒的日程 / 开启后台持续同步 / 点击设置页"去开启通知")。 + * ⚠️ 严禁在 App 启动、首次打开等用户未操作的时机调用(《审核指南》7.17)。 + */ + private async ensureNotifyPermission(): Promise { + if (this.context === undefined) { + return; + } + try { + const enabled: boolean = await notificationManager.isNotificationEnabled(); + if (!enabled) { + await notificationManager.requestEnableNotification( + this.context as common.UIAbilityContext); + } + } catch (err) { + // 用户拒绝或系统未弹窗:不阻断功能,仅状态仍显示为未开启 + } + this.refreshNotifyState(); + } + /** 加载全部 DAV 日历本(含探测到的写权限,只读/静音标记管理用) */ private async loadAllBooks(): Promise { if (this.context === undefined) { @@ -198,6 +229,18 @@ struct SettingsPage { if (this.context === undefined) { return; } + // "备份到 CalDAV" 需要读取手机系统(本地)日历 → 只在用户主动选择该模式时申请权限 + if (mode === 'backup') { + const granted: boolean = await CalendarDataService.requestSystemCalendarPermission( + this.context as common.UIAbilityContext); + if (!granted) { + this.sysMode = 'display'; // 回弹选择,保持与实际授权状态一致 + this.getUIContext().getPromptAction().showToast({ + message: '未获得"读取全部日程"权限,无法开启系统日历备份' + }); + return; + } + } this.sysMode = mode; await AppSettings.setSysCalMode(this.context, mode); if (mode === 'backup' && this.backupKey === '') { @@ -223,9 +266,26 @@ struct SettingsPage { .showToast({ message: '备份目标已更新,下次同步生效' }); } - private async saveShowSystem(value: boolean): Promise { if (this.context === undefined) { + /** + * 保存「混合显示系统日历」开关。 + * ⚠️ 合规约束(《审核指南》7.17):读取系统日历依赖"读取全部日程"敏感权限, + * **只有在用户主动打开该开关时才申请**;未授权则开关回弹、不保存设置。 + */ + private async saveShowSystem(value: boolean): Promise { + if (this.context === undefined) { return; } + if (value) { + const granted: boolean = await CalendarDataService.requestSystemCalendarPermission( + this.context as common.UIAbilityContext); + if (!granted) { + this.showSystem = false; // 回弹开关,保持与实际授权状态一致 + this.getUIContext().getPromptAction().showToast({ + message: '未获得"读取全部日程"权限,无法混合显示系统日历' + }); + return; + } + } await AppSettings.setShowSystemCalendar(this.context, value); this.getUIContext().getPromptAction() .showToast({ message: value ? '已开启系统日历混合显示,返回首页生效' : '已关闭系统日历混合显示,返回首页生效' }); @@ -245,6 +305,10 @@ struct SettingsPage { if (this.context === undefined) { return; } + // 后台持续同步依赖常驻通知 → 在用户主动开启时才申请通知权限(不随启动自动弹窗) + if (value) { + await this.ensureNotifyPermission(); + } try { await BackgroundSyncService.setBackgroundSync( this.context as common.UIAbilityContext, value); @@ -447,7 +511,7 @@ struct SettingsPage { .fontSize(15) .fontWeight(FontWeight.Medium) .fontColor($r('app.color.text_primary')) - Text('选择手机系统日历日程的处理方式') + Text('选择手机系统日历日程的处理方式(需授权读取系统日历)') .fontSize(12) .fontColor($r('app.color.text_secondary')) }