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.
- package/AppAmbitSdkPushNotifications.podspec +14 -4
- package/README.md +184 -105
- package/android/build.gradle +21 -3
- package/android/src/main/AndroidManifest.xml +107 -1
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitContextHolder.kt +22 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitHeadlessService.kt +177 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitInitProvider.kt +73 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitMessagingService.kt +12 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitNotificationSerializer.kt +88 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitPayloadUtils.kt +59 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitPushEventEmitter.kt +100 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitRNServiceExtension.kt +75 -0
- package/android/src/main/java/com/appambitpushnotifications/AppAmbitRemoteMessageStore.kt +26 -0
- package/android/src/main/java/com/appambitpushnotifications/AppambitPushNotificationsModule.kt +377 -76
- package/ios/AppAmbitNotificationSwizzler.m +290 -0
- package/ios/AppAmbitPushWrapper.swift +165 -25
- package/ios/AppAmbitRNNotificationService.swift +46 -0
- package/ios/AppambitPushNotifications.mm +264 -10
- package/lib/module/NativeAppambitPushNotifications.js.map +1 -1
- package/lib/module/index.js +46 -10
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/NativeAppambitPushNotifications.d.ts +2 -1
- package/lib/typescript/src/NativeAppambitPushNotifications.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +32 -6
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/NativeAppambitPushNotifications.ts +7 -1
- 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
|
+
}
|