feat(notifications): defer FCM registration until auth is ready
Queue token registration when Bearer auth is unavailable at startup, with bounded exponential backoff retries. Flush the pending token when identity is set, the app resumes, or the native fetcher configures. Skip refresh API calls when auth is missing and log lifecycle events for registration wait and refresh skip.
This commit is contained in:
@@ -46,6 +46,7 @@ import "@timesafari/daily-notification-plugin";
|
||||
import {
|
||||
configureNativeFetcherIfReady,
|
||||
initializeNativePushAndFirebaseMessaging,
|
||||
onNotificationAuthMayBeReady,
|
||||
} from "@/services/notifications";
|
||||
|
||||
logger.log("[Capacitor] 🚀 Starting initialization");
|
||||
@@ -465,6 +466,7 @@ if (
|
||||
// Refresh JWT for background New Activity prefetch (WorkManager cannot run JS;
|
||||
// short-lived tokens would expire between configure and T−5 fetch without this).
|
||||
await configureNativeFetcherIfReady();
|
||||
onNotificationAuthMayBeReady();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ import {
|
||||
import {
|
||||
getNotificationApiHeaders,
|
||||
httpAuthErrorMessage,
|
||||
logNotificationAuthFailure,
|
||||
logNotificationRequestAuthenticated,
|
||||
logSkippingRefreshDueToMissingAuth,
|
||||
} from "./notificationApiAuth";
|
||||
import { logNotification } from "./NotificationDebugEvents";
|
||||
|
||||
@@ -593,7 +593,7 @@ export async function refreshNotificationsWithDiagnostics(options?: {
|
||||
try {
|
||||
const auth = await getNotificationApiHeaders();
|
||||
if (!auth.ok) {
|
||||
logNotificationAuthFailure("refresh", auth.message);
|
||||
logSkippingRefreshDueToMissingAuth();
|
||||
logRefreshFailure(startedAt, auth.message);
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
} from "firebase/messaging";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { handleCapacitorPushNotificationReceived } from "./NativeNotificationService";
|
||||
import { getNotificationApiHeaders } from "./notificationApiAuth";
|
||||
import { deferFcmRegistration } from "./notificationAuthLifecycle";
|
||||
import { registerToken } from "./NotificationService";
|
||||
import {
|
||||
logPushNotificationActionPerformed,
|
||||
@@ -51,6 +53,16 @@ async function registerRetrievedToken(
|
||||
logTokenRegistrationSkippedDuplicate(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = await getNotificationApiHeaders();
|
||||
if (!auth.ok) {
|
||||
if (options?.force) {
|
||||
throw new Error(`FCM registration auth unavailable: ${auth.message}`);
|
||||
}
|
||||
deferFcmRegistration(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
await registerToken(trimmed);
|
||||
lastRegisteredFcmToken = trimmed;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,11 @@ export { NativeNotificationService } from "./NativeNotificationService";
|
||||
export { WebPushNotificationService } from "./WebPushNotificationService";
|
||||
|
||||
export { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
|
||||
export {
|
||||
deferFcmRegistration,
|
||||
flushDeferredFcmRegistration,
|
||||
onNotificationAuthMayBeReady,
|
||||
} from "./notificationAuthLifecycle";
|
||||
export {
|
||||
ensureFirebaseApp,
|
||||
initializeNativePushAndFirebaseMessaging,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { mintBackgroundJwtTokenPool } from "@/libs/crypto";
|
||||
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
|
||||
import { logger } from "@/utils/logger";
|
||||
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
|
||||
import { onNotificationAuthMayBeReady } from "./notificationAuthLifecycle";
|
||||
|
||||
/**
|
||||
* Configure the native notification content fetcher with API credentials.
|
||||
@@ -90,6 +91,7 @@ export async function configureNativeFetcherIfReady(
|
||||
jwtTokens.length +
|
||||
")",
|
||||
);
|
||||
onNotificationAuthMayBeReady();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error("[nativeFetcherConfig] configureNativeFetcher failed:", error);
|
||||
|
||||
@@ -93,6 +93,14 @@ export function logNotificationAuthFailure(
|
||||
logNotification(`${verb} auth unavailable: ${message}`);
|
||||
}
|
||||
|
||||
export function logWaitingForAuthBeforeRegistration(): void {
|
||||
logNotification("Waiting for auth before registration");
|
||||
}
|
||||
|
||||
export function logSkippingRefreshDueToMissingAuth(): void {
|
||||
logNotification("Skipping refresh due to missing auth");
|
||||
}
|
||||
|
||||
export function httpAuthErrorMessage(status: number): string {
|
||||
if (status === 401) {
|
||||
return "unauthorized (expired or invalid auth)";
|
||||
|
||||
115
src/services/notifications/notificationAuthLifecycle.ts
Normal file
115
src/services/notifications/notificationAuthLifecycle.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Defers notification register/refresh until app auth (active DID + Bearer) is available.
|
||||
* Bounded retries avoid racing startup and prevent infinite loops.
|
||||
*/
|
||||
|
||||
import { logger } from "@/utils/logger";
|
||||
import {
|
||||
getNotificationApiHeaders,
|
||||
logWaitingForAuthBeforeRegistration,
|
||||
} from "./notificationApiAuth";
|
||||
import { registerToken } from "./NotificationService";
|
||||
|
||||
const MAX_REGISTER_RETRY_ATTEMPTS = 6;
|
||||
const REGISTER_RETRY_BASE_MS = 2_000;
|
||||
const REGISTER_RETRY_MAX_MS = 60_000;
|
||||
|
||||
let pendingFcmToken: string | null = null;
|
||||
let registerRetryAttempt = 0;
|
||||
let registerRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let registerFlushInFlight: Promise<void> | null = null;
|
||||
|
||||
function clearRegisterRetryTimer(): void {
|
||||
if (registerRetryTimer != null) {
|
||||
clearTimeout(registerRetryTimer);
|
||||
registerRetryTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDeferredRegistrationRetry(): void {
|
||||
if (!pendingFcmToken || registerRetryTimer != null) {
|
||||
return;
|
||||
}
|
||||
if (registerRetryAttempt >= MAX_REGISTER_RETRY_ATTEMPTS) {
|
||||
logger.warn(
|
||||
"[notificationAuthLifecycle] Stopped retrying deferred FCM registration (max attempts)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
REGISTER_RETRY_BASE_MS * 2 ** registerRetryAttempt,
|
||||
REGISTER_RETRY_MAX_MS,
|
||||
);
|
||||
registerRetryAttempt += 1;
|
||||
registerRetryTimer = setTimeout(() => {
|
||||
registerRetryTimer = null;
|
||||
void flushDeferredFcmRegistration("scheduled retry");
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue FCM token registration until Bearer auth is available; retries with backoff.
|
||||
*/
|
||||
export function deferFcmRegistration(token: string): void {
|
||||
const trimmed = token.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
pendingFcmToken = trimmed;
|
||||
logWaitingForAuthBeforeRegistration();
|
||||
scheduleDeferredRegistrationRetry();
|
||||
}
|
||||
|
||||
/** Attempt pending FCM registration when auth may now be ready (identity, resume, fetcher config). */
|
||||
export async function flushDeferredFcmRegistration(
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
if (!pendingFcmToken) {
|
||||
return;
|
||||
}
|
||||
if (registerFlushInFlight) {
|
||||
return registerFlushInFlight;
|
||||
}
|
||||
|
||||
registerFlushInFlight = (async () => {
|
||||
const token = pendingFcmToken;
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = await getNotificationApiHeaders();
|
||||
if (!auth.ok) {
|
||||
scheduleDeferredRegistrationRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
clearRegisterRetryTimer();
|
||||
registerRetryAttempt = 0;
|
||||
pendingFcmToken = null;
|
||||
|
||||
try {
|
||||
await registerToken(token);
|
||||
} catch (err) {
|
||||
pendingFcmToken = token;
|
||||
logger.warn(
|
||||
`[notificationAuthLifecycle] Deferred FCM registration failed${reason ? ` (${reason})` : ""}`,
|
||||
err,
|
||||
);
|
||||
scheduleDeferredRegistrationRetry();
|
||||
}
|
||||
})().finally(() => {
|
||||
registerFlushInFlight = null;
|
||||
});
|
||||
|
||||
return registerFlushInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call when active identity or session may have become available (additive hooks only).
|
||||
*/
|
||||
export function onNotificationAuthMayBeReady(): void {
|
||||
registerRetryAttempt = 0;
|
||||
clearRegisterRetryTimer();
|
||||
void flushDeferredFcmRegistration("auth may be ready");
|
||||
}
|
||||
@@ -139,6 +139,12 @@ export abstract class BaseDatabaseService {
|
||||
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
|
||||
[did],
|
||||
);
|
||||
if (did?.trim()) {
|
||||
const { onNotificationAuthMayBeReady } = await import(
|
||||
"@/services/notifications/notificationAuthLifecycle"
|
||||
);
|
||||
onNotificationAuthMayBeReady();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -231,6 +231,12 @@ export const PlatformServiceMixin = {
|
||||
logger.debug(
|
||||
`[PlatformServiceMixin] ActiveDid updated in active_identity table: ${newDid}`,
|
||||
);
|
||||
if (newDid) {
|
||||
const { onNotificationAuthMayBeReady } = await import(
|
||||
"@/services/notifications/notificationAuthLifecycle"
|
||||
);
|
||||
onNotificationAuthMayBeReady();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[PlatformServiceMixin] Error updating activeDid in active_identity table ${newDid}:`,
|
||||
|
||||
Reference in New Issue
Block a user