@telnyx/react-voice-commons-sdk 0.4.2 → 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/CHANGELOG.md +11 -0
- package/android/src/main/java/com/telnyx/react_voice_commons/TelnyxFirebaseMessagingService.kt +16 -2
- package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnBridgeModule.kt +55 -1
- package/android/src/main/java/com/telnyx/react_voice_commons/VoicePnManager.kt +17 -1
- package/ios/CallKitBridge.swift +426 -0
- package/ios/VoicePnBridge.m +15 -1
- package/ios/VoicePnBridge.swift +47 -1
- package/lib/index.d.ts +1 -0
- package/lib/index.js +9 -0
- package/lib/internal/calls/call-state-controller.d.ts +6 -0
- package/lib/internal/calls/call-state-controller.js +52 -36
- package/lib/internal/session/session-manager.d.ts +1 -0
- package/lib/internal/session/session-manager.js +32 -30
- package/lib/internal/voice-pn-bridge.d.ts +26 -0
- package/lib/internal/voice-pn-bridge.js +55 -0
- package/lib/models/config.d.ts +8 -0
- package/lib/telnyx-voip-client.d.ts +2 -0
- package/lib/telnyx-voip-client.js +49 -18
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/internal/calls/call-state-controller.ts +61 -45
- package/src/internal/session/session-manager.ts +34 -30
- package/src/internal/voice-pn-bridge.ts +64 -0
- package/src/models/config.ts +8 -0
- package/src/telnyx-voip-client.ts +55 -19
- package/src/types/telnyx-sdk.d.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# CHANGELOG.md
|
|
2
2
|
|
|
3
|
+
## [0.4.3] (2026-04-30)
|
|
4
|
+
|
|
5
|
+
### Bug Fixing
|
|
6
|
+
|
|
7
|
+
- **VoIP push always rebuilds the `TelnyxRTC` instance.** `SessionManager.handlePushNotification` now unconditionally disposes any prior client and runs `_connect()` afresh, so the new socket's URL bakes in THIS push's `voice_sdk_id`. Reusing a prior connection would cause the gateway to route this push's INVITE to whichever client announced with the new `voice_sdk_id` (i.e. a different connection), leaving us listening on the wrong socket until CallKit timed out.
|
|
8
|
+
- **Dropped the `loginFromStoredConfig()` fallback inside `TelnyxVoipClient.handlePushNotification`.** When SessionManager's push-driven `_connect()` didn't immediately reach `CONNECTED`, the fallback would create a second TelnyxRTC instance with no `voice_sdk_id` awareness. The gateway then `punt`-ed one of the parallel sessions, dropping the active call. SessionManager already loads stored credentials internally for the cold-launch path; the redundant fallback only created races.
|
|
9
|
+
|
|
10
|
+
### Dependencies
|
|
11
|
+
|
|
12
|
+
- Now requires `@telnyx/react-native-voice-sdk >= 0.4.5`, which removes the auto-reconnect on socket error/close that was triggering the multi-instance race. See the [voice-sdk 0.4.5 changelog](../package/CHANGELOG.md#045-2026-04-30) for details.
|
|
13
|
+
|
|
3
14
|
## [0.4.2] (2026-04-27)
|
|
4
15
|
|
|
5
16
|
### Bug Fixing
|
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,6 +1,8 @@
|
|
|
1
1
|
package com.telnyx.react_voice_commons
|
|
2
2
|
|
|
3
3
|
import android.content.Intent
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.media.AudioManager
|
|
4
6
|
import android.os.Build
|
|
5
7
|
import android.util.Log
|
|
6
8
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
@@ -222,6 +224,58 @@ class VoicePnBridgeModule(reactContext: ReactApplicationContext) : ReactContextB
|
|
|
222
224
|
promise.reject("HIDE_INCOMING_NOTIFICATION_ERROR", e.message, e)
|
|
223
225
|
}
|
|
224
226
|
}
|
|
227
|
+
|
|
228
|
+
@ReactMethod
|
|
229
|
+
fun setMissedCallNotificationsEnabled(enabled: Boolean, promise: Promise) {
|
|
230
|
+
try {
|
|
231
|
+
val result = VoicePnManager.setMissedCallNotificationsEnabled(
|
|
232
|
+
reactApplicationContext,
|
|
233
|
+
enabled
|
|
234
|
+
)
|
|
235
|
+
promise.resolve(result)
|
|
236
|
+
} catch (e: Exception) {
|
|
237
|
+
Log.e(TAG, "Error setting missed call notifications", e)
|
|
238
|
+
promise.reject("SET_MISSED_CALL_NOTIFICATIONS_ERROR", e.message, e)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@ReactMethod
|
|
243
|
+
fun getMissedCallNotificationsEnabled(promise: Promise) {
|
|
244
|
+
try {
|
|
245
|
+
promise.resolve(VoicePnManager.getMissedCallNotificationsEnabled(reactApplicationContext))
|
|
246
|
+
} catch (e: Exception) {
|
|
247
|
+
Log.e(TAG, "Error getting missed call notifications", e)
|
|
248
|
+
promise.reject("GET_MISSED_CALL_NOTIFICATIONS_ERROR", e.message, e)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
@ReactMethod
|
|
253
|
+
fun setSpeakerEnabled(enabled: Boolean, promise: Promise) {
|
|
254
|
+
try {
|
|
255
|
+
val audioManager = reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
256
|
+
// The active call flow owns AudioManager.mode. This bridge only requests the speaker route.
|
|
257
|
+
// Connected Bluetooth or wired accessories may still take precedence over speakerphone.
|
|
258
|
+
audioManager.isSpeakerphoneOn = enabled
|
|
259
|
+
val actualEnabled = audioManager.isSpeakerphoneOn
|
|
260
|
+
|
|
261
|
+
Log.d(TAG, "setSpeakerEnabled called from React Native: requested=$enabled actual=$actualEnabled")
|
|
262
|
+
promise.resolve(actualEnabled)
|
|
263
|
+
} catch (e: Exception) {
|
|
264
|
+
Log.e(TAG, "Error setting speaker route", e)
|
|
265
|
+
promise.reject("SET_SPEAKER_ENABLED_ERROR", e.message, e)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
@ReactMethod
|
|
270
|
+
fun isSpeakerEnabled(promise: Promise) {
|
|
271
|
+
try {
|
|
272
|
+
val audioManager = reactApplicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
|
273
|
+
promise.resolve(audioManager.isSpeakerphoneOn)
|
|
274
|
+
} catch (e: Exception) {
|
|
275
|
+
Log.e(TAG, "Error getting speaker route", e)
|
|
276
|
+
promise.reject("GET_SPEAKER_ENABLED_ERROR", e.message, e)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
225
279
|
|
|
226
280
|
/**
|
|
227
281
|
* Emit call action event to React Native (for immediate notification)
|
|
@@ -245,4 +299,4 @@ class VoicePnBridgeModule(reactContext: ReactApplicationContext) : ReactContextB
|
|
|
245
299
|
Log.e(TAG, "Error emitting call action event", e)
|
|
246
300
|
}
|
|
247
301
|
}
|
|
248
|
-
}
|
|
302
|
+
}
|
|
@@ -8,6 +8,7 @@ object VoicePnManager {
|
|
|
8
8
|
|
|
9
9
|
private const val TAG = "VoicePnManager"
|
|
10
10
|
private const val PREFS_NAME = "telnyx_voice_prefs"
|
|
11
|
+
private const val MISSED_CALL_NOTIFICATIONS_ENABLED = "telnyx_enable_missed_call_notifications"
|
|
11
12
|
|
|
12
13
|
private fun getSharedPreferences(context: Context): SharedPreferences {
|
|
13
14
|
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
|
@@ -78,6 +79,21 @@ object VoicePnManager {
|
|
|
78
79
|
return true
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
fun setMissedCallNotificationsEnabled(context: Context, enabled: Boolean): Boolean {
|
|
83
|
+
Log.d(TAG, "setMissedCallNotificationsEnabled called with: $enabled")
|
|
84
|
+
getSharedPreferences(context)
|
|
85
|
+
.edit()
|
|
86
|
+
.putBoolean(MISSED_CALL_NOTIFICATIONS_ENABLED, enabled)
|
|
87
|
+
.apply()
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fun getMissedCallNotificationsEnabled(context: Context): Boolean {
|
|
92
|
+
Log.d(TAG, "getMissedCallNotificationsEnabled called")
|
|
93
|
+
return getSharedPreferences(context)
|
|
94
|
+
.getBoolean(MISSED_CALL_NOTIFICATIONS_ENABLED, true)
|
|
95
|
+
}
|
|
96
|
+
|
|
81
97
|
// PnModule functions
|
|
82
98
|
fun setPendingPushAction(context: Context, action: String, metadata: String) {
|
|
83
99
|
Log.d(TAG, "setPendingPushAction called with action: $action")
|
|
@@ -151,4 +167,4 @@ object VoicePnManager {
|
|
|
151
167
|
editor.apply()
|
|
152
168
|
return true
|
|
153
169
|
}
|
|
154
|
-
}
|
|
170
|
+
}
|
package/ios/CallKitBridge.swift
CHANGED
|
@@ -6,6 +6,7 @@ import React
|
|
|
6
6
|
import AVFoundation
|
|
7
7
|
import UIKit
|
|
8
8
|
import PushKit
|
|
9
|
+
import UserNotifications
|
|
9
10
|
import WebRTC
|
|
10
11
|
|
|
11
12
|
@objc(CallKitBridge)
|
|
@@ -291,6 +292,210 @@ import React
|
|
|
291
292
|
}
|
|
292
293
|
}
|
|
293
294
|
|
|
295
|
+
// MARK: - Missed Call
|
|
296
|
+
enum TelnyxVoiceUserDefaultsKey {
|
|
297
|
+
static let missedCallNotificationsEnabled = "telnyx_enable_missed_call_notifications"
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private enum TelnyxMissedCallPush {
|
|
301
|
+
static let processedMissedCallIdsKey = "telnyx_processed_missed_call_ids"
|
|
302
|
+
private static let missedCallMarkerKeys: Set<String> = [
|
|
303
|
+
"is_missed_call",
|
|
304
|
+
"message",
|
|
305
|
+
"missed",
|
|
306
|
+
"missed_call",
|
|
307
|
+
"notification_type",
|
|
308
|
+
"push_type",
|
|
309
|
+
"type",
|
|
310
|
+
]
|
|
311
|
+
private static let affirmativeMissedCallValues: Set<String> = [
|
|
312
|
+
"1",
|
|
313
|
+
"missed",
|
|
314
|
+
"missed call",
|
|
315
|
+
"true",
|
|
316
|
+
"yes",
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
static func isMissedCallPayload(_ payload: [AnyHashable: Any]) -> Bool {
|
|
320
|
+
return containsMissedCallMarker(payload)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
static func stableIdentifier(from payload: [AnyHashable: Any]) -> String {
|
|
324
|
+
if let callId = callId(from: payload), !callId.isEmpty {
|
|
325
|
+
return callId
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return "payload-\(hashHex(canonicalString(from: payload)))"
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
static func uuid(from identifier: String) -> UUID {
|
|
332
|
+
if let uuid = UUID(uuidString: identifier) {
|
|
333
|
+
return uuid
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
let firstHash = hashHex(identifier)
|
|
337
|
+
let secondHash = hashHex("telnyx-\(identifier)")
|
|
338
|
+
let uuidString =
|
|
339
|
+
"\(firstHash.prefix(8))-\(firstHash.dropFirst(8).prefix(4))-\(firstHash.dropFirst(12).prefix(4))-\(secondHash.prefix(4))-\(secondHash.dropFirst(4).prefix(12))"
|
|
340
|
+
|
|
341
|
+
return UUID(uuidString: uuidString) ?? UUID()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
static func callId(from payload: [AnyHashable: Any]) -> String? {
|
|
345
|
+
return stringValue(for: ["call_id", "callId"], in: payload)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
static func callerName(from payload: [AnyHashable: Any]) -> String {
|
|
349
|
+
return stringValue(for: ["caller_name", "caller", "from"], in: payload)
|
|
350
|
+
?? callerNumber(from: payload)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
static func callerNumber(from payload: [AnyHashable: Any]) -> String {
|
|
354
|
+
return stringValue(for: ["caller_number", "caller", "from"], in: payload)
|
|
355
|
+
?? "Unknown Caller"
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
static func missedCallNotificationsEnabled() -> Bool {
|
|
359
|
+
// bool(forKey:) returns false for both "unset" and "false"; default to enabled
|
|
360
|
+
// unless the app explicitly stores a preference.
|
|
361
|
+
guard
|
|
362
|
+
UserDefaults.standard.object(
|
|
363
|
+
forKey: TelnyxVoiceUserDefaultsKey.missedCallNotificationsEnabled) != nil
|
|
364
|
+
else {
|
|
365
|
+
return true
|
|
366
|
+
}
|
|
367
|
+
return UserDefaults.standard.bool(
|
|
368
|
+
forKey: TelnyxVoiceUserDefaultsKey.missedCallNotificationsEnabled)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private static func stringValue(for keys: [String], in payload: [AnyHashable: Any]) -> String? {
|
|
372
|
+
if let metadata = payload["metadata"] as? [AnyHashable: Any],
|
|
373
|
+
let value = firstStringValue(for: keys, in: metadata)
|
|
374
|
+
{
|
|
375
|
+
return value
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if let metadata = payload["metadata"] as? [String: Any],
|
|
379
|
+
let value = firstStringValue(for: keys, in: metadata)
|
|
380
|
+
{
|
|
381
|
+
return value
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return firstStringValue(for: keys, in: payload)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private static func firstStringValue(for keys: [String], in dictionary: [AnyHashable: Any])
|
|
388
|
+
-> String?
|
|
389
|
+
{
|
|
390
|
+
for key in keys {
|
|
391
|
+
if let value = dictionary[key] as? String, !value.isEmpty {
|
|
392
|
+
return value
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return nil
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private static func firstStringValue(for keys: [String], in dictionary: [String: Any])
|
|
399
|
+
-> String?
|
|
400
|
+
{
|
|
401
|
+
for key in keys {
|
|
402
|
+
if let value = dictionary[key] as? String, !value.isEmpty {
|
|
403
|
+
return value
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return nil
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private static func containsMissedCallMarker(_ value: Any, key: String? = nil) -> Bool {
|
|
410
|
+
if let key = key, missedCallMarkerKeys.contains(normalizedPayloadKey(key)) {
|
|
411
|
+
if let boolValue = value as? Bool {
|
|
412
|
+
return boolValue
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if let stringValue = value as? String {
|
|
416
|
+
return affirmativeMissedCallValues.contains(normalizedMissedCallValue(stringValue))
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if let stringValue = value as? String {
|
|
421
|
+
let normalized = normalizedMissedCallValue(stringValue)
|
|
422
|
+
if normalized == "missed call" || normalized == "missed" {
|
|
423
|
+
return true
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if let dictionary = value as? [AnyHashable: Any] {
|
|
428
|
+
return dictionary.contains { rawKey, nestedValue in
|
|
429
|
+
containsMissedCallMarker(nestedValue, key: String(describing: rawKey))
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if let dictionary = value as? [String: Any] {
|
|
434
|
+
return dictionary.contains { key, nestedValue in
|
|
435
|
+
containsMissedCallMarker(nestedValue, key: key)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if let array = value as? [Any] {
|
|
440
|
+
return array.contains { containsMissedCallMarker($0) }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return false
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
private static func normalizedPayloadKey(_ key: String) -> String {
|
|
447
|
+
return key
|
|
448
|
+
.lowercased()
|
|
449
|
+
.replacingOccurrences(of: "-", with: "_")
|
|
450
|
+
.replacingOccurrences(of: " ", with: "_")
|
|
451
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private static func normalizedMissedCallValue(_ value: String) -> String {
|
|
455
|
+
return value
|
|
456
|
+
.lowercased()
|
|
457
|
+
.replacingOccurrences(of: "_", with: " ")
|
|
458
|
+
.replacingOccurrences(of: "-", with: " ")
|
|
459
|
+
.replacingOccurrences(of: "!", with: "")
|
|
460
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private static func canonicalString(from value: Any) -> String {
|
|
464
|
+
if let dictionary = value as? [AnyHashable: Any] {
|
|
465
|
+
return dictionary
|
|
466
|
+
.map { (String(describing: $0.key), $0.value) }
|
|
467
|
+
.sorted { $0.0 < $1.0 }
|
|
468
|
+
.map { "\($0):\(canonicalString(from: $1))" }
|
|
469
|
+
.joined(separator: "|")
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if let dictionary = value as? [String: Any] {
|
|
473
|
+
return dictionary
|
|
474
|
+
.sorted { $0.key < $1.key }
|
|
475
|
+
.map { "\($0):\(canonicalString(from: $1))" }
|
|
476
|
+
.joined(separator: "|")
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if let array = value as? [Any] {
|
|
480
|
+
return array.map { canonicalString(from: $0) }.joined(separator: ",")
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return String(describing: value)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private static func hashHex(_ value: String) -> String {
|
|
487
|
+
let prime: UInt64 = 1_099_511_628_211
|
|
488
|
+
var hash: UInt64 = 14_695_981_039_346_656_037
|
|
489
|
+
|
|
490
|
+
for byte in value.utf8 {
|
|
491
|
+
hash ^= UInt64(byte)
|
|
492
|
+
hash = hash &* prime
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return String(format: "%016llx", hash)
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
294
499
|
// MARK: - TelnyxCallKitManager
|
|
295
500
|
/// TelnyxCallKitManager - Automatically handles CallKit integration for Telnyx Voice
|
|
296
501
|
/// This class sets up CallKit and VoIP push notifications automatically when the app starts
|
|
@@ -404,6 +609,216 @@ import React
|
|
|
404
609
|
setupCallKit()
|
|
405
610
|
}
|
|
406
611
|
}
|
|
612
|
+
|
|
613
|
+
@discardableResult
|
|
614
|
+
public func handleMissedCallPushIfNeeded(
|
|
615
|
+
_ payload: [AnyHashable: Any],
|
|
616
|
+
completion: (() -> Void)? = nil
|
|
617
|
+
) -> Bool {
|
|
618
|
+
guard TelnyxMissedCallPush.isMissedCallPayload(payload) else {
|
|
619
|
+
return false
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
NSLog("TelnyxVoice: Missed call VoIP push received: \(payload)")
|
|
623
|
+
setupSynchronously()
|
|
624
|
+
|
|
625
|
+
let callId = TelnyxMissedCallPush.callId(from: payload)
|
|
626
|
+
let missedCallId = TelnyxMissedCallPush.stableIdentifier(from: payload)
|
|
627
|
+
if hasProcessedMissedCall(id: missedCallId) {
|
|
628
|
+
NSLog("TelnyxVoice: Ignoring duplicate missed call push: \(missedCallId)")
|
|
629
|
+
completion?()
|
|
630
|
+
return true
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
let callUUID = TelnyxMissedCallPush.uuid(from: missedCallId)
|
|
634
|
+
let callerName = TelnyxMissedCallPush.callerName(from: payload)
|
|
635
|
+
let callerNumber = TelnyxMissedCallPush.callerNumber(from: payload)
|
|
636
|
+
|
|
637
|
+
let finishMissedCall = {
|
|
638
|
+
self.callKitProvider?.reportCall(with: callUUID, endedAt: Date(), reason: .remoteEnded)
|
|
639
|
+
self.activeCalls.removeValue(forKey: callUUID)
|
|
640
|
+
|
|
641
|
+
if self.pendingAnswerAction?.callUUID == callUUID {
|
|
642
|
+
self.pendingAnswerAction?.fail()
|
|
643
|
+
self.pendingAnswerAction = nil
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
NSLog("TelnyxVoice: Ended stale CallKit call for missed call UUID: \(callUUID)")
|
|
647
|
+
self.clearPendingPushData(for: callId)
|
|
648
|
+
self.showMissedCallNotification(
|
|
649
|
+
callerName: callerName,
|
|
650
|
+
callerNumber: callerNumber,
|
|
651
|
+
missedCallId: missedCallId
|
|
652
|
+
)
|
|
653
|
+
self.markMissedCallProcessed(id: missedCallId)
|
|
654
|
+
completion?()
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if let activeCall = activeCalls[callUUID] {
|
|
658
|
+
guard activeCall["source"] as? String == "missed_call_push" else {
|
|
659
|
+
NSLog("TelnyxVoice: Ignoring missed call push because UUID belongs to an active call: \(callUUID)")
|
|
660
|
+
markMissedCallProcessed(id: missedCallId)
|
|
661
|
+
completion?()
|
|
662
|
+
return true
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
finishMissedCall()
|
|
666
|
+
return true
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
activeCalls[callUUID] = [
|
|
670
|
+
"caller": callerName,
|
|
671
|
+
"handle": callerNumber,
|
|
672
|
+
"payload": payload,
|
|
673
|
+
"uuid": callUUID.uuidString,
|
|
674
|
+
"direction": "incoming",
|
|
675
|
+
"source": "missed_call_push",
|
|
676
|
+
]
|
|
677
|
+
|
|
678
|
+
let handle = CXHandle(type: .phoneNumber, value: callerNumber)
|
|
679
|
+
let callUpdate = CXCallUpdate()
|
|
680
|
+
callUpdate.remoteHandle = handle
|
|
681
|
+
callUpdate.hasVideo = false
|
|
682
|
+
callUpdate.localizedCallerName = callerName
|
|
683
|
+
|
|
684
|
+
callKitProvider?.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
|
|
685
|
+
if let error = error {
|
|
686
|
+
NSLog("TelnyxVoice: Missed call CallKit report failed: \(error.localizedDescription)")
|
|
687
|
+
self.activeCalls.removeValue(forKey: callUUID)
|
|
688
|
+
self.clearPendingPushData(for: callId)
|
|
689
|
+
completion?()
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
finishMissedCall()
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return true
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private func clearPendingPushData(for callId: String?) {
|
|
700
|
+
if hasPendingPushData(forDifferentCallThan: callId) {
|
|
701
|
+
NSLog("TelnyxVoice: Skipping pending push cleanup for missed call; pending data belongs to a different call")
|
|
702
|
+
return
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
UserDefaults.standard.removeObject(forKey: "pending_push_action")
|
|
706
|
+
UserDefaults.standard.removeObject(forKey: "pending_push_metadata")
|
|
707
|
+
UserDefaults.standard.removeObject(forKey: "pending_voip_push")
|
|
708
|
+
UserDefaults.standard.removeObject(forKey: "pending_voip_action")
|
|
709
|
+
UserDefaults.standard.removeObject(forKey: "@pending_callkit_uuid")
|
|
710
|
+
UserDefaults.standard.synchronize()
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private func hasPendingPushData(forDifferentCallThan missedCallId: String?) -> Bool {
|
|
714
|
+
guard let missedCallId = missedCallId, !missedCallId.isEmpty else {
|
|
715
|
+
return false
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
let pendingIds = [
|
|
719
|
+
pendingCallId(fromJsonForKey: "pending_push_metadata"),
|
|
720
|
+
pendingCallId(fromJsonForKey: "pending_voip_push"),
|
|
721
|
+
UserDefaults.standard.string(forKey: "@pending_callkit_uuid"),
|
|
722
|
+
].compactMap { $0 }
|
|
723
|
+
|
|
724
|
+
return pendingIds.contains { !$0.isEmpty && $0 != missedCallId }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
private func pendingCallId(fromJsonForKey key: String) -> String? {
|
|
728
|
+
guard let jsonString = UserDefaults.standard.string(forKey: key),
|
|
729
|
+
let data = jsonString.data(using: .utf8),
|
|
730
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
731
|
+
else {
|
|
732
|
+
return nil
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
if let directCallId = callId(from: json) {
|
|
736
|
+
return directCallId
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if let payload = json["payload"] as? [String: Any] {
|
|
740
|
+
return callId(from: payload)
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return nil
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
private func callId(from dictionary: [String: Any]) -> String? {
|
|
747
|
+
for key in ["call_id", "callId", "telnyx_call_id"] {
|
|
748
|
+
if let value = dictionary[key] as? String, !value.isEmpty {
|
|
749
|
+
return value
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
if let metadata = dictionary["metadata"] as? [String: Any] {
|
|
754
|
+
return callId(from: metadata)
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return nil
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private func hasProcessedMissedCall(id: String) -> Bool {
|
|
761
|
+
return processedMissedCallIds().contains(id)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private func markMissedCallProcessed(id: String) {
|
|
765
|
+
var ids = processedMissedCallIds().filter { $0 != id }
|
|
766
|
+
ids.append(id)
|
|
767
|
+
UserDefaults.standard.set(Array(ids.suffix(50)), forKey: TelnyxMissedCallPush.processedMissedCallIdsKey)
|
|
768
|
+
UserDefaults.standard.synchronize()
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private func processedMissedCallIds() -> [String] {
|
|
772
|
+
return UserDefaults.standard.stringArray(
|
|
773
|
+
forKey: TelnyxMissedCallPush.processedMissedCallIdsKey) ?? []
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private func showMissedCallNotification(
|
|
777
|
+
callerName: String, callerNumber: String, missedCallId: String
|
|
778
|
+
) {
|
|
779
|
+
guard TelnyxMissedCallPush.missedCallNotificationsEnabled() else {
|
|
780
|
+
NSLog("TelnyxVoice: Missed call local notification disabled")
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
let content = UNMutableNotificationContent()
|
|
785
|
+
content.title = "Missed Call"
|
|
786
|
+
content.body = "You missed a call from \(callerName)"
|
|
787
|
+
content.sound = .default
|
|
788
|
+
content.userInfo = [
|
|
789
|
+
"call_id": missedCallId,
|
|
790
|
+
"caller_name": callerName,
|
|
791
|
+
"caller_number": callerNumber,
|
|
792
|
+
]
|
|
793
|
+
|
|
794
|
+
let identifier = "telnyx.missed-call.\(missedCallId)"
|
|
795
|
+
let request = UNNotificationRequest(
|
|
796
|
+
identifier: identifier,
|
|
797
|
+
content: content,
|
|
798
|
+
trigger: nil
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
let notificationCenter = UNUserNotificationCenter.current()
|
|
802
|
+
notificationCenter.getNotificationSettings { settings in
|
|
803
|
+
guard settings.authorizationStatus == .authorized
|
|
804
|
+
|| settings.authorizationStatus == .provisional
|
|
805
|
+
|| settings.authorizationStatus == .ephemeral
|
|
806
|
+
else {
|
|
807
|
+
NSLog(
|
|
808
|
+
"TelnyxVoice: Missed call notification not shown; notification authorization status: \(settings.authorizationStatus.rawValue)"
|
|
809
|
+
)
|
|
810
|
+
return
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
notificationCenter.add(request) { error in
|
|
814
|
+
if let error = error {
|
|
815
|
+
NSLog("TelnyxVoice: Failed to show missed call notification: \(error)")
|
|
816
|
+
} else {
|
|
817
|
+
NSLog("TelnyxVoice: Missed call notification scheduled")
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
407
822
|
}
|
|
408
823
|
|
|
409
824
|
// MARK: - PKPushRegistryDelegate
|
|
@@ -458,6 +873,10 @@ import React
|
|
|
458
873
|
) {
|
|
459
874
|
NSLog("TelnyxVoice: Received VoIP push notification: \(payload.dictionaryPayload)")
|
|
460
875
|
|
|
876
|
+
if handleMissedCallPushIfNeeded(payload.dictionaryPayload, completion: completion) {
|
|
877
|
+
return
|
|
878
|
+
}
|
|
879
|
+
|
|
461
880
|
// Store the VoIP push data for VoicePnBridge (same format as AppDelegate was using)
|
|
462
881
|
do {
|
|
463
882
|
let jsonData = try JSONSerialization.data(withJSONObject: payload.dictionaryPayload)
|
|
@@ -698,6 +1117,13 @@ import React
|
|
|
698
1117
|
NSLog("[TelnyxVoipPushHandler] VoIP push received for type: \(type.rawValue)")
|
|
699
1118
|
NSLog("[TelnyxVoipPushHandler] VoIP payload: \(payload.dictionaryPayload)")
|
|
700
1119
|
|
|
1120
|
+
if TelnyxCallKitManager.shared.handleMissedCallPushIfNeeded(
|
|
1121
|
+
payload.dictionaryPayload,
|
|
1122
|
+
completion: completion
|
|
1123
|
+
) {
|
|
1124
|
+
return
|
|
1125
|
+
}
|
|
1126
|
+
|
|
701
1127
|
// Configure audio session early for VoIP call
|
|
702
1128
|
let audioSession = AVAudioSession.sharedInstance()
|
|
703
1129
|
do {
|
package/ios/VoicePnBridge.m
CHANGED
|
@@ -28,4 +28,18 @@ RCT_EXTERN_METHOD(getPendingVoipAction:(RCTPromiseResolveBlock)resolve
|
|
|
28
28
|
RCT_EXTERN_METHOD(clearPendingVoipAction:(RCTPromiseResolveBlock)resolve
|
|
29
29
|
rejecter:(RCTPromiseRejectBlock)reject)
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
RCT_EXTERN_METHOD(setMissedCallNotificationsEnabled:(BOOL)enabled
|
|
32
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
33
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
34
|
+
|
|
35
|
+
RCT_EXTERN_METHOD(getMissedCallNotificationsEnabled:(RCTPromiseResolveBlock)resolve
|
|
36
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
37
|
+
|
|
38
|
+
RCT_EXTERN_METHOD(setSpeakerEnabled:(BOOL)enabled
|
|
39
|
+
resolver:(RCTPromiseResolveBlock)resolve
|
|
40
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
41
|
+
|
|
42
|
+
RCT_EXTERN_METHOD(isSpeakerEnabled:(RCTPromiseResolveBlock)resolve
|
|
43
|
+
rejecter:(RCTPromiseRejectBlock)reject)
|
|
44
|
+
|
|
45
|
+
@end
|