@sigx/lynx-notifications 0.4.0 → 0.4.2

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
@@ -1,6 +1,11 @@
1
1
  # @sigx/lynx-notifications
2
2
 
3
- Local push notifications for sigx-lynx. `UNUserNotificationCenter` on iOS, `NotificationManager` + `AlarmManager` on Android. **Local-only** — push notifications via APNs/FCM are not in this module.
3
+ Local **and remote** push notifications for sigx-lynx.
4
+
5
+ - iOS: `UNUserNotificationCenter` for scheduling and the foreground/tap delegate, APNs for remote push.
6
+ - Android: `NotificationManager` + `AlarmManager` for scheduling, Firebase Cloud Messaging for remote push.
7
+
8
+ Transport-agnostic on the server side: you pair this with any APNs/FCM-fronting service — Azure Notification Hubs, Firebase Admin, OneSignal, Braze, AWS SNS, or direct APNs/FCM — by forwarding the device token to your backend.
4
9
 
5
10
  ## Install
6
11
 
@@ -8,12 +13,37 @@ Local push notifications for sigx-lynx. `UNUserNotificationCenter` on iOS, `Noti
8
13
  pnpm add @sigx/lynx-notifications
9
14
  ```
10
15
 
11
- `sigx prebuild` auto-discovers the package, links the native module, and adds `android.permission.POST_NOTIFICATIONS` (Android 13+). iOS notification permission is requested at runtime via `requestPermission()`.
16
+ `sigx prebuild` auto-discovers the package, links the native module, adds `android.permission.POST_NOTIFICATIONS` (Android 13+), registers the FCM service in `AndroidManifest.xml`, adds `UIBackgroundModes: remote-notification` to iOS `Info.plist`, and wires the APNs callbacks through the generated `AppDelegate` dispatcher.
12
17
 
13
18
  > **Android pairs with `@sigx/lynx-permissions`** — needed for the runtime permission prompt on Android 13+.
14
19
 
