react-native-qpush 0.0.0-stage → 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 (98) hide show
  1. package/README.md +439 -2
  2. package/android/build.gradle +77 -0
  3. package/android/gradle.properties +5 -0
  4. package/android/proguard-rules.pro +5 -0
  5. package/android/src/main/AndroidManifest.xml +29 -0
  6. package/android/src/main/java/com/qpush/reactnative/QPushDeviceInfo.kt +96 -0
  7. package/android/src/main/java/com/qpush/reactnative/QPushEventBus.kt +153 -0
  8. package/android/src/main/java/com/qpush/reactnative/QPushFcm.kt +108 -0
  9. package/android/src/main/java/com/qpush/reactnative/QPushHttp.kt +92 -0
  10. package/android/src/main/java/com/qpush/reactnative/QPushImages.kt +103 -0
  11. package/android/src/main/java/com/qpush/reactnative/QPushLog.kt +39 -0
  12. package/android/src/main/java/com/qpush/reactnative/QPushMessagingService.kt +77 -0
  13. package/android/src/main/java/com/qpush/reactnative/QPushNotificationOpenActivity.kt +121 -0
  14. package/android/src/main/java/com/qpush/reactnative/QPushNotificationRenderer.kt +256 -0
  15. package/android/src/main/java/com/qpush/reactnative/QPushPayload.kt +114 -0
  16. package/android/src/main/java/com/qpush/reactnative/QPushPushModule.kt +368 -0
  17. package/android/src/main/java/com/qpush/reactnative/QPushPushPackage.kt +15 -0
  18. package/android/src/main/java/com/qpush/reactnative/QPushStore.kt +108 -0
  19. package/android/src/main/res/values/qpush_strings.xml +6 -0
  20. package/app.plugin.js +1 -0
  21. package/ios/Extension/QPushNotificationServiceExtension.swift +261 -0
  22. package/ios/Extension/QPushPushExtension.podspec +34 -0
  23. package/ios/QPushPush.m +62 -0
  24. package/ios/QPushPush.swift +186 -0
  25. package/ios/QPushPushBridge.h +5 -0
  26. package/ios/QPushPushCore.swift +457 -0
  27. package/ios/QPushPushHooks.m +237 -0
  28. package/ios/QPushPushSupport.swift +249 -0
  29. package/ios_nse_template/Info.plist +38 -0
  30. package/ios_nse_template/NotificationService.entitlements +10 -0
  31. package/ios_nse_template/NotificationService.swift +44 -0
  32. package/lib/NativeQPushPush.d.ts +44 -0
  33. package/lib/NativeQPushPush.js +4 -0
  34. package/lib/QPush.d.ts +117 -0
  35. package/lib/QPush.js +206 -0
  36. package/lib/core/QPushCore.d.ts +148 -0
  37. package/lib/core/QPushCore.js +1387 -0
  38. package/lib/core/constants.d.ts +37 -0
  39. package/lib/core/constants.js +40 -0
  40. package/lib/core/events.d.ts +11 -0
  41. package/lib/core/events.js +40 -0
  42. package/lib/core/http.d.ts +41 -0
  43. package/lib/core/http.js +123 -0
  44. package/lib/core/logger.d.ts +18 -0
  45. package/lib/core/logger.js +43 -0
  46. package/lib/core/operationQueue.d.ts +117 -0
  47. package/lib/core/operationQueue.js +264 -0
  48. package/lib/core/operations.d.ts +80 -0
  49. package/lib/core/operations.js +88 -0
  50. package/lib/core/payload.d.ts +20 -0
  51. package/lib/core/payload.js +187 -0
  52. package/lib/core/platform.d.ts +72 -0
  53. package/lib/core/platform.js +7 -0
  54. package/lib/core/state.d.ts +51 -0
  55. package/lib/core/state.js +74 -0
  56. package/lib/core/storage.d.ts +34 -0
  57. package/lib/core/storage.js +68 -0
  58. package/lib/core/subscription.d.ts +67 -0
  59. package/lib/core/subscription.js +98 -0
  60. package/lib/core/tags.d.ts +39 -0
  61. package/lib/core/tags.js +123 -0
  62. package/lib/core/url.d.ts +17 -0
  63. package/lib/core/url.js +64 -0
  64. package/lib/core/uuid.d.ts +2 -0
  65. package/lib/core/uuid.js +18 -0
  66. package/lib/index.d.ts +7 -0
  67. package/lib/index.js +15 -0
  68. package/lib/notification.d.ts +67 -0
  69. package/lib/notification.js +73 -0
  70. package/lib/reactNativePlatform.d.ts +3 -0
  71. package/lib/reactNativePlatform.js +164 -0
  72. package/lib/types.d.ts +157 -0
  73. package/lib/types.js +31 -0
  74. package/package.json +70 -4
  75. package/plugin/withQPush.js +330 -0
  76. package/react-native-qpush.podspec +30 -0
  77. package/react-native.config.js +18 -0
  78. package/src/NativeQPushPush.ts +57 -0
  79. package/src/QPush.ts +292 -0
  80. package/src/core/QPushCore.ts +1515 -0
  81. package/src/core/constants.ts +42 -0
  82. package/src/core/events.ts +34 -0
  83. package/src/core/http.ts +146 -0
  84. package/src/core/logger.ts +42 -0
  85. package/src/core/operationQueue.ts +324 -0
  86. package/src/core/operations.ts +166 -0
  87. package/src/core/payload.ts +175 -0
  88. package/src/core/platform.ts +84 -0
  89. package/src/core/state.ts +127 -0
  90. package/src/core/storage.ts +76 -0
  91. package/src/core/subscription.ts +133 -0
  92. package/src/core/tags.ts +116 -0
  93. package/src/core/url.ts +58 -0
  94. package/src/core/uuid.ts +13 -0
  95. package/src/index.ts +23 -0
  96. package/src/notification.ts +113 -0
  97. package/src/reactNativePlatform.ts +172 -0
  98. package/src/types.ts +174 -0
