@telnyx/react-voice-commons-sdk 0.4.3 → 1.1.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 (47) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +28 -7
  3. package/android/src/main/java/com/telnyx/react_voice_commons/CallForegroundService.kt +16 -2
  4. package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxFirebaseMessagingService.kt +16 -2
  5. package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxNotificationHelper.kt +345 -24
  6. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnBridgeModule.kt +74 -1
  7. package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnManager.kt +36 -1
  8. package/ios/CallKitBridge.m +20 -1
  9. package/ios/CallKitBridge.swift +935 -60
  10. package/ios/README.md +2 -2
  11. package/ios/VoicePnBridge.m +21 -1
  12. package/ios/VoicePnBridge.swift +62 -1
  13. package/lib/callkit/callkit-coordinator.d.ts +39 -3
  14. package/lib/callkit/callkit-coordinator.js +561 -148
  15. package/lib/callkit/callkit.d.ts +7 -0
  16. package/lib/callkit/callkit.js +83 -11
  17. package/lib/hooks/useAppStateHandler.js +6 -2
  18. package/lib/index.d.ts +1 -0
  19. package/lib/index.js +9 -0
  20. package/lib/internal/CallKitHandler.d.ts +3 -0
  21. package/lib/internal/CallKitHandler.js +52 -46
  22. package/lib/internal/calls/call-state-controller.d.ts +39 -1
  23. package/lib/internal/calls/call-state-controller.js +152 -64
  24. package/lib/internal/session/session-manager.d.ts +16 -1
  25. package/lib/internal/session/session-manager.js +229 -58
  26. package/lib/internal/voice-pn-bridge.d.ts +44 -0
  27. package/lib/internal/voice-pn-bridge.js +94 -0
  28. package/lib/models/call.js +35 -0
  29. package/lib/models/config.d.ts +18 -0
  30. package/lib/models/config.js +2 -0
  31. package/lib/telnyx-voice-app.js +85 -43
  32. package/lib/telnyx-voip-client.d.ts +48 -5
  33. package/lib/telnyx-voip-client.js +276 -22
  34. package/package.json +7 -5
  35. package/src/callkit/callkit-coordinator.ts +642 -160
  36. package/src/callkit/callkit.ts +110 -12
  37. package/src/hooks/useAppStateHandler.ts +7 -2
  38. package/src/index.ts +3 -0
  39. package/src/internal/CallKitHandler.tsx +60 -55
  40. package/src/internal/calls/call-state-controller.ts +173 -74
  41. package/src/internal/session/session-manager.ts +277 -61
  42. package/src/internal/voice-pn-bridge.ts +111 -0
  43. package/src/models/call.ts +34 -0
  44. package/src/models/config.ts +18 -0
  45. package/src/telnyx-voice-app.tsx +96 -45
  46. package/src/telnyx-voip-client.ts +260 -24
  47. package/src/types/telnyx-sdk.d.ts +12 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # CHANGELOG.md
2
2
 
3
+ ## Unreleased
4
+
5
+ ## [1.1.0] (2026-08-07)
6
+
7
+ ### Enhancement
8
+
9
+ - Add and persist opt-in `pushWhenActive` support so push routing remains available when an active WebSocket connection would otherwise be prioritized.
10
+ - Add Android `incomingCallRingtone` configuration. Apps can provide the name of an audio resource bundled in `android/app/src/main/res/raw`; the setting is retained for incoming FCM calls.
11
+ - Add iOS call-waiting support, including UUID-targeted answer, end, hold, resume, and `swapCalls(targetCallId)` operations for two simultaneous CallKit calls.
12
+ - Expose the explicitly selected active call through the public client API and re-emit call collections when a tracked call changes state.
13
+ - Forward custom SIP headers supplied to `newCall()` in the React Native SDK's expected format.
14
+ - Add Android push-notification fallbacks when call notifications are unavailable, and declare the required foreground-service type for active calls.
15
+ - Add documentation for `pushWhenActive`, answered-elsewhere handling, Trickle ICE, and Android custom incoming ringtones.
16
+
17
+ ### Bug Fixing
18
+
19
+ - Play and loop the Android incoming-call ringtone until the call is answered, rejected, ended, or dismissed. The SDK falls back to the device's selected phone ringtone when no valid app resource is configured.
20
+ - Preserve iOS CallKit answers that occur before React Native listeners attach during a VoIP-push cold launch, including the case where the app omits a PushKit token while the native token is already available.
21
+ - Prevent stale CallKit callbacks and subscription leaks after React re-renders or the active call changes.
22
+ - Handle repeated holds, call swaps, rejected pushes, malformed PushKit payloads, Focus/DND-filtered CallKit registrations, and a second incoming call without leaving ghost calls or stuck foreground flags.
23
+ - Harden session teardown and reconnection by serializing disposal, cancelling pending connection work, and waiting for socket cleanup before continuing.
24
+ - Clear stale fallback push actions and keep incoming-call handling available when notifications are disabled.
25
+
3
26
  ## [0.4.3] (2026-04-30)
