appambit-push-notifications 0.3.0 → 1.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.
Files changed (28) hide show
  1. package/AppAmbitSdkPushNotifications.podspec +14 -4
  2. package/README.md +184 -105
  3. package/android/build.gradle +21 -3
  4. package/android/src/main/AndroidManifest.xml +107 -1
  5. package/android/src/main/java/com/appambitpushnotifications/AppAmbitContextHolder.kt +22 -0
  6. package/android/src/main/java/com/appambitpushnotifications/AppAmbitHeadlessService.kt +177 -0
  7. package/android/src/main/java/com/appambitpushnotifications/AppAmbitInitProvider.kt +73 -0
  8. package/android/src/main/java/com/appambitpushnotifications/AppAmbitMessagingService.kt +12 -0
  9. package/android/src/main/java/com/appambitpushnotifications/AppAmbitNotificationSerializer.kt +88 -0
  10. package/android/src/main/java/com/appambitpushnotifications/AppAmbitPayloadUtils.kt +59 -0
  11. package/android/src/main/java/com/appambitpushnotifications/AppAmbitPushEventEmitter.kt +100 -0
  12. package/android/src/main/java/com/appambitpushnotifications/AppAmbitRNServiceExtension.kt +75 -0
  13. package/android/src/main/java/com/appambitpushnotifications/AppAmbitRemoteMessageStore.kt +26 -0
  14. package/android/src/main/java/com/appambitpushnotifications/AppambitPushNotificationsModule.kt +377 -76
  15. package/ios/AppAmbitNotificationSwizzler.m +290 -0
  16. package/ios/AppAmbitPushWrapper.swift +165 -25
  17. package/ios/AppAmbitRNNotificationService.swift +46 -0
  18. package/ios/AppambitPushNotifications.mm +264 -10
  19. package/lib/module/NativeAppambitPushNotifications.js.map +1 -1
  20. package/lib/module/index.js +46 -10
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/typescript/src/NativeAppambitPushNotifications.d.ts +2 -1
  23. package/lib/typescript/src/NativeAppambitPushNotifications.d.ts.map +1 -1
  24. package/lib/typescript/src/index.d.ts +32 -6
  25. package/lib/typescript/src/index.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/NativeAppambitPushNotifications.ts +7 -1
  28. package/src/index.tsx +93 -20
