react-native-notify-sphere 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.tsx CHANGED
@@ -2,63 +2,219 @@ import messaging from '@react-native-firebase/messaging';
2
2
  import notifee, {
3
3
  AndroidImportance,
4
4
  AndroidStyle,
5
- EventType, // Import EventType if available
5
+ EventType,
6
+ type AndroidChannel,
6
7
  } from '@notifee/react-native';
7
8
  import axios from 'axios';
9
+ import AsyncStorage from '@react-native-async-storage/async-storage';
8
10
  import { PermissionsAndroid, Platform } from 'react-native';
9
- import DeviceInfo from "react-native-device-info";
10
- import * as RNLocalize from "react-native-localize";
11
- type NotificationData = {
11
+
12
+ const STORAGE_KEY_TOKEN = '@notifysphere_fcm_token';
13
+ const STORAGE_KEY_SUB_ID = '@notifysphere_subscription_id';
14
+ const STORAGE_KEY_USER_HASH = '@notifysphere_user_hash';
15
+
16
+ // ─── Type definitions ──────────────────────────────────────────────────────────
17
+
18
+ /** Shape of a Firebase Cloud Messaging remote message */
19
+ export interface FirebaseRemoteMessage {
20
+ messageId?: string;
21
+ notification?: {
22
+ title?: string;
23
+ body?: string;
24
+ android?: { imageUrl?: string };
25
+ ios?: { attachments?: Array<{ url?: string }> };
26
+ imageUrl?: string;
27
+ };
28
+ data?: Record<string, string>;
29
+ }
30
+
31
+ /** Shape of a Notifee notification object (returned from press events) */
32
+ export interface NotifeeNotification {
33
+ id?: string;
34
+ title?: string;
35
+ body?: string;
36
+ data?: Record<string, string>;
37
+ android?: { largeIcon?: string };
38
+ ios?: { attachments?: Array<{ url?: string }> };
39
+ }
40
+
41
+ /** Normalised notification data delivered to the consumer callback */
42
+ export type NotificationData = {
12
43
  title?: string;
13
44
  body?: string;
14
- data?: any;
45
+ data?: Record<string, string>;
15
46
  image?: string;
16
47
  sound?: string;
17
48
  };
18
49
 
19
- type Tags = {
20
- [key: string]: string;
21
- };
22
-
23
- type NotificationCallback = (
50
+ export type NotificationCallback = (
24
51
  notification: NotificationData,
25
52
  type?: string
26
53
  ) => void;
27
54
 
55
+ export type InitializeConfig = {
56
+ /** Your application's user ID for this device */
57
+ applicationUserId: number;
58
+ /** Push type, e.g. 'AndroidPush' or 'IOSPush' */
59
+ type: string;
60
+ /** NotifySphere app ID (required) */
61
+ appId: string;
62
+ name?: string;
63
+ lat?: string;
64
+ long?: string;
65
+ city?: string;
66
+ state?: string;
67
+ email?: string;
68
+ /** Phone number as a string to preserve leading zeros and country codes */
69
+ phone?: string;
70
+ tags?: Record<string, string>;
71
+ /**
72
+ * Base URL of your NotifySphere API server.
73
+ * Defaults to the hosted service.
74
+ */
75
+ baseUrl?: string;
76
+ /**
77
+ * Full URL of your onpress-tracking endpoint.
78
+ * Defaults to the hosted service.
79
+ */
80
+ trackingUrl?: string;
81
+ /**
82
+ * Full URL of your delivery-confirmation endpoint.
83
+ * Called as soon as a notification is received (foreground, background, or
84
+ * terminated — see note below).
85
+ * Defaults to the hosted service.
86
+ *
87
+ * NOTE: delivery confirmation when the app is terminated only works for
88
+ * **data-only** FCM messages (no `notification` field in the payload).
89
+ * If the payload contains a `notification` field, the OS delivers it
90
+ * silently without waking the app, so this callback cannot fire.
91
+ */
92
+ deliveryUrl?: string;
93
+ /**
94
+ * Optional API key sent as a Bearer token to the tracking endpoints.
95
+ * Never hard-code this — read it from a secure config or environment.
96
+ */
97
+ apiKey?: string;
98
+ /** Enable verbose logging. Defaults to false. */
99
+ debug?: boolean;
100
+ };
101
+
102
+ // ─── Internal logger ──────────────────────────────────────────────────────────
103
+
104
+ let _debug = false;
105
+
106
+ const log = (...args: unknown[]) => {
107
+ if (_debug) console.log('[NotifySphere]', ...args);
108
+ };
109
+ const warn = (...args: unknown[]) => {
110
+ if (_debug) console.warn('[NotifySphere]', ...args);
111
+ };
112
+ // Errors are always surfaced regardless of the debug flag
113
+ const logError = (...args: unknown[]) =>
114
+ console.error('[NotifySphere]', ...args);
115
+
116
+ // ─── Default endpoints ────────────────────────────────────────────────────────
117
+
118
+ const DEFAULT_BASE_URL = 'https://api.notifysphere.in/client/';
119
+ // const DEFAULT_BASE_URL = 'https://apinotify.dothejob.in';
120
+
121
+ // ─── NotifySphere class ───────────────────────────────────────────────────────
122
+
28
123
  class NotifySphere {
29
124
  private static callback: NotificationCallback | null = null;
125
+ private static fcmToken: string | null = null;
126
+ private static appId: string | null = null;
127
+ private static subscriptionId: string | null = null;
128
+ private static baseUrl: string = DEFAULT_BASE_URL;
129
+ private static trackingUrl: string = '';
130
+ private static deliveryUrl: string = '';
131
+ private static apiKey: string | null = null;
132
+ private static unsubscribers: Array<() => void> = [];
133
+ private static initialized = false;
134
+ private static createdChannels = new Set<string>();
30
135
 
31
- // Permission check remains fully here
136
+ // ─── Press tracking ──────────────────────────────────────────────────────
32
137
 
33
- static async callbackOnpress(remoteMessage: any, status: string) {
34
- console.log('remoteMessage413123', remoteMessage);
138
+ /**
139
+ * Tracks a notification press event and fires the consumer callback.
140
+ * Accepts both Firebase remote messages and Notifee notification objects.
141
+ */
142
+ static async callbackOnpress(
143
+ notification: FirebaseRemoteMessage | NotifeeNotification,
144
+ status: string
145
+ ): Promise<void> {
35
146
  try {
36
- const data = JSON.stringify({
37
- notifiction_id: remoteMessage?.data?.notification_id, // 🔑 keep same spelling as backend expects
38
- onPressStatus: 1,
39
- });
40
-
41
- const config = {
42
- method: 'post' as const,
43
- maxBodyLength: Infinity,
44
- url: 'https://notifysphere.dothejob.in:3008/onpress/handle',
45
- headers: {
46
- 'Content-Type': 'application/json',
47
- 'Cookie':
48
- 'token=sYPT8POivlxfFSR54MWd3reaArvODMiIcM4KM39cqkXBLVu%2B8b1zV2csabU9byzo',
49
- },
50
- data,
147
+ const notificationId =
148
+ (notification as FirebaseRemoteMessage).data?.notification_id ??
149
+ (notification as NotifeeNotification).data?.notification_id;
150
+
151
+ const headers: Record<string, string> = {
152
+ 'Content-Type': 'application/json',
51
153
  };
154
+ if (NotifySphere.apiKey) {
155
+ headers['Authorization'] = `Bearer ${NotifySphere.apiKey}`;
156
+ }
157
+
158
+ const response = await axios.post(
159
+ NotifySphere.trackingUrl,
160
+ {
161
+ notification_id: notificationId,
162
+ subscription_id: NotifySphere.subscriptionId,
163
+ onPressStatus: 1,
164
+ },
165
+ { headers, maxBodyLength: 1024 * 50, timeout: 10000 }
166
+ );
167
+
168
+ log('onPress tracking response:', response.data);
169
+ NotifySphere.sendCallback(notification, status);
170
+ } catch (err) {
171
+ logError('Error in callbackOnpress:', err);
172
+ }
173
+ }
52
174
 
53
- const response = await axios.request(config);
54
- console.log('onPress API response:', response);
175
+ // ─── Delivery confirmation ───────────────────────────────────────────────
176
+
177
+ /**
178
+ * Sends a delivery receipt to the server as soon as a notification arrives.
179
+ *
180
+ * This fires in three scenarios:
181
+ * - Foreground : app is open when the message arrives
182
+ * - Background : app is alive in the background (data-only FCM message)
183
+ * - Terminated : app is completely killed (data-only FCM message only —
184
+ * notification messages are shown by the OS without waking
185
+ * the app, so this cannot fire in that case)
186
+ */
187
+ private static async callbackOnDelivery(
188
+ remoteMessage: FirebaseRemoteMessage
189
+ ): Promise<void> {
190
+ try {
191
+ const notificationId = remoteMessage.data?.notification_id;
192
+
193
+ const headers: Record<string, string> = {
194
+ 'Content-Type': 'application/json',
195
+ };
196
+ if (NotifySphere.apiKey) {
197
+ headers['Authorization'] = `Bearer ${NotifySphere.apiKey}`;
198
+ }
199
+ log('Sending delivery confirmation to:', NotifySphere.deliveryUrl);
200
+ const response = await axios.post(
201
+ NotifySphere.deliveryUrl,
202
+ {
203
+ notification_id: notificationId,
204
+ subscription_id: NotifySphere.subscriptionId,
205
+ onDeliveredStatus: 1,
206
+ },
207
+ { headers, maxBodyLength: 1024 * 50, timeout: 10000 }
208
+ );
55
209
 
56
- NotifySphere.sendCallback(remoteMessage, status);
57
- } catch (error) {
58
- console.error('Error in callbackOnpress:', error);
210
+ log('Delivery confirmation response:', response.data);
211
+ } catch (err) {
212
+ logError('Error sending delivery confirmation:', err);
59
213
  }
60
214
  }
61
-
215
+
216
+ // ─── Permissions ─────────────────────────────────────────────────────────
217
+
62
218
  static async checkApplicationPermission(): Promise<boolean> {
63
219
  try {
64
220
  if (Platform.OS === 'android' && Platform.Version >= 33) {
@@ -74,165 +230,497 @@ class NotifySphere {
74
230
  );
75
231
  }
76
232
  return true;
77
- } catch (error) {
78
- console.error('Error requesting notification permission:', error);
233
+ } catch (err) {
234
+ logError('Error requesting notification permission:', err);
79
235
  return false;
80
236
  }
81
237
  }
82
238
 
239
+ // ─── User details fingerprint ─────────────────────────────────────────────
83
240
 
84
- static async getDeviceInfo() {
85
- return {
86
- sdk: DeviceInfo.getApiLevel()?.toString(), // e.g. "34"
87
- device_model: DeviceInfo.getModel(), // e.g. "iPhone 14" or "TECNO CK6"
88
- device_os: DeviceInfo.getSystemName(), // e.g. "Android" or "iOS"
89
- device_version: DeviceInfo.getSystemVersion(), // e.g. "15"
90
- carrier: await DeviceInfo.getCarrier(), // e.g. "airtel"
91
- app_version: DeviceInfo.getVersion(), // e.g. "1.0.2"
92
- timezone_id: RNLocalize.getTimeZone(), // e.g. "Asia/Kolkata"
93
- };
241
+ /**
242
+ * Builds a stable string fingerprint of all user-profile fields.
243
+ * Used to detect whether any detail has changed since the last registration,
244
+ * so the API is only called when something actually differs.
245
+ */
246
+ private static buildUserHash(config: InitializeConfig): string {
247
+ const fields = {
248
+ applicationUserId: config.applicationUserId,
249
+ type: config.type,
250
+ name: config.name ?? '',
251
+ lat: config.lat ?? '',
252
+ long: config.long ?? '',
253
+ city: config.city ?? '',
254
+ state: config.state ?? '',
255
+ email: config.email ?? '',
256
+ phone: config.phone ?? '',
257
+ // Stringify tags with sorted keys so order doesn't affect the hash
258
+ tags: config.tags
259
+ ? JSON.stringify(
260
+ Object.keys(config.tags)
261
+ .sort()
262
+ .reduce<Record<string, string>>((acc, k) => {
263
+ acc[k] = config.tags![k]!;
264
+ return acc;
265
+ }, {})
266
+ )
267
+ : '',
268
+ };
269
+ return JSON.stringify(fields);
270
+ }
271
+
272
+ // ─── Initialize ──────────────────────────────────────────────────────────
273
+
274
+ /**
275
+ * Initialises the SDK and sets up notification listeners.
276
+ *
277
+ * Safe to call every time the app opens — it only hits the registration API
278
+ * when the FCM token OR any user detail (email, phone, location, tags, etc.)
279
+ * has changed since the last successful registration.
280
+ * Listeners are registered only once per process lifetime.
281
+ *
282
+ * Token changes are also handled automatically via `onTokenRefresh`.
283
+ */
284
+ static async initialize(
285
+ config: InitializeConfig
286
+ ): Promise<string | undefined> {
287
+ _debug = config.debug ?? false;
288
+ NotifySphere.appId = config.appId;
289
+ NotifySphere.baseUrl = NotifySphere.enforceHttps(config.baseUrl ?? DEFAULT_BASE_URL);
290
+ NotifySphere.trackingUrl = NotifySphere.enforceHttps(
291
+ config.trackingUrl ?? `${NotifySphere.baseUrl}${config.appId}/onpress/handle`
292
+ );
293
+ NotifySphere.deliveryUrl = NotifySphere.enforceHttps(
294
+ config.deliveryUrl ?? `${NotifySphere.baseUrl}${config.appId}/delivery/confirm`
295
+ );
296
+ NotifySphere.apiKey = config.apiKey ?? null;
297
+
298
+ const hasPermission = await NotifySphere.checkApplicationPermission();
299
+ if (!hasPermission) {
300
+ warn('Notification permission not granted — aborting initialization');
301
+ return undefined;
302
+ }
303
+
304
+ try {
305
+ const fcmToken = await messaging().getToken();
306
+ NotifySphere.fcmToken = fcmToken;
307
+
308
+ // ── Restore cached subscription_id immediately so tracking calls
309
+ // have it available even before the registration API responds.
310
+ const cachedSubId = await AsyncStorage.getItem(STORAGE_KEY_SUB_ID);
311
+ if (cachedSubId) {
312
+ NotifySphere.subscriptionId = cachedSubId;
313
+ }
314
+
315
+ // ── Only call the registration API when the FCM token OR any user
316
+ // detail has changed. This avoids a redundant network call on every
317
+ // app open while still syncing updates (email, phone, location, etc.)
318
+ const [cachedToken, cachedUserHash] = await Promise.all([
319
+ AsyncStorage.getItem(STORAGE_KEY_TOKEN),
320
+ AsyncStorage.getItem(STORAGE_KEY_USER_HASH),
321
+ ]);
322
+
323
+ const currentUserHash = NotifySphere.buildUserHash(config);
324
+ let subscriptionId: string | undefined = cachedSubId ?? undefined;
325
+
326
+ const tokenChanged = fcmToken !== cachedToken;
327
+ const userChanged = currentUserHash !== cachedUserHash;
328
+
329
+ if (tokenChanged || userChanged) {
330
+ if (tokenChanged) log('FCM token changed — re-registering device');
331
+ if (userChanged) log('User details changed — re-registering device');
332
+ subscriptionId = await NotifySphere.registerDevice(
333
+ config,
334
+ fcmToken,
335
+ currentUserHash
336
+ );
337
+ } else {
338
+ log('Token and user details unchanged — skipping registration API call');
339
+ }
340
+
341
+ // ── Channel and listeners are always set up (idempotent)
342
+ await NotifySphere.ensureChannel(
343
+ 'channel_default',
344
+ 'Default Channel',
345
+ 'default'
346
+ );
347
+
348
+ if (!NotifySphere.initialized) {
349
+ NotifySphere.setupListeners();
350
+ // Watch for token refreshes (Firebase can rotate the token silently)
351
+ NotifySphere.watchTokenRefresh(config);
352
+ NotifySphere.initialized = true;
353
+ }
354
+
355
+ return subscriptionId;
356
+ } catch (err) {
357
+ logError('Error during initialization:', err);
358
+ return undefined;
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Calls the registration API and persists the token, subscription_id, and
364
+ * user hash so future launches can detect what has changed.
365
+ * Extracted so it can also be called from the token-refresh watcher.
366
+ */
367
+ private static async registerDevice(
368
+ config: InitializeConfig,
369
+ fcmToken: string,
370
+ userHash?: string
371
+ ): Promise<string | undefined> {
372
+ const res = await axios.post(
373
+ `${NotifySphere.baseUrl}${config.appId}/users`,
374
+ {
375
+ applicationUserId: config.applicationUserId,
376
+ token: fcmToken,
377
+ type: config.type,
378
+ user: {
379
+ userName: config.name,
380
+ lat: config.lat,
381
+ long: config.long,
382
+ city: config.city,
383
+ state: config.state,
384
+ email: config.email,
385
+ phone: config.phone,
386
+ tags: config.tags,
387
+ },
388
+ },
389
+ {
390
+ headers: {
391
+ 'Content-Type': 'application/json',
392
+ ...(NotifySphere.apiKey
393
+ ? { Authorization: `Bearer ${NotifySphere.apiKey}` }
394
+ : {}),
395
+ },
396
+ maxBodyLength: 1024 * 50,
397
+ timeout: 10000,
398
+ }
399
+ );
400
+
401
+ log('Device registration response:', res.data);
402
+ const subscriptionId: string | undefined = res.data?.subscription_id;
403
+ NotifySphere.subscriptionId = subscriptionId ?? null;
404
+
405
+ // Persist token, subscription_id, and user hash for future launch comparisons
406
+ const hash = userHash ?? NotifySphere.buildUserHash(config);
407
+ await Promise.all([
408
+ AsyncStorage.setItem(STORAGE_KEY_TOKEN, fcmToken),
409
+ AsyncStorage.setItem(STORAGE_KEY_USER_HASH, hash),
410
+ subscriptionId
411
+ ? AsyncStorage.setItem(STORAGE_KEY_SUB_ID, subscriptionId)
412
+ : Promise.resolve(),
413
+ ]);
414
+
415
+ return subscriptionId;
416
+ }
417
+
418
+ /**
419
+ * Listens for Firebase token refreshes and automatically re-registers
420
+ * the device when the token rotates, without requiring the consumer to
421
+ * call initialize() again.
422
+ */
423
+ private static watchTokenRefresh(config: InitializeConfig): void {
424
+ const unsubRefresh = messaging().onTokenRefresh(async (newToken) => {
425
+ log('FCM token refreshed — re-registering device');
426
+ NotifySphere.fcmToken = newToken;
427
+ try {
428
+ // User details haven't changed here, reuse the existing hash
429
+ const existingHash =
430
+ (await AsyncStorage.getItem(STORAGE_KEY_USER_HASH)) ??
431
+ NotifySphere.buildUserHash(config);
432
+ await NotifySphere.registerDevice(config, newToken, existingHash);
433
+ } catch (err) {
434
+ logError('Error re-registering after token refresh:', err);
435
+ }
436
+ });
437
+ NotifySphere.unsubscribers.push(unsubRefresh);
94
438
  }
95
439
 
96
- static async initialize(config: {
440
+ // ─── Update tags ─────────────────────────────────────────────────────────
441
+
442
+ /**
443
+ * Updates user tags without re-registering the device.
444
+ * `appId` is optional here — it falls back to the value passed in `initialize`.
445
+ */
446
+ static async updateTags(params: {
97
447
  applicationUserId: number;
98
448
  type: string;
99
- name?: string;
100
- lat?: string;
101
- long?: string;
102
- city?: string;
103
- state?: string;
104
- email?: string;
449
+ tags: Record<string, string>;
450
+ /** Falls back to the appId provided in initialize() */
105
451
  appId?: string;
106
- phone?: number;
107
- tags?:Tags
108
- }) {
109
- const hasPermission = await NotifySphere.checkApplicationPermission();
110
- const devInfo = await NotifySphere.getDeviceInfo();
111
- if (!hasPermission) return;
112
-
113
- const fcmToken = await messaging().getToken();
114
- console.log('fcmToken31232', fcmToken, config);
452
+ }): Promise<unknown> {
453
+ const appId = params.appId ?? NotifySphere.appId;
115
454
 
116
- if (!config.appId) return;
455
+ if (!NotifySphere.fcmToken) {
456
+ warn('FCM token missing — cannot update tags');
457
+ return null;
458
+ }
459
+ if (!appId) {
460
+ warn('appId missing — cannot update tags');
461
+ return null;
462
+ }
117
463
 
118
- let subscription_id = await axios
119
- .post(
120
- `https://notifysphere.dothejob.in:3008/apps/${config.appId}/users`,
464
+ try {
465
+ const res = await axios.post(
466
+ `${NotifySphere.baseUrl}${appId}/users`,
121
467
  {
122
- applicationUserId: config.applicationUserId,
123
- token: fcmToken,
124
- type: config.type,
125
- user: {
126
- userName: config.name,
127
- lat: config.lat,
128
- long: config.long,
129
- city: config.city,
130
- state: config.state,
131
- email: config.email,
132
- phone: config.phone,
133
- tags: config.tags,
134
- sdk: devInfo.sdk,
135
- device_model: devInfo.device_model,
136
- device_os: devInfo.device_os,
137
- device_version: devInfo.device_version,
138
- carrier: devInfo.carrier,
139
- app_version: devInfo.app_version,
140
- timezone_id: devInfo.timezone_id
141
- },
468
+ applicationUserId: params.applicationUserId,
469
+ token: NotifySphere.fcmToken,
470
+ type: params.type,
471
+ user: { tags: params.tags },
142
472
  },
143
473
  {
144
474
  headers: { 'Content-Type': 'application/json' },
145
- maxBodyLength: Infinity,
475
+ maxBodyLength: 1024 * 50,
476
+ timeout: 10000,
146
477
  }
147
- )
148
- .then(async (res: any) => {
149
- console.log('res313132', res);
150
- return res.data.subscription_id;
151
- })
152
- .catch((err) => {
153
- console.log('err31232', err);
154
- });
478
+ );
155
479
 
156
- NotifySphere.setupListeners();
157
- return subscription_id;
480
+ log('Tags update response:', res.data);
481
+ return res.data;
482
+ } catch (err) {
483
+ logError('Error updating tags:', err);
484
+ return null;
485
+ }
158
486
  }
159
487
 
160
- static onNotification(callback: NotificationCallback) {
488
+ // ─── Public callback setter ───────────────────────────────────────────────
489
+
490
+ /** Register a callback to receive notification events (received / opened / initial). */
491
+ static onNotification(callback: NotificationCallback): void {
161
492
  NotifySphere.callback = callback;
162
493
  }
163
494
 
164
- private static setupListeners() {
165
- messaging().onMessage(async (remoteMessage) => {
166
- await NotifySphere.displayLocalNotification(remoteMessage);
495
+ // ─── Background handler ───────────────────────────────────────────────────
496
+
497
+ /**
498
+ * Registers Firebase and Notifee background handlers.
499
+ *
500
+ * **IMPORTANT:** Call this in your root `index.js` file, before
501
+ * `AppRegistry.registerComponent()`, so it is available when the app wakes
502
+ * in the background — or starts from a terminated state — to handle a
503
+ * notification.
504
+ *
505
+ * When the app is **terminated**, React Native only runs `index.js` (a
506
+ * headless JS task). Your app components and `initialize()` never execute,
507
+ * so any custom `deliveryUrl` / `apiKey` you passed to `initialize()` are
508
+ * not available. Pass them here instead so they are set before the handler
509
+ * fires.
510
+ *
511
+ * @example
512
+ * // index.js
513
+ * import { AppRegistry } from 'react-native';
514
+ * import NotifySphere from 'react-native-notify-sphere';
515
+ * import App from './App';
516
+ *
517
+ * NotifySphere.setBackgroundHandler({
518
+ * deliveryUrl: 'https://your-server.com/ondelivery/handle', // optional
519
+ * apiKey: 'your-api-key', // optional
520
+ * });
521
+ * AppRegistry.registerComponent('MyApp', () => App);
522
+ */
523
+ static setBackgroundHandler(config?: {
524
+ /**
525
+ * Override the delivery-confirmation URL for background/terminated state.
526
+ * Required if you use a custom deliveryUrl AND need it to work when the
527
+ * app is terminated (since initialize() won't have run yet).
528
+ */
529
+ deliveryUrl?: string;
530
+ /**
531
+ * Override the API key for background/terminated state.
532
+ * Required if you use apiKey AND need delivery receipts when terminated.
533
+ */
534
+ apiKey?: string;
535
+ /**
536
+ * The subscription ID returned by initialize().
537
+ * Required for delivery receipts to include subscription_id when the app
538
+ * is terminated (since initialize() won't have run yet).
539
+ * Persist this value (e.g. in AsyncStorage) and pass it here.
540
+ */
541
+ subscriptionId?: string;
542
+ }): void {
543
+ // Apply any config overrides immediately — before the handler fires —
544
+ // so they are in place even when the app starts from a terminated state.
545
+ if (config?.deliveryUrl) {
546
+ NotifySphere.deliveryUrl = NotifySphere.enforceHttps(config.deliveryUrl);
547
+ }
548
+ if (config?.apiKey) {
549
+ NotifySphere.apiKey = config.apiKey;
550
+ }
551
+ if (config?.subscriptionId) {
552
+ NotifySphere.subscriptionId = config.subscriptionId;
553
+ }
554
+
555
+ messaging().setBackgroundMessageHandler(async (remoteMessage) => {
556
+ const msg = remoteMessage as FirebaseRemoteMessage;
557
+ // Run both in parallel — display and delivery receipt
558
+ await Promise.all([
559
+ NotifySphere.displayLocalNotification(msg),
560
+ NotifySphere.callbackOnDelivery(msg),
561
+ ]);
562
+ });
563
+
564
+ notifee.onBackgroundEvent(async ({ type, detail }) => {
565
+ log('Background notifee event, type:', type);
566
+ if (type === EventType.PRESS && detail.notification) {
567
+ await NotifySphere.callbackOnpress(
568
+ detail.notification as NotifeeNotification,
569
+ 'press'
570
+ );
571
+ }
167
572
  });
573
+ }
574
+
575
+ // ─── Cleanup ──────────────────────────────────────────────────────────────
576
+
577
+ /**
578
+ * Unsubscribes all active listeners and resets internal state.
579
+ * Call this when you want to stop receiving notifications (e.g. on logout).
580
+ */
581
+ static destroy(): void {
582
+ for (const unsub of NotifySphere.unsubscribers) {
583
+ unsub();
584
+ }
585
+ NotifySphere.unsubscribers = [];
586
+ NotifySphere.initialized = false;
587
+ log('NotifySphere destroyed — all listeners removed');
588
+ }
589
+
590
+ // ─── Private: listeners ───────────────────────────────────────────────────
168
591
 
169
- messaging().onNotificationOpenedApp(async (remoteMessage: any) => {
170
- console.log('data34234', remoteMessage);
171
- NotifySphere.callbackOnpress(remoteMessage, 'opened');
592
+ private static setupListeners(): void {
593
+ // Foreground messages — display, send delivery receipt, and fire callback
594
+ const unsubMessage = messaging().onMessage(async (remoteMessage) => {
595
+ const msg = remoteMessage as FirebaseRemoteMessage;
596
+ log('Foreground message received:', msg);
597
+ // Display and delivery receipt run in parallel for speed
598
+
599
+ await Promise.all([
600
+ NotifySphere.displayLocalNotification(msg),
601
+ NotifySphere.callbackOnDelivery(msg),
602
+ ]);
603
+ NotifySphere.sendCallback(msg, 'received');
172
604
  });
173
605
 
606
+ // App brought from background via notification tap
607
+ const unsubOpened = messaging().onNotificationOpenedApp(
608
+ async (remoteMessage) => {
609
+ log(
610
+ 'App opened from background via notification:',
611
+ remoteMessage.messageId
612
+ );
613
+ await NotifySphere.callbackOnpress(
614
+ remoteMessage as FirebaseRemoteMessage,
615
+ 'opened'
616
+ );
617
+ }
618
+ );
619
+
620
+ // App launched from a quit state via notification tap
174
621
  messaging()
175
622
  .getInitialNotification()
176
623
  .then((remoteMessage) => {
177
624
  if (remoteMessage) {
178
- NotifySphere.callbackOnpress(remoteMessage, 'initial');
625
+ NotifySphere.callbackOnpress(
626
+ remoteMessage as FirebaseRemoteMessage,
627
+ 'initial'
628
+ );
179
629
  }
180
- });
630
+ })
631
+ .catch((err) => logError('getInitialNotification error:', err));
181
632
 
182
- notifee.onForegroundEvent(({ type, detail }) => {
183
- if (type === EventType.PRESS) {
184
- console.log('detail323213', detail);
185
- NotifySphere.callbackOnpress(detail.notification, 'opened');
633
+ // Notifee foreground press events (covers in-app notification taps)
634
+ const unsubForeground = notifee.onForegroundEvent(({ type, detail }) => {
635
+ if (type === EventType.PRESS && detail.notification) {
636
+ NotifySphere.callbackOnpress(
637
+ detail.notification as NotifeeNotification,
638
+ 'opened'
639
+ );
186
640
  }
187
641
  });
188
642
 
189
- notifee.onBackgroundEvent(async ({ type, detail }) => {
190
- console.log('detail323213111111', type, detail);
643
+ NotifySphere.unsubscribers.push(
644
+ unsubMessage,
645
+ unsubOpened,
646
+ unsubForeground
647
+ );
648
+ }
191
649
 
192
- if (type === EventType.PRESS) {
193
- console.log('detail323213', detail);
650
+ // ─── Private: channel management ─────────────────────────────────────────
194
651
 
195
- NotifySphere.callbackOnpress(detail.notification, 'press');
196
- }
197
- });
652
+ /**
653
+ * Creates a notification channel if it hasn't been created yet this session.
654
+ * Notifee handles idempotency across app restarts; this in-memory guard
655
+ * prevents redundant async calls within the same session.
656
+ */
657
+ private static async ensureChannel(
658
+ id: string,
659
+ name: string,
660
+ sound: string
661
+ ): Promise<void> {
662
+ if (NotifySphere.createdChannels.has(id)) return;
663
+
664
+ const soundName = sound.replace(/\..+$/, '');
665
+ const channelDef: AndroidChannel = {
666
+ id,
667
+ name,
668
+ importance: AndroidImportance.HIGH,
669
+ sound: soundName !== 'default' ? soundName : undefined,
670
+ lights: true,
671
+ lightColor: '#0000FF',
672
+ vibration: true,
673
+ };
674
+
675
+ await notifee.createChannel(channelDef);
676
+ NotifySphere.createdChannels.add(id);
677
+ log('Android channel created/verified:', id);
198
678
  }
199
679
 
200
- private static async displayLocalNotification(remoteMessage: any) {
680
+ // ─── Private: display notification ───────────────────────────────────────
681
+
682
+ private static async displayLocalNotification(
683
+ remoteMessage: FirebaseRemoteMessage
684
+ ): Promise<void> {
685
+ // Use || so empty strings fall back just like null/undefined
201
686
  const soundRaw = remoteMessage.data?.sound || 'default';
202
- const androidChannelId = `channel_${soundRaw}`;
203
- const androidSoundName = soundRaw.replace(/\..+$/, '');
204
- console.log('remoteMessage1312', remoteMessage);
687
+ const soundName = soundRaw.replace(/\..+$/, '');
688
+ const channelId = `channel_${soundName}`;
205
689
 
206
- await notifee.createChannel({
207
- id: androidChannelId,
208
- name: `Channel ${androidSoundName}`,
209
- importance: AndroidImportance.HIGH,
210
- sound: androidSoundName !== 'default' ? androidSoundName : undefined,
211
- });
690
+ await NotifySphere.ensureChannel(channelId, `Channel ${soundName}`, soundRaw);
212
691
 
692
+ // Resolve image — empty string treated same as missing
213
693
  const image =
214
- remoteMessage?.data?.fcm_options?.image ||
694
+ remoteMessage.data?.imageUrl ||
215
695
  remoteMessage.notification?.android?.imageUrl ||
216
696
  remoteMessage.notification?.imageUrl ||
217
- remoteMessage.notification?.ios?.attachments?.[0]?.url;
697
+ remoteMessage.notification?.ios?.attachments?.[0]?.url ||
698
+ undefined;
699
+
700
+ // Resolve title/body — empty string treated as missing
701
+ const title =
702
+ remoteMessage.data?.title || remoteMessage.notification?.title || undefined;
703
+ const body =
704
+ remoteMessage.data?.body || remoteMessage.notification?.body || undefined;
705
+
706
+ // smallIcon falls back to 'ic_stat_onesignal_default' if empty/missing
707
+ // largeIcon is removed entirely
708
+ const smallIcon =
709
+ NotifySphere.resolveIcon(remoteMessage.data?.smallIcon) ?? 'ic_stat_onesignal_default';
710
+
218
711
  await notifee.displayNotification({
219
- title: remoteMessage.notification?.title,
220
- body: remoteMessage.notification?.body,
712
+ title,
713
+ body,
221
714
  android: {
222
- channelId: androidChannelId,
223
- importance: AndroidImportance.HIGH,
224
- sound: androidSoundName !== 'default' ? androidSoundName : undefined,
225
- largeIcon: image,
715
+ channelId,
716
+ smallIcon,
226
717
  style: image
227
- ? {
228
- type: AndroidStyle.BIGPICTURE,
229
- picture: image,
230
- }
718
+ ? { type: AndroidStyle.BIGPICTURE, picture: image }
231
719
  : undefined,
232
720
  },
233
721
  ios: {
234
- sound: soundRaw, // must be a bundled sound file or "default"
235
- attachments: image ? [{ url: image }] : undefined, // ✅ this is correct
722
+ sound: soundRaw,
723
+ attachments: image ? [{ url: image }] : undefined,
236
724
  foregroundPresentationOptions: {
237
725
  alert: true,
238
726
  badge: true,
@@ -243,24 +731,75 @@ class NotifySphere {
243
731
  });
244
732
  }
245
733
 
246
- private static sendCallback(remoteMessage: any, type?: string) {
734
+ /**
735
+ * Returns the icon string if it is a real, usable value.
736
+ * Treats empty string (""), "ic_stat_onesignal_default", and
737
+ * any other placeholder-like value as absent (returns undefined).
738
+ */
739
+ private static resolveIcon(value: string | undefined): string | undefined {
740
+ if (!value) return undefined; // catches "" / null / undefined
741
+ return value;
742
+ }
743
+
744
+ /**
745
+ * Rejects plain HTTP URLs to prevent user data (FCM token, email, phone,
746
+ * location) being sent over an unencrypted connection.
747
+ */
748
+ private static enforceHttps(url: string): string {
749
+ if (url.startsWith('http://')) {
750
+ logError(
751
+ `Insecure URL rejected: "${url}". NotifySphere only allows HTTPS endpoints.`
752
+ );
753
+ throw new Error(
754
+ `NotifySphere: insecure HTTP URL is not allowed — use HTTPS instead.`
755
+ );
756
+ }
757
+ return url;
758
+ }
759
+
760
+ // ─── Private: fire consumer callback ─────────────────────────────────────
761
+
762
+ /**
763
+ * Normalises both Firebase remote messages and Notifee notification objects
764
+ * into the common NotificationData shape and fires the consumer callback.
765
+ */
766
+ private static sendCallback(
767
+ notification: FirebaseRemoteMessage | NotifeeNotification,
768
+ type?: string
769
+ ): void {
247
770
  if (!NotifySphere.callback) return;
248
771
 
249
- const image =
250
- remoteMessage.notification?.android?.imageUrl ||
251
- remoteMessage.notification?.imageUrl ||
252
- remoteMessage.notification?.ios?.attachments?.[0]?.url;
772
+ // Detect shape: Firebase messages have a `notification` or `messageId` field
773
+ const isFirebase =
774
+ 'notification' in notification || 'messageId' in notification;
253
775
 
254
- NotifySphere.callback(
255
- {
256
- title: remoteMessage.notification?.title,
257
- body: remoteMessage.notification?.body,
258
- data: remoteMessage.data,
259
- image,
260
- sound: remoteMessage.data?.sound,
261
- },
262
- type
263
- );
776
+ let title: string | undefined;
777
+ let body: string | undefined;
778
+ let data: Record<string, string> | undefined;
779
+ let image: string | undefined;
780
+ let sound: string | undefined;
781
+
782
+ if (isFirebase) {
783
+ const msg = notification as FirebaseRemoteMessage;
784
+ title = msg.notification?.title ?? msg.data?.title;
785
+ body = msg.notification?.body ?? msg.data?.body;
786
+ data = msg.data;
787
+ image =
788
+ msg.notification?.android?.imageUrl ||
789
+ msg.notification?.imageUrl ||
790
+ msg.notification?.ios?.attachments?.[0]?.url ||
791
+ msg.data?.imageUrl;
792
+ sound = msg.data?.sound;
793
+ } else {
794
+ const notif = notification as NotifeeNotification;
795
+ title = notif.title;
796
+ body = notif.body;
797
+ data = notif.data;
798
+ image = notif.data?.imageUrl;
799
+ sound = notif.data?.sound;
800
+ }
801
+
802
+ NotifySphere.callback({ title, body, data, image, sound }, type);
264
803
  }
265
804
  }
266
805