diff --git a/entry/src/main/ets/common/AppSettings.ets b/entry/src/main/ets/common/AppSettings.ets index e4edb11..9d7dd95 100644 --- a/entry/src/main/ets/common/AppSettings.ets +++ b/entry/src/main/ets/common/AppSettings.ets @@ -8,6 +8,30 @@ export class AppSettings { private static readonly STORE: string = 'sync_settings'; private static readonly KEY_SHOW_SYSTEM: string = 'show_system_calendar'; private static readonly KEY_SYNC_INTERVAL: string = 'sync_interval_minutes'; + private static readonly KEY_BACKGROUND_SYNC: string = 'background_sync'; + + /** 是否开启后台持续同步(长时任务,默认关) */ + static async getBackgroundSync(context: common.Context): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + return await store.get(AppSettings.KEY_BACKGROUND_SYNC, false) as boolean; + } catch (err) { + return false; + } + } + + static async setBackgroundSync(context: common.Context, value: boolean): Promise { + try { + const store: preferences.Preferences = + await preferences.getPreferences(context, AppSettings.STORE); + await store.put(AppSettings.KEY_BACKGROUND_SYNC, value); + await store.flush(); + } catch (err) { + const e = err as BusinessError; + console.error(`保存后台同步设置失败: ${e.message}`); + } + } /** 是否混合显示系统日历日程(默认开) */ static async getShowSystemCalendar(context: common.Context): Promise { diff --git a/entry/src/main/ets/common/BackgroundSyncService.ets b/entry/src/main/ets/common/BackgroundSyncService.ets new file mode 100644 index 0000000..2be7a3d --- /dev/null +++ b/entry/src/main/ets/common/BackgroundSyncService.ets @@ -0,0 +1,104 @@ +// entry/src/main/ets/common/BackgroundSyncService.ets +// 后台同步管理: +// 1) 长时任务(DATA_TRANSFER):进程不被挂起,前台定时器在后台继续跑(需用户在设置中开启) +// 2) 延迟任务(workScheduler):App 被杀/重启后由系统按条件拉起兜底同步(最小周期 20 分钟) +import { backgroundTaskManager } from '@kit.BackgroundTasksKit'; +import { wantAgent, common } from '@kit.AbilityKit'; +import { workScheduler } from '@kit.BackgroundTasksKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { AppSettings } from './AppSettings'; +import { LogUtil } from './LogUtil'; + +const WORK_ID: number = 1001; + +export class BackgroundSyncService { + /** 申请长时任务(DATA_TRANSFER):申请成功后进程在后台不被挂起 */ + static async startContinuousTask(context: common.UIAbilityContext): Promise { + try { + const info: wantAgent.WantAgentInfo = { + wants: [{ + bundleName: context.abilityInfo.bundleName, + abilityName: 'EntryAbility' + }], + operationType: wantAgent.OperationType.START_ABILITY, + requestCode: 0 + }; + const agent = await wantAgent.getWantAgent(info); + await backgroundTaskManager.startBackgroundRunning( + context, backgroundTaskManager.BackgroundMode.DATA_TRANSFER, agent); + LogUtil.write('长时任务已申请:后台持续同步开启'); + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`长时任务申请失败: ${e.code} - ${e.message}`); + } + } + + /** 停止长时任务 */ + static async stopContinuousTask(context: common.UIAbilityContext): Promise { + try { + await backgroundTaskManager.stopBackgroundRunning(context); + LogUtil.write('长时任务已停止'); + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`长时任务停止失败: ${e.code} - ${e.message}`); + } + } + + /** + * 注册延迟任务兜底同步:无论长时任务开关与否都注册。 + * 条件:有任意网络即执行;周期重复;重启后自动恢复。 + * 注:repeatCycleTime 各版本下限不同(20 分钟 ~ 2 小时),先用 20 分钟, + * 被系统拒绝(9700003 条件无效)时降级为 2 小时再试。 + */ + static scheduleDeferredSync(context: common.UIAbilityContext): void { + const build = (cycleMs: number): workScheduler.WorkInfo => { + const info: workScheduler.WorkInfo = { + workId: WORK_ID, + bundleName: context.abilityInfo.bundleName, + abilityName: 'SyncWorkAbility', + networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY, + isRepeat: true, + isPersisted: true, + repeatCycleTime: cycleMs + }; + return info; + }; + try { + workScheduler.startWork(build(20 * 60 * 1000)); + LogUtil.write('延迟任务已注册:20 分钟兜底同步'); + } catch (err) { + const e = err as BusinessError; + if (e.code === 9700003) { + try { + workScheduler.startWork(build(2 * 60 * 60 * 1000)); + LogUtil.write('延迟任务降级注册:2 小时兜底同步'); + return; + } catch (err2) { + const e2 = err2 as BusinessError; + LogUtil.write(`延迟任务注册失败(2h): ${e2.code} - ${e2.message}`); + return; + } + } + LogUtil.write(`延迟任务注册失败: ${e.code} - ${e.message}`); + } + } + + /** App 启动时统一入口:注册延迟任务 + 按设置恢复长时任务 */ + static async initOnLaunch(context: common.UIAbilityContext): Promise { + BackgroundSyncService.scheduleDeferredSync(context); + const enabled: boolean = await AppSettings.getBackgroundSync(context); + if (enabled) { + await BackgroundSyncService.startContinuousTask(context); + } + } + + /** 设置页开关切换 */ + static async setBackgroundSync(context: common.UIAbilityContext, enabled: boolean): Promise { + await AppSettings.setBackgroundSync(context, enabled); + if (enabled) { + await BackgroundSyncService.startContinuousTask(context); + } else { + await BackgroundSyncService.stopContinuousTask(context); + } + } +} diff --git a/entry/src/main/ets/common/SyncEngine.ets b/entry/src/main/ets/common/SyncEngine.ets index 8939f37..0b4f8ec 100644 --- a/entry/src/main/ets/common/SyncEngine.ets +++ b/entry/src/main/ets/common/SyncEngine.ets @@ -25,7 +25,7 @@ export class SyncEngine { * 同步一个 CalDAV 账号(先推该账号的本地修改,再拉远端变更), * 返回远端事件总数(拉取侧)。带 120 秒超时保护。 */ - static async syncAccount(context: common.UIAbilityContext, acc: DavAccount): Promise { + static async syncAccount(context: common.Context, acc: DavAccount): Promise { const t0: number = Date.now(); LogUtil.write(`========== 同步账号「${acc.name}」开始:服务器 ${acc.serverUrl},共 ${acc.calendarHrefs.length} 个日历本 ==========`); try { diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 6d10a42..8a0a161 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,12 +1,15 @@ import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { window } from '@kit.ArkUI'; +import { BackgroundSyncService } from '../common/BackgroundSyncService'; const DOMAIN = 0x0000; export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); + // 后台同步:注册延迟任务兜底 + 按设置恢复长时任务 + BackgroundSyncService.initOnLaunch(this.context); } onDestroy(): void { diff --git a/entry/src/main/ets/pages/SettingsPage.ets b/entry/src/main/ets/pages/SettingsPage.ets index 5d7c656..c94c40c 100644 --- a/entry/src/main/ets/pages/SettingsPage.ets +++ b/entry/src/main/ets/pages/SettingsPage.ets @@ -3,6 +3,7 @@ import { router } from '@kit.ArkUI'; import { common } from '@kit.AbilityKit'; import { AppSettings } from '../common/AppSettings'; +import { BackgroundSyncService } from '../common/BackgroundSyncService'; import { LogUtil } from '../common/LogUtil'; const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60]; @@ -12,6 +13,7 @@ const INTERVAL_OPTIONS: number[] = [1, 5, 15, 30, 60]; struct SettingsPage { @State showSystem: boolean = true; @State intervalMinutes: number = 1; + @State backgroundSync: boolean = false; private context?: common.Context; aboutToAppear(): void { @@ -27,6 +29,9 @@ struct SettingsPage { AppSettings.getSyncIntervalMinutes(ctx).then((v: number): void => { this.intervalMinutes = v; }); + AppSettings.getBackgroundSync(ctx).then((v: boolean): void => { + this.backgroundSync = v; + }); } private async saveShowSystem(value: boolean): Promise { @@ -48,6 +53,24 @@ struct SettingsPage { .showToast({ message: `自动同步间隔已设为 ${minutes} 分钟` }); } + private async saveBackgroundSync(value: boolean): Promise { + if (this.context === undefined) { + return; + } + try { + await BackgroundSyncService.setBackgroundSync( + this.context as common.UIAbilityContext, value); + this.getUIContext().getPromptAction().showToast({ + message: value + ? '后台持续同步已开启,通知栏将显示常驻通知' + : '后台持续同步已关闭' + }); + } catch (err) { + this.backgroundSync = !value; // 失败回滚 UI + this.getUIContext().getPromptAction().showToast({ message: '设置失败,请重试' }); + } + } + private intervalLabel(minutes: number): string { return minutes >= 60 ? `${minutes / 60} 小时` : `${minutes} 分钟`; } @@ -134,6 +157,34 @@ struct SettingsPage { .borderRadius(12) .backgroundColor($r('app.color.card_bg')) .border({ width: 1, color: $r('app.color.shadow_color') }) + + // 后台持续同步(长时任务) + Row({ space: 10 }) { + Column({ space: 2 }) { + Text('后台持续同步') + .fontSize(15) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + Text('开启后申请后台长时任务,App 退到后台也按设定间隔继续同步(通知栏会显示常驻通知)') + .fontSize(12) + .fontColor($r('app.color.text_secondary')) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + Toggle({ type: ToggleType.Switch, isOn: this.backgroundSync }) + .selectedColor($r('app.color.brand')) + .onChange((isOn: boolean) => { + if (isOn !== this.backgroundSync) { + this.backgroundSync = isOn; + this.saveBackgroundSync(isOn); + } + }) + } + .width('100%') + .padding(14) + .borderRadius(12) + .backgroundColor($r('app.color.card_bg')) + .border({ width: 1, color: $r('app.color.shadow_color') }) } .width('100%') .padding({ left: 20, right: 20, top: 8, bottom: 20 }) diff --git a/entry/src/main/ets/syncwork/SyncWorkAbility.ets b/entry/src/main/ets/syncwork/SyncWorkAbility.ets new file mode 100644 index 0000000..8641e64 --- /dev/null +++ b/entry/src/main/ets/syncwork/SyncWorkAbility.ets @@ -0,0 +1,54 @@ +// entry/src/main/ets/syncwork/SyncWorkAbility.ets +// 延迟任务兜底同步:App 进程被杀/设备重启后,系统在满足条件(有网络)时拉起本扩展执行同步 +// 注意:必须幂等——同步本身基于 ETag 全量比对,天然幂等;执行完必须 stopWork,否则系统按超时处理 +import { WorkSchedulerExtensionAbility, workScheduler } from '@kit.BackgroundTasksKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { DavAccount, AccountStore, TYPE_CALDAV } from '../common/AccountStore'; +import { SyncEngine } from '../common/SyncEngine'; +import { CardDataService } from '../common/CardDataService'; +import { ReminderService } from '../common/ReminderService'; +import { LogUtil } from '../common/LogUtil'; + +export default class SyncWorkAbility extends WorkSchedulerExtensionAbility { + onWorkStart(workInfo: workScheduler.WorkInfo): void { + LogUtil.init(this.context); + LogUtil.write(`延迟任务触发:workId=${workInfo.workId}`); + this.doSync(workInfo); + } + + onWorkStop(workInfo: workScheduler.WorkInfo): void { + LogUtil.write(`延迟任务结束:workId=${workInfo.workId}`); + } + + private async doSync(workInfo: workScheduler.WorkInfo): Promise { + try { + const context = this.context; + const accounts: DavAccount[] = await AccountStore.loadAll(context); + let ok: number = 0; + for (const acc of accounts) { + if (acc.type !== TYPE_CALDAV) { + continue; + } + try { + await SyncEngine.withTimeout( + SyncEngine.syncAccount(context, acc), 100000); + ok++; + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`延迟任务同步「${acc.name}」失败: ${e.message}`); + } + } + await SyncEngine.settleLocalEvents(context); + // 同步后刷新卡片 + 重建提醒,保证后台同步的成果直接可见 + await CardDataService.pushToAllForms(context); + await ReminderService.refreshReminders(context); + LogUtil.write(`延迟任务同步完成:${ok} 个账号`); + } catch (err) { + const e = err as BusinessError; + LogUtil.write(`延迟任务同步失败: ${e.message}`); + } finally { + // 必须主动结束,否则系统按 120 秒超时处理 + workScheduler.stopWork(workInfo); + } + } +} diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 index c90c3d5..9dc7d9c 100644 --- a/entry/src/main/module.json5 +++ b/entry/src/main/module.json5 @@ -58,6 +58,9 @@ "startWindowIcon": "$media:startIcon", "startWindowBackground": "$color:start_window_background", "exported": true, + "backgroundModes": [ + "dataTransfer" + ], "skills": [ { "entities": [ @@ -95,6 +98,13 @@ "resource": "$profile:form_config" } ] + }, + { + "name": "SyncWorkAbility", + "srcEntry": "./ets/syncwork/SyncWorkAbility.ets", + "description": "$string:sync_work_desc", + "type": "workScheduler", + "exported": false } ] }, diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json index 7b78d0b..2c0f745 100644 --- a/entry/src/main/resources/base/element/string.json +++ b/entry/src/main/resources/base/element/string.json @@ -47,6 +47,10 @@ { "name": "card_6x4_name", "value": "日程大全" + }, + { + "name": "sync_work_desc", + "value": "日历后台兜底同步任务" } ] } \ No newline at end of file