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.
@@ -1,42 +1,124 @@
1
1
  "use strict";
2
2
 
3
3
  import messaging from '@react-native-firebase/messaging';
4
- import notifee, { AndroidImportance, AndroidStyle, EventType // Import EventType if available
5
- } from '@notifee/react-native';
4
+ import notifee, { AndroidImportance, AndroidStyle, EventType } from '@notifee/react-native';
6
5
  import axios from 'axios';
6
+ import AsyncStorage from '@react-native-async-storage/async-storage';
7
7
  import { PermissionsAndroid, Platform } from 'react-native';
8
- import DeviceInfo from "react-native-device-info";
9
- import * as RNLocalize from "react-native-localize";
8
+ const STORAGE_KEY_TOKEN = '@notifysphere_fcm_token';
9
+ const STORAGE_KEY_SUB_ID = '@notifysphere_subscription_id';
10
+ const STORAGE_KEY_USER_HASH = '@notifysphere_user_hash';
11
+
12
+ // ─── Type definitions ──────────────────────────────────────────────────────────
13
+
14
+ /** Shape of a Firebase Cloud Messaging remote message */
15
+
16
+ /** Shape of a Notifee notification object (returned from press events) */
17
+
18
+ /** Normalised notification data delivered to the consumer callback */
19
+
20
+ // ─── Internal logger ──────────────────────────────────────────────────────────
21
+
22
+ let _debug = false;
23
+ const log = (...args) => {
24
+ if (_debug) console.log('[NotifySphere]', ...args);
25
+ };
26
+ const warn = (...args) => {
27
+ if (_debug) console.warn('[NotifySphere]', ...args);
28
+ };
29
+ // Errors are always surfaced regardless of the debug flag
30
+ const logError = (...args) => console.error('[NotifySphere]', ...args);
31
+
32
+ // ─── Default endpoints ────────────────────────────────────────────────────────
33
+
34
+ const DEFAULT_BASE_URL = 'https://api.notifysphere.in/client/';
35
+ // const DEFAULT_BASE_URL = 'https://apinotify.dothejob.in';
36
+
37
+ // ─── NotifySphere class ───────────────────────────────────────────────────────
38
+
10
39
  class NotifySphere {
11
40
  static callback = null;
41
+ static fcmToken = null;
42
+ static appId = null;
43
+ static subscriptionId = null;
44
+ static baseUrl = DEFAULT_BASE_URL;
45
+ static trackingUrl = '';
46
+ static deliveryUrl = '';
47
+ static apiKey = null;
48
+ static unsubscribers = [];
49
+ static initialized = false;
50
+ static createdChannels = new Set();
12
51
 
13
- // Permission check remains fully here
52
+ // ─── Press tracking ──────────────────────────────────────────────────────
14
53
 
15
- static async callbackOnpress(remoteMessage, status) {
16
- console.log('remoteMessage413123', remoteMessage);
54
+ /**
55
+ * Tracks a notification press event and fires the consumer callback.
56
+ * Accepts both Firebase remote messages and Notifee notification objects.
57
+ */
58
+ static async callbackOnpress(notification, status) {
17
59
  try {
18
- const data = JSON.stringify({
19
- notifiction_id: remoteMessage?.data?.notification_id,
20
- // 🔑 keep same spelling as backend expects
60
+ const notificationId = notification.data?.notification_id ?? notification.data?.notification_id;
61
+ const headers = {
62
+ 'Content-Type': 'application/json'
63
+ };
64
+ if (NotifySphere.apiKey) {
65
+ headers['Authorization'] = `Bearer ${NotifySphere.apiKey}`;
66
+ }
67
+ const response = await axios.post(NotifySphere.trackingUrl, {
68
+ notification_id: notificationId,
69
+ subscription_id: NotifySphere.subscriptionId,
21
70
  onPressStatus: 1
71
+ }, {
72
+ headers,
73
+ maxBodyLength: 1024 * 50,
74
+ timeout: 10000
22
75
  });
23
- const config = {
24
- method: 'post',
25
- maxBodyLength: Infinity,
26
- url: 'https://notifysphere.dothejob.in:3008/onpress/handle',
27
- headers: {
28
- 'Content-Type': 'application/json',
29
- 'Cookie': 'token=sYPT8POivlxfFSR54MWd3reaArvODMiIcM4KM39cqkXBLVu%2B8b1zV2csabU9byzo'
30
- },
31
- data
76
+ log('onPress tracking response:', response.data);
77
+ NotifySphere.sendCallback(notification, status);
78
+ } catch (err) {
79
+ logError('Error in callbackOnpress:', err);
80
+ }
81
+ }
82
+
83
+ // ─── Delivery confirmation ───────────────────────────────────────────────
84
+
85
+ /**
86
+ * Sends a delivery receipt to the server as soon as a notification arrives.
87
+ *
88
+ * This fires in three scenarios:
89
+ * - Foreground : app is open when the message arrives
90
+ * - Background : app is alive in the background (data-only FCM message)
91
+ * - Terminated : app is completely killed (data-only FCM message only —
92
+ * notification messages are shown by the OS without waking
93
+ * the app, so this cannot fire in that case)
94
+ */
95
+ static async callbackOnDelivery(remoteMessage) {
96
+ try {
97
+ const notificationId = remoteMessage.data?.notification_id;
98
+ const headers = {
99
+ 'Content-Type': 'application/json'
32
100
  };
33
- const response = await axios.request(config);
34
- console.log('onPress API response:', response);
35
- NotifySphere.sendCallback(remoteMessage, status);
36
- } catch (error) {
37
- console.error('Error in callbackOnpress:', error);
101
+ if (NotifySphere.apiKey) {
102
+ headers['Authorization'] = `Bearer ${NotifySphere.apiKey}`;
103
+ }
104
+ log('Sending delivery confirmation to:', NotifySphere.deliveryUrl);
105
+ const response = await axios.post(NotifySphere.deliveryUrl, {
106
+ notification_id: notificationId,
107
+ subscription_id: NotifySphere.subscriptionId,
108
+ onDeliveredStatus: 1
109
+ }, {
110
+ headers,
111
+ maxBodyLength: 1024 * 50,
112
+ timeout: 10000
113
+ });
114
+ log('Delivery confirmation response:', response.data);
115
+ } catch (err) {
116
+ logError('Error sending delivery confirmation:', err);
38
117
  }
39
118
  }
119
+
120
+ // ─── Permissions ─────────────────────────────────────────────────────────
121
+
40
122
  static async checkApplicationPermission() {
41
123
  try {
42
124
  if (Platform.OS === 'android' && Platform.Version >= 33) {
@@ -47,36 +129,112 @@ class NotifySphere {
47
129
  return authStatus === messaging.AuthorizationStatus.AUTHORIZED || authStatus === messaging.AuthorizationStatus.PROVISIONAL;
48
130
  }
49
131
  return true;
50
- } catch (error) {
51
- console.error('Error requesting notification permission:', error);
132
+ } catch (err) {
133
+ logError('Error requesting notification permission:', err);
52
134
  return false;
53
135
  }
54
136
  }
55
- static async getDeviceInfo() {
56
- return {
57
- sdk: DeviceInfo.getApiLevel()?.toString(),
58
- // e.g. "34"
59
- device_model: DeviceInfo.getModel(),
60
- // e.g. "iPhone 14" or "TECNO CK6"
61
- device_os: DeviceInfo.getSystemName(),
62
- // e.g. "Android" or "iOS"
63
- device_version: DeviceInfo.getSystemVersion(),
64
- // e.g. "15"
65
- carrier: await DeviceInfo.getCarrier(),
66
- // e.g. "airtel"
67
- app_version: DeviceInfo.getVersion(),
68
- // e.g. "1.0.2"
69
- timezone_id: RNLocalize.getTimeZone() // e.g. "Asia/Kolkata"
137
+
138
+ // ─── User details fingerprint ─────────────────────────────────────────────
139
+
140
+ /**
141
+ * Builds a stable string fingerprint of all user-profile fields.
142
+ * Used to detect whether any detail has changed since the last registration,
143
+ * so the API is only called when something actually differs.
144
+ */
145
+ static buildUserHash(config) {
146
+ const fields = {
147
+ applicationUserId: config.applicationUserId,
148
+ type: config.type,
149
+ name: config.name ?? '',
150
+ lat: config.lat ?? '',
151
+ long: config.long ?? '',
152
+ city: config.city ?? '',
153
+ state: config.state ?? '',
154
+ email: config.email ?? '',
155
+ phone: config.phone ?? '',
156
+ // Stringify tags with sorted keys so order doesn't affect the hash
157
+ tags: config.tags ? JSON.stringify(Object.keys(config.tags).sort().reduce((acc, k) => {
158
+ acc[k] = config.tags[k];
159
+ return acc;
160
+ }, {})) : ''
70
161
  };
162
+ return JSON.stringify(fields);
71
163
  }
164
+
165
+ // ─── Initialize ──────────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Initialises the SDK and sets up notification listeners.
169
+ *
170
+ * Safe to call every time the app opens — it only hits the registration API
171
+ * when the FCM token OR any user detail (email, phone, location, tags, etc.)
172
+ * has changed since the last successful registration.
173
+ * Listeners are registered only once per process lifetime.
174
+ *
175
+ * Token changes are also handled automatically via `onTokenRefresh`.
176
+ */
72
177
  static async initialize(config) {
178
+ _debug = config.debug ?? false;
179
+ NotifySphere.appId = config.appId;
180
+ NotifySphere.baseUrl = NotifySphere.enforceHttps(config.baseUrl ?? DEFAULT_BASE_URL);
181
+ NotifySphere.trackingUrl = NotifySphere.enforceHttps(config.trackingUrl ?? `${NotifySphere.baseUrl}${config.appId}/onpress/handle`);
182
+ NotifySphere.deliveryUrl = NotifySphere.enforceHttps(config.deliveryUrl ?? `${NotifySphere.baseUrl}${config.appId}/delivery/confirm`);
183
+ NotifySphere.apiKey = config.apiKey ?? null;
73
184
  const hasPermission = await NotifySphere.checkApplicationPermission();
74
- const devInfo = await NotifySphere.getDeviceInfo();
75
- if (!hasPermission) return;
76
- const fcmToken = await messaging().getToken();
77
- console.log('fcmToken31232', fcmToken, config);
78
- if (!config.appId) return;
79
- let subscription_id = await axios.post(`https://notifysphere.dothejob.in:3008/apps/${config.appId}/users`, {
185
+ if (!hasPermission) {
186
+ warn('Notification permission not granted — aborting initialization');
187
+ return undefined;
188
+ }
189
+ try {
190
+ const fcmToken = await messaging().getToken();
191
+ NotifySphere.fcmToken = fcmToken;
192
+
193
+ // ── Restore cached subscription_id immediately so tracking calls
194
+ // have it available even before the registration API responds.
195
+ const cachedSubId = await AsyncStorage.getItem(STORAGE_KEY_SUB_ID);
196
+ if (cachedSubId) {
197
+ NotifySphere.subscriptionId = cachedSubId;
198
+ }
199
+
200
+ // ── Only call the registration API when the FCM token OR any user
201
+ // detail has changed. This avoids a redundant network call on every
202
+ // app open while still syncing updates (email, phone, location, etc.)
203
+ const [cachedToken, cachedUserHash] = await Promise.all([AsyncStorage.getItem(STORAGE_KEY_TOKEN), AsyncStorage.getItem(STORAGE_KEY_USER_HASH)]);
204
+ const currentUserHash = NotifySphere.buildUserHash(config);
205
+ let subscriptionId = cachedSubId ?? undefined;
206
+ const tokenChanged = fcmToken !== cachedToken;
207
+ const userChanged = currentUserHash !== cachedUserHash;
208
+ if (tokenChanged || userChanged) {
209
+ if (tokenChanged) log('FCM token changed — re-registering device');
210
+ if (userChanged) log('User details changed — re-registering device');
211
+ subscriptionId = await NotifySphere.registerDevice(config, fcmToken, currentUserHash);
212
+ } else {
213
+ log('Token and user details unchanged — skipping registration API call');
214
+ }
215
+
216
+ // ── Channel and listeners are always set up (idempotent)
217
+ await NotifySphere.ensureChannel('channel_default', 'Default Channel', 'default');
218
+ if (!NotifySphere.initialized) {
219
+ NotifySphere.setupListeners();
220
+ // Watch for token refreshes (Firebase can rotate the token silently)
221
+ NotifySphere.watchTokenRefresh(config);
222
+ NotifySphere.initialized = true;
223
+ }
224
+ return subscriptionId;
225
+ } catch (err) {
226
+ logError('Error during initialization:', err);
227
+ return undefined;
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Calls the registration API and persists the token, subscription_id, and
233
+ * user hash so future launches can detect what has changed.
234
+ * Extracted so it can also be called from the token-refresh watcher.
235
+ */
236
+ static async registerDevice(config, fcmToken, userHash) {
237
+ const res = await axios.post(`${NotifySphere.baseUrl}${config.appId}/users`, {
80
238
  applicationUserId: config.applicationUserId,
81
239
  token: fcmToken,
82
240
  type: config.type,
@@ -88,85 +246,252 @@ class NotifySphere {
88
246
  state: config.state,
89
247
  email: config.email,
90
248
  phone: config.phone,
91
- tags: config.tags,
92
- sdk: devInfo.sdk,
93
- device_model: devInfo.device_model,
94
- device_os: devInfo.device_os,
95
- device_version: devInfo.device_version,
96
- carrier: devInfo.carrier,
97
- app_version: devInfo.app_version,
98
- timezone_id: devInfo.timezone_id
249
+ tags: config.tags
99
250
  }
100
251
  }, {
101
252
  headers: {
102
- 'Content-Type': 'application/json'
253
+ 'Content-Type': 'application/json',
254
+ ...(NotifySphere.apiKey ? {
255
+ Authorization: `Bearer ${NotifySphere.apiKey}`
256
+ } : {})
103
257
  },
104
- maxBodyLength: Infinity
105
- }).then(async res => {
106
- console.log('res313132', res);
107
- return res.data.subscription_id;
108
- }).catch(err => {
109
- console.log('err31232', err);
258
+ maxBodyLength: 1024 * 50,
259
+ timeout: 10000
110
260
  });
111
- NotifySphere.setupListeners();
112
- return subscription_id;
261
+ log('Device registration response:', res.data);
262
+ const subscriptionId = res.data?.subscription_id;
263
+ NotifySphere.subscriptionId = subscriptionId ?? null;
264
+
265
+ // Persist token, subscription_id, and user hash for future launch comparisons
266
+ const hash = userHash ?? NotifySphere.buildUserHash(config);
267
+ await Promise.all([AsyncStorage.setItem(STORAGE_KEY_TOKEN, fcmToken), AsyncStorage.setItem(STORAGE_KEY_USER_HASH, hash), subscriptionId ? AsyncStorage.setItem(STORAGE_KEY_SUB_ID, subscriptionId) : Promise.resolve()]);
268
+ return subscriptionId;
269
+ }
270
+
271
+ /**
272
+ * Listens for Firebase token refreshes and automatically re-registers
273
+ * the device when the token rotates, without requiring the consumer to
274
+ * call initialize() again.
275
+ */
276
+ static watchTokenRefresh(config) {
277
+ const unsubRefresh = messaging().onTokenRefresh(async newToken => {
278
+ log('FCM token refreshed — re-registering device');
279
+ NotifySphere.fcmToken = newToken;
280
+ try {
281
+ // User details haven't changed here, reuse the existing hash
282
+ const existingHash = (await AsyncStorage.getItem(STORAGE_KEY_USER_HASH)) ?? NotifySphere.buildUserHash(config);
283
+ await NotifySphere.registerDevice(config, newToken, existingHash);
284
+ } catch (err) {
285
+ logError('Error re-registering after token refresh:', err);
286
+ }
287
+ });
288
+ NotifySphere.unsubscribers.push(unsubRefresh);
289
+ }
290
+
291
+ // ─── Update tags ─────────────────────────────────────────────────────────
292
+
293
+ /**
294
+ * Updates user tags without re-registering the device.
295
+ * `appId` is optional here — it falls back to the value passed in `initialize`.
296
+ */
297
+ static async updateTags(params) {
298
+ const appId = params.appId ?? NotifySphere.appId;
299
+ if (!NotifySphere.fcmToken) {
300
+ warn('FCM token missing — cannot update tags');
301
+ return null;
302
+ }
303
+ if (!appId) {
304
+ warn('appId missing — cannot update tags');
305
+ return null;
306
+ }
307
+ try {
308
+ const res = await axios.post(`${NotifySphere.baseUrl}${appId}/users`, {
309
+ applicationUserId: params.applicationUserId,
310
+ token: NotifySphere.fcmToken,
311
+ type: params.type,
312
+ user: {
313
+ tags: params.tags
314
+ }
315
+ }, {
316
+ headers: {
317
+ 'Content-Type': 'application/json'
318
+ },
319
+ maxBodyLength: 1024 * 50,
320
+ timeout: 10000
321
+ });
322
+ log('Tags update response:', res.data);
323
+ return res.data;
324
+ } catch (err) {
325
+ logError('Error updating tags:', err);
326
+ return null;
327
+ }
113
328
  }
329
+
330
+ // ─── Public callback setter ───────────────────────────────────────────────
331
+
332
+ /** Register a callback to receive notification events (received / opened / initial). */
114
333
  static onNotification(callback) {
115
334
  NotifySphere.callback = callback;
116
335
  }
336
+
337
+ // ─── Background handler ───────────────────────────────────────────────────
338
+
339
+ /**
340
+ * Registers Firebase and Notifee background handlers.
341
+ *
342
+ * **IMPORTANT:** Call this in your root `index.js` file, before
343
+ * `AppRegistry.registerComponent()`, so it is available when the app wakes
344
+ * in the background — or starts from a terminated state — to handle a
345
+ * notification.
346
+ *
347
+ * When the app is **terminated**, React Native only runs `index.js` (a
348
+ * headless JS task). Your app components and `initialize()` never execute,
349
+ * so any custom `deliveryUrl` / `apiKey` you passed to `initialize()` are
350
+ * not available. Pass them here instead so they are set before the handler
351
+ * fires.
352
+ *
353
+ * @example
354
+ * // index.js
355
+ * import { AppRegistry } from 'react-native';
356
+ * import NotifySphere from 'react-native-notify-sphere';
357
+ * import App from './App';
358
+ *
359
+ * NotifySphere.setBackgroundHandler({
360
+ * deliveryUrl: 'https://your-server.com/ondelivery/handle', // optional
361
+ * apiKey: 'your-api-key', // optional
362
+ * });
363
+ * AppRegistry.registerComponent('MyApp', () => App);
364
+ */
365
+ static setBackgroundHandler(config) {
366
+ // Apply any config overrides immediately — before the handler fires —
367
+ // so they are in place even when the app starts from a terminated state.
368
+ if (config?.deliveryUrl) {
369
+ NotifySphere.deliveryUrl = NotifySphere.enforceHttps(config.deliveryUrl);
370
+ }
371
+ if (config?.apiKey) {
372
+ NotifySphere.apiKey = config.apiKey;
373
+ }
374
+ if (config?.subscriptionId) {
375
+ NotifySphere.subscriptionId = config.subscriptionId;
376
+ }
377
+ messaging().setBackgroundMessageHandler(async remoteMessage => {
378
+ const msg = remoteMessage;
379
+ // Run both in parallel — display and delivery receipt
380
+ await Promise.all([NotifySphere.displayLocalNotification(msg), NotifySphere.callbackOnDelivery(msg)]);
381
+ });
382
+ notifee.onBackgroundEvent(async ({
383
+ type,
384
+ detail
385
+ }) => {
386
+ log('Background notifee event, type:', type);
387
+ if (type === EventType.PRESS && detail.notification) {
388
+ await NotifySphere.callbackOnpress(detail.notification, 'press');
389
+ }
390
+ });
391
+ }
392
+
393
+ // ─── Cleanup ──────────────────────────────────────────────────────────────
394
+
395
+ /**
396
+ * Unsubscribes all active listeners and resets internal state.
397
+ * Call this when you want to stop receiving notifications (e.g. on logout).
398
+ */
399
+ static destroy() {
400
+ for (const unsub of NotifySphere.unsubscribers) {
401
+ unsub();
402
+ }
403
+ NotifySphere.unsubscribers = [];
404
+ NotifySphere.initialized = false;
405
+ log('NotifySphere destroyed — all listeners removed');
406
+ }
407
+
408
+ // ─── Private: listeners ───────────────────────────────────────────────────
409
+
117
410
  static setupListeners() {
118
- messaging().onMessage(async remoteMessage => {
119
- await NotifySphere.displayLocalNotification(remoteMessage);
411
+ // Foreground messages — display, send delivery receipt, and fire callback
412
+ const unsubMessage = messaging().onMessage(async remoteMessage => {
413
+ const msg = remoteMessage;
414
+ log('Foreground message received:', msg);
415
+ // Display and delivery receipt run in parallel for speed
416
+
417
+ await Promise.all([NotifySphere.displayLocalNotification(msg), NotifySphere.callbackOnDelivery(msg)]);
418
+ NotifySphere.sendCallback(msg, 'received');
120
419
  });
121
- messaging().onNotificationOpenedApp(async remoteMessage => {
122
- console.log('data34234', remoteMessage);
123
- NotifySphere.callbackOnpress(remoteMessage, 'opened');
420
+
421
+ // App brought from background via notification tap
422
+ const unsubOpened = messaging().onNotificationOpenedApp(async remoteMessage => {
423
+ log('App opened from background via notification:', remoteMessage.messageId);
424
+ await NotifySphere.callbackOnpress(remoteMessage, 'opened');
124
425
  });
426
+
427
+ // App launched from a quit state via notification tap
125
428
  messaging().getInitialNotification().then(remoteMessage => {
126
429
  if (remoteMessage) {
127
430
  NotifySphere.callbackOnpress(remoteMessage, 'initial');
128
431
  }
129
- });
130
- notifee.onForegroundEvent(({
432
+ }).catch(err => logError('getInitialNotification error:', err));
433
+
434
+ // Notifee foreground press events (covers in-app notification taps)
435
+ const unsubForeground = notifee.onForegroundEvent(({
131
436
  type,
132
437
  detail
133
438
  }) => {
134
- if (type === EventType.PRESS) {
135
- console.log('detail323213', detail);
439
+ if (type === EventType.PRESS && detail.notification) {
136
440
  NotifySphere.callbackOnpress(detail.notification, 'opened');
137
441
  }
138
442
  });
139
- notifee.onBackgroundEvent(async ({
140
- type,
141
- detail
142
- }) => {
143
- console.log('detail323213111111', type, detail);
144
- if (type === EventType.PRESS) {
145
- console.log('detail323213', detail);
146
- NotifySphere.callbackOnpress(detail.notification, 'press');
147
- }
148
- });
443
+ NotifySphere.unsubscribers.push(unsubMessage, unsubOpened, unsubForeground);
149
444
  }
445
+
446
+ // ─── Private: channel management ─────────────────────────────────────────
447
+
448
+ /**
449
+ * Creates a notification channel if it hasn't been created yet this session.
450
+ * Notifee handles idempotency across app restarts; this in-memory guard
451
+ * prevents redundant async calls within the same session.
452
+ */
453
+ static async ensureChannel(id, name, sound) {
454
+ if (NotifySphere.createdChannels.has(id)) return;
455
+ const soundName = sound.replace(/\..+$/, '');
456
+ const channelDef = {
457
+ id,
458
+ name,
459
+ importance: AndroidImportance.HIGH,
460
+ sound: soundName !== 'default' ? soundName : undefined,
461
+ lights: true,
462
+ lightColor: '#0000FF',
463
+ vibration: true
464
+ };
465
+ await notifee.createChannel(channelDef);
466
+ NotifySphere.createdChannels.add(id);
467
+ log('Android channel created/verified:', id);
468
+ }
469
+
470
+ // ─── Private: display notification ───────────────────────────────────────
471
+
150
472
  static async displayLocalNotification(remoteMessage) {
473
+ // Use || so empty strings fall back just like null/undefined
151
474
  const soundRaw = remoteMessage.data?.sound || 'default';
152
- const androidChannelId = `channel_${soundRaw}`;
153
- const androidSoundName = soundRaw.replace(/\..+$/, '');
154
- console.log('remoteMessage1312', remoteMessage);
155
- await notifee.createChannel({
156
- id: androidChannelId,
157
- name: `Channel ${androidSoundName}`,
158
- importance: AndroidImportance.HIGH,
159
- sound: androidSoundName !== 'default' ? androidSoundName : undefined
160
- });
161
- const image = remoteMessage?.data?.fcm_options?.image || remoteMessage.notification?.android?.imageUrl || remoteMessage.notification?.imageUrl || remoteMessage.notification?.ios?.attachments?.[0]?.url;
475
+ const soundName = soundRaw.replace(/\..+$/, '');
476
+ const channelId = `channel_${soundName}`;
477
+ await NotifySphere.ensureChannel(channelId, `Channel ${soundName}`, soundRaw);
478
+
479
+ // Resolve image — empty string treated same as missing
480
+ const image = remoteMessage.data?.imageUrl || remoteMessage.notification?.android?.imageUrl || remoteMessage.notification?.imageUrl || remoteMessage.notification?.ios?.attachments?.[0]?.url || undefined;
481
+
482
+ // Resolve title/body empty string treated as missing
483
+ const title = remoteMessage.data?.title || remoteMessage.notification?.title || undefined;
484
+ const body = remoteMessage.data?.body || remoteMessage.notification?.body || undefined;
485
+
486
+ // smallIcon falls back to 'ic_stat_onesignal_default' if empty/missing
487
+ // largeIcon is removed entirely
488
+ const smallIcon = NotifySphere.resolveIcon(remoteMessage.data?.smallIcon) ?? 'ic_stat_onesignal_default';
162
489
  await notifee.displayNotification({
163
- title: remoteMessage.notification?.title,
164
- body: remoteMessage.notification?.body,
490
+ title,
491
+ body,
165
492
  android: {
166
- channelId: androidChannelId,
167
- importance: AndroidImportance.HIGH,
168
- sound: androidSoundName !== 'default' ? androidSoundName : undefined,
169
- largeIcon: image,
493
+ channelId,
494
+ smallIcon,
170
495
  style: image ? {
171
496
  type: AndroidStyle.BIGPICTURE,
172
497
  picture: image
@@ -174,11 +499,9 @@ class NotifySphere {
174
499
  },
175
500
  ios: {
176
501
  sound: soundRaw,
177
- // must be a bundled sound file or "default"
178
502
  attachments: image ? [{
179
503
  url: image
180
504
  }] : undefined,
181
- // ✅ this is correct
182
505
  foregroundPresentationOptions: {
183
506
  alert: true,
184
507
  badge: true,
@@ -188,15 +511,66 @@ class NotifySphere {
188
511
  data: remoteMessage.data
189
512
  });
190
513
  }
191
- static sendCallback(remoteMessage, type) {
514
+
515
+ /**
516
+ * Returns the icon string if it is a real, usable value.
517
+ * Treats empty string (""), "ic_stat_onesignal_default", and
518
+ * any other placeholder-like value as absent (returns undefined).
519
+ */
520
+ static resolveIcon(value) {
521
+ if (!value) return undefined; // catches "" / null / undefined
522
+ return value;
523
+ }
524
+
525
+ /**
526
+ * Rejects plain HTTP URLs to prevent user data (FCM token, email, phone,
527
+ * location) being sent over an unencrypted connection.
528
+ */
529
+ static enforceHttps(url) {
530
+ if (url.startsWith('http://')) {
531
+ logError(`Insecure URL rejected: "${url}". NotifySphere only allows HTTPS endpoints.`);
532
+ throw new Error(`NotifySphere: insecure HTTP URL is not allowed — use HTTPS instead.`);
533
+ }
534
+ return url;
535
+ }
536
+
537
+ // ─── Private: fire consumer callback ─────────────────────────────────────
538
+
539
+ /**
540
+ * Normalises both Firebase remote messages and Notifee notification objects
541
+ * into the common NotificationData shape and fires the consumer callback.
542
+ */
543
+ static sendCallback(notification, type) {
192
544
  if (!NotifySphere.callback) return;
193
- const image = remoteMessage.notification?.android?.imageUrl || remoteMessage.notification?.imageUrl || remoteMessage.notification?.ios?.attachments?.[0]?.url;
545
+
546
+ // Detect shape: Firebase messages have a `notification` or `messageId` field
547
+ const isFirebase = 'notification' in notification || 'messageId' in notification;
548
+ let title;
549
+ let body;
550
+ let data;
551
+ let image;
552
+ let sound;
553
+ if (isFirebase) {
554
+ const msg = notification;
555
+ title = msg.notification?.title ?? msg.data?.title;
556
+ body = msg.notification?.body ?? msg.data?.body;
557
+ data = msg.data;
558
+ image = msg.notification?.android?.imageUrl || msg.notification?.imageUrl || msg.notification?.ios?.attachments?.[0]?.url || msg.data?.imageUrl;
559
+ sound = msg.data?.sound;
560
+ } else {
561
+ const notif = notification;
562
+ title = notif.title;
563
+ body = notif.body;
564
+ data = notif.data;
565
+ image = notif.data?.imageUrl;
566
+ sound = notif.data?.sound;
567
+ }
194
568
  NotifySphere.callback({
195
- title: remoteMessage.notification?.title,
196
- body: remoteMessage.notification?.body,
197
- data: remoteMessage.data,
569
+ title,
570
+ body,
571
+ data,
198
572
  image,
199
- sound: remoteMessage.data?.sound
573
+ sound
200
574
  }, type);
201
575
  }
202
576
  }