feat(notifications): initialize Firebase Messaging and Capacitor push on native

Add firebaseMessagingClient to ensure the Firebase app is created from VITE_FIREBASE_*,
wire PushNotifications (listeners, requestPermissions, register) before token work,
and call getMessaging/getToken/onMessage when firebase/messaging is supported. Hook
startup from main.capacitor and set PushNotifications presentationOptions in
capacitor.config. Depend on firebase and @capacitor/push-notifications.
This commit is contained in:
Jose Olarte III
2026-05-06 15:30:46 +08:00
parent 1643bab18b
commit 162158066f
12 changed files with 2983 additions and 1278 deletions

View File

@@ -15,6 +15,7 @@ dependencies {
implementation project(':capacitor-camera')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-share')
implementation project(':capacitor-status-bar')
implementation project(':capawesome-capacitor-file-picker')

View File

@@ -16,6 +16,13 @@
]
}
},
"PushNotifications": {
"presentationOptions": [
"badge",
"sound",
"alert"
]
},
"SplashScreen": {
"launchShowDuration": 3000,
"launchAutoHide": true,

View File

@@ -23,6 +23,10 @@
"pkg": "@capacitor/filesystem",
"classpath": "com.capacitorjs.plugins.filesystem.FilesystemPlugin"
},
{
"pkg": "@capacitor/push-notifications",
"classpath": "com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin"
},
{
"pkg": "@capacitor/share",
"classpath": "com.capacitorjs.plugins.share.SharePlugin"

View File

@@ -20,6 +20,9 @@ project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacito
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')

View File

@@ -18,6 +18,9 @@ const config: CapacitorConfig = {
]
}
},
PushNotifications: {
presentationOptions: ['badge', 'sound', 'alert']
},
SplashScreen: {
launchShowDuration: 3000,
launchAutoHide: true,

View File

@@ -18,6 +18,7 @@ def capacitor_pods
pod 'CapacitorCamera', :path => '../../node_modules/@capacitor/camera'
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'CapawesomeCapacitorFilePicker', :path => '../../node_modules/@capawesome/capacitor-file-picker'

View File

@@ -17,6 +17,8 @@ PODS:
- CapacitorMlkitBarcodeScanning (6.2.0):
- Capacitor
- GoogleMLKit/BarcodeScanning (= 5.0.0)
- CapacitorPushNotifications (6.0.5):
- Capacitor
- CapacitorShare (6.0.3):
- Capacitor
- CapacitorStatusBar (6.0.2):
@@ -99,6 +101,7 @@ DEPENDENCIES:
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)"
- "CapacitorMlkitBarcodeScanning (from `../../node_modules/@capacitor-mlkit/barcode-scanning`)"
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
- "CapacitorShare (from `../../node_modules/@capacitor/share`)"
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
- "CapawesomeCapacitorFilePicker (from `../../node_modules/@capawesome/capacitor-file-picker`)"
@@ -138,6 +141,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/filesystem"
CapacitorMlkitBarcodeScanning:
:path: "../../node_modules/@capacitor-mlkit/barcode-scanning"
CapacitorPushNotifications:
:path: "../../node_modules/@capacitor/push-notifications"
CapacitorShare:
:path: "../../node_modules/@capacitor/share"
CapacitorStatusBar:
@@ -156,6 +161,7 @@ SPEC CHECKSUMS:
CapacitorCordova: 8d93e14982f440181be7304aa9559ca631d77fff
CapacitorFilesystem: 59270a63c60836248812671aa3b15df673fbaf74
CapacitorMlkitBarcodeScanning: 7652be9c7922f39203a361de735d340ae37e134e
CapacitorPushNotifications: 35abece14371c57172e8321c9ccc8b6fa35fabfe
CapacitorShare: d2a742baec21c8f3b92b361a2fbd2401cdd8288e
CapacitorStatusBar: b16799a26320ffa52f6c8b01737d5a95bbb8f3eb
CapawesomeCapacitorFilePicker: c40822f0a39f86855321943c7829d52bca7f01bd
@@ -175,6 +181,6 @@ SPEC CHECKSUMS:
TimesafariDailyNotificationPlugin: 95e0a6238f6586ca5190cc6d653ac882fb0e82ac
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: bf247ff01f83709ef1010f328f5fb4ab5370cb41
PODFILE CHECKSUM: efd66cc2a3ebb7b5bae6a7c15e52bda1ab546cf6
COCOAPODS: 1.16.2