20
+ ### One-time manual setup for remote push
21
+
22
+ Two things are **not** handled by `sigx prebuild` and must be configured per-app:
23
+
24
+ **iOS — APNs entitlement.** Push won't work without the `aps-environment` entitlement plus a paid Apple Developer account with the Push Notifications capability enabled for your bundle id.
25
+
26
+ 1. In Xcode → Signing & Capabilities → `+ Capability` → Push Notifications.
27
+ 2. That creates `<AppName>/<AppName>.entitlements` containing `aps-environment = development`. Xcode also sets `CODE_SIGN_ENTITLEMENTS` automatically.
28
+
29
+ **Android — Firebase project + `google-services.json`.**
30
+
31
+ 1. Create a Firebase project at console.firebase.google.com, add an Android app with your `applicationId`.
32
+ 2. Download `google-services.json` and place it at `android/app/google-services.json`.
33
+ 3. Add the Google Services Gradle plugin (modern plugins DSL — the sigx-lynx Android template doesn't use the legacy `buildscript { classpath … }` block):
34
+ - In `android/build.gradle.kts` (root), add to the top-level `plugins { … }`:
35
+ ```kotlin
36
+ id("com.google.gms.google-services") version "4.4.2" apply false
37
+ ```
38
+ - In `android/app/build.gradle.kts`, add to its `plugins { … }`:
39
+ ```kotlin
40
+ id("com.google.gms.google-services")
41
+ ```
42
+
15
43
  ## Usage
16
44
 
45
+ ### Local notifications (unchanged)
46
+
17
47
  ```ts
18
48
  import { Notifications } from '@sigx/lynx-notifications';
19
49
 
@@ -23,48 +53,90 @@ if (status === 'granted') {
23
53
  { title: 'Reminder', body: 'Check your tasks', data: { taskId: '42' } },
24
54
  { delay: 60 }, // seconds
25
55
  );
26
- // Cancel later by id:
27
- // await Notifications.cancel(id);
28
56
  }
57
+ ```
29
58
 
30
- // Daily reminder
31
- await Notifications.schedule(
32
- { title: 'Daily check-in', body: 'How are you feeling today?' },
33
- { delay: 60 * 60 * 24, repeat: 'day' },
34
- );
59
+ ### Remote push (Azure Notification Hubs, FCM, etc.)
35
60
 
36
- await Notifications.cancelAll();
61
+ ```ts
62
+ import { Notifications } from '@sigx/lynx-notifications';
63
+
64
+ // 1. Get permission.
65
+ const { status } = await Notifications.requestPermission();
66
+ if (status !== 'granted') return;
67
+
68
+ // 2. Subscribe to events BEFORE registering — on iOS the token arrives
69
+ // asynchronously through the AppDelegate hook, so a late subscriber
70
+ // would miss it. (The native side caches the last token and replays
71
+ // it on subscribe, but subscribing first is the cleaner pattern.)
72
+ const unsubToken = Notifications.addTokenListener(async ({ token, platform }) => {
73
+ // Forward to your backend; backend registers with Azure Notification Hubs:
74
+ // await fetch('/api/register-device', { method: 'POST', body: JSON.stringify({ token, platform }) });
75
+ });
76
+
77
+ const unsubMsg = Notifications.addPushListener((msg) => {
78
+ console.log('foreground push', msg);
79
+ });
80
+
81
+ const unsubTap = Notifications.addNotificationResponseListener(({ notificationId, data }) => {
82
+ // User tapped a notification — route them somewhere.
83
+ // Works for remote AND local notifications.
84
+ });
85
+
86
+ // 3. Trigger registration.
87
+ const result = await Notifications.registerForPushNotifications();
88
+ // iOS: { dispatched: true } — token arrives via addTokenListener
89
+ // Android: { token, platform: 'fcm' } directly
90
+
91
+ // 4. Cold-start handler: if the app was launched by a notification tap.
92
+ const initial = await Notifications.getInitialNotification();
93
+ if (initial) {
94
+ // route based on initial.data
95
+ }
96
+
97
+ // 5. Badge management (iOS only meaningfully — Android passes count=0 to clear).
98
+ await Notifications.setBadgeCount(0);
37
99
  ```
38
100
 
39
101
  ## API
40
102
 
41
- | Method | Notes |
42
- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
43
- | `schedule(content: NotificationContent, options?: ScheduleOptions): Promise<string>` | Returns the notification id (use it for `cancel()`). |
44
- | `cancel(notificationId: string): Promise<void>` | Cancels a scheduled notification. No-op if not scheduled. |
45
- | `cancelAll(): Promise<void>` | Cancels all pending notifications scheduled by this app. |
46
- | `requestPermission(): Promise<PermissionResponse>` | Shows the OS permission dialog if needed. |
47
- | `getPermissionStatus(): Promise<PermissionResponse>` | Read-only check — no prompt. |
48
- | `isAvailable(): boolean` | Whether the native module is registered in the current build. |
103
+ | Method | Notes |
104
+ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
105
+ | `schedule(content, options?): Promise<string>` | Local. Returns the notification id (use it for `cancel`). |
106
+ | `cancel(notificationId): Promise<void>` | Cancels a scheduled notification. No-op if not scheduled. |
107
+ | `cancelAll(): Promise<void>` | Cancels all pending notifications scheduled by this app. |
108
+ | `requestPermission(): Promise<PermissionResponse>` | Shows the OS permission dialog if needed. |
109
+ | `getPermissionStatus(): Promise<PermissionResponse>` | Read-only check — no prompt. |
110
+ | `registerForPushNotifications(): Promise<RegisterPushResult>` | iOS dispatches APNs registration (token via `addTokenListener`). Android resolves with FCM token directly. |
111
+ | `unregisterForPushNotifications(): Promise<void>` | Stops receiving remote pushes. |
112
+ | `setBadgeCount(n): Promise<void>` | iOS: app icon badge. Android: no-op (stock Android has no portable badging API; call `cancelAll()` to clear). |
113
+ | `getBadgeCount(): Promise<number>` | iOS: current badge. Android: always 0. |
114
+ | `getInitialNotification(): Promise<NotificationResponse \| null>` | Payload that launched the app from a cold start. One-shot — call exactly once on startup. |
115
+ | `addTokenListener(cb): () => void` | Subscribe to `{ token, platform }`. Returns unsubscribe. |
116
+ | `addTokenErrorListener(cb): () => void` | Subscribe to APNs / FCM registration failures. |
117
+ | `addPushListener(cb): () => void` | Subscribe to incoming remote messages. Fires on foreground + when FCM data messages arrive while backgrounded. |
118
+ | `addNotificationResponseListener(cb): () => void` | Subscribe to user taps — fires for **remote AND local** notifications. |
119
+ | `isAvailable(): boolean` | Whether the native module is registered in the current build. |
49
120
 
50
121
  ```ts
51
- interface NotificationContent {
52
- title: string;
53
- body: string;
54
- data?: Record<string, string>;
55
- }
56
-
57
- interface ScheduleOptions {
58
- delay?: number; // seconds from now; default = immediate
59
- repeat?: 'minute' | 'hour' | 'day' | 'week'; // periodic re-fire
60
- }
122
+ interface NotificationContent { title: string; body: string; data?: Record<string, string>; }
123
+ interface ScheduleOptions { delay?: number; repeat?: 'minute' | 'hour' | 'day' | 'week'; }
124
+ interface RegisterPushResult { token?: string; platform?: 'apns' | 'fcm'; dispatched?: boolean; error?: string; }
125
+ interface RemoteMessage { title?: string; body?: string; data: Record<string, string>; foreground: boolean; }
126
+ interface NotificationResponse{ notificationId: string; data: Record<string, string>; actionIdentifier: string; }
61
127
  ```
62
128
 
129
+ ## Event channels (advanced)
130
+
131
+ Native publishes on three `GlobalEventEmitter` channels: `__sigxPushToken`, `__sigxPushMessage`, `__sigxNotificationResponse` (plus `__sigxPushTokenError`). The listener helpers above are thin wrappers — apps that already manage their own event bus can subscribe to those channels directly.
132
+
63
133
  ## Gotchas
64
134
 
65
- - **Foreground delivery on iOS.** When the app is in the foreground, iOS suppresses the banner by default. Hook into `UNUserNotificationCenterDelegate` natively if you need in-app banners.
66
- - **Tap callbacks aren't surfaced in JS yet.** The notification fires, but if the user taps it the routing/payload-handling has to happen on the native side (or via deep links). A future revision could expose an `onResponse` event.
135
+ - **iOS simulator can't receive APNs pushes** registration will fail with `remote notifications are not supported in the simulator`. Use a real device for end-to-end push testing. Local notifications and the tap-callback path work fine in the simulator.
136
+ - **FCM token before Firebase init** if `google-services.json` is missing or invalid, `registerForPushNotifications` resolves with `{ error: 'FCM unavailable: ...' }` instead of crashing the bridge. Check the result.
137
+ - **Foreground delivery on iOS** — now shown with banner + sound by default. To suppress, override `UNUserNotificationCenter.current().delegate` in your own AppDelegate hook.
138
+ - **Android notification taps** — fire `addNotificationResponseListener` only when the notification was popped via `SigxFirebaseMessagingService` (the launch intent carries our extras). Local notifications scheduled via `Notifications.schedule` do not yet route taps on Android — track via the GitHub issue.
67
139
 
68
140
  ## Reference app
69
141
 
70
- `examples/lynx-one/my-sigx-app/src/cards/NotificationsCard.tsx` covers permission + schedule-with-delay + cancel.
142
+ `examples/showcase/src/cards/NotificationsCard.tsx` covers permission, schedule + cancel, push registration, listeners, and badge clearing.
@@ -6,6 +6,8 @@ import android.content.Context
6
6
  import android.os.Build
7
7
  import android.util.Log
8
8
  import androidx.core.app.NotificationCompat
9
+ import com.google.android.gms.tasks.OnCompleteListener
10
+ import com.google.firebase.messaging.FirebaseMessaging
9
11
  import com.lynx.jsbridge.LynxMethod
10
12
  import com.lynx.jsbridge.LynxModule
11
13
  import com.lynx.react.bridge.Callback
@@ -15,14 +17,14 @@ import com.lynx.react.bridge.ReadableMap
15
17
  import java.util.UUID
16
18
 
17
19
  /**
18
- * Local notifications module.
19
- * JS usage: NativeModules.Notifications.schedule({ title, body }, { delay }, callback)
20
+ * Local + remote notifications module.
21
+ * JS usage: NativeModules.Notifications.<method>(...)
20
22
  */
21
23
  class NotificationsModule(context: Context) : LynxModule(context) {
22
24
 
23
25
  companion object {
24
26
  private const val TAG = "NotificationsModule"
25
- private const val CHANNEL_ID = "sigx_lynxgo_default"
27
+ internal const val CHANNEL_ID = "sigx_lynxgo_default"
26
28
  private const val CHANNEL_NAME = "sigx-lynx-go"
27
29
  }
28
30
 
@@ -91,6 +93,106 @@ class NotificationsModule(context: Context) : LynxModule(context) {
91
93
  callback?.invoke(result)
92
94
  }
93
95
 
96
+ // ── Remote push ──────────────────────────────────────────────────────────
97
+
98
+ /**
99
+ * Resolve the FCM token. Unlike iOS where APNs delivery is async via
100
+ * AppDelegate, FCM exposes a Task<String> we can await directly. We still
101
+ * publish to [PushEventBus] (for the global event channel) so consumers
102
+ * using `addTokenListener` see the same value.
103
+ */
104
+ @LynxMethod
105
+ fun registerForPushNotifications(callback: Callback?) {
106
+ try {
107
+ FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task ->
108
+ if (!task.isSuccessful) {
109
+ val message = task.exception?.localizedMessage ?: "FCM token unavailable"
110
+ PushEventBus.publishTokenError(message)
111
+ val err = JavaOnlyMap().apply { putString("error", message) }
112
+ callback?.invoke(err)
113
+ return@OnCompleteListener
114
+ }
115
+ val token = task.result ?: ""
116
+ PushEventBus.publishToken(token, platform = "fcm")
117
+ val payload = JavaOnlyMap().apply {
118
+ putString("token", token)
119
+ putString("platform", "fcm")
120
+ }
121
+ callback?.invoke(payload)
122
+ })
123
+ } catch (e: Throwable) {
124
+ // Firebase not initialised (no google-services.json). Surface a
125
+ // clean error instead of letting the bridge spam an uncaught.
126
+ val message = "FCM unavailable: ${e.message ?: e.javaClass.simpleName}"
127
+ PushEventBus.publishTokenError(message)
128
+ callback?.invoke(JavaOnlyMap().apply { putString("error", message) })
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Awaits the FCM `deleteToken()` Task. Callers get an `{ ok, error? }`
134
+ * back so the JS shim can tell whether the device is genuinely
135
+ * unregistered or still holding a token server-side.
136
+ */
137
+ @LynxMethod
138
+ fun unregisterForPushNotifications(callback: Callback?) {
139
+ try {
140
+ FirebaseMessaging.getInstance().deleteToken()
141
+ .addOnCompleteListener(OnCompleteListener { task ->
142
+ if (task.isSuccessful) {
143
+ callback?.invoke(JavaOnlyMap().apply { putBoolean("ok", true) })
144
+ } else {
145
+ val message = task.exception?.localizedMessage
146
+ ?: "Failed to delete FCM token"
147
+ PushEventBus.publishTokenError(message)
148
+ callback?.invoke(JavaOnlyMap().apply {
149
+ putBoolean("ok", false)
150
+ putString("error", message)
151
+ })
152
+ }
153
+ })
154
+ } catch (e: Throwable) {
155
+ val message = "FCM unavailable: ${e.message ?: e.javaClass.simpleName}"
156
+ PushEventBus.publishTokenError(message)
157
+ callback?.invoke(JavaOnlyMap().apply {
158
+ putBoolean("ok", false)
159
+ putString("error", message)
160
+ })
161
+ }
162
+ }
163
+
164
+ /**
165
+ * No-op on Android. Stock Android has no portable app-icon badging API
166
+ * (it's vendor-specific: Samsung's `ShortcutBadger`, etc.) and we
167
+ * intentionally don't wipe pending notifications on setBadgeCount(0) —
168
+ * the call's name promises "set a badge", not "clear notifications".
169
+ * Callers wanting to clear notifications should use `cancelAll()` directly.
170
+ */
171
+ @LynxMethod
172
+ fun setBadgeCount(count: Int, callback: Callback?) {
173
+ callback?.invoke(true)
174
+ }
175
+
176
+ @LynxMethod
177
+ fun getBadgeCount(callback: Callback?) {
178
+ // No portable read API. Always return 0; documented in README.
179
+ callback?.invoke(0)
180
+ }
181
+
182
+ /**
183
+ * One-shot: drain the cold-start tap payload, or null.
184
+ * Marshals JavaOnlyMap → callback so JS sees the same shape as remote events.
185
+ */
186
+ @LynxMethod
187
+ fun getInitialNotification(callback: Callback?) {
188
+ val payload = PushEventBus.consumeInitialResponse()
189
+ if (payload == null) {
190
+ callback?.invoke(null as Any?)
191
+ } else {
192
+ callback?.invoke(payload)
193
+ }
194
+ }
195
+
94
196
  private fun createNotificationChannel() {
95
197
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
96
198
  val channel = NotificationChannel(
@@ -101,4 +203,3 @@ class NotificationsModule(context: Context) : LynxModule(context) {
101
203
  }
102
204
  }
103
205
  }
104
-
@@ -0,0 +1,66 @@
1
+ package com.sigx.notifications
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import android.os.Bundle
6
+
7
+ /**
8
+ * Activity-lifecycle hook for push notifications. Discovered by the auto-linker
9
+ * via `signalx-module.json`'s `android.activityHook` field.
10
+ *
11
+ * Responsibilities:
12
+ * - `onCreate`: app cold-started by a notification tap → stash the payload
13
+ * for [PushEventBus.captureInitialResponse] so JS can read it via
14
+ * `Notifications.getInitialNotification()`.
15
+ * - `onNewIntent`: app foregrounded by a tap while alive → publish to JS
16
+ * immediately via [PushEventBus.publishResponse].
17
+ *
18
+ * The launch intent is populated by [SigxFirebaseMessagingService]
19
+ * (`EXTRA_NOTIFICATION_TAP` + `sigx_push_data_*` extras). Local notifications
20
+ * scheduled via [NotificationsModule.schedule] don't currently set this flag
21
+ * (they show via the system manager directly), so local-tap routing on Android
22
+ * lands here only when scheduled with a launch intent — TODO for v2.
23
+ */
24
+ object PushActivityHook {
25
+
26
+ const val EXTRA_NOTIFICATION_TAP = "sigx_notification_tap"
27
+
28
+ @JvmStatic
29
+ fun onCreate(activity: Activity, savedInstanceState: Bundle?) {
30
+ val intent = activity.intent ?: return
31
+ if (intent.getBooleanExtra(EXTRA_NOTIFICATION_TAP, false)) {
32
+ val (id, data) = extractPayload(intent)
33
+ PushEventBus.captureInitialResponse(
34
+ notificationId = id,
35
+ data = data,
36
+ actionIdentifier = "default",
37
+ )
38
+ }
39
+ }
40
+
41
+ @JvmStatic
42
+ fun onNewIntent(activity: Activity, intent: Intent) {
43
+ if (!intent.getBooleanExtra(EXTRA_NOTIFICATION_TAP, false)) return
44
+ val (id, data) = extractPayload(intent)
45
+ PushEventBus.publishResponse(
46
+ notificationId = id,
47
+ data = data,
48
+ actionIdentifier = "default",
49
+ )
50
+ }
51
+
52
+ private fun extractPayload(intent: Intent): Pair<String, Map<String, String>> {
53
+ val data = mutableMapOf<String, String>()
54
+ val extras = intent.extras ?: return "" to data
55
+ for (key in extras.keySet()) {
56
+ if (!key.startsWith("sigx_push_data_")) continue
57
+ val short = key.removePrefix("sigx_push_data_")
58
+ val v = extras.getString(key) ?: continue
59
+ data[short] = v
60
+ }
61
+ val id = data["notification_id"]
62
+ ?: data["notificationId"]
63
+ ?: java.util.UUID.randomUUID().toString()
64
+ return id to data
65
+ }
66
+ }
@@ -0,0 +1,148 @@
1
+ package com.sigx.notifications
2
+
3
+ import com.lynx.react.bridge.JavaOnlyMap
4
+ import java.util.UUID
5
+
6
+ /**
7
+ * Process-wide pub/sub bus that converts FCM + notification-tap callbacks
8
+ * into JS-side event payloads. [SigxFirebaseMessagingService] and
9
+ * [PushActivityHook] write here; per-LynxView [PushPublisher] instances read.
10
+ *
11
+ * Mirrors the [com.sigx.websocket.WebSocketEventBus] pattern. Decoupled from
12
+ * any specific LynxView so events that fire before the JS heap is ready
13
+ * (cold-start taps, async token refresh) survive view recreation.
14
+ */
15
+ internal object PushEventBus {
16
+
17
+ /**
18
+ * Single lock guards [listeners] + [lastToken] + [initialResponse]. We
19
+ * use a private monitor object so external code can't accidentally
20
+ * acquire our lock and deadlock the bus.
21
+ *
22
+ * All listener invocations happen OUTSIDE the lock — a listener that
23
+ * publishes recursively (e.g. attaches another listener inside its
24
+ * callback) must not deadlock.
25
+ */
26
+ private val lock = Any()
27
+ private val listeners = mutableListOf<Pair<UUID, (String, JavaOnlyMap) -> Unit>>()
28
+
29
+ /** Cached cold-start tap. Drained by [consumeInitialResponse]. */
30
+ private var initialResponse: JavaOnlyMap? = null
31
+
32
+ /**
33
+ * Cached last token so a JS subscriber that attaches after FCM has already
34
+ * returned the token still sees the value on subscribe. FCM returns the
35
+ * same token across calls until rotation.
36
+ */
37
+ private var lastToken: JavaOnlyMap? = null
38
+
39
+ fun addListener(fn: (String, JavaOnlyMap) -> Unit): UUID {
40
+ val token = UUID.randomUUID()
41
+ // Snapshot the cached token in the same critical section as the
42
+ // listener append — without this, a concurrent publishToken can slip
43
+ // between `listeners.add` and reading `lastToken`, causing the new
44
+ // listener to fire twice (once from the publish, once from the
45
+ // replay). Matches the iOS bus' semantics.
46
+ val cached: JavaOnlyMap?
47
+ synchronized(lock) {
48
+ listeners.add(token to fn)
49
+ cached = lastToken
50
+ }
51
+ cached?.let { fn(Channel.TOKEN, it) }
52
+ return token
53
+ }
54
+
55
+ fun removeListener(token: UUID) {
56
+ synchronized(lock) {
57
+ listeners.removeAll { it.first == token }
58
+ }
59
+ }
60
+
61
+ fun publishToken(token: String, platform: String = "fcm") {
62
+ val payload = JavaOnlyMap().apply {
63
+ putString("token", token)
64
+ putString("platform", platform)
65
+ }
66
+ synchronized(lock) { lastToken = payload }
67
+ emit(Channel.TOKEN, payload)
68
+ }
69
+
70
+ fun publishTokenError(message: String) {
71
+ emit(
72
+ Channel.TOKEN_ERROR,
73
+ JavaOnlyMap().apply { putString("error", message) },
74
+ )
75
+ }
76
+
77
+ fun publishMessage(
78
+ title: String?,
79
+ body: String?,
80
+ data: Map<String, String>,
81
+ foreground: Boolean,
82
+ ) {
83
+ val payload = JavaOnlyMap().apply {
84
+ if (title != null) putString("title", title)
85
+ if (body != null) putString("body", body)
86
+ putMap("data", data.toJavaOnlyMap())
87
+ putBoolean("foreground", foreground)
88
+ }
89
+ emit(Channel.MESSAGE, payload)
90
+ }
91
+
92
+ fun publishResponse(
93
+ notificationId: String,
94
+ data: Map<String, String>,
95
+ actionIdentifier: String,
96
+ ) {
97
+ val payload = JavaOnlyMap().apply {
98
+ putString("notificationId", notificationId)
99
+ putMap("data", data.toJavaOnlyMap())
100
+ putString("actionIdentifier", actionIdentifier)
101
+ }
102
+ emit(Channel.RESPONSE, payload)
103
+ }
104
+
105
+ /** Stash a cold-start tap until JS calls `getInitialNotification`. */
106
+ fun captureInitialResponse(
107
+ notificationId: String,
108
+ data: Map<String, String>,
109
+ actionIdentifier: String,
110
+ ) {
111
+ val payload = JavaOnlyMap().apply {
112
+ putString("notificationId", notificationId)
113
+ putMap("data", data.toJavaOnlyMap())
114
+ putString("actionIdentifier", actionIdentifier)
115
+ }
116
+ synchronized(lock) { initialResponse = payload }
117
+ }
118
+
119
+ /** One-shot drain. */
120
+ fun consumeInitialResponse(): JavaOnlyMap? {
121
+ return synchronized(lock) {
122
+ val p = initialResponse
123
+ initialResponse = null
124
+ p
125
+ }
126
+ }
127
+
128
+ private fun emit(channel: String, payload: JavaOnlyMap) {
129
+ // Snapshot under the lock so a concurrent add/remove can't tear the
130
+ // iteration. Call listeners OUTSIDE the lock so a callback that
131
+ // re-enters the bus doesn't deadlock.
132
+ val snapshot = synchronized(lock) { listeners.toList() }
133
+ for ((_, fn) in snapshot) fn(channel, payload)
134
+ }
135
+
136
+ private fun Map<String, String>.toJavaOnlyMap(): JavaOnlyMap {
137
+ val m = JavaOnlyMap()
138
+ for ((k, v) in this) m.putString(k, v)
139
+ return m
140
+ }
141
+
142
+ object Channel {
143
+ const val TOKEN = "__sigxPushToken"
144
+ const val TOKEN_ERROR = "__sigxPushTokenError"
145
+ const val MESSAGE = "__sigxPushMessage"
146
+ const val RESPONSE = "__sigxNotificationResponse"
147
+ }
148
+ }
@@ -0,0 +1,42 @@
1
+ package com.sigx.notifications
2
+
3
+ import android.util.Log
4
+ import com.lynx.react.bridge.JavaOnlyArray
5
+ import com.lynx.tasm.LynxView
6
+ import java.util.UUID
7
+
8
+ /**
9
+ * Per-[LynxView] publisher that pumps [PushEventBus] payloads into JS via
10
+ * `LynxView.sendGlobalEvent(channel, [payload])`.
11
+ *
12
+ * One instance per LynxView; instantiated by the generated
13
+ * `GeneratedLifecyclePublishers.attachAll(lynxView)` and retained for the
14
+ * LynxView's lifetime. The bus replays the last token to late subscribers so
15
+ * a JS shim that mounts after FCM has already returned the token still sees
16
+ * the value.
17
+ */
18
+ class PushPublisher(private val lynxView: LynxView) {
19
+
20
+ private var token: UUID? = null
21
+
22
+ fun attach() {
23
+ token = PushEventBus.addListener { channel, payload ->
24
+ try {
25
+ val params = JavaOnlyArray()
26
+ params.pushMap(payload)
27
+ lynxView.sendGlobalEvent(channel, params)
28
+ } catch (e: Throwable) {
29
+ Log.w(TAG, "publish failed: ${e.message}")
30
+ }
31
+ }
32
+ }
33
+
34
+ fun detach() {
35
+ token?.let { PushEventBus.removeListener(it) }
36
+ token = null
37
+ }
38
+
39
+ private companion object {
40
+ const val TAG = "SigxPushPublisher"
41
+ }
42
+ }