@goliapkg/sentori-react-native 2.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +12 -0
- package/android/src/main/AndroidManifest.xml +24 -1
- package/android/src/main/java/com/sentori/SentoriFirebaseMessagingService.kt +56 -0
- package/android/src/main/java/com/sentori/SentoriModule.kt +43 -0
- package/android/src/main/java/com/sentori/SentoriPushNotifications.kt +293 -0
- package/ios/SentoriModule.swift +39 -0
- package/ios/SentoriPushNotifications.swift +303 -0
- package/lib/expo-compat.d.ts +270 -0
- package/lib/expo-compat.d.ts.map +1 -0
- package/lib/expo-compat.js +500 -0
- package/lib/expo-compat.js.map +1 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +12 -0
- package/lib/index.js.map +1 -1
- package/lib/native.d.ts +17 -0
- package/lib/native.d.ts.map +1 -1
- package/lib/native.js +57 -0
- package/lib/native.js.map +1 -1
- package/lib/push.d.ts +58 -0
- package/lib/push.d.ts.map +1 -0
- package/lib/push.js +294 -0
- package/lib/push.js.map +1 -0
- package/package.json +9 -5
- package/src/__tests__/push.test.ts +178 -0
- package/src/expo-compat.ts +698 -0
- package/src/index.ts +26 -0
- package/src/native.ts +102 -0
- package/src/push.ts +382 -0
package/android/build.gradle
CHANGED
|
@@ -35,4 +35,16 @@ android {
|
|
|
35
35
|
dependencies {
|
|
36
36
|
implementation project(':expo-modules-core')
|
|
37
37
|
implementation "org.jetbrains.kotlin:kotlin-stdlib:${rootProject.ext.kotlinVersion ?: '2.0.21'}"
|
|
38
|
+
|
|
39
|
+
// v2.10 — Firebase Cloud Messaging. `compileOnly` so non-push
|
|
40
|
+
// hosts aren't forced to ship Firebase. Hosts that want push add
|
|
41
|
+
// `com.google.firebase:firebase-messaging` themselves (plus
|
|
42
|
+
// `google-services.json` + the gradle plugin) — the SDK's
|
|
43
|
+
// `SentoriPushNotifications` code then sees the FCM classes on
|
|
44
|
+
// the runtime classpath and proceeds. Hosts without those deps
|
|
45
|
+
// see the SDK no-op cleanly via the runtime Class.forName probe.
|
|
46
|
+
compileOnly 'com.google.firebase:firebase-messaging:24.0.3'
|
|
47
|
+
// androidx.core for ContextCompat.checkSelfPermission +
|
|
48
|
+
// ActivityCompat.requestPermissions (Android 13+ POST_NOTIFICATIONS).
|
|
49
|
+
implementation 'androidx.core:core:1.13.1'
|
|
38
50
|
}
|
|
@@ -1 +1,24 @@
|
|
|
1
|
-
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
2
|
+
|
|
3
|
+
<!-- v2.10 — Android 13+ runtime notifications permission. The SDK
|
|
4
|
+
requests this on `pushRequestPermission`; older Android grants
|
|
5
|
+
it automatically at install time when the host app's notifications
|
|
6
|
+
channel is enabled. -->
|
|
7
|
+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
8
|
+
|
|
9
|
+
<application>
|
|
10
|
+
<!-- v2.10 — FCM message routing. Firebase Messaging's
|
|
11
|
+
manifest-merger discovers this service via the
|
|
12
|
+
`MESSAGING_EVENT` action; we never call it directly.
|
|
13
|
+
Host apps that don't include `firebase-messaging` will
|
|
14
|
+
never invoke this service (Firebase isn't initialized). -->
|
|
15
|
+
<service
|
|
16
|
+
android:name="com.sentori.SentoriFirebaseMessagingService"
|
|
17
|
+
android:exported="false">
|
|
18
|
+
<intent-filter>
|
|
19
|
+
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
|
20
|
+
</intent-filter>
|
|
21
|
+
</service>
|
|
22
|
+
</application>
|
|
23
|
+
|
|
24
|
+
</manifest>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// v2.10 — FCM message routing service.
|
|
2
|
+
//
|
|
3
|
+
// Manifest-registered in `AndroidManifest.xml`. Firebase's manifest
|
|
4
|
+
// merger picks this up via the `MESSAGING_EVENT` intent filter.
|
|
5
|
+
// Three responsibilities:
|
|
6
|
+
//
|
|
7
|
+
// * `onNewToken` — push the refreshed FCM token into
|
|
8
|
+
// `SentoriPushNotifications.handleRegisteredToken`.
|
|
9
|
+
// * `onMessageReceived` — extract a `Map<String, Any?>` payload
|
|
10
|
+
// from the `RemoteMessage` and route it to
|
|
11
|
+
// `SentoriPushNotifications.handleIncomingNotification`. Whether
|
|
12
|
+
// the system also displays the notification tray entry depends
|
|
13
|
+
// on `notification` vs `data`-only messages — we always surface
|
|
14
|
+
// it to JS regardless.
|
|
15
|
+
//
|
|
16
|
+
// `firebase-messaging` is `compileOnly` in `build.gradle`. The class
|
|
17
|
+
// compiles against Firebase but isn't loaded at runtime unless the
|
|
18
|
+
// host app pulls in `firebase-messaging` themselves. The
|
|
19
|
+
// AndroidManifest `<service>` declaration is harmless when Firebase
|
|
20
|
+
// isn't on the classpath — it just never gets invoked.
|
|
21
|
+
|
|
22
|
+
package com.sentori
|
|
23
|
+
|
|
24
|
+
import com.google.firebase.messaging.FirebaseMessagingService
|
|
25
|
+
import com.google.firebase.messaging.RemoteMessage
|
|
26
|
+
|
|
27
|
+
class SentoriFirebaseMessagingService : FirebaseMessagingService() {
|
|
28
|
+
|
|
29
|
+
override fun onNewToken(token: String) {
|
|
30
|
+
super.onNewToken(token)
|
|
31
|
+
try {
|
|
32
|
+
SentoriPushNotifications.handleRegisteredToken(token)
|
|
33
|
+
} catch (_: Throwable) {
|
|
34
|
+
// never crash a Firebase callback
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
override fun onMessageReceived(message: RemoteMessage) {
|
|
39
|
+
super.onMessageReceived(message)
|
|
40
|
+
try {
|
|
41
|
+
val payload = mutableMapOf<String, Any?>(
|
|
42
|
+
"id" to (message.messageId ?: ""),
|
|
43
|
+
"userInfo" to message.data,
|
|
44
|
+
"receivedAt" to (message.sentTime / 1000.0),
|
|
45
|
+
)
|
|
46
|
+
message.notification?.let { notif ->
|
|
47
|
+
notif.title?.let { payload["title"] = it }
|
|
48
|
+
notif.body?.let { payload["body"] = it }
|
|
49
|
+
notif.channelId?.let { payload["channelId"] = it }
|
|
50
|
+
}
|
|
51
|
+
SentoriPushNotifications.handleIncomingNotification(payload)
|
|
52
|
+
} catch (_: Throwable) {
|
|
53
|
+
// never crash a Firebase callback
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -104,5 +104,48 @@ class SentoriModule : Module() {
|
|
|
104
104
|
throw RuntimeException("Sentori test native crash")
|
|
105
105
|
}, 50)
|
|
106
106
|
}
|
|
107
|
+
|
|
108
|
+
// v2.10 — push notification bridge.
|
|
109
|
+
//
|
|
110
|
+
// Same surface as iOS so the JS layer (sdk/react-native/src/push.ts)
|
|
111
|
+
// doesn't branch on Platform.OS. The Android flow uses
|
|
112
|
+
// FirebaseMessaging behind a runtime `Class.forName` gate so
|
|
113
|
+
// non-push hosts pay nothing.
|
|
114
|
+
|
|
115
|
+
AsyncFunction("pushGetStatus") {
|
|
116
|
+
val ctx = appContext.reactContext ?: return@AsyncFunction "unavailable"
|
|
117
|
+
SentoriPushNotifications.currentPermission(ctx)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
AsyncFunction("pushRequestPermission") { promise: expo.modules.kotlin.Promise ->
|
|
121
|
+
val activity = appContext.currentActivity
|
|
122
|
+
if (activity == null) {
|
|
123
|
+
val ctx = appContext.reactContext
|
|
124
|
+
// No Activity to run the permission flow against; fall
|
|
125
|
+
// back to the (non-prompting) current status. Same
|
|
126
|
+
// behaviour as iOS when there's no UI scene.
|
|
127
|
+
promise.resolve(
|
|
128
|
+
if (ctx != null) SentoriPushNotifications.currentPermission(ctx) else "unavailable"
|
|
129
|
+
)
|
|
130
|
+
return@AsyncFunction
|
|
131
|
+
}
|
|
132
|
+
SentoriPushNotifications.requestPermission(activity) { status ->
|
|
133
|
+
promise.resolve(status)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
Function("pushRegister") {
|
|
138
|
+
val ctx = appContext.reactContext ?: return@Function
|
|
139
|
+
SentoriPushNotifications.registerForRemoteNotifications(ctx)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
Function("pushUnregister") {
|
|
143
|
+
val ctx = appContext.reactContext ?: return@Function
|
|
144
|
+
SentoriPushNotifications.unregisterForRemoteNotifications(ctx)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
AsyncFunction("pushDrainState") {
|
|
148
|
+
SentoriPushNotifications.drainState()
|
|
149
|
+
}
|
|
107
150
|
}
|
|
108
151
|
}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// v2.10 — Android push notification bridge.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the iOS shape:
|
|
4
|
+
// * Static singleton owning 32-slot FIFO buffers for token,
|
|
5
|
+
// foreground notifications, and tap responses.
|
|
6
|
+
// * JS drains via `drainState()` at 1 Hz.
|
|
7
|
+
// * No EventEmitter — the existing crash-handler pattern.
|
|
8
|
+
//
|
|
9
|
+
// FCM-specific:
|
|
10
|
+
// * `firebase-messaging` is a `compileOnly` dep so non-push hosts
|
|
11
|
+
// pay nothing. Runtime gate via `Class.forName` before any
|
|
12
|
+
// Firebase call.
|
|
13
|
+
// * Token retrieval / refresh routes through
|
|
14
|
+
// `SentoriFirebaseMessagingService.onNewToken` (system-initiated)
|
|
15
|
+
// and `FirebaseMessaging.getInstance().token` (caller-initiated).
|
|
16
|
+
//
|
|
17
|
+
// Android 13+ (API 33) added `POST_NOTIFICATIONS` as a runtime
|
|
18
|
+
// permission. We surface it via `requestPermission(activity, cb)`;
|
|
19
|
+
// older Android resolves immediately to `granted` (system grants at
|
|
20
|
+
// install time; user can still disable it in Settings, which we
|
|
21
|
+
// detect via `NotificationManagerCompat.areNotificationsEnabled`).
|
|
22
|
+
|
|
23
|
+
package com.sentori
|
|
24
|
+
|
|
25
|
+
import android.Manifest
|
|
26
|
+
import android.app.Activity
|
|
27
|
+
import android.app.NotificationChannel
|
|
28
|
+
import android.app.NotificationManager
|
|
29
|
+
import android.content.Context
|
|
30
|
+
import android.content.pm.PackageManager
|
|
31
|
+
import android.os.Build
|
|
32
|
+
import androidx.core.app.ActivityCompat
|
|
33
|
+
import androidx.core.app.NotificationManagerCompat
|
|
34
|
+
import androidx.core.content.ContextCompat
|
|
35
|
+
|
|
36
|
+
object SentoriPushNotifications {
|
|
37
|
+
private const val DEFAULT_CHANNEL_ID = "sentori"
|
|
38
|
+
private const val DEFAULT_CHANNEL_NAME = "Sentori notifications"
|
|
39
|
+
private const val BUFFER_CAP = 32
|
|
40
|
+
private const val PERMISSION_REQUEST_CODE = 0x5E70_3001.toInt()
|
|
41
|
+
|
|
42
|
+
private val lock = Any()
|
|
43
|
+
private var tokenHex: String? = null
|
|
44
|
+
private var registrationError: String? = null
|
|
45
|
+
private val notifications = mutableListOf<Map<String, Any?>>()
|
|
46
|
+
private val taps = mutableListOf<Map<String, Any?>>()
|
|
47
|
+
|
|
48
|
+
private var pendingPermissionCallback: ((String) -> Unit)? = null
|
|
49
|
+
|
|
50
|
+
// ── status / permission ─────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** Returns `granted` / `denied` / `notDetermined` without
|
|
53
|
+
* prompting. Mirrors the iOS string return. */
|
|
54
|
+
@JvmStatic
|
|
55
|
+
fun currentPermission(ctx: Context): String {
|
|
56
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
57
|
+
val status = ContextCompat.checkSelfPermission(
|
|
58
|
+
ctx,
|
|
59
|
+
Manifest.permission.POST_NOTIFICATIONS,
|
|
60
|
+
)
|
|
61
|
+
if (status == PackageManager.PERMISSION_GRANTED) return "granted"
|
|
62
|
+
// Permission has been explicitly denied or never requested.
|
|
63
|
+
// The framework distinguishes these only via
|
|
64
|
+
// `shouldShowRequestPermissionRationale` which needs an
|
|
65
|
+
// Activity; without one we conservatively report
|
|
66
|
+
// `notDetermined`.
|
|
67
|
+
return "notDetermined"
|
|
68
|
+
}
|
|
69
|
+
// Pre-Android 13: install-time permission. The user can
|
|
70
|
+
// still disable notifications per-app; we surface that.
|
|
71
|
+
val enabled = NotificationManagerCompat.from(ctx).areNotificationsEnabled()
|
|
72
|
+
return if (enabled) "granted" else "denied"
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Requests POST_NOTIFICATIONS on Android 13+ (no-op on older
|
|
77
|
+
* Android — they auto-grant + the SDK resolves immediately).
|
|
78
|
+
*
|
|
79
|
+
* Callbacks run on the main thread.
|
|
80
|
+
*/
|
|
81
|
+
@JvmStatic
|
|
82
|
+
fun requestPermission(activity: Activity?, completion: (String) -> Unit) {
|
|
83
|
+
val ctx = activity ?: run {
|
|
84
|
+
completion("error:no-activity")
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
|
88
|
+
completion(currentPermission(ctx))
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
val current = ContextCompat.checkSelfPermission(
|
|
92
|
+
ctx,
|
|
93
|
+
Manifest.permission.POST_NOTIFICATIONS,
|
|
94
|
+
)
|
|
95
|
+
if (current == PackageManager.PERMISSION_GRANTED) {
|
|
96
|
+
completion("granted")
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
pendingPermissionCallback = completion
|
|
100
|
+
ActivityCompat.requestPermissions(
|
|
101
|
+
ctx,
|
|
102
|
+
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
|
103
|
+
PERMISSION_REQUEST_CODE,
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Hook for the host Activity's `onRequestPermissionsResult`. Not
|
|
109
|
+
* mandatory — Android dispatches the result back to the same
|
|
110
|
+
* Activity that requested it, but ActivityCompat's flow doesn't
|
|
111
|
+
* give us a callback API on older devices. Hosts that want
|
|
112
|
+
* deterministic delivery can call this from their override.
|
|
113
|
+
*
|
|
114
|
+
* The Activity-based ActivityResultLauncher pattern would be
|
|
115
|
+
* cleaner but requires the Activity to be a ComponentActivity;
|
|
116
|
+
* we stick with ActivityCompat for broader RN host compat and
|
|
117
|
+
* accept that the callback may not fire on every device — the
|
|
118
|
+
* JS drain loop will still pick up the `granted` state next tick.
|
|
119
|
+
*/
|
|
120
|
+
@JvmStatic
|
|
121
|
+
fun handlePermissionResult(requestCode: Int, grantResults: IntArray) {
|
|
122
|
+
if (requestCode != PERMISSION_REQUEST_CODE) return
|
|
123
|
+
val cb = pendingPermissionCallback ?: return
|
|
124
|
+
pendingPermissionCallback = null
|
|
125
|
+
val granted = grantResults.isNotEmpty() &&
|
|
126
|
+
grantResults[0] == PackageManager.PERMISSION_GRANTED
|
|
127
|
+
cb(if (granted) "granted" else "denied")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── register / unregister ───────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Kick off FCM token retrieval. The result lands in the buffer
|
|
134
|
+
* (drained by JS) — either via this caller-initiated path or
|
|
135
|
+
* via `SentoriFirebaseMessagingService.onNewToken`, whichever
|
|
136
|
+
* fires first.
|
|
137
|
+
*
|
|
138
|
+
* Silently no-ops when `firebase-messaging` isn't on the
|
|
139
|
+
* classpath — the SDK shipped a `compileOnly` dep, so a host
|
|
140
|
+
* without push runs through this path without throwing.
|
|
141
|
+
*/
|
|
142
|
+
@JvmStatic
|
|
143
|
+
fun registerForRemoteNotifications(ctx: Context) {
|
|
144
|
+
ensureChannel(ctx)
|
|
145
|
+
if (!isFirebaseAvailable()) {
|
|
146
|
+
handleRegistrationFailure("firebase-messaging not available")
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
// Call FirebaseMessaging.getInstance().getToken() via
|
|
151
|
+
// reflection so the SDK's bytecode doesn't reference the
|
|
152
|
+
// Firebase classes directly (allows non-push hosts to
|
|
153
|
+
// skip Firebase entirely without LinkageError).
|
|
154
|
+
val cls = Class.forName("com.google.firebase.messaging.FirebaseMessaging")
|
|
155
|
+
val instance = cls.getMethod("getInstance").invoke(null)
|
|
156
|
+
val tokenTask = cls.getMethod("getToken").invoke(instance)
|
|
157
|
+
val taskCls = Class.forName("com.google.android.gms.tasks.Task")
|
|
158
|
+
val listenerCls = Class.forName("com.google.android.gms.tasks.OnCompleteListener")
|
|
159
|
+
val listener = java.lang.reflect.Proxy.newProxyInstance(
|
|
160
|
+
listenerCls.classLoader,
|
|
161
|
+
arrayOf(listenerCls),
|
|
162
|
+
) { _, method, args ->
|
|
163
|
+
if (method.name == "onComplete") {
|
|
164
|
+
val task = args?.firstOrNull() ?: return@newProxyInstance null
|
|
165
|
+
val taskClass = task.javaClass
|
|
166
|
+
val successful = taskClass.getMethod("isSuccessful").invoke(task) as Boolean
|
|
167
|
+
if (successful) {
|
|
168
|
+
val tok = taskClass.getMethod("getResult").invoke(task) as? String
|
|
169
|
+
if (tok != null) handleRegisteredToken(tok)
|
|
170
|
+
} else {
|
|
171
|
+
val ex = taskClass.getMethod("getException").invoke(task) as? Throwable
|
|
172
|
+
handleRegistrationFailure(ex?.localizedMessage ?: "fcm token request failed")
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
null
|
|
176
|
+
}
|
|
177
|
+
taskCls.getMethod("addOnCompleteListener", listenerCls).invoke(tokenTask, listener)
|
|
178
|
+
} catch (e: Throwable) {
|
|
179
|
+
handleRegistrationFailure(e.localizedMessage ?: e.javaClass.simpleName)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Counterpart — calls `FirebaseMessaging.deleteToken()` via
|
|
184
|
+
* reflection. Best-effort; failures are swallowed. */
|
|
185
|
+
@JvmStatic
|
|
186
|
+
fun unregisterForRemoteNotifications(ctx: Context) {
|
|
187
|
+
synchronized(lock) {
|
|
188
|
+
tokenHex = null
|
|
189
|
+
registrationError = null
|
|
190
|
+
}
|
|
191
|
+
if (!isFirebaseAvailable()) return
|
|
192
|
+
try {
|
|
193
|
+
val cls = Class.forName("com.google.firebase.messaging.FirebaseMessaging")
|
|
194
|
+
val instance = cls.getMethod("getInstance").invoke(null)
|
|
195
|
+
cls.getMethod("deleteToken").invoke(instance)
|
|
196
|
+
} catch (_: Throwable) {
|
|
197
|
+
// best-effort
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── service-callable mutators ───────────────────────────────
|
|
202
|
+
|
|
203
|
+
/** Called from `SentoriFirebaseMessagingService.onNewToken`. */
|
|
204
|
+
@JvmStatic
|
|
205
|
+
fun handleRegisteredToken(token: String) {
|
|
206
|
+
synchronized(lock) {
|
|
207
|
+
tokenHex = token
|
|
208
|
+
registrationError = null
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@JvmStatic
|
|
213
|
+
fun handleRegistrationFailure(reason: String) {
|
|
214
|
+
synchronized(lock) {
|
|
215
|
+
registrationError = reason
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Called from `SentoriFirebaseMessagingService.onMessageReceived`.
|
|
220
|
+
* `payload` is the keyset extracted from the FCM RemoteMessage —
|
|
221
|
+
* see the service for the shape. */
|
|
222
|
+
@JvmStatic
|
|
223
|
+
fun handleIncomingNotification(payload: Map<String, Any?>) {
|
|
224
|
+
synchronized(lock) {
|
|
225
|
+
notifications.add(payload)
|
|
226
|
+
while (notifications.size > BUFFER_CAP) notifications.removeAt(0)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Called when the user taps a notification (host wires this in
|
|
231
|
+
* Activity.onCreate to forward the intent extras). */
|
|
232
|
+
@JvmStatic
|
|
233
|
+
fun handleNotificationTap(extras: Map<String, Any?>) {
|
|
234
|
+
synchronized(lock) {
|
|
235
|
+
taps.add(extras)
|
|
236
|
+
while (taps.size > BUFFER_CAP) taps.removeAt(0)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── drain (called by Expo AsyncFunction) ───────────────────
|
|
241
|
+
|
|
242
|
+
@JvmStatic
|
|
243
|
+
fun drainState(): Map<String, Any?> {
|
|
244
|
+
synchronized(lock) {
|
|
245
|
+
val tok = tokenHex
|
|
246
|
+
val err = registrationError
|
|
247
|
+
val nList = notifications.toList()
|
|
248
|
+
val tList = taps.toList()
|
|
249
|
+
notifications.clear()
|
|
250
|
+
taps.clear()
|
|
251
|
+
val map = mutableMapOf<String, Any?>(
|
|
252
|
+
"notifications" to nList,
|
|
253
|
+
"taps" to tList,
|
|
254
|
+
)
|
|
255
|
+
if (tok != null) map["token"] = tok
|
|
256
|
+
if (err != null) map["error"] = err
|
|
257
|
+
return map
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ── helpers ────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Create the default notification channel idempotently. Android
|
|
265
|
+
* 8+ requires every visible notification to belong to a channel;
|
|
266
|
+
* we provide a sensible "sentori" channel for hosts that don't
|
|
267
|
+
* register one themselves. Hosts that want their own channel
|
|
268
|
+
* pass `channelId` in the SDK push send options.
|
|
269
|
+
*/
|
|
270
|
+
private fun ensureChannel(ctx: Context) {
|
|
271
|
+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
272
|
+
val mgr = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
|
273
|
+
?: return
|
|
274
|
+
if (mgr.getNotificationChannel(DEFAULT_CHANNEL_ID) != null) return
|
|
275
|
+
val channel = NotificationChannel(
|
|
276
|
+
DEFAULT_CHANNEL_ID,
|
|
277
|
+
DEFAULT_CHANNEL_NAME,
|
|
278
|
+
NotificationManager.IMPORTANCE_DEFAULT,
|
|
279
|
+
)
|
|
280
|
+
mgr.createNotificationChannel(channel)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private fun isFirebaseAvailable(): Boolean {
|
|
284
|
+
return try {
|
|
285
|
+
Class.forName("com.google.firebase.messaging.FirebaseMessaging")
|
|
286
|
+
true
|
|
287
|
+
} catch (_: ClassNotFoundException) {
|
|
288
|
+
false
|
|
289
|
+
} catch (_: NoClassDefFoundError) {
|
|
290
|
+
false
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
package/ios/SentoriModule.swift
CHANGED
|
@@ -111,5 +111,44 @@ public class SentoriModule: Module {
|
|
|
111
111
|
).raise()
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
// v2.9 — push notification bridge.
|
|
116
|
+
//
|
|
117
|
+
// Five Functions / AsyncFunctions form the surface that
|
|
118
|
+
// `sdk/react-native/src/push.ts` consumes:
|
|
119
|
+
//
|
|
120
|
+
// pushGetStatus — non-prompting status read
|
|
121
|
+
// pushRequestPermission — triggers the OS prompt if undecided
|
|
122
|
+
// pushRegister — UIApplication.registerForRemoteNotifications
|
|
123
|
+
// pushUnregister — UIApplication.unregisterForRemoteNotifications
|
|
124
|
+
// pushDrainState — token / notifications / taps buffer drain
|
|
125
|
+
//
|
|
126
|
+
// All five route through `SentoriPushNotifications.shared`,
|
|
127
|
+
// which also installs the AppDelegate method swizzle that
|
|
128
|
+
// routes APNs token callbacks into the buffer.
|
|
129
|
+
|
|
130
|
+
AsyncFunction("pushGetStatus") { (promise: Promise) in
|
|
131
|
+
SentoriPushNotifications.shared.currentPermission { status in
|
|
132
|
+
promise.resolve(status)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
AsyncFunction("pushRequestPermission") { (promise: Promise) in
|
|
137
|
+
SentoriPushNotifications.shared.requestPermission { status in
|
|
138
|
+
promise.resolve(status)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
Function("pushRegister") {
|
|
143
|
+
SentoriPushNotifications.shared.registerForRemoteNotifications()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
Function("pushUnregister") {
|
|
147
|
+
SentoriPushNotifications.shared.unregisterForRemoteNotifications()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
AsyncFunction("pushDrainState") { () -> [String: Any] in
|
|
151
|
+
return SentoriPushNotifications.shared.drainState()
|
|
152
|
+
}
|
|
114
153
|
}
|
|
115
154
|
}
|