@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.
package/Package.swift ADDED
@@ -0,0 +1,31 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "CapacitorPushCalls",
6
+ platforms: [.iOS(.v13)],
7
+ products: [
8
+ .library(
9
+ name: "CapacitorPushCalls",
10
+ targets: ["CapacitorPushCallsPlugin"])
11
+ ],
12
+ dependencies: [
13
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", branch: "main")
14
+ ],
15
+ targets: [
16
+ .target(
17
+ name: "CapacitorPushCallsPlugin",
18
+ dependencies: [
19
+ .product(name: "Capacitor", package: "capacitor-swift-pm"),
20
+ .product(name: "Cordova", package: "capacitor-swift-pm")
21
+ ],
22
+ path: "ios/Plugin",
23
+ exclude: ["Plugin.m"],
24
+ sources: [
25
+ "CallManager.swift",
26
+ "CapacitorVoipCallsPlugin.swift"
27
+ ],
28
+ publicHeadersPath: "."
29
+ )
30
+ ]
31
+ )
package/README.md ADDED
@@ -0,0 +1,252 @@
1
+ # Capacitor Push Calls Plugin
2
+
3
+ `@kapsula-chat/capacitor-push-calls` is a unified Capacitor plugin for:
4
+ - regular push notifications (API compatible in shape with `@capacitor/push-notifications`)
5
+ - VoIP/telephony call handling
6
+
7
+ Platform behavior:
8
+ - iOS: regular push via APNS registration + VoIP via PushKit + CallKit UI
9
+ - Android: single FCM router service inside this plugin (`type=message|call`) + ConnectionService UI for calls
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @kapsula-chat/capacitor-push-calls
15
+ npx cap sync
16
+ ```
17
+
18
+ ## Add Plugin To App (iOS + Android)
19
+
20
+ 1. Install and sync:
21
+ ```bash
22
+ npm install @kapsula-chat/capacitor-push-calls
23
+ npx cap sync
24
+ ```
25
+ 2. Open native projects at least once so Capacitor updates plugin wiring:
26
+ ```bash
27
+ npx cap open ios
28
+ npx cap open android
29
+ ```
30
+ 3. iOS:
31
+ - Ensure VoIP background mode is enabled in `Info.plist` (see iOS section below).
32
+ - If you use FCM for regular pushes on iOS, keep your APNs + Firebase setup configured in the app.
33
+ 4. Android:
34
+ - Ensure your app is configured for Firebase Messaging (`google-services.json`, Firebase project).
35
+ - Register the phone account once on app startup (recommended):
36
+
37
+ ```kotlin
38
+ import com.capacitor.voipcalls.VoipConnectionService
39
+
40
+ override fun onCreate(savedInstanceState: Bundle?) {
41
+ super.onCreate(savedInstanceState)
42
+ VoipConnectionService.registerPhoneAccount(this)
43
+ }
44
+ ```
45
+
46
+ ## Import
47
+
48
+ ```ts
49
+ import { CapacitorPushCalls } from '@kapsula-chat/capacitor-push-calls';
50
+ ```
51
+
52
+ ## Push API (regular notifications)
53
+
54
+ ### Register / permissions
55
+
56
+ ```ts
57
+ const permissions = await CapacitorPushCalls.checkPermissions();
58
+ if (permissions.receive !== 'granted') {
59
+ await CapacitorPushCalls.requestPermissions();
60
+ }
61
+
62
+ await CapacitorPushCalls.register();
63
+
64
+ CapacitorPushCalls.addListener('registration', token => {
65
+ console.log('push token:', token.value);
66
+ // Send token to backend and bind with authenticated user/device
67
+ });
68
+
69
+ CapacitorPushCalls.addListener('registrationError', error => {
70
+ console.error('registration error:', error.error);
71
+ });
72
+ ```
73
+
74
+ ### Receive and tap events
75
+
76
+ ```ts
77
+ CapacitorPushCalls.addListener('pushNotificationReceived', notification => {
78
+ console.log('push received:', notification);
79
+ });
80
+
81
+ CapacitorPushCalls.addListener('pushNotificationActionPerformed', action => {
82
+ console.log('push action:', action.actionId, action.notification);
83
+ });
84
+ ```
85
+
86
+ ### Delivered notifications and channels
87
+
88
+ ```ts
89
+ const delivered = await CapacitorPushCalls.getDeliveredNotifications();
90
+ await CapacitorPushCalls.removeAllDeliveredNotifications();
91
+
92
+ await CapacitorPushCalls.createChannel({
93
+ id: 'messages',
94
+ name: 'Messages',
95
+ importance: 4,
96
+ });
97
+
98
+ const channels = await CapacitorPushCalls.listChannels();
99
+ await CapacitorPushCalls.deleteChannel({ id: 'messages' });
100
+ ```
101
+
102
+ Note: channel APIs are Android-only (iOS returns no-op / empty list).
103
+
104
+ ### Badge count
105
+
106
+ ```ts
107
+ const current = await CapacitorPushCalls.getBadgeCount();
108
+ await CapacitorPushCalls.setBadgeCount({ count: current.count + 1 });
109
+ await CapacitorPushCalls.clearBadgeCount();
110
+ ```
111
+
112
+ ## Android Device Registration
113
+
114
+ Recommended bootstrap flow on Android:
115
+
116
+ ```ts
117
+ const permissions = await CapacitorPushCalls.checkPermissions();
118
+ if (permissions.receive !== 'granted') {
119
+ await CapacitorPushCalls.requestPermissions();
120
+ }
121
+
122
+ await CapacitorPushCalls.register();
123
+
124
+ CapacitorPushCalls.addListener('registration', async ({ value }) => {
125
+ // Persist FCM token on backend for this device/session
126
+ await api.registerPushDevice({ token: value, platform: 'android' });
127
+ });
128
+ ```
129
+
130
+ Use the same `registration` flow on iOS for regular push token registration.
131
+
132
+ ## VoIP / calls API
133
+
134
+ ### iOS VoIP registration
135
+
136
+ ```ts
137
+ await CapacitorPushCalls.registerVoipNotifications();
138
+
139
+ CapacitorPushCalls.addListener('voipPushToken', ({ token }) => {
140
+ console.log('voip token:', token);
141
+ });
142
+ ```
143
+
144
+ ### Start / control calls
145
+
146
+ ```ts
147
+ const { callId } = await CapacitorPushCalls.startCall({
148
+ handle: '+1234567890',
149
+ displayName: 'John Doe',
150
+ handleType: 'phone',
151
+ video: false,
152
+ });
153
+
154
+ await CapacitorPushCalls.setMuted({ muted: true });
155
+ await CapacitorPushCalls.setAudioRoute({ route: 'speaker' });
156
+ await CapacitorPushCalls.setCallOnHold({ callId, onHold: true });
157
+ await CapacitorPushCalls.endCall({ callId });
158
+ ```
159
+
160
+ ### Call events
161
+
162
+ ```ts
163
+ CapacitorPushCalls.addListener('incomingCall', call => {});
164
+ CapacitorPushCalls.addListener('callStarted', call => {});
165
+ CapacitorPushCalls.addListener('callAnswered', call => {});
166
+ CapacitorPushCalls.addListener('callEnded', call => {});
167
+ CapacitorPushCalls.addListener('callRejected', call => {});
168
+ CapacitorPushCalls.addListener('callHeld', call => {});
169
+ ```
170
+
171
+ ## Android FCM routing contract
172
+
173
+ This plugin owns one `FirebaseMessagingService` and routes by `data.type`.
174
+
175
+ - `type=call` -> reported as incoming call to system UI
176
+ - anything else (or missing `type`) -> emitted as `pushNotificationReceived`
177
+
178
+ Example FCM message payloads:
179
+
180
+ ### Message push
181
+
182
+ ```json
183
+ {
184
+ "data": {
185
+ "type": "message",
186
+ "messageId": "msg-123",
187
+ "chatId": "room-7"
188
+ },
189
+ "notification": {
190
+ "title": "New message",
191
+ "body": "Hello"
192
+ }
193
+ }
194
+ ```
195
+
196
+ ### Call push
197
+
198
+ ```json
199
+ {
200
+ "data": {
201
+ "type": "call",
202
+ "callId": "call-123",
203
+ "handle": "+1234567890",
204
+ "displayName": "John Doe",
205
+ "handleType": "phone",
206
+ "video": "false"
207
+ }
208
+ }
209
+ ```
210
+
211
+ ## Foreground Service (Android)
212
+
213
+ The plugin currently routes FCM and triggers ConnectionService/Call UI, but it does not run a long-lived custom foreground service for your app logic.
214
+
215
+ Use your own foreground service if you need:
216
+ - long-running signaling/reconnect loops in background,
217
+ - persistent background media/session processing,
218
+ - guaranteed ongoing processing beyond call UI handoff.
219
+
220
+ Typical split:
221
+ - plugin handles push ingress + system call UI bridge,
222
+ - app service handles long-lived networking/media responsibilities.
223
+
224
+ ## iOS setup notes
225
+
226
+ Add VoIP background mode in `Info.plist`:
227
+
228
+ ```xml
229
+ <key>UIBackgroundModes</key>
230
+ <array>
231
+ <string>voip</string>
232
+ </array>
233
+ ```
234
+
235
+ Server side:
236
+ - regular push -> APNS/FCM token from `registration`
237
+ - VoIP call push -> PushKit token from `voipPushToken`
238
+
239
+ ## Test app helper
240
+
241
+ Use included helper scripts and UI:
242
+
243
+ ```bash
244
+ npm run test:setup
245
+ npm run test:update
246
+ ```
247
+
248
+ The generated test app uses `scripts/test-app-ui.html`.
249
+
250
+ ## API Types
251
+
252
+ See `/src/definitions.ts` for the complete TypeScript contract.
@@ -0,0 +1,46 @@
1
+ apply plugin: 'com.android.library'
2
+ apply plugin: 'kotlin-android'
3
+
4
+ android {
5
+ namespace "com.capacitor.voipcalls"
6
+ compileSdkVersion project.hasProperty('compileSdkVersion') ? rootProject.ext.compileSdkVersion : 33
7
+ defaultConfig {
8
+ minSdkVersion project.hasProperty('minSdkVersion') ? rootProject.ext.minSdkVersion : 23
9
+ targetSdkVersion project.hasProperty('targetSdkVersion') ? rootProject.ext.targetSdkVersion : 33
10
+ versionCode 1
11
+ versionName "1.0"
12
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
13
+ }
14
+ buildTypes {
15
+ release {
16
+ minifyEnabled false
17
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
18
+ }
19
+ }
20
+ lintOptions {
21
+ abortOnError false
22
+ }
23
+ compileOptions {
24
+ sourceCompatibility JavaVersion.VERSION_17
25
+ targetCompatibility JavaVersion.VERSION_17
26
+ }
27
+ kotlinOptions {
28
+ jvmTarget = '17'
29
+ }
30
+ }
31
+
32
+ repositories {
33
+ google()
34
+ mavenCentral()
35
+ }
36
+
37
+ dependencies {
38
+ implementation fileTree(dir: 'libs', include: ['*.jar'])
39
+ implementation project(':capacitor-android')
40
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
41
+ implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
42
+ compileOnly "com.google.firebase:firebase-messaging:24.1.2"
43
+ testImplementation "junit:junit:$junitVersion"
44
+ androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
45
+ androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
46
+ }
@@ -0,0 +1,31 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
+ package="com.capacitor.voipcalls">
3
+
4
+ <uses-permission android:name="android.permission.RECORD_AUDIO" />
5
+ <uses-permission android:name="android.permission.CAMERA" />
6
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
7
+ <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
8
+ <uses-permission android:name="android.permission.BLUETOOTH" />
9
+ <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
10
+ <uses-permission android:name="android.permission.READ_PHONE_STATE" />
11
+ <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
12
+
13
+ <application>
14
+ <service
15
+ android:name=".VoipConnectionService"
16
+ android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
17
+ android:exported="true">
18
+ <intent-filter>
19
+ <action android:name="android.telecom.ConnectionService" />
20
+ </intent-filter>
21
+ </service>
22
+
23
+ <service
24
+ android:name=".PushRouterMessagingService"
25
+ android:exported="false">
26
+ <intent-filter>
27
+ <action android:name="com.google.firebase.MESSAGING_EVENT" />
28
+ </intent-filter>
29
+ </service>
30
+ </application>
31
+ </manifest>
@@ -0,0 +1,213 @@
1
+ package com.capacitor.voipcalls
2
+
3
+ import android.content.Context
4
+ import android.media.AudioManager
5
+ import android.os.Build
6
+ import android.telecom.*
7
+ import androidx.annotation.RequiresApi
8
+ import com.getcapacitor.JSObject
9
+
10
+ @RequiresApi(Build.VERSION_CODES.M)
11
+ class CallManager(
12
+ private val context: Context,
13
+ private val eventEmitter: ((String, JSObject) -> Unit)? = null
14
+ ) {
15
+ private val telecomManager: TelecomManager? =
16
+ context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager
17
+
18
+ private val activeCalls = mutableMapOf<String, VoipConnection>()
19
+
20
+ data class CallData(
21
+ val callId: String,
22
+ val handle: String,
23
+ val displayName: String,
24
+ val handleType: String,
25
+ val video: Boolean,
26
+ val metadata: JSObject? = null
27
+ )
28
+
29
+ constructor(plugin: CapacitorVoipCallsPlugin) : this(plugin.context, plugin::emitEvent)
30
+
31
+ fun startCall(callId: String, handle: String, displayName: String, handleType: String, video: Boolean) {
32
+ val phoneAccount = VoipConnectionService.getPhoneAccount(context)
33
+
34
+ val extras = android.os.Bundle().apply {
35
+ putString("callId", callId)
36
+ putString("handle", handle)
37
+ putString("displayName", displayName)
38
+ putString("handleType", handleType)
39
+ putBoolean("video", video)
40
+ putBoolean("isOutgoing", true)
41
+ }
42
+
43
+ val uri = android.net.Uri.fromParts("tel", handle, null)
44
+ val telecomExtras = android.os.Bundle().apply {
45
+ putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, phoneAccount.accountHandle)
46
+ putInt(TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE,
47
+ if (video) VideoProfile.STATE_BIDIRECTIONAL else VideoProfile.STATE_AUDIO_ONLY)
48
+ putBundle(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, extras)
49
+ }
50
+
51
+ try {
52
+ telecomManager?.placeCall(uri, telecomExtras)
53
+ } catch (e: SecurityException) {
54
+ notifyError("Failed to place call: ${e.message}")
55
+ }
56
+ }
57
+
58
+ fun reportIncomingCall(
59
+ callId: String,
60
+ handle: String,
61
+ displayName: String,
62
+ handleType: String,
63
+ video: Boolean,
64
+ metadata: JSObject?
65
+ ) {
66
+ val phoneAccount = VoipConnectionService.getPhoneAccount(context)
67
+
68
+ val extras = android.os.Bundle().apply {
69
+ putString("callId", callId)
70
+ putString("handle", handle)
71
+ putString("displayName", displayName)
72
+ putString("handleType", handleType)
73
+ putBoolean("video", video)
74
+ if (metadata != null) {
75
+ putString("metadata", metadata.toString())
76
+ }
77
+ }
78
+
79
+ val uri = android.net.Uri.fromParts("tel", handle, null)
80
+
81
+ try {
82
+ telecomManager?.addNewIncomingCall(phoneAccount.accountHandle, extras)
83
+
84
+ // Notify JavaScript layer
85
+ val data = JSObject().apply {
86
+ put("callId", callId)
87
+ put("handle", handle)
88
+ put("displayName", displayName)
89
+ put("handleType", handleType)
90
+ put("video", video)
91
+ if (metadata != null) {
92
+ put("metadata", metadata)
93
+ }
94
+ }
95
+ eventEmitter?.invoke("incomingCall", data)
96
+ } catch (e: SecurityException) {
97
+ notifyError("Failed to report incoming call: ${e.message}")
98
+ }
99
+ }
100
+
101
+ fun endCall(callId: String) {
102
+ activeCalls[callId]?.onDisconnect()
103
+ activeCalls.remove(callId)
104
+ }
105
+
106
+ fun answerCall(callId: String) {
107
+ activeCalls[callId]?.onAnswer()
108
+ }
109
+
110
+ fun rejectCall(callId: String) {
111
+ activeCalls[callId]?.onReject()
112
+ activeCalls.remove(callId)
113
+ }
114
+
115
+ fun setCallOnHold(callId: String, onHold: Boolean) {
116
+ if (onHold) {
117
+ activeCalls[callId]?.onHold()
118
+ } else {
119
+ activeCalls[callId]?.onUnhold()
120
+ }
121
+ }
122
+
123
+ fun setAudioRoute(route: String) {
124
+ val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
125
+
126
+ when (route) {
127
+ "speaker" -> {
128
+ audioManager.isSpeakerphoneOn = true
129
+ }
130
+ "earpiece" -> {
131
+ audioManager.isSpeakerphoneOn = false
132
+ }
133
+ "bluetooth" -> {
134
+ // Bluetooth routing is handled automatically when connected
135
+ audioManager.isBluetoothScoOn = true
136
+ audioManager.startBluetoothSco()
137
+ }
138
+ }
139
+ }
140
+
141
+ fun setMuted(muted: Boolean) {
142
+ val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
143
+ audioManager.isMicrophoneMute = muted
144
+ }
145
+
146
+ fun updateCallStatus(callId: String, status: String) {
147
+ val connection = activeCalls[callId] ?: return
148
+
149
+ when (status) {
150
+ "connecting" -> connection.setDialing()
151
+ "connected" -> connection.setActive()
152
+ "disconnected" -> {
153
+ connection.setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
154
+ connection.destroy()
155
+ activeCalls.remove(callId)
156
+ }
157
+ "failed" -> {
158
+ connection.setDisconnected(DisconnectCause(DisconnectCause.ERROR))
159
+ connection.destroy()
160
+ activeCalls.remove(callId)
161
+ }
162
+ }
163
+ }
164
+
165
+ fun registerConnection(callId: String, connection: VoipConnection) {
166
+ activeCalls[callId] = connection
167
+ }
168
+
169
+ fun notifyCallStarted(callId: String) {
170
+ val data = JSObject().apply {
171
+ put("callId", callId)
172
+ }
173
+ eventEmitter?.invoke("callStarted", data)
174
+ }
175
+
176
+ fun notifyCallAnswered(callId: String) {
177
+ val data = JSObject().apply {
178
+ put("callId", callId)
179
+ }
180
+ eventEmitter?.invoke("callAnswered", data)
181
+ }
182
+
183
+ fun notifyCallEnded(callId: String, reason: String? = null) {
184
+ val data = JSObject().apply {
185
+ put("callId", callId)
186
+ if (reason != null) {
187
+ put("reason", reason)
188
+ }
189
+ }
190
+ eventEmitter?.invoke("callEnded", data)
191
+ activeCalls.remove(callId)
192
+ }
193
+
194
+ fun notifyCallRejected(callId: String) {
195
+ val data = JSObject().apply {
196
+ put("callId", callId)
197
+ }
198
+ eventEmitter?.invoke("callRejected", data)
199
+ activeCalls.remove(callId)
200
+ }
201
+
202
+ fun notifyCallHeld(callId: String, onHold: Boolean) {
203
+ val data = JSObject().apply {
204
+ put("callId", callId)
205
+ put("onHold", onHold)
206
+ }
207
+ eventEmitter?.invoke("callHeld", data)
208
+ }
209
+
210
+ private fun notifyError(message: String) {
211
+ android.util.Log.e("CallManager", message)
212
+ }
213
+ }