@voipsdk/react-native-voipcloud 0.2.1 → 1.0.1

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 (45) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +182 -117
  3. package/android/build.gradle +7 -7
  4. package/android/src/main/java/com/voipcloud/sdk/rn/VoipCloudModule.java +307 -208
  5. package/android/src/main/java/com/voipcloud/sdk/rn/internal/BridgeGuard.java +74 -0
  6. package/android/src/main/java/com/voipcloud/sdk/rn/internal/Events.java +7 -2
  7. package/android/src/main/java/com/voipcloud/sdk/rn/internal/ManagedAccount.java +49 -5
  8. package/android/src/main/java/com/voipcloud/sdk/rn/internal/ManagedCall.java +58 -2
  9. package/android/src/main/java/com/voipcloud/sdk/rn/internal/ManagedCallRegistry.java +4 -0
  10. package/android/src/main/libs/voipcloud-release.aar +0 -0
  11. package/ios/VoipCloud.mm +8 -8
  12. package/lib/commonjs/VoipCloud.js +33 -48
  13. package/lib/commonjs/VoipCloud.js.map +1 -1
  14. package/lib/commonjs/events.js +13 -7
  15. package/lib/commonjs/events.js.map +1 -1
  16. package/lib/commonjs/index.js +2 -3
  17. package/lib/commonjs/index.js.map +1 -1
  18. package/lib/commonjs/nativeModule.js +18 -0
  19. package/lib/commonjs/nativeModule.js.map +1 -0
  20. package/lib/module/VoipCloud.js +34 -48
  21. package/lib/module/VoipCloud.js.map +1 -1
  22. package/lib/module/events.js +9 -8
  23. package/lib/module/events.js.map +1 -1
  24. package/lib/module/index.js +1 -2
  25. package/lib/module/index.js.map +1 -1
  26. package/lib/module/nativeModule.js +13 -0
  27. package/lib/module/nativeModule.js.map +1 -0
  28. package/lib/typescript/VoipCloud.d.ts +7 -31
  29. package/lib/typescript/events.d.ts +6 -2
  30. package/lib/typescript/index.d.ts +2 -3
  31. package/lib/typescript/nativeModule.d.ts +7 -0
  32. package/lib/typescript/types.d.ts +5 -17
  33. package/package.json +7 -5
  34. package/react-native-voipcloud.podspec +7 -5
  35. package/src/VoipCloud.ts +42 -49
  36. package/src/events.ts +9 -8
  37. package/src/index.ts +1 -3
  38. package/src/nativeModule.ts +17 -0
  39. package/src/types.ts +5 -18
  40. package/lib/commonjs/License.js +0 -51
  41. package/lib/commonjs/License.js.map +0 -1
  42. package/lib/module/License.js +0 -45
  43. package/lib/module/License.js.map +0 -1
  44. package/lib/typescript/License.d.ts +0 -31
  45. package/src/License.ts +0 -52
@@ -1,6 +1,5 @@
1
1
  package com.voipcloud.sdk.rn;
2
2
 
3
- import android.provider.Settings;
4
3
  import androidx.annotation.NonNull;
5
4
 
6
5
  import com.facebook.react.bridge.Arguments;
@@ -17,345 +16,434 @@ import com.voipcloud.sdk.CallOpParam;
17
16
  import com.voipcloud.sdk.CallSendDtmfParam;
18
17
  import com.voipcloud.sdk.Endpoint;
19
18
  import com.voipcloud.sdk.EpConfig;
20
- import com.voipcloud.sdk.License;
21
- import com.voipcloud.sdk.LicenseRefresher;
22
19
  import com.voipcloud.sdk.TransportConfig;
23
20
  import com.voipcloud.sdk.pjsip_status_code;
24
21
  import com.voipcloud.sdk.pjsip_transport_type_e;
22
+ import com.voipcloud.sdk.pjsua_call_flag;
25
23
  import com.voipcloud.sdk.pjsua_destroy_flag;
24
+ import com.voipcloud.sdk.AudDevManager;
26
25
 
26
+ import com.voipcloud.sdk.rn.internal.BridgeGuard;
27
27
  import com.voipcloud.sdk.rn.internal.Events;
28
28
  import com.voipcloud.sdk.rn.internal.ManagedAccount;
29
29
  import com.voipcloud.sdk.rn.internal.ManagedCall;
30
30
  import com.voipcloud.sdk.rn.internal.ManagedCallRegistry;
31
31
 
32
32
  import java.util.concurrent.ConcurrentHashMap;
33
- import java.util.concurrent.atomic.AtomicInteger;
34
33
 
