@nativetalkcommunications/react-native-call-sdk 0.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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/NativetalkCallSdk.podspec +31 -0
  3. package/README.md +494 -0
  4. package/android/build.gradle +58 -0
  5. package/android/gradle.properties +2 -0
  6. package/android/src/main/AndroidManifest.xml +84 -0
  7. package/android/src/main/java/io/nativetalk/callsdk/BackgroundService.kt +149 -0
  8. package/android/src/main/java/io/nativetalk/callsdk/CallActionReceiver.kt +24 -0
  9. package/android/src/main/java/io/nativetalk/callsdk/CallService.kt +45 -0
  10. package/android/src/main/java/io/nativetalk/callsdk/Compatibility.kt +96 -0
  11. package/android/src/main/java/io/nativetalk/callsdk/CoreManager.kt +801 -0
  12. package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallScreeningService.kt +105 -0
  13. package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallSdkModule.kt +205 -0
  14. package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallSdkPackage.kt +18 -0
  15. package/android/src/main/java/io/nativetalk/callsdk/TelephonyMonitor.kt +229 -0
  16. package/android/src/main/java/io/nativetalk/callsdk/Utils.kt +42 -0
  17. package/android/src/main/res/drawable/ic_nativetalk_call.xml +9 -0
  18. package/android/src/main/res/values/strings.xml +9 -0
  19. package/app.plugin.js +1 -0
  20. package/ios/NativetalkCallSdk-Bridging-Header.h +4 -0
  21. package/ios/NativetalkCallSdk.swift +738 -0
  22. package/ios/NativetalkCallSdkBridge.m +35 -0
  23. package/lib/commonjs/CallProvider.js +602 -0
  24. package/lib/commonjs/helpers.js +173 -0
  25. package/lib/commonjs/index.js +96 -0
  26. package/lib/commonjs/native.js +146 -0
  27. package/lib/commonjs/types.js +8 -0
  28. package/lib/commonjs/ui/Avatar.js +29 -0
  29. package/lib/commonjs/ui/Dialer.js +189 -0
  30. package/lib/commonjs/ui/IncomingCallView.js +128 -0
  31. package/lib/commonjs/ui/OutgoingCallView.js +117 -0
  32. package/lib/commonjs/ui/index.js +22 -0
  33. package/lib/commonjs/ui/theme.js +21 -0
  34. package/lib/module/CallProvider.js +573 -0
  35. package/lib/module/helpers.js +161 -0
  36. package/lib/module/index.js +57 -0
  37. package/lib/module/native.js +123 -0
  38. package/lib/module/types.js +7 -0
  39. package/lib/module/ui/Avatar.js +22 -0
  40. package/lib/module/ui/Dialer.js +162 -0
  41. package/lib/module/ui/IncomingCallView.js +101 -0
  42. package/lib/module/ui/OutgoingCallView.js +110 -0
  43. package/lib/module/ui/index.js +13 -0
  44. package/lib/module/ui/theme.js +17 -0
  45. package/lib/typescript/CallProvider.d.ts +46 -0
  46. package/lib/typescript/helpers.d.ts +52 -0
  47. package/lib/typescript/index.d.ts +77 -0
  48. package/lib/typescript/native.d.ts +53 -0
  49. package/lib/typescript/types.d.ts +155 -0
  50. package/lib/typescript/ui/Avatar.d.ts +13 -0
  51. package/lib/typescript/ui/Dialer.d.ts +29 -0
  52. package/lib/typescript/ui/IncomingCallView.d.ts +39 -0
  53. package/lib/typescript/ui/OutgoingCallView.d.ts +28 -0
  54. package/lib/typescript/ui/index.d.ts +13 -0
  55. package/lib/typescript/ui/theme.d.ts +20 -0
  56. package/linphonesw-pod/Sources/LinphoneSdkInfos.swift +4 -0
  57. package/linphonesw-pod/Sources/LinphoneWrapper.swift +42949 -0
  58. package/linphonesw-pod/linphonesw.podspec +46 -0
  59. package/package.json +90 -0
  60. package/plugin/build/index.js +12 -0
  61. package/plugin/build/withAndroid.js +78 -0
  62. package/plugin/build/withIos.js +66 -0
  63. package/src/CallProvider.tsx +675 -0
  64. package/src/helpers.ts +179 -0
  65. package/src/index.ts +84 -0
  66. package/src/native.ts +185 -0
  67. package/src/types.ts +202 -0
  68. package/src/ui/Avatar.tsx +46 -0
  69. package/src/ui/Dialer.tsx +248 -0
  70. package/src/ui/IncomingCallView.tsx +161 -0
  71. package/src/ui/OutgoingCallView.tsx +203 -0
  72. package/src/ui/index.ts +13 -0
  73. package/src/ui/theme.ts +36 -0
  74. package/ui/package.json +6 -0
