@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.
@@ -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
+ }
@@ -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
  }