@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,573 @@
1
+ /**
2
+ * <CallProvider> — the single React component the host app mounts to enable
3
+ * calling. It wires the native event stream into React state and exposes
4
+ * everything through `useCall()`.
5
+ *
6
+ * ──────────────────────────────────────────────────────────────────────────
7
+ * Mental model
8
+ * ──────────────────────────────────────────────────────────────────────────
9
+ *
10
+ * Linphone Core ──events──► Native module ──RN bridge──► CallProvider
11
+ * ▲ │
12
+ * │ ▼
13
+ * └────── method calls (dial/answer/end) ──────────── useCall() hook
14
+ *
15
+ * - The Linphone `Core` is the single source of truth for "what state is the
16
+ * call in". CallProvider just mirrors that state into React.
17
+ * - Provider → native flows via plain method calls. Native → provider flows
18
+ * via NativeEventEmitter events (RegistrationChanged, CallIncoming, etc.).
19
+ *
20
+ * ──────────────────────────────────────────────────────────────────────────
21
+ * Design rules
22
+ * ──────────────────────────────────────────────────────────────────────────
23
+ * - No coupling to auth, navigation, or any specific HTTP client. SIP config
24
+ * is passed in as a prop; lifecycle events (incoming, ended) are exposed as
25
+ * callbacks so the host app decides how to navigate / display screens.
26
+ * - Safe to mount with `config={null}` while you fetch credentials — the SDK
27
+ * simply idles until a real config arrives, then auto-registers (unless
28
+ * `autoRegister={false}`).
29
+ * - All event callbacks are read through refs (see `*Ref` block) so the host
30
+ * app can pass fresh closures on every render WITHOUT re-subscribing the
31
+ * native events — that would drop in-flight calls during a re-render.
32
+ */
33
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
34
+ import { Platform } from 'react-native';
35
+ import { callStateName, callStatusLabel, destinationToSipUri, formatDuration, formatTenantDomain, initialsFrom, parseSipUser, regStateName, } from './helpers';
36
+ import * as Native from './native';
37
+ const CallContext = createContext(null);
38
+ // ── State-group lookups for the call lifecycle FSM ────────────────────────
39
+ //
40
+ // Linphone exposes ~20 fine-grained call states. The UI only cares about a
41
+ // handful of behavioural buckets, so we precompute them here and look them
42
+ // up in O(1) inside the CallState event handler.
43
+ //
44
+ // ACTIVE → audio is flowing; start the duration timer
45
+ // TERMINAL → call is gone; freeze the timer & reset audio toggles
46
+ // HELD → on hold (initiated by us OR by the remote peer)
47
+ // RESUMED → coming out of hold OR media flowing again
48
+ //
49
+ // Note: `Connected` and `StreamsRunning` appear in BOTH active and resumed —
50
+ // they're listed twice on purpose. "Active" treats them as "start timer";
51
+ // "Resumed" treats them as "clear the held flag".
52
+ const ACTIVE_STATES = ['Connected', 'StreamsRunning'];
53
+ const TERMINAL_STATES = [
54
+ 'End',
55
+ 'Released',
56
+ 'Error', // SIP error (busy, not-found, network drop, etc.)
57
+ ];
58
+ const HELD_STATES = [
59
+ 'Pausing',
60
+ 'Paused',
61
+ 'PausedByRemote', // peer put us on hold
62
+ ];
63
+ const RESUMED_STATES = [
64
+ 'Resuming',
65
+ 'Connected',
66
+ 'StreamsRunning',
67
+ ];
68
+ export function CallProvider(props) {
69
+ const { children, config = null, autoRegister = true, enableNativeServices = true, requestMicPermission = true, onIncomingCall, onOutgoingCall, onCallEnded, onCallStateChanged, onRegistrationStateChanged, onError, } = props;
70
+ // ── Stable refs for the event callbacks ──────────────────────────────────
71
+ //
72
+ // The host app typically passes inline arrow functions:
73
+ //
74
+ // <CallProvider onIncomingCall={(i) => navigation.navigate(…)} />
75
+ //
76
+ // Each render produces a NEW function identity. If we depended on these
77
+ // directly in the subscription useEffect, every render would tear down the
78
+ // native event subscriptions and re-create them — guaranteed to drop
79
+ // events that happen mid-render. So we mirror them into refs and let the
80
+ // event handlers read `.current` at fire time.
81
+ //
82
+ // The trailing `useEffect` (no deps array) runs after every commit, which
83
+ // keeps `current` aligned with the latest props.
84
+ const onIncomingCallRef = useRef(onIncomingCall);
85
+ const onOutgoingCallRef = useRef(onOutgoingCall);
86
+ const onCallEndedRef = useRef(onCallEnded);
87
+ const onCallStateChangedRef = useRef(onCallStateChanged);
88
+ const onRegistrationStateChangedRef = useRef(onRegistrationStateChanged);
89
+ const onErrorRef = useRef(onError);
90
+ useEffect(() => {
91
+ onIncomingCallRef.current = onIncomingCall;
92
+ onOutgoingCallRef.current = onOutgoingCall;
93
+ onCallEndedRef.current = onCallEnded;
94
+ onCallStateChangedRef.current = onCallStateChanged;
95
+ onRegistrationStateChangedRef.current = onRegistrationStateChanged;
96
+ onErrorRef.current = onError;
97
+ });
98
+ // ----- State -----
99
+ const [registration, setRegistration] = useState(null);
100
+ const [callStatus, setCallStatus] = useState('Idle');
101
+ const [incoming, setIncoming] = useState(false);
102
+ const [incomingInfo, setIncomingInfo] = useState(null);
103
+ const [muted, setMuted] = useState(false);
104
+ const [speakerOn, setSpeakerOn] = useState(false);
105
+ const [held, setHeld] = useState(false);
106
+ const [durationSec, setDurationSec] = useState(0);
107
+ const [ending, setEnding] = useState(false);
108
+ // ── Duration timer ────────────────────────────────────────────────────────
109
+ //
110
+ // We anchor the timer to a wall-clock timestamp (`startTsRef`) rather than
111
+ // incrementing a counter. Why? `setInterval` drifts when the device is
112
+ // backgrounded, the JS thread stalls, or the user switches apps. By
113
+ // re-computing `now - start` on every tick we always show the true elapsed
114
+ // time, even if 30 intervals were skipped while the screen was off.
115
+ //
116
+ // The 500ms tick is half a second of "lag" worst-case but spares the JS
117
+ // thread the overhead of a 30Hz refresh — UI just renders to second
118
+ // precision anyway.
119
+ const tickRef = useRef(null);
120
+ const startTsRef = useRef(null);
121
+ const clearTimer = useCallback(() => {
122
+ if (tickRef.current)
123
+ clearInterval(tickRef.current);
124
+ tickRef.current = null;
125
+ }, []);
126
+ // Called when a new call starts — wipes the previous duration so the UI
127
+ // doesn't briefly show the old call's timer.
128
+ const resetDuration = useCallback(() => {
129
+ clearTimer();
130
+ startTsRef.current = null;
131
+ setDurationSec(0);
132
+ }, [clearTimer]);
133
+ // Idempotent: calling startDuration() multiple times during the same call
134
+ // doesn't restart the clock. This matters because Linphone can fire
135
+ // `Connected` and `StreamsRunning` back-to-back, and we'd otherwise reset
136
+ // the start time on the second event.
137
+ const startDuration = useCallback(() => {
138
+ if (tickRef.current)
139
+ return;
140
+ startTsRef.current = Date.now();
141
+ tickRef.current = setInterval(() => {
142
+ if (startTsRef.current != null) {
143
+ setDurationSec((Date.now() - startTsRef.current) / 1000);
144
+ }
145
+ }, 500);
146
+ }, []);
147
+ // ── Initial boot sequence: native init → mic permission → services ──────
148
+ //
149
+ // Order matters:
150
+ // 1. Native.init() — boots the Linphone Core. Required before anything
151
+ // else; safe to call multiple times (the native side is idempotent).
152
+ // 2. Mic permission — Linphone won't open the mic without it. We do this
153
+ // eagerly so the user is prompted once on first launch rather than
154
+ // mid-call. Set `requestMicPermission={false}` to skip and handle it
155
+ // yourself.
156
+ // 3. Background service (Android only) — keeps the SIP socket alive when
157
+ // the app is backgrounded. On iOS this is the host app's job via
158
+ // VoIP push, so the call is a no-op.
159
+ //
160
+ // `cancelled` guards against the provider unmounting mid-await. Without
161
+ // it, a fast remount-then-unmount could fire `onError` callbacks against
162
+ // a stale provider instance.
163
+ useEffect(() => {
164
+ let cancelled = false;
165
+ (async () => {
166
+ try {
167
+ await Native.init();
168
+ if (cancelled)
169
+ return;
170
+ if (requestMicPermission) {
171
+ const granted = await Native.ensureMicPermission();
172
+ if (!granted) {
173
+ onErrorRef.current?.({
174
+ code: 'MIC_PERMISSION_DENIED',
175
+ message: 'Microphone permission denied. Calls cannot be made or received.',
176
+ });
177
+ }
178
+ }
179
+ if (enableNativeServices && Platform.OS === 'android') {
180
+ Native.startNativeServices();
181
+ }
182
+ }
183
+ catch (e) {
184
+ onErrorRef.current?.({
185
+ code: 'INIT_FAILED',
186
+ message: e?.message ?? String(e),
187
+ });
188
+ }
189
+ })();
190
+ return () => {
191
+ cancelled = true;
192
+ };
193
+ }, [enableNativeServices, requestMicPermission]);
194
+ // ── Native event subscriptions ───────────────────────────────────────────
195
+ //
196
+ // This effect runs once and never re-runs (its deps array contains only
197
+ // stable useCallback refs). All five events from the native module are
198
+ // bridged into React state here:
199
+ //
200
+ // RegistrationChanged → setRegistration + onRegistrationStateChanged
201
+ // CallIncoming → setIncoming(true) + onIncomingCall
202
+ // CallState → setCallStatus + timer/hold/cleanup based on FSM
203
+ // CallEnded → reset transient state + onCallEnded
204
+ //
205
+ // The cleanup function removes all subscriptions on unmount. Critically,
206
+ // we DO NOT call `Native.stopNativeServices()` here — that would kill the
207
+ // foreground service even on a transient unmount. Apps that want to fully
208
+ // shut down call functionality (e.g. on logout) should call
209
+ // `stopNativeServices(true)` explicitly via the hook.
210
+ useEffect(() => {
211
+ // Registration state changes — `progress`, `ok`, `failed`, etc.
212
+ // `regStateName()` normalises the raw value to a canonical lowercase
213
+ // string. We surface `failed` as a structured error so the host app can
214
+ // distinguish "user hasn't logged in yet" from "credentials are bad".
215
+ const subReg = Native.on.RegistrationChanged((e) => {
216
+ const pretty = regStateName(e?.state);
217
+ const event = { ...e, state: pretty, pretty };
218
+ setRegistration(event);
219
+ onRegistrationStateChangedRef.current?.(event);
220
+ if (pretty === 'failed') {
221
+ onErrorRef.current?.({
222
+ code: 'REGISTRATION_FAILED',
223
+ message: e?.message || 'SIP registration failed',
224
+ });
225
+ }
226
+ });
227
+ // Incoming call — pick the best display name we have. SIP can deliver
228
+ // any of: displayName ("Jane Doe"), username ("100"), or full URI
229
+ // ("sip:+234…@gateway"). Some servers send "anonymous" as the display
230
+ // name when caller-ID is suppressed; we treat that as missing and fall
231
+ // back to the username instead.
232
+ const subIncoming = Native.on.CallIncoming((e = {}) => {
233
+ const display = (e.displayName || '').trim();
234
+ const user = (e.username || '').trim();
235
+ const parsed = parseSipUser(e.uri || '');
236
+ const phone = display && display.toLowerCase() !== 'anonymous'
237
+ ? display
238
+ : user || parsed || 'Unknown';
239
+ const info = {
240
+ name: phone,
241
+ phone,
242
+ initials: initialsFrom(phone),
243
+ callId: e?.callId,
244
+ uri: e?.uri,
245
+ };
246
+ setIncoming(true);
247
+ setIncomingInfo(info);
248
+ setCallStatus('IncomingReceived');
249
+ onIncomingCallRef.current?.(info);
250
+ });
251
+ // Generic call state transitions — drives the FSM defined at the top of
252
+ // the file. The four set-membership checks below are mutually exclusive
253
+ // for any single event (e.g. a state can't be both ACTIVE and TERMINAL),
254
+ // so the if-chain is intentional, not a fall-through bug.
255
+ const subState = Native.on.CallState((e) => {
256
+ const pretty = callStateName(e?.state);
257
+ setCallStatus(pretty);
258
+ onCallStateChangedRef.current?.(pretty);
259
+ if (HELD_STATES.includes(pretty))
260
+ setHeld(true);
261
+ if (RESUMED_STATES.includes(pretty))
262
+ setHeld(false);
263
+ if (ACTIVE_STATES.includes(pretty)) {
264
+ // Media is flowing. If this was an incoming call, clear the
265
+ // "ringing" flag so the UI moves from incoming-screen → in-call.
266
+ setIncoming(false);
267
+ startDuration();
268
+ }
269
+ if (TERMINAL_STATES.includes(pretty)) {
270
+ // Freeze the duration (don't reset to 0) so the UI can show
271
+ // "Call ended · 2:34" for a moment before navigation pops the
272
+ // screen. Audio toggles reset because they don't persist across
273
+ // calls — mute is per-session.
274
+ clearTimer();
275
+ setIncoming(false);
276
+ setIncomingInfo(null);
277
+ setMuted(false);
278
+ setSpeakerOn(false);
279
+ }
280
+ });
281
+ // CallEnded fires AFTER the CallState transition to End/Released. It's
282
+ // a convenience event — listeners that only care about "call is gone"
283
+ // can subscribe to this without parsing every state transition.
284
+ const subEnd = Native.on.CallEnded(() => {
285
+ clearTimer();
286
+ setIncoming(false);
287
+ setIncomingInfo(null);
288
+ setMuted(false);
289
+ setSpeakerOn(false);
290
+ onCallEndedRef.current?.();
291
+ });
292
+ return () => {
293
+ subReg.remove();
294
+ subIncoming.remove();
295
+ subState.remove();
296
+ subEnd.remove();
297
+ clearTimer();
298
+ };
299
+ }, [clearTimer, startDuration]);
300
+ // ── Registration ─────────────────────────────────────────────────────────
301
+ //
302
+ // Three entry points into the SIP registration flow:
303
+ //
304
+ // - Auto-register effect (below) — fires whenever `config` changes
305
+ // - register(cfg?) — host app forces a re-register
306
+ // - refreshRegistration() — light-touch refresh, skips full setup
307
+ // when the existing session looks healthy
308
+ // Internal helper — strips `https://` and trailing `/` from the domain
309
+ // (a common user-input mistake) before handing the params to native.
310
+ // Native is permissive about transport, but defaults to TCP if unset
311
+ // because most production PBXs allow TCP and it's NAT-friendlier than UDP.
312
+ const registerWith = useCallback(async (cfg) => {
313
+ const domain = formatTenantDomain(cfg.domain);
314
+ // Strip port before domain check (e.g. "charles.nativetalk.io:5060")
315
+ const domainHost = domain.split(':')[0];
316
+ if (!domainHost.endsWith('.nativetalk.io') && domainHost !== 'nativetalk.io') {
317
+ onErrorRef.current?.({
318
+ code: 'INVALID_DOMAIN',
319
+ message: `Domain "${domainHost}" is not a valid Nativetalk domain. Only *.nativetalk.io domains are supported.`,
320
+ });
321
+ return;
322
+ }
323
+ const transport = cfg.transport ?? 'tcp';
324
+ Native.register({
325
+ username: cfg.username,
326
+ password: cfg.password,
327
+ domain,
328
+ transport,
329
+ });
330
+ }, []);
331
+ // Auto-register whenever `config` changes. Identity comparison only —
332
+ // passing the same SIP config object on every render is fine, but passing
333
+ // `{...sip}` will re-register every time, so don't do that.
334
+ useEffect(() => {
335
+ if (!autoRegister)
336
+ return;
337
+ if (!config || !config.username)
338
+ return;
339
+ registerWith(config).catch((e) => onErrorRef.current?.({
340
+ code: 'REGISTER_FAILED',
341
+ message: e?.message ?? String(e),
342
+ }));
343
+ }, [config, autoRegister, registerWith]);
344
+ // ── Public actions returned from useCall() ───────────────────────────────
345
+ // Force a re-register. Use this when:
346
+ // - You disabled `autoRegister` and want to control timing yourself.
347
+ // - You want to switch accounts at runtime — pass the new config as arg.
348
+ // - The user toggled a "Reconnect" button after a network blip.
349
+ const register = useCallback(async (overrideCfg) => {
350
+ const next = overrideCfg ?? config ?? undefined;
351
+ if (!next || !next.username) {
352
+ onErrorRef.current?.({
353
+ code: 'NO_CONFIG',
354
+ message: 'No SIP config available — pass `config` to <CallProvider> or call register({...}).',
355
+ });
356
+ return;
357
+ }
358
+ await registerWith(next);
359
+ }, [config, registerWith]);
360
+ // Smart refresh — if registration was already in a working state, we use
361
+ // Linphone's lightweight `refreshRegisters()` (it just re-pings the
362
+ // server). If it was failed/cleared, we do a full re-register from
363
+ // scratch since the lightweight refresh won't recover from a 401 or DNS
364
+ // blackhole.
365
+ const refreshRegistration = useCallback(async () => {
366
+ const state = registration?.state;
367
+ if (!state || state === 'none' || state === 'cleared' || state === 'failed') {
368
+ if (config)
369
+ await registerWith(config);
370
+ return;
371
+ }
372
+ Native.refreshRegisters();
373
+ }, [registration, config, registerWith]);
374
+ const unregister = useCallback(async () => {
375
+ Native.setRegisterEnabled(false);
376
+ }, []);
377
+ // Place an outgoing call. Accepts either:
378
+ // - a plain extension/number ("100" or "+2348012345678") — combined with
379
+ // `config.domain` to form `sip:<dest>@<domain>`
380
+ // - a fully-qualified SIP URI ("sip:user@gateway.example.com") — passed
381
+ // through unchanged, useful when dialling external PSTN gateways
382
+ //
383
+ // We optimistically set status to OutgoingInit before the native call
384
+ // returns so the UI can render immediately — the real state will be
385
+ // confirmed by the next CallState event in ~50ms.
386
+ const dial = useCallback(async (destination) => {
387
+ if (!destination || destination.length < 1) {
388
+ onErrorRef.current?.({
389
+ code: 'INVALID_NUMBER',
390
+ message: 'Cannot dial an empty number.',
391
+ });
392
+ return;
393
+ }
394
+ if (!config?.domain) {
395
+ onErrorRef.current?.({
396
+ code: 'NO_CONFIG',
397
+ message: 'Cannot dial: no SIP domain configured.',
398
+ });
399
+ return;
400
+ }
401
+ resetDuration();
402
+ const uri = destinationToSipUri(destination, config.domain);
403
+ setCallStatus('OutgoingInit');
404
+ Native.call(uri);
405
+ onOutgoingCallRef.current?.({
406
+ phone: destination,
407
+ initials: initialsFrom(destination),
408
+ });
409
+ }, [config?.domain, resetDuration]);
410
+ const answer = useCallback(async () => {
411
+ setIncoming(false);
412
+ setCallStatus('Connected');
413
+ resetDuration();
414
+ Native.answer();
415
+ }, [resetDuration]);
416
+ // Hangup is debounced via the `ending` flag — Linphone takes ~500ms to
417
+ // settle, and tapping the end-call button twice in that window can put it
418
+ // into a weird state. The 600ms timer is empirical: long enough to swallow
419
+ // double-taps, short enough that the next call attempt isn't blocked.
420
+ const hangup = useCallback(async () => {
421
+ if (ending)
422
+ return;
423
+ setEnding(true);
424
+ try {
425
+ Native.end();
426
+ }
427
+ finally {
428
+ setTimeout(() => setEnding(false), 600);
429
+ }
430
+ }, [ending]);
431
+ // Decline an incoming call with a specific SIP response code. Default is
432
+ // "busy" (486) which is how voicemail systems usually decide to take a
433
+ // message; use "declined" if you don't want the caller routed to VM.
434
+ const decline = useCallback(async (reason = 'busy') => {
435
+ Native.decline(reason);
436
+ }, []);
437
+ // Toggle helpers use the functional setState form so concurrent taps don't
438
+ // race — without it, `next = !muted` could read stale state if React is
439
+ // batching renders.
440
+ const toggleMute = useCallback(() => {
441
+ setMuted((current) => {
442
+ const next = !current;
443
+ Native.mute(next);
444
+ return next;
445
+ });
446
+ }, []);
447
+ const toggleSpeaker = useCallback(() => {
448
+ setSpeakerOn((current) => {
449
+ const next = !current;
450
+ Native.speaker(next);
451
+ return next;
452
+ });
453
+ }, []);
454
+ // No optimistic update here — `held` is driven by HELD_STATES / RESUMED_STATES
455
+ // from the native CallState event, so we just kick off the action and let
456
+ // the event update React state.
457
+ const toggleHold = useCallback(() => {
458
+ if (held)
459
+ Native.resume();
460
+ else
461
+ Native.hold();
462
+ }, [held]);
463
+ // Send a DTMF (touch-tone) digit IN BAND on the active call — used for
464
+ // navigating IVRs ("Press 1 for support…"). Different from playKeyTone,
465
+ // which only plays a UI feedback tone locally.
466
+ const sendDtmf = useCallback((digit) => {
467
+ if (!digit)
468
+ return;
469
+ Native.sendDtmf(String(digit));
470
+ }, []);
471
+ // Local DTMF feedback tone — plays the standard touch-tone sound through
472
+ // the device speaker when the user taps a key on the dial-pad. Does NOT
473
+ // send anything over the SIP call; for that, use sendDtmf().
474
+ const playKeyTone = useCallback((digit) => {
475
+ if (!digit)
476
+ return;
477
+ Native.playKeyTone(String(digit));
478
+ }, []);
479
+ const getCallLogs = useCallback(async () => {
480
+ try {
481
+ return await Native.getCallLogs();
482
+ }
483
+ catch (e) {
484
+ onErrorRef.current?.({
485
+ code: 'GET_CALL_LOGS_FAILED',
486
+ message: e?.message ?? String(e),
487
+ });
488
+ return [];
489
+ }
490
+ }, []);
491
+ const getRegistrationStatus = useCallback(async () => {
492
+ const raw = await Native.getRegistrationStatus();
493
+ const pretty = regStateName(raw?.state);
494
+ return { ...raw, state: pretty, pretty };
495
+ }, []);
496
+ const startServices = useCallback(() => Native.startNativeServices(), []);
497
+ const stopServices = useCallback((logout = false) => Native.stopNativeServices(logout), []);
498
+ const formattedDuration = useMemo(() => formatDuration(durationSec), [durationSec]);
499
+ const value = useMemo(() => ({
500
+ config,
501
+ registration,
502
+ callStatus,
503
+ durationSec,
504
+ formattedDuration,
505
+ incoming,
506
+ incomingInfo,
507
+ isMuted: muted,
508
+ isSpeaker: speakerOn,
509
+ isHeld: held,
510
+ dial,
511
+ answer,
512
+ hangup,
513
+ decline,
514
+ toggleMute,
515
+ toggleSpeaker,
516
+ toggleHold,
517
+ sendDtmf,
518
+ playKeyTone,
519
+ register,
520
+ refreshRegistration,
521
+ unregister,
522
+ getCallLogs,
523
+ getRegistrationStatus,
524
+ startNativeServices: startServices,
525
+ stopNativeServices: stopServices,
526
+ }), [
527
+ config,
528
+ registration,
529
+ callStatus,
530
+ durationSec,
531
+ formattedDuration,
532
+ incoming,
533
+ incomingInfo,
534
+ muted,
535
+ speakerOn,
536
+ held,
537
+ dial,
538
+ answer,
539
+ hangup,
540
+ decline,
541
+ toggleMute,
542
+ toggleSpeaker,
543
+ toggleHold,
544
+ sendDtmf,
545
+ playKeyTone,
546
+ register,
547
+ refreshRegistration,
548
+ unregister,
549
+ getCallLogs,
550
+ getRegistrationStatus,
551
+ startServices,
552
+ stopServices,
553
+ ]);
554
+ return <CallContext.Provider value={value}>{children}</CallContext.Provider>;
555
+ }
556
+ /**
557
+ * Access the call API from any descendant of `<CallProvider>`.
558
+ *
559
+ * Throws if used outside the provider.
560
+ */
561
+ export function useCall() {
562
+ const ctx = useContext(CallContext);
563
+ if (!ctx) {
564
+ throw new Error('`useCall()` must be used inside `<CallProvider>`. Mount the provider near the root of your app tree.');
565
+ }
566
+ return ctx;
567
+ }
568
+ /** Internal — exported for power users who want to read the context without throwing. */
569
+ export function useCallSafe() {
570
+ return useContext(CallContext);
571
+ }
572
+ /** Re-export to allow `formatDuration`, `callStatusLabel`, etc. to be imported from the main entry too. */
573
+ export { callStatusLabel, formatDuration };