4036
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -148,6 +148,7 @@
"@capacitor/core": "^6.2.0",
"@capacitor/filesystem": "^6.0.0",
"@capacitor/ios": "^6.2.0",
"@capacitor/push-notifications": "^6.0.5",
"@capacitor/share": "^6.0.3",
"@capacitor/status-bar": "^6.0.2",
"@capawesome/capacitor-file-picker": "^6.2.0",
@@ -194,6 +195,7 @@
"electron-builder": "^26.0.12",
"ethereum-cryptography": "^2.1.3",
"ethereumjs-util": "^7.1.5",
"firebase": "^12.12.1",
"jdenticon": "^3.2.0",
"js-generate-password": "^0.1.9",
"js-yaml": "^4.1.0",

View File

@@ -43,7 +43,10 @@ import "./utils/safeAreaInset";
// Load Daily Notification plugin at startup so native performRecovery() runs at launch (rollover recovery)
import "@timesafari/daily-notification-plugin";
import { configureNativeFetcherIfReady } from "@/services/notifications";
import {
configureNativeFetcherIfReady,
initializeNativePushAndFirebaseMessaging,
} from "@/services/notifications";
logger.log("[Capacitor] 🚀 Starting initialization");
logger.log("[Capacitor] Platform:", process.env.VITE_PLATFORM);
@@ -474,6 +477,8 @@ setTimeout(async () => {
);
await registerDeepLinkListener();
logger.info(`[Main] 🎉 Deep link system fully initialized!`);
// Firebase Messaging (JS) + Capacitor PushNotifications (FCM/APNs token, delivery listeners)
await initializeNativePushAndFirebaseMessaging();
// Configure native fetcher for API-driven daily notifications (activeDid + JWT)
await configureNativeFetcherIfReady();
} catch (error) {

View File

@@ -0,0 +1,185 @@
/**
* Firebase Cloud Messaging (JS SDK) + Capacitor Push Notifications (native bridge).
*
* Initializes the Firebase web app when VITE_FIREBASE_* env vars are set, wires
* Capacitor push listeners, requests permission before registration/token flow,
* and attaches Firebase messaging when the browser/WebView reports support.
*/
import { Capacitor } from "@capacitor/core";
import { PushNotifications } from "@capacitor/push-notifications";
import {
type FirebaseApp,
type FirebaseOptions,
getApps,
initializeApp,
} from "firebase/app";
import {
getMessaging,
getToken,
isSupported,
onMessage,
} from "firebase/messaging";
import { logger } from "@/utils/logger";
const LOG = "[FirebaseMessaging]";
let firebaseAppSingleton: FirebaseApp | null = null;
let nativeInitPromise: Promise<void> | null = null;
function readFirebaseOptions(): FirebaseOptions | null {
const env = import.meta.env;
const apiKey = env.VITE_FIREBASE_API_KEY as string | undefined;
const projectId = env.VITE_FIREBASE_PROJECT_ID as string | undefined;
const appId = env.VITE_FIREBASE_APP_ID as string | undefined;
const messagingSenderId = env.VITE_FIREBASE_MESSAGING_SENDER_ID as
| string
| undefined;
if (!apiKey || !projectId || !appId || !messagingSenderId) {
logger.debug(
`${LOG} Missing one or more VITE_FIREBASE_* keys; Firebase app not initialized`,
);
return null;
}
const authDomain =
(env.VITE_FIREBASE_AUTH_DOMAIN as string | undefined) ||
`${projectId}.firebaseapp.com`;
const storageBucket =
(env.VITE_FIREBASE_STORAGE_BUCKET as string | undefined) ||
`${projectId}.appspot.com`;
const opts: FirebaseOptions = {
apiKey,
authDomain,
projectId,
storageBucket,
messagingSenderId,
appId,
};
const measurementId = env.VITE_FIREBASE_MEASUREMENT_ID as string | undefined;
if (measurementId) {
opts.measurementId = measurementId;
}
return opts;
}
/**
* Ensures a single Firebase app instance for the client when config is present.
*/
export function ensureFirebaseApp(): FirebaseApp | null {
if (firebaseAppSingleton) {
return firebaseAppSingleton;
}
const options = readFirebaseOptions();
if (!options) {
return null;
}
firebaseAppSingleton =
getApps().length > 0 ? getApps()[0]! : initializeApp(options);
logger.info(`${LOG} Firebase app initialized`);
return firebaseAppSingleton;
}
async function attachFirebaseMessagingIfSupported(
app: FirebaseApp,
): Promise<void> {
if (!(await isSupported())) {
logger.debug(
`${LOG} firebase/messaging not supported in this context; skipping getMessaging`,
);
return;
}
const messaging = getMessaging(app);
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY as
| string
| undefined;
try {
const token = await getToken(
messaging,
vapidKey ? { vapidKey } : undefined,
);
logger.info(`${LOG} Firebase getToken completed`, {
tokenPrefix: token ? `${token.slice(0, 12)}` : "(empty)",
});
} catch (err) {
logger.warn(
`${LOG} Firebase getToken failed (common on native WebView without SW)`,
err,
);
}
onMessage(messaging, (payload) => {
logger.debug(`${LOG} onMessage (foreground)`, payload);
});
}
/**
* Native: register Capacitor push listeners, request permissions, register for push,
* then initialize Firebase Messaging when env config and platform support allow.
*/
async function initializeNativePushAndFirebaseMessagingImpl(): Promise<void> {
if (!Capacitor.isNativePlatform()) {
return;
}
try {
const app = ensureFirebaseApp();
await PushNotifications.addListener("registration", (token) => {
logger.info(`${LOG} Capacitor registration token`, {
valuePrefix: token.value ? `${token.value.slice(0, 12)}` : "(empty)",
});
});
await PushNotifications.addListener("registrationError", (err) => {
logger.error(`${LOG} registrationError`, err);
});
await PushNotifications.addListener(
"pushNotificationReceived",
(notification) => {
logger.debug(`${LOG} pushNotificationReceived`, notification);
},
);
await PushNotifications.addListener(
"pushNotificationActionPerformed",
(action) => {
logger.debug(`${LOG} pushNotificationActionPerformed`, action);
},
);
const perm = await PushNotifications.requestPermissions();
if (perm.receive !== "granted") {
logger.warn(`${LOG} Push permission not granted`, perm);
return;
}
await PushNotifications.register();
if (app) {
await attachFirebaseMessagingIfSupported(app);
}
} catch (err) {
logger.error(`${LOG} Native push / Firebase messaging init failed`, err);
}
}
/**
* Idempotent startup hook for Capacitor iOS/Android.
*/
export function initializeNativePushAndFirebaseMessaging(): Promise<void> {
if (!Capacitor.isNativePlatform()) {
return Promise.resolve();
}
if (!nativeInitPromise) {
nativeInitPromise = initializeNativePushAndFirebaseMessagingImpl();
}
return nativeInitPromise;
}

View File

@@ -18,6 +18,10 @@ export { NativeNotificationService } from "./NativeNotificationService";
export { WebPushNotificationService } from "./WebPushNotificationService";
export { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
export {
ensureFirebaseApp,
initializeNativePushAndFirebaseMessaging,
} from "./firebaseMessagingClient";
export { syncStarredPlansToNativePlugin } from "./syncStarredPlansToNativePlugin";
export {
buildDualScheduleConfig,