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.
- package/README.md +439 -2
- package/android/build.gradle +77 -0
- package/android/gradle.properties +5 -0
- package/android/proguard-rules.pro +5 -0
- package/android/src/main/AndroidManifest.xml +29 -0
- package/android/src/main/java/com/qpush/reactnative/QPushDeviceInfo.kt +96 -0
- package/android/src/main/java/com/qpush/reactnative/QPushEventBus.kt +153 -0
- package/android/src/main/java/com/qpush/reactnative/QPushFcm.kt +108 -0
- package/android/src/main/java/com/qpush/reactnative/QPushHttp.kt +92 -0
- package/android/src/main/java/com/qpush/reactnative/QPushImages.kt +103 -0
- package/android/src/main/java/com/qpush/reactnative/QPushLog.kt +39 -0
- package/android/src/main/java/com/qpush/reactnative/QPushMessagingService.kt +77 -0
- package/android/src/main/java/com/qpush/reactnative/QPushNotificationOpenActivity.kt +121 -0
- package/android/src/main/java/com/qpush/reactnative/QPushNotificationRenderer.kt +256 -0
- package/android/src/main/java/com/qpush/reactnative/QPushPayload.kt +114 -0
- package/android/src/main/java/com/qpush/reactnative/QPushPushModule.kt +368 -0
- package/android/src/main/java/com/qpush/reactnative/QPushPushPackage.kt +15 -0
- package/android/src/main/java/com/qpush/reactnative/QPushStore.kt +108 -0
- package/android/src/main/res/values/qpush_strings.xml +6 -0
- package/app.plugin.js +1 -0
- package/ios/Extension/QPushNotificationServiceExtension.swift +261 -0
- package/ios/Extension/QPushPushExtension.podspec +34 -0
- package/ios/QPushPush.m +62 -0
- package/ios/QPushPush.swift +186 -0
- package/ios/QPushPushBridge.h +5 -0
- package/ios/QPushPushCore.swift +457 -0
- package/ios/QPushPushHooks.m +237 -0
- package/ios/QPushPushSupport.swift +249 -0
- package/ios_nse_template/Info.plist +38 -0
- package/ios_nse_template/NotificationService.entitlements +10 -0
- package/ios_nse_template/NotificationService.swift +44 -0
- package/lib/NativeQPushPush.d.ts +44 -0
- package/lib/NativeQPushPush.js +4 -0
- package/lib/QPush.d.ts +117 -0
- package/lib/QPush.js +206 -0
- package/lib/core/QPushCore.d.ts +148 -0
- package/lib/core/QPushCore.js +1387 -0
- package/lib/core/constants.d.ts +37 -0
- package/lib/core/constants.js +40 -0
- package/lib/core/events.d.ts +11 -0
- package/lib/core/events.js +40 -0
- package/lib/core/http.d.ts +41 -0
- package/lib/core/http.js +123 -0
- package/lib/core/logger.d.ts +18 -0
- package/lib/core/logger.js +43 -0
- package/lib/core/operationQueue.d.ts +117 -0
- package/lib/core/operationQueue.js +264 -0
- package/lib/core/operations.d.ts +80 -0
- package/lib/core/operations.js +88 -0
- package/lib/core/payload.d.ts +20 -0
- package/lib/core/payload.js +187 -0
- package/lib/core/platform.d.ts +72 -0
- package/lib/core/platform.js +7 -0
- package/lib/core/state.d.ts +51 -0
- package/lib/core/state.js +74 -0
- package/lib/core/storage.d.ts +34 -0
- package/lib/core/storage.js +68 -0
- package/lib/core/subscription.d.ts +67 -0
- package/lib/core/subscription.js +98 -0
- package/lib/core/tags.d.ts +39 -0
- package/lib/core/tags.js +123 -0
- package/lib/core/url.d.ts +17 -0
- package/lib/core/url.js +64 -0
- package/lib/core/uuid.d.ts +2 -0
- package/lib/core/uuid.js +18 -0
- package/lib/index.d.ts +7 -0
- package/lib/index.js +15 -0
- package/lib/notification.d.ts +67 -0
- package/lib/notification.js +73 -0
- package/lib/reactNativePlatform.d.ts +3 -0
- package/lib/reactNativePlatform.js +164 -0
- package/lib/types.d.ts +157 -0
- package/lib/types.js +31 -0
- package/package.json +70 -4
- package/plugin/withQPush.js +330 -0
- package/react-native-qpush.podspec +30 -0
- package/react-native.config.js +18 -0
- package/src/NativeQPushPush.ts +57 -0
- package/src/QPush.ts +292 -0
- package/src/core/QPushCore.ts +1515 -0
- package/src/core/constants.ts +42 -0
- package/src/core/events.ts +34 -0
- package/src/core/http.ts +146 -0
- package/src/core/logger.ts +42 -0
- package/src/core/operationQueue.ts +324 -0
- package/src/core/operations.ts +166 -0
- package/src/core/payload.ts +175 -0
- package/src/core/platform.ts +84 -0
- package/src/core/state.ts +127 -0
- package/src/core/storage.ts +76 -0
- package/src/core/subscription.ts +133 -0
- package/src/core/tags.ts +116 -0
- package/src/core/url.ts +58 -0
- package/src/core/uuid.ts +13 -0
- package/src/index.ts +23 -0
- package/src/notification.ts +113 -0
- package/src/reactNativePlatform.ts +172 -0
- 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
|
+
}
|