@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.
- package/LICENSE +21 -0
- package/NativetalkCallSdk.podspec +31 -0
- package/README.md +494 -0
- package/android/build.gradle +58 -0
- package/android/gradle.properties +2 -0
- package/android/src/main/AndroidManifest.xml +84 -0
- package/android/src/main/java/io/nativetalk/callsdk/BackgroundService.kt +149 -0
- package/android/src/main/java/io/nativetalk/callsdk/CallActionReceiver.kt +24 -0
- package/android/src/main/java/io/nativetalk/callsdk/CallService.kt +45 -0
- package/android/src/main/java/io/nativetalk/callsdk/Compatibility.kt +96 -0
- package/android/src/main/java/io/nativetalk/callsdk/CoreManager.kt +801 -0
- package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallScreeningService.kt +105 -0
- package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallSdkModule.kt +205 -0
- package/android/src/main/java/io/nativetalk/callsdk/NativetalkCallSdkPackage.kt +18 -0
- package/android/src/main/java/io/nativetalk/callsdk/TelephonyMonitor.kt +229 -0
- package/android/src/main/java/io/nativetalk/callsdk/Utils.kt +42 -0
- package/android/src/main/res/drawable/ic_nativetalk_call.xml +9 -0
- package/android/src/main/res/values/strings.xml +9 -0
- package/app.plugin.js +1 -0
- package/ios/NativetalkCallSdk-Bridging-Header.h +4 -0
- package/ios/NativetalkCallSdk.swift +738 -0
- package/ios/NativetalkCallSdkBridge.m +35 -0
- package/lib/commonjs/CallProvider.js +602 -0
- package/lib/commonjs/helpers.js +173 -0
- package/lib/commonjs/index.js +96 -0
- package/lib/commonjs/native.js +146 -0
- package/lib/commonjs/types.js +8 -0
- package/lib/commonjs/ui/Avatar.js +29 -0
- package/lib/commonjs/ui/Dialer.js +189 -0
- package/lib/commonjs/ui/IncomingCallView.js +128 -0
- package/lib/commonjs/ui/OutgoingCallView.js +117 -0
- package/lib/commonjs/ui/index.js +22 -0
- package/lib/commonjs/ui/theme.js +21 -0
- package/lib/module/CallProvider.js +573 -0
- package/lib/module/helpers.js +161 -0
- package/lib/module/index.js +57 -0
- package/lib/module/native.js +123 -0
- package/lib/module/types.js +7 -0
- package/lib/module/ui/Avatar.js +22 -0
- package/lib/module/ui/Dialer.js +162 -0
- package/lib/module/ui/IncomingCallView.js +101 -0
- package/lib/module/ui/OutgoingCallView.js +110 -0
- package/lib/module/ui/index.js +13 -0
- package/lib/module/ui/theme.js +17 -0
- package/lib/typescript/CallProvider.d.ts +46 -0
- package/lib/typescript/helpers.d.ts +52 -0
- package/lib/typescript/index.d.ts +77 -0
- package/lib/typescript/native.d.ts +53 -0
- package/lib/typescript/types.d.ts +155 -0
- package/lib/typescript/ui/Avatar.d.ts +13 -0
- package/lib/typescript/ui/Dialer.d.ts +29 -0
- package/lib/typescript/ui/IncomingCallView.d.ts +39 -0
- package/lib/typescript/ui/OutgoingCallView.d.ts +28 -0
- package/lib/typescript/ui/index.d.ts +13 -0
- package/lib/typescript/ui/theme.d.ts +20 -0
- package/linphonesw-pod/Sources/LinphoneSdkInfos.swift +4 -0
- package/linphonesw-pod/Sources/LinphoneWrapper.swift +42949 -0
- package/linphonesw-pod/linphonesw.podspec +46 -0
- package/package.json +90 -0
- package/plugin/build/index.js +12 -0
- package/plugin/build/withAndroid.js +78 -0
- package/plugin/build/withIos.js +66 -0
- package/src/CallProvider.tsx +675 -0
- package/src/helpers.ts +179 -0
- package/src/index.ts +84 -0
- package/src/native.ts +185 -0
- package/src/types.ts +202 -0
- package/src/ui/Avatar.tsx +46 -0
- package/src/ui/Dialer.tsx +248 -0
- package/src/ui/IncomingCallView.tsx +161 -0
- package/src/ui/OutgoingCallView.tsx +203 -0
- package/src/ui/index.ts +13 -0
- package/src/ui/theme.ts +36 -0
- 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
|
+
}
|