Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60e6ddd8c6 | ||
|
|
1fb322e013 |
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user