@voipsdk/react-native-voipcloud 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ All notable changes to `@voipsdk/react-native-voipcloud` are documented here.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.0.3] - 2026-07-17
9
+
10
+ Folds in the NAT / media / call-routing fixes that were living as app-side
11
+ `patch-package` patches, so a fresh install of the SDK behaves correctly
12
+ without any patches.
13
+
14
+ ### Added
15
+
16
+ - `AccountConfig.proxy` — outbound proxy URI (e.g. `sip:proxy.example.com:5060;lr`).
17
+ Routes every SIP request, including in-dialog re-INVITE / BYE, through the proxy
18
+ so in-dialog SIP survives NAT (a NAT'd client no longer sends its BYE / unhold
19
+ re-INVITE straight to the peer's private address). Wired on both iOS and Android.
20
+ - `AccountConfig.srtp` — `'disabled' | 'optional' | 'mandatory'` media policy,
21
+ defaulting to `'disabled'` so a plain-UDP trunk never offers `a=crypto`
22
+ (an optional-SRTP offer breaks media on some PBX / rtpengine setups → one-way audio).
23
+
24
+ ### Changed / Fixed
25
+
26
+ - **iOS:** transports are now pinned per account (REGISTER + INVITE + in-dialog
27
+ re-INVITE / BYE share one socket/port for a stable NAT binding across hold/unhold);
28
+ media-only STUN (`mediaStunUse = RETRY_ON_FAILURE`, SIP STUN disabled) so the SDP
29
+ offer carries a routable address; endpoint codec allow-list pinned to PCMU/PCMA
30
+ (+G722 fallback) to stop the proxy rewriting the `m=` line; session timers disabled
31
+ on-device; hold / unhold / makeCall forced audio-only (`audioCount=1, videoCount=0`).
32
+ - **iOS podspec:** `HEADER_SEARCH_PATHS` points at each slice's `Headers` root instead
33
+ of a recursive `/**` glob, so `pj/compat/` no longer shadows the C stdlib.
34
+ - **Android:** accounts pinned to a single transport (`transportIdForRegistrar`);
35
+ `contactUseSrcPort` kept in the rewritten Contact; RFC 4028 session timers as a
36
+ safety net for a lost in-dialog BYE; unhold re-INVITE forces every audio stream back
37
+ to `sendrecv` so the PBX stops streaming MOH after resume.
38
+ - **Android:** native call-end hook (`VoipCallEndNotifier.onCallEnded`) clears the
39
+ incoming notification / ringtone even when JS is gone (headless FCM); `sipCallId`
40
+ now forwarded to the native incoming-call dispatch.
41
+ - **Android:** `minSdkVersion` lowered from 31 to 24.
42
+
8
43
  ## [1.1.0] - 2026-07-06
9
44
 
10
45
  ### Added
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 VoipCloud
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @voipsdk/react-native-voipcloud
2
2
 
3
- > Public PJSIP VoIP SDK for React Native — Android **and** iOS (RN 0.70+).
4
- > Package: `@voipsdk/react-native-voipcloud@1.1.0`
3
+ > Public VoIP SDK for React Native — Android **and** iOS (RN 0.70+).
4
+ > Package: `@voipsdk/react-native-voipcloud@1.0.3`
5
5
 
6
6
  Android and iOS expose the **same** JS API, events and `VC_*` error codes.
7
7
 
@@ -41,7 +41,7 @@ packages.add(new com.voipcloud.sdk.rn.VoipCloudPackage());
41
41
 
42
42
  ### iOS
43
43
 
44
- Autolinking installs the pod (which vendors the prebuilt `VoipCloud.xcframework` — PJSIP + OpenSSL, self-contained). Just run:
44
+ Autolinking installs the pod (which vendors the prebuilt `VoipCloud.xcframework` — native SIP engine, self-contained). Just run:
45
45
 
46
46
  ```bash
47
47
  cd ios && pod install
@@ -79,7 +79,7 @@ Request at runtime: `RECORD_AUDIO`, `POST_NOTIFICATIONS` (Android 13+), `BLUETOO
79
79
  ```ts
80
80
  import { VoipCloud } from '@voipsdk/react-native-voipcloud';
81
81
 
82
- // 1. Start PJSIP endpoint (required before any SIP call).
82
+ // 1. Start the VoIP endpoint (required before any SIP call).
83
83
  await VoipCloud.init({ userAgent: 'my-app/1.0' });
84
84
 
85
85
  // 2. Register a SIP account → returns numeric accountId.
@@ -142,7 +142,7 @@ export function useVoipEvents() {
142
142
 
143
143
  | Method | Description |
144
144
  |--------|-------------|
145
- | `init(config?)` | Create and start PJSIP endpoint |
145
+ | `init(config?)` | Create and start the VoIP endpoint |
146
146
  | `shutdown()` | Hang up all, destroy endpoint |
147
147
  | `register(account)` | Register SIP account → `accountId` |
148
148
  | `unregister(accountId)` | Unregister account |
@@ -163,7 +163,7 @@ export function useVoipEvents() {
163
163
  |-------|------|-------------|
164
164
  | `userAgent` | `string` | SIP User-Agent header |
165
165
  | `stunServer` | `string` | STUN server URI |
166
- | `logLevel` | `number` | PJSIP log level (0–6) |
166
+ | `logLevel` | `number` | SIP log level (0–6) |
167
167
 
168
168
  ### `AccountConfig`
169
169
 
@@ -196,7 +196,7 @@ export function useVoipEvents() {
196
196
 
197
197
  ## Error handling
198
198
 
199
- Every method returns a `Promise`. Failures reject with `error.code` (string). The bridge **never crashes** the host app for API misuse or recoverable PJSIP errors.
199
+ Every method returns a `Promise`. Failures reject with `error.code` (string). The bridge **never crashes** the host app for API misuse or recoverable SIP errors.
200
200
 
201
201
  ```ts
202
202
  try {
@@ -239,13 +239,12 @@ try {
239
239
 
240
240
  | Artifact | Name |
241
241
  |----------|------|
242
- | Prebuilt framework | `VoipCloud.xcframework` — PJSIP + OpenSSL, static, self-contained |
243
- | PJSUA2 wrapper | `VoipCloudEngine` (Objective-C++) |
242
+ | Prebuilt framework | `VoipCloud.xcframework` — native SIP engine, static, self-contained |
243
+ | Native SIP wrapper | `VoipCloudEngine` (Objective-C++) |
244
244
  | RN bridge module | `VoipsdkReactNativeVoipcloud` (`RCTEventEmitter`, JS: `VoipCloud`) |
245
245
 
246
- The iOS framework is rebuilt from `vendor/pjproject` + `vendor/openssl` via
247
- `scripts/build-openssl.sh` → `build-pjproject.sh {device,simulator-x86_64,simulator-arm64}`
248
- → `build-xcframework.sh` (the last step also copies it into `ios/`).
246
+ Both platforms ship their native engine prebuilt and self-contained — no extra
247
+ native toolchain or SIP stack is required in the host app.
249
248
 
250
249
  ---
251
250
 
@@ -265,7 +264,7 @@ android { packagingOptions { pickFirst '**/libc++_shared.so' } }
265
264
  ```
266
265
 
267
266
  **Registration fails (`VC_REGISTER`)**
268
- Check `registrar`, credentials, and network. TLS requires a valid server certificate or appropriate PJSIP TLS settings on the PBX side.
267
+ Check `registrar`, credentials, and network. TLS requires a valid server certificate or appropriate TLS settings on the PBX side.
269
268
 
270
269
  **No audio**
271
270
  Android: grant `RECORD_AUDIO` at runtime before placing a call.
@@ -280,4 +279,4 @@ and links `libc++` + the required system frameworks.
280
279
 
281
280
  ## License
282
281
 
283
- UNLICENSED — see package metadata. Contact the author for commercial terms.
282
+ [MIT](./LICENSE) © VoipCloud. Free for personal **and** commercial use.
@@ -36,7 +36,7 @@ android {
36
36
  compileSdkVersion safeExtGet('compileSdkVersion', 34)
37
37
 
38
38
  defaultConfig {
39
- minSdkVersion safeExtGet('minSdkVersion', 31)
39
+ minSdkVersion safeExtGet('minSdkVersion', 24)
40
40
  targetSdkVersion safeExtGet('targetSdkVersion', 34)
41
41
 
42
42
  ndk {
@@ -10,17 +10,26 @@ import com.facebook.react.bridge.ReactMethod;
10
10
  import com.facebook.react.bridge.ReadableMap;
11
11
  import com.facebook.react.bridge.WritableMap;
12
12
 
13
+ import android.util.Log;
14
+
13
15
  import com.voipcloud.sdk.AccountConfig;
14
16
  import com.voipcloud.sdk.AuthCredInfo;
17
+ import com.voipcloud.sdk.CallInfo;
18
+ import com.voipcloud.sdk.CallMediaInfo;
15
19
  import com.voipcloud.sdk.CallOpParam;
20
+ import com.voipcloud.sdk.CallSetting;
16
21
  import com.voipcloud.sdk.CallSendDtmfParam;
17
22
  import com.voipcloud.sdk.Endpoint;
18
23
  import com.voipcloud.sdk.EpConfig;
24
+ import com.voipcloud.sdk.IntVector;
19
25
  import com.voipcloud.sdk.TransportConfig;
26
+ import com.voipcloud.sdk.pjmedia_dir;
27
+ import com.voipcloud.sdk.pjmedia_type;
20
28
  import com.voipcloud.sdk.pjsip_status_code;
21
29
  import com.voipcloud.sdk.pjsip_transport_type_e;
22
30
  import com.voipcloud.sdk.pjsua_call_flag;
23
31
  import com.voipcloud.sdk.pjsua_destroy_flag;
32
+ import com.voipcloud.sdk.pjsua_sip_timer_use;
24
33
  import com.voipcloud.sdk.AudDevManager;
25
34
 
26
35
  import com.voipcloud.sdk.rn.internal.BridgeGuard;
@@ -44,6 +53,14 @@ public class VoipCloudModule extends ReactContextBaseJavaModule {
44
53
 
45
54
  private Endpoint endpoint;
46
55
  private boolean inited = false;
56
+ /**
57
+ * Transport IDs captured at init() so accounts can be PINNED to one
58
+ * transport (see register()). Both UDP and TCP transports are created; an
59
+ * account bound to neither lets pjsip auto-pick a transport per request,
60
+ * which can switch mid-dialog and break in-dialog routing.
61
+ */
62
+ private int udpTransportId = -1;
63
+ private int tcpTransportId = -1;
47
64
 
48
65
  public VoipCloudModule(ReactApplicationContext ctx) {
49
66
  super(ctx);
@@ -95,6 +112,8 @@ public class VoipCloudModule extends ReactContextBaseJavaModule {
95
112
  } catch (Throwable ignored) {}
96
113
  endpoint = null;
97
114
  inited = false;
115
+ udpTransportId = -1;
116
+ tcpTransportId = -1;
98
117
  }
99
118
 
100
119
  @ReactMethod
@@ -143,10 +162,15 @@ public class VoipCloudModule extends ReactContextBaseJavaModule {
143
162
 
144
163
  ep.libInit(epCfg);
145
164
 
165
+ // Capture the transport IDs so register() can pin each account to
166
+ // exactly ONE transport. Without a binding pjsip is free to choose
167
+ // a different transport for a mid-dialog re-INVITE/UPDATE than the
168
+ // one that established the dialog — the source ip:port then changes
169
+ // and in-dialog routing breaks (see register()).
146
170
  TransportConfig udp = new TransportConfig();
147
- ep.transportCreate(pjsip_transport_type_e.PJSIP_TRANSPORT_UDP, udp);
171
+ udpTransportId = ep.transportCreate(pjsip_transport_type_e.PJSIP_TRANSPORT_UDP, udp);
148
172
  TransportConfig tcp = new TransportConfig();
149
- ep.transportCreate(pjsip_transport_type_e.PJSIP_TRANSPORT_TCP, tcp);
173
+ tcpTransportId = ep.transportCreate(pjsip_transport_type_e.PJSIP_TRANSPORT_TCP, tcp);
150
174
 
151
175
  ep.libStart();
152
176
  endpoint = ep;
@@ -205,11 +229,50 @@ public class VoipCloudModule extends ReactContextBaseJavaModule {
205
229
  acfg.getNatConfig().setContactRewriteUse(1);
206
230
  acfg.getNatConfig().setViaRewriteUse(1);
207
231
  acfg.getNatConfig().setUdpKaIntervalSec(20);
232
+ // Keep the learned NAT source PORT in the rewritten Contact (not just
233
+ // the source IP). The remote target our peer routes in-dialog requests
234
+ // to must match the exact ip:port our transport sends from, or replies
235
+ // to our re-INVITE stall and the peer's BYE misses the NAT pinhole.
236
+ acfg.getNatConfig().setContactUseSrcPort(1);
208
237
  // Short re-REGISTER interval keeps the binding fresh behind NAT.
209
238
  acfg.getRegConfig().setTimeoutSec(120);
210
239
  acfg.getRegConfig().setFirstRetryIntervalSec(5);
211
240
  acfg.getRegConfig().setRetryIntervalSec(30);
212
241
 
242
+ // RFC 4028 session timers — safety net for a LOST in-dialog BYE.
243
+ // Behind NAT (kamailio+asterisk, UDP) the remote party's BYE is an
244
+ // inbound in-dialog request; if it never reaches the device the
245
+ // call would otherwise stay "up" forever (remote hangs up, our
246
+ // side never disconnects). With session timers the dialog must be
247
+ // refreshed (re-INVITE/UPDATE) every session-expires seconds — when
248
+ // the peer is gone the refresh gets no answer (or none arrives) and
249
+ // PJSIP tears the call down locally, firing onCallState=DISCONNECTED
250
+ // so the JS/UI ends the call. ALWAYS advertises "timer" in Supported
251
+ // on every INVITE (graceful: peers without RFC 4028 just ignore it;
252
+ // Asterisk negotiates it). Min-SE 90 is the RFC floor; 120s expiry
253
+ // bounds the worst-case stuck-call window (~60s typical — refresh at
254
+ // half). This does NOT fix the root cause (see kamailio record-route
255
+ // NAT config); it guarantees the call cannot hang indefinitely.
256
+ acfg.getCallConfig().setTimerUse(pjsua_sip_timer_use.PJSUA_SIP_TIMER_ALWAYS);
257
+ acfg.getCallConfig().setTimerMinSESec(90);
258
+ acfg.getCallConfig().setTimerSessExpiresSec(120);
259
+
260
+ // PIN the account to a single transport so EVERY request in the call
261
+ // — the initial INVITE, the hold/unhold re-INVITE and any session-timer
262
+ // UPDATE, and the BYE — originates from the SAME local socket/port.
263
+ // Both UDP and TCP transports exist; if the account is bound to neither
264
+ // (transportId = -1 / auto), pjsip may pick a different transport for a
265
+ // mid-dialog re-INVITE than the one that set up the dialog (observed:
266
+ // hold over TCP, unhold over UDP). The source ip:port then changes,
267
+ // kamailio/asterisk can no longer match the request to the dialog, the
268
+ // re-INVITE hangs at 100 Trying, and the call effectively forks into a
269
+ // second dialog — so hold/unhold and hangup act on the wrong one.
270
+ // Binding to one transport keeps the SIP 5-tuple stable for the whole call.
271
+ int boundTransport = transportIdForRegistrar(registrar);
272
+ if (boundTransport >= 0) {
273
+ acfg.getSipConfig().setTransportId(boundTransport);
274
+ }
275
+
213
276
  AuthCredInfo cred = new AuthCredInfo("digest", "*", username, 0, password);
214
277
  acfg.getSipConfig().getAuthCreds().add(cred);
215
278
 
@@ -374,7 +437,38 @@ public class VoipCloudModule extends ReactContextBaseJavaModule {
374
437
  ManagedCall call = requireCall(callId, promise);
375
438
  if (call == null) return;
376
439
  CallOpParam prm = new CallOpParam(true);
377
- prm.getOpt().setFlag(pjsua_call_flag.PJSUA_CALL_UNHOLD);
440
+ // PJSUA_CALL_UNHOLD alone regenerates the offer, but on this build
441
+ // the audio m-line can stay a=sendonly, so the remote PBX never sees
442
+ // the resume and keeps streaming MOH to the caller. Explicitly force
443
+ // every audio stream back to sendrecv (PJMEDIA_DIR_ENCODING_DECODING)
444
+ // via PJSUA_CALL_SET_MEDIA_DIR so the re-INVITE offer is unambiguous.
445
+ // Build the CallSetting explicitly and attach it with setOpt() —
446
+ // getOpt() can return a detached copy whose mutations are dropped.
447
+ CallSetting opt = new CallSetting(true);
448
+ long flags = pjsua_call_flag.PJSUA_CALL_UNHOLD;
449
+ int mediaCount = 0;
450
+ try {
451
+ IntVector dirs = new IntVector();
452
+ CallInfo info = call.getInfo();
453
+ for (int i = 0; i < info.getMedia().size(); i++) {
454
+ CallMediaInfo mi = info.getMedia().get(i);
455
+ dirs.add(mi.getType() == pjmedia_type.PJMEDIA_TYPE_AUDIO
456
+ ? pjmedia_dir.PJMEDIA_DIR_ENCODING_DECODING
457
+ : pjmedia_dir.PJMEDIA_DIR_NONE);
458
+ }
459
+ if (dirs.isEmpty()) {
460
+ dirs.add(pjmedia_dir.PJMEDIA_DIR_ENCODING_DECODING);
461
+ }
462
+ mediaCount = dirs.size();
463
+ opt.setMediaDir(dirs);
464
+ flags |= pjsua_call_flag.PJSUA_CALL_SET_MEDIA_DIR;
465
+ } catch (Throwable ignored) {
466
+ // Fall back to a plain UNHOLD re-INVITE if media enumeration fails.
467
+ }
468
+ opt.setFlag(flags);
469
+ prm.setOpt(opt);
470
+ Log.i("VoipCloudUnhold", "unhold callId=" + callId
471
+ + " flags=" + flags + " forcedSendrecvOn=" + mediaCount + " media");
378
472
  call.reinvite(prm);
379
473
  // See hold(): audio is rewired by onCallMediaState when the
380
474
  // unhold re-INVITE completes and media becomes ACTIVE again.
@@ -513,6 +607,22 @@ public class VoipCloudModule extends ReactContextBaseJavaModule {
513
607
  }
514
608
  }
515
609
 
610
+ /**
611
+ * Choose the transport to bind an account to, based on the {@code transport=}
612
+ * param in its registrar URI (built by mapAccountToVoipCloud on the JS side).
613
+ * Defaults to UDP — the app's standard transport, whose 20s keep-alive holds
614
+ * the NAT pinhole open for the whole call. TCP/TLS accounts bind to the TCP
615
+ * transport. Returns -1 (auto) only if the matching transport was never
616
+ * created, leaving pjsip's default behaviour untouched as a safe fallback.
617
+ */
618
+ private int transportIdForRegistrar(String registrar) {
619
+ String t = registrar == null ? "" : registrar.toLowerCase();
620
+ if (t.contains("transport=tcp") || t.contains("transport=tls")) {
621
+ return tcpTransportId;
622
+ }
623
+ return udpTransportId;
624
+ }
625
+
516
626
  private ManagedCall requireCall(int callId, Promise promise) {
517
627
  ManagedCall call = calls.get(callId);
518
628
  if (call == null) {
@@ -60,19 +60,23 @@ public final class ManagedAccount extends Account {
60
60
 
61
61
  String from = "";
62
62
  String to = "";
63
+ // SIP Call-ID header — stable across systems (PBX CDR, CRM sync, and the
64
+ // FCM push `callId`), unlike the numeric PJSUA call slot which is
65
+ // recycled. Captured here so the native dispatch below can map it to the
66
+ // PJSUA slot (answer the correct call from a push).
67
+ String sipCallId = "";
63
68
  try {
64
69
  AccountInfo accInfo = getInfo();
65
70
  com.voipcloud.sdk.CallInfo callInfo = call.getInfo();
66
71
  from = callInfo.getRemoteUri();
67
72
  to = accInfo.getUri();
73
+ sipCallId = callInfo.getCallIdString();
68
74
  WritableMap payload = Arguments.createMap();
69
75
  payload.putInt("accountId", accountId());
70
76
  payload.putInt("callId", call.callId());
71
77
  payload.putString("from", from);
72
78
  payload.putString("to", to);
73
- // SIP Call-ID header — stable across systems (PBX CDR, CRM sync),
74
- // unlike the numeric PJSUA call slot which is recycled.
75
- payload.putString("sipCallId", callInfo.getCallIdString());
79
+ payload.putString("sipCallId", sipCallId);
76
80
  Events.emit(ctx, Events.INCOMING_CALL, payload);
77
81
  } catch (Exception ignored) {
78
82
  WritableMap payload = Arguments.createMap();
@@ -80,12 +84,12 @@ public final class ManagedAccount extends Account {
80
84
  payload.putInt("callId", call.callId());
81
85
  Events.emit(ctx, Events.INCOMING_CALL, payload);
82
86
  }
83
- dispatchIncomingToApp(ctx, accountId(), call.callId(), from, to);
87
+ dispatchIncomingToApp(ctx, accountId(), call.callId(), from, to, sipCallId);
84
88
  }
85
89
 
86
90
  /** Notify VoicePBX native layer before JS (background incoming calls). */
87
91
  private static void dispatchIncomingToApp(
88
- ReactContext ctx, int accountId, int callId, String from, String to) {
92
+ ReactContext ctx, int accountId, int callId, String from, String to, String sipCallId) {
89
93
  try {
90
94
  Class<?> cls = Class.forName("com.voicepbx.voip.VoipIncomingNotifier");
91
95
  cls.getMethod(
@@ -94,6 +98,7 @@ public final class ManagedAccount extends Account {
94
98
  int.class,
95
99
  int.class,
96
100
  String.class,
101
+ String.class,
97
102
  String.class)
98
103
  .invoke(
99
104
  null,
@@ -101,7 +106,8 @@ public final class ManagedAccount extends Account {
101
106
  accountId,
102
107
  callId,
103
108
  from != null ? from : "",
104
- to != null ? to : "");
109
+ to != null ? to : "",
110
+ sipCallId != null ? sipCallId : "");
105
111
  } catch (Throwable ignored) {}
106
112
  }
107
113
  }
@@ -132,6 +132,12 @@ public final class ManagedCall extends Call {
132
132
  p.putInt("code", 0);
133
133
  p.putString("reason", "");
134
134
  Events.emit(ctx, Events.CALL_STATE, p);
135
+ // Native call-end hook: clear the incoming notification / ringing
136
+ // even when JS (React Native) is gone — a headless FCM task may have
137
+ // torn down its RN instance while PJSIP stays alive to receive the
138
+ // CANCEL/BYE. Without this a cancelled call keeps ringing on next
139
+ // app open.
140
+ if (cachedId >= 0) dispatchCallEndToApp(ctx, cachedId, "");
135
141
  if (cachedId >= 0) registry.remove(cachedId);
136
142
  return;
137
143
  }
@@ -142,15 +148,41 @@ public final class ManagedCall extends Call {
142
148
  p.putString("reason", info.getLastReason());
143
149
  // SIP Call-ID header — lets JS attach the dialog id to outgoing
144
150
  // calls too (makeCall only returns the recycled PJSUA slot).
145
- try { p.putString("sipCallId", info.getCallIdString()); } catch (Exception ignored) {}
151
+ String sipCallId = "";
152
+ try { sipCallId = info.getCallIdString(); } catch (Exception ignored) {}
153
+ p.putString("sipCallId", sipCallId);
146
154
  Events.emit(ctx, Events.CALL_STATE, p);
147
155
  int state = info.getState();
148
156
  if (state == pjsip_inv_state.PJSIP_INV_STATE_DISCONNECTED
149
157
  || state == pjsip_inv_state.PJSIP_INV_STATE_NULL) {
158
+ dispatchCallEndToApp(ctx, callId(), sipCallId);
150
159
  registry.remove(callId());
151
160
  }
152
161
  }
153
162
 
163
+ /**
164
+ * Notify the VoicePBX native layer that a call ended (background hangup /
165
+ * remote cancel), so it can cancel the incoming notification, stop the
166
+ * ringtone and clear the pending open-call id even with no live JS. Mirror
167
+ * of ManagedAccount.dispatchIncomingToApp. Best-effort via reflection so
168
+ * the SDK has no compile dependency on the app package.
169
+ */
170
+ private static void dispatchCallEndToApp(ReactContext ctx, int callId, String sipCallId) {
171
+ try {
172
+ Class<?> cls = Class.forName("com.voicepbx.voip.VoipCallEndNotifier");
173
+ cls.getMethod(
174
+ "onCallEnded",
175
+ android.content.Context.class,
176
+ int.class,
177
+ String.class)
178
+ .invoke(
179
+ null,
180
+ ctx.getApplicationContext(),
181
+ callId,
182
+ sipCallId != null ? sipCallId : "");
183
+ } catch (Throwable ignored) {}
184
+ }
185
+
154
186
  @Override
155
187
  public void onCallMediaState(OnCallMediaStateParam prm) {
156
188
  connectAudioMedia();
@@ -8,6 +8,8 @@
8
8
  #include <pjsua-lib/pjsua.h>
9
9
  #include <string>
10
10
  #include <map>
11
+ #include <algorithm>
12
+ #include <cctype>
11
13
 
12
14
  using namespace pj;
13
15
 
@@ -270,6 +272,12 @@ void VCCall::onDtmfDigit(OnDtmfDigitParam &prm) {
270
272
  VCLogWriter *_logWriter;
271
273
  std::map<int, VCAccount*> _accounts;
272
274
  std::map<int, VCCall*> _calls;
275
+ // Transport ids returned by transportCreate, so an account can be pinned to
276
+ // the SAME socket/port for REGISTER + INVITE + in-dialog re-INVITE/BYE
277
+ // (stable NAT binding across hold/unhold). -1 = that transport not created.
278
+ int _udpTransportId;
279
+ int _tcpTransportId;
280
+ int _tlsTransportId;
273
281
  }
274
282
 
275
283
  + (instancetype)shared {
@@ -315,17 +323,42 @@ void VCCall::onDtmfDigit(OnDtmfDigitParam &prm) {
315
323
  _ep->libInit(epCfg);
316
324
 
317
325
  // Ephemeral ports (0) avoid "Address already in use" for SIP clients.
318
- { TransportConfig t; t.port = 0; _ep->transportCreate(PJSIP_TRANSPORT_UDP, t); }
319
- { TransportConfig t; t.port = 0; _ep->transportCreate(PJSIP_TRANSPORT_TCP, t); }
326
+ // Keep each transport id so registerAccountWithConfig can pin the
327
+ // account to the matching one (0 is a valid id → init to -1).
328
+ _udpTransportId = _tcpTransportId = _tlsTransportId = -1;
329
+ { TransportConfig t; t.port = 0; _udpTransportId = _ep->transportCreate(PJSIP_TRANSPORT_UDP, t); }
330
+ { TransportConfig t; t.port = 0; _tcpTransportId = _ep->transportCreate(PJSIP_TRANSPORT_TCP, t); }
320
331
  {
321
332
  TransportConfig t; t.port = 0;
322
333
  t.tlsConfig.verifyServer = false;
323
334
  t.tlsConfig.verifyClient = false;
324
335
  t.tlsConfig.method = PJSIP_TLSV1_2_METHOD;
325
- _ep->transportCreate(PJSIP_TRANSPORT_TLS, t);
336
+ _tlsTransportId = _ep->transportCreate(PJSIP_TRANSPORT_TLS, t);
326
337
  }
327
338
 
328
339
  _ep->libStart();
340
+
341
+ // Codec allow-list. The PBX/proxy speaks G.711 (+G722 fallback); PJSIP's
342
+ // full default set (speex 8k/16k/32k, iLBC, …) makes the proxy rewrite
343
+ // the m= line and shift dynamic payload-type numbers → wrong codec map →
344
+ // broken audio. Priority 0 = codec not offered at all. Order: PCMU/PCMA
345
+ // first, G722 as fallback, so the far end answers G.711. Codec priority
346
+ // is endpoint-global in PJSIP, so it is configured once here.
347
+ try {
348
+ CodecInfoVector2 codecs = _ep->codecEnum2();
349
+ for (size_t i = 0; i < codecs.size(); i++) {
350
+ std::string cid = codecs[i].codecId; // e.g. "PCMU/8000/1"
351
+ std::string fam = cid.substr(0, cid.find('/'));
352
+ std::transform(fam.begin(), fam.end(), fam.begin(),
353
+ [](unsigned char c){ return (char)std::toupper(c); });
354
+ pj_uint8_t prio = 0;
355
+ if (fam == "PCMU") prio = 255;
356
+ else if (fam == "PCMA") prio = 254;
357
+ else if (fam == "G722") prio = 200;
358
+ _ep->codecSetPriority(codecs[i].codecId, prio);
359
+ }
360
+ } catch (...) {}
361
+
329
362
  [self configureAudioSessionCategory];
330
363
  _isStarted = YES;
331
364
  return YES;
@@ -372,6 +405,15 @@ void VCCall::onDtmfDigit(OnDtmfDigitParam &prm) {
372
405
  acfg.idUri = std::string(uri.UTF8String);
373
406
  acfg.regConfig.registrarUri = std::string(registrar.UTF8String);
374
407
 
408
+ // Outbound proxy (Kamailio). Route EVERY SIP request — including
409
+ // in-dialog re-INVITE / BYE — through the proxy so a NAT'd client's
410
+ // teardown/unhold doesn't go straight to the peer's private address and
411
+ // get lost (peer stuck on MOH after unhold, call never ends).
412
+ NSString *proxy = cfg[@"proxy"];
413
+ if ([proxy isKindOfClass:[NSString class]] && proxy.length) {
414
+ acfg.sipConfig.proxies.push_back(std::string(proxy.UTF8String));
415
+ }
416
+
375
417
  // NAT survival — keep the registration binding alive behind NAT so
376
418
  // inbound INVITEs still reach the device (matches Android).
377
419
  acfg.natConfig.contactRewriteUse = 1;
@@ -381,11 +423,43 @@ void VCCall::onDtmfDigit(OnDtmfDigitParam &prm) {
381
423
  acfg.regConfig.firstRetryIntervalSec = 5;
382
424
  acfg.regConfig.retryIntervalSec = 30;
383
425
 
426
+ // STUN for MEDIA only: without it the SDP offer carries the LAN IP
427
+ // (c=192.168.x) and the far end sends RTP to an unroutable address →
428
+ // no 2-way audio. Falls back to the host addr if STUN is unreachable.
429
+ // SIP keeps rport/contactRewrite — do NOT STUN it (would disturb
430
+ // REGISTER). STUN server itself is set at endpoint level (stunServer).
431
+ acfg.natConfig.mediaStunUse = PJSUA_STUN_RETRY_ON_FAILURE;
432
+ acfg.natConfig.sipStunUse = PJSUA_STUN_USE_DISABLED;
433
+
384
434
  AuthCredInfo cred("digest", "*", std::string(username.UTF8String), 0,
385
435
  std::string(password.UTF8String));
386
436
  acfg.sipConfig.authCreds.push_back(cred);
387
437
 
388
- acfg.mediaConfig.srtpUse = PJMEDIA_SRTP_OPTIONAL;
438
+ // Pin REGISTER + INVITE + in-dialog re-INVITE/BYE to the SAME transport
439
+ // (socket/port) so the NAT binding is stable across hold/unhold. Without
440
+ // this PJSIP may auto-pick a different transport per request. Falls back
441
+ // to auto-select if the requested transport failed to create (-1).
442
+ NSString *transport = cfg[@"transport"];
443
+ int pinnedTransportId = -1;
444
+ if ([transport isEqualToString:@"TLS"]) pinnedTransportId = _tlsTransportId;
445
+ else if ([transport isEqualToString:@"TCP"]) pinnedTransportId = _tcpTransportId;
446
+ else if ([transport isEqualToString:@"UDP"]) pinnedTransportId = _udpTransportId;
447
+ if (pinnedTransportId >= 0) acfg.sipConfig.transportId = pinnedTransportId;
448
+
449
+ // No session timers on the device — the VoIP server owns session
450
+ // refresh. With them on, the device (refresher) proposes Session-Expires
451
+ // and periodically sends a chatty UPDATE after ACK.
452
+ acfg.callConfig.timerUse = PJSUA_SIP_TIMER_INACTIVE;
453
+
454
+ // SRTP is opt-in per account. A plain-UDP trunk with srtp='disabled'
455
+ // must NOT offer a=crypto (an optional-SRTP offer breaks media on some
456
+ // PBX/rtpengine setups → one-way / no audio on outgoing). Default
457
+ // DISABLED when the account doesn't ask for it.
458
+ NSString *srtp = cfg[@"srtp"];
459
+ pjmedia_srtp_use srtpUse = PJMEDIA_SRTP_DISABLED;
460
+ if ([srtp isEqualToString:@"mandatory"]) srtpUse = PJMEDIA_SRTP_MANDATORY;
461
+ else if ([srtp isEqualToString:@"optional"]) srtpUse = PJMEDIA_SRTP_OPTIONAL;
462
+ acfg.mediaConfig.srtpUse = srtpUse;
389
463
  acfg.mediaConfig.srtpSecureSignaling = 0;
390
464
 
391
465
  VCAccount *acc = new VCAccount();
@@ -425,6 +499,8 @@ void VCCall::onDtmfDigit(OnDtmfDigitParam &prm) {
425
499
  VCCall *call = new VCCall(*it->second);
426
500
  call->owner = self;
427
501
  CallOpParam prm(true);
502
+ prm.opt.audioCount = 1;
503
+ prm.opt.videoCount = 0; // audio-only: no stray video m-line in the offer
428
504
  call->makeCall(std::string(uri.UTF8String), prm);
429
505
  int cid = call->getId();
430
506
  call->cachedId = cid;
@@ -470,7 +546,13 @@ void VCCall::onDtmfDigit(OnDtmfDigitParam &prm) {
470
546
  - (BOOL)holdCall:(int)callId error:(NSError **)error {
471
547
  VCCall *call = [self callForId:callId];
472
548
  if (!call) { if (error) *error = VCMakeError(-1, [NSString stringWithFormat:@"Unknown callId %d", callId]); return NO; }
473
- try { CallOpParam prm(true); call->setHold(prm); return YES; }
549
+ try {
550
+ CallOpParam prm(true);
551
+ prm.opt.audioCount = 1;
552
+ prm.opt.videoCount = 0; // audio-only re-INVITE
553
+ call->setHold(prm);
554
+ return YES;
555
+ }
474
556
  catch (Error &err) { if (error) *error = VCMakeError(err.status, VCStr(err.info())); return NO; }
475
557
  }
476
558
 
@@ -480,6 +562,8 @@ void VCCall::onDtmfDigit(OnDtmfDigitParam &prm) {
480
562
  try {
481
563
  CallOpParam prm(true);
482
564
  prm.opt.flag |= PJSUA_CALL_UNHOLD;
565
+ prm.opt.audioCount = 1;
566
+ prm.opt.videoCount = 0; // audio-only re-INVITE
483
567
  call->reinvite(prm);
484
568
  return YES;
485
569
  } catch (Error &err) { if (error) *error = VCMakeError(err.status, VCStr(err.info())); return NO; }
@@ -31,6 +31,20 @@ export interface AccountConfig {
31
31
  * the two in sync to avoid surprises.
32
32
  */
33
33
  transport: Transport;
34
+ /**
35
+ * Outbound proxy URI, e.g. "sip:proxy.example.com:5060;lr". When set,
36
+ * every SIP request — including in-dialog re-INVITE / BYE — is routed
37
+ * through this proxy (`proxies.push_back`). Required for in-dialog SIP
38
+ * to survive NAT: without a proxy Route a NAT'd client sends its BYE /
39
+ * unhold re-INVITE straight to the peer's private address and it is lost.
40
+ */
41
+ proxy?: string;
42
+ /**
43
+ * SRTP media policy. Defaults to 'disabled' when omitted so a plain-UDP
44
+ * trunk never offers a=crypto (an optional-SRTP offer breaks media on some
45
+ * PBX / rtpengine setups → one-way / no audio).
46
+ */
47
+ srtp?: 'disabled' | 'optional' | 'mandatory';
34
48
  }
35
49
  export interface RegistrationStateEvent {
36
50
  accountId: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voipsdk/react-native-voipcloud",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Public React Native VoIP SDK backed by PJSIP (VoipCloud).",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -16,6 +16,7 @@
16
16
  "react-native.config.js",
17
17
  "README.md",
18
18
  "CHANGELOG.md",
19
+ "LICENSE",
19
20
  "!**/__tests__",
20
21
  "!**/.*"
21
22
  ],
@@ -41,7 +42,7 @@
41
42
  "name": "VoipCloud",
42
43
  "email": "htt.bkap@gmail.com"
43
44
  },
44
- "license": "UNLICENSED",
45
+ "license": "MIT",
45
46
  "repository": {
46
47
  "type": "git",
47
48
  "url": "git+https://github.com/hoangdx/react-native-voipcloud.git"
@@ -34,10 +34,15 @@ Pod::Spec.new do |s|
34
34
  # fix arm64 endianness detection and to keep struct layouts ABI-compatible
35
35
  # with the prebuilt libVoipCloud.a.
36
36
  "GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) PJ_AUTOCONF=1",
37
- # pjsua2.hpp and friends live in each slice's bundled Headers/ dir. The
38
- # trailing /** makes Xcode search recursively so it resolves for whichever
39
- # slice (device / simulator) is being built.
40
- "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ios/VoipCloud.xcframework/**\""
37
+ # pjsua2.hpp and the pj/ pjsip/ pjmedia/ trees live at each slice's Headers/
38
+ # root, and PJSIP includes are all rooted there (<pjsua2.hpp>, <pj/xxx.h>).
39
+ # Point at that root directly — one path per slice, only the matching one
40
+ # exists per build. Do NOT use a recursive /** glob: it drags every subdir,
41
+ # including pj/compat/, onto the search path. pj/compat/ ships files named
42
+ # ctype.h, string.h, setjmp.h, etc. that shadow the C stdlib, so PJSIP's own
43
+ # `#include <ctype.h>` resolves to the shadow (self, guard already defined)
44
+ # instead of libc — leaving isalnum/strlen/memset/jmp_buf undeclared.
45
+ "HEADER_SEARCH_PATHS" => "\"$(PODS_TARGET_SRCROOT)/ios/VoipCloud.xcframework/ios-arm64/Headers\" \"$(PODS_TARGET_SRCROOT)/ios/VoipCloud.xcframework/ios-arm64_x86_64-simulator/Headers\""
41
46
  }
42
47
 
43
48
  s.dependency "React-Core"
package/src/types.ts CHANGED
@@ -34,6 +34,20 @@ export interface AccountConfig {
34
34
  * the two in sync to avoid surprises.
35
35
  */
36
36
  transport: Transport;
37
+ /**
38
+ * Outbound proxy URI, e.g. "sip:proxy.example.com:5060;lr". When set,
39
+ * every SIP request — including in-dialog re-INVITE / BYE — is routed
40
+ * through this proxy (`proxies.push_back`). Required for in-dialog SIP
41
+ * to survive NAT: without a proxy Route a NAT'd client sends its BYE /
42
+ * unhold re-INVITE straight to the peer's private address and it is lost.
43
+ */
44
+ proxy?: string;
45
+ /**
46
+ * SRTP media policy. Defaults to 'disabled' when omitted so a plain-UDP
47
+ * trunk never offers a=crypto (an optional-SRTP offer breaks media on some
48
+ * PBX / rtpengine setups → one-way / no audio).
49
+ */
50
+ srtp?: 'disabled' | 'optional' | 'mandatory';
37
51
  }
38
52
 
39
53
  export interface RegistrationStateEvent {