@fishonfire/bubbles-expo 0.1.0 → 0.1.3

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/README.md CHANGED
@@ -70,6 +70,8 @@ Plugin options:
70
70
 
71
71
  For Android remote display, backend payloads must use the same `android.notification.channel_id` as `defaultChannelId`. The plugin writes Firebase's default-channel manifest metadata, and the package creates the actual Android notification channel when `BubblesNotificationsProvider`, `getNotificationPermissions()`, or `ensureDefaultNotificationChannel()` runs. On a fresh install, the app must be opened at least once before remote notifications can reliably use this configured channel; otherwise Android/Firebase may fall back to its own default behavior before JavaScript has created the channel.
72
72
 
73
+ Device registration uses the Firebase Installation ID (FID) as the push registration identifier. The package does not call Firebase Messaging's deprecated `getToken()` path for Bubbles device sync; the same FID value is sent as both `push_token` and `fid`. On Android, the config plugin also opts into FID-based Firebase Messaging registration and installs native startup code that calls `FirebaseMessaging.getInstance().register()` automatically.
74
+
73
75
  ## Background registration
74
76
 
75
77
  Register the package-owned background notification handlers once from your app entrypoint:
@@ -162,10 +164,25 @@ export function EnableNotificationsButton() {
162
164
  }
163
165
  ```
164
166
 
165
- `useBubblesNotifications()` also exposes `deviceId`, `pushToken`, `permissionStatus`, `notificationsEnabled`, `tokenType`, and `error`.
167
+ `useBubblesNotifications()` also exposes `addDeviceAttribute`, `deviceId`, `pushToken`, `permissionStatus`, `notificationsEnabled`, `tokenType`, and `error`. When notifications are enabled, `pushToken` is the Firebase Installation ID and `tokenType` is `"fid"`.
166
168
 
167
169
  If your app already requested notification permissions, call `registerDevice({ requestPermissions: false })`.
168
170
 
171
+ ## Add custom device attributes
172
+
173
+ After the provider has a backend `deviceId`, apps can add their own device attributes:
174
+
175
+ ```tsx
176
+ const { addDeviceAttribute, deviceId } = useBubblesNotifications();
177
+
178
+ if (deviceId) {
179
+ void addDeviceAttribute('plan', {
180
+ tier: 'pro',
181
+ seats: 4,
182
+ });
183
+ }
184
+ ```
185
+
169
186
  ## Example application
170
187
  A example application can be found at: https://github.com/fishonfire/bubbles-notifications-expo-example
171
188
 
@@ -177,4 +194,4 @@ A example application can be found at: https://github.com/fishonfire/bubbles-not
177
194
  ## Copyright and Licence
178
195
  Copyright (c) 2026, Fish on Fire.
179
196
 
180
- Source code is licensed under the [`GPL License`](https://github.com/fishonfire/bubbles-notifications-expo/blob/develop/LICENSE).
197
+ Source code is licensed under the [`GPL License`](https://github.com/fishonfire/bubbles-notifications-expo/blob/develop/LICENSE).
@@ -458,6 +458,78 @@ function throwAppAuthenticationError(error) {
458
458
  }
459
459
  throw error;
460
460
  }
461
+ function normalizeDeviceAttributes(value) {
462
+ return normalizeDeviceAttributeObject(value, "attributes", /* @__PURE__ */ new WeakSet());
463
+ }
464
+ function normalizeDeviceAttributeValue(value, sourceDescription, seenValues) {
465
+ if (value === null) {
466
+ return null;
467
+ }
468
+ if (typeof value === "string" || typeof value === "boolean") {
469
+ return value;
470
+ }
471
+ if (typeof value === "number") {
472
+ if (!Number.isFinite(value)) {
473
+ failWithBubblesError(`${sourceDescription} must be a finite number.`);
474
+ }
475
+ return value;
476
+ }
477
+ if (Array.isArray(value)) {
478
+ if (seenValues.has(value)) {
479
+ failWithBubblesError(`${sourceDescription} must not contain cycles.`);
480
+ }
481
+ seenValues.add(value);
482
+ const normalizedValue = value.map(
483
+ (entry, index) => normalizeDeviceAttributeValue(
484
+ entry,
485
+ `${sourceDescription}[${index}]`,
486
+ seenValues
487
+ )
488
+ );
489
+ seenValues.delete(value);
490
+ return normalizedValue;
491
+ }
492
+ if (isPlainObject(value)) {
493
+ return normalizeDeviceAttributeObject(
494
+ value,
495
+ sourceDescription,
496
+ seenValues
497
+ );
498
+ }
499
+ failWithBubblesError(
500
+ `${sourceDescription} must be a JSON-compatible value.`
501
+ );
502
+ }
503
+ function normalizeDeviceAttributeObject(value, sourceDescription, seenValues) {
504
+ if (!isPlainObject(value)) {
505
+ failWithBubblesError('"attributes" must be an object.');
506
+ }
507
+ const valuePrototype = Object.getPrototypeOf(value);
508
+ if (valuePrototype !== Object.prototype && valuePrototype !== null) {
509
+ failWithBubblesError(
510
+ `${sourceDescription} must be a JSON-compatible object.`
511
+ );
512
+ }
513
+ if (Object.getOwnPropertySymbols(value).length > 0) {
514
+ failWithBubblesError(
515
+ `${sourceDescription} must not contain symbol keys.`
516
+ );
517
+ }
518
+ if (seenValues.has(value)) {
519
+ failWithBubblesError(`${sourceDescription} must not contain cycles.`);
520
+ }
521
+ seenValues.add(value);
522
+ const normalizedValue = {};
523
+ for (const [attributeName, attributeValue] of Object.entries(value)) {
524
+ normalizedValue[attributeName] = normalizeDeviceAttributeValue(
525
+ attributeValue,
526
+ `${sourceDescription}.${attributeName}`,
527
+ seenValues
528
+ );
529
+ }
530
+ seenValues.delete(value);
531
+ return normalizedValue;
532
+ }
461
533
  function extractDeviceIdFromDeviceResponse(value) {
462
534
  if (!value || typeof value !== "object" || Array.isArray(value)) {
463
535
  return null;
@@ -497,6 +569,18 @@ async function updateBubblesDevice(options) {
497
569
  throwAppAuthenticationError(error);
498
570
  }
499
571
  }
572
+ async function updateBubblesDeviceAttributes(options) {
573
+ const client = resolveDeviceClient(options);
574
+ try {
575
+ return await client.updateDeviceAttributes(
576
+ normalizeIdentifier(options.deviceId, "deviceId"),
577
+ normalizeDeviceAttributes(options.attributes),
578
+ withAppKeyHeader(options.appKey, options.requestOptions)
579
+ );
580
+ } catch (error) {
581
+ throwAppAuthenticationError(error);
582
+ }
583
+ }
500
584
  async function syncBubblesDevice(options) {
501
585
  const client = resolveDeviceClient(options);
502
586
  if (options.deviceId !== null) {
@@ -1279,6 +1363,7 @@ export {
1279
1363
  extractDeviceIdFromDeviceResponse,
1280
1364
  createBubblesDevice,
1281
1365
  updateBubblesDevice,
1366
+ updateBubblesDeviceAttributes,
1282
1367
  syncBubblesDevice,
1283
1368
  BUBBLES_DELIVERY_STATUSES,
1284
1369
  buildBubblesDeliveryStatusPayload,
package/dist/index.d.mts CHANGED
@@ -93,10 +93,20 @@ interface SyncBubblesDeviceResult<TResponse = unknown> {
93
93
  deviceId: string | null;
94
94
  response: TResponse;
95
95
  }
96
+ type BubblesDeviceAttributeValue = string | number | boolean | null | BubblesDeviceAttributeValue[] | {
97
+ [key: string]: BubblesDeviceAttributeValue;
98
+ };
99
+ type BubblesDeviceAttributes = Record<string, BubblesDeviceAttributeValue>;
100
+ interface UpdateBubblesDeviceAttributesOptions extends BubblesDeviceClientInput {
101
+ deviceId: string | number;
102
+ attributes: BubblesDeviceAttributes;
103
+ requestOptions?: RequestOptions;
104
+ }
96
105
  declare function createBubblesDeviceClient(options: BubblesDeviceClientOptions): DeviceClient;
97
106
  declare function extractDeviceIdFromDeviceResponse(value: unknown): string | null;
98
107
  declare function createBubblesDevice<TResponse = unknown>(options: CreateBubblesDeviceOptions): Promise<TResponse>;
99
108
  declare function updateBubblesDevice<TResponse = unknown>(options: UpdateBubblesDeviceOptions): Promise<TResponse>;
109
+ declare function updateBubblesDeviceAttributes<TResponse = unknown>(options: UpdateBubblesDeviceAttributesOptions): Promise<TResponse>;
100
110
  declare function syncBubblesDevice<TResponse = unknown>(options: SyncBubblesDeviceOptions): Promise<SyncBubblesDeviceResult<TResponse>>;
101
111
 
102
112
  type DeviceClientFactoryOptions = Omit<DeviceClientOptions, 'baseUrl'>;
@@ -202,7 +212,7 @@ declare function ensureNotificationPermissions(options?: GetNotificationPermissi
202
212
 
203
213
  type SupportedPlatform = 'android' | 'ios';
204
214
 
205
- type NativeTokenType = 'fcm';
215
+ type NativeTokenType = 'fid';
206
216
  type GetDeviceTokenOptions = GetNotificationPermissionsOptions;
207
217
  interface DeviceTokenResult {
208
218
  platform: SupportedPlatform;
@@ -220,6 +230,8 @@ declare function getDeviceRegistrationState(options?: GetDeviceTokenOptions): Pr
220
230
  declare function getDeviceToken(options?: GetDeviceTokenOptions): Promise<DeviceTokenResult>;
221
231
  declare function getFCMToken(options?: GetDeviceTokenOptions): Promise<string>;
222
232
 
233
+ declare function collectBubblesDeviceAttributes(): Promise<BubblesDeviceAttributes>;
234
+
223
235
  interface RegisterDeviceOptions {
224
236
  requestPermissions?: boolean;
225
237
  permissionRequestOptions?: PermissionRequestOptions;
@@ -256,6 +268,7 @@ interface BubblesNotificationsProviderProps {
256
268
  }
257
269
  interface BubblesNotificationsContextValue {
258
270
  registerDevice: (options?: RegisterDeviceOptions) => Promise<void>;
271
+ addDeviceAttribute: (name: string, value: BubblesDeviceAttributeValue) => Promise<void>;
259
272
  deviceId: string | null;
260
273
  pushToken: string | null;
261
274
  tokenType: NativeTokenType | null;
@@ -303,4 +316,4 @@ interface BubblesNotificationPayload {
303
316
  url: string | null;
304
317
  }
305
318
 
306
- export { ANDROID_CHANNEL_IMPORTANCE_VALUES, type AndroidChannelImportance, BUBBLES_DELIVERY_STATUSES, BUBBLES_NOTIFICATIONS_EXPO_EXTRA_KEY, BubblesAppAuthenticationError, type BubblesCreateDevicePayload, type BubblesDeliveryStatus, type BubblesDeliveryStatusPayload, type BubblesDevicePayload, type BubblesDevicePlatform, type BubblesForegroundNotificationPayloadContract, type BubblesNotificationDataContract, type BubblesNotificationPayload, type BubblesNotificationResponseEvent, type BubblesNotificationsContextValue, type BubblesNotificationsLogoutEvent, BubblesNotificationsProvider, type BubblesNotificationsProviderProps, type BubblesNotificationsRuntimeConfig, type BubblesSilentNotificationPayloadContract, type BubblesUpdateDevicePayload, type BubblesVisibleNotificationPayloadContract, type BuildBubblesCreateDevicePayloadOptions, type BuildBubblesDeliveryStatusPayloadOptions, type BuildBubblesDevicePayloadOptions, type CreateBubblesDeviceOptions, type CreateStoredDeliveryStatusEventOptions, type DefaultNotificationChannelConfig, type DeviceRegistrationState, type DeviceTokenResult, type EnsureDefaultNotificationChannelOptions, type ForegroundPresentationOptions, type GetDeviceTokenOptions, type GetNotificationPermissionsOptions, type NativeTokenType, type PermissionRequestOptions, type PostBubblesDeliveryStatusOptions, type PostStoredBubblesDeliveryStatusOptions, type RegisterDeviceOptions, type StoredDeliveryStatusEvent, type StoredDeliveryStatusLedger, type StoredDeviceState, type StoredDeviceStateUpdate, type SupportedPlatform, type SyncBubblesDeviceOptions, type SyncBubblesDeviceResult, type UpdateBubblesDeviceOptions, buildBubblesDeliveryStatusPayload, buildCreateDevicePayload, buildStoredDeliveryStatusEventKey, buildUpdateDevicePayload, createBubblesDevice, createBubblesDeviceClient, createStoredDeliveryStatusEvent, describeNotificationPermissionStatus, enqueueStoredDeliveryStatusEvent, ensureDefaultNotificationChannel, ensureNotificationPermissions, extractDeviceIdFromDeviceResponse, flushStoredBubblesDeliveryStatuses, getAndroidNotificationChannelImportance, getBubblesNotificationsRuntimeConfig, getDefaultNotificationChannelConfig, getDeviceRegistrationState, getDeviceToken, getFCMToken, getNotificationPermissions, isNotificationPermissionGranted, markStoredDeliveryStatusEventPosted, patchStoredDeviceState, postBubblesDeliveryStatus, postStoredBubblesDeliveryStatus, readStoredApiBaseUrl, readStoredAppKey, readStoredDeliveryStatusLedger, readStoredDeviceId, readStoredDeviceState, registerBubblesBackgroundHandlers, storeApiBaseUrl, storeAppKey, storeDeliveryStatusLedger, storeDeviceId, storeDeviceState, syncBubblesDevice, updateBubblesDevice, useBubblesNotifications };
319
+ export { ANDROID_CHANNEL_IMPORTANCE_VALUES, type AndroidChannelImportance, BUBBLES_DELIVERY_STATUSES, BUBBLES_NOTIFICATIONS_EXPO_EXTRA_KEY, BubblesAppAuthenticationError, type BubblesCreateDevicePayload, type BubblesDeliveryStatus, type BubblesDeliveryStatusPayload, type BubblesDeviceAttributeValue, type BubblesDeviceAttributes, type BubblesDevicePayload, type BubblesDevicePlatform, type BubblesForegroundNotificationPayloadContract, type BubblesNotificationDataContract, type BubblesNotificationPayload, type BubblesNotificationResponseEvent, type BubblesNotificationsContextValue, type BubblesNotificationsLogoutEvent, BubblesNotificationsProvider, type BubblesNotificationsProviderProps, type BubblesNotificationsRuntimeConfig, type BubblesSilentNotificationPayloadContract, type BubblesUpdateDevicePayload, type BubblesVisibleNotificationPayloadContract, type BuildBubblesCreateDevicePayloadOptions, type BuildBubblesDeliveryStatusPayloadOptions, type BuildBubblesDevicePayloadOptions, type CreateBubblesDeviceOptions, type CreateStoredDeliveryStatusEventOptions, type DefaultNotificationChannelConfig, type DeviceRegistrationState, type DeviceTokenResult, type EnsureDefaultNotificationChannelOptions, type ForegroundPresentationOptions, type GetDeviceTokenOptions, type GetNotificationPermissionsOptions, type NativeTokenType, type PermissionRequestOptions, type PostBubblesDeliveryStatusOptions, type PostStoredBubblesDeliveryStatusOptions, type RegisterDeviceOptions, type StoredDeliveryStatusEvent, type StoredDeliveryStatusLedger, type StoredDeviceState, type StoredDeviceStateUpdate, type SupportedPlatform, type SyncBubblesDeviceOptions, type SyncBubblesDeviceResult, type UpdateBubblesDeviceAttributesOptions, type UpdateBubblesDeviceOptions, buildBubblesDeliveryStatusPayload, buildCreateDevicePayload, buildStoredDeliveryStatusEventKey, buildUpdateDevicePayload, collectBubblesDeviceAttributes, createBubblesDevice, createBubblesDeviceClient, createStoredDeliveryStatusEvent, describeNotificationPermissionStatus, enqueueStoredDeliveryStatusEvent, ensureDefaultNotificationChannel, ensureNotificationPermissions, extractDeviceIdFromDeviceResponse, flushStoredBubblesDeliveryStatuses, getAndroidNotificationChannelImportance, getBubblesNotificationsRuntimeConfig, getDefaultNotificationChannelConfig, getDeviceRegistrationState, getDeviceToken, getFCMToken, getNotificationPermissions, isNotificationPermissionGranted, markStoredDeliveryStatusEventPosted, patchStoredDeviceState, postBubblesDeliveryStatus, postStoredBubblesDeliveryStatus, readStoredApiBaseUrl, readStoredAppKey, readStoredDeliveryStatusLedger, readStoredDeviceId, readStoredDeviceState, registerBubblesBackgroundHandlers, storeApiBaseUrl, storeAppKey, storeDeliveryStatusLedger, storeDeviceId, storeDeviceState, syncBubblesDevice, updateBubblesDevice, updateBubblesDeviceAttributes, useBubblesNotifications };
package/dist/index.d.ts CHANGED
@@ -93,10 +93,20 @@ interface SyncBubblesDeviceResult<TResponse = unknown> {
93
93
  deviceId: string | null;
94
94
  response: TResponse;
95
95
  }
96
+ type BubblesDeviceAttributeValue = string | number | boolean | null | BubblesDeviceAttributeValue[] | {
97
+ [key: string]: BubblesDeviceAttributeValue;
98
+ };
99
+ type BubblesDeviceAttributes = Record<string, BubblesDeviceAttributeValue>;
100
+ interface UpdateBubblesDeviceAttributesOptions extends BubblesDeviceClientInput {
101
+ deviceId: string | number;
102
+ attributes: BubblesDeviceAttributes;
103
+ requestOptions?: RequestOptions;
104
+ }
96
105
  declare function createBubblesDeviceClient(options: BubblesDeviceClientOptions): DeviceClient;
97
106
  declare function extractDeviceIdFromDeviceResponse(value: unknown): string | null;
98
107
  declare function createBubblesDevice<TResponse = unknown>(options: CreateBubblesDeviceOptions): Promise<TResponse>;
99
108
  declare function updateBubblesDevice<TResponse = unknown>(options: UpdateBubblesDeviceOptions): Promise<TResponse>;
109
+ declare function updateBubblesDeviceAttributes<TResponse = unknown>(options: UpdateBubblesDeviceAttributesOptions): Promise<TResponse>;
100
110
  declare function syncBubblesDevice<TResponse = unknown>(options: SyncBubblesDeviceOptions): Promise<SyncBubblesDeviceResult<TResponse>>;
101
111
 
102
112
  type DeviceClientFactoryOptions = Omit<DeviceClientOptions, 'baseUrl'>;
@@ -202,7 +212,7 @@ declare function ensureNotificationPermissions(options?: GetNotificationPermissi
202
212
 
203
213
  type SupportedPlatform = 'android' | 'ios';
204
214
 
205
- type NativeTokenType = 'fcm';
215
+ type NativeTokenType = 'fid';
206
216
  type GetDeviceTokenOptions = GetNotificationPermissionsOptions;
207
217
  interface DeviceTokenResult {
208
218
  platform: SupportedPlatform;
@@ -220,6 +230,8 @@ declare function getDeviceRegistrationState(options?: GetDeviceTokenOptions): Pr
220
230
  declare function getDeviceToken(options?: GetDeviceTokenOptions): Promise<DeviceTokenResult>;
221
231
  declare function getFCMToken(options?: GetDeviceTokenOptions): Promise<string>;
222
232
 
233
+ declare function collectBubblesDeviceAttributes(): Promise<BubblesDeviceAttributes>;
234
+
223
235
  interface RegisterDeviceOptions {
224
236
  requestPermissions?: boolean;
225
237
  permissionRequestOptions?: PermissionRequestOptions;
@@ -256,6 +268,7 @@ interface BubblesNotificationsProviderProps {
256
268
  }
257
269
  interface BubblesNotificationsContextValue {
258
270
  registerDevice: (options?: RegisterDeviceOptions) => Promise<void>;
271
+ addDeviceAttribute: (name: string, value: BubblesDeviceAttributeValue) => Promise<void>;
259
272
  deviceId: string | null;
260
273
  pushToken: string | null;
261
274
  tokenType: NativeTokenType | null;
@@ -303,4 +316,4 @@ interface BubblesNotificationPayload {
303
316
  url: string | null;
304
317
  }
305
318
 
306
- export { ANDROID_CHANNEL_IMPORTANCE_VALUES, type AndroidChannelImportance, BUBBLES_DELIVERY_STATUSES, BUBBLES_NOTIFICATIONS_EXPO_EXTRA_KEY, BubblesAppAuthenticationError, type BubblesCreateDevicePayload, type BubblesDeliveryStatus, type BubblesDeliveryStatusPayload, type BubblesDevicePayload, type BubblesDevicePlatform, type BubblesForegroundNotificationPayloadContract, type BubblesNotificationDataContract, type BubblesNotificationPayload, type BubblesNotificationResponseEvent, type BubblesNotificationsContextValue, type BubblesNotificationsLogoutEvent, BubblesNotificationsProvider, type BubblesNotificationsProviderProps, type BubblesNotificationsRuntimeConfig, type BubblesSilentNotificationPayloadContract, type BubblesUpdateDevicePayload, type BubblesVisibleNotificationPayloadContract, type BuildBubblesCreateDevicePayloadOptions, type BuildBubblesDeliveryStatusPayloadOptions, type BuildBubblesDevicePayloadOptions, type CreateBubblesDeviceOptions, type CreateStoredDeliveryStatusEventOptions, type DefaultNotificationChannelConfig, type DeviceRegistrationState, type DeviceTokenResult, type EnsureDefaultNotificationChannelOptions, type ForegroundPresentationOptions, type GetDeviceTokenOptions, type GetNotificationPermissionsOptions, type NativeTokenType, type PermissionRequestOptions, type PostBubblesDeliveryStatusOptions, type PostStoredBubblesDeliveryStatusOptions, type RegisterDeviceOptions, type StoredDeliveryStatusEvent, type StoredDeliveryStatusLedger, type StoredDeviceState, type StoredDeviceStateUpdate, type SupportedPlatform, type SyncBubblesDeviceOptions, type SyncBubblesDeviceResult, type UpdateBubblesDeviceOptions, buildBubblesDeliveryStatusPayload, buildCreateDevicePayload, buildStoredDeliveryStatusEventKey, buildUpdateDevicePayload, createBubblesDevice, createBubblesDeviceClient, createStoredDeliveryStatusEvent, describeNotificationPermissionStatus, enqueueStoredDeliveryStatusEvent, ensureDefaultNotificationChannel, ensureNotificationPermissions, extractDeviceIdFromDeviceResponse, flushStoredBubblesDeliveryStatuses, getAndroidNotificationChannelImportance, getBubblesNotificationsRuntimeConfig, getDefaultNotificationChannelConfig, getDeviceRegistrationState, getDeviceToken, getFCMToken, getNotificationPermissions, isNotificationPermissionGranted, markStoredDeliveryStatusEventPosted, patchStoredDeviceState, postBubblesDeliveryStatus, postStoredBubblesDeliveryStatus, readStoredApiBaseUrl, readStoredAppKey, readStoredDeliveryStatusLedger, readStoredDeviceId, readStoredDeviceState, registerBubblesBackgroundHandlers, storeApiBaseUrl, storeAppKey, storeDeliveryStatusLedger, storeDeviceId, storeDeviceState, syncBubblesDevice, updateBubblesDevice, useBubblesNotifications };
319
+ export { ANDROID_CHANNEL_IMPORTANCE_VALUES, type AndroidChannelImportance, BUBBLES_DELIVERY_STATUSES, BUBBLES_NOTIFICATIONS_EXPO_EXTRA_KEY, BubblesAppAuthenticationError, type BubblesCreateDevicePayload, type BubblesDeliveryStatus, type BubblesDeliveryStatusPayload, type BubblesDeviceAttributeValue, type BubblesDeviceAttributes, type BubblesDevicePayload, type BubblesDevicePlatform, type BubblesForegroundNotificationPayloadContract, type BubblesNotificationDataContract, type BubblesNotificationPayload, type BubblesNotificationResponseEvent, type BubblesNotificationsContextValue, type BubblesNotificationsLogoutEvent, BubblesNotificationsProvider, type BubblesNotificationsProviderProps, type BubblesNotificationsRuntimeConfig, type BubblesSilentNotificationPayloadContract, type BubblesUpdateDevicePayload, type BubblesVisibleNotificationPayloadContract, type BuildBubblesCreateDevicePayloadOptions, type BuildBubblesDeliveryStatusPayloadOptions, type BuildBubblesDevicePayloadOptions, type CreateBubblesDeviceOptions, type CreateStoredDeliveryStatusEventOptions, type DefaultNotificationChannelConfig, type DeviceRegistrationState, type DeviceTokenResult, type EnsureDefaultNotificationChannelOptions, type ForegroundPresentationOptions, type GetDeviceTokenOptions, type GetNotificationPermissionsOptions, type NativeTokenType, type PermissionRequestOptions, type PostBubblesDeliveryStatusOptions, type PostStoredBubblesDeliveryStatusOptions, type RegisterDeviceOptions, type StoredDeliveryStatusEvent, type StoredDeliveryStatusLedger, type StoredDeviceState, type StoredDeviceStateUpdate, type SupportedPlatform, type SyncBubblesDeviceOptions, type SyncBubblesDeviceResult, type UpdateBubblesDeviceAttributesOptions, type UpdateBubblesDeviceOptions, buildBubblesDeliveryStatusPayload, buildCreateDevicePayload, buildStoredDeliveryStatusEventKey, buildUpdateDevicePayload, collectBubblesDeviceAttributes, createBubblesDevice, createBubblesDeviceClient, createStoredDeliveryStatusEvent, describeNotificationPermissionStatus, enqueueStoredDeliveryStatusEvent, ensureDefaultNotificationChannel, ensureNotificationPermissions, extractDeviceIdFromDeviceResponse, flushStoredBubblesDeliveryStatuses, getAndroidNotificationChannelImportance, getBubblesNotificationsRuntimeConfig, getDefaultNotificationChannelConfig, getDeviceRegistrationState, getDeviceToken, getFCMToken, getNotificationPermissions, isNotificationPermissionGranted, markStoredDeliveryStatusEventPosted, patchStoredDeviceState, postBubblesDeliveryStatus, postStoredBubblesDeliveryStatus, readStoredApiBaseUrl, readStoredAppKey, readStoredDeliveryStatusLedger, readStoredDeviceId, readStoredDeviceState, registerBubblesBackgroundHandlers, storeApiBaseUrl, storeAppKey, storeDeliveryStatusLedger, storeDeviceId, storeDeviceState, syncBubblesDevice, updateBubblesDevice, updateBubblesDeviceAttributes, useBubblesNotifications };
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ __export(index_exports, {
39
39
  buildCreateDevicePayload: () => buildCreateDevicePayload,
40
40
  buildStoredDeliveryStatusEventKey: () => buildStoredDeliveryStatusEventKey,
41
41
  buildUpdateDevicePayload: () => buildUpdateDevicePayload,
42
+ collectBubblesDeviceAttributes: () => collectBubblesDeviceAttributes,
42
43
  createBubblesDevice: () => createBubblesDevice,
43
44
  createBubblesDeviceClient: () => createBubblesDeviceClient,
44
45
  createStoredDeliveryStatusEvent: () => createStoredDeliveryStatusEvent,
@@ -73,6 +74,7 @@ __export(index_exports, {
73
74
  storeDeviceState: () => storeDeviceState,
74
75
  syncBubblesDevice: () => syncBubblesDevice,
75
76
  updateBubblesDevice: () => updateBubblesDevice,
77
+ updateBubblesDeviceAttributes: () => updateBubblesDeviceAttributes,
76
78
  useBubblesNotifications: () => useBubblesNotifications
77
79
  });
78
80
  module.exports = __toCommonJS(index_exports);
@@ -535,6 +537,78 @@ function throwAppAuthenticationError(error) {
535
537
  }
536
538
  throw error;
537
539
  }
540
+ function normalizeDeviceAttributes(value) {
541
+ return normalizeDeviceAttributeObject(value, "attributes", /* @__PURE__ */ new WeakSet());
542
+ }
543
+ function normalizeDeviceAttributeValue(value, sourceDescription, seenValues) {
544
+ if (value === null) {
545
+ return null;
546
+ }
547
+ if (typeof value === "string" || typeof value === "boolean") {
548
+ return value;
549
+ }
550
+ if (typeof value === "number") {
551
+ if (!Number.isFinite(value)) {
552
+ failWithBubblesError(`${sourceDescription} must be a finite number.`);
553
+ }
554
+ return value;
555
+ }
556
+ if (Array.isArray(value)) {
557
+ if (seenValues.has(value)) {
558
+ failWithBubblesError(`${sourceDescription} must not contain cycles.`);
559
+ }
560
+ seenValues.add(value);
561
+ const normalizedValue = value.map(
562
+ (entry, index) => normalizeDeviceAttributeValue(
563
+ entry,
564
+ `${sourceDescription}[${index}]`,
565
+ seenValues
566
+ )
567
+ );
568
+ seenValues.delete(value);
569
+ return normalizedValue;
570
+ }
571
+ if (isPlainObject(value)) {
572
+ return normalizeDeviceAttributeObject(
573
+ value,
574
+ sourceDescription,
575
+ seenValues
576
+ );
577
+ }
578
+ failWithBubblesError(
579
+ `${sourceDescription} must be a JSON-compatible value.`
580
+ );
581
+ }
582
+ function normalizeDeviceAttributeObject(value, sourceDescription, seenValues) {
583
+ if (!isPlainObject(value)) {
584
+ failWithBubblesError('"attributes" must be an object.');
585
+ }
586
+ const valuePrototype = Object.getPrototypeOf(value);
587
+ if (valuePrototype !== Object.prototype && valuePrototype !== null) {
588
+ failWithBubblesError(
589
+ `${sourceDescription} must be a JSON-compatible object.`
590
+ );
591
+ }
592
+ if (Object.getOwnPropertySymbols(value).length > 0) {
593
+ failWithBubblesError(
594
+ `${sourceDescription} must not contain symbol keys.`
595
+ );
596
+ }
597
+ if (seenValues.has(value)) {
598
+ failWithBubblesError(`${sourceDescription} must not contain cycles.`);
599
+ }
600
+ seenValues.add(value);
601
+ const normalizedValue = {};
602
+ for (const [attributeName, attributeValue] of Object.entries(value)) {
603
+ normalizedValue[attributeName] = normalizeDeviceAttributeValue(
604
+ attributeValue,
605
+ `${sourceDescription}.${attributeName}`,
606
+ seenValues
607
+ );
608
+ }
609
+ seenValues.delete(value);
610
+ return normalizedValue;
611
+ }
538
612
  function extractDeviceIdFromDeviceResponse(value) {
539
613
  if (!value || typeof value !== "object" || Array.isArray(value)) {
540
614
  return null;
@@ -574,6 +648,18 @@ async function updateBubblesDevice(options) {
574
648
  throwAppAuthenticationError(error);
575
649
  }
576
650
  }
651
+ async function updateBubblesDeviceAttributes(options) {
652
+ const client = resolveDeviceClient(options);
653
+ try {
654
+ return await client.updateDeviceAttributes(
655
+ normalizeIdentifier(options.deviceId, "deviceId"),
656
+ normalizeDeviceAttributes(options.attributes),
657
+ withAppKeyHeader(options.appKey, options.requestOptions)
658
+ );
659
+ } catch (error) {
660
+ throwAppAuthenticationError(error);
661
+ }
662
+ }
577
663
  async function syncBubblesDevice(options) {
578
664
  const client = resolveDeviceClient(options);
579
665
  if (options.deviceId !== null) {
@@ -1332,27 +1418,28 @@ function registerBubblesBackgroundHandlers() {
1332
1418
  }
1333
1419
 
1334
1420
  // src/runtime/tokens.ts
1335
- var import_messaging2 = require("@react-native-firebase/messaging");
1336
1421
  var import_react_native4 = require("react-native");
1337
- async function getFirebaseMessagingToken(platform) {
1422
+
1423
+ // src/runtime/installations.ts
1424
+ var import_installations = require("@react-native-firebase/installations");
1425
+ async function getFirebaseInstallationId(platform) {
1338
1426
  try {
1339
- const messagingInstance = (0, import_messaging2.getMessaging)();
1340
- if (!(0, import_messaging2.isDeviceRegisteredForRemoteMessages)(messagingInstance)) {
1341
- await (0, import_messaging2.registerDeviceForRemoteMessages)(messagingInstance);
1342
- }
1343
- const token = await (0, import_messaging2.getToken)(messagingInstance);
1427
+ const installationsInstance = (0, import_installations.getInstallations)();
1428
+ const installationId = await (0, import_installations.getId)(installationsInstance);
1344
1429
  return getRequiredNonEmptyStringFromSource(
1345
- token,
1346
- `${platform} FCM token from Firebase Messaging`
1430
+ installationId,
1431
+ `${platform} Firebase installation id from Firebase Installations`
1347
1432
  );
1348
1433
  } catch (error) {
1349
1434
  const reason = getErrorMessage(error);
1350
- const platformSpecificSetupHint = platform === "ios" ? " On iOS, also ensure Firebase APNs setup is complete and `expo.ios.googleServicesFile` is configured." : " On Android, ensure `google-services.json` is configured and the native app has been rebuilt.";
1435
+ const platformSpecificSetupHint = platform === "ios" ? " On iOS, ensure `expo.ios.googleServicesFile` is configured and the native app has been rebuilt." : " On Android, ensure `expo.android.googleServicesFile` is configured and the native app has been rebuilt.";
1351
1436
  failWithBubblesError(
1352
- `Failed to get a ${platform} FCM token from Firebase Messaging. Ensure "@react-native-firebase/app" and "@react-native-firebase/messaging" are installed, the Expo app includes the "@react-native-firebase/messaging" config plugin, and Firebase is configured for this platform.${platformSpecificSetupHint} Original error: ${reason}`
1437
+ `Failed to get a ${platform} Firebase installation id from Firebase Installations. Ensure "@react-native-firebase/app" and "@react-native-firebase/installations" are installed and Firebase is configured for this platform.${platformSpecificSetupHint} Original error: ${reason}`
1353
1438
  );
1354
1439
  }
1355
1440
  }
1441
+
1442
+ // src/runtime/tokens.ts
1356
1443
  async function getDeviceRegistrationState(options) {
1357
1444
  const platform = getSupportedPlatform(import_react_native4.Platform.OS);
1358
1445
  const permissions = await getNotificationPermissions(options);
@@ -1367,10 +1454,10 @@ async function getDeviceRegistrationState(options) {
1367
1454
  notificationsEnabled: false
1368
1455
  };
1369
1456
  }
1370
- const token = await getFirebaseMessagingToken(platform);
1457
+ const token = await getFirebaseInstallationId(platform);
1371
1458
  return {
1372
1459
  platform,
1373
- tokenType: "fcm",
1460
+ tokenType: "fid",
1374
1461
  token,
1375
1462
  permissionStatus,
1376
1463
  notificationsEnabled: true
@@ -1383,7 +1470,7 @@ async function getDeviceToken(options) {
1383
1470
  }
1384
1471
  return {
1385
1472
  platform: registrationState.platform,
1386
- tokenType: "fcm",
1473
+ tokenType: "fid",
1387
1474
  token: registrationState.token
1388
1475
  };
1389
1476
  }
@@ -1392,6 +1479,73 @@ async function getFCMToken(options) {
1392
1479
  return tokenResult.token;
1393
1480
  }
1394
1481
 
1482
+ // src/runtime/device-attributes.ts
1483
+ var Application = __toESM(require("expo-application"));
1484
+ var Device = __toESM(require("expo-device"));
1485
+ function compactAttributes(attributes) {
1486
+ return Object.fromEntries(
1487
+ Object.entries(attributes).filter((entry) => entry[1] !== null && entry[1] !== void 0)
1488
+ );
1489
+ }
1490
+ function addSection(attributes, name, section) {
1491
+ const compactedSection = compactAttributes(section);
1492
+ if (Object.keys(compactedSection).length > 0) {
1493
+ attributes[name] = compactedSection;
1494
+ }
1495
+ }
1496
+ function getDeviceTypeName(deviceType2) {
1497
+ switch (deviceType2) {
1498
+ case Device.DeviceType.PHONE:
1499
+ return "phone";
1500
+ case Device.DeviceType.TABLET:
1501
+ return "tablet";
1502
+ case Device.DeviceType.DESKTOP:
1503
+ return "desktop";
1504
+ case Device.DeviceType.TV:
1505
+ return "tv";
1506
+ case Device.DeviceType.UNKNOWN:
1507
+ return "unknown";
1508
+ default:
1509
+ return void 0;
1510
+ }
1511
+ }
1512
+ async function getDeviceType() {
1513
+ if (Device.deviceType !== null && Device.deviceType !== void 0) {
1514
+ return Device.deviceType;
1515
+ }
1516
+ try {
1517
+ return await Device.getDeviceTypeAsync();
1518
+ } catch {
1519
+ return null;
1520
+ }
1521
+ }
1522
+ async function collectBubblesDeviceAttributes() {
1523
+ const attributes = {};
1524
+ const deviceType2 = await getDeviceType();
1525
+ addSection(attributes, "device", {
1526
+ manufacturer: Device.manufacturer,
1527
+ brand: Device.brand,
1528
+ modelName: Device.modelName,
1529
+ modelId: typeof Device.modelId === "string" ? Device.modelId : void 0,
1530
+ deviceName: Device.deviceName,
1531
+ deviceType: getDeviceTypeName(deviceType2),
1532
+ totalMemory: Device.totalMemory
1533
+ });
1534
+ addSection(attributes, "os", {
1535
+ name: Device.osName,
1536
+ version: Device.osVersion,
1537
+ buildId: Device.osBuildId,
1538
+ platformApiLevel: Device.platformApiLevel
1539
+ });
1540
+ addSection(attributes, "app", {
1541
+ applicationId: Application.applicationId,
1542
+ applicationName: Application.applicationName,
1543
+ nativeApplicationVersion: Application.nativeApplicationVersion,
1544
+ nativeBuildVersion: Application.nativeBuildVersion
1545
+ });
1546
+ return attributes;
1547
+ }
1548
+
1395
1549
  // src/runtime/BubblesNotificationsProvider.tsx
1396
1550
  var import_react3 = require("react");
1397
1551
 
@@ -1530,7 +1684,7 @@ async function prepareBubblesNotificationPresentation(options) {
1530
1684
  }
1531
1685
 
1532
1686
  // src/runtime/foreground-message-observer.ts
1533
- var import_messaging3 = require("@react-native-firebase/messaging");
1687
+ var import_messaging2 = require("@react-native-firebase/messaging");
1534
1688
  var import_react_native5 = require("react-native");
1535
1689
  function supportsFirebaseForegroundMessages() {
1536
1690
  return import_react_native5.Platform.OS === "android" || import_react_native5.Platform.OS === "ios";
@@ -1550,8 +1704,8 @@ function observeBubblesForegroundMessages() {
1550
1704
  return () => {
1551
1705
  };
1552
1706
  }
1553
- const unsubscribe = (0, import_messaging3.onMessage)(
1554
- (0, import_messaging3.getMessaging)(),
1707
+ const unsubscribe = (0, import_messaging2.onMessage)(
1708
+ (0, import_messaging2.getMessaging)(),
1555
1709
  (remoteMessage) => {
1556
1710
  void handleFirebaseForegroundMessage(remoteMessage).catch(
1557
1711
  (error) => {
@@ -1568,25 +1722,6 @@ function observeBubblesForegroundMessages() {
1568
1722
  };
1569
1723
  }
1570
1724
 
1571
- // src/runtime/installations.ts
1572
- var import_installations = require("@react-native-firebase/installations");
1573
- async function getFirebaseInstallationId(platform) {
1574
- try {
1575
- const installationsInstance = (0, import_installations.getInstallations)();
1576
- const installationId = await (0, import_installations.getId)(installationsInstance);
1577
- return getRequiredNonEmptyStringFromSource(
1578
- installationId,
1579
- `${platform} Firebase installation id from Firebase Installations`
1580
- );
1581
- } catch (error) {
1582
- const reason = getErrorMessage(error);
1583
- const platformSpecificSetupHint = platform === "ios" ? " On iOS, ensure `expo.ios.googleServicesFile` is configured and the native app has been rebuilt." : " On Android, ensure `expo.android.googleServicesFile` is configured and the native app has been rebuilt.";
1584
- failWithBubblesError(
1585
- `Failed to get a ${platform} Firebase installation id from Firebase Installations. Ensure "@react-native-firebase/app" and "@react-native-firebase/installations" are installed and Firebase is configured for this platform.${platformSpecificSetupHint} Original error: ${reason}`
1586
- );
1587
- }
1588
- }
1589
-
1590
1725
  // src/runtime/transport.ts
1591
1726
  function observeBubblesForegroundRemoteMessages() {
1592
1727
  return observeBubblesForegroundMessages();
@@ -1594,9 +1729,6 @@ function observeBubblesForegroundRemoteMessages() {
1594
1729
  async function getBubblesDeviceRegistrationState(options) {
1595
1730
  return getDeviceRegistrationState(options);
1596
1731
  }
1597
- async function getBubblesInstallationId(platform) {
1598
- return getFirebaseInstallationId(platform);
1599
- }
1600
1732
 
1601
1733
  // src/runtime/sync-device.ts
1602
1734
  var BubblesNotificationsSyncError = class extends Error {
@@ -1635,6 +1767,20 @@ function buildRuntimeSnapshot(deviceId, registrationState) {
1635
1767
  notificationsEnabled: registrationState.notificationsEnabled
1636
1768
  };
1637
1769
  }
1770
+ async function syncBubblesDeviceAttributesBestEffort(apiBaseUrl, appKey, deviceId) {
1771
+ try {
1772
+ const attributes = await collectBubblesDeviceAttributes();
1773
+ if (Object.keys(attributes).length > 0) {
1774
+ await updateBubblesDeviceAttributes({
1775
+ apiBaseUrl,
1776
+ appKey,
1777
+ deviceId,
1778
+ attributes
1779
+ });
1780
+ }
1781
+ } catch {
1782
+ }
1783
+ }
1638
1784
  async function syncDeviceRegistrationState(options) {
1639
1785
  const apiBaseUrl = getRequiredNonEmptyString3(options.apiBaseUrl, "apiBaseUrl");
1640
1786
  const appKey = getRequiredNonEmptyString3(options.appKey, "appKey");
@@ -1645,7 +1791,7 @@ async function syncDeviceRegistrationState(options) {
1645
1791
  options.registrationState
1646
1792
  );
1647
1793
  try {
1648
- const installationId = options.registrationState.notificationsEnabled ? await getBubblesInstallationId(options.registrationState.platform) : null;
1794
+ const installationId = options.registrationState.notificationsEnabled ? options.registrationState.token : null;
1649
1795
  const syncResult = await syncBubblesDevice({
1650
1796
  apiBaseUrl,
1651
1797
  appId: options.appId,
@@ -1661,6 +1807,13 @@ async function syncDeviceRegistrationState(options) {
1661
1807
  });
1662
1808
  const nextDeviceId = syncResult.deviceId ?? options.deviceId;
1663
1809
  storeDeviceId(nextDeviceId);
1810
+ if (nextDeviceId) {
1811
+ await syncBubblesDeviceAttributesBestEffort(
1812
+ apiBaseUrl,
1813
+ appKey,
1814
+ nextDeviceId
1815
+ );
1816
+ }
1664
1817
  return {
1665
1818
  ...snapshot,
1666
1819
  action: syncResult.action,
@@ -1848,6 +2001,7 @@ function BubblesNotificationsProvider(props) {
1848
2001
  const [state, setState] = (0, import_react3.useState)(getInitialRuntimeState);
1849
2002
  const { enqueueOperation, isMountedRef } = useBubblesRuntimeOperationQueue(setState);
1850
2003
  const lastAutomaticSyncSignatureRef = (0, import_react3.useRef)(null);
2004
+ const currentDeviceIdRef = (0, import_react3.useRef)(state.deviceId);
1851
2005
  const previousUserIdRef = (0, import_react3.useRef)(normalizedUserId);
1852
2006
  const buildAutomaticSyncSignature = (0, import_react3.useCallback)((deviceId) => {
1853
2007
  return getAutomaticSyncSignature({
@@ -1873,6 +2027,9 @@ function BubblesNotificationsProvider(props) {
1873
2027
  clearBubblesForegroundPresentation();
1874
2028
  };
1875
2029
  }, [isMountedRef]);
2030
+ (0, import_react3.useEffect)(() => {
2031
+ currentDeviceIdRef.current = state.deviceId;
2032
+ }, [state.deviceId]);
1876
2033
  (0, import_react3.useEffect)(() => {
1877
2034
  let isCancelled = false;
1878
2035
  void (async () => {
@@ -1940,6 +2097,7 @@ function BubblesNotificationsProvider(props) {
1940
2097
  appVersion,
1941
2098
  deviceId: storedDeviceId
1942
2099
  });
2100
+ currentDeviceIdRef.current = result.deviceId;
1943
2101
  return getRuntimeStateUpdate(result);
1944
2102
  });
1945
2103
  }, [
@@ -1973,9 +2131,10 @@ function BubblesNotificationsProvider(props) {
1973
2131
  userId: normalizedUserId,
1974
2132
  aliasing,
1975
2133
  appVersion,
1976
- deviceId: state.deviceId,
2134
+ deviceId: currentDeviceIdRef.current,
1977
2135
  registrationOptions: options
1978
2136
  });
2137
+ currentDeviceIdRef.current = result.deviceId;
1979
2138
  if (result.deviceId !== null) {
1980
2139
  lastAutomaticSyncSignatureRef.current = buildAutomaticSyncSignature(
1981
2140
  result.deviceId
@@ -1984,8 +2143,33 @@ function BubblesNotificationsProvider(props) {
1984
2143
  return getRuntimeStateUpdate(result);
1985
2144
  });
1986
2145
  }
2146
+ function addDeviceAttribute(name, value) {
2147
+ return enqueueOperation(async () => {
2148
+ const attributeName = getRequiredNonEmptyString(name, "attributeName");
2149
+ if (value === void 0) {
2150
+ failWithBubblesError(
2151
+ '"addDeviceAttribute()" requires "value" to be a JSON-compatible value.'
2152
+ );
2153
+ }
2154
+ const deviceId = currentDeviceIdRef.current;
2155
+ if (deviceId === null) {
2156
+ failWithBubblesError(
2157
+ '"addDeviceAttribute()" requires a backend "deviceId" before attributes can be synced.'
2158
+ );
2159
+ }
2160
+ await updateBubblesDeviceAttributes({
2161
+ apiBaseUrl,
2162
+ appKey,
2163
+ deviceId,
2164
+ attributes: {
2165
+ [attributeName]: value
2166
+ }
2167
+ });
2168
+ });
2169
+ }
1987
2170
  const contextValue = {
1988
2171
  registerDevice,
2172
+ addDeviceAttribute,
1989
2173
  deviceId: state.deviceId,
1990
2174
  pushToken: state.pushToken,
1991
2175
  tokenType: state.tokenType,
@@ -2022,6 +2206,7 @@ function useBubblesNotifications() {
2022
2206
  buildCreateDevicePayload,
2023
2207
  buildStoredDeliveryStatusEventKey,
2024
2208
  buildUpdateDevicePayload,
2209
+ collectBubblesDeviceAttributes,
2025
2210
  createBubblesDevice,
2026
2211
  createBubblesDeviceClient,
2027
2212
  createStoredDeliveryStatusEvent,
@@ -2056,5 +2241,6 @@ function useBubblesNotifications() {
2056
2241
  storeDeviceState,
2057
2242
  syncBubblesDevice,
2058
2243
  updateBubblesDevice,
2244
+ updateBubblesDeviceAttributes,
2059
2245
  useBubblesNotifications
2060
2246
  });
package/dist/index.mjs CHANGED
@@ -47,36 +47,33 @@ import {
47
47
  storeDeviceId,
48
48
  storeDeviceState,
49
49
  syncBubblesDevice,
50
- updateBubblesDevice
51
- } from "./chunk-6NPXFGQX.mjs";
50
+ updateBubblesDevice,
51
+ updateBubblesDeviceAttributes
52
+ } from "./chunk-3BAGWXHY.mjs";
52
53
 
53
54
  // src/runtime/tokens.ts
54
- import {
55
- getMessaging,
56
- getToken,
57
- isDeviceRegisteredForRemoteMessages,
58
- registerDeviceForRemoteMessages
59
- } from "@react-native-firebase/messaging";
60
55
  import { Platform } from "react-native";
61
- async function getFirebaseMessagingToken(platform) {
56
+
57
+ // src/runtime/installations.ts
58
+ import { getId, getInstallations } from "@react-native-firebase/installations";
59
+ async function getFirebaseInstallationId(platform) {
62
60
  try {
63
- const messagingInstance = getMessaging();
64
- if (!isDeviceRegisteredForRemoteMessages(messagingInstance)) {
65
- await registerDeviceForRemoteMessages(messagingInstance);
66
- }
67
- const token = await getToken(messagingInstance);
61
+ const installationsInstance = getInstallations();
62
+ const installationId = await getId(installationsInstance);
68
63
  return getRequiredNonEmptyStringFromSource(
69
- token,
70
- `${platform} FCM token from Firebase Messaging`
64
+ installationId,
65
+ `${platform} Firebase installation id from Firebase Installations`
71
66
  );
72
67
  } catch (error) {
73
68
  const reason = getErrorMessage(error);
74
- const platformSpecificSetupHint = platform === "ios" ? " On iOS, also ensure Firebase APNs setup is complete and `expo.ios.googleServicesFile` is configured." : " On Android, ensure `google-services.json` is configured and the native app has been rebuilt.";
69
+ const platformSpecificSetupHint = platform === "ios" ? " On iOS, ensure `expo.ios.googleServicesFile` is configured and the native app has been rebuilt." : " On Android, ensure `expo.android.googleServicesFile` is configured and the native app has been rebuilt.";
75
70
  failWithBubblesError(
76
- `Failed to get a ${platform} FCM token from Firebase Messaging. Ensure "@react-native-firebase/app" and "@react-native-firebase/messaging" are installed, the Expo app includes the "@react-native-firebase/messaging" config plugin, and Firebase is configured for this platform.${platformSpecificSetupHint} Original error: ${reason}`
71
+ `Failed to get a ${platform} Firebase installation id from Firebase Installations. Ensure "@react-native-firebase/app" and "@react-native-firebase/installations" are installed and Firebase is configured for this platform.${platformSpecificSetupHint} Original error: ${reason}`
77
72
  );
78
73
  }
79
74
  }
75
+
76
+ // src/runtime/tokens.ts
80
77
  async function getDeviceRegistrationState(options) {
81
78
  const platform = getSupportedPlatform(Platform.OS);
82
79
  const permissions = await getNotificationPermissions(options);
@@ -91,10 +88,10 @@ async function getDeviceRegistrationState(options) {
91
88
  notificationsEnabled: false
92
89
  };
93
90
  }
94
- const token = await getFirebaseMessagingToken(platform);
91
+ const token = await getFirebaseInstallationId(platform);
95
92
  return {
96
93
  platform,
97
- tokenType: "fcm",
94
+ tokenType: "fid",
98
95
  token,
99
96
  permissionStatus,
100
97
  notificationsEnabled: true
@@ -107,7 +104,7 @@ async function getDeviceToken(options) {
107
104
  }
108
105
  return {
109
106
  platform: registrationState.platform,
110
- tokenType: "fcm",
107
+ tokenType: "fid",
111
108
  token: registrationState.token
112
109
  };
113
110
  }
@@ -116,6 +113,73 @@ async function getFCMToken(options) {
116
113
  return tokenResult.token;
117
114
  }
118
115
 
116
+ // src/runtime/device-attributes.ts
117
+ import * as Application from "expo-application";
118
+ import * as Device from "expo-device";
119
+ function compactAttributes(attributes) {
120
+ return Object.fromEntries(
121
+ Object.entries(attributes).filter((entry) => entry[1] !== null && entry[1] !== void 0)
122
+ );
123
+ }
124
+ function addSection(attributes, name, section) {
125
+ const compactedSection = compactAttributes(section);
126
+ if (Object.keys(compactedSection).length > 0) {
127
+ attributes[name] = compactedSection;
128
+ }
129
+ }
130
+ function getDeviceTypeName(deviceType2) {
131
+ switch (deviceType2) {
132
+ case Device.DeviceType.PHONE:
133
+ return "phone";
134
+ case Device.DeviceType.TABLET:
135
+ return "tablet";
136
+ case Device.DeviceType.DESKTOP:
137
+ return "desktop";
138
+ case Device.DeviceType.TV:
139
+ return "tv";
140
+ case Device.DeviceType.UNKNOWN:
141
+ return "unknown";
142
+ default:
143
+ return void 0;
144
+ }
145
+ }
146
+ async function getDeviceType() {
147
+ if (Device.deviceType !== null && Device.deviceType !== void 0) {
148
+ return Device.deviceType;
149
+ }
150
+ try {
151
+ return await Device.getDeviceTypeAsync();
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+ async function collectBubblesDeviceAttributes() {
157
+ const attributes = {};
158
+ const deviceType2 = await getDeviceType();
159
+ addSection(attributes, "device", {
160
+ manufacturer: Device.manufacturer,
161
+ brand: Device.brand,
162
+ modelName: Device.modelName,
163
+ modelId: typeof Device.modelId === "string" ? Device.modelId : void 0,
164
+ deviceName: Device.deviceName,
165
+ deviceType: getDeviceTypeName(deviceType2),
166
+ totalMemory: Device.totalMemory
167
+ });
168
+ addSection(attributes, "os", {
169
+ name: Device.osName,
170
+ version: Device.osVersion,
171
+ buildId: Device.osBuildId,
172
+ platformApiLevel: Device.platformApiLevel
173
+ });
174
+ addSection(attributes, "app", {
175
+ applicationId: Application.applicationId,
176
+ applicationName: Application.applicationName,
177
+ nativeApplicationVersion: Application.nativeApplicationVersion,
178
+ nativeBuildVersion: Application.nativeBuildVersion
179
+ });
180
+ return attributes;
181
+ }
182
+
119
183
  // src/runtime/BubblesNotificationsProvider.tsx
120
184
  import { useCallback as useCallback2, useEffect, useRef as useRef2, useState } from "react";
121
185
 
@@ -255,7 +319,7 @@ async function prepareBubblesNotificationPresentation(options) {
255
319
 
256
320
  // src/runtime/foreground-message-observer.ts
257
321
  import {
258
- getMessaging as getMessaging2,
322
+ getMessaging,
259
323
  onMessage
260
324
  } from "@react-native-firebase/messaging";
261
325
  import { Platform as Platform2 } from "react-native";
@@ -278,7 +342,7 @@ function observeBubblesForegroundMessages() {
278
342
  };
279
343
  }
280
344
  const unsubscribe = onMessage(
281
- getMessaging2(),
345
+ getMessaging(),
282
346
  (remoteMessage) => {
283
347
  void handleFirebaseForegroundMessage(remoteMessage).catch(
284
348
  (error) => {
@@ -295,25 +359,6 @@ function observeBubblesForegroundMessages() {
295
359
  };
296
360
  }
297
361
 
298
- // src/runtime/installations.ts
299
- import { getId, getInstallations } from "@react-native-firebase/installations";
300
- async function getFirebaseInstallationId(platform) {
301
- try {
302
- const installationsInstance = getInstallations();
303
- const installationId = await getId(installationsInstance);
304
- return getRequiredNonEmptyStringFromSource(
305
- installationId,
306
- `${platform} Firebase installation id from Firebase Installations`
307
- );
308
- } catch (error) {
309
- const reason = getErrorMessage(error);
310
- const platformSpecificSetupHint = platform === "ios" ? " On iOS, ensure `expo.ios.googleServicesFile` is configured and the native app has been rebuilt." : " On Android, ensure `expo.android.googleServicesFile` is configured and the native app has been rebuilt.";
311
- failWithBubblesError(
312
- `Failed to get a ${platform} Firebase installation id from Firebase Installations. Ensure "@react-native-firebase/app" and "@react-native-firebase/installations" are installed and Firebase is configured for this platform.${platformSpecificSetupHint} Original error: ${reason}`
313
- );
314
- }
315
- }
316
-
317
362
  // src/runtime/transport.ts
318
363
  function observeBubblesForegroundRemoteMessages() {
319
364
  return observeBubblesForegroundMessages();
@@ -321,9 +366,6 @@ function observeBubblesForegroundRemoteMessages() {
321
366
  async function getBubblesDeviceRegistrationState(options) {
322
367
  return getDeviceRegistrationState(options);
323
368
  }
324
- async function getBubblesInstallationId(platform) {
325
- return getFirebaseInstallationId(platform);
326
- }
327
369
 
328
370
  // src/runtime/sync-device.ts
329
371
  var BubblesNotificationsSyncError = class extends Error {
@@ -362,6 +404,20 @@ function buildRuntimeSnapshot(deviceId, registrationState) {
362
404
  notificationsEnabled: registrationState.notificationsEnabled
363
405
  };
364
406
  }
407
+ async function syncBubblesDeviceAttributesBestEffort(apiBaseUrl, appKey, deviceId) {
408
+ try {
409
+ const attributes = await collectBubblesDeviceAttributes();
410
+ if (Object.keys(attributes).length > 0) {
411
+ await updateBubblesDeviceAttributes({
412
+ apiBaseUrl,
413
+ appKey,
414
+ deviceId,
415
+ attributes
416
+ });
417
+ }
418
+ } catch {
419
+ }
420
+ }
365
421
  async function syncDeviceRegistrationState(options) {
366
422
  const apiBaseUrl = getRequiredNonEmptyString2(options.apiBaseUrl, "apiBaseUrl");
367
423
  const appKey = getRequiredNonEmptyString2(options.appKey, "appKey");
@@ -372,7 +428,7 @@ async function syncDeviceRegistrationState(options) {
372
428
  options.registrationState
373
429
  );
374
430
  try {
375
- const installationId = options.registrationState.notificationsEnabled ? await getBubblesInstallationId(options.registrationState.platform) : null;
431
+ const installationId = options.registrationState.notificationsEnabled ? options.registrationState.token : null;
376
432
  const syncResult = await syncBubblesDevice({
377
433
  apiBaseUrl,
378
434
  appId: options.appId,
@@ -388,6 +444,13 @@ async function syncDeviceRegistrationState(options) {
388
444
  });
389
445
  const nextDeviceId = syncResult.deviceId ?? options.deviceId;
390
446
  storeDeviceId(nextDeviceId);
447
+ if (nextDeviceId) {
448
+ await syncBubblesDeviceAttributesBestEffort(
449
+ apiBaseUrl,
450
+ appKey,
451
+ nextDeviceId
452
+ );
453
+ }
391
454
  return {
392
455
  ...snapshot,
393
456
  action: syncResult.action,
@@ -578,6 +641,7 @@ function BubblesNotificationsProvider(props) {
578
641
  const [state, setState] = useState(getInitialRuntimeState);
579
642
  const { enqueueOperation, isMountedRef } = useBubblesRuntimeOperationQueue(setState);
580
643
  const lastAutomaticSyncSignatureRef = useRef2(null);
644
+ const currentDeviceIdRef = useRef2(state.deviceId);
581
645
  const previousUserIdRef = useRef2(normalizedUserId);
582
646
  const buildAutomaticSyncSignature = useCallback2((deviceId) => {
583
647
  return getAutomaticSyncSignature({
@@ -603,6 +667,9 @@ function BubblesNotificationsProvider(props) {
603
667
  clearBubblesForegroundPresentation();
604
668
  };
605
669
  }, [isMountedRef]);
670
+ useEffect(() => {
671
+ currentDeviceIdRef.current = state.deviceId;
672
+ }, [state.deviceId]);
606
673
  useEffect(() => {
607
674
  let isCancelled = false;
608
675
  void (async () => {
@@ -670,6 +737,7 @@ function BubblesNotificationsProvider(props) {
670
737
  appVersion,
671
738
  deviceId: storedDeviceId
672
739
  });
740
+ currentDeviceIdRef.current = result.deviceId;
673
741
  return getRuntimeStateUpdate(result);
674
742
  });
675
743
  }, [
@@ -703,9 +771,10 @@ function BubblesNotificationsProvider(props) {
703
771
  userId: normalizedUserId,
704
772
  aliasing,
705
773
  appVersion,
706
- deviceId: state.deviceId,
774
+ deviceId: currentDeviceIdRef.current,
707
775
  registrationOptions: options
708
776
  });
777
+ currentDeviceIdRef.current = result.deviceId;
709
778
  if (result.deviceId !== null) {
710
779
  lastAutomaticSyncSignatureRef.current = buildAutomaticSyncSignature(
711
780
  result.deviceId
@@ -714,8 +783,33 @@ function BubblesNotificationsProvider(props) {
714
783
  return getRuntimeStateUpdate(result);
715
784
  });
716
785
  }
786
+ function addDeviceAttribute(name, value) {
787
+ return enqueueOperation(async () => {
788
+ const attributeName = getRequiredNonEmptyString(name, "attributeName");
789
+ if (value === void 0) {
790
+ failWithBubblesError(
791
+ '"addDeviceAttribute()" requires "value" to be a JSON-compatible value.'
792
+ );
793
+ }
794
+ const deviceId = currentDeviceIdRef.current;
795
+ if (deviceId === null) {
796
+ failWithBubblesError(
797
+ '"addDeviceAttribute()" requires a backend "deviceId" before attributes can be synced.'
798
+ );
799
+ }
800
+ await updateBubblesDeviceAttributes({
801
+ apiBaseUrl,
802
+ appKey,
803
+ deviceId,
804
+ attributes: {
805
+ [attributeName]: value
806
+ }
807
+ });
808
+ });
809
+ }
717
810
  const contextValue = {
718
811
  registerDevice,
812
+ addDeviceAttribute,
719
813
  deviceId: state.deviceId,
720
814
  pushToken: state.pushToken,
721
815
  tokenType: state.tokenType,
@@ -751,6 +845,7 @@ export {
751
845
  buildCreateDevicePayload,
752
846
  buildStoredDeliveryStatusEventKey,
753
847
  buildUpdateDevicePayload,
848
+ collectBubblesDeviceAttributes,
754
849
  createBubblesDevice,
755
850
  createBubblesDeviceClient,
756
851
  createStoredDeliveryStatusEvent,
@@ -785,5 +880,6 @@ export {
785
880
  storeDeviceState,
786
881
  syncBubblesDevice,
787
882
  updateBubblesDevice,
883
+ updateBubblesDeviceAttributes,
788
884
  useBubblesNotifications
789
885
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  registerBubblesBackgroundHandlers
3
- } from "./chunk-6NPXFGQX.mjs";
3
+ } from "./chunk-3BAGWXHY.mjs";
4
4
 
5
5
  // src/register-task.ts
6
6
  registerBubblesBackgroundHandlers();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fishonfire/bubbles-expo",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Expo config plugin, explicit background registration API, and React provider for Bubbles notifications.",
5
5
  "license": "MIT",
6
6
  "main": "./dist/index.js",
@@ -68,8 +68,10 @@
68
68
  }
69
69
  },
70
70
  "dependencies": {
71
+ "expo-application": "~57.0.2",
71
72
  "@fishonfire/bubbles-js": "0.1.0",
72
73
  "expo-constants": "~57.0.16",
74
+ "expo-device": "~57.0.1",
73
75
  "expo-file-system": "~57.0.6"
74
76
  },
75
77
  "devDependencies": {
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const config_1 = require("./config");
7
7
  const with_firebase_messaging_manifest_1 = __importDefault(require("./with-firebase-messaging-manifest"));
8
+ const with_firebase_messaging_registration_1 = __importDefault(require("./with-firebase-messaging-registration"));
8
9
  const with_expo_notifications_1 = __importDefault(require("./with-expo-notifications"));
9
10
  const with_runtime_defaults_1 = __importDefault(require("./with-runtime-defaults"));
10
11
  const with_rnfirebase_disable_spm_1 = __importDefault(require("./with-rnfirebase-disable-spm"));
@@ -13,6 +14,7 @@ const withBubblesNotificationsExpo = (config, input) => {
13
14
  config = (0, with_runtime_defaults_1.default)(config, options);
14
15
  config = (0, with_expo_notifications_1.default)(config, options);
15
16
  config = (0, with_firebase_messaging_manifest_1.default)(config, options);
17
+ config = (0, with_firebase_messaging_registration_1.default)(config, options);
16
18
  config = (0, with_rnfirebase_disable_spm_1.default)(config);
17
19
  return config;
18
20
  };
@@ -3,6 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const config_plugins_1 = require("expo/config-plugins");
4
4
  const FCM_DEFAULT_CHANNEL = 'com.google.firebase.messaging.default_notification_channel_id';
5
5
  const FCM_DEFAULT_COLOR = 'com.google.firebase.messaging.default_notification_color';
6
+ const FCM_INSTALLATION_ID_ENABLED = 'firebase_messaging_installation_id_enabled';
7
+ const RN_FIREBASE_MESSAGING_SERVICE = 'io.invertase.firebase.messaging.ReactNativeFirebaseMessagingService';
8
+ const BUBBLES_FIREBASE_MESSAGING_SERVICE = '.BubblesFirebaseMessagingService';
9
+ const BUBBLES_FIREBASE_MESSAGING_REGISTRAR = '.BubblesFirebaseMessagingRegistrar';
6
10
  const withFirebaseMessagingManifest = (config, options) => {
7
11
  return (0, config_plugins_1.withAndroidManifest)(config, config => {
8
12
  const manifest = config.modResults;
@@ -17,6 +21,8 @@ const withFirebaseMessagingManifest = (config, options) => {
17
21
  if (channel) {
18
22
  setToolsReplace(channel.$, 'android:value');
19
23
  }
24
+ config_plugins_1.AndroidConfig.Manifest.addMetaDataItemToMainApplication(application, FCM_INSTALLATION_ID_ENABLED, 'true', 'value');
25
+ addBubblesFirebaseMessagingRegistrationEntries(application, getAndroidPackageName(config));
20
26
  // Expo only creates the FCM color metadata when a notification
21
27
  // color has actually been configured.
22
28
  if (options.androidNotificationColor) {
@@ -29,6 +35,36 @@ const withFirebaseMessagingManifest = (config, options) => {
29
35
  return config;
30
36
  });
31
37
  };
38
+ function getAndroidPackageName(config) {
39
+ const packageName = config.android?.package?.trim();
40
+ if (!packageName) {
41
+ throw new Error('[@fishonfire/bubbles-expo] "expo.android.package" is required to configure Android Firebase Messaging registration.');
42
+ }
43
+ return packageName;
44
+ }
45
+ function addBubblesFirebaseMessagingRegistrationEntries(application, packageName) {
46
+ application.service = application.service ?? [];
47
+ application.provider = application.provider ?? [];
48
+ removeManifestService(application, RN_FIREBASE_MESSAGING_SERVICE);
49
+ removeManifestService(application, BUBBLES_FIREBASE_MESSAGING_SERVICE);
50
+ upsertManifestProvider(application, {
51
+ $: {
52
+ 'android:name': BUBBLES_FIREBASE_MESSAGING_REGISTRAR,
53
+ 'android:authorities': `${packageName}.bubblesnotifications.fcmregistrar`,
54
+ 'android:exported': 'false',
55
+ 'android:initOrder': '100',
56
+ },
57
+ });
58
+ }
59
+ function removeManifestService(application, serviceName) {
60
+ application.service = (application.service ?? []).filter(item => item.$['android:name'] !== serviceName);
61
+ }
62
+ function upsertManifestProvider(application, provider) {
63
+ application.provider = [
64
+ ...(application.provider ?? []).filter(item => item.$['android:name'] !== provider.$['android:name']),
65
+ provider,
66
+ ];
67
+ }
32
68
  function setToolsReplace(attributes, value) {
33
69
  attributes['tools:replace'] = value;
34
70
  }
@@ -0,0 +1,4 @@
1
+ import { ConfigPlugin } from 'expo/config-plugins';
2
+ import type { NormalizedBubblesNotificationsExpoPluginConfig } from './config';
3
+ declare const withFirebaseMessagingRegistration: ConfigPlugin<NormalizedBubblesNotificationsExpoPluginConfig>;
4
+ export default withFirebaseMessagingRegistration;
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const promises_1 = __importDefault(require("node:fs/promises"));
7
+ const node_path_1 = __importDefault(require("node:path"));
8
+ const config_plugins_1 = require("expo/config-plugins");
9
+ const REGISTRAR_CLASS = 'BubblesFirebaseMessagingRegistrar';
10
+ const withFirebaseMessagingRegistration = config => {
11
+ return (0, config_plugins_1.withDangerousMod)(config, [
12
+ 'android',
13
+ async (config) => {
14
+ const packageName = getAndroidPackageName(config);
15
+ const sourceDirectory = node_path_1.default.join(config.modRequest.projectRoot, 'android', 'app', 'src', 'main', 'java', ...packageName.split('.'));
16
+ await promises_1.default.mkdir(sourceDirectory, { recursive: true });
17
+ await promises_1.default.writeFile(node_path_1.default.join(sourceDirectory, `${REGISTRAR_CLASS}.java`), buildFirebaseMessagingRegistrarSource(packageName));
18
+ return config;
19
+ },
20
+ ]);
21
+ };
22
+ function getAndroidPackageName(config) {
23
+ const packageName = config.android?.package?.trim();
24
+ if (!packageName) {
25
+ throw new Error('[@fishonfire/bubbles-expo] "expo.android.package" is required to configure Android Firebase Messaging registration.');
26
+ }
27
+ return packageName;
28
+ }
29
+ function buildFirebaseMessagingRegistrarSource(packageName) {
30
+ return `package ${packageName};
31
+
32
+ import android.content.ContentProvider;
33
+ import android.content.ContentValues;
34
+ import android.database.Cursor;
35
+ import android.net.Uri;
36
+ import android.util.Log;
37
+
38
+ import androidx.annotation.NonNull;
39
+ import androidx.annotation.Nullable;
40
+
41
+ public final class ${REGISTRAR_CLASS} extends ContentProvider {
42
+ private static final String TAG = "BubblesFCMRegistrar";
43
+
44
+ @Override
45
+ public boolean onCreate() {
46
+ try {
47
+ Class<?> firebaseAppClass = Class.forName("com.google.firebase.FirebaseApp");
48
+ Class<?> firebaseMessagingClass = Class.forName("com.google.firebase.messaging.FirebaseMessaging");
49
+
50
+ if (getContext() != null) {
51
+ Object apps = firebaseAppClass
52
+ .getMethod("getApps", android.content.Context.class)
53
+ .invoke(null, getContext());
54
+
55
+ if (apps instanceof java.util.List && ((java.util.List<?>) apps).isEmpty()) {
56
+ firebaseAppClass
57
+ .getMethod("initializeApp", android.content.Context.class)
58
+ .invoke(null, getContext());
59
+ }
60
+ }
61
+
62
+ Object messaging = firebaseMessagingClass
63
+ .getMethod("getInstance")
64
+ .invoke(null);
65
+
66
+ firebaseMessagingClass
67
+ .getMethod("register")
68
+ .invoke(messaging);
69
+ } catch (ReflectiveOperationException | RuntimeException error) {
70
+ Log.w(TAG, "Failed to start Firebase Messaging registration.", error);
71
+ }
72
+
73
+ return false;
74
+ }
75
+
76
+ @Nullable
77
+ @Override
78
+ public Cursor query(
79
+ @NonNull Uri uri,
80
+ @Nullable String[] projection,
81
+ @Nullable String selection,
82
+ @Nullable String[] selectionArgs,
83
+ @Nullable String sortOrder) {
84
+ return null;
85
+ }
86
+
87
+ @Nullable
88
+ @Override
89
+ public String getType(@NonNull Uri uri) {
90
+ return null;
91
+ }
92
+
93
+ @Nullable
94
+ @Override
95
+ public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
96
+ return null;
97
+ }
98
+
99
+ @Override
100
+ public int delete(
101
+ @NonNull Uri uri,
102
+ @Nullable String selection,
103
+ @Nullable String[] selectionArgs) {
104
+ return 0;
105
+ }
106
+
107
+ @Override
108
+ public int update(
109
+ @NonNull Uri uri,
110
+ @Nullable ContentValues values,
111
+ @Nullable String selection,
112
+ @Nullable String[] selectionArgs) {
113
+ return 0;
114
+ }
115
+ }
116
+ `;
117
+ }
118
+ exports.default = withFirebaseMessagingRegistration;