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:
Jose Olarte III
2026-05-20 15:54:36 +08:00
parent 8cd8727a84
commit 5bc030125a
9 changed files with 158 additions and 2 deletions

View File

@@ -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 T5 fetch without this).
await configureNativeFetcherIfReady();
onNotificationAuthMayBeReady();
}
});
}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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)";

View 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");
}

View File

@@ -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();
}
}
/**

View File

@@ -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}:`,