@kapsula-chat/capacitor-push-calls 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.
@@ -0,0 +1,584 @@
1
+ package com.capacitor.voipcalls
2
+
3
+ import android.Manifest
4
+ import android.app.Notification
5
+ import android.app.NotificationChannel
6
+ import android.app.NotificationManager
7
+ import android.content.Context
8
+ import android.content.Intent
9
+ import android.content.pm.PackageManager
10
+ import android.os.Build
11
+ import androidx.core.content.ContextCompat
12
+ import com.getcapacitor.JSArray
13
+ import com.getcapacitor.JSObject
14
+ import com.getcapacitor.PermissionState
15
+ import com.getcapacitor.Plugin
16
+ import com.getcapacitor.PluginCall
17
+ import com.getcapacitor.PluginMethod
18
+ import com.getcapacitor.annotation.CapacitorPlugin
19
+ import com.getcapacitor.annotation.Permission
20
+ import com.getcapacitor.annotation.PermissionCallback
21
+ import com.google.firebase.messaging.FirebaseMessaging
22
+ import org.json.JSONObject
23
+ import java.util.UUID
24
+
25
+ @CapacitorPlugin(
26
+ name = "CapacitorPushCalls",
27
+ permissions = [
28
+ Permission(strings = [Manifest.permission.RECORD_AUDIO], alias = "audio"),
29
+ Permission(strings = [Manifest.permission.CAMERA], alias = "camera"),
30
+ Permission(strings = [Manifest.permission.POST_NOTIFICATIONS], alias = "receive")
31
+ ]
32
+ )
33
+ class CapacitorVoipCallsPlugin : Plugin() {
34
+ internal var callManager: CallManager? = null
35
+
36
+ companion object {
37
+ private const val BADGE_PREFS = "capacitor_push_calls"
38
+ private const val BADGE_KEY = "badge_count"
39
+
40
+ @Volatile
41
+ private var pluginInstance: CapacitorVoipCallsPlugin? = null
42
+
43
+ private val pendingEvents = mutableListOf<Pair<String, JSObject>>()
44
+
45
+ fun emitFromService(event: String, data: JSObject) {
46
+ val instance = pluginInstance
47
+ if (instance != null) {
48
+ instance.emitEvent(event, data)
49
+ return
50
+ }
51
+
52
+ synchronized(pendingEvents) {
53
+ pendingEvents.add(event to data)
54
+ }
55
+ }
56
+
57
+ fun routeRemoteMessage(context: Context, data: Map<String, String>, title: String?, body: String?) {
58
+ val type = data["type"]?.lowercase()
59
+ if (type == "call") {
60
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
61
+ return
62
+ }
63
+
64
+ try {
65
+ VoipConnectionService.registerPhoneAccount(context)
66
+ } catch (_: SecurityException) {
67
+ }
68
+
69
+ val callId = data["callId"] ?: UUID.randomUUID().toString()
70
+ val handle = data["handle"] ?: return
71
+ val displayName = data["displayName"] ?: handle
72
+ val handleType = data["handleType"] ?: "generic"
73
+ val video = parseBoolean(data["video"])
74
+
75
+ val metadata = JSObject()
76
+ for ((key, value) in data) {
77
+ if (key in setOf("type", "callId", "handle", "displayName", "handleType", "video")) {
78
+ continue
79
+ }
80
+ metadata.put(key, value)
81
+ }
82
+
83
+ val manager = CallManager(context)
84
+ manager.reportIncomingCall(
85
+ callId = callId,
86
+ handle = handle,
87
+ displayName = displayName,
88
+ handleType = handleType,
89
+ video = video,
90
+ metadata = if (metadata.length() > 0) metadata else null
91
+ )
92
+ return
93
+ }
94
+
95
+ val notification = JSObject().apply {
96
+ put("id", data["messageId"] ?: UUID.randomUUID().toString())
97
+ if (title != null) put("title", title)
98
+ if (body != null) put("body", body)
99
+ put("data", JSObject().apply {
100
+ data.forEach { (key, value) -> put(key, value) }
101
+ })
102
+ }
103
+
104
+ emitFromService("pushNotificationReceived", notification)
105
+ }
106
+
107
+ private fun parseBoolean(value: String?): Boolean {
108
+ return value.equals("true", ignoreCase = true) || value == "1"
109
+ }
110
+ }
111
+
112
+ override fun load() {
113
+ callManager = CallManager(this)
114
+ pluginInstance = this
115
+ flushPendingEvents()
116
+ }
117
+
118
+ override fun handleOnDestroy() {
119
+ if (pluginInstance === this) {
120
+ pluginInstance = null
121
+ }
122
+ super.handleOnDestroy()
123
+ }
124
+
125
+ @PluginMethod
126
+ fun register(call: PluginCall) {
127
+ FirebaseMessaging.getInstance().token
128
+ .addOnSuccessListener { token ->
129
+ emitEvent("registration", JSObject().apply { put("value", token) })
130
+ call.resolve()
131
+ }
132
+ .addOnFailureListener { error ->
133
+ emitEvent("registrationError", JSObject().apply {
134
+ put("error", error.localizedMessage ?: "Failed to get FCM token")
135
+ })
136
+ call.reject("Failed to register for push notifications", error)
137
+ }
138
+ }
139
+
140
+ @PluginMethod
141
+ fun unregister(call: PluginCall) {
142
+ FirebaseMessaging.getInstance().deleteToken()
143
+ .addOnSuccessListener { call.resolve() }
144
+ .addOnFailureListener { error ->
145
+ call.reject("Failed to unregister from push notifications", error)
146
+ }
147
+ }
148
+
149
+ @PluginMethod
150
+ override fun checkPermissions(call: PluginCall) {
151
+ val receive = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
152
+ PermissionState.GRANTED.toString()
153
+ } else {
154
+ getPermissionState("receive").toString()
155
+ }
156
+
157
+ call.resolve(JSObject().apply {
158
+ put("receive", receive)
159
+ })
160
+ }
161
+
162
+ @PluginMethod
163
+ override fun requestPermissions(call: PluginCall) {
164
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
165
+ call.resolve(JSObject().apply {
166
+ put("receive", PermissionState.GRANTED.toString())
167
+ })
168
+ return
169
+ }
170
+
171
+ requestPermissionForAlias("receive", call, "receivePermissionCallback")
172
+ }
173
+
174
+ @PermissionCallback
175
+ private fun receivePermissionCallback(call: PluginCall) {
176
+ checkPermissions(call)
177
+ }
178
+
179
+ @PluginMethod
180
+ fun getDeliveredNotifications(call: PluginCall) {
181
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
182
+ val notifications = JSArray()
183
+
184
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
185
+ manager.activeNotifications.forEach { sbn ->
186
+ val extras = sbn.notification.extras
187
+ val notification = JSObject().apply {
188
+ put("id", sbn.id.toString())
189
+ put("title", extras.getString("android.title"))
190
+ put("body", extras.getCharSequence("android.text")?.toString())
191
+ put("tag", sbn.tag)
192
+ put("data", JSObject())
193
+ }
194
+ notifications.put(notification)
195
+ }
196
+ }
197
+
198
+ call.resolve(JSObject().apply {
199
+ put("notifications", notifications)
200
+ })
201
+ }
202
+
203
+ @PluginMethod
204
+ fun removeDeliveredNotifications(call: PluginCall) {
205
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
206
+ val payload = call.getArray("notifications")
207
+
208
+ if (payload == null) {
209
+ call.reject("Missing notifications parameter")
210
+ return
211
+ }
212
+
213
+ for (index in 0 until payload.length()) {
214
+ when (val item = payload.opt(index)) {
215
+ is String -> manager.cancel(item.toIntOrNull() ?: continue)
216
+ is JSObject -> {
217
+ val id = item.getString("id")
218
+ if (id != null) {
219
+ manager.cancel(id.toIntOrNull() ?: continue)
220
+ }
221
+ }
222
+ is JSONObject -> {
223
+ val id = item.optString("id", null)
224
+ if (id != null) {
225
+ manager.cancel(id.toIntOrNull() ?: continue)
226
+ }
227
+ }
228
+ }
229
+ }
230
+
231
+ call.resolve()
232
+ }
233
+
234
+ @PluginMethod
235
+ fun removeAllDeliveredNotifications(call: PluginCall) {
236
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
237
+ manager.cancelAll()
238
+ call.resolve()
239
+ }
240
+
241
+ @PluginMethod
242
+ fun getBadgeCount(call: PluginCall) {
243
+ val prefs = context.getSharedPreferences(BADGE_PREFS, Context.MODE_PRIVATE)
244
+ val count = prefs.getInt(BADGE_KEY, 0)
245
+ call.resolve(JSObject().apply {
246
+ put("count", count)
247
+ })
248
+ }
249
+
250
+ @PluginMethod
251
+ fun getBadgeNumber(call: PluginCall) {
252
+ getBadgeCount(call)
253
+ }
254
+
255
+ @PluginMethod
256
+ fun setBadgeCount(call: PluginCall) {
257
+ val count = call.getInt("count")
258
+ if (count == null || count < 0) {
259
+ call.reject("Invalid count parameter")
260
+ return
261
+ }
262
+
263
+ val prefs = context.getSharedPreferences(BADGE_PREFS, Context.MODE_PRIVATE)
264
+ prefs.edit().putInt(BADGE_KEY, count).apply()
265
+ call.resolve()
266
+ }
267
+
268
+ @PluginMethod
269
+ fun setBadgeNumber(call: PluginCall) {
270
+ setBadgeCount(call)
271
+ }
272
+
273
+ @PluginMethod
274
+ fun clearBadgeCount(call: PluginCall) {
275
+ val prefs = context.getSharedPreferences(BADGE_PREFS, Context.MODE_PRIVATE)
276
+ prefs.edit().putInt(BADGE_KEY, 0).apply()
277
+ call.resolve()
278
+ }
279
+
280
+ @PluginMethod
281
+ fun createChannel(call: PluginCall) {
282
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
283
+ call.resolve()
284
+ return
285
+ }
286
+
287
+ val id = call.getString("id")
288
+ val name = call.getString("name")
289
+ if (id == null || name == null) {
290
+ call.reject("Missing required channel fields: id and name")
291
+ return
292
+ }
293
+
294
+ val importance = call.getInt("importance") ?: NotificationManager.IMPORTANCE_DEFAULT
295
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
296
+ val channel = NotificationChannel(id, name, importance).apply {
297
+ description = call.getString("description")
298
+ enableLights(call.getBoolean("lights") ?: false)
299
+ enableVibration(call.getBoolean("vibration") ?: true)
300
+ lockscreenVisibility = call.getInt("visibility") ?: Notification.VISIBILITY_PRIVATE
301
+ val sound = call.getString("sound")
302
+ if (sound == null || sound == "default") {
303
+ setSound(null, null)
304
+ }
305
+ }
306
+
307
+ manager.createNotificationChannel(channel)
308
+ call.resolve()
309
+ }
310
+
311
+ @PluginMethod
312
+ fun deleteChannel(call: PluginCall) {
313
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
314
+ call.resolve()
315
+ return
316
+ }
317
+
318
+ val id = call.getString("id")
319
+ if (id == null) {
320
+ call.reject("Missing channel id")
321
+ return
322
+ }
323
+
324
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
325
+ manager.deleteNotificationChannel(id)
326
+ call.resolve()
327
+ }
328
+
329
+ @PluginMethod
330
+ fun listChannels(call: PluginCall) {
331
+ val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
332
+ val channels = JSArray()
333
+
334
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
335
+ manager.notificationChannels.forEach { channel ->
336
+ channels.put(JSObject().apply {
337
+ put("id", channel.id)
338
+ put("name", channel.name.toString())
339
+ put("description", channel.description)
340
+ put("importance", channel.importance)
341
+ put("visibility", channel.lockscreenVisibility)
342
+ put("lights", channel.shouldShowLights())
343
+ put("vibration", channel.shouldVibrate())
344
+ })
345
+ }
346
+ }
347
+
348
+ call.resolve(JSObject().apply {
349
+ put("channels", channels)
350
+ })
351
+ }
352
+
353
+ @PluginMethod
354
+ fun registerVoipNotifications(call: PluginCall) {
355
+ // Android uses FCM for call signaling via PushRouterMessagingService.
356
+ call.resolve()
357
+ }
358
+
359
+ @PluginMethod
360
+ fun startCall(call: PluginCall) {
361
+ startCallInternal(call, canRequestPermissions = true)
362
+ }
363
+
364
+ private fun startCallInternal(call: PluginCall, canRequestPermissions: Boolean) {
365
+ val handle = call.getString("handle") ?: run {
366
+ call.reject("Missing handle parameter")
367
+ return
368
+ }
369
+ val displayName = call.getString("displayName") ?: run {
370
+ call.reject("Missing displayName parameter")
371
+ return
372
+ }
373
+
374
+ val handleType = call.getString("handleType") ?: "generic"
375
+ val video = call.getBoolean("video", false) ?: false
376
+
377
+ if (!hasRequiredPermissions()) {
378
+ if (!canRequestPermissions) {
379
+ call.reject("Missing required RECORD_AUDIO permission")
380
+ return
381
+ }
382
+ requestPermissionForAliases(arrayOf("audio"), call, "startCallPermissions")
383
+ return
384
+ }
385
+
386
+ val callId = UUID.randomUUID().toString()
387
+
388
+ callManager?.startCall(
389
+ callId = callId,
390
+ handle = handle,
391
+ displayName = displayName,
392
+ handleType = handleType,
393
+ video = video
394
+ )
395
+
396
+ call.resolve(JSObject().apply {
397
+ put("callId", callId)
398
+ })
399
+ }
400
+
401
+ @PermissionCallback
402
+ private fun startCallPermissions(call: PluginCall) {
403
+ startCallInternal(call, canRequestPermissions = false)
404
+ }
405
+
406
+ @PluginMethod
407
+ fun endCall(call: PluginCall) {
408
+ val callId = call.getString("callId") ?: run {
409
+ call.reject("Missing callId parameter")
410
+ return
411
+ }
412
+
413
+ callManager?.endCall(callId)
414
+ call.resolve()
415
+ }
416
+
417
+ @PluginMethod
418
+ fun answerCall(call: PluginCall) {
419
+ answerCallInternal(call, canRequestPermissions = true)
420
+ }
421
+
422
+ private fun answerCallInternal(call: PluginCall, canRequestPermissions: Boolean) {
423
+ val callId = call.getString("callId") ?: run {
424
+ call.reject("Missing callId parameter")
425
+ return
426
+ }
427
+
428
+ if (!hasRequiredPermissions()) {
429
+ if (!canRequestPermissions) {
430
+ call.reject("Missing required RECORD_AUDIO permission")
431
+ return
432
+ }
433
+ requestPermissionForAliases(arrayOf("audio"), call, "answerCallPermissions")
434
+ return
435
+ }
436
+
437
+ callManager?.answerCall(callId)
438
+ call.resolve()
439
+ }
440
+
441
+ @PermissionCallback
442
+ private fun answerCallPermissions(call: PluginCall) {
443
+ answerCallInternal(call, canRequestPermissions = false)
444
+ }
445
+
446
+ @PluginMethod
447
+ fun rejectCall(call: PluginCall) {
448
+ val callId = call.getString("callId") ?: run {
449
+ call.reject("Missing callId parameter")
450
+ return
451
+ }
452
+
453
+ callManager?.rejectCall(callId)
454
+ call.resolve()
455
+ }
456
+
457
+ @PluginMethod
458
+ fun setCallOnHold(call: PluginCall) {
459
+ val callId = call.getString("callId") ?: run {
460
+ call.reject("Missing callId parameter")
461
+ return
462
+ }
463
+ val onHold = call.getBoolean("onHold", false) ?: false
464
+
465
+ callManager?.setCallOnHold(callId, onHold)
466
+ call.resolve()
467
+ }
468
+
469
+ @PluginMethod
470
+ fun setAudioRoute(call: PluginCall) {
471
+ val route = call.getString("route") ?: run {
472
+ call.reject("Missing route parameter")
473
+ return
474
+ }
475
+
476
+ callManager?.setAudioRoute(route)
477
+ call.resolve()
478
+ }
479
+
480
+ @PluginMethod
481
+ fun setMuted(call: PluginCall) {
482
+ val muted = call.getBoolean("muted", false) ?: false
483
+
484
+ callManager?.setMuted(muted)
485
+ call.resolve()
486
+ }
487
+
488
+ @PluginMethod
489
+ fun handleIncomingCall(call: PluginCall) {
490
+ val callId = call.getString("callId") ?: UUID.randomUUID().toString()
491
+ val handle = call.getString("handle") ?: run {
492
+ call.reject("Missing handle parameter")
493
+ return
494
+ }
495
+ val displayName = call.getString("displayName") ?: run {
496
+ call.reject("Missing displayName parameter")
497
+ return
498
+ }
499
+
500
+ val handleType = call.getString("handleType") ?: "generic"
501
+ val video = call.getBoolean("video", false) ?: false
502
+ val metadata = call.getObject("metadata")
503
+
504
+ callManager?.reportIncomingCall(
505
+ callId = callId,
506
+ handle = handle,
507
+ displayName = displayName,
508
+ handleType = handleType,
509
+ video = video,
510
+ metadata = metadata
511
+ )
512
+
513
+ call.resolve(JSObject().apply {
514
+ put("callId", callId)
515
+ })
516
+ }
517
+
518
+ @PluginMethod
519
+ fun updateCallStatus(call: PluginCall) {
520
+ val callId = call.getString("callId") ?: run {
521
+ call.reject("Missing callId parameter")
522
+ return
523
+ }
524
+ val status = call.getString("status") ?: run {
525
+ call.reject("Missing status parameter")
526
+ return
527
+ }
528
+
529
+ callManager?.updateCallStatus(callId, status)
530
+ call.resolve()
531
+ }
532
+
533
+ @PluginMethod
534
+ fun isSupported(call: PluginCall) {
535
+ call.resolve(JSObject().apply {
536
+ put("supported", Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
537
+ })
538
+ }
539
+
540
+ override fun handleOnNewIntent(intent: Intent?) {
541
+ super.handleOnNewIntent(intent)
542
+ val extras = intent?.extras ?: return
543
+
544
+ if (!extras.containsKey("google.message_id") && !extras.containsKey("type")) {
545
+ return
546
+ }
547
+
548
+ val data = JSObject()
549
+ for (key in extras.keySet()) {
550
+ data.put(key, extras.get(key))
551
+ }
552
+
553
+ val actionPayload = JSObject().apply {
554
+ put("actionId", "tap")
555
+ put("notification", JSObject().apply {
556
+ put("id", extras.getString("google.message_id") ?: UUID.randomUUID().toString())
557
+ put("title", extras.getString("title"))
558
+ put("body", extras.getString("body"))
559
+ put("data", data)
560
+ })
561
+ }
562
+ emitEvent("pushNotificationActionPerformed", actionPayload)
563
+ }
564
+
565
+ private fun hasRequiredPermissions(): Boolean {
566
+ return ContextCompat.checkSelfPermission(
567
+ context,
568
+ Manifest.permission.RECORD_AUDIO
569
+ ) == PackageManager.PERMISSION_GRANTED
570
+ }
571
+
572
+ internal fun emitEvent(event: String, data: JSObject) {
573
+ super.notifyListeners(event, data)
574
+ }
575
+
576
+ private fun flushPendingEvents() {
577
+ synchronized(pendingEvents) {
578
+ pendingEvents.forEach { (event, payload) ->
579
+ emitEvent(event, payload)
580
+ }
581
+ pendingEvents.clear()
582
+ }
583
+ }
584
+ }
@@ -0,0 +1,26 @@
1
+ package com.capacitor.voipcalls
2
+
3
+ import com.getcapacitor.JSObject
4
+ import com.google.firebase.messaging.FirebaseMessagingService
5
+ import com.google.firebase.messaging.RemoteMessage
6
+
7
+ class PushRouterMessagingService : FirebaseMessagingService() {
8
+ override fun onNewToken(token: String) {
9
+ super.onNewToken(token)
10
+ CapacitorVoipCallsPlugin.emitFromService(
11
+ "registration",
12
+ JSObject().apply { put("value", token) }
13
+ )
14
+ }
15
+
16
+ override fun onMessageReceived(message: RemoteMessage) {
17
+ super.onMessageReceived(message)
18
+
19
+ CapacitorVoipCallsPlugin.routeRemoteMessage(
20
+ context = applicationContext,
21
+ data = message.data,
22
+ title = message.notification?.title,
23
+ body = message.notification?.body
24
+ )
25
+ }
26
+ }