Compare commits

...
2 Commits
Author SHA1 Message Date
Jose Olarte III 60e6ddd8c6 Merge branch 'master' into api-notify-deep-link 2026-10-21 16:39:38 -04:00
Jose Olarte III 1fb322e013 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.
2026-10-19 12:12:28 -04:00
6 changed files with 389 additions and 19 deletions
@@ -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
+72 -1
View File
@@ -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
@@ -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,
@@ -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<Promise<void>, [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 },
});
});
});
@@ -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<string, number>();
export interface PushNotificationTap {
actionId?: string;
notification?: {
id?: string;
data?: unknown;
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<void>,
now: number = Date.now(),
): Promise<boolean> {
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;
}
@@ -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<string, unknown> | undefined,
): Promise<void> {
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<void> {
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<void> {
@@ -319,16 +315,21 @@ async function initializeNativePushAndFirebaseMessagingImpl(): Promise<void> {
},
);
// 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);
});
},
);