/** * 家书 FamilyBook · 后台定期同步(鸿蒙端,v83) * * 🔴 用户 2026-10-23 的原话: * 「另外,要有定期同步的功能,就是家书app在后台,也能自动同步数据, * 这样打开app,基本上数据就是最新的。同步的策略,去synccalendar找,里面有办法。」 * * ⇒ 策略照搬 `SyncCalendar/entry/src/main/ets/common/BackgroundSyncService.ets` * 与 `BackgroundSyncRunner.ets`,**不自己另发明一套**。它的分层调度是踩出来的: * * ① **前台**:无需在此处理(顶栏手动同步 + 页面各自同步已有)。 * ② **退后台瞬间**:`requestSuspendDelay` 申请**短时任务**,让"刚退后台那一次" * 能跑完。⚠️ 必须在"前台或退后台 5 秒内"申请,否则被拒。 * ③ **长期后台**:`workScheduler` **延迟任务**,周期 2 小时、`isPersisted` 重启自动恢复。 * ④ **服务卡片定时刷新**:`updateDuration: 1`(单位 **30 分钟**)触发 * `FbFormAbility.onUpdateForm`,**不受"应用活跃分组"压制** ⇒ 这是唯一 * 能做到"约 30 分钟一次"的通道。借这个时机跑一次同步(带节流)。 * * ⚠️ **30 分钟那条不能靠 workScheduler 实现**(本文件最大的坑,别改): * 系统按"应用活跃分组"压制延迟任务周期 —— * 活跃≥2h / 经常使用≥4h / 常用≥24h / 极少使用≥48h / 受限·从未使用禁止调度。 * 请求 30 分钟**不报错**,但会被压到 2 小时甚至更久 ⇒ **形同虚设**。 * 这也是为什么 `DEFERRED_CYCLE_MS` 写死 2 小时而不是"用户想要的 30 分钟"。 * * ⚠️ `WorkSchedulerExtensionAbility` 单次回调**上限 2 分钟**,超时进程被杀。 * * ⚠️ 短时任务配额:**单日 10 分钟、单次最长 3 分钟**(低电量 1 分钟), * 用完必须 `cancelSuspendDelay` 归还,否则后面的"退后台同步"没配额可用。 * * 🔴🔴 **后台进程是"另一个进程"**(WorkScheduler / Form 扩展都跑在独立进程里): * 本文件里的 `prepare()` 必须在执行同步前把这些单例**重新初始化**一遍 —— * 主进程里 `EntryAbility.onCreate` 做过的那些 init,在新进程里**一个都没做**。 * 漏了任何一步的表现都是"后台同步永远失败、但日志里看不出原因": * · 漏 `FB_TOKEN_STORE.init` ⇒ 令牌空 ⇒ `syncAll` 直接判"未登录" * · 漏 `FB_SERVER_STORE.init` ⇒ 服务器地址空 ⇒ `fbApi` 报 `no_server` * · 漏 `FbDb.open` ⇒ 数据拉下来也存不进去(只能空跑一趟网络) * · 漏 `FB_MODULE_TOKEN.set` ⇒ 通用缓存仓库拿不到令牌(它走注入式读取) * * ⚠️ 所有方法**永不抛错**(后台路径没有 UI 承担异常)。 */ import { common } from '@kit.AbilityKit'; import { backgroundTaskManager, workScheduler } from '@kit.BackgroundTasksKit'; import { preferences } from '@kit.ArkData'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { BusinessError } from '@kit.BasicServicesKit'; import { FB_TOKEN_STORE } from '../data/FbTokenStore'; import { FB_SERVER_STORE } from '../data/FbServerStore'; import { FB_DEVICE_STORE } from '../data/FbDeviceStore'; import { FB_SESSION } from '../data/FbSession'; import { FB_KEY_STORE } from '../data/FbKeyStore'; import { FbDb } from '../data/FbDb'; import { FB_MODULE_TOKEN } from '../data/FbModuleRepo'; import { FbSyncAllResult, syncAll } from '../data/FbSyncAll'; import { FB_CAL_SETTINGS } from '../data/FbCalSettings'; import { FbCalMirror, FbMirrorResult } from '../data/FbCalMirror'; import { FbCalInbound, FbInboundResult } from '../data/FbCalInbound'; const DOMAIN: number = 0x0000; const TAG: string = 'FbBgSync'; /** 延迟任务的 workId(与 `module.json5` 里声明的 extension 名配套) */ const WORK_ID: number = 2001; /** 延迟任务要拉起的扩展名(必须与 `module.json5` 的 `name` 逐字相同) */ const WORK_ABILITY: string = 'FbSyncWorkAbility'; /** * 延迟任务周期:**2 小时**。 * 🔴 不是"用户想要的间隔",而是系统"活跃分组"的**下限** —— 写小了会被压制, * 不报错、也不生效(详见文件头)。想更频繁只能靠服务卡片刷新(30 分钟那条)。 */ const DEFERRED_CYCLE_MS: number = 2 * 60 * 60 * 1000; /** * 卡片驱动同步的最小间隔。 * * ⚠️ 与 `form_config.json` 的 `updateDuration: 1`(30 分钟)**必须对得上**: * 比 30 分钟还大的话,每两次卡片刷新就白白跳过一轮; * 太小则系统刷新一有漂移就会连着跑两遍。取 25 分钟,留 5 分钟容差。 * (SyncCalendar 的取值与理由同此。) */ const CARD_MIN_GAP_MS: number = 25 * 60 * 1000; /** * 卡片进程里**没申请到短时任务**时的软超时。 * * 🔴 官方规定 `FormExtensionAbility` 生命周期回调返回后进程**仅存续约 10 秒**, * 超过就被回收(异步任务被硬杀半途)。这里主动在 9 秒收手, * 让"这一次"**干净地放弃**而不是被硬杀 —— 同步是幂等的,下轮会重试。 */ const CARD_SOFT_TIMEOUT_MS: number = 9 * 1000; /** * 卡片驱动同步可用的**总时长上限**。 * * 🔴 短时任务单日总配额只有 10 分钟。若卡片每 30 分钟就把配额吃满, * "退后台那一次同步"(`onBackground` 申请)就会没配额可用 —— * 那是用户最需要它跑的时刻。⇒ 卡片这条路每次最多 60 秒。 */ const CARD_MAX_TOTAL_MS: number = 60 * 1000; /** 申请到短时任务后留出的余量:提前收手才能干净地 `cancelSuspendDelay` */ const TRANSIENT_RESERVE_MS: number = 15 * 1000; /** * 回前台 / 退后台补同步的**最小间隔**。 * * 🔴 为什么必须有这道节流:`onForeground` / `onBackground` 会被**反复**触发 * (切应用、下拉通知栏、锁屏解锁、多任务来回滑), * 每次都真跑一轮就是要用户为"切一下应用"付一次网络往返。 * 而同步是增量的、又幂等 —— 5 分钟内基本不可能有新数据。 * ⚠️ 取 5 分钟而不是 30:用户"去别的应用抄个东西再回来"是常见动作, * 超过 5 分钟的数据变化他大概率是想看到的(卡片驱动的 30 分钟那条只保证桌面)。 */ const RESUME_MIN_GAP_MS: number = 5 * 60 * 1000; const STORE_NAME: string = 'fb_bg_sync'; const K_ENABLED: string = 'enabled'; const K_LAST_CARD: string = 'last_card_sync_at'; const K_LAST_ANY: string = 'last_sync_at'; /** 一次后台同步的结果(只用于日志,不做 UI) */ export class FbBgSyncResult { /** 是否真的跑了同步(false = 未登录/未配置服务器/被节流,属正常跳过) */ ran: boolean = false; /** 同步是否全部成功 */ ok: boolean = false; done: number = 0; total: number = 0; /** 人话说明 */ note: string = ''; } class FbBackgroundSyncImpl { /** 当前短时任务的 requestId(0 = 无);退后台申请、回前台取消 */ private transientId: number = 0; /* ═══════════════════════════════════════════════════════════ ① 用户开关 ═══════════════════════════════════════════════════════════ */ /** * 是否开启"后台自动同步"。**默认开启**。 * * 🔴 为什么默认开:用户要的是"打开 app 数据就是最新的", * 默认关等于这个功能不存在。关掉是给"在意流量/电量"的人留的口子。 * ⚠️ 读不到 preferences 时返回 true(宁可多同步一次,也不要静默失效)。 */ async isEnabled(ctx: common.Context): Promise { try { const p: preferences.Preferences = await preferences.getPreferences(ctx, STORE_NAME); const v: preferences.ValueType = await p.get(K_ENABLED, true); return typeof v === 'boolean' ? v : true; } catch (err) { hilog.warn(DOMAIN, TAG, '读后台同步开关失败(按开启处理):%{public}s', JSON.stringify(err)); return true; } } /** * 设置页切换开关:开 ⇒ 注册延迟任务;关 ⇒ 取消延迟任务。 * * ⚠️ 关掉时**顺手取消短时任务**:用户刚说了不要后台同步, * 不该还留一个配额在用着。 */ async setEnabled(ctx: common.Context, on: boolean): Promise { try { const p: preferences.Preferences = await preferences.getPreferences(ctx, STORE_NAME); await p.put(K_ENABLED, on); await p.flush(); // 不 flush 等于没写(进程随时可能被回收) } catch (err) { hilog.warn(DOMAIN, TAG, '写后台同步开关失败:%{public}s', JSON.stringify(err)); } if (on) { this.ensureWork(ctx); } else { this.cancelWork(ctx); this.cancelTransient(); } } /** 上次同步时间(毫秒;0 = 从没同步过)。设置页展示"上次同步"用。 */ async lastSyncAt(ctx: common.Context): Promise { return this.readNum(ctx, K_LAST_ANY); } /* ═══════════════════════════════════════════════════════════ ② 延迟任务(长期后台) ═══════════════════════════════════════════════════════════ */ /** * 注册(或刷新)延迟任务。**幂等**:重复调用只是让系统重排同一个 workId。 * * ⚠️ `isPersisted: true` 是"重启后自动恢复"的开关,缺了它设备重启后 * 这个任务就永久消失(用户不会知道,只会觉得"后台不同步了")。 * ⚠️ `networkType: NETWORK_TYPE_ANY`:没网时不必浪费一次唤醒。 */ ensureWork(ctx: common.Context): void { /* ⚠️ 这里取包名用的是 `ctx.applicationInfo.name` —— **不是 `bundleName`**。 `ApplicationInfo` 里没有 `bundleName` 这个字段(写了会编译报 `Property 'bundleName' does not exist on type 'ApplicationInfo'`), 而 `name` 的文档原文就是「Name of the application bundle. It corresponds to the **bundleName** field in the app.json5」⇒ 它就是包名。 另一条路 `bundleManager.getBundleInfoForSelfSync().name` 也行, 但那是**跨进程通信**(IPC,几毫秒到几十毫秒),在 onCreate 里不值得。 ⚠️ `WorkInfo.bundleName` 字段本身**必须**是包名 —— 写错了 `startWork` 不报错、但永远不会回调(系统找不到这个扩展)。 */ const info: workScheduler.WorkInfo = { workId: WORK_ID, bundleName: ctx.applicationInfo.name, abilityName: WORK_ABILITY, networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY, isRepeat: true, isPersisted: true, repeatCycleTime: DEFERRED_CYCLE_MS, }; try { workScheduler.startWork(info); hilog.info(DOMAIN, TAG, '延迟任务已注册:%{public}d 小时兜底同步', DEFERRED_CYCLE_MS / 3600000); } catch (err) { const e: BusinessError = err as BusinessError; hilog.warn(DOMAIN, TAG, '延迟任务注册失败:%{public}s %{public}s', (e.code !== undefined ? e.code.toString() : '-'), e.message); } } /** 取消延迟任务(用户关掉后台自动同步时) */ cancelWork(ctx: common.Context): void { const info: workScheduler.WorkInfo = { workId: WORK_ID, bundleName: ctx.applicationInfo.name, abilityName: WORK_ABILITY, networkType: workScheduler.NetworkType.NETWORK_TYPE_ANY, isRepeat: true, isPersisted: true, repeatCycleTime: DEFERRED_CYCLE_MS, }; try { workScheduler.stopWork(info, true); hilog.info(DOMAIN, TAG, '延迟任务已取消'); } catch (err) { const e: BusinessError = err as BusinessError; hilog.warn(DOMAIN, TAG, '延迟任务取消失败:%{public}s', e.message); } } /* ═══════════════════════════════════════════════════════════ ③ 短时任务(退后台那一次) ═══════════════════════════════════════════════════════════ */ /** * 申请短时任务,返回**实际可用时长(毫秒)**;失败返回 0。 * * ⚠️ 只能在"应用处于前台"或"`onBackground` 回调里(5 秒内)"调用, * 其它时机会被系统拒绝(9900002 等)。被拒**不是 bug**,调用方退回软超时即可。 * ⚠️ 已有短时任务在跑时**不重复申请** —— 重复申请会白白消耗当日配额。 */ requestTransient(reason: string): number { if (this.transientId !== 0) { return 0; } try { const info: backgroundTaskManager.DelaySuspendInfo = backgroundTaskManager.requestSuspendDelay(reason, (): void => { // 超时回调:配额用尽,系统即将挂起。清状态,等下次前/退后台再申请 hilog.info(DOMAIN, TAG, '短时任务超时(配额用尽),后台同步将被挂起'); this.transientId = 0; }); this.transientId = info.requestId; const actual: number = info.actualDelayTime !== undefined ? info.actualDelayTime : 0; hilog.info(DOMAIN, TAG, '短时任务已申请:剩余 %{public}d 秒', Math.round(actual / 1000)); return actual; } catch (err) { const e: BusinessError = err as BusinessError; hilog.warn(DOMAIN, TAG, '短时任务申请失败(不影响使用):%{public}s', e.message); return 0; } } /** * 取消短时任务,**归还当日配额**。 * * 🔴 这一步不是可选的:配额一天只有 10 分钟,跑完不还, * 下一次"退后台同步"就会申请不到 —— 而用户只会看到"后台又不同步了"。 */ cancelTransient(): void { if (this.transientId === 0) { return; } try { backgroundTaskManager.cancelSuspendDelay(this.transientId); hilog.info(DOMAIN, TAG, '短时任务已取消(归还配额)'); } catch (err) { const e: BusinessError = err as BusinessError; hilog.warn(DOMAIN, TAG, '短时任务取消失败:%{public}s', e.message); } this.transientId = 0; } /** * 回前台 / 退后台的**节流版**同步。 * * 与 `runOnce` 的区别只有一处:距上次同步不到 `RESUME_MIN_GAP_MS` 就跳过。 * 理由见常量说明(前后台回调会被反复触发,不节流等于"切一下应用打一次网络")。 * * ⚠️ 手动同步(顶栏那颗按钮)**不走这里** —— 用户明确点了就必须真的跑, * 不能被节流挡掉,否则他会以为"点了没反应"。 */ async runOnceResumed(ctx: common.Context, tag: string): Promise { try { const last: number = await this.readNum(ctx, K_LAST_ANY); const now: number = Date.now(); if (last > 0 && now - last < RESUME_MIN_GAP_MS) { hilog.info(DOMAIN, TAG, '同步[%{public}s] 跳过:距上次仅 %{public}d 分钟', tag, Math.round((now - last) / 60000)); return; } await this.runOnce(ctx, tag); } catch (err) { const e: BusinessError = err as BusinessError; hilog.warn(DOMAIN, TAG, '同步[%{public}s] 异常:%{public}s', tag, e.message); } } /* ═══════════════════════════════════════════════════════════ ④ 执行体(所有触发源共用) ═══════════════════════════════════════════════════════════ */ /** * 在新进程里把"主进程启动时该做的事"补齐,再判断能不能同步。 * * 🔴 后台扩展跑在**独立进程**里,`EntryAbility.onCreate` 那套初始化 * 在这里**一步都没跑过** —— 见文件头"后台进程是另一个进程"那段。 * * @returns true = 可以同步;false = 未登录 / 未配置服务器(正常跳过) */ private async prepare(ctx: common.Context): Promise { try { await FB_TOKEN_STORE.init(ctx); await FB_SERVER_STORE.init(ctx); await FB_DEVICE_STORE.init(ctx); await FB_SESSION.init(ctx); // 数据密钥(附件解密用)。失败了不影响"拉数据",只影响附件预览 await FB_KEY_STORE.init(ctx); /* ⚠️ 通用缓存仓库靠注入拿令牌(它与令牌存储之间有循环 import 风险, 见 `FbModuleRepo.FbModuleToken` 的说明)⇒ 新进程必须重新注入一次, 否则 `module_cache` 那些模块永远拿不到令牌、全部 401。 */ FB_MODULE_TOKEN.set((): string => FB_TOKEN_STORE.get()); // 打开本地库:不开的话数据拉下来也存不住,白跑一趟网络 await FbDb.inst().open(ctx); } catch (err) { hilog.warn(DOMAIN, TAG, '后台同步的准备步骤失败:%{public}s', JSON.stringify(err)); // 继续往下走:也许只是某个非关键步骤失败(比如密钥库不支持) } if (FB_TOKEN_STORE.get() === '') { hilog.info(DOMAIN, TAG, '后台同步跳过:没有令牌(未登录)'); return false; } if (FB_SERVER_STORE.get() === '') { hilog.info(DOMAIN, TAG, '后台同步跳过:没配置服务器地址'); return false; } return true; } /** * 跑一次完整同步。**永不抛错**。 * * @param tag 触发来源(`deferred` / `transient` / `cardTick` / `foreground` / `netRecover`) */ /** * 跑一次「系统日历镜像」(入站 → 出站)。 * * 🔴 2026-11-03 新增。用户要求把日历镜像到系统日历, * 这样小艺 / 桌面卡片 / 手表能看到日程。 * * ⚠️ **整段吞异常**:镜像是附加能力,失败不能让同步算失败。 * (与上面「后台路径必须吞掉所有异常」同一条纪律。) * ⚠️ 只在用户开着开关、且已授权时才跑。 */ private async runMirror(ctx: common.Context, tag: string): Promise { try { if (!FB_CAL_SETTINGS.getMirror()) { return; } if (!await FbCalMirror.hasPermission()) { return; } // ① 入站:系统日历里的改动 → 服务器(必须在出站之前) const inb: FbInboundResult = await FbCalInbound.reconcile(ctx); // ② 出站:最新数据 → 系统日历 const r: FbMirrorResult = await FbCalMirror.syncNow(ctx); hilog.info(DOMAIN, TAG, '镜像[%{public}s]:入站 改=%{public}d 删=%{public}d 增=%{public}d;' + '出站 增=%{public}d 更=%{public}d 删=%{public}d 失败=%{public}d', tag, inb.updated, inb.deleted, inb.created, r.added, r.updated, r.deleted, r.failed); } catch (err) { const e: BusinessError = err as BusinessError; hilog.warn(DOMAIN, TAG, '镜像[%{public}s] 失败(不影响同步):%{public}s', tag, e.message); } } async runOnce(ctx: common.Context, tag: string): Promise { const r: FbBgSyncResult = new FbBgSyncResult(); const t0: number = Date.now(); try { if (!(await this.isEnabled(ctx))) { r.note = '后台自动同步已关闭'; hilog.info(DOMAIN, TAG, '后台同步[%{public}s] 跳过:用户已关闭', tag); return r; } if (!(await this.prepare(ctx))) { r.note = '未登录或未配置服务器,跳过'; return r; } r.ran = true; hilog.info(DOMAIN, TAG, '后台同步[%{public}s] 开始', tag); const res: FbSyncAllResult = await syncAll(); r.ok = res.ok; r.done = res.done; r.total = res.total; r.note = res.note; /* 🔴 2026-11-03:同步成功后接着跑「系统日历镜像」(用户要求的功能)。 ⚠️ 顺序:先入站(把用户在系统日历里的改动收回服务器),再出站(写回系统日历)。 反了的话出站会把系统日历里的旧值当"期望值",覆盖掉用户的改动。 ⚠️ 整段 try/catch 包住:镜像失败**绝不能**让同步算失败 —— 同步是主业,镜像是附加能力,坏了不该拖累数据对齐。 */ await this.runMirror(ctx, tag); await this.writeNum(ctx, K_LAST_ANY, Date.now()); hilog.info(DOMAIN, TAG, '后台同步[%{public}s] 完成:%{public}d/%{public}d,耗时 %{public}d 秒', tag, res.done, res.total, Math.round((Date.now() - t0) / 1000)); } catch (err) { /* 🔴 后台路径**必须吞掉所有异常**:抛出去没人接(没有 UI 栈), 系统只会看到"扩展崩了",而用户那边表现为"卡片变白 / 后台不同步"。 */ const e: BusinessError = err as BusinessError; r.note = '同步异常:' + (e.message !== undefined ? e.message : JSON.stringify(err)); hilog.warn(DOMAIN, TAG, '后台同步[%{public}s] 异常:%{public}s', tag, r.note); } return r; } /** * ⭐ **服务卡片定时刷新驱动**的同步(0.0.8 起唯一的"约 30 分钟"通道)。 * * 为什么是卡片:`workScheduler` 周期被活跃分组压制到 ≥2 小时(见文件头), * 而卡片配了 `updateDuration: 1`(30 分钟)后系统会**每 30 分钟**回调一次 * `onUpdateForm`,且不受活跃分组压制(每天上限 50 次)。 * * ⚠️ 节流:系统刷新可能漂移或重入 ⇒ 用时间戳挡一道,保证两次真实同步 * 之间至少间隔 `CARD_MIN_GAP_MS`。**先写时间戳再同步**, * 避免执行期间卡片再次刷新导致并发重入(两个进程同时拉同一批数据)。 * ⚠️ 时间预算:优先申请短时任务把 10 秒窗口延长;申请不到就 9 秒收手。 * 两种情况下都**主动放弃**而不是被硬杀 —— 同步幂等,下轮重试即可。 */ async runThrottledByCard(ctx: common.Context): Promise { try { const last: number = await this.readNum(ctx, K_LAST_CARD); const now: number = Date.now(); if (last > 0 && now - last < CARD_MIN_GAP_MS) { hilog.info(DOMAIN, TAG, '卡片驱动同步:距上次仅 %{public}d 分钟,跳过', Math.round((now - last) / 60000)); return; } await this.writeNum(ctx, K_LAST_CARD, now); // 尝试把 10 秒窗口延长到最多 3 分钟(失败则退回 9 秒软超时) let budget: number = CARD_SOFT_TIMEOUT_MS; const granted: number = this.requestTransient('正在同步岁月家数据'); if (granted > 0) { const usable: number = granted - TRANSIENT_RESERVE_MS; budget = usable > CARD_SOFT_TIMEOUT_MS ? usable : CARD_SOFT_TIMEOUT_MS; if (budget > CARD_MAX_TOTAL_MS) { budget = CARD_MAX_TOTAL_MS; } } hilog.info(DOMAIN, TAG, '卡片驱动同步:开始(预算 %{public}d 秒)', Math.round(budget / 1000)); try { await this.raceWithBudget(this.runOnce(ctx, 'cardTick'), budget); } finally { // 无论成败都释放:① 归还配额 ② 避免超时不取消被系统强杀进程 this.cancelTransient(); } } catch (err) { const e: BusinessError = err as BusinessError; hilog.warn(DOMAIN, TAG, '卡片驱动同步异常:%{public}s', e.message); } } /** * 给一个异步任务套"时间预算":超时就让调用方继续,**不杀**那个任务。 * * ⚠️ 用 `Promise.race` 而不是 `AbortController`:这里的任务是**一串幂等的 * 网络+落库**,中途掐断会留下"拉了一半"的中间态;让它自己在后台跑完, * 或者被系统回收(同样幂等),都更安全。 */ private raceWithBudget(job: Promise, budgetMs: number): Promise { return new Promise((resolve: () => void): void => { let settled: boolean = false; const fin = (): void => { if (!settled) { settled = true; resolve(); } }; job.then((): void => { fin(); }).catch((): void => { fin(); }); setTimeout((): void => { if (!settled) { hilog.info(DOMAIN, TAG, '卡片驱动同步:时间预算用尽,主动收手(幂等,下轮重试)'); } fin(); }, budgetMs); }); } /* ---- preferences 小工具 ---- */ private async readNum(ctx: common.Context, key: string): Promise { try { const p: preferences.Preferences = await preferences.getPreferences(ctx, STORE_NAME); const v: preferences.ValueType = await p.get(key, 0); return typeof v === 'number' ? v : 0; } catch (err) { return 0; } } private async writeNum(ctx: common.Context, key: string, val: number): Promise { try { const p: preferences.Preferences = await preferences.getPreferences(ctx, STORE_NAME); await p.put(key, val); await p.flush(); } catch (err) { hilog.warn(DOMAIN, TAG, '写 %{public}s 失败:%{public}s', key, JSON.stringify(err)); } } } export const FB_BG_SYNC: FbBackgroundSyncImpl = new FbBackgroundSyncImpl();