diff --git a/src/main.capacitor.ts b/src/main.capacitor.ts index d25347ce..e6e45702 100644 --- a/src/main.capacitor.ts +++ b/src/main.capacitor.ts @@ -48,6 +48,7 @@ import { configureNativeFetcherIfReady, initializeNativePushAndFirebaseMessaging, onNotificationAuthMayBeReady, + runLegacyApiNotificationsCleanupOnce, } from "@/services/notifications"; logger.log("[Capacitor] 🚀 Starting initialization"); @@ -361,6 +362,8 @@ setTimeout(async () => { ); await registerDeepLinkListener(); logger.info(`[Main] 🎉 Deep link system fully initialized!`); + // One-time: remove persisted legacy api_* schedules from pre-retirement installs + await runLegacyApiNotificationsCleanupOnce(); // Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token, delivery listeners) await initializeNativePushAndFirebaseMessaging(); // Configure native fetcher for API-driven daily notifications (activeDid + JWT) diff --git a/src/services/notifications/index.ts b/src/services/notifications/index.ts index df73cdfe..7f3d141a 100644 --- a/src/services/notifications/index.ts +++ b/src/services/notifications/index.ts @@ -42,6 +42,7 @@ export { uploadAlertSearchAuthorization } from "./alertAuthorization"; export type { AlertAuthorizationUploadResult } from "./alertAuthorization"; export { configureNativeFetcherIfReady } from "./nativeFetcherConfig"; +export { runLegacyApiNotificationsCleanupOnce } from "./legacyApiNotificationsCleanup"; export { deferFcmRegistration, flushDeferredFcmRegistration, diff --git a/src/services/notifications/legacyApiNotificationsCleanup.test.ts b/src/services/notifications/legacyApiNotificationsCleanup.test.ts new file mode 100644 index 00000000..90cb0f04 --- /dev/null +++ b/src/services/notifications/legacyApiNotificationsCleanup.test.ts @@ -0,0 +1,101 @@ +import { Capacitor } from "@capacitor/core"; +import { Preferences } from "@capacitor/preferences"; +import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; +import { + LEGACY_API_NOTIFICATIONS_CLEANUP_KEY, + runLegacyApiNotificationsCleanupOnce, +} from "./legacyApiNotificationsCleanup"; + +jest.mock("@capacitor/core", () => ({ + Capacitor: { + isNativePlatform: jest.fn(), + }, +})); + +jest.mock("@capacitor/preferences", () => ({ + Preferences: { + get: jest.fn(), + set: jest.fn(), + }, +})); + +jest.mock("@/plugins/DailyNotificationPlugin", () => ({ + DailyNotification: { + clearApiNotifications: jest.fn(), + }, +})); + +jest.mock("@/utils/logger", () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + log: jest.fn(), + }, +})); + +const isNativePlatform = Capacitor.isNativePlatform as jest.Mock; +const preferencesGet = Preferences.get as jest.Mock; +const preferencesSet = Preferences.set as jest.Mock; +const clearApiNotifications = + DailyNotification.clearApiNotifications as jest.Mock; + +describe("runLegacyApiNotificationsCleanupOnce", () => { + beforeEach(() => { + jest.clearAllMocks(); + isNativePlatform.mockReturnValue(true); + preferencesGet.mockResolvedValue({ value: null }); + preferencesSet.mockResolvedValue(undefined); + clearApiNotifications.mockResolvedValue(undefined); + }); + + it("no-ops on non-native platforms", async () => { + isNativePlatform.mockReturnValue(false); + + await runLegacyApiNotificationsCleanupOnce(); + + expect(preferencesGet).not.toHaveBeenCalled(); + expect(clearApiNotifications).not.toHaveBeenCalled(); + expect(preferencesSet).not.toHaveBeenCalled(); + }); + + it("skips cleanup when the migration marker is already set", async () => { + preferencesGet.mockResolvedValue({ value: "1" }); + + await runLegacyApiNotificationsCleanupOnce(); + + expect(clearApiNotifications).not.toHaveBeenCalled(); + expect(preferencesSet).not.toHaveBeenCalled(); + }); + + it("clears api_* state and writes the marker only after success", async () => { + await runLegacyApiNotificationsCleanupOnce(); + + expect(clearApiNotifications).toHaveBeenCalledTimes(1); + expect(preferencesSet).toHaveBeenCalledWith({ + key: LEGACY_API_NOTIFICATIONS_CLEANUP_KEY, + value: "1", + }); + }); + + it("does not write the marker when clearApiNotifications fails", async () => { + clearApiNotifications.mockRejectedValue(new Error("native failure")); + + await runLegacyApiNotificationsCleanupOnce(); + + expect(clearApiNotifications).toHaveBeenCalledTimes(1); + expect(preferencesSet).not.toHaveBeenCalled(); + }); + + it("does not write the marker when clearApiNotifications is missing", async () => { + const original = DailyNotification.clearApiNotifications; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (DailyNotification as any).clearApiNotifications; + + await runLegacyApiNotificationsCleanupOnce(); + + expect(preferencesSet).not.toHaveBeenCalled(); + DailyNotification.clearApiNotifications = original; + }); +}); diff --git a/src/services/notifications/legacyApiNotificationsCleanup.ts b/src/services/notifications/legacyApiNotificationsCleanup.ts new file mode 100644 index 00000000..52f14e58 --- /dev/null +++ b/src/services/notifications/legacyApiNotificationsCleanup.ts @@ -0,0 +1,85 @@ +/** + * One-time upgrade cleanup for legacy API-managed notification schedules + * (`api_*`) left behind by the retired WAKEUP_PING → /notifications/refresh path. + * + * Uses DailyNotification.clearApiNotifications(), which cancels only `api_*` + * pending/delivered state (and Android persisted api_* schedule rows). It does + * not touch daily reminders, dual/New Activity schedules, AlertSearch, or FCM. + */ + +import { Capacitor } from "@capacitor/core"; +import { Preferences } from "@capacitor/preferences"; +import { DailyNotification } from "@/plugins/DailyNotificationPlugin"; +import { logger } from "@/utils/logger"; + +/** Preferences key; written only after a successful native cleanup. */ +export const LEGACY_API_NOTIFICATIONS_CLEANUP_KEY = + "legacy_api_notifications_cleanup_v1"; + +const DONE_VALUE = "1"; + +let inFlight: Promise | null = null; + +/** + * Idempotent startup hook. Safe when zero `api_*` schedules exist. + * On failure, leaves the marker unset so a later launch can retry. + */ +export async function runLegacyApiNotificationsCleanupOnce(): Promise { + if (inFlight) { + return inFlight; + } + inFlight = runCleanup().finally(() => { + inFlight = null; + }); + return inFlight; +} + +async function runCleanup(): Promise { + if (!Capacitor.isNativePlatform()) { + return; + } + + try { + const existing = await Preferences.get({ + key: LEGACY_API_NOTIFICATIONS_CLEANUP_KEY, + }); + if (existing.value === DONE_VALUE) { + return; + } + } catch (error) { + logger.warn( + "[legacyApiNotificationsCleanup] Could not read migration marker; skipping this launch", + error, + ); + return; + } + + const clearApiNotifications = ( + DailyNotification as { + clearApiNotifications?: () => Promise; + } + ).clearApiNotifications; + + if (typeof clearApiNotifications !== "function") { + logger.warn( + "[legacyApiNotificationsCleanup] clearApiNotifications unavailable; will retry on a later launch", + ); + return; + } + + try { + await clearApiNotifications.call(DailyNotification); + await Preferences.set({ + key: LEGACY_API_NOTIFICATIONS_CLEANUP_KEY, + value: DONE_VALUE, + }); + logger.info( + "[legacyApiNotificationsCleanup] Cleared legacy api_* notification state", + ); + } catch (error) { + logger.warn( + "[legacyApiNotificationsCleanup] Cleanup failed; will retry on next startup", + error, + ); + } +}