@@ -0,0 +1,153 @@
1
+ package com.qpush.reactnative
2
+
3
+ import android.app.ActivityManager
4
+ import android.content.Context
5
+ import android.os.Handler
6
+ import android.os.Looper
7
+ import java.lang.ref.WeakReference
8
+ import java.util.UUID
9
+ import java.util.concurrent.ConcurrentHashMap
10
+ import java.util.concurrent.ExecutorService
11
+ import java.util.concurrent.Executors
12
+
13
+ /**
14
+ * Bridge between native components (messaging service, trampoline activity)
15
+ * and the JS layer. Events are queued until JS subscribed (`setJsReady`), so
16
+ * a click that cold-starts the app is delivered once the bundle is running.
17
+ */
18
+ internal object QPushEventBus {
19
+ const val EVENT_CLICK = "QPushPushClick"
20
+ const val EVENT_FOREGROUND_WILL_DISPLAY = "QPushPushForegroundWillDisplay"
21
+ const val EVENT_PUSH_TOKEN = "QPushPushToken"
22
+
23
+ /** JS must answer a foregroundWillDisplay event within this time, else the notification is shown. */
24
+ private const val FOREGROUND_RESPONSE_TIMEOUT_MS = 1_000L
25
+ /** After `preventDefault()`, JS may still call `display()` during this window. */
26
+ private const val PREVENTED_EXPIRY_MS = 25_000L
27
+ private const val MAX_PENDING_EVENTS = 50
28
+
29
+ private class PendingEvent(val name: String, val body: Map<String, Any?>)
30
+
31
+ private class PendingDisplay(val context: Context, val payload: QPushPayload) {
32
+ var timeout: Runnable? = null
33
+ }
34
+
35
+ private val lock = Any()
36
+ private val pendingEvents = ArrayList<PendingEvent>()
37
+ private val pendingDisplays = ConcurrentHashMap<String, PendingDisplay>()
38
+ private val mainHandler = Handler(Looper.getMainLooper())
39
+
40
+ /** Single thread for rendering (image downloads) outside the main thread. */
41
+ val worker: ExecutorService = Executors.newSingleThreadExecutor()
42
+
43
+ @Volatile
44
+ private var moduleRef: WeakReference<QPushPushModule>? = null
45
+
46
+ @Volatile
47
+ private var jsReady = false
48
+
49
+ @Volatile
50
+ var hostResumed = false
51
+
52
+ fun attach(module: QPushPushModule) {
53
+ moduleRef = WeakReference(module)
54
+ jsReady = false
55
+ }
56
+
57
+ fun detach(module: QPushPushModule) {
58
+ if (moduleRef?.get() === module) {
59
+ moduleRef = null
60
+ jsReady = false
61
+ hostResumed = false
62
+ }
63
+ }
64
+
65
+ /** Called by JS once it subscribed to the native events. */
66
+ fun setJsReady() {
67
+ val toSend: List<PendingEvent>
68
+ synchronized(lock) {
69
+ jsReady = true
70
+ toSend = ArrayList(pendingEvents)
71
+ pendingEvents.clear()
72
+ }
73
+ for (event in toSend) emit(event.name, event.body)
74
+ }
75
+
76
+ fun emit(name: String, body: Map<String, Any?>) {
77
+ val module = moduleRef?.get()
78
+ if (module != null && jsReady && module.canEmit()) {
79
+ try {
80
+ module.sendEvent(name, body)
81
+ return
82
+ } catch (e: Exception) {
83
+ QPushLog.w("Could not emit $name, queueing it", e)
84
+ }
85
+ }
86
+ synchronized(lock) {
87
+ pendingEvents.add(PendingEvent(name, body))
88
+ while (pendingEvents.size > MAX_PENDING_EVENTS) pendingEvents.removeAt(0)
89
+ }
90
+ }
91
+
92
+ fun emitClick(rawPayload: String, actionId: String?, url: String?) {
93
+ emit(EVENT_CLICK, mapOf("rawPayload" to rawPayload, "actionId" to actionId, "url" to url))
94
+ }
95
+
96
+ fun emitToken(token: String) {
97
+ emit(EVENT_PUSH_TOKEN, mapOf("token" to token))
98
+ }
99
+
100
+ /** True when the app UI is visible and JS can decide about foreground display. */
101
+ fun canHandleForeground(): Boolean {
102
+ val module = moduleRef?.get() ?: return false
103
+ return jsReady && hostResumed && module.canEmit() && isProcessInForeground()
104
+ }
105
+
106
+ private fun isProcessInForeground(): Boolean {
107
+ return try {
108
+ val info = ActivityManager.RunningAppProcessInfo()
109
+ ActivityManager.getMyMemoryState(info)
110
+ info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
111
+ } catch (e: Exception) {
112
+ false
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Asks JS whether to display a notification received in the foreground.
118
+ * Returns false when JS cannot answer (caller renders directly).
119
+ */
120
+ fun requestForegroundDisplay(context: Context, payload: QPushPayload): Boolean {
121
+ if (!canHandleForeground()) return false
122
+ val displayId = UUID.randomUUID().toString()
123
+ val pending = PendingDisplay(context.applicationContext, payload)
124
+ pendingDisplays[displayId] = pending
125
+ val timeout = Runnable { display(displayId) }
126
+ pending.timeout = timeout
127
+ mainHandler.postDelayed(timeout, FOREGROUND_RESPONSE_TIMEOUT_MS)
128
+ emit(EVENT_FOREGROUND_WILL_DISPLAY, mapOf("displayId" to displayId, "rawPayload" to payload.raw))
129
+ return true
130
+ }
131
+
132
+ /** JS (or the timeout) decided to display. */
133
+ fun display(displayId: String) {
134
+ val pending = pendingDisplays.remove(displayId) ?: return
135
+ pending.timeout?.let { mainHandler.removeCallbacks(it) }
136
+ worker.execute {
137
+ try {
138
+ QPushNotificationRenderer.render(pending.context, pending.payload)
139
+ } catch (e: Exception) {
140
+ QPushLog.e("Failed to render notification", e)
141
+ }
142
+ }
143
+ }
144
+
145
+ /** JS called `preventDefault()`: keep the payload around in case `display()` is called later. */
146
+ fun prevent(displayId: String) {
147
+ val pending = pendingDisplays[displayId] ?: return
148
+ pending.timeout?.let { mainHandler.removeCallbacks(it) }
149
+ val expiry = Runnable { pendingDisplays.remove(displayId) }
150
+ pending.timeout = expiry
151
+ mainHandler.postDelayed(expiry, PREVENTED_EXPIRY_MS)
152
+ }
153
+ }
@@ -0,0 +1,108 @@
1
+ package com.qpush.reactnative
2
+
3
+ import android.content.Context
4
+ import com.google.firebase.FirebaseApp
5
+ import com.google.firebase.FirebaseOptions
6
+ import com.google.firebase.messaging.FirebaseMessaging
7
+
8
+ /** FCM params from `GET /apps/{app_id}/android_params.js`. */
9
+ internal data class FcmParams(
10
+ val projectId: String,
11
+ val appId: String,
12
+ val apiKey: String,
13
+ val senderId: String,
14
+ )
15
+
16
+ internal data class PushRegistrationResult(
17
+ val token: String?,
18
+ val notificationTypes: Int?,
19
+ val error: String?,
20
+ )
21
+
22
+ /**
23
+ * Obtains the FCM registration token.
24
+ *
25
+ * With params from the QPush dashboard, a secondary FirebaseApp named
26
+ * `QPUSH_SDK_FCM_APP` is initialized programmatically, so the app needs
27
+ * neither google-services.json nor the google-services Gradle plugin (same
28
+ * approach as OneSignal). Without params the default FirebaseApp is used when
29
+ * the app has one.
30
+ */
31
+ internal object QPushFcm {
32
+ const val FIREBASE_APP_NAME = "QPUSH_SDK_FCM_APP"
33
+ /** notification_types for token errors (protocol: -3 missing play services / FCM error). */
34
+ const val ERROR_NOTIFICATION_TYPES = -3
35
+
36
+ fun register(context: Context, params: FcmParams?, callback: (PushRegistrationResult) -> Unit) {
37
+ val appContext = context.applicationContext
38
+ val firebaseApp: FirebaseApp = try {
39
+ if (params != null) {
40
+ QPushStore.set(appContext, QPushStore.KEY_FCM_MODE, "secondary")
41
+ secondaryApp(appContext, params)
42
+ } else {
43
+ QPushStore.set(appContext, QPushStore.KEY_FCM_MODE, "default")
44
+ defaultApp(appContext) ?: run {
45
+ callback(
46
+ PushRegistrationResult(
47
+ null,
48
+ ERROR_NOTIFICATION_TYPES,
49
+ "No FCM configuration: add Android (FCM) credentials to the QPush app or google-services.json to the project",
50
+ ),
51
+ )
52
+ return
53
+ }
54
+ }
55
+ } catch (t: Throwable) {
56
+ QPushLog.e("Could not initialize Firebase", t)
57
+ callback(PushRegistrationResult(null, ERROR_NOTIFICATION_TYPES, t.message ?: t.javaClass.simpleName))
58
+ return
59
+ }
60
+
61
+ try {
62
+ val messaging = firebaseApp.get(FirebaseMessaging::class.java)
63
+ messaging.token.addOnCompleteListener { task ->
64
+ val token = if (task.isSuccessful) task.result else null
65
+ if (!token.isNullOrEmpty()) {
66
+ QPushLog.d("FCM token received")
67
+ callback(PushRegistrationResult(token, null, null))
68
+ } else {
69
+ val message = task.exception?.message ?: "FCM returned an empty token"
70
+ QPushLog.w("FCM token request failed: $message", task.exception)
71
+ callback(PushRegistrationResult(null, ERROR_NOTIFICATION_TYPES, message))
72
+ }
73
+ }
74
+ } catch (t: Throwable) {
75
+ QPushLog.e("FCM token request failed", t)
76
+ callback(PushRegistrationResult(null, ERROR_NOTIFICATION_TYPES, t.message ?: t.javaClass.simpleName))
77
+ }
78
+ }
79
+
80
+ private fun secondaryApp(context: Context, params: FcmParams): FirebaseApp {
81
+ val options = FirebaseOptions.Builder()
82
+ .setProjectId(params.projectId)
83
+ .setApplicationId(params.appId)
84
+ .setApiKey(params.apiKey)
85
+ .setGcmSenderId(params.senderId)
86
+ .build()
87
+ val existing = try {
88
+ FirebaseApp.getInstance(FIREBASE_APP_NAME)
89
+ } catch (e: IllegalStateException) {
90
+ null
91
+ }
92
+ if (existing != null) {
93
+ if (existing.options == options) return existing
94
+ // Credentials changed in the dashboard: recreate the app.
95
+ existing.delete()
96
+ }
97
+ return FirebaseApp.initializeApp(context, options, FIREBASE_APP_NAME)
98
+ }
99
+
100
+ private fun defaultApp(context: Context): FirebaseApp? {
101
+ FirebaseApp.getApps(context).firstOrNull { it.name == FirebaseApp.DEFAULT_APP_NAME }?.let { return it }
102
+ return try {
103
+ FirebaseApp.initializeApp(context)
104
+ } catch (e: Exception) {
105
+ null
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,92 @@
1
+ package com.qpush.reactnative
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import org.json.JSONObject
6
+ import java.io.IOException
7
+ import java.net.HttpURLConnection
8
+ import java.net.URL
9
+
10
+ /**
11
+ * Native HTTP calls that must work while the JS runtime is not running
12
+ * (notification receipt and open). Must be called off the main thread.
13
+ */
14
+ internal object QPushHttp {
15
+ const val SDK_VERSION = "qpush-react-native/1.0.0"
16
+ private const val DEVICE_TYPE_ANDROID = 1
17
+ private const val TIMEOUT_MS = 15_000
18
+ private const val MAX_ATTEMPTS = 3
19
+
20
+ /** `PUT /notifications/{id}/report_received` */
21
+ fun reportReceived(context: Context, notificationId: String): Boolean {
22
+ val body = baseBody(context) ?: return false
23
+ return send(context, "/notifications/${Uri.encode(notificationId)}/report_received", body)
24
+ }
25
+
26
+ /** `PUT /notifications/{id}` with `opened: true` (click). */
27
+ fun reportOpened(context: Context, notificationId: String, actionId: String?): Boolean {
28
+ val body = baseBody(context) ?: return false
29
+ body.put("opened", true)
30
+ if (!actionId.isNullOrEmpty()) body.put("action_id", actionId)
31
+ return send(context, "/notifications/${Uri.encode(notificationId)}", body)
32
+ }
33
+
34
+ private fun baseBody(context: Context): JSONObject? {
35
+ val appId = QPushStore.get(context, QPushStore.KEY_APP_ID)
36
+ if (appId.isNullOrEmpty()) {
37
+ QPushLog.w("Cannot report notification event: SDK was never initialized")
38
+ return null
39
+ }
40
+ val body = JSONObject()
41
+ body.put("app_id", appId)
42
+ QPushStore.get(context, QPushStore.KEY_SUBSCRIPTION_ID)?.let { body.put("subscription_id", it) }
43
+ body.put("device_type", DEVICE_TYPE_ANDROID)
44
+ return body
45
+ }
46
+
47
+ private fun send(context: Context, path: String, body: JSONObject): Boolean {
48
+ val base = QPushStore.get(context, QPushStore.KEY_SERVER_URL)
49
+ if (base.isNullOrEmpty()) return false
50
+ val url = base.trimEnd('/') + path
51
+ val payload = body.toString().toByteArray(Charsets.UTF_8)
52
+ val subscriptionId = QPushStore.get(context, QPushStore.KEY_SUBSCRIPTION_ID)
53
+
54
+ for (attempt in 1..MAX_ATTEMPTS) {
55
+ var connection: HttpURLConnection? = null
56
+ try {
57
+ connection = (URL(url).openConnection() as HttpURLConnection).apply {
58
+ requestMethod = "PUT"
59
+ doOutput = true
60
+ connectTimeout = TIMEOUT_MS
61
+ readTimeout = TIMEOUT_MS
62
+ setRequestProperty("Content-Type", "application/json")
63
+ setRequestProperty("Accept", "application/json")
64
+ setRequestProperty("SDK-Version", SDK_VERSION)
65
+ if (!subscriptionId.isNullOrEmpty()) setRequestProperty("QPush-Subscription-Id", subscriptionId)
66
+ setFixedLengthStreamingMode(payload.size)
67
+ }
68
+ connection.outputStream.use { it.write(payload) }
69
+ val status = connection.responseCode
70
+ try {
71
+ (if (status < 400) connection.inputStream else connection.errorStream)?.close()
72
+ } catch (ignored: IOException) {
73
+ }
74
+ QPushLog.d("PUT $path -> $status")
75
+ if (status in 200..299) return true
76
+ if (status != 429 && status < 500) return false
77
+ } catch (e: Exception) {
78
+ QPushLog.d("PUT $path failed: ${e.message}")
79
+ } finally {
80
+ connection?.disconnect()
81
+ }
82
+ if (attempt < MAX_ATTEMPTS) {
83
+ try {
84
+ Thread.sleep(1000L * attempt)
85
+ } catch (e: InterruptedException) {
86
+ return false
87
+ }
88
+ }
89
+ }
90
+ return false
91
+ }
92
+ }
@@ -0,0 +1,103 @@
1
+ package com.qpush.reactnative
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import android.graphics.BitmapFactory
6
+ import android.graphics.Canvas
7
+ import androidx.core.content.ContextCompat
8
+ import java.io.ByteArrayOutputStream
9
+ import java.net.HttpURLConnection
10
+ import java.net.URL
11
+
12
+ /** Image helpers for notification rendering (blocking: call off the main thread). */
13
+ internal object QPushImages {
14
+ private const val TIMEOUT_MS = 8_000
15
+ private const val MAX_BYTES = 5 * 1024 * 1024
16
+
17
+ fun isRemote(value: String): Boolean =
18
+ value.startsWith("http://", ignoreCase = true) || value.startsWith("https://", ignoreCase = true)
19
+
20
+ /** Downloads and decodes an image, downsampling it to at most `maxDimension` px. */
21
+ fun download(url: String, maxDimension: Int): Bitmap? {
22
+ var connection: HttpURLConnection? = null
23
+ return try {
24
+ connection = (URL(url).openConnection() as HttpURLConnection).apply {
25
+ connectTimeout = TIMEOUT_MS
26
+ readTimeout = TIMEOUT_MS
27
+ instanceFollowRedirects = true
28
+ }
29
+ if (connection.responseCode !in 200..299) return null
30
+ val bytes = connection.inputStream.use { input ->
31
+ val output = ByteArrayOutputStream()
32
+ val buffer = ByteArray(16 * 1024)
33
+ var total = 0
34
+ while (true) {
35
+ val read = input.read(buffer)
36
+ if (read < 0) break
37
+ total += read
38
+ if (total > MAX_BYTES) return null
39
+ output.write(buffer, 0, read)
40
+ }
41
+ output.toByteArray()
42
+ }
43
+ decode(bytes, maxDimension)
44
+ } catch (e: Exception) {
45
+ QPushLog.w("Could not download image $url", e)
46
+ null
47
+ } finally {
48
+ connection?.disconnect()
49
+ }
50
+ }
51
+
52
+ private fun decode(bytes: ByteArray, maxDimension: Int): Bitmap? {
53
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
54
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
55
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
56
+ var sample = 1
57
+ while (bounds.outWidth / (sample * 2) >= maxDimension || bounds.outHeight / (sample * 2) >= maxDimension) {
58
+ sample *= 2
59
+ }
60
+ val options = BitmapFactory.Options().apply { inSampleSize = sample }
61
+ return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
62
+ }
63
+
64
+ /** Resolves a drawable/mipmap resource id by name (`ic_stat_x`, `drawable/ic_x`, `ic_x.png`). */
65
+ fun resourceId(context: Context, rawName: String?): Int {
66
+ if (rawName.isNullOrBlank()) return 0
67
+ val name = rawName.substringAfterLast('/').substringBeforeLast('.')
68
+ if (name.isEmpty()) return 0
69
+ val resources = context.resources
70
+ val packageName = context.packageName
71
+ for (type in arrayOf("drawable", "mipmap")) {
72
+ @Suppress("DiscouragedApi")
73
+ val id = resources.getIdentifier(name, type, packageName)
74
+ if (id != 0) return id
75
+ }
76
+ return 0
77
+ }
78
+
79
+ /** Loads a bitmap from a drawable resource name (bitmap or vector). */
80
+ fun fromResource(context: Context, name: String): Bitmap? {
81
+ val id = resourceId(context, name)
82
+ if (id == 0) return null
83
+ BitmapFactory.decodeResource(context.resources, id)?.let { return it }
84
+ return try {
85
+ val drawable = ContextCompat.getDrawable(context, id) ?: return null
86
+ val width = if (drawable.intrinsicWidth > 0) drawable.intrinsicWidth else 128
87
+ val height = if (drawable.intrinsicHeight > 0) drawable.intrinsicHeight else 128
88
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
89
+ val canvas = Canvas(bitmap)
90
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
91
+ drawable.draw(canvas)
92
+ bitmap
93
+ } catch (e: Exception) {
94
+ null
95
+ }
96
+ }
97
+
98
+ /** Remote URL or drawable name. */
99
+ fun load(context: Context, value: String?, maxDimension: Int): Bitmap? {
100
+ if (value.isNullOrBlank()) return null
101
+ return if (isRemote(value)) download(value, maxDimension) else fromResource(context, value)
102
+ }
103
+ }
@@ -0,0 +1,39 @@
1
+ package com.qpush.reactnative
2
+
3
+ import android.util.Log
4
+
5
+ /** Leveled logger using the same numeric levels as the JS `LogLevel` enum. */
6
+ internal object QPushLog {
7
+ private const val TAG = "QPush"
8
+
9
+ const val NONE = 0
10
+ const val FATAL = 1
11
+ const val ERROR = 2
12
+ const val WARN = 3
13
+ const val INFO = 4
14
+ const val DEBUG = 5
15
+ const val VERBOSE = 6
16
+
17
+ @Volatile
18
+ var level: Int = WARN
19
+
20
+ fun e(message: String, error: Throwable? = null) {
21
+ if (level >= ERROR) Log.e(TAG, message, error)
22
+ }
23
+
24
+ fun w(message: String, error: Throwable? = null) {
25
+ if (level >= WARN) Log.w(TAG, message, error)
26
+ }
27
+
28
+ fun i(message: String) {
29
+ if (level >= INFO) Log.i(TAG, message)
30
+ }
31
+
32
+ fun d(message: String) {
33
+ if (level >= DEBUG) Log.d(TAG, message)
34
+ }
35
+
36
+ fun v(message: String) {
37
+ if (level >= VERBOSE) Log.v(TAG, message)
38
+ }
39
+ }
@@ -0,0 +1,77 @@
1
+ package com.qpush.reactnative
2
+
3
+ import android.content.Context
4
+ import com.google.firebase.messaging.FirebaseMessagingService
5
+ import com.google.firebase.messaging.RemoteMessage
6
+
7
+ /**
8
+ * Receives QPush data messages. Works with the JS runtime dead: rendering
9
+ * and the delivery receipt only need SharedPreferences.
10
+ *
11
+ * If your app already declares its own FirebaseMessagingService (only one
12
+ * service receives MESSAGING_EVENT), forward to QPush from it:
13
+ *
14
+ * ```kotlin
15
+ * override fun onMessageReceived(message: RemoteMessage) {
16
+ * if (QPushMessagingService.handleMessage(this, message)) return
17
+ * // your own handling
18
+ * }
19
+ * override fun onNewToken(token: String) { QPushMessagingService.handleNewToken(this, token) }
20
+ * ```
21
+ */
22
+ class QPushMessagingService : FirebaseMessagingService() {
23
+
24
+ override fun onMessageReceived(message: RemoteMessage) {
25
+ handleMessage(applicationContext, message)
26
+ }
27
+
28
+ override fun onNewToken(token: String) {
29
+ handleNewToken(applicationContext, token)
30
+ }
31
+
32
+ companion object {
33
+ private const val DATA_KEY = "qpush"
34
+
35
+ /** Returns true when the message was a QPush notification. Blocking: call from the FCM thread. */
36
+ @JvmStatic
37
+ fun handleMessage(context: Context, message: RemoteMessage): Boolean {
38
+ val raw = message.data[DATA_KEY] ?: return false
39
+ processPayload(context.applicationContext, raw)
40
+ return true
41
+ }
42
+
43
+ /** Token refresh of the default FirebaseApp. */
44
+ @JvmStatic
45
+ fun handleNewToken(context: Context, token: String) {
46
+ QPushStore.set(context, QPushStore.KEY_DEFAULT_APP_TOKEN, token)
47
+ // With dashboard FCM params the token comes from the secondary app and is
48
+ // refreshed by JS on every cold start; only forward default-app tokens.
49
+ if (QPushStore.get(context, QPushStore.KEY_FCM_MODE) == "default") {
50
+ QPushEventBus.emitToken(token)
51
+ }
52
+ }
53
+
54
+ internal fun processPayload(context: Context, raw: String) {
55
+ val payload = QPushPayload.parse(raw)
56
+ if (payload == null) {
57
+ QPushLog.w("Ignoring malformed QPush payload")
58
+ return
59
+ }
60
+ if (QPushStore.markNotificationSeen(context, payload.id)) {
61
+ QPushLog.d("Duplicate notification ${payload.id} ignored")
62
+ return
63
+ }
64
+ QPushLog.d("Received notification ${payload.id}")
65
+
66
+ if (!payload.isSilent && !QPushEventBus.requestForegroundDisplay(context, payload)) {
67
+ try {
68
+ QPushNotificationRenderer.render(context, payload)
69
+ } catch (e: Exception) {
70
+ QPushLog.e("Failed to render notification ${payload.id}", e)
71
+ }
72
+ }
73
+ // Confirmed delivery (idempotent on the server).
74
+ QPushHttp.reportReceived(context, payload.id)
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,121 @@
1
+ package com.qpush.reactnative
2
+
3
+ import android.app.Activity
4
+ import android.content.ActivityNotFoundException
5
+ import android.content.Intent
6
+ import android.net.Uri
7
+ import android.os.Bundle
8
+
9
+ /**
10
+ * Invisible activity started by the notification PendingIntent (body tap or
11
+ * action button). Starting an Activity directly from the notification is the
12
+ * Android 12+ compliant way (no service/receiver trampolines).
13
+ *
14
+ * It reports the open natively, queues the click for JS (delivered once a
15
+ * listener is attached, so cold-start clicks are not lost), opens the URL
16
+ * (button URL wins) unless disabled, brings the app to the front and finishes.
17
+ */
18
+ class QPushNotificationOpenActivity : Activity() {
19
+
20
+ override fun onCreate(savedInstanceState: Bundle?) {
21
+ super.onCreate(savedInstanceState)
22
+ try {
23
+ val current: Intent? = intent
24
+ if (current == null) openApp() else handle(current)
25
+ } catch (e: Exception) {
26
+ QPushLog.e("Failed to handle notification open", e)
27
+ openApp()
28
+ }
29
+ finish()
30
+ overridePendingTransitionCompat()
31
+ }
32
+
33
+ private fun handle(intent: Intent) {
34
+ val raw = intent.getStringExtra(QPushNotificationRenderer.EXTRA_PAYLOAD)
35
+ val payload = QPushPayload.parse(raw)
36
+ if (raw == null || payload == null) {
37
+ // Group summary or unknown intent: just open the app.
38
+ openApp()
39
+ return
40
+ }
41
+ val actionId = intent.getStringExtra(QPushNotificationRenderer.EXTRA_ACTION_ID)
42
+
43
+ // Action buttons do not auto-cancel the notification.
44
+ if (actionId != null && intent.hasExtra(QPushNotificationRenderer.EXTRA_NOTIFICATION_ID)) {
45
+ val notificationId = intent.getIntExtra(QPushNotificationRenderer.EXTRA_NOTIFICATION_ID, 0)
46
+ QPushNotificationRenderer.cancel(applicationContext, notificationId)
47
+ }
48
+
49
+ val url = payload.buttonUrl(actionId) ?: payload.url
50
+ val appContext = applicationContext
51
+ Thread {
52
+ QPushHttp.reportOpened(appContext, payload.id, actionId)
53
+ }.start()
54
+
55
+ QPushEventBus.emitClick(raw, actionId, url)
56
+
57
+ if (url != null && QPushStore.autoOpenUrls(appContext)) {
58
+ openUrl(url)
59
+ } else {
60
+ openApp()
61
+ }
62
+ }
63
+
64
+ private fun launchIntent(): Intent? {
65
+ val launch = packageManager.getLaunchIntentForPackage(packageName) ?: return null
66
+ // Behave like the launcher icon: resume the existing task instead of
67
+ // stacking a new activity on top of it.
68
+ launch.setPackage(null)
69
+ launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
70
+ return launch
71
+ }
72
+
73
+ private fun openApp() {
74
+ val launch = launchIntent() ?: return
75
+ try {
76
+ startActivity(launch)
77
+ } catch (e: Exception) {
78
+ QPushLog.e("Could not open the app", e)
79
+ }
80
+ }
81
+
82
+ private fun openUrl(url: String) {
83
+ val uri = try {
84
+ Uri.parse(url)
85
+ } catch (e: Exception) {
86
+ null
87
+ }
88
+ if (uri == null || uri.scheme.isNullOrEmpty()) {
89
+ QPushLog.w("Invalid notification URL: $url")
90
+ openApp()
91
+ return
92
+ }
93
+ val view = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
94
+ try {
95
+ // Deep link handled by this app: deliver it to the app itself
96
+ // (React Native Linking receives it).
97
+ val ownHandler = view.resolveActivity(packageManager)?.packageName == packageName
98
+ if (ownHandler) {
99
+ view.setPackage(packageName)
100
+ startActivity(view)
101
+ return
102
+ }
103
+ // External URL: open the app with the browser on top, so going back
104
+ // lands in the app (and JS receives the click event).
105
+ val launch = launchIntent()
106
+ if (launch != null) startActivities(arrayOf(launch, view)) else startActivity(view)
107
+ } catch (e: ActivityNotFoundException) {
108
+ QPushLog.w("No activity can open $url", e)
109
+ openApp()
110
+ }
111
+ }
112
+
113
+ @Suppress("DEPRECATION")
114
+ private fun overridePendingTransitionCompat() {
115
+ try {
116
+ overridePendingTransition(0, 0)
117
+ } catch (e: Exception) {
118
+ // ignore
119
+ }
120
+ }
121
+ }