capacitor-push-signal 0.0.1

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 (38) hide show
  1. package/CapacitorPushSignal.podspec +19 -0
  2. package/Package.swift +36 -0
  3. package/README.md +271 -0
  4. package/android/build.gradle +66 -0
  5. package/android/src/main/AndroidManifest.xml +19 -0
  6. package/android/src/main/java/com/antonseagull/cap/push/signal/PushModels.kt +15 -0
  7. package/android/src/main/java/com/antonseagull/cap/push/signal/PushSignalCenter.kt +397 -0
  8. package/android/src/main/java/com/antonseagull/cap/push/signal/PushSignalInitProvider.kt +34 -0
  9. package/android/src/main/java/com/antonseagull/cap/push/signal/PushSignalMessagingService.kt +20 -0
  10. package/android/src/main/java/com/antonseagull/cap/push/signal/PushSignalPlugin.kt +85 -0
  11. package/android/src/main/res/.gitkeep +0 -0
  12. package/dist/docs.json +275 -0
  13. package/dist/esm/definitions.d.ts +19 -0
  14. package/dist/esm/definitions.js +2 -0
  15. package/dist/esm/definitions.js.map +1 -0
  16. package/dist/esm/index.d.ts +2 -0
  17. package/dist/esm/index.js +2 -0
  18. package/dist/esm/index.js.map +1 -0
  19. package/dist/esm/pushSignal.d.ts +8 -0
  20. package/dist/esm/pushSignal.js +76 -0
  21. package/dist/esm/pushSignal.js.map +1 -0
  22. package/dist/esm/types.d.ts +20 -0
  23. package/dist/esm/types.js +2 -0
  24. package/dist/esm/types.js.map +1 -0
  25. package/dist/esm/web.d.ts +8 -0
  26. package/dist/esm/web.js +13 -0
  27. package/dist/esm/web.js.map +1 -0
  28. package/dist/plugin.cjs.js +100 -0
  29. package/dist/plugin.cjs.js.map +1 -0
  30. package/dist/plugin.js +103 -0
  31. package/dist/plugin.js.map +1 -0
  32. package/ios/Sources/PushSignalCore/PushSignalCenter.m +583 -0
  33. package/ios/Sources/PushSignalCore/PushSignalLaunchStore.m +38 -0
  34. package/ios/Sources/PushSignalCore/include/PushSignalCenter.h +26 -0
  35. package/ios/Sources/PushSignalCore/include/PushSignalLaunchStore.h +21 -0
  36. package/ios/Sources/PushSignalPlugin/PushSignalPlugin.swift +66 -0
  37. package/ios/Tests/PushSignalPluginTests/PushSignalTests.swift +9 -0
  38. package/package.json +80 -0