@@ -0,0 +1,177 @@
1
+ package com.appambitpushnotifications
2
+
3
+ import android.content.Context
4
+ import android.content.Intent
5
+ import android.util.Log
6
+ import com.appambit.sdk.models.AppAmbitNotification
7
+ import com.facebook.react.HeadlessJsTaskService
8
+ import com.facebook.react.ReactApplication
9
+ import com.facebook.react.bridge.Arguments
10
+ import com.facebook.react.jstasks.HeadlessJsTaskConfig
11
+
12
+ class AppAmbitHeadlessService : HeadlessJsTaskService() {
13
+
14
+ companion object {
15
+ private const val TAG = "AppAmbitHeadless"
16
+
17
+ const val HEADLESS_TASK_NAME = "AppAmbitBackgroundNotification"
18
+
19
+ // Intent extras
20
+ private const val EXTRA_TITLE = "aa_title"
21
+ private const val EXTRA_BODY = "aa_body"
22
+ private const val EXTRA_IMAGE_URL = "aa_image_url"
23
+ private const val EXTRA_ANDROID_COLOR = "aa_android_color"
24
+ private const val EXTRA_ANDROID_ICON = "aa_android_small_icon"
25
+ private const val EXTRA_ANDROID_TICKER = "aa_android_ticker"
26
+ private const val EXTRA_ANDROID_STICKY = "aa_android_sticky"
27
+ private const val EXTRA_ANDROID_VISIBILITY = "aa_android_visibility"
28
+ private const val EXTRA_ANDROID_CHANNEL_ID = "aa_android_channel_id"
29
+ private const val EXTRA_ANDROID_TAG = "aa_android_tag"
30
+ private const val EXTRA_ANDROID_SOUND = "aa_android_sound"
31
+ private const val EXTRA_ANDROID_CLICK_ACTION = "aa_android_click_action"
32
+ private const val EXTRA_DATA_KEYS = "aa_data_keys"
33
+ private const val EXTRA_DATA_VALS = "aa_data_vals"
34
+
35
+ private const val TASK_TIMEOUT_MS = 30_000L
36
+
37
+ fun enqueueNotification(context: Context, notification: AppAmbitNotification) {
38
+ Log.d(TAG, "Enqueueing headless task for: ${notification.title}")
39
+ HeadlessJsTaskService.acquireWakeLockNow(context)
40
+ val intent = buildIntent(context, notification)
41
+ try {
42
+ context.startService(intent)
43
+ } catch (e: Exception) {
44
+ Log.e(TAG, "Could not start HeadlessService", e)
45
+ }
46
+ }
47
+
48
+ private fun buildIntent(context: Context, notification: AppAmbitNotification): Intent {
49
+ val data = notification.data
50
+ val intent = Intent(context, AppAmbitHeadlessService::class.java)
51
+
52
+ intent.putExtra(EXTRA_TITLE, notification.title)
53
+ intent.putExtra(EXTRA_BODY, notification.body)
54
+ intent.putExtra(EXTRA_ANDROID_COLOR, notification.color)
55
+ intent.putExtra(EXTRA_ANDROID_ICON, notification.smallIconName)
56
+
57
+ val imageUrl = notification.imageUrl
58
+ ?: data["_aa_image_url"]
59
+ ?: data["image_url"]
60
+ ?: data["image"]
61
+ intent.putExtra(EXTRA_IMAGE_URL, imageUrl)
62
+
63
+ intent.putExtra(EXTRA_ANDROID_TICKER, data["_aa_ticker"] ?: data["ticker"])
64
+ intent.putExtra(EXTRA_ANDROID_VISIBILITY, data["_aa_visibility"] ?: data["visibility"])
65
+ intent.putExtra(EXTRA_ANDROID_CHANNEL_ID, data["_aa_channel_id"] ?: data["channelId"] ?: data["channel_id"])
66
+ intent.putExtra(EXTRA_ANDROID_TAG, data["_aa_tag"] ?: data["tag"])
67
+ intent.putExtra(EXTRA_ANDROID_SOUND, data["_aa_sound"] ?: data["sound"])
68
+ intent.putExtra(EXTRA_ANDROID_CLICK_ACTION, data["_aa_click_action"] ?: data["clickAction"] ?: data["click_action"])
69
+
70
+ val stickyStr = data["_aa_sticky"] ?: data["sticky"]
71
+ if (stickyStr != null) {
72
+ intent.putExtra(EXTRA_ANDROID_STICKY, stickyStr.equals("true", ignoreCase = true) || stickyStr == "1")
73
+ }
74
+
75
+ val filteredData = data.filterKeys { it !in AppAmbitPayloadUtils.INTERNAL_KEYS }
76
+ if (filteredData.isNotEmpty()) {
77
+ val keys = filteredData.keys.toTypedArray()
78
+ val values = keys.map { filteredData[it] }.toTypedArray()
79
+ intent.putExtra(EXTRA_DATA_KEYS, keys)
80
+ intent.putExtra(EXTRA_DATA_VALS, values)
81
+ }
82
+ return intent
83
+ }
84
+ }
85
+
86
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
87
+ if (application !is ReactApplication) {
88
+ Log.e(TAG,
89
+ "Application does not implement ReactApplication. " +
90
+ "Headless JS cannot start. Ensure your Application class extends ReactApplication."
91
+ )
92
+ stopSelf()
93
+ return START_NOT_STICKY
94
+ }
95
+ return super.onStartCommand(intent, flags, startId)
96
+ }
97
+
98
+ override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
99
+ val extras = intent?.extras ?: return null
100
+ val rm = AppAmbitRemoteMessageStore.get()
101
+ val fcmNotif = rm?.notification
102
+
103
+ val payload = Arguments.createMap()
104
+ payload.putString("title", extras.getString(EXTRA_TITLE))
105
+ payload.putString("body", extras.getString(EXTRA_BODY))
106
+
107
+ val imageUrl = extras.getString(EXTRA_IMAGE_URL) ?: fcmNotif?.imageUrl?.toString()
108
+ if (imageUrl != null) payload.putString("imageUrl", imageUrl) else payload.putNull("imageUrl")
109
+
110
+ val keys = extras.getStringArray(EXTRA_DATA_KEYS)
111
+ val values = extras.getStringArray(EXTRA_DATA_VALS)
112
+ val dataMap = Arguments.createMap()
113
+ if (keys != null && values != null) {
114
+ keys.forEachIndexed { i, key ->
115
+ if (i < values.size) dataMap.putString(key, values[i])
116
+ }
117
+ }
118
+ payload.putMap("data", dataMap)
119
+
120
+ // ── FCM message-level fields ──────────────────────────────────────────
121
+ AppAmbitPayloadUtils.putFcmMessageFields(payload, rm)
122
+
123
+ // ── Android sub-object ────────────────────────────────────────────────
124
+ val androidMap = Arguments.createMap()
125
+
126
+ putStringOrNull(androidMap, "color", extras.getString(EXTRA_ANDROID_COLOR))
127
+ putStringOrNull(androidMap, "smallIconName", extras.getString(EXTRA_ANDROID_ICON))
128
+
129
+ // Intent extras take priority; fall back to RemoteMessage.Notification
130
+ putStringOrNull(androidMap, "ticker",
131
+ extras.getString(EXTRA_ANDROID_TICKER) ?: fcmNotif?.ticker)
132
+ putStringOrNull(androidMap, "channelId",
133
+ extras.getString(EXTRA_ANDROID_CHANNEL_ID) ?: fcmNotif?.channelId)
134
+ putStringOrNull(androidMap, "tag",
135
+ extras.getString(EXTRA_ANDROID_TAG) ?: fcmNotif?.tag)
136
+ putStringOrNull(androidMap, "sound",
137
+ extras.getString(EXTRA_ANDROID_SOUND) ?: fcmNotif?.sound)
138
+ putStringOrNull(androidMap, "clickAction",
139
+ extras.getString(EXTRA_ANDROID_CLICK_ACTION) ?: fcmNotif?.clickAction)
140
+ putStringOrNull(androidMap, "visibility",
141
+ extras.getString(EXTRA_ANDROID_VISIBILITY)
142
+ ?: AppAmbitPayloadUtils.fcmVisibilityToString(fcmNotif?.visibility))
143
+
144
+ when {
145
+ extras.containsKey(EXTRA_ANDROID_STICKY) ->
146
+ androidMap.putBoolean("sticky", extras.getBoolean(EXTRA_ANDROID_STICKY))
147
+ fcmNotif != null ->
148
+ androidMap.putBoolean("sticky", fcmNotif.sticky)
149
+ else ->
150
+ androidMap.putNull("sticky")
151
+ }
152
+
153
+ if (fcmNotif != null) {
154
+ androidMap.putBoolean("localOnly", fcmNotif.localOnly)
155
+ } else {
156
+ androidMap.putNull("localOnly")
157
+ }
158
+
159
+ payload.putMap("android", androidMap)
160
+ payload.putNull("ios")
161
+
162
+ Log.d(TAG, "HeadlessJsTask config built for: ${extras.getString(EXTRA_TITLE)}")
163
+ return HeadlessJsTaskConfig(
164
+ HEADLESS_TASK_NAME,
165
+ payload,
166
+ TASK_TIMEOUT_MS,
167
+ /* allowedInForeground = */ true
168
+ )
169
+ }
170
+
171
+ // ── Helpers ───────────────────────────────────────────────────────────────
172
+
173
+ private fun putStringOrNull(map: com.facebook.react.bridge.WritableMap, key: String, value: String?) {
174
+ AppAmbitPayloadUtils.putStringOrNull(map, key, value)
175
+ }
176
+
177
+ }
@@ -0,0 +1,73 @@
1
+ package com.appambitpushnotifications
2
+
3
+ import android.content.ContentProvider
4
+ import android.content.ContentValues
5
+ import android.database.Cursor
6
+ import android.net.Uri
7
+ import android.util.Log
8
+
9
+ /**
10
+ * AppAmbitInitProvider — early-initialisation ContentProvider.
11
+ *
12
+ * Android starts ContentProviders BEFORE [Application.onCreate], which makes them
13
+ * the only reliable way to obtain an [android.app.Application] context at process
14
+ * startup — even when the process is created solely to handle an FCM message (killed state).
15
+ *
16
+ * This provider sets [AppAmbitContextHolder.applicationContext] at the earliest possible
17
+ * moment so that any component in the SDK can rely on it without waiting for the React
18
+ * Native bridge to initialise.
19
+ *
20
+ * This is the exact same pattern used by:
21
+ * - Firebase (FirebaseInitProvider)
22
+ * - WorkManager (WorkManagerInitializer)
23
+ * - OneSignal (OneSignalSyncServiceUtils)
24
+ *
25
+ * Registration:
26
+ * Declared automatically in the SDK's AndroidManifest.xml (merged into the host app):
27
+ *
28
+ * <provider
29
+ * android:name="com.appambitpushnotifications.AppAmbitInitProvider"
30
+ * android:authorities="${applicationId}.appambit-init-provider"
31
+ * android:exported="false"
32
+ * android:initOrder="100" />
33
+ *
34
+ * No action is required from the consumer app.
35
+ */
36
+ internal class AppAmbitInitProvider : ContentProvider() {
37
+
38
+ private val TAG = "AppAmbitInitProvider"
39
+
40
+ override fun onCreate(): Boolean {
41
+ val appContext = context?.applicationContext
42
+ if (appContext == null) {
43
+ Log.e(TAG, "Context is null during ContentProvider.onCreate — cannot initialise AppAmbitContextHolder")
44
+ return false
45
+ }
46
+ AppAmbitContextHolder.set(appContext)
47
+ Log.d(TAG, "AppAmbitContextHolder initialised early via ContentProvider")
48
+ return true
49
+ }
50
+
51
+ // ── Required ContentProvider stubs (this provider serves no data) ─────────
52
+
53
+ override fun query(
54
+ uri: Uri,
55
+ projection: Array<String>?,
56
+ selection: String?,
57
+ selectionArgs: Array<String>?,
58
+ sortOrder: String?
59
+ ): Cursor? = null
60
+
61
+ override fun getType(uri: Uri): String? = null
62
+
63
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
64
+
65
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
66
+
67
+ override fun update(
68
+ uri: Uri,
69
+ values: ContentValues?,
70
+ selection: String?,
71
+ selectionArgs: Array<String>?
72
+ ): Int = 0
73
+ }
@@ -0,0 +1,12 @@
1
+ package com.appambitpushnotifications
2
+
3
+ import com.appambit.sdk.MessagingService
4
+ import com.google.firebase.messaging.RemoteMessage
5
+
6
+ class AppAmbitMessagingService : MessagingService() {
7
+
8
+ override fun onMessageReceived(remoteMessage: RemoteMessage) {
9
+ AppAmbitRemoteMessageStore.set(remoteMessage)
10
+ super.onMessageReceived(remoteMessage)
11
+ }
12
+ }
@@ -0,0 +1,88 @@
1
+ package com.appambitpushnotifications
2
+
3
+ import com.appambit.sdk.models.AppAmbitNotification
4
+ import com.facebook.react.bridge.Arguments
5
+ import com.facebook.react.bridge.WritableMap
6
+
7
+ internal object AppAmbitNotificationSerializer {
8
+
9
+ fun toEventPayload(notification: AppAmbitNotification): WritableMap {
10
+ val data = notification.data
11
+ val rm = AppAmbitRemoteMessageStore.get()
12
+ val fcmNotif = rm?.notification
13
+ val payload = Arguments.createMap()
14
+
15
+ // ── Core fields ───────────────────────────────────────────────────────
16
+ payload.putString("title", notification.title)
17
+ payload.putString("body", notification.body)
18
+
19
+ val imageUrl = notification.imageUrl
20
+ ?: data["_aa_image_url"]
21
+ ?: data["image_url"]
22
+ ?: data["image"]
23
+ ?: fcmNotif?.imageUrl?.toString()
24
+ AppAmbitPayloadUtils.putStringOrNull(payload, "imageUrl", imageUrl)
25
+
26
+ // ── Custom data — strip internal _aa_* keys ───────────────────────────
27
+ val dataMap = Arguments.createMap()
28
+ data.forEach { (key, value) ->
29
+ if (key !in AppAmbitPayloadUtils.INTERNAL_KEYS) dataMap.putString(key, value)
30
+ }
31
+ payload.putMap("data", dataMap)
32
+
33
+ // ── FCM message-level fields ──────────────────────────────────────────
34
+ AppAmbitPayloadUtils.putFcmMessageFields(payload, rm)
35
+
36
+ // ── Android sub-object ────────────────────────────────────────────────
37
+ payload.putMap("android", buildAndroidMap(data, notification, fcmNotif))
38
+ payload.putNull("ios")
39
+
40
+ return payload
41
+ }
42
+
43
+ private fun buildAndroidMap(
44
+ data: Map<String, String>,
45
+ notification: AppAmbitNotification,
46
+ fcmNotif: com.google.firebase.messaging.RemoteMessage.Notification?
47
+ ): WritableMap {
48
+ val map = Arguments.createMap()
49
+
50
+ // Fields from AppAmbitNotification model directly
51
+ AppAmbitPayloadUtils.putStringOrNull(map, "color", notification.color)
52
+ AppAmbitPayloadUtils.putStringOrNull(map, "smallIconName", notification.smallIconName)
53
+
54
+ // Fields read from data map (_aa_* prefix wins) with RemoteMessage.Notification fallback
55
+ AppAmbitPayloadUtils.putStringOrNull(map, "ticker",
56
+ data["_aa_ticker"] ?: data["ticker"] ?: fcmNotif?.ticker)
57
+ AppAmbitPayloadUtils.putStringOrNull(map, "channelId",
58
+ data["_aa_channel_id"] ?: data["channelId"] ?: data["channel_id"] ?: fcmNotif?.channelId)
59
+ AppAmbitPayloadUtils.putStringOrNull(map, "tag",
60
+ data["_aa_tag"] ?: data["tag"] ?: fcmNotif?.tag)
61
+ AppAmbitPayloadUtils.putStringOrNull(map, "sound",
62
+ data["_aa_sound"] ?: data["sound"] ?: fcmNotif?.sound)
63
+ AppAmbitPayloadUtils.putStringOrNull(map, "clickAction",
64
+ data["_aa_click_action"] ?: data["clickAction"] ?: data["click_action"] ?: fcmNotif?.clickAction)
65
+ AppAmbitPayloadUtils.putStringOrNull(map, "visibility",
66
+ data["_aa_visibility"] ?: data["visibility"]
67
+ ?: AppAmbitPayloadUtils.fcmVisibilityToString(fcmNotif?.visibility))
68
+ // sticky: data key takes priority, then RemoteMessage.Notification field
69
+ val stickyParsed = AppAmbitPayloadUtils.parseSticky(data["_aa_sticky"] ?: data["sticky"])
70
+ when {
71
+ stickyParsed != null ->
72
+ map.putBoolean("sticky", stickyParsed)
73
+ fcmNotif != null ->
74
+ map.putBoolean("sticky", fcmNotif.sticky)
75
+ else ->
76
+ map.putNull("sticky")
77
+ }
78
+
79
+ if (fcmNotif != null) {
80
+ map.putBoolean("localOnly", fcmNotif.localOnly)
81
+ } else {
82
+ map.putNull("localOnly")
83
+ }
84
+
85
+ return map
86
+ }
87
+
88
+ }
@@ -0,0 +1,59 @@
1
+ package com.appambitpushnotifications
2
+
3
+ import com.facebook.react.bridge.WritableMap
4
+ import com.google.firebase.messaging.RemoteMessage
5
+
6
+ /**
7
+ * Shared helpers for building the cross-platform NotificationPayload on Android.
8
+ *
9
+ * Used by both delivery paths:
10
+ * - [AppAmbitNotificationSerializer]: app alive (reads from AppAmbitNotification).
11
+ * - [AppAmbitHeadlessService]: app killed (reads from Intent extras).
12
+ */
13
+ internal object AppAmbitPayloadUtils {
14
+
15
+ /** Internal _aa_* keys injected by checkInitialIntent (cold-start opened path). */
16
+ val INTERNAL_KEYS = setOf(
17
+ "_aa_image_url", "_aa_ticker", "_aa_sticky",
18
+ "_aa_visibility", "_aa_channel_id", "_aa_priority",
19
+ "_aa_tag", "_aa_sound", "_aa_click_action"
20
+ )
21
+
22
+ fun putStringOrNull(map: WritableMap, key: String, value: String?) {
23
+ if (value != null) map.putString(key, value) else map.putNull(key)
24
+ }
25
+
26
+ /** RemoteMessage.PRIORITY_HIGH=1, PRIORITY_NORMAL=2, PRIORITY_UNKNOWN=0 */
27
+ fun fcmPriorityToString(priority: Int): String? = when (priority) {
28
+ 1 -> "high"
29
+ 2 -> "normal"
30
+ else -> null
31
+ }
32
+
33
+ fun fcmVisibilityToString(visibility: Int?): String? = when (visibility) {
34
+ 1 -> "public"
35
+ 0 -> "private"
36
+ -1 -> "secret"
37
+ else -> null
38
+ }
39
+
40
+ fun parseSticky(value: String?): Boolean? =
41
+ value?.let { it.equals("true", ignoreCase = true) || it == "1" }
42
+
43
+ /** FCM message-level fields, shared by both delivery paths. */
44
+ fun putFcmMessageFields(payload: WritableMap, rm: RemoteMessage?) {
45
+ if (rm != null) {
46
+ putStringOrNull(payload, "messageId", rm.messageId)
47
+ payload.putDouble("sentTime", rm.sentTime.toDouble())
48
+ payload.putInt("ttl", rm.ttl)
49
+ putStringOrNull(payload, "collapseKey", rm.collapseKey)
50
+ putStringOrNull(payload, "from", rm.from)
51
+ putStringOrNull(payload, "messagePriority", fcmPriorityToString(rm.priority))
52
+ putStringOrNull(payload, "originalPriority", fcmPriorityToString(rm.originalPriority))
53
+ } else {
54
+ for (key in listOf("messageId", "sentTime", "ttl", "collapseKey",
55
+ "from", "messagePriority", "originalPriority"))
56
+ payload.putNull(key)
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,100 @@
1
+ package com.appambitpushnotifications
2
+
3
+ import android.util.Log
4
+ import com.facebook.react.bridge.ReactApplicationContext
5
+ import com.facebook.react.bridge.WritableMap
6
+ import com.facebook.react.modules.core.DeviceEventManagerModule
7
+ import java.util.concurrent.CopyOnWriteArrayList
8
+
9
+ /**
10
+ * Thread-safe event emitter for the AppAmbit Push SDK.
11
+ *
12
+ * Design goals:
13
+ * 1. Never crash if ReactContext is not yet initialized (e.g. cold start).
14
+ * 2. Queue events that arrive before JS is ready and drain them once the
15
+ * bridge is up.
16
+ * 3. Provide a single place to route all native→JS events so that the
17
+ * Module, the HeadlessTask, and the ServiceExtension all emit through
18
+ * the same channel.
19
+ */
20
+ internal object AppAmbitPushEventEmitter {
21
+
22
+ private const val TAG = "AppAmbitEmitter"
23
+ private const val MAX_QUEUE_SIZE = 100
24
+
25
+ // Events used by the SDK — kept here to avoid magic strings everywhere.
26
+ const val EVENT_FOREGROUND = "AppAmbit_onForegroundNotification"
27
+ const val EVENT_BACKGROUND = "AppAmbit_onBackgroundNotification"
28
+ const val EVENT_OPENED = "AppAmbit_onOpenedNotification"
29
+
30
+ @Volatile
31
+ private var reactContext: ReactApplicationContext? = null
32
+
33
+ // Queue for events that arrive before the React bridge is ready.
34
+ private val eventQueue = CopyOnWriteArrayList<Pair<String, WritableMap>>()
35
+
36
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Called once the TurboModule is instantiated.
40
+ * Drains the queue of any events that arrived before JS was ready.
41
+ */
42
+ fun attach(context: ReactApplicationContext) {
43
+ reactContext = context
44
+ Log.d(TAG, "ReactContext attached — draining ${eventQueue.size} queued events")
45
+ val iter = eventQueue.iterator()
46
+ while (iter.hasNext()) {
47
+ val (event, payload) = iter.next()
48
+ dispatchToJS(event, payload)
49
+ }
50
+ eventQueue.clear()
51
+ }
52
+
53
+ /**
54
+ * Called when the module is invalidated (e.g. hot reload or app restart).
55
+ *
56
+ * NOTE: we intentionally do NOT clear [eventQueue] here. Events that arrived
57
+ * during a React Native reload should be delivered to the next bridge instance
58
+ * once [attach] is called again.
59
+ */
60
+ fun detach() {
61
+ reactContext = null
62
+ // eventQueue is preserved so events survive a bridge restart / hot reload.
63
+ }
64
+
65
+ // ── Emission ─────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Emit an event. If the React bridge is ready the event is delivered
69
+ * immediately; otherwise it is queued until [attach] is called.
70
+ */
71
+ fun emit(eventName: String, payload: WritableMap) {
72
+ if (!dispatchToJS(eventName, payload)) {
73
+ Log.d(TAG, "Bridge not ready — queuing event: $eventName")
74
+ if (eventQueue.size >= MAX_QUEUE_SIZE) {
75
+ eventQueue.removeAt(0)
76
+ Log.w(TAG, "Event queue full — dropped oldest event")
77
+ }
78
+ eventQueue.add(eventName to payload)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Returns true if the event was emitted, false if the bridge was not available.
84
+ */
85
+ private fun dispatchToJS(eventName: String, payload: WritableMap): Boolean {
86
+ val ctx = reactContext ?: return false
87
+ return try {
88
+ if (ctx.hasActiveReactInstance()) {
89
+ ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
90
+ .emit(eventName, payload)
91
+ true
92
+ } else {
93
+ false
94
+ }
95
+ } catch (e: Exception) {
96
+ Log.e(TAG, "Failed to emit event $eventName", e)
97
+ false
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,75 @@
1
+ package com.appambitpushnotifications
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import com.appambit.sdk.IAppAmbitNotificationServiceExtension
6
+ import com.appambit.sdk.models.AppAmbitNotification
7
+
8
+ /**
9
+ * Concrete implementation of [IAppAmbitNotificationServiceExtension] that acts
10
+ * as the bridge between the AppAmbit Android Push SDK and React Native.
11
+ *
12
+ * Registration:
13
+ * Declared in the consuming app's AndroidManifest.xml as a <meta-data> entry:
14
+ *
15
+ * <meta-data
16
+ * android:name="com.appambit.sdk.NotificationServiceExtension"
17
+ * android:value="com.appambitpushnotifications.AppAmbitRNServiceExtension" />
18
+ *
19
+ * The SDK's MessagingService reads this meta-data key and reflectively instantiates
20
+ * this class, then calls [onNotificationForeground] or [onNotificationBackground]
21
+ * depending on the current app state.
22
+ *
23
+ * Threading:
24
+ * Both callbacks are invoked on a background thread from within FirebaseMessagingService.
25
+ * The AppAmbitPushEventEmitter is thread-safe, so no synchronization is needed here.
26
+ *
27
+ * Background / killed state:
28
+ * When the app is in background or killed, [onNotificationBackground] fires.
29
+ * We receive the FirebaseMessagingService Context directly (always valid) and
30
+ * pass it to AppAmbitHeadlessService so it can start without any static holder.
31
+ */
32
+ class AppAmbitRNServiceExtension : IAppAmbitNotificationServiceExtension {
33
+
34
+ private val TAG = "AppAmbitRNExtension"
35
+
36
+ // ── Abstract method implementations (no-ops) ──────────────────────────────
37
+ // MessagingService always calls the Context-carrying overloads below.
38
+ // These no-context variants exist solely to satisfy the interface contract.
39
+
40
+ override fun onNotificationForeground(notification: AppAmbitNotification) = Unit
41
+
42
+ override fun onNotificationBackground(notification: AppAmbitNotification) = Unit
43
+
44
+ // ── Context-carrying overrides (actual implementations) ───────────────────
45
+ // Overriding a default interface method takes full priority over the default.
46
+ // MessagingService calls ext.onNotificationXxx(this, notification) — "this"
47
+ // is the FirebaseMessagingService, which is a valid Context at all times,
48
+ // even in killed state before the React Native bridge exists.
49
+
50
+ /**
51
+ * Called when the app is in the foreground.
52
+ * [context] is the FirebaseMessagingService — always valid.
53
+ */
54
+ override fun onNotificationForeground(context: Context, notification: AppAmbitNotification) {
55
+ Log.d(TAG, "onNotificationForeground: ${notification.title}")
56
+ val payload = AppAmbitNotificationSerializer.toEventPayload(notification)
57
+ AppAmbitPushEventEmitter.emit(AppAmbitPushEventEmitter.EVENT_FOREGROUND, payload)
58
+ }
59
+
60
+ /**
61
+ * Called when the app is in background or killed state.
62
+ * [context] is the FirebaseMessagingService — always valid, even before the RN bridge starts.
63
+ */
64
+ override fun onNotificationBackground(context: Context, notification: AppAmbitNotification) {
65
+ Log.d(TAG, "onNotificationBackground: ${notification.title}")
66
+ val payload = AppAmbitNotificationSerializer.toEventPayload(notification)
67
+
68
+ // 1. Try to emit directly (works when the React host is alive in background).
69
+ AppAmbitPushEventEmitter.emit(AppAmbitPushEventEmitter.EVENT_BACKGROUND, payload)
70
+
71
+ // 2. Trigger Headless JS using the FirebaseMessagingService context (always valid).
72
+ // This starts the JS task even in killed state, without relying on any RN bridge holder.
73
+ AppAmbitHeadlessService.enqueueNotification(context, notification)
74
+ }
75
+ }
@@ -0,0 +1,26 @@
1
+ package com.appambitpushnotifications
2
+
3
+ import com.google.firebase.messaging.RemoteMessage
4
+ import java.util.concurrent.atomic.AtomicLong
5
+ import java.util.concurrent.atomic.AtomicReference
6
+
7
+ internal object AppAmbitRemoteMessageStore {
8
+
9
+ private val stored = AtomicReference<RemoteMessage?>(null)
10
+ private val storedAt = AtomicLong(0L)
11
+ private const val TTL_MS = 60_000L
12
+
13
+ fun set(message: RemoteMessage) {
14
+ stored.set(message)
15
+ storedAt.set(System.currentTimeMillis())
16
+ }
17
+
18
+ fun get(): RemoteMessage? {
19
+ val msg = stored.get() ?: return null
20
+ if (System.currentTimeMillis() - storedAt.get() > TTL_MS) {
21
+ stored.set(null)
22
+ return null
23
+ }
24
+ return msg
25
+ }
26
+ }