35
34
  /**
36
- * React Native bridge for the VoipCloud SDK. All methods are async
37
- * Promises; license-gate failures rejecting with code "VC_<ENUM>".
35
+ * React Native bridge for the VoipCloud public SDK (PJSIP, no license gate).
36
+ * All failures are returned as rejected Promises with VC_* codes — never crash
37
+ * the host app for API misuse or recoverable PJSIP errors.
38
38
  */
39
39
  public class VoipCloudModule extends ReactContextBaseJavaModule {
40
40
 
41
- static {
42
- // Load the prebuilt PJSIP + license-layer library. Names must
43
- // match jniLibs path inside the AAR: jni/<abi>/libvoipcloud.so.
44
- // libc++_shared is co-located and pulled in implicitly by the
45
- // loader as a NEEDED dependency.
46
- System.loadLibrary("voipcloud");
47
- }
48
-
49
41
  private final ReactApplicationContext ctx;
50
42
  private final ManagedCallRegistry calls = new ManagedCallRegistry();
51
43
  private final ConcurrentHashMap<Integer, ManagedAccount> accounts = new ConcurrentHashMap<>();
52
- private final AtomicInteger nextAccKey = new AtomicInteger(1);
53
44
 
54
45
  private Endpoint endpoint;
55
46
  private boolean inited = false;
56
- /** Last token accepted by License.set; reused by the refresher. */
57
- private volatile String licenseToken = null;
58
- /** Refresh URL currently driving LicenseRefresher. Used to detect
59
- * when a token rotation also rotates the refresh endpoint. */
60
- private volatile String activeRefreshUrl = null;
61
47
 
62
48
  public VoipCloudModule(ReactApplicationContext ctx) {
63
49
  super(ctx);
64
50
  this.ctx = ctx;
65
-
66
- // Tell native the consumer app's package up front so subsequent
67
- // License.set() can enforce the bundle_id binding embedded in
68
- // the token. Also anchor the clock-skew detector.
69
- License.setBundle(ctx.getPackageName());
70
- License.setStateDir(ctx.getFilesDir().getAbsolutePath());
71
51
  }
72
52
 
53
+ /** Must match NATIVE_MODULE_NAME in src/nativeModule.ts (@voipsdk/react-native-voipcloud). */
73
54
  @NonNull
74
55
  @Override
75
- public String getName() { return "VoipCloud"; }
76
-
77
- // ============== License ============================================
78
-
79
- @ReactMethod
80
- public void setLicense(String token, Promise promise) {
81
- try {
82
- License.set(token);
83
- licenseToken = token;
84
- bootstrapRefresher(License.refreshUrl());
85
- promise.resolve(null);
86
- } catch (Throwable t) {
87
- // Stop any refresher tied to the previous (now-rejected) token.
88
- stopRefresherInternal();
89
- promise.reject(mapLicenseError(t), t.getMessage(), t);
90
- }
91
- }
56
+ public String getName() { return "VoipsdkReactNativeVoipcloud"; }
92
57
 
93
58
  /**
94
- * Start, restart, or no-op the LicenseRefresher to match the URL
95
- * embedded in the most recently accepted token. The consumer app
96
- * never specifies a URL — it travels inside the signed token.
59
+ * PJSIP aborts (SIGABRT, "Calling pjlib from unknown/external thread")
60
+ * when pjlib is entered from a thread never passed to
61
+ * pj_thread_register(). SWIG finalizers run on the runtime's
62
+ * FinalizerDaemon thread and call native destructors that touch pjlib
63
+ * (CallOpParam, EpConfig, CallInfo, ... dropped to the GC), so that
64
+ * thread must be registered too. There is no API to run code on
65
+ * FinalizerDaemon directly — enqueue a sacrificial object whose
66
+ * finalizer registers it, and force a finalization pass now, while no
67
+ * other SDK garbage is pending yet.
97
68
  */
98
- private void bootstrapRefresher(String refreshUrl) {
99
- if (refreshUrl == null || refreshUrl.isEmpty()) {
100
- // Token opted out of refresh. Stop any leftover loop.
101
- stopRefresherInternal();
102
- return;
103
- }
104
- // If the URL is unchanged, the existing refresher is still
105
- // correct — just leave it running so its next 24 h fire reuses
106
- // the now-updated token via its internal cache.
107
- if (refreshUrl.equals(activeRefreshUrl)) return;
108
-
109
- // URL changed (or first start) — restart with the new endpoint.
110
- try { LicenseRefresher.stop(); } catch (Throwable ignored) {}
111
- LicenseRefresher.start(ctx, refreshUrl, licenseToken, autoDeviceId());
112
- activeRefreshUrl = refreshUrl;
113
- }
114
-
115
- private void stopRefresherInternal() {
116
- try { LicenseRefresher.stop(); } catch (Throwable ignored) {}
117
- activeRefreshUrl = null;
69
+ private static void registerFinalizerDaemonThread(final Endpoint ep) {
70
+ new Object() {
71
+ @Override
72
+ protected void finalize() throws Throwable {
73
+ try {
74
+ if (!ep.libIsThreadRegistered()) {
75
+ ep.libRegisterThread("FinalizerDaemon");
76
+ }
77
+ } catch (Throwable ignored) {
78
+ // Endpoint already destroyed or registration raced — the
79
+ // next init() enqueues a fresh registration.
80
+ }
81
+ super.finalize();
82
+ }
83
+ };
84
+ System.gc();
85
+ System.runFinalization();
118
86
  }
119
87
 
120
- /**
121
- * Stable per-install identifier without asking the consumer. On
122
- * Android 8+ the ANDROID_ID is scoped to package+signing key, so
123
- * it survives reinstalls of the same APK but rotates if the app is
124
- * sideloaded by another publisher — exactly the semantics a
125
- * license server wants.
126
- */
127
- private String autoDeviceId() {
88
+ private void destroyEndpointQuietly() {
89
+ if (endpoint == null) return;
128
90
  try {
129
- String id = Settings.Secure.getString(
130
- ctx.getContentResolver(), Settings.Secure.ANDROID_ID);
131
- if (id != null && !id.isEmpty()) return id;
91
+ endpoint.libDestroy(pjsua_destroy_flag.PJSUA_DESTROY_NO_NETWORK);
132
92
  } catch (Throwable ignored) {}
133
- // Fall back to a manifest-derived label.
134
- return ctx.getPackageName() + ":" + android.os.Build.FINGERPRINT;
135
- }
136
-
137
- /** Translate a PJSUA2 Error into the granular VC_* code surfaced to JS. */
138
- private static String mapLicenseError(Throwable t) {
139
- String msg = t.getMessage();
140
- if (msg == null) return "VC_LICENSE";
141
- if (msg.contains("license not set")) return "VC_NO_LICENSE";
142
- if (msg.contains("malformed")) return "VC_BAD_FORMAT";
143
- if (msg.contains("signature invalid")) return "VC_BAD_SIGNATURE";
144
- if (msg.contains("license expired")) return "VC_EXPIRED";
145
- if (msg.contains("not active")) return "VC_NOT_ACTIVE";
146
- if (msg.contains("revoked")) return "VC_REVOKED";
147
- if (msg.contains("bundle id mismatch")) return "VC_BUNDLE_MISMATCH";
148
- if (msg.contains("concurrent calls")) return "VC_CCU_EXCEEDED";
149
- if (msg.contains("payload too large")) return "VC_PAYLOAD_TOO_BIG";
150
- if (msg.contains("clock rolled back")) return "VC_CLOCK_SKEW";
151
- return "VC_LICENSE";
152
- }
153
-
154
- @ReactMethod
155
- public void getLicenseStatus(Promise promise) {
156
93
  try {
157
- WritableMap m = Arguments.createMap();
158
- m.putBoolean("valid", License.isValid());
159
- m.putString ("status", License.status());
160
- m.putString ("customerId", License.customerId());
161
- m.putInt ("ccu", License.maxCcu());
162
- m.putInt ("currentCalls",License.currentCalls());
163
- m.putDouble ("expiry", License.expiryUnix());
164
- m.putDouble ("serverTime", License.serverTime());
165
- promise.resolve(m);
166
- } catch (Throwable t) {
167
- promise.reject("VC_STATUS", t.getMessage(), t);
168
- }
94
+ endpoint.delete();
95
+ } catch (Throwable ignored) {}
96
+ endpoint = null;
97
+ inited = false;
169
98
  }
170
99
 
171
- /**
172
- * The refresher bootstraps itself from token.ref during
173
- * setLicense(); the consumer never starts it manually. We expose
174
- * stop() only so apps can pause refresh during user logout / SDK
175
- * shutdown.
176
- */
177
100
  @ReactMethod
178
- public void stopLicenseRefresh(Promise promise) {
179
- stopRefresherInternal();
180
- promise.resolve(null);
181
- }
101
+ public void init(ReadableMap config, Promise promise) {
102
+ if (!BridgeGuard.ensureNative(promise)) return;
103
+ if (inited) { promise.resolve(null); return; }
182
104
 
183
- // ============== Endpoint lifecycle =================================
105
+ if (config == null) config = Arguments.createMap();
184
106
 
185
- @ReactMethod
186
- public void init(ReadableMap config, Promise promise) {
107
+ Endpoint ep = null;
187
108
  try {
188
- if (inited) { promise.resolve(null); return; }
189
- endpoint = new Endpoint();
190
- endpoint.libCreate();
109
+ ep = new Endpoint();
110
+ ep.libCreate();
111
+ registerFinalizerDaemonThread(ep);
191
112
 
192
113
  EpConfig epCfg = new EpConfig();
193
- if (config.hasKey("userAgent"))
114
+ if (config.hasKey("userAgent") && !config.isNull("userAgent"))
194
115
  epCfg.getUaConfig().setUserAgent(config.getString("userAgent"));
195
- if (config.hasKey("stunServer")) {
116
+ if (config.hasKey("stunServer") && !config.isNull("stunServer")) {
196
117
  com.voipcloud.sdk.StringVector v = new com.voipcloud.sdk.StringVector();
197
118
  v.add(config.getString("stunServer"));
198
119
  epCfg.getUaConfig().setStunServer(v);
199
120
  }
200
- if (config.hasKey("logLevel"))
121
+ if (config.hasKey("logLevel") && !config.isNull("logLevel"))
201
122
  epCfg.getLogConfig().setLevel(config.getInt("logLevel"));
202
123
 
203
- endpoint.libInit(epCfg);
124
+ // Disable PJSIP's software echo canceller: it is CPU-heavy on
125
+ // mobile and a common cause of crackling/choppy audio. Android
126
+ // applies its hardware AEC to VOICE_COMMUNICATION streams.
127
+ try {
128
+ epCfg.getMedConfig().setEcTailLen(0);
129
+ } catch (Throwable ignored) {}
130
+
131
+ // Audio quality: run the sound device at the Android native rate
132
+ // (48 kHz) so the HAL does not resample a 16 kHz stream a second
133
+ // time (double resampling on the JNI audio backend is the main
134
+ // source of crackling/harsh speaker audio). The conference
135
+ // bridge stays at 16 kHz; PJSIP resamples once with the
136
+ // high-quality (large filter) resampler. VAD is disabled so
137
+ // silence suppression cannot chop word edges.
138
+ try {
139
+ epCfg.getMedConfig().setSndClockRate(48000);
140
+ epCfg.getMedConfig().setQuality(10);
141
+ epCfg.getMedConfig().setNoVad(true);
142
+ } catch (Throwable ignored) {}
143
+
144
+ ep.libInit(epCfg);
204
145
 
205
- // Default transports — TLS handled per-account via registrar URI.
206
146
  TransportConfig udp = new TransportConfig();
207
- endpoint.transportCreate(pjsip_transport_type_e.PJSIP_TRANSPORT_UDP, udp);
147
+ ep.transportCreate(pjsip_transport_type_e.PJSIP_TRANSPORT_UDP, udp);
208
148
  TransportConfig tcp = new TransportConfig();
209
- endpoint.transportCreate(pjsip_transport_type_e.PJSIP_TRANSPORT_TCP, tcp);
149
+ ep.transportCreate(pjsip_transport_type_e.PJSIP_TRANSPORT_TCP, tcp);
210
150
 
211
- endpoint.libStart();
151
+ ep.libStart();
152
+ endpoint = ep;
212
153
  inited = true;
213
154
  promise.resolve(null);
214
155
  } catch (Throwable t) {
215
- promise.reject("VC_INIT", t.getMessage(), t);
156
+ if (ep != null) {
157
+ try { ep.libDestroy(pjsua_destroy_flag.PJSUA_DESTROY_NO_NETWORK); } catch (Throwable ignored) {}
158
+ try { ep.delete(); } catch (Throwable ignored) {}
159
+ }
160
+ endpoint = null;
161
+ inited = false;
162
+ BridgeGuard.reject(promise, "VC_INIT", "Failed to start VoipCloud endpoint", t);
216
163
  }
217
164
  }
218
165
 
219
166
  @ReactMethod
220
167
  public void shutdown(Promise promise) {
168
+ if (!BridgeGuard.ensureNative(promise)) return;
221
169
  try {
222
170
  calls.clear();
223
- for (ManagedAccount a : accounts.values()) a.delete();
224
- accounts.clear();
225
- if (endpoint != null) {
226
- endpoint.libDestroy(pjsua_destroy_flag.PJSUA_DESTROY_NO_NETWORK);
227
- endpoint.delete();
228
- endpoint = null;
171
+ for (ManagedAccount a : accounts.values()) {
172
+ try { a.setRegistration(false); } catch (Throwable ignored) {}
173
+ try { a.delete(); } catch (Throwable ignored) {}
229
174
  }
230
- inited = false;
175
+ accounts.clear();
176
+ destroyEndpointQuietly();
231
177
  promise.resolve(null);
232
178
  } catch (Throwable t) {
233
- promise.reject("VC_SHUTDOWN", t.getMessage(), t);
179
+ BridgeGuard.reject(promise, "VC_SHUTDOWN", "Failed to shut down VoipCloud", t);
234
180
  }
235
181
  }
236
182
 
237
- // ============== Account ============================================
238
-
239
183
  @ReactMethod
240
184
  public void register(ReadableMap cfg, Promise promise) {
185
+ if (!BridgeGuard.ensureNative(promise)) return;
186
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
187
+
188
+ String uri = BridgeGuard.requireString(cfg, "uri", promise);
189
+ if (uri == null) return;
190
+ String registrar = BridgeGuard.requireString(cfg, "registrar", promise);
191
+ if (registrar == null) return;
192
+ String username = BridgeGuard.requireString(cfg, "username", promise);
193
+ if (username == null) return;
194
+ String password = BridgeGuard.requireString(cfg, "password", promise);
195
+ if (password == null) return;
196
+
241
197
  try {
242
198
  AccountConfig acfg = new AccountConfig();
243
- acfg.setIdUri(cfg.getString("uri"));
244
- acfg.getRegConfig().setRegistrarUri(cfg.getString("registrar"));
245
-
246
- AuthCredInfo cred = new AuthCredInfo(
247
- "digest", "*",
248
- cfg.getString("username"), 0,
249
- cfg.getString("password"));
199
+ acfg.setIdUri(uri);
200
+ acfg.getRegConfig().setRegistrarUri(registrar);
201
+
202
+ // NAT survival: without keep-alives and contact rewrite the
203
+ // registration "succeeds" but the NAT mapping dies within a
204
+ // minute and inbound INVITEs never reach the device.
205
+ acfg.getNatConfig().setContactRewriteUse(1);
206
+ acfg.getNatConfig().setViaRewriteUse(1);
207
+ acfg.getNatConfig().setUdpKaIntervalSec(20);
208
+ // Short re-REGISTER interval keeps the binding fresh behind NAT.
209
+ acfg.getRegConfig().setTimeoutSec(120);
210
+ acfg.getRegConfig().setFirstRetryIntervalSec(5);
211
+ acfg.getRegConfig().setRetryIntervalSec(30);
212
+
213
+ AuthCredInfo cred = new AuthCredInfo("digest", "*", username, 0, password);
250
214
  acfg.getSipConfig().getAuthCreds().add(cred);
251
215
 
252
- ManagedAccount acc = new ManagedAccount(ctx, calls);
216
+ ManagedAccount acc = new ManagedAccount(ctx, calls, endpoint);
253
217
  acc.create(acfg);
254
218
  int id = acc.accountId();
219
+ if (id < 0) {
220
+ acc.delete();
221
+ promise.reject("VC_REGISTER", "Account created but returned invalid id");
222
+ return;
223
+ }
255
224
  accounts.put(id, acc);
256
225
  promise.resolve(id);
257
226
  } catch (Throwable t) {
258
- promise.reject("VC_REGISTER", t.getMessage(), t);
227
+ BridgeGuard.reject(promise, "VC_REGISTER", "Failed to register SIP account", t);
259
228
  }
260
229
  }
261
230
 
262
231
  @ReactMethod
263
232
  public void unregister(int accountId, Promise promise) {
233
+ if (!BridgeGuard.ensureNative(promise)) return;
264
234
  try {
265
235
  ManagedAccount acc = accounts.remove(accountId);
266
236
  if (acc != null) {
267
- acc.setRegistration(false);
268
- acc.delete();
237
+ try { acc.setRegistration(false); } catch (Throwable ignored) {}
238
+ try { acc.delete(); } catch (Throwable ignored) {}
269
239
  }
270
240
  promise.resolve(null);
271
241
  } catch (Throwable t) {
272
- promise.reject("VC_UNREGISTER", t.getMessage(), t);
242
+ BridgeGuard.reject(promise, "VC_UNREGISTER", "Failed to unregister account", t);
273
243
  }
274
244
  }
275
245
 
276
- // ============== Call ===============================================
277
-
278
246
  @ReactMethod
279
247
  public void makeCall(int accountId, String uri, Promise promise) {
248
+ if (!BridgeGuard.ensureNative(promise)) return;
249
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
250
+
251
+ if (uri == null || uri.trim().isEmpty()) {
252
+ promise.reject("VC_INVALID_ARGUMENT", "destinationUri must not be empty");
253
+ return;
254
+ }
255
+
280
256
  try {
281
257
  ManagedAccount acc = accounts.get(accountId);
282
- if (acc == null) { promise.reject("VC_NO_ACCOUNT", "unknown accountId"); return; }
283
- ManagedCall call = new ManagedCall(ctx, acc);
284
- call.makeCall(uri, new CallOpParam(true));
258
+ if (acc == null) {
259
+ promise.reject("VC_NO_ACCOUNT", "Unknown accountId " + accountId + ". Call register() first.");
260
+ return;
261
+ }
262
+ ManagedCall call = new ManagedCall(ctx, acc, calls);
263
+ call.bindEndpoint(endpoint);
264
+ call.makeCall(uri.trim(), new CallOpParam(true));
265
+ int callId = call.callId();
266
+ if (callId < 0) {
267
+ promise.reject("VC_MAKE_CALL", "Call started but returned invalid callId");
268
+ return;
269
+ }
285
270
  calls.put(call);
286
- promise.resolve(call.callId());
271
+ promise.resolve(callId);
287
272
  } catch (Throwable t) {
288
- promise.reject("VC_MAKE_CALL", t.getMessage(), t);
273
+ BridgeGuard.reject(promise, "VC_MAKE_CALL", "Failed to place call", t);
289
274
  }
290
275
  }
291
276
 
292
277
  @ReactMethod
293
278
  public void answer(int callId, Promise promise) {
279
+ if (!BridgeGuard.ensureNative(promise)) return;
280
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
294
281
  try {
295
282
  ManagedCall call = calls.get(callId);
296
- if (call == null) { promise.reject("VC_NO_CALL", "unknown callId"); return; }
283
+ if (call == null) {
284
+ promise.reject("VC_NO_CALL", "Unknown callId " + callId);
285
+ return;
286
+ }
297
287
  CallOpParam op = new CallOpParam();
298
288
  op.setStatusCode(pjsip_status_code.PJSIP_SC_OK);
299
289
  call.answer(op);
300
290
  promise.resolve(null);
301
291
  } catch (Throwable t) {
302
- promise.reject("VC_ANSWER", t.getMessage(), t);
292
+ BridgeGuard.reject(promise, "VC_ANSWER", "Failed to answer call", t);
303
293
  }
304
294
  }
305
295
 
306
296
  @ReactMethod
307
- public void hangup(int callId, Promise promise) {
297
+ public void hangup(int callId, String mode, Promise promise) {
298
+ if (!BridgeGuard.ensureNative(promise)) return;
308
299
  try {
309
300
  ManagedCall call = calls.get(callId);
310
301
  if (call == null) { promise.resolve(null); return; }
311
- CallOpParam op = new CallOpParam();
312
- op.setStatusCode(pjsip_status_code.PJSIP_SC_DECLINE);
302
+ CallOpParam op = new CallOpParam(true);
303
+ if ("busy".equals(mode)) {
304
+ op.setStatusCode(pjsip_status_code.PJSIP_SC_BUSY_HERE);
305
+ } else if ("cancel".equals(mode)) {
306
+ op.setStatusCode(pjsip_status_code.PJSIP_SC_REQUEST_TERMINATED);
307
+ }
313
308
  call.hangup(op);
314
- calls.remove(callId);
315
309
  promise.resolve(null);
316
310
  } catch (Throwable t) {
317
- promise.reject("VC_HANGUP", t.getMessage(), t);
311
+ BridgeGuard.reject(promise, "VC_HANGUP", "Failed to hang up call", t);
312
+ }
313
+ }
314
+
315
+ /** Synchronous answer for native notification / Telecom UI when JS is inactive. */
316
+ public boolean answerCallSync(int callId) {
317
+ if (!inited) return false;
318
+ try {
319
+ ManagedCall call = calls.get(callId);
320
+ if (call == null) return false;
321
+ CallOpParam op = new CallOpParam();
322
+ op.setStatusCode(pjsip_status_code.PJSIP_SC_OK);
323
+ call.answer(op);
324
+ return true;
325
+ } catch (Throwable t) {
326
+ return false;
327
+ }
328
+ }
329
+
330
+ /** Synchronous hangup for native notification / Telecom UI when JS is inactive. */
331
+ public boolean hangupCallSync(int callId, String mode) {
332
+ try {
333
+ ManagedCall call = calls.get(callId);
334
+ if (call == null) return false;
335
+ CallOpParam op = new CallOpParam(true);
336
+ if ("busy".equals(mode)) {
337
+ op.setStatusCode(pjsip_status_code.PJSIP_SC_BUSY_HERE);
338
+ } else if ("cancel".equals(mode)) {
339
+ op.setStatusCode(pjsip_status_code.PJSIP_SC_REQUEST_TERMINATED);
340
+ }
341
+ call.hangup(op);
342
+ return true;
343
+ } catch (Throwable t) {
344
+ return false;
318
345
  }
319
346
  }
320
347
 
321
348
  @ReactMethod
322
349
  public void hold(int callId, Promise promise) {
350
+ if (!BridgeGuard.ensureNative(promise)) return;
351
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
323
352
  try {
324
- ManagedCall call = calls.get(callId);
325
- if (call == null) { promise.reject("VC_NO_CALL", "unknown callId"); return; }
353
+ ManagedCall call = requireCall(callId, promise);
354
+ if (call == null) return;
326
355
  call.setHold(new CallOpParam(true));
356
+ // Do NOT reconnect audio here: the hold re-INVITE is still in
357
+ // flight, so reconnecting emits a media-state snapshot with the
358
+ // OLD hold flag and desyncs the JS hold state. onCallMediaState
359
+ // rewires the bridge once the media status actually changes.
327
360
  WritableMap ev = Arguments.createMap();
328
361
  ev.putInt("callId", callId);
329
362
  Events.emit(getReactApplicationContext(), Events.CALL_HOLD, ev);
330
363
  promise.resolve(null);
331
364
  } catch (Throwable t) {
332
- promise.reject("VC_HOLD", t.getMessage(), t);
365
+ BridgeGuard.reject(promise, "VC_HOLD", "Failed to hold call", t);
333
366
  }
334
367
  }
335
368
 
336
369
  @ReactMethod
337
370
  public void unhold(int callId, Promise promise) {
371
+ if (!BridgeGuard.ensureNative(promise)) return;
372
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
338
373
  try {
339
- ManagedCall call = calls.get(callId);
340
- if (call == null) { promise.reject("VC_NO_CALL", "unknown callId"); return; }
341
- // PJSUA2 re-INVITE without an explicit hold flag re-offers
342
- // sendrecv SDP; that releases the existing hold.
343
- call.reinvite(new CallOpParam(true));
374
+ ManagedCall call = requireCall(callId, promise);
375
+ if (call == null) return;
376
+ CallOpParam prm = new CallOpParam(true);
377
+ prm.getOpt().setFlag(pjsua_call_flag.PJSUA_CALL_UNHOLD);
378
+ call.reinvite(prm);
379
+ // See hold(): audio is rewired by onCallMediaState when the
380
+ // unhold re-INVITE completes and media becomes ACTIVE again.
344
381
  WritableMap ev = Arguments.createMap();
345
382
  ev.putInt("callId", callId);
346
383
  Events.emit(getReactApplicationContext(), Events.CALL_UNHOLD, ev);
347
384
  promise.resolve(null);
348
385
  } catch (Throwable t) {
349
- promise.reject("VC_UNHOLD", t.getMessage(), t);
386
+ BridgeGuard.reject(promise, "VC_UNHOLD", "Failed to resume call", t);
387
+ }
388
+ }
389
+
390
+ private void refreshPjsipPlaybackDevices() {
391
+ if (endpoint == null) return;
392
+ try {
393
+ AudDevManager adm = endpoint.audDevManager();
394
+ adm.refreshDevs();
395
+ } catch (Throwable ignored) {}
396
+ }
397
+
398
+ /** Reconnect PJSIP audio bridge after Android route changes (speaker/earpiece). */
399
+ public boolean reconnectCallAudioSync(int callId) {
400
+ if (!inited) return false;
401
+ ManagedCall call = calls.get(callId);
402
+ if (call == null) return false;
403
+ try {
404
+ refreshPjsipPlaybackDevices();
405
+ call.reconnectAudioMedia();
406
+ return true;
407
+ } catch (Throwable t) {
408
+ return false;
409
+ }
410
+ }
411
+
412
+ public int reconnectAllCallsAudioSync() {
413
+ if (!inited) return 0;
414
+ refreshPjsipPlaybackDevices();
415
+ int count = 0;
416
+ for (ManagedCall call : calls.snapshot()) {
417
+ try {
418
+ call.reconnectAudioMedia();
419
+ count++;
420
+ } catch (Throwable ignored) {}
421
+ }
422
+ return count;
423
+ }
424
+
425
+ @ReactMethod
426
+ public void reconnectCallAudio(int callId, Promise promise) {
427
+ if (!BridgeGuard.ensureNative(promise)) return;
428
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
429
+ if (reconnectCallAudioSync(callId)) {
430
+ promise.resolve(null);
431
+ } else {
432
+ promise.reject("VC_AUDIO", "Failed to reconnect call audio");
350
433
  }
351
434
  }
352
435
 
353
436
  @ReactMethod
354
437
  public void mute(int callId, boolean on, Promise promise) {
438
+ if (!BridgeGuard.ensureNative(promise)) return;
439
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
355
440
  try {
356
- ManagedCall call = calls.get(callId);
357
- if (call == null) { promise.reject("VC_NO_CALL", "unknown callId"); return; }
358
- if (endpoint == null) { promise.reject("VC_INIT", "endpoint not started"); return; }
441
+ ManagedCall call = requireCall(callId, promise);
442
+ if (call == null) return;
443
+ if (endpoint == null) {
444
+ promise.reject("VC_NOT_INITIALIZED", "VoipCloud.init() must succeed before mute()");
445
+ return;
446
+ }
359
447
  call.setMuted(endpoint, on);
360
448
  WritableMap ev = Arguments.createMap();
361
449
  ev.putInt("callId", callId);
@@ -363,15 +451,21 @@ public class VoipCloudModule extends ReactContextBaseJavaModule {
363
451
  Events.emit(getReactApplicationContext(), Events.CALL_MUTE, ev);
364
452
  promise.resolve(null);
365
453
  } catch (Throwable t) {
366
- promise.reject("VC_MUTE", t.getMessage(), t);
454
+ BridgeGuard.reject(promise, "VC_MUTE", "Failed to change mute state", t);
367
455
  }
368
456
  }
369
457
 
370
458
  @ReactMethod
371
459
  public void sendDtmf(int callId, String digits, Promise promise) {
460
+ if (!BridgeGuard.ensureNative(promise)) return;
461
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
462
+ if (digits == null || digits.isEmpty()) {
463
+ promise.reject("VC_INVALID_ARGUMENT", "digits must not be empty");
464
+ return;
465
+ }
372
466
  try {
373
- ManagedCall call = calls.get(callId);
374
- if (call == null) { promise.reject("VC_NO_CALL", "unknown callId"); return; }
467
+ ManagedCall call = requireCall(callId, promise);
468
+ if (call == null) return;
375
469
  CallSendDtmfParam p = new CallSendDtmfParam();
376
470
  p.setDigits(digits);
377
471
  call.sendDtmf(p);
@@ -381,48 +475,53 @@ public class VoipCloudModule extends ReactContextBaseJavaModule {
381
475
  Events.emit(getReactApplicationContext(), Events.CALL_DTMF_SENT, ev);
382
476
  promise.resolve(null);
383
477
  } catch (Throwable t) {
384
- promise.reject("VC_DTMF", t.getMessage(), t);
478
+ BridgeGuard.reject(promise, "VC_DTMF", "Failed to send DTMF", t);
385
479
  }
386
480
  }
387
481
 
388
- /**
389
- * Blind-transfer a call (REFER). The Promise resolves once the
390
- * REFER is *sent* — progress of the remote-side INVITE comes
391
- * through `callTransferStatus` events (statusCode + finalNotify).
392
- */
393
482
  @ReactMethod
394
483
  public void transferCall(int callId, String destinationUri, Promise promise) {
484
+ if (!BridgeGuard.ensureNative(promise)) return;
485
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
486
+ if (destinationUri == null || destinationUri.trim().isEmpty()) {
487
+ promise.reject("VC_INVALID_ARGUMENT", "destinationUri must not be empty");
488
+ return;
489
+ }
395
490
  try {
396
- ManagedCall call = calls.get(callId);
397
- if (call == null) { promise.reject("VC_NO_CALL", "unknown callId"); return; }
398
- call.xfer(destinationUri, new CallOpParam(true));
491
+ ManagedCall call = requireCall(callId, promise);
492
+ if (call == null) return;
493
+ call.xfer(destinationUri.trim(), new CallOpParam(true));
399
494
  WritableMap ev = Arguments.createMap();
400
495
  ev.putInt("callId", callId);
401
- ev.putString("destinationUri", destinationUri);
496
+ ev.putString("destinationUri", destinationUri.trim());
402
497
  Events.emit(getReactApplicationContext(), Events.CALL_TRANSFER, ev);
403
498
  promise.resolve(null);
404
499
  } catch (Throwable t) {
405
- promise.reject("VC_TRANSFER", t.getMessage(), t);
500
+ BridgeGuard.reject(promise, "VC_TRANSFER", "Failed to transfer call", t);
406
501
  }
407
502
  }
408
503
 
409
- /**
410
- * End every active call in one shot. Useful for SDK shutdown or
411
- * a global "end all" UI action. Each call still emits its own
412
- * DISCONNECTED callState.
413
- */
414
504
  @ReactMethod
415
505
  public void hangupAllCalls(Promise promise) {
506
+ if (!BridgeGuard.ensureNative(promise)) return;
416
507
  try {
417
- if (endpoint != null) endpoint.hangupAllCalls();
508
+ if (endpoint != null && inited) endpoint.hangupAllCalls();
509
+ calls.clear();
418
510
  promise.resolve(null);
419
511
  } catch (Throwable t) {
420
- promise.reject("VC_HANGUP_ALL", t.getMessage(), t);
512
+ BridgeGuard.reject(promise, "VC_HANGUP_ALL", "Failed to hang up all calls", t);
513
+ }
514
+ }
515
+
516
+ private ManagedCall requireCall(int callId, Promise promise) {
517
+ ManagedCall call = calls.get(callId);
518
+ if (call == null) {
519
+ promise.reject("VC_NO_CALL", "Unknown callId " + callId);
520
+ return null;
421
521
  }
522
+ return call;
422
523
  }
423
524
 
424
- // RN event-emitter contract — required on RN 0.65+ when used with
425
- // NativeEventEmitter on the JS side.
426
525
  @ReactMethod public void addListener(String eventName) { /* no-op */ }
427
526
  @ReactMethod public void removeListeners(int count) { /* no-op */ }
428
527
  }