@@ -0,0 +1,397 @@
1
+ package com.antonseagull.cap.push.signal
2
+
3
+ import android.app.Activity
4
+ import android.app.Application
5
+ import android.app.NotificationChannel
6
+ import android.app.NotificationManager
7
+ import android.app.PendingIntent
8
+ import android.content.Context
9
+ import android.content.Intent
10
+ import android.os.Build
11
+ import android.os.Bundle
12
+ import android.os.Handler
13
+ import android.os.Looper
14
+ import androidx.activity.ComponentActivity
15
+ import androidx.core.app.NotificationCompat
16
+ import com.google.android.gms.tasks.Tasks
17
+ import com.google.firebase.FirebaseApp
18
+ import com.google.firebase.FirebaseOptions
19
+ import com.google.firebase.messaging.FirebaseMessaging
20
+ import com.google.firebase.messaging.RemoteMessage
21
+ import java.util.Collections
22
+ import java.util.UUID
23
+ import java.util.WeakHashMap
24
+ import java.util.concurrent.CopyOnWriteArrayList
25
+
26
+ internal object PushSignalCenter : Application.ActivityLifecycleCallbacks {
27
+ private const val EXTRA_HANDLED = "pushsignal.handled"
28
+ private const val CHANNEL_ID = "push_signal_default"
29
+
30
+ private val lock = Any()
31
+ private val mainHandler = Handler(Looper.getMainLooper())
32
+ @Volatile private var application: Application? = null
33
+ @Volatile private var currentActivity: Activity? = null
34
+ @Volatile private var onMessage: ((PushMessage) -> Unit)? = null
35
+ @Volatile private var onNotificationPress: ((PushMessage) -> Unit)? = null
36
+ @Volatile private var pendingPress: PushMessage? = null
37
+ private val pendingMessages = CopyOnWriteArrayList<PushMessage>()
38
+ private val registeredActivities = Collections.newSetFromMap(WeakHashMap<Activity, Boolean>())
39
+ @Volatile private var startedActivityCount = 0
40
+ @Volatile private var pendingFirebaseConfig: AndroidFirebaseConfig? = null
41
+ private val initializeWaiters = CopyOnWriteArrayList<(Exception?) -> Unit>()
42
+
43
+ fun attach(context: Context) {
44
+ val app = context.applicationContext as? Application ?: return
45
+ if (application === app) {
46
+ return
47
+ }
48
+ application?.unregisterActivityLifecycleCallbacks(this)
49
+ application = app
50
+ app.registerActivityLifecycleCallbacks(this)
51
+ app.currentActivityOrNull()?.let { activity ->
52
+ currentActivity = activity
53
+ registerActivity(activity)
54
+ handleIntent(activity.intent)
55
+ }
56
+ pendingFirebaseConfig?.let { config ->
57
+ pendingFirebaseConfig = null
58
+ finishInitialize(applyFirebaseConfig(app, config))
59
+ }
60
+ }
61
+
62
+ fun initialize(config: AndroidFirebaseConfig, onDone: (Exception?) -> Unit) {
63
+ if (!config.hasRequiredFields()) {
64
+ onDone(null)
65
+ return
66
+ }
67
+
68
+ val context = application
69
+ if (context == null) {
70
+ pendingFirebaseConfig = config
71
+ initializeWaiters.add(onDone)
72
+ return
73
+ }
74
+
75
+ onDone(applyFirebaseConfig(context, config))
76
+ }
77
+
78
+ fun setOnMessage(callback: (PushMessage) -> Unit) {
79
+ onMessage = callback
80
+ val queued = pendingMessages.toList()
81
+ pendingMessages.clear()
82
+ queued.forEach { message ->
83
+ runOnMain { deliverMessage(message) }
84
+ }
85
+ }
86
+
87
+ fun setOnNotificationPress(callback: (PushMessage) -> Unit) {
88
+ onNotificationPress = callback
89
+ val pending = synchronized(lock) {
90
+ val message = pendingPress
91
+ pendingPress = null
92
+ message
93
+ }
94
+ if (pending != null) {
95
+ callback(pending)
96
+ }
97
+ }
98
+
99
+ fun fetchToken(): String {
100
+ val context = application
101
+ ?: throw IllegalStateException("PushSignal is not initialized")
102
+
103
+ try {
104
+ if (FirebaseApp.getApps(context).isEmpty()) {
105
+ FirebaseApp.initializeApp(context)
106
+ }
107
+ FirebaseApp.getInstance()
108
+ } catch (error: IllegalStateException) {
109
+ throw IllegalStateException(
110
+ "Firebase is not configured. Call initialize({ project_id, mobilesdk_app_id, current_key, project_number }) or add google-services.json.",
111
+ error
112
+ )
113
+ }
114
+
115
+ val task = FirebaseMessaging.getInstance().token
116
+ val token = try {
117
+ Tasks.await(task)
118
+ } catch (error: Exception) {
119
+ throw IllegalStateException(
120
+ "Failed to get an FCM token. Call initialize({ project_id, mobilesdk_app_id, current_key, project_number }) or add google-services.json.",
121
+ error
122
+ )
123
+ }
124
+
125
+ if (token.isNullOrEmpty()) {
126
+ throw IllegalStateException("Firebase returned an empty FCM token")
127
+ }
128
+
129
+ return token
130
+ }
131
+
132
+ fun emitMessage(remoteMessage: RemoteMessage) {
133
+ val message = remoteMessage.toPushMessage()
134
+ runOnMain { deliverMessage(message) }
135
+ }
136
+
137
+ private fun deliverMessage(message: PushMessage) {
138
+ val callback = onMessage
139
+ if (callback == null) {
140
+ pendingMessages.add(message)
141
+ return
142
+ }
143
+
144
+ try {
145
+ callback(message)
146
+ } catch (_: Throwable) {
147
+ // Ignore listener failures.
148
+ }
149
+
150
+ val inForeground = startedActivityCount > 0 || currentActivity != null
151
+ if (
152
+ inForeground &&
153
+ (!message.title.isNullOrEmpty() || !message.body.isNullOrEmpty())
154
+ ) {
155
+ postForegroundNotification(message)
156
+ }
157
+ }
158
+
159
+ private fun runOnMain(block: () -> Unit) {
160
+ if (Looper.myLooper() == Looper.getMainLooper()) {
161
+ block()
162
+ } else {
163
+ mainHandler.post(block)
164
+ }
165
+ }
166
+
167
+ private fun postForegroundNotification(message: PushMessage) {
168
+ val context = application ?: return
169
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
170
+ ?: return
171
+
172
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
173
+ val existing = manager.getNotificationChannel(CHANNEL_ID)
174
+ if (existing == null) {
175
+ manager.createNotificationChannel(
176
+ NotificationChannel(
177
+ CHANNEL_ID,
178
+ "Notifications",
179
+ NotificationManager.IMPORTANCE_HIGH
180
+ )
181
+ )
182
+ }
183
+ }
184
+
185
+ val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
186
+ ?: return
187
+ launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
188
+ launchIntent.putExtra("google.message_id", message.id ?: UUID.randomUUID().toString())
189
+ message.title?.let { launchIntent.putExtra("gcm.notification.title", it) }
190
+ message.body?.let { launchIntent.putExtra("gcm.notification.body", it) }
191
+ message.data.forEach { (key, value) ->
192
+ launchIntent.putExtra(key, value)
193
+ }
194
+
195
+ val requestCode = (message.id ?: message.title ?: "push").hashCode()
196
+ val pendingIntent = PendingIntent.getActivity(
197
+ context,
198
+ requestCode,
199
+ launchIntent,
200
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
201
+ )
202
+
203
+ val builder = NotificationCompat.Builder(context, CHANNEL_ID)
204
+ .setSmallIcon(smallIcon(context))
205
+ .setContentTitle(message.title.orEmpty())
206
+ .setContentText(message.body.orEmpty())
207
+ .setContentIntent(pendingIntent)
208
+ .setAutoCancel(true)
209
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
210
+ .setDefaults(NotificationCompat.DEFAULT_SOUND)
211
+ .setNumber(1)
212
+
213
+ manager.notify(requestCode, builder.build())
214
+ }
215
+
216
+ private fun smallIcon(context: Context): Int {
217
+ val named = context.resources.getIdentifier("ic_notification", "drawable", context.packageName)
218
+ if (named != 0) {
219
+ return named
220
+ }
221
+ val appIcon = context.applicationInfo.icon
222
+ if (appIcon != 0) {
223
+ return appIcon
224
+ }
225
+ return android.R.drawable.stat_notify_more
226
+ }
227
+
228
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
229
+ currentActivity = activity
230
+ registerActivity(activity)
231
+ handleIntent(activity.intent)
232
+ }
233
+
234
+ override fun onActivityStarted(activity: Activity) {
235
+ currentActivity = activity
236
+ startedActivityCount += 1
237
+ }
238
+
239
+ override fun onActivityResumed(activity: Activity) {
240
+ currentActivity = activity
241
+ handleIntent(activity.intent)
242
+ }
243
+
244
+ override fun onActivityPaused(activity: Activity) {
245
+ if (currentActivity === activity) {
246
+ currentActivity = null
247
+ }
248
+ }
249
+
250
+ override fun onActivityStopped(activity: Activity) {
251
+ startedActivityCount = (startedActivityCount - 1).coerceAtLeast(0)
252
+ if (currentActivity === activity) {
253
+ currentActivity = null
254
+ }
255
+ }
256
+
257
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
258
+
259
+ override fun onActivityDestroyed(activity: Activity) {
260
+ if (currentActivity === activity) {
261
+ currentActivity = null
262
+ }
263
+ }
264
+
265
+ private fun registerActivity(activity: Activity) {
266
+ if (!registeredActivities.add(activity)) {
267
+ return
268
+ }
269
+
270
+ val componentActivity = activity as? ComponentActivity ?: return
271
+ componentActivity.addOnNewIntentListener { intent ->
272
+ handleIntent(intent)
273
+ }
274
+ }
275
+
276
+ private fun handleIntent(intent: Intent?) {
277
+ if (intent == null || !intent.isPushTap() || intent.getBooleanExtra(EXTRA_HANDLED, false)) {
278
+ return
279
+ }
280
+
281
+ intent.putExtra(EXTRA_HANDLED, true)
282
+ emitPress(intent.toPushMessage())
283
+ }
284
+
285
+ private fun applyFirebaseConfig(context: Context, config: AndroidFirebaseConfig): Exception? {
286
+ if (FirebaseApp.getApps(context).isNotEmpty()) {
287
+ FirebaseMessaging.getInstance().isAutoInitEnabled = true
288
+ return null
289
+ }
290
+
291
+ if (!config.hasRequiredFields()) {
292
+ return null
293
+ }
294
+
295
+ return try {
296
+ val options =
297
+ FirebaseOptions.Builder()
298
+ .setProjectId(config.project_id!!.trim())
299
+ .setApplicationId(config.mobilesdk_app_id!!.trim())
300
+ .setApiKey(config.current_key!!.trim())
301
+ .setGcmSenderId(config.project_number!!.trim())
302
+ .build()
303
+ FirebaseApp.initializeApp(context, options)
304
+ FirebaseMessaging.getInstance().isAutoInitEnabled = true
305
+ null
306
+ } catch (error: Exception) {
307
+ error
308
+ }
309
+ }
310
+
311
+ private fun finishInitialize(error: Exception?) {
312
+ val waiters = initializeWaiters.toList()
313
+ initializeWaiters.clear()
314
+ waiters.forEach { it(error) }
315
+ }
316
+
317
+ private fun emitPress(message: PushMessage) {
318
+ val listener = onNotificationPress
319
+ if (listener != null) {
320
+ listener(message)
321
+ } else {
322
+ synchronized(lock) {
323
+ pendingPress = message
324
+ }
325
+ }
326
+ }
327
+ }
328
+
329
+ private fun Application.currentActivityOrNull(): Activity? {
330
+ return try {
331
+ val activityThreadClass = Class.forName("android.app.ActivityThread")
332
+ val activityThread = activityThreadClass.getMethod("currentActivityThread").invoke(null)
333
+ val activitiesField = activityThreadClass.getDeclaredField("mActivities")
334
+ activitiesField.isAccessible = true
335
+ val activities = activitiesField.get(activityThread) as Map<*, *>
336
+ activities.values.firstNotNullOfOrNull { record ->
337
+ val recordClass = record?.javaClass ?: return@firstNotNullOfOrNull null
338
+ val paused = recordClass.getDeclaredField("paused").apply { isAccessible = true }.getBoolean(record)
339
+ if (paused) {
340
+ return@firstNotNullOfOrNull null
341
+ }
342
+ recordClass.getDeclaredField("activity").apply { isAccessible = true }.get(record) as? Activity
343
+ }
344
+ } catch (_: Exception) {
345
+ null
346
+ }
347
+ }
348
+
349
+ private fun Intent.isPushTap(): Boolean {
350
+ val extras = extras ?: return false
351
+ return extras.containsKey("google.message_id") ||
352
+ extras.containsKey("google.sent_time") ||
353
+ extras.containsKey("gcm.n.e") ||
354
+ extras.containsKey("gcm.notification.title")
355
+ }
356
+
357
+ private fun Intent.toPushMessage(): PushMessage {
358
+ val extras = extras ?: Bundle()
359
+ val data = linkedMapOf<String, String>()
360
+ for (key in extras.keySet()) {
361
+ if (key.startsWith("google.") || key.startsWith("gcm.") || key == EXTRA_HANDLED_KEY) {
362
+ continue
363
+ }
364
+ @Suppress("DEPRECATION")
365
+ val value = extras.get(key) ?: continue
366
+ data[key] = value.toString()
367
+ }
368
+
369
+ return PushMessage(
370
+ extras.getString("google.message_id"),
371
+ extras.getString("gcm.n.title")
372
+ ?: extras.getString("gcm.notification.title")
373
+ ?: extras.getString("title"),
374
+ extras.getString("gcm.n.body")
375
+ ?: extras.getString("gcm.notification.body")
376
+ ?: extras.getString("body"),
377
+ data
378
+ )
379
+ }
380
+
381
+ private fun AndroidFirebaseConfig.hasRequiredFields(): Boolean {
382
+ return !project_id.isNullOrBlank() &&
383
+ !mobilesdk_app_id.isNullOrBlank() &&
384
+ !current_key.isNullOrBlank() &&
385
+ !project_number.isNullOrBlank()
386
+ }
387
+
388
+ private const val EXTRA_HANDLED_KEY = "pushsignal.handled"
389
+
390
+ internal fun RemoteMessage.toPushMessage(): PushMessage {
391
+ return PushMessage(
392
+ messageId,
393
+ notification?.title,
394
+ notification?.body,
395
+ data
396
+ )
397
+ }
@@ -0,0 +1,34 @@
1
+ package com.antonseagull.cap.push.signal
2
+
3
+ import android.content.ContentProvider
4
+ import android.content.ContentValues
5
+ import android.database.Cursor
6
+ import android.net.Uri
7
+
8
+ class PushSignalInitProvider : ContentProvider() {
9
+ override fun onCreate(): Boolean {
10
+ context?.let(PushSignalCenter::attach)
11
+ return true
12
+ }
13
+
14
+ override fun query(
15
+ uri: Uri,
16
+ projection: Array<out String>?,
17
+ selection: String?,
18
+ selectionArgs: Array<out String>?,
19
+ sortOrder: String?
20
+ ): Cursor? = null
21
+
22
+ override fun getType(uri: Uri): String? = null
23
+
24
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
25
+
26
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
27
+
28
+ override fun update(
29
+ uri: Uri,
30
+ values: ContentValues?,
31
+ selection: String?,
32
+ selectionArgs: Array<out String>?
33
+ ): Int = 0
34
+ }
@@ -0,0 +1,20 @@
1
+ package com.antonseagull.cap.push.signal
2
+
3
+ import com.google.firebase.messaging.FirebaseMessagingService
4
+ import com.google.firebase.messaging.RemoteMessage
5
+
6
+ class PushSignalMessagingService : FirebaseMessagingService() {
7
+ override fun onCreate() {
8
+ super.onCreate()
9
+ PushSignalCenter.attach(applicationContext)
10
+ }
11
+
12
+ override fun onNewToken(token: String) {
13
+ PushSignalCenter.attach(applicationContext)
14
+ }
15
+
16
+ override fun onMessageReceived(message: RemoteMessage) {
17
+ PushSignalCenter.attach(applicationContext)
18
+ PushSignalCenter.emitMessage(message)
19
+ }
20
+ }
@@ -0,0 +1,85 @@
1
+ package com.antonseagull.cap.push.signal
2
+
3
+ import com.getcapacitor.JSObject
4
+ import com.getcapacitor.Plugin
5
+ import com.getcapacitor.PluginCall
6
+ import com.getcapacitor.PluginMethod
7
+ import com.getcapacitor.annotation.CapacitorPlugin
8
+ import java.util.concurrent.Executors
9
+
10
+ @CapacitorPlugin(name = "PushSignal")
11
+ class PushSignalPlugin : Plugin() {
12
+ @Volatile
13
+ private var listening = false
14
+
15
+ private val executor = Executors.newSingleThreadExecutor()
16
+
17
+ override fun load() {
18
+ PushSignalCenter.attach(context)
19
+ }
20
+
21
+ @PluginMethod
22
+ fun initialize(call: PluginCall) {
23
+ val config =
24
+ AndroidFirebaseConfig(
25
+ project_id = call.getString("project_id"),
26
+ mobilesdk_app_id = call.getString("mobilesdk_app_id"),
27
+ current_key = call.getString("current_key"),
28
+ project_number = call.getString("project_number"),
29
+ )
30
+
31
+ PushSignalCenter.attach(context)
32
+ PushSignalCenter.initialize(config) { error ->
33
+ if (error == null) {
34
+ call.resolve()
35
+ } else {
36
+ call.reject(error.message, error)
37
+ }
38
+ }
39
+ }
40
+
41
+ @PluginMethod
42
+ fun getCredentials(call: PluginCall) {
43
+ executor.execute {
44
+ try {
45
+ PushSignalCenter.attach(context)
46
+ val token = PushSignalCenter.fetchToken()
47
+ val result =
48
+ JSObject().apply {
49
+ put("platform", "android")
50
+ put("token", token)
51
+ }
52
+ call.resolve(result)
53
+ } catch (error: Exception) {
54
+ call.reject(error.message, error)
55
+ }
56
+ }
57
+ }
58
+
59
+ @PluginMethod
60
+ fun startListening(call: PluginCall) {
61
+ if (!listening) {
62
+ listening = true
63
+ PushSignalCenter.setOnMessage { message ->
64
+ notifyListeners("onMessage", message.toJSObject())
65
+ }
66
+ PushSignalCenter.setOnNotificationPress { message ->
67
+ notifyListeners("onNotificationPress", message.toJSObject())
68
+ }
69
+ }
70
+ call.resolve()
71
+ }
72
+ }
73
+
74
+ private fun PushMessage.toJSObject(): JSObject {
75
+ val map = JSObject()
76
+ id?.let { map.put("id", it) }
77
+ title?.let { map.put("title", it) }
78
+ body?.let { map.put("body", it) }
79
+ val dataMap = JSObject()
80
+ data.forEach { (key, value) ->
81
+ dataMap.put(key, value)
82
+ }
83
+ map.put("data", dataMap)
84
+ return map
85
+ }
File without changes