@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 +103 -31
- package/android/com/sigx/notifications/NotificationsModule.kt +105 -4
- package/android/com/sigx/notifications/PushActivityHook.kt +66 -0
- package/android/com/sigx/notifications/PushEventBus.kt +148 -0
- package/android/com/sigx/notifications/PushPublisher.kt +42 -0
- package/android/com/sigx/notifications/SigxFirebaseMessagingService.kt +115 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -25
- package/dist/index.js.map +1 -1
- package/dist/notifications.d.ts +85 -11
- package/dist/notifications.d.ts.map +1 -1
- package/dist/notifications.js +114 -0
- package/dist/notifications.js.map +1 -0
- package/dist/push.d.ts +30 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +59 -0
- package/dist/push.js.map +1 -0
- package/ios/NotificationsModule.swift +68 -2
- package/ios/PushAppDelegateHook.swift +155 -0
- package/ios/PushEventBus.swift +164 -0
- package/ios/PushPublisher.swift +30 -0
- package/package.json +8 -5
- package/signalx-module.json +41 -3
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# @sigx/lynx-notifications
|
|
2
2
|
|
|
3
|
-
Local push notifications for sigx-lynx.
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
|
|
|
43
|
-
| `schedule(content
|
|
44
|
-
| `cancel(notificationId
|
|
45
|
-
| `cancelAll(): Promise<void>`
|
|
46
|
-
| `requestPermission(): Promise<PermissionResponse>`
|
|
47
|
-
| `getPermissionStatus(): Promise<PermissionResponse>`
|
|
48
|
-
| `
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
- **
|
|
66
|
-
- **
|
|
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/
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|