From 1fb322e013ace099f2b5b48532673a2f44524471 Mon Sep 17 00:00:00 2001 From: Jose Olarte III Date: Mon, 19 Oct 2026 12:12:28 -0400 Subject: [PATCH] Open New Activity when the AlertSearch notification is tapped, including after a cold start. The tap uses the existing deep link, and Android and iOS now forward the launch tap that Capacitor drops. Receiving the push still does not navigate. --- .../java/app/timesafari/MainActivity.java | 28 +++ ios/App/App/AppDelegate.swift | 73 ++++++- .../NativeNotificationService.ts | 5 +- .../alertSearchNotificationTap.test.ts | 179 ++++++++++++++++++ .../alertSearchNotificationTap.ts | 90 +++++++++ .../notifications/firebaseMessagingClient.ts | 33 ++-- 6 files changed, 389 insertions(+), 19 deletions(-) create mode 100644 src/services/notifications/alertSearchNotificationTap.test.ts create mode 100644 src/services/notifications/alertSearchNotificationTap.ts diff --git a/android/app/src/main/java/app/timesafari/MainActivity.java b/android/app/src/main/java/app/timesafari/MainActivity.java index 1209ff8b..b379f33d 100644 --- a/android/app/src/main/java/app/timesafari/MainActivity.java +++ b/android/app/src/main/java/app/timesafari/MainActivity.java @@ -85,6 +85,9 @@ public class MainActivity extends BridgeActivity { // Handle share intent if app was launched from share sheet handleShareIntent(getIntent()); + // A notification tap that started the process is this first intent. + // Capacitor does not call onNewIntent for it. + deliverLaunchNotificationTap(); } @Override @@ -94,6 +97,31 @@ public class MainActivity extends BridgeActivity { handleShareIntent(intent); } + /** + * Forward a cold-start notification tap into PushNotificationsPlugin. + * The plugin emits pushNotificationActionPerformed only from onNewIntent, and + * that is not called for the intent that created the activity. The message id + * is removed after the synchronous handoff so restoring this same intent does + * not open New Activity again. + */ + private void deliverLaunchNotificationTap() { + Intent intent = getIntent(); + if (intent == null) { + return; + } + Bundle extras = intent.getExtras(); + if (extras == null || !extras.containsKey("google.message_id")) { + return; + } + if (getBridge() == null) { + Log.w(TAG, "Bridge unavailable; launch notification tap was not delivered"); + return; + } + Log.d(TAG, "Delivering launch notification tap"); + getBridge().onNewIntent(intent); + extras.remove("google.message_id"); + } + /** * Handle share intents (ACTION_SEND or ACTION_SEND_MULTIPLE) * Processes shared images and stores them in SharedPreferences for plugin to read diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index 816a3a09..9ad71e18 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -8,6 +8,9 @@ import UserNotifications class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { var window: UIWindow? + /// Remote-notification tap received before the Capacitor bridge could take it. + private var pendingPushTap: UNNotificationResponse? + private var pushTapFlushScheduled = false func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // New Activity / dual schedule: plugin requires a registered native fetcher before configureNativeFetcher (parity with Android setNativeFetcher). @@ -96,8 +99,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func applicationDidBecomeActive(_ application: UIApplication) { // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - // Re-set notification delegate when app becomes active (in case Capacitor resets it) + // Re-set notification delegate when app becomes active (in case Capacitor resets it). + // Daily reminders depend on willPresent staying here. Push taps are forwarded below. UNUserNotificationCenter.current().delegate = self + flushPendingPushTap() // Check for shared image from Share Extension when app becomes active checkForSharedImageOnActivation() @@ -131,9 +136,75 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } /// Handle notification tap/interaction. + /// Local notifications (daily reminders) stay here. Remote notification taps are + /// forwarded to Capacitor, including a cold start where the bridge is not ready yet. func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + if response.notification.request.trigger is UNPushNotificationTrigger { + deliverOrQueuePushTap(response) + } completionHandler() } + + /// AppDelegate owns UNUserNotificationCenter so daily-reminder foreground delivery + /// keeps working. Capacitor's push listener only runs if we hand it the tap. + private func deliverOrQueuePushTap(_ response: UNNotificationResponse) { + if deliverPushTapToCapacitor(response) { + pendingPushTap = nil + return + } + pendingPushTap = response + schedulePendingPushTapFlush() + } + + private func flushPendingPushTap() { + guard let pending = pendingPushTap else { return } + if deliverPushTapToCapacitor(pending) { + pendingPushTap = nil + } + } + + private func schedulePendingPushTapFlush() { + if pushTapFlushScheduled { return } + pushTapFlushScheduled = true + var attempts = 0 + func attempt() { + attempts += 1 + self.flushPendingPushTap() + if self.pendingPushTap == nil { + self.pushTapFlushScheduled = false + return + } + if attempts < 5 { + DispatchQueue.main.asyncAfter(deadline: .now() + Double(attempts) * 0.5) { + attempt() + } + } else { + self.pushTapFlushScheduled = false + print("[AppDelegate] Push notification tap still pending; Capacitor bridge was not ready") + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + attempt() + } + } + + private func deliverPushTapToCapacitor(_ response: UNNotificationResponse) -> Bool { + if !Thread.isMainThread { + var delivered = false + DispatchQueue.main.sync { + delivered = self.deliverPushTapToCapacitor(response) + } + return delivered + } + guard let window = self.window, + let bridgeVC = window.rootViewController as? CAPBridgeViewController, + let bridge = bridgeVC.bridge, + let handler = bridge.notificationRouter.pushNotificationHandler else { + return false + } + handler.didReceive(response: response) + return true + } /** * Check for shared image when app launches or becomes active diff --git a/src/services/notifications/NativeNotificationService.ts b/src/services/notifications/NativeNotificationService.ts index 9e02000b..1ea1dae1 100644 --- a/src/services/notifications/NativeNotificationService.ts +++ b/src/services/notifications/NativeNotificationService.ts @@ -543,8 +543,9 @@ export class NativeNotificationService implements NotificationServiceInterface { /** * Capacitor push delivery hook. Legacy WAKEUP_PING → /notifications/refresh - * consumption was retired; AlertSearch uses visible FCM notifications and is - * not handled here. Keep logging for diagnostics without scheduling api_* work. + * consumption was retired. AlertSearch is not opened here: a tap is a separate + * event and opens `/deep-link/new-activity` from the action listener. Keep + * logging for diagnostics without scheduling api_* work. */ export async function handleCapacitorPushNotificationReceived( notification: PushNotificationSchema, diff --git a/src/services/notifications/alertSearchNotificationTap.test.ts b/src/services/notifications/alertSearchNotificationTap.test.ts new file mode 100644 index 00000000..3a3c3c29 --- /dev/null +++ b/src/services/notifications/alertSearchNotificationTap.test.ts @@ -0,0 +1,179 @@ +jest.mock("@/services/QRNavigationService", () => ({ + QRNavigationService: { + getInstance: () => ({ + getQRScannerRoute: () => ({ name: "contact-qr" }), + }), + }, +})); + +jest.mock("@/utils/logger", () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + }, +})); + +import { DeepLinkHandler } from "@/services/deepLinks"; +import { + alertSearchDeepLinkForTap, + openAlertSearchFromNotificationTap, + resetAlertSearchNotificationTapState, + type PushNotificationTap, +} from "./alertSearchNotificationTap"; + +const CURSORS = "1234567.45678-1234567"; + +function alertSearchTap( + overrides: PushNotificationTap = {}, +): PushNotificationTap { + return { + actionId: "tap", + notification: { + id: "msg-1", + data: { type: "alert_search", c: CURSORS }, + }, + ...overrides, + }; +} + +describe("AlertSearch notification tap routing", () => { + const openDeepLink = jest.fn, [string]>(); + + beforeEach(() => { + resetAlertSearchNotificationTapState(); + openDeepLink.mockReset(); + openDeepLink.mockResolvedValue(undefined); + }); + + it("opens the in-app deep link for /deep-link/new-activity, including start cursors", async () => { + const opened = await openAlertSearchFromNotificationTap( + alertSearchTap(), + openDeepLink, + ); + + expect(opened).toBe(true); + expect(openDeepLink).toHaveBeenCalledWith( + `timesafari://new-activity?c=${CURSORS}`, + ); + }); + + it("omits c when the payload has no start cursors", () => { + expect( + alertSearchDeepLinkForTap( + alertSearchTap({ + notification: { id: "msg-1", data: { type: "alert_search" } }, + }), + ), + ).toBe("timesafari://new-activity"); + }); + + it.each([ + ["another FCM type", { type: "daily_reminder", c: CURSORS }], + ["a payload with no type", { c: CURSORS }], + ["a missing payload", undefined], + ])("does not navigate for %s", async (_label, data) => { + const opened = await openAlertSearchFromNotificationTap( + alertSearchTap({ notification: { id: "other", data } }), + openDeepLink, + ); + + expect(opened).toBe(false); + expect(openDeepLink).not.toHaveBeenCalled(); + }); + + it("does not navigate when the notification is dismissed", async () => { + const opened = await openAlertSearchFromNotificationTap( + alertSearchTap({ actionId: "dismiss" }), + openDeepLink, + ); + + expect(opened).toBe(false); + expect(openDeepLink).not.toHaveBeenCalled(); + }); + + it("opens once when a cold-start check and the tap listener both see the same tap", async () => { + const tap = alertSearchTap(); + + const first = await openAlertSearchFromNotificationTap( + tap, + openDeepLink, + 1_000, + ); + const second = await openAlertSearchFromNotificationTap( + tap, + openDeepLink, + 2_000, + ); + + expect(first).toBe(true); + expect(second).toBe(false); + expect(openDeepLink).toHaveBeenCalledTimes(1); + }); + + it("opens a later tap of the same notification after the dedupe window", async () => { + const tap = alertSearchTap(); + + await openAlertSearchFromNotificationTap(tap, openDeepLink, 1_000); + const later = await openAlertSearchFromNotificationTap( + tap, + openDeepLink, + 1_000 + 5_000, + ); + + expect(later).toBe(true); + expect(openDeepLink).toHaveBeenCalledTimes(2); + }); + + it("still opens a different notification that arrives immediately after", async () => { + await openAlertSearchFromNotificationTap( + alertSearchTap(), + openDeepLink, + 1_000, + ); + const other = await openAlertSearchFromNotificationTap( + alertSearchTap({ + notification: { + id: "msg-2", + data: { type: "alert_search", c: "99.99" }, + }, + }), + openDeepLink, + 1_100, + ); + + expect(other).toBe(true); + expect(openDeepLink).toHaveBeenCalledTimes(2); + }); + + it("lets a failed open be retried", async () => { + openDeepLink.mockRejectedValueOnce(new Error("router not ready")); + const tap = alertSearchTap(); + + await expect( + openAlertSearchFromNotificationTap(tap, openDeepLink, 1_000), + ).rejects.toThrow("router not ready"); + const retried = await openAlertSearchFromNotificationTap( + tap, + openDeepLink, + 1_100, + ); + + expect(retried).toBe(true); + expect(openDeepLink).toHaveBeenCalledTimes(2); + }); + + it("resolves that deep link to the New Activity route with c", async () => { + const router = { replace: jest.fn().mockResolvedValue(undefined) }; + const handler = new DeepLinkHandler(router as never); + + await handler.handleDeepLink(`timesafari://new-activity?c=${CURSORS}`); + + expect(router.replace).toHaveBeenCalledWith({ + name: "new-activity", + params: {}, + query: { c: CURSORS }, + }); + }); +}); diff --git a/src/services/notifications/alertSearchNotificationTap.ts b/src/services/notifications/alertSearchNotificationTap.ts new file mode 100644 index 00000000..76b35bbb --- /dev/null +++ b/src/services/notifications/alertSearchNotificationTap.ts @@ -0,0 +1,90 @@ +/** + * Routes a tap on the daily New Activity FCM notification to the in-app form of + * `/deep-link/new-activity`. + * + * Arrival (`pushNotificationReceived`) must not call this. Foreground, background + * resume, and cold start all arrive as `pushNotificationActionPerformed`: Capacitor + * retains that event until the listener exists, and the native shells forward the + * launch intent into the same event. A launch check and the listener can both + * see one tap, so the same notification is opened once. + */ + +const ALERT_SEARCH_FCM_TYPE = "alert_search"; +/** Long enough to cover the startup listener registering after a launch forward. */ +const DEDUPE_WINDOW_MS = 5000; + +const recentTapAt = new Map(); + +export interface PushNotificationTap { + actionId?: string; + notification?: { + id?: string; + data?: unknown; + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNotificationBodyTap(actionId: string | undefined): boolean { + return actionId === undefined || actionId === "" || actionId === "tap"; +} + +/** + * `timesafari://new-activity` is the in-app deep link for `/deep-link/new-activity`. + * Optional `c` is the notification's start cursors. + */ +export function alertSearchDeepLinkForTap( + tap: PushNotificationTap, +): string | null { + if (!isNotificationBodyTap(tap.actionId)) return null; + const data = tap.notification?.data; + if (!isRecord(data) || data.type !== ALERT_SEARCH_FCM_TYPE) return null; + const c = + typeof data.c === "string" && data.c.length > 0 ? data.c : undefined; + return c + ? `timesafari://new-activity?c=${encodeURIComponent(c)}` + : "timesafari://new-activity"; +} + +function dedupeKey(tap: PushNotificationTap, url: string): string { + const id = tap.notification?.id?.trim(); + return id ? `id:${id}` : `url:${url}`; +} + +function claimTap(key: string, now: number): boolean { + const previous = recentTapAt.get(key); + if (previous !== undefined && now - previous < DEDUPE_WINDOW_MS) { + return false; + } + recentTapAt.set(key, now); + return true; +} + +/** Clears the tap dedupe window. Tests use this between cases. */ +export function resetAlertSearchNotificationTapState(): void { + recentTapAt.clear(); +} + +/** + * Open `/deep-link/new-activity` for an AlertSearch notification tap. + * Returns false when the action is not that tap, or when this tap was already opened. + */ +export async function openAlertSearchFromNotificationTap( + tap: PushNotificationTap, + openDeepLink: (url: string) => Promise, + now: number = Date.now(), +): Promise { + const url = alertSearchDeepLinkForTap(tap); + if (!url) return false; + const key = dedupeKey(tap, url); + if (!claimTap(key, now)) return false; + try { + await openDeepLink(url); + } catch (error) { + recentTapAt.delete(key); + throw error; + } + return true; +} diff --git a/src/services/notifications/firebaseMessagingClient.ts b/src/services/notifications/firebaseMessagingClient.ts index 7e043331..9afbc92e 100644 --- a/src/services/notifications/firebaseMessagingClient.ts +++ b/src/services/notifications/firebaseMessagingClient.ts @@ -22,6 +22,7 @@ import { } from "firebase/messaging"; import { logger } from "@/utils/logger"; import { NotificationInspector } from "@/plugins/NotificationInspectorPlugin"; +import { openAlertSearchFromNotificationTap } from "./alertSearchNotificationTap"; import { handleCapacitorPushNotificationReceived } from "./NativeNotificationService"; import { getNotificationApiHeaders } from "./notificationApiAuth"; import { deferFcmRegistration } from "./notificationAuthLifecycle"; @@ -259,21 +260,16 @@ async function attachFirebaseMessagingIfSupported( * Native: register Capacitor push listeners, request permissions, register for push, * then initialize Firebase Messaging when env config and platform support allow. */ -/** The FCM data `type` of the daily New Activity notification. */ -const ALERT_SEARCH_FCM_TYPE = "alert_search"; /** - * Open New Activity when the daily New Activity notification is tapped, - * passing its start cursors (`c`) so the screen can compare them with its own. + * Open `/deep-link/new-activity` through the deep-link handler. + * The router is imported here because it loads views that import this module's callers. */ -async function openNewActivityForAlertSearch( - data: Record | undefined, -): Promise { - if (data?.type !== ALERT_SEARCH_FCM_TYPE) return; - // imported here: the router imports views that import this module's callers +async function openNewActivityDeepLink(url: string): Promise { const { default: router } = await import("@/router"); - const c = typeof data.c === "string" ? data.c : undefined; - await router.push({ name: "new-activity", query: c ? { c } : {} }); + const { DeepLinkHandler } = await import("@/services/deepLinks"); + await router.isReady(); + await new DeepLinkHandler(router).handleDeepLink(url); } async function initializeNativePushAndFirebaseMessagingImpl(): Promise { @@ -319,16 +315,21 @@ async function initializeNativePushAndFirebaseMessagingImpl(): Promise { }, ); + // Tap only. pushNotificationReceived (above) stays a log. Cold start is this + // same event: native code forwards the launch tap, and Capacitor retains it + // until this listener exists. openAlertSearchFromNotificationTap ignores a + // second delivery of that tap. await PushNotifications.addListener( "pushNotificationActionPerformed", (action) => { logger.debug(`${LOG} pushNotificationActionPerformed`, action); logPushNotificationActionPerformed(action); - void openNewActivityForAlertSearch(action.notification?.data).catch( - (err) => { - logger.warn(`${LOG} Could not open New Activity`, err); - }, - ); + void openAlertSearchFromNotificationTap( + action, + openNewActivityDeepLink, + ).catch((err) => { + logger.warn(`${LOG} Could not open New Activity`, err); + }); }, ); -- 2.54.0