4
27
 
5
28
  ### Bug Fixing
package/README.md CHANGED
@@ -79,7 +79,7 @@ const voipClient = createTelnyxVoipClient({
79
79
 
80
80
  // If you need to tear down and recreate the client (e.g., on logout):
81
81
  import { destroyTelnyxVoipClient } from '@telnyx/react-voice-commons-sdk';
82
- destroyTelnyxVoipClient(); // Disposes the singleton; next createTelnyxVoipClient() call creates a fresh instance
82
+ await destroyTelnyxVoipClient(); // Disposes the singleton; next createTelnyxVoipClient() call creates a fresh instance
83
83
  ```
84
84
 
85
85
  **Configuration Options Explained:**
@@ -120,14 +120,16 @@ call.callState$.subscribe((state) => {
120
120
 
121
121
  As of **v0.3.0**, the SDK no longer navigates the host app. Routing on state transitions (e.g. redirecting to a login screen on disconnect, surfacing an in-call screen when a call arrives via push) is entirely the host app's responsibility. Subscribe to the observables below and invoke your own navigator.
122
122
 
123
+ > In the snippets below, `voipClient` is the instance returned by `createTelnyxVoipClient()` (see [Basic Setup](#basic-setup)). It is a singleton, so it's safe to reference the same module-level value from any component, or to read it from context.
124
+
123
125
  **What to observe:**
124
126
 
125
- | Observable | Emits | Use it for |
126
- | ----------------------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
- | `voipClient.connectionState$` | `TelnyxConnectionState` (`CONNECTING`, `CONNECTED`, `RECONNECTING`, `DISCONNECTED`, `ERROR`) | Redirect to login on `DISCONNECTED`; gate outbound-call UI on `CONNECTED`. There is no separate `loginState$` — `CONNECTED` means the socket is up **and** authenticated. |
128
- | `voipClient.activeCall$` | `Call \| null` | Navigate to your in-call screen when a call appears. Primary signal for push-launched cold starts — when the SDK finishes the push-driven login and the call arrives, this emits. |
129
- | `voipClient.calls$` | `Call[]` | Multi-call UIs (call waiting, conference). |
130
- | `call.callState$` | `TelnyxCallState` | Per-call transitions (ringing / active / held / ended). |
127
+ | Observable | Emits | Use it for |
128
+ | ----------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
129
+ | `voipClient.connectionState$` | `TelnyxConnectionState` (`CONNECTING`, `CONNECTED`, `RECONNECTING`, `DISCONNECTED`, `ERROR`) | Redirect to login on `DISCONNECTED`; gate outbound-call UI on `CONNECTED`. There is no separate `loginState$` — `CONNECTED` means the socket is up **and** authenticated. |
130
+ | `voipClient.activeCall$` | `Call \| null` | Emits the `Call` once the SDK has processed the push and the call has arrived. Navigate to your in-call screen here. Fires for push-launched, foreground, and outbound. |
131
+ | `voipClient.calls$` | `Call[]` | Multi-call UIs (call waiting, conference). |
132
+ | `call.callState$` | `TelnyxCallState` | Per-call transitions (ringing / active / held / ended). |
131
133
 
132
134
  #### Redirect to login on disconnect
133
135
 
@@ -173,6 +175,8 @@ useEffect(() => {
173
175
 
174
176
  #### Optional: gate outbound-call UI on CONNECTED
175
177
 
178
+ `canMakeCalls(state)` is a small helper that returns `true` only when `state === TelnyxConnectionState.CONNECTED`. It exists purely to keep the check readable — you can inline the comparison if you prefer.
179
+
176
180
  ```tsx
177
181
  import { canMakeCalls } from '@telnyx/react-voice-commons-sdk';
178
182
 
@@ -270,6 +274,7 @@ import { createCredentialConfig } from '@telnyx/react-voice-commons-sdk';
270
274
  const config = createCredentialConfig('your_sip_username', 'your_sip_password', {
271
275
  debug: true,
272
276
  pushNotificationDeviceToken: 'your_device_token',
277
+ pushWhenActive: true,
273
278
  });
274
279
 
275
280
  await voipClient.login(config);
@@ -283,11 +288,26 @@ import { createTokenConfig } from '@telnyx/react-voice-commons-sdk';
283
288
  const config = createTokenConfig('your_jwt_token', {
284
289
  debug: true,
285
290
  pushNotificationDeviceToken: 'your_device_token',
291
+ pushWhenActive: true,
286
292
  });
287
293
 
288
294
  await voipClient.loginWithToken(config);
289
295
  ```
290
296
 
297
+ `pushWhenActive` is opt-in and defaults to `false`. Enable it on iOS when the device must receive a second PushKit call while another call is active or held.
298
+
299
+ ### Android Incoming Ringtone
300
+
301
+ Android uses the device's selected phone ringtone by default. To use an app-bundled sound instead, add a supported audio file such as `my_ringtone.wav` to `android/app/src/main/res/raw/` and pass its resource name without the extension when logging in:
302
+
303
+ ```tsx
304
+ const config = createCredentialConfig('your_sip_username', 'your_sip_password', {
305
+ incomingCallRingtone: 'my_ringtone',
306
+ });
307
+ ```
308
+
309
+ The SDK persists this setting for incoming FCM pushes. If the resource is missing or the option is omitted, it falls back to the device ringtone.
310
+
291
311
  ### Automatic Storage & Reconnection
292
312
 
293
313
  The library automatically stores authentication data securely for seamless reconnection. **You don't need to manually manage these storage keys** - the library handles everything internally.
@@ -300,6 +320,7 @@ The library uses these AsyncStorage keys internally:
300
320
  - `@telnyx_password` - SIP password (credential auth)
301
321
  - `@credential_token` - JWT authentication token (token auth)
302
322
  - `@push_token` - Push notification device token
323
+ - `@push_when_active` - Whether the device remains eligible for PushKit calls while connected
303
324
 
304
325
  **Note**: These are managed automatically by the library. You only need to call `login()` once, and the library will handle storage and future reconnections.
305
326
 
@@ -2,6 +2,8 @@ package com.telnyx.react_voice_commons
2
2
 
3
3
  import android.app.Service
4
4
  import android.content.Intent
5
+ import android.content.pm.ServiceInfo
6
+ import android.os.Build
5
7
  import android.os.IBinder
6
8
  import android.util.Log
7
9
 
@@ -18,6 +20,10 @@ class CallForegroundService : Service() {
18
20
  const val EXTRA_CALLER_NAME = "caller_name"
19
21
  const val EXTRA_CALLER_NUMBER = "caller_number"
20
22
  const val EXTRA_CALL_ID = "call_id"
23
+
24
+ private val callForegroundServiceType =
25
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or
26
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
21
27
  }
22
28
 
23
29
  override fun onBind(intent: Intent?): IBinder? {
@@ -51,7 +57,15 @@ class CallForegroundService : Service() {
51
57
  val notification = notificationHelper.createOngoingCallNotification(callerName, callerNumber, callId)
52
58
 
53
59
  // Start foreground service with the notification
54
- startForeground(TelnyxNotificationHelper.ONGOING_CALL_NOTIFICATION_ID, notification)
60
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
61
+ startForeground(
62
+ TelnyxNotificationHelper.ONGOING_CALL_NOTIFICATION_ID,
63
+ notification,
64
+ callForegroundServiceType
65
+ )
66
+ } else {
67
+ startForeground(TelnyxNotificationHelper.ONGOING_CALL_NOTIFICATION_ID, notification)
68
+ }
55
69
 
56
70
  Log.d(TAG, "Foreground service started with ongoing call notification")
57
71
  } catch (e: Exception) {
@@ -80,4 +94,4 @@ class CallForegroundService : Service() {
80
94
  super.onDestroy()
81
95
  Log.d(TAG, "CallForegroundService destroyed")
82
96
  }
83
- }
97
+ }
@@ -1,5 +1,6 @@
1
1
  package com.telnyx.react_voice_commons
2
2
 
3
+ import android.content.Intent
3
4
  import android.util.Log
4
5
  import com.google.firebase.messaging.FirebaseMessagingService
5
6
  import com.google.firebase.messaging.RemoteMessage
@@ -86,7 +87,20 @@ open class TelnyxFirebaseMessagingService : FirebaseMessagingService() {
86
87
 
87
88
  // Show missed call notification (this will hide any existing incoming call notification)
88
89
  val notificationHelper = TelnyxNotificationHelper(this)
89
- notificationHelper.showMissedCallNotification(callerName, callerNumber, callId)
90
+ notificationHelper.hideIncomingCallNotification()
91
+ notificationHelper.hideOngoingCallNotification()
92
+
93
+ try {
94
+ stopService(Intent(this, CallForegroundService::class.java))
95
+ } catch (e: Exception) {
96
+ Log.e(tag, "Failed to stop CallForegroundService for missed call", e)
97
+ }
98
+
99
+ if (VoicePnManager.getMissedCallNotificationsEnabled(this)) {
100
+ notificationHelper.showMissedCallNotification(callerName, callerNumber, callId)
101
+ } else {
102
+ Log.d(tag, "Missed call notification display disabled")
103
+ }
90
104
 
91
105
  Log.d(tag, "Telnyx missed call notification processed successfully")
92
106
  } catch (e: Exception) {
@@ -176,4 +190,4 @@ open class TelnyxFirebaseMessagingService : FirebaseMessagingService() {
176
190
  protected open fun handleTokenRefresh(token: String) {
177
191
  Log.d(tag, "Default token refresh handler - override this method if needed")
178
192
  }
179
- }
193
+ }
@@ -1,26 +1,190 @@
1
1
  package com.telnyx.react_voice_commons
2
2
 
3
+ import android.Manifest
4
+ import android.annotation.SuppressLint
5
+ import android.app.ActivityOptions
3
6
  import android.app.Notification
4
7
  import android.app.NotificationChannel
5
8
  import android.app.NotificationManager
6
9
  import android.app.PendingIntent
7
10
  import android.content.Context
8
11
  import android.content.Intent
9
- import android.os.Build
10
- import androidx.core.app.NotificationCompat
12
+ import android.content.pm.PackageManager
11
13
  import android.graphics.Color
14
+ import android.media.AudioAttributes
15
+ import android.media.AudioManager
16
+ import android.media.MediaPlayer
17
+ import android.media.Ringtone
18
+ import android.media.RingtoneManager
19
+ import android.net.Uri
20
+ import android.os.Build
12
21
  import android.util.Log
22
+ import androidx.core.app.NotificationCompat
23
+ import androidx.core.app.NotificationManagerCompat
24
+ import androidx.core.content.ContextCompat
13
25
 
14
26
  /**
15
27
  * Helper class for managing Telnyx voice call notifications
16
28
  */
17
29
  class TelnyxNotificationHelper(private val context: Context) {
18
30
  companion object {
19
- const val CHANNEL_ID = "telnyx_voice_calls"
20
- const val CHANNEL_NAME = "Telnyx Voice Calls"
31
+ private const val INCOMING_CALL_CHANNEL_PREFIX = "telnyx_voice_incoming_calls_v3"
32
+ const val ONGOING_CALL_CHANNEL_ID = "telnyx_voice_ongoing_calls"
33
+ const val MISSED_CALL_CHANNEL_ID = "telnyx_voice_missed_calls"
34
+ const val INCOMING_CALL_CHANNEL_NAME = "Incoming Telnyx Voice Calls"
35
+ const val ONGOING_CALL_CHANNEL_NAME = "Ongoing Telnyx Voice Calls"
36
+ const val MISSED_CALL_CHANNEL_NAME = "Missed Telnyx Voice Calls"
21
37
  const val NOTIFICATION_ID = 1001
22
38
  const val ONGOING_CALL_NOTIFICATION_ID = 1002
23
39
  private const val TAG = "TelnyxNotifications"
40
+ private val ringtoneLock = Any()
41
+ private var incomingCallRingtone: Ringtone? = null
42
+ private var incomingCallMediaPlayer: MediaPlayer? = null
43
+
44
+ /**
45
+ * Notifications can produce only a short alert (and some OEMs suppress it when
46
+ * a full-screen call notification immediately launches the app). Keep the
47
+ * configured app ringtone, or the device's selected phone ringtone, playing
48
+ * until the incoming call is handled.
49
+ */
50
+ private fun startIncomingCallRingtone(context: Context) {
51
+ synchronized(ringtoneLock) {
52
+ if (
53
+ incomingCallRingtone?.isPlaying == true ||
54
+ incomingCallMediaPlayer?.isPlaying == true
55
+ ) {
56
+ return
57
+ }
58
+
59
+ val ringtoneUri = getIncomingCallRingtoneUriForPlayback(context) ?: return
60
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
61
+ startLegacyIncomingCallRingtone(context, ringtoneUri)
62
+ return
63
+ }
64
+
65
+ val ringtone = RingtoneManager.getRingtone(context.applicationContext, ringtoneUri)
66
+ if (ringtone == null) {
67
+ Log.w(TAG, "No incoming call ringtone is configured")
68
+ return
69
+ }
70
+
71
+ try {
72
+ ringtone.audioAttributes = AudioAttributes.Builder()
73
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
74
+ .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
75
+ .build()
76
+ ringtone.isLooping = true
77
+ ringtone.play()
78
+ incomingCallRingtone = ringtone
79
+ Log.d(TAG, "Started incoming call ringtone")
80
+ } catch (e: RuntimeException) {
81
+ Log.e(TAG, "Failed to start incoming call ringtone", e)
82
+ ringtone.stop()
83
+ }
84
+ }
85
+ }
86
+
87
+ private fun startLegacyIncomingCallRingtone(context: Context, ringtoneUri: Uri) {
88
+ try {
89
+ val mediaPlayer = MediaPlayer().apply {
90
+ setAudioAttributes(
91
+ AudioAttributes.Builder()
92
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
93
+ .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
94
+ .build()
95
+ )
96
+ setDataSource(context.applicationContext, ringtoneUri)
97
+ isLooping = true
98
+ setOnPreparedListener { player ->
99
+ synchronized(ringtoneLock) {
100
+ if (incomingCallMediaPlayer === player) {
101
+ player.start()
102
+ } else {
103
+ player.release()
104
+ }
105
+ }
106
+ }
107
+ setOnErrorListener { player, _, _ ->
108
+ Log.e(TAG, "Failed to play incoming call ringtone")
109
+ if (incomingCallMediaPlayer === player) {
110
+ incomingCallMediaPlayer = null
111
+ }
112
+ player.release()
113
+ true
114
+ }
115
+ }
116
+ incomingCallMediaPlayer = mediaPlayer
117
+ mediaPlayer.prepareAsync()
118
+ Log.d(TAG, "Preparing incoming call ringtone")
119
+ } catch (e: Exception) {
120
+ Log.e(TAG, "Failed to start incoming call ringtone", e)
121
+ }
122
+ }
123
+
124
+ private fun getConfiguredIncomingCallRingtoneUri(context: Context): Uri {
125
+ val resourceName = VoicePnManager.getIncomingCallRingtoneResource(context)
126
+ if (!resourceName.isNullOrBlank()) {
127
+ val resourceId = context.resources.getIdentifier(
128
+ resourceName,
129
+ "raw",
130
+ context.packageName
131
+ )
132
+ if (resourceId != 0) {
133
+ return Uri.parse("android.resource://${context.packageName}/$resourceId")
134
+ }
135
+ Log.w(TAG, "Incoming ringtone resource '$resourceName' was not found; using the device ringtone")
136
+ }
137
+
138
+ return RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
139
+ }
140
+
141
+ private fun getIncomingCallChannelId(context: Context): String {
142
+ val resourceName = VoicePnManager.getIncomingCallRingtoneResource(context)
143
+ if (resourceName.isNullOrBlank()) return "${INCOMING_CALL_CHANNEL_PREFIX}_default"
144
+
145
+ val resourceId = context.resources.getIdentifier(resourceName, "raw", context.packageName)
146
+ return if (resourceId == 0) {
147
+ "${INCOMING_CALL_CHANNEL_PREFIX}_default"
148
+ } else {
149
+ "${INCOMING_CALL_CHANNEL_PREFIX}_${Integer.toUnsignedString(resourceName.hashCode(), 16)}"
150
+ }
151
+ }
152
+
153
+ private fun getIncomingCallRingtoneUriForPlayback(context: Context): Uri? {
154
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
155
+ return getConfiguredIncomingCallRingtoneUri(context)
156
+ }
157
+
158
+ val channel = (context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
159
+ .getNotificationChannel(getIncomingCallChannelId(context))
160
+ if (channel?.sound == null) {
161
+ Log.d(TAG, "Incoming call ringtone is disabled in notification settings")
162
+ return null
163
+ }
164
+ return channel.sound
165
+ }
166
+
167
+ private fun stopIncomingCallRingtone() {
168
+ synchronized(ringtoneLock) {
169
+ incomingCallRingtone?.let { ringtone ->
170
+ try {
171
+ ringtone.stop()
172
+ } catch (e: RuntimeException) {
173
+ Log.w(TAG, "Failed to stop incoming call ringtone", e)
174
+ }
175
+ }
176
+ incomingCallRingtone = null
177
+ incomingCallMediaPlayer?.let { mediaPlayer ->
178
+ try {
179
+ mediaPlayer.stop()
180
+ } catch (_: IllegalStateException) {
181
+ // The player may still be preparing.
182
+ }
183
+ mediaPlayer.release()
184
+ }
185
+ incomingCallMediaPlayer = null
186
+ }
187
+ }
24
188
 
25
189
  /**
26
190
  * Static method to hide notifications from anywhere in the app
@@ -29,6 +193,7 @@ class TelnyxNotificationHelper(private val context: Context) {
29
193
  fun hideNotificationFromContext(context: Context) {
30
194
  val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
31
195
  notificationManager.cancel(NOTIFICATION_ID)
196
+ stopIncomingCallRingtone()
32
197
  Log.d(TAG, "Dismissed Telnyx notification from static context")
33
198
  }
34
199
 
@@ -44,26 +209,146 @@ class TelnyxNotificationHelper(private val context: Context) {
44
209
  }
45
210
 
46
211
  private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
47
-
48
212
  init {
49
- createNotificationChannel()
213
+ createNotificationChannels()
214
+ }
215
+
216
+ private fun getNotificationBlockReason(channelId: String): String? {
217
+ if (
218
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
219
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
220
+ ) {
221
+ return "POST_NOTIFICATIONS permission is denied"
222
+ }
223
+
224
+ if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) {
225
+ return "app notifications are disabled"
226
+ }
227
+
228
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
229
+ val channel = notificationManager.getNotificationChannel(channelId)
230
+ if (channel?.importance == NotificationManager.IMPORTANCE_NONE) {
231
+ return "incoming-call notification channel is blocked"
232
+ }
233
+
234
+ val channelGroupId = channel?.group
235
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && channelGroupId != null) {
236
+ val channelGroup = notificationManager.getNotificationChannelGroup(channelGroupId)
237
+ if (channelGroup?.isBlocked == true) {
238
+ return "incoming-call notification channel group is blocked"
239
+ }
240
+ }
241
+ }
242
+
243
+ return null
244
+ }
245
+
246
+ @SuppressLint("MissingPermission")
247
+ private fun notifyIfPermitted(
248
+ notificationId: Int,
249
+ notification: Notification,
250
+ channelId: String,
251
+ ): String? {
252
+ val blockReason = getNotificationBlockReason(channelId)
253
+ if (blockReason != null) {
254
+ Log.w(TAG, "Skipping notification $notificationId because $blockReason")
255
+ return blockReason
256
+ }
257
+
258
+ notificationManager.notify(notificationId, notification)
259
+ return null
260
+ }
261
+
262
+ private fun launchFullScreenIntentFallback(
263
+ notification: Notification,
264
+ callId: String,
265
+ metadata: String,
266
+ blockReason: String,
267
+ ) {
268
+ val fullScreenIntent = notification.fullScreenIntent
269
+ if (fullScreenIntent == null) {
270
+ Log.w(TAG, "Notifications unavailable ($blockReason); no full-screen fallback available for call: $callId")
271
+ return
272
+ }
273
+
274
+ try {
275
+ VoicePnManager.setPendingPushAction(context, "incoming_call", metadata)
276
+ } catch (e: Exception) {
277
+ Log.e(TAG, "Failed to preserve incoming-call metadata before fallback launch for call: $callId", e)
278
+ }
279
+
280
+ try {
281
+ sendFullScreenIntent(fullScreenIntent)
282
+ Log.w(TAG, "Notifications unavailable ($blockReason); launched full-screen incoming call fallback for call: $callId")
283
+ } catch (e: PendingIntent.CanceledException) {
284
+ Log.e(TAG, "Notifications unavailable ($blockReason); failed to launch full-screen incoming call fallback for call: $callId", e)
285
+ clearFallbackPushAction(callId)
286
+ } catch (e: RuntimeException) {
287
+ Log.e(TAG, "Notifications unavailable ($blockReason); background fallback launch failed for call: $callId", e)
288
+ clearFallbackPushAction(callId)
289
+ }
290
+ }
291
+
292
+ private fun clearFallbackPushAction(callId: String) {
293
+ try {
294
+ VoicePnManager.clearPendingPushAction(context)
295
+ } catch (e: Exception) {
296
+ Log.e(TAG, "Failed to clear preserved incoming-call metadata after fallback launch failure for call: $callId", e)
297
+ }
50
298
  }
51
299
 
52
- private fun createNotificationChannel() {
300
+ private fun sendFullScreenIntent(fullScreenIntent: PendingIntent) {
301
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
302
+ val options = ActivityOptions.makeBasic().apply {
303
+ setPendingIntentBackgroundActivityStartMode(ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED)
304
+ }.toBundle()
305
+
306
+ fullScreenIntent.send(context, 0, null, null, null, null, options)
307
+ } else {
308
+ fullScreenIntent.send()
309
+ }
310
+ }
311
+
312
+ private fun createNotificationChannels() {
53
313
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
54
- val channel = NotificationChannel(
55
- CHANNEL_ID,
56
- CHANNEL_NAME,
314
+ // New ID: Android preserves a channel's sound after it is created, so the
315
+ // prior silent channel cannot be updated for existing installations.
316
+ val incomingCallChannel = NotificationChannel(
317
+ getIncomingCallChannelId(context),
318
+ INCOMING_CALL_CHANNEL_NAME,
57
319
  NotificationManager.IMPORTANCE_HIGH
58
320
  ).apply {
59
321
  description = "Notifications for incoming Telnyx voice calls"
60
322
  enableLights(true)
61
323
  lightColor = Color.GREEN
62
324
  enableVibration(true)
63
- setSound(null, null) // Disable sound, CallKit will handle audio
325
+ setSound(
326
+ getConfiguredIncomingCallRingtoneUri(context),
327
+ AudioAttributes.Builder()
328
+ .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
329
+ .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
330
+ .build()
331
+ )
332
+ }
333
+ val ongoingCallChannel = NotificationChannel(
334
+ ONGOING_CALL_CHANNEL_ID,
335
+ ONGOING_CALL_CHANNEL_NAME,
336
+ NotificationManager.IMPORTANCE_LOW
337
+ ).apply {
338
+ description = "Notifications for ongoing Telnyx voice calls"
339
+ }
340
+ val missedCallChannel = NotificationChannel(
341
+ MISSED_CALL_CHANNEL_ID,
342
+ MISSED_CALL_CHANNEL_NAME,
343
+ NotificationManager.IMPORTANCE_DEFAULT
344
+ ).apply {
345
+ description = "Notifications for missed Telnyx voice calls"
64
346
  }
65
- notificationManager.createNotificationChannel(channel)
66
- Log.d(TAG, "Created notification channel: $CHANNEL_ID")
347
+
348
+ notificationManager.createNotificationChannels(
349
+ listOf(incomingCallChannel, ongoingCallChannel, missedCallChannel)
350
+ )
351
+ Log.d(TAG, "Created Telnyx voice notification channels")
67
352
  }
68
353
  }
69
354
 
@@ -95,14 +380,17 @@ class TelnyxNotificationHelper(private val context: Context) {
95
380
  val appIntent = Intent(context, activityClass).apply {
96
381
  flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
97
382
  putExtra("call_id", callId)
98
- putExtra("action", "open_call")
383
+ putExtra("action", "incoming_call")
384
+ putExtra("meta_data", metadata)
385
+ putExtra("caller_name", callerName)
386
+ putExtra("caller_number", callerNumber)
99
387
  }
100
388
  val appPendingIntent = PendingIntent.getActivity(
101
389
  context, 0, appIntent,
102
390
  PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
103
391
  )
104
392
 
105
- val builder = NotificationCompat.Builder(context, CHANNEL_ID)
393
+ val builder = NotificationCompat.Builder(context, getIncomingCallChannelId(context))
106
394
  .setContentTitle("Incoming Call")
107
395
  .setContentText("$displayName")
108
396
  .setSmallIcon(android.R.drawable.ic_menu_call)
@@ -114,6 +402,13 @@ class TelnyxNotificationHelper(private val context: Context) {
114
402
  .setContentIntent(appPendingIntent)
115
403
  .setColor(Color.GREEN)
116
404
 
405
+ // Android 21-25 has no notification channels, so configure the
406
+ // ringtone directly on the incoming-call notification.
407
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
408
+ @Suppress("DEPRECATION")
409
+ builder.setSound(getConfiguredIncomingCallRingtoneUri(context), AudioManager.STREAM_RING)
410
+ }
411
+
117
412
  // Add action buttons - use direct activity PendingIntents to avoid trampoline restrictions
118
413
  // Answer action - direct activity launch to avoid BAL restrictions
119
414
  val answerActivityIntent = Intent(context, activityClass).apply {
@@ -160,8 +455,17 @@ class TelnyxNotificationHelper(private val context: Context) {
160
455
  hideIncomingCallNotification()
161
456
 
162
457
  val notification = createIncomingCallNotification(callerName, callerNumber, callId,metadata, mainActivityClass)
163
- notificationManager.notify(NOTIFICATION_ID, notification)
164
- Log.d(TAG, "Showed incoming call notification for: $callerName ($callerNumber)")
458
+ val blockReason = notifyIfPermitted(
459
+ NOTIFICATION_ID,
460
+ notification,
461
+ getIncomingCallChannelId(context),
462
+ )
463
+ if (blockReason == null) {
464
+ startIncomingCallRingtone(context)
465
+ Log.d(TAG, "Showed incoming call notification for: $callerName ($callerNumber)")
466
+ } else {
467
+ launchFullScreenIntentFallback(notification, callId, metadata, blockReason)
468
+ }
165
469
  }
166
470
 
167
471
  fun showMissedCallNotification(
@@ -175,7 +479,7 @@ class TelnyxNotificationHelper(private val context: Context) {
175
479
  val displayName = callerName ?: callerNumber ?: "Unknown Caller"
176
480
  val displayNumber = if (callerName != null && callerNumber != null) callerNumber else ""
177
481
 
178
- val notification = NotificationCompat.Builder(context, CHANNEL_ID)
482
+ val notification = NotificationCompat.Builder(context, MISSED_CALL_CHANNEL_ID)
179
483
  .setContentTitle("Missed Call")
180
484
  .setContentText("$displayName${if (displayNumber.isNotEmpty()) "\n$displayNumber" else ""}")
181
485
  .setSmallIcon(android.R.drawable.ic_menu_call)
@@ -186,8 +490,16 @@ class TelnyxNotificationHelper(private val context: Context) {
186
490
  .setColor(Color.RED)
187
491
  .build()
188
492
 
189
- notificationManager.notify(NOTIFICATION_ID, notification)
190
- Log.d(TAG, "Showed missed call notification for: $callerName ($callerNumber)")
493
+ val blockReason = notifyIfPermitted(
494
+ NOTIFICATION_ID,
495
+ notification,
496
+ MISSED_CALL_CHANNEL_ID,
497
+ )
498
+ if (blockReason == null) {
499
+ Log.d(TAG, "Showed missed call notification for: $callerName ($callerNumber)")
500
+ } else {
501
+ Log.w(TAG, "Skipped missed call notification because $blockReason")
502
+ }
191
503
  }
192
504
 
193
505
  fun showOngoingCallNotification(
@@ -197,8 +509,16 @@ class TelnyxNotificationHelper(private val context: Context) {
197
509
  mainActivityClass: Class<*>? = null
198
510
  ) {
199
511
  val notification = createOngoingCallNotification(callerName, callerNumber, callId, mainActivityClass)
200
- notificationManager.notify(ONGOING_CALL_NOTIFICATION_ID, notification)
201
- Log.d(TAG, "Showed ongoing call notification for: $callerName ($callerNumber)")
512
+ val blockReason = notifyIfPermitted(
513
+ ONGOING_CALL_NOTIFICATION_ID,
514
+ notification,
515
+ ONGOING_CALL_CHANNEL_ID,
516
+ )
517
+ if (blockReason == null) {
518
+ Log.d(TAG, "Showed ongoing call notification for: $callerName ($callerNumber)")
519
+ } else {
520
+ Log.w(TAG, "Skipped ongoing call notification because $blockReason")
521
+ }
202
522
  }
203
523
 
204
524
  /**
@@ -251,7 +571,7 @@ class TelnyxNotificationHelper(private val context: Context) {
251
571
  PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
252
572
  )
253
573
 
254
- return NotificationCompat.Builder(context, CHANNEL_ID)
574
+ return NotificationCompat.Builder(context, ONGOING_CALL_CHANNEL_ID)
255
575
  .setContentTitle("Ongoing Call")
256
576
  .setContentText("$displayName${if (displayNumber.isNotEmpty()) " ($displayNumber)" else ""}")
257
577
  .setSmallIcon(android.R.drawable.ic_menu_call)
@@ -267,6 +587,7 @@ class TelnyxNotificationHelper(private val context: Context) {
267
587
 
268
588
  fun hideIncomingCallNotification() {
269
589
  notificationManager.cancel(NOTIFICATION_ID)
590
+ stopIncomingCallRingtone()
270
591
  Log.d(TAG, "Hid incoming call notification")
271
592
  }
272
593
 
@@ -274,4 +595,4 @@ class TelnyxNotificationHelper(private val context: Context) {
274
595
  notificationManager.cancel(ONGOING_CALL_NOTIFICATION_ID)
275
596
  Log.d(TAG, "Hid ongoing call notification")
276
597
  }
277
- }
598
+ }