@@ -0,0 +1,738 @@
1
+ import AVFoundation
2
+ import Foundation
3
+ import Network
4
+ import React
5
+ import linphonesw
6
+
7
+ /**
8
+ * Native bridge exposed to JavaScript as `NativeModules.NativetalkCallSdk`.
9
+ *
10
+ * ──────────────────────────────────────────────────────────────────────────
11
+ * Mental model
12
+ * ──────────────────────────────────────────────────────────────────────────
13
+ *
14
+ * This class owns ONE Linphone `Core` instance per app lifecycle. The Core
15
+ * is the SIP/RTP engine; we just drive it and forward its events back to
16
+ * JS via `sendEvent(withName:body:)`.
17
+ *
18
+ * Unlike Android, iOS doesn't run our own foreground service for calls —
19
+ * Apple wants CallKit to own that UX. So this class is purely a bridge:
20
+ *
21
+ * JS ── NativeModules.NativetalkCallSdk.dial("…") ──► this class
22
+ * this class ── core.inviteAddress(…) ──► Linphone
23
+ * Linphone ── onCallStateChanged ──► NativetalkCoreDelegate
24
+ * delegate ── sendEvent("CallState", …) ──► JS
25
+ *
26
+ * CallKit + VoIP push wiring lives in the HOST app's AppDelegate. We
27
+ * expose a hook (`registerVoipToken`) so the AppDelegate can hand us the
28
+ * push token; everything else (PKPushRegistry, CXProvider, etc.) is the
29
+ * host app's responsibility. See `docs/push-notifications.md` for the
30
+ * full AppDelegate template.
31
+ *
32
+ * ──────────────────────────────────────────────────────────────────────────
33
+ * Threading
34
+ * ──────────────────────────────────────────────────────────────────────────
35
+ * All methods run on the main queue (`requiresMainQueueSetup` returns
36
+ * true). Linphone's iOS binding is happy with this — it dispatches its
37
+ * own audio/IO threads internally. We just need to be careful that any
38
+ * long-running JS callback doesn't block the main thread, since that
39
+ * would stall both UI and call signalling.
40
+ */
41
+ @objc(NativetalkCallSdk)
42
+ class NativetalkCallSdk: RCTEventEmitter {
43
+
44
+ // The Linphone engine. Lazily initialised on first init() call and kept
45
+ // alive for the rest of the app lifecycle. Optional because creation can
46
+ // fail (rare) — in that case we log and the rest of the methods become
47
+ // no-ops.
48
+ private var core: Core?
49
+
50
+ // The delegate that translates Linphone events into RN events. Held as a
51
+ // property so it isn't deallocated while attached to the core.
52
+ private var coreDelegate: NativetalkCoreDelegate?
53
+
54
+ // Guards against re-entrant end() calls. Linphone takes ~500ms to fully
55
+ // terminate; if the user double-taps the hang-up button in that window
56
+ // we'd otherwise call terminate() twice and crash.
57
+ fileprivate var isEndingCall = false
58
+
59
+ // VoIP push token. Cached so we can reapply it whenever the SIP account
60
+ // is created or refreshed — push tokens have to be attached to the
61
+ // PROXY config, not stored globally.
62
+ private var voipTokenHex: String?
63
+
64
+ private var pathMonitor: NWPathMonitor?
65
+ private let monitorQueue = DispatchQueue(label: "io.nativetalk.callsdk.network")
66
+
67
+ // RN requires this to be true if the module touches UIKit on init.
68
+ // Linphone Core creation doesn't strictly need the main queue, but
69
+ // CallKit / AVAudioSession do, so it's simpler to be main-queue-only.
70
+ override static func requiresMainQueueSetup() -> Bool { true }
71
+
72
+ // Events we promise to emit. RN throws at runtime if we emit any name
73
+ // that isn't in this list.
74
+ override func supportedEvents() -> [String]! {
75
+ return ["RegistrationChanged", "CallIncoming", "CallState", "CallEnded"]
76
+ }
77
+
78
+ // MARK: - AVAudioSession dance
79
+ //
80
+ // iOS audio is configured via a global "audio session" object. The order
81
+ // matters:
82
+ // 1. setCategory — what KIND of audio are we using (playAndRecord =
83
+ // full-duplex voice call).
84
+ // 2. setMode — what specific profile (voiceChat enables AGC, AEC,
85
+ // and Bluetooth HFP routing).
86
+ // 3. setActive — actually claim the audio hardware.
87
+ //
88
+ // CallKit will activate the audio session automatically when the call
89
+ // connects, but for outgoing calls we need to bootstrap it ourselves so
90
+ // Linphone has something to write into.
91
+
92
+ private func startAudioSession() {
93
+ let s = AVAudioSession.sharedInstance()
94
+ try? s.setCategory(.playAndRecord, options: [.allowBluetooth, .defaultToSpeaker])
95
+ try? s.setMode(.voiceChat)
96
+ try? s.setActive(true)
97
+ }
98
+
99
+ // Reapply the audio session if something else (e.g. AVAudioPlayer for a
100
+ // notification sound) has changed it under us. The `isOtherAudioPlaying`
101
+ // guard avoids interrupting Apple Music / podcast playback unnecessarily —
102
+ // CallKit's audio activation will handle that case more gracefully than
103
+ // our manual override.
104
+ private func ensureAudioSessionActive() {
105
+ let s = AVAudioSession.sharedInstance()
106
+ guard !s.isOtherAudioPlaying else { return }
107
+ do {
108
+ if s.category != .playAndRecord {
109
+ try s.setCategory(.playAndRecord, options: [.allowBluetooth, .defaultToSpeaker])
110
+ }
111
+ if s.mode != .voiceChat { try s.setMode(.voiceChat) }
112
+ if !s.isOtherAudioPlaying { try s.setActive(true) }
113
+ } catch {
114
+ NSLog("NativetalkCallSdk: failed to ensure audio session: \(error)")
115
+ }
116
+ }
117
+
118
+ // MARK: - UI tone (DTMF feedback for the dial-pad)
119
+ //
120
+ // Why we don't use the system AudioServicesPlaySystemSound: it's hardcoded
121
+ // to play through the loudspeaker and ignores the audio session's
122
+ // routing, which is wrong on a Bluetooth headset. Generating the tones
123
+ // ourselves through AVAudioEngine respects the current output route.
124
+ //
125
+ // The DTMF dual-tone frequencies are an ITU standard — each key is the
126
+ // sum of one low-group and one high-group sine wave. The classic Bell
127
+ // System matrix:
128
+ //
129
+ // 1209Hz 1336Hz 1477Hz
130
+ // 697Hz 1 2 3
131
+ // 770Hz 4 5 6
132
+ // 852Hz 7 8 9
133
+ // 941Hz * 0 #
134
+
135
+ private var toneEngine: AVAudioEngine?
136
+ private var toneNode: AVAudioSourceNode?
137
+
138
+ private let dtmfMap: [String: (Double, Double)] = [
139
+ "1": (697, 1209), "2": (697, 1336), "3": (697, 1477),
140
+ "4": (770, 1209), "5": (770, 1336), "6": (770, 1477),
141
+ "7": (852, 1209), "8": (852, 1336), "9": (852, 1477),
142
+ "*": (941, 1209), "0": (941, 1336), "#": (941, 1477),
143
+ ]
144
+
145
+ @objc(playKeyTone:)
146
+ func playKeyTone(_ digit: NSString) {
147
+ let d = String(digit)
148
+ guard let (f1, f2) = dtmfMap[d] else { return }
149
+
150
+ // Lazy-init the engine on first key press to avoid paying ~10ms of
151
+ // setup cost during app launch when most users never touch the dialer.
152
+ if toneEngine == nil { toneEngine = AVAudioEngine() }
153
+
154
+ // If the user mashes the keypad, tear down the previous tone node
155
+ // before starting a new one — overlapping nodes cause clicks/pops.
156
+ if let node = toneNode {
157
+ node.removeTap(onBus: 0)
158
+ toneEngine?.detach(node)
159
+ toneNode = nil
160
+ }
161
+
162
+ // 48 kHz matches what most iOS hardware uses internally — using the
163
+ // device's native rate avoids an extra sample-rate conversion stage.
164
+ let sr = 48000.0
165
+ var t: Double = 0
166
+ let dt = 1.0 / sr
167
+ let amp: Float = 0.18 // gentle "click" volume, not full DTMF
168
+ let twoPi = 2.0 * Double.pi
169
+
170
+ // AVAudioSourceNode lets us synthesise samples in a render callback.
171
+ // We sum the two sines (each with half amplitude so the sum stays
172
+ // within ±1.0) and write the same value to every channel.
173
+ let node = AVAudioSourceNode { _, _, frameCount, audioBufferList -> OSStatus in
174
+ let abl = UnsafeMutableAudioBufferListPointer(audioBufferList)
175
+ for frame in 0..<Int(frameCount) {
176
+ let sample = sin(twoPi * f1 * t) + sin(twoPi * f2 * t)
177
+ t += dt
178
+ let v = Float(sample * 0.5) * amp
179
+ for buf in abl {
180
+ let ptr = buf.mData!.assumingMemoryBound(to: Float.self)
181
+ ptr[frame] = v
182
+ }
183
+ }
184
+ return noErr
185
+ }
186
+
187
+ let format = AVAudioFormat(standardFormatWithSampleRate: sr, channels: 1)!
188
+ toneEngine?.attach(node)
189
+ toneEngine?.connect(node, to: toneEngine!.mainMixerNode, format: format)
190
+
191
+ do {
192
+ if !(toneEngine?.isRunning ?? false) { try toneEngine?.start() }
193
+ } catch {
194
+ NSLog("DTMF: engine start failed \(error)")
195
+ return
196
+ }
197
+ toneNode = node
198
+
199
+ // 120ms beep — matches Apple's stock dialer feel. Any longer and it
200
+ // starts to feel laggy; any shorter and it sounds like a click.
201
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in
202
+ guard let self = self, let n = self.toneNode else { return }
203
+ self.toneEngine?.disconnectNodeInput(n)
204
+ self.toneEngine?.detach(n)
205
+ self.toneNode = nil
206
+ }
207
+ }
208
+
209
+ // MARK: - Core lifecycle
210
+
211
+ /**
212
+ * Boot Linphone. Idempotent — calling twice is a no-op.
213
+ *
214
+ * Called automatically by `register()` if it sees no core yet, so most
215
+ * apps never need to invoke this directly. The `cfg` param is reserved
216
+ * for future use (e.g. log level, codec preferences); currently ignored.
217
+ *
218
+ * `pushNotificationEnabled = true` tells Linphone we want it to use the
219
+ * VoIP push token attached to the proxy config. We still need the host
220
+ * app to wire PushKit and feed us the token via `registerVoipToken`.
221
+ */
222
+ @objc(init:)
223
+ func `init`(_ cfg: NSDictionary?) {
224
+ if core != nil { return }
225
+ startAudioSession()
226
+
227
+ let f = Factory.Instance
228
+ do {
229
+ core = try f.createCore(configPath: nil, factoryConfigPath: nil, systemContext: nil)
230
+ let delegate = NativetalkCoreDelegate(module: self)
231
+ coreDelegate = delegate
232
+ core?.addDelegate(delegate: delegate)
233
+ core?.pushNotificationEnabled = true
234
+ core?.networkReachable = true
235
+ try core?.start()
236
+ } catch {
237
+ NSLog("NativetalkCallSdk: createCore/start failed: \(error)")
238
+ }
239
+ }
240
+
241
+ /**
242
+ * iOS counterpart to the Android background service.
243
+ *
244
+ * iOS doesn't allow long-running background services for VoIP — Apple's
245
+ * model is "the app sleeps; VoIP push wakes it on demand". So
246
+ * `startNativeServices` just ensures the core exists and is ready to
247
+ * receive a push-driven re-register. The actual "service" is provided
248
+ * by the host app's PushKit/CallKit code (see docs/push-notifications.md).
249
+ */
250
+ @objc(startNativeServices)
251
+ func startNativeServices() {
252
+ if core == nil { self.`init`(nil) }
253
+ startNetworkMonitoring()
254
+ }
255
+
256
+ private func startNetworkMonitoring() {
257
+ guard pathMonitor == nil else { return }
258
+ let monitor = NWPathMonitor()
259
+ monitor.pathUpdateHandler = { [weak self] path in
260
+ guard let self = self else { return }
261
+ DispatchQueue.main.async {
262
+ guard let core = self.core else { return }
263
+ if path.status == .satisfied {
264
+ NSLog("NativetalkCallSdk: network restored — re-registering")
265
+ core.networkReachable = true
266
+ try? core.refreshRegisters()
267
+ } else {
268
+ NSLog("NativetalkCallSdk: network lost")
269
+ core.networkReachable = false
270
+ }
271
+ }
272
+ }
273
+ monitor.start(queue: monitorQueue)
274
+ pathMonitor = monitor
275
+ }
276
+
277
+ private func stopNetworkMonitoring() {
278
+ pathMonitor?.cancel()
279
+ pathMonitor = nil
280
+ }
281
+
282
+ /**
283
+ * Soft-stop: clear all SIP accounts but keep the Core alive so a future
284
+ * `register()` doesn't have to pay the startup cost.
285
+ *
286
+ * We don't fully tear down the core because doing so leaves the
287
+ * AVAudioSession in a half-deactivated state that the next call has to
288
+ * recover from. Better to keep it warm.
289
+ */
290
+ @objc(stopNativeServices:)
291
+ func stopNativeServices(_ logout: Bool) {
292
+ stopNetworkMonitoring()
293
+ core?.clearAccounts()
294
+ core?.clearProxyConfig()
295
+ core?.clearAllAuthInfo()
296
+ }
297
+
298
+ // MARK: - Registration
299
+
300
+ /**
301
+ * Register a SIP account, replacing any previous one.
302
+ *
303
+ * Unlike the Android side (which has a "same user → skip wipe" fast
304
+ * path), we always wipe on iOS. The reason: iOS apps re-mount this
305
+ * module on every cold start, and they typically only call register()
306
+ * once per session, so the wipe cost is negligible. Keeping the code
307
+ * simpler is the better tradeoff here.
308
+ *
309
+ * Account params are the modern Linphone API for registration (vs. the
310
+ * legacy ProxyConfig). They support push notification config in a way
311
+ * proxy configs don't, which matters on iOS where VoIP push is the
312
+ * primary delivery mechanism.
313
+ */
314
+ @objc(register:)
315
+ func register(_ acc: NSDictionary) {
316
+ if core == nil { self.`init`(nil) }
317
+ guard let core = core else { return }
318
+
319
+ let username = acc["username"] as? String ?? ""
320
+ let password = acc["password"] as? String ?? ""
321
+ let domain = acc["domain"] as? String ?? ""
322
+ let transport = (acc["transport"] as? String)?.lowercased()
323
+
324
+ do {
325
+ // Wipe any previous registration. See Android CoreManager's
326
+ // wipeAllAccounts() docs for why this matters.
327
+ core.clearAccounts()
328
+ core.clearProxyConfig()
329
+ core.clearAllAuthInfo()
330
+
331
+ // Auth info = "if anyone challenges us with a 401, here's the
332
+ // password". Keyed by username + domain.
333
+ let auth = try Factory.Instance.createAuthInfo(
334
+ username: username, userid: nil, passwd: password,
335
+ ha1: nil, realm: nil, domain: domain
336
+ )
337
+ core.addAuthInfo(info: auth)
338
+
339
+ // Identity = our SIP address. Server = where to send REGISTER.
340
+ // These are often the same hostname but conceptually distinct.
341
+ let identityAddr = try Factory.Instance.createAddress(addr: "sip:\(username)@\(domain)")
342
+ let serverAddr = try Factory.Instance.createAddress(addr: "sip:\(domain)")
343
+
344
+ // Transport: TLS (encrypted), TCP (plain reliable), UDP (plain
345
+ // best-effort). Default is UDP if unspecified — but most modern
346
+ // PBXs require TCP or TLS for security.
347
+ if let t = transport {
348
+ switch t {
349
+ case "tls": try serverAddr.setTransport(newValue: .Tls)
350
+ case "tcp": try serverAddr.setTransport(newValue: .Tcp)
351
+ default: try serverAddr.setTransport(newValue: .Udp)
352
+ }
353
+ }
354
+
355
+ let params = try core.createAccountParams()
356
+ try params.setIdentityaddress(newValue: identityAddr)
357
+ try params.setServeraddress(newValue: serverAddr)
358
+ params.pushNotificationAllowed = true // tell the server we accept push
359
+ params.registerEnabled = true
360
+
361
+ // Attach any cached VoIP push token. If we don't have one yet
362
+ // (token comes from PushKit asynchronously), it'll be applied later
363
+ // via registerVoipToken().
364
+ if let token = self.voipTokenHex, !token.isEmpty {
365
+ params.pushNotificationConfig?.param = token
366
+ }
367
+
368
+ let account = try core.createAccount(params: params)
369
+ try core.addAccount(account: account)
370
+ core.defaultAccount = account
371
+ } catch {
372
+ NSLog("NativetalkCallSdk: register() failed: \(error)")
373
+ }
374
+ }
375
+
376
+ @objc(refreshRegisters)
377
+ func refreshRegisters() {
378
+ try? core?.refreshRegisters()
379
+ }
380
+
381
+ @objc(setRegisterEnabled:)
382
+ func setRegisterEnabled(_ on: Bool) {
383
+ guard let core = core, let proxy = core.defaultProxyConfig else { return }
384
+ proxy.registerEnabled = on
385
+ try? core.refreshRegisters()
386
+ }
387
+
388
+ @objc(registerVoipToken:)
389
+ func registerVoipToken(_ tokenHex: NSString) {
390
+ self.voipTokenHex = tokenHex as String
391
+ guard let core = core, let account = core.defaultAccount else { return }
392
+ if let newParams = account.params?.clone() {
393
+ newParams.pushNotificationConfig?.param = self.voipTokenHex
394
+ account.params = newParams
395
+ try? core.refreshRegisters()
396
+ }
397
+ }
398
+
399
+ @objc(getRegistrationStatus:rejecter:)
400
+ func getRegistrationStatus(
401
+ _ resolve: RCTPromiseResolveBlock,
402
+ rejecter reject: RCTPromiseRejectBlock
403
+ ) {
404
+ guard let core = core else {
405
+ reject("NO_CORE", "Core not initialized", nil)
406
+ return
407
+ }
408
+ guard let proxy = core.defaultProxyConfig else {
409
+ resolve([
410
+ "state": "none",
411
+ "message": "",
412
+ "username": "",
413
+ "domain": "",
414
+ "displayName": "",
415
+ ])
416
+ return
417
+ }
418
+ let addr = proxy.identityAddress
419
+ let diag: String = {
420
+ if let info = proxy.errorInfo, let phrase = info.phrase, !phrase.isEmpty { return phrase }
421
+ return ""
422
+ }()
423
+ let state: String = {
424
+ switch proxy.state {
425
+ case .None: return "none"
426
+ case .Progress: return "progress"
427
+ case .Ok: return "ok"
428
+ case .Cleared: return "cleared"
429
+ case .Failed: return "failed"
430
+ @unknown default: return "unknown"
431
+ }
432
+ }()
433
+ resolve([
434
+ "state": state,
435
+ "message": diag,
436
+ "username": addr?.username ?? "",
437
+ "domain": addr?.domain ?? "",
438
+ "displayName": addr?.displayName ?? "",
439
+ ])
440
+ }
441
+
442
+ // MARK: - Call control
443
+
444
+ @objc(call:)
445
+ func call(_ sipUri: String) {
446
+ guard let core = core,
447
+ let addr = try? Factory.Instance.createAddress(addr: sipUri) else { return }
448
+ _ = core.inviteAddress(addr: addr)
449
+ }
450
+
451
+ @objc(answer)
452
+ func answer() {
453
+ do { try core?.currentCall?.accept() } catch {
454
+ NSLog("NativetalkCallSdk: answer() failed: \(error)")
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Reject an incoming call with a specific SIP response code.
460
+ *
461
+ * The reason determines what the CALLER sees:
462
+ * - .Busy (486) → "User busy" — often → voicemail
463
+ * - .NotAcceptable (406) → "Not acceptable here"
464
+ * - .TemporarilyUnavailable(480) → "Temporarily unavailable"
465
+ * - .Declined (default) → "Call declined"
466
+ *
467
+ * Most apps want .Busy for "don't disturb me right now, take a message"
468
+ * or .Declined for "I'm choosing not to answer".
469
+ *
470
+ * The state check matters: `decline()` is only valid for incoming calls
471
+ * that haven't yet been accepted. If we somehow got into this method
472
+ * with an outgoing or already-connected call, we fall back to
473
+ * `terminate()` which works in any state.
474
+ */
475
+ @objc(decline:)
476
+ func decline(_ reasonStr: NSString?) {
477
+ ensureAudioSessionActive()
478
+ guard let call = core?.currentCall else { return }
479
+
480
+ let r: Reason
481
+ switch (reasonStr as String?)?.lowercased() {
482
+ case "busy", "486": r = .Busy
483
+ case "notacceptable", "406": r = .NotAcceptable
484
+ case "temporarilyunavailable", "480": r = .TemporarilyUnavailable
485
+ default: r = .Declined
486
+ }
487
+
488
+ switch call.state {
489
+ case .IncomingReceived, .IncomingEarlyMedia, .PushIncomingReceived:
490
+ do { try call.decline(reason: r) }
491
+ catch { NSLog("NativetalkCallSdk: decline() failed: \(error)") }
492
+ default:
493
+ // Not a ringing call — `decline` would throw. Fall back to terminate.
494
+ do { try call.terminate() }
495
+ catch { NSLog("NativetalkCallSdk: terminate() (fallback) failed: \(error)") }
496
+ }
497
+ }
498
+
499
+ /**
500
+ * End the active call (any direction, any state).
501
+ *
502
+ * `isEndingCall` guards against re-entrancy: Linphone takes ~500ms to
503
+ * fully tear down a call, and the user double-tapping the hang-up
504
+ * button in that window would call terminate() twice and crash. The
505
+ * guard is cleared automatically by [NativetalkCoreDelegate] when the
506
+ * call reaches a terminal state.
507
+ *
508
+ * The state-aware dispatch (`decline` for ringing calls, `terminate`
509
+ * for everything else) is required because Linphone disallows
510
+ * `terminate()` on a call that hasn't been answered.
511
+ */
512
+ @objc(end)
513
+ func end() {
514
+ guard let call = core?.currentCall, !isEndingCall else { return }
515
+ isEndingCall = true
516
+ do {
517
+ switch call.state {
518
+ case .IncomingReceived, .PushIncomingReceived, .IncomingEarlyMedia:
519
+ try call.decline(reason: .Declined)
520
+ default:
521
+ try call.terminate()
522
+ }
523
+ } catch {
524
+ isEndingCall = false
525
+ NSLog("NativetalkCallSdk: end() failed: \(error)")
526
+ }
527
+ }
528
+
529
+ @objc(hangup)
530
+ func hangup() { end() }
531
+
532
+ @objc(mute:)
533
+ func mute(_ on: Bool) { core?.micEnabled = !on }
534
+
535
+ @objc(speaker:)
536
+ func speaker(_ on: Bool) {
537
+ let s = AVAudioSession.sharedInstance()
538
+ try? s.overrideOutputAudioPort(on ? .speaker : .none)
539
+ }
540
+
541
+ @objc(sendDtmf:)
542
+ func sendDtmf(_ d: String) {
543
+ do {
544
+ guard let byte = d.utf8.first else { return }
545
+ let ch = CChar(bitPattern: byte)
546
+ try core?.currentCall?.sendDtmf(dtmf: ch)
547
+ } catch {
548
+ NSLog("NativetalkCallSdk: sendDtmf() failed: \(error)")
549
+ }
550
+ }
551
+
552
+ @objc(hold)
553
+ func hold() {
554
+ do { try core?.currentCall?.pause() } catch {
555
+ NSLog("NativetalkCallSdk: hold() failed: \(error)")
556
+ }
557
+ }
558
+
559
+ @objc(resume)
560
+ func resume() {
561
+ do { try core?.currentCall?.resume() } catch {
562
+ NSLog("NativetalkCallSdk: resume() failed: \(error)")
563
+ }
564
+ }
565
+
566
+ // MARK: - Call logs
567
+
568
+ private func sipUserPart(_ uri: String) -> String {
569
+ if let at = uri.firstIndex(of: "@") {
570
+ let start = uri.hasPrefix("sip:") ? uri.index(uri.startIndex, offsetBy: 4) : uri.startIndex
571
+ return String(uri[start..<at])
572
+ }
573
+ return uri.replacingOccurrences(of: "sip:", with: "")
574
+ }
575
+
576
+ private func mmss(_ seconds: Int) -> String {
577
+ let m = seconds / 60
578
+ let s = seconds % 60
579
+ return String(format: "%02d:%02d", m, s)
580
+ }
581
+
582
+ private func guessCallType(direction: String, called: String, mySipUser: String?) -> String {
583
+ if called.count <= 3 { return "LOCAL" }
584
+ if direction == "inbound", let me = mySipUser, called == me { return "DID" }
585
+ return "STANDARD"
586
+ }
587
+
588
+ private func dispositionFor(status: String) -> String {
589
+ let s = status.lowercased()
590
+ if s.contains("success") || s.contains("ok") { return "NORMAL_CLEARING [16]" }
591
+ if s.contains("missed") { return "NO_USER_RESPONSE [18]" }
592
+ if s.contains("aborted") || s.contains("declined") || s.contains("cancel") {
593
+ return "ORIGINATOR_CANCEL [487]"
594
+ }
595
+ if s.contains("busy") { return "USER_BUSY [17]" }
596
+ return "NORMAL_CLEARING [16]"
597
+ }
598
+
599
+ @objc(getCallLogs:rejecter:)
600
+ func getCallLogs(
601
+ _ resolve: RCTPromiseResolveBlock,
602
+ rejecter reject: RCTPromiseRejectBlock
603
+ ) {
604
+ guard let logs = core?.callLogs else {
605
+ resolve([])
606
+ return
607
+ }
608
+ let df = ISO8601DateFormatter()
609
+ var mySipUser: String? = nil
610
+ if let me = core?.defaultAccount?.params?.identityAddress?.username, !me.isEmpty {
611
+ mySipUser = me
612
+ }
613
+
614
+ var items: [[String: Any]] = []
615
+ for (idx, log) in logs.enumerated() {
616
+ let fromRaw = log.fromAddress?.asStringUriOnly() ?? log.fromAddress?.asString() ?? ""
617
+ let toRaw = log.toAddress?.asStringUriOnly() ?? log.toAddress?.asString() ?? ""
618
+ let fromNum = sipUserPart(fromRaw)
619
+ let toNum = sipUserPart(toRaw)
620
+
621
+ let direction: String = {
622
+ let s = String(describing: log.dir).lowercased()
623
+ if s.contains("incoming") { return "inbound" }
624
+ if s.contains("outgoing") { return "outbound" }
625
+ return s
626
+ }()
627
+
628
+ let startISO = df.string(from: Date(timeIntervalSince1970: TimeInterval(log.startDate)))
629
+ let callType = guessCallType(direction: direction, called: toNum, mySipUser: mySipUser)
630
+ let disp = dispositionFor(status: String(describing: log.status))
631
+ let durationStr = mmss(Int(log.duration))
632
+ let destination = (callType == "LOCAL") ? "Local" : ""
633
+ let idVal: Int = {
634
+ if let cid = log.callId { return abs(cid.hashValue) }
635
+ return 100000 + idx
636
+ }()
637
+
638
+ items.append([
639
+ "id": idVal,
640
+ "call_start": startISO,
641
+ "call_type": callType,
642
+ "caller_id": "\(fromNum) <\(fromNum)>",
643
+ "call_direction": direction,
644
+ "called_number": toNum,
645
+ "disposition": disp,
646
+ "debit": "0.0000",
647
+ "duration": durationStr,
648
+ "destination": destination,
649
+ "sip_user": mySipUser ?? "",
650
+ "created_at": startISO,
651
+ "updated_at": startISO,
652
+ ])
653
+ }
654
+ resolve(items)
655
+ }
656
+
657
+ deinit {
658
+ if let d = coreDelegate { core?.removeDelegate(delegate: d) }
659
+ if let node = toneNode {
660
+ toneEngine?.disconnectNodeInput(node)
661
+ toneEngine?.detach(node)
662
+ }
663
+ toneNode = nil
664
+ toneEngine?.stop()
665
+ toneEngine = nil
666
+ }
667
+ }
668
+
669
+ // MARK: - Delegate that pipes Linphone events into RN events.
670
+
671
+ class NativetalkCoreDelegate: CoreDelegate {
672
+ private weak var module: NativetalkCallSdk?
673
+
674
+ init(module: NativetalkCallSdk) {
675
+ self.module = module
676
+ }
677
+
678
+ func onCallStateChanged(core: Core, call: Call, state: Call.State, message: String) {
679
+ let stateStr = String(describing: state)
680
+ module?.sendEvent(withName: "CallState", body: ["state": stateStr, "message": message])
681
+
682
+ switch state {
683
+ case .IncomingReceived, .PushIncomingReceived, .IncomingEarlyMedia:
684
+ let addr = call.remoteAddress
685
+ let display = addr?.displayName ?? ""
686
+ let username = addr?.username ?? ""
687
+ let uri = addr?.asStringUriOnly() ?? addr?.asString() ?? ""
688
+ let short = (!display.isEmpty && display.lowercased() != "anonymous") ? display : username
689
+
690
+ module?.sendEvent(
691
+ withName: "CallIncoming",
692
+ body: [
693
+ "from": short,
694
+ "displayName": display,
695
+ "username": username,
696
+ "uri": uri,
697
+ "callId": call.callLog?.callId ?? "",
698
+ ])
699
+
700
+ case .End, .Released, .Error:
701
+ module?.isEndingCall = false
702
+ module?.sendEvent(withName: "CallEnded", body: [:])
703
+
704
+ default:
705
+ break
706
+ }
707
+ }
708
+
709
+ func onRegistrationStateChanged(
710
+ core: Core, proxyConfig: ProxyConfig, state: RegistrationState, message: String
711
+ ) {
712
+ module?.sendEvent(
713
+ withName: "RegistrationChanged",
714
+ body: ["state": registrationStateString(state), "message": message])
715
+ }
716
+
717
+ // Linphone 5.x registers via Account internally. When refreshRegisters()
718
+ // fires after a network change, this callback is more reliably called than
719
+ // the legacy ProxyConfig-based one above.
720
+ func onAccountRegistrationStateChanged(
721
+ core: Core, account: Account, state: RegistrationState, message: String
722
+ ) {
723
+ module?.sendEvent(
724
+ withName: "RegistrationChanged",
725
+ body: ["state": registrationStateString(state), "message": message])
726
+ }
727
+
728
+ private func registrationStateString(_ state: RegistrationState) -> String {
729
+ switch state {
730
+ case .None: return "none"
731
+ case .Progress: return "progress"
732
+ case .Ok: return "ok"
733
+ case .Cleared: return "cleared"
734
+ case .Failed: return "failed"
735
+ @unknown default: return "unknown"
736
+ }
737
+ }
738
+ }