@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.
- package/CHANGELOG.md +23 -0
- package/README.md +28 -7
- package/android/src/main/java/com/telnyx/react_voice_commons/CallForegroundService.kt +16 -2
- package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxFirebaseMessagingService.kt +16 -2
- package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxNotificationHelper.kt +345 -24
- package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnBridgeModule.kt +74 -1
- package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnManager.kt +36 -1
- package/ios/CallKitBridge.m +20 -1
- package/ios/CallKitBridge.swift +935 -60
- package/ios/README.md +2 -2
- package/ios/VoicePnBridge.m +21 -1
- package/ios/VoicePnBridge.swift +62 -1
- package/lib/callkit/callkit-coordinator.d.ts +39 -3
- package/lib/callkit/callkit-coordinator.js +561 -148
- package/lib/callkit/callkit.d.ts +7 -0
- package/lib/callkit/callkit.js +83 -11
- package/lib/hooks/useAppStateHandler.js +6 -2
- package/lib/index.d.ts +1 -0
- package/lib/index.js +9 -0
- package/lib/internal/CallKitHandler.d.ts +3 -0
- package/lib/internal/CallKitHandler.js +52 -46
- package/lib/internal/calls/call-state-controller.d.ts +39 -1
- package/lib/internal/calls/call-state-controller.js +152 -64
- package/lib/internal/session/session-manager.d.ts +16 -1
- package/lib/internal/session/session-manager.js +229 -58
- package/lib/internal/voice-pn-bridge.d.ts +44 -0
- package/lib/internal/voice-pn-bridge.js +94 -0
- package/lib/models/call.js +35 -0
- package/lib/models/config.d.ts +18 -0
- package/lib/models/config.js +2 -0
- package/lib/telnyx-voice-app.js +85 -43
- package/lib/telnyx-voip-client.d.ts +48 -5
- package/lib/telnyx-voip-client.js +276 -22
- package/package.json +7 -5
- package/src/callkit/callkit-coordinator.ts +642 -160
- package/src/callkit/callkit.ts +110 -12
- package/src/hooks/useAppStateHandler.ts +7 -2
- package/src/index.ts +3 -0
- package/src/internal/CallKitHandler.tsx +60 -55
- package/src/internal/calls/call-state-controller.ts +173 -74
- package/src/internal/session/session-manager.ts +277 -61
- package/src/internal/voice-pn-bridge.ts +111 -0
- package/src/models/call.ts +34 -0
- package/src/models/config.ts +18 -0
- package/src/telnyx-voice-app.tsx +96 -45
- package/src/telnyx-voip-client.ts +260 -24
- 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` |
|
|
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
|
-
|
|
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
|
+
}
|
package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxFirebaseMessagingService.kt
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
20
|
-
const val
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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(
|
|
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
|
-
|
|
66
|
-
|
|
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", "
|
|
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,
|
|
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
|
-
|
|
164
|
-
|
|
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,
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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,
|
|
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
|
+
}
|