@voipsdk/react-native-voipcloud 0.2.1 → 1.0.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 (41) hide show
  1. package/README.md +181 -117
  2. package/android/build.gradle +7 -7
  3. package/android/src/main/java/com/voipcloud/sdk/rn/VoipCloudModule.java +148 -204
  4. package/android/src/main/java/com/voipcloud/sdk/rn/internal/BridgeGuard.java +74 -0
  5. package/android/src/main/java/com/voipcloud/sdk/rn/internal/Events.java +7 -2
  6. package/android/src/main/libs/voipcloud-release.aar +0 -0
  7. package/ios/VoipCloud.mm +8 -8
  8. package/lib/commonjs/VoipCloud.js +29 -47
  9. package/lib/commonjs/VoipCloud.js.map +1 -1
  10. package/lib/commonjs/events.js +13 -7
  11. package/lib/commonjs/events.js.map +1 -1
  12. package/lib/commonjs/index.js +2 -3
  13. package/lib/commonjs/index.js.map +1 -1
  14. package/lib/commonjs/nativeModule.js +18 -0
  15. package/lib/commonjs/nativeModule.js.map +1 -0
  16. package/lib/module/VoipCloud.js +30 -47
  17. package/lib/module/VoipCloud.js.map +1 -1
  18. package/lib/module/events.js +9 -8
  19. package/lib/module/events.js.map +1 -1
  20. package/lib/module/index.js +1 -2
  21. package/lib/module/index.js.map +1 -1
  22. package/lib/module/nativeModule.js +13 -0
  23. package/lib/module/nativeModule.js.map +1 -0
  24. package/lib/typescript/VoipCloud.d.ts +5 -30
  25. package/lib/typescript/events.d.ts +6 -2
  26. package/lib/typescript/index.d.ts +2 -3
  27. package/lib/typescript/nativeModule.d.ts +7 -0
  28. package/lib/typescript/types.d.ts +1 -17
  29. package/package.json +5 -4
  30. package/react-native-voipcloud.podspec +7 -5
  31. package/src/VoipCloud.ts +37 -48
  32. package/src/events.ts +9 -8
  33. package/src/index.ts +1 -3
  34. package/src/nativeModule.ts +17 -0
  35. package/src/types.ts +1 -18
  36. package/lib/commonjs/License.js +0 -51
  37. package/lib/commonjs/License.js.map +0 -1
  38. package/lib/module/License.js +0 -45
  39. package/lib/module/License.js.map +0 -1
  40. package/lib/typescript/License.d.ts +0 -31
  41. 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,294 +16,221 @@ 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;
25
22
  import com.voipcloud.sdk.pjsua_destroy_flag;
26
23
 
24
+ import com.voipcloud.sdk.rn.internal.BridgeGuard;
27
25
  import com.voipcloud.sdk.rn.internal.Events;
28
26
  import com.voipcloud.sdk.rn.internal.ManagedAccount;
29
27
  import com.voipcloud.sdk.rn.internal.ManagedCall;
30
28
  import com.voipcloud.sdk.rn.internal.ManagedCallRegistry;
31
29
 
32
30
  import java.util.concurrent.ConcurrentHashMap;
33
- import java.util.concurrent.atomic.AtomicInteger;
34
31
 
35
32
  /**
36
- * React Native bridge for the VoipCloud SDK. All methods are async
37
- * Promises; license-gate failures rejecting with code "VC_<ENUM>".
33
+ * React Native bridge for the VoipCloud public SDK (PJSIP, no license gate).
34
+ * All failures are returned as rejected Promises with VC_* codes — never crash
35
+ * the host app for API misuse or recoverable PJSIP errors.
38
36
  */
39
37
  public class VoipCloudModule extends ReactContextBaseJavaModule {
40
38
 
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
39
  private final ReactApplicationContext ctx;
50
40
  private final ManagedCallRegistry calls = new ManagedCallRegistry();
51
41
  private final ConcurrentHashMap<Integer, ManagedAccount> accounts = new ConcurrentHashMap<>();
52
- private final AtomicInteger nextAccKey = new AtomicInteger(1);
53
42
 
54
43
  private Endpoint endpoint;
55
44
  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
45
 
62
46
  public VoipCloudModule(ReactApplicationContext ctx) {
63
47
  super(ctx);
64
48
  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
49
  }
72
50
 
51
+ /** Must match NATIVE_MODULE_NAME in src/nativeModule.ts (@voipsdk/react-native-voipcloud). */
73
52
  @NonNull
74
53
  @Override
75
- public String getName() { return "VoipCloud"; }
76
-
77
- // ============== License ============================================
54
+ public String getName() { return "VoipsdkReactNativeVoipcloud"; }
78
55
 
79
- @ReactMethod
80
- public void setLicense(String token, Promise promise) {
56
+ private void destroyEndpointQuietly() {
57
+ if (endpoint == null) return;
81
58
  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
- }
92
-
93
- /**
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.
97
- */
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;
118
- }
119
-
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() {
128
- try {
129
- String id = Settings.Secure.getString(
130
- ctx.getContentResolver(), Settings.Secure.ANDROID_ID);
131
- if (id != null && !id.isEmpty()) return id;
59
+ endpoint.libDestroy(pjsua_destroy_flag.PJSUA_DESTROY_NO_NETWORK);
132
60
  } 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
61
  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
- }
62
+ endpoint.delete();
63
+ } catch (Throwable ignored) {}
64
+ endpoint = null;
65
+ inited = false;
169
66
  }
170
67
 
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
68
  @ReactMethod
178
- public void stopLicenseRefresh(Promise promise) {
179
- stopRefresherInternal();
180
- promise.resolve(null);
181
- }
69
+ public void init(ReadableMap config, Promise promise) {
70
+ if (!BridgeGuard.ensureNative(promise)) return;
71
+ if (inited) { promise.resolve(null); return; }
182
72
 
183
- // ============== Endpoint lifecycle =================================
73
+ if (config == null) config = Arguments.createMap();
184
74
 
185
- @ReactMethod
186
- public void init(ReadableMap config, Promise promise) {
75
+ Endpoint ep = null;
187
76
  try {
188
- if (inited) { promise.resolve(null); return; }
189
- endpoint = new Endpoint();
190
- endpoint.libCreate();
77
+ ep = new Endpoint();
78
+ ep.libCreate();
191
79
 
192
80
  EpConfig epCfg = new EpConfig();
193
- if (config.hasKey("userAgent"))
81
+ if (config.hasKey("userAgent") && !config.isNull("userAgent"))
194
82
  epCfg.getUaConfig().setUserAgent(config.getString("userAgent"));
195
- if (config.hasKey("stunServer")) {
83
+ if (config.hasKey("stunServer") && !config.isNull("stunServer")) {
196
84
  com.voipcloud.sdk.StringVector v = new com.voipcloud.sdk.StringVector();
197
85
  v.add(config.getString("stunServer"));
198
86
  epCfg.getUaConfig().setStunServer(v);
199
87
  }
200
- if (config.hasKey("logLevel"))
88
+ if (config.hasKey("logLevel") && !config.isNull("logLevel"))
201
89
  epCfg.getLogConfig().setLevel(config.getInt("logLevel"));
202
90
 
203
- endpoint.libInit(epCfg);
91
+ ep.libInit(epCfg);
204
92
 
205
- // Default transports — TLS handled per-account via registrar URI.
206
93
  TransportConfig udp = new TransportConfig();
207
- endpoint.transportCreate(pjsip_transport_type_e.PJSIP_TRANSPORT_UDP, udp);
94
+ ep.transportCreate(pjsip_transport_type_e.PJSIP_TRANSPORT_UDP, udp);
208
95
  TransportConfig tcp = new TransportConfig();
209
- endpoint.transportCreate(pjsip_transport_type_e.PJSIP_TRANSPORT_TCP, tcp);
96
+ ep.transportCreate(pjsip_transport_type_e.PJSIP_TRANSPORT_TCP, tcp);
210
97
 
211
- endpoint.libStart();
98
+ ep.libStart();
99
+ endpoint = ep;
212
100
  inited = true;
213
101
  promise.resolve(null);
214
102
  } catch (Throwable t) {
215
- promise.reject("VC_INIT", t.getMessage(), t);
103
+ if (ep != null) {
104
+ try { ep.libDestroy(pjsua_destroy_flag.PJSUA_DESTROY_NO_NETWORK); } catch (Throwable ignored) {}
105
+ try { ep.delete(); } catch (Throwable ignored) {}
106
+ }
107
+ endpoint = null;
108
+ inited = false;
109
+ BridgeGuard.reject(promise, "VC_INIT", "Failed to start VoipCloud endpoint", t);
216
110
  }
217
111
  }
218
112
 
219
113
  @ReactMethod
220
114
  public void shutdown(Promise promise) {
115
+ if (!BridgeGuard.ensureNative(promise)) return;
221
116
  try {
222
117
  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;
118
+ for (ManagedAccount a : accounts.values()) {
119
+ try { a.setRegistration(false); } catch (Throwable ignored) {}
120
+ try { a.delete(); } catch (Throwable ignored) {}
229
121
  }
230
- inited = false;
122
+ accounts.clear();
123
+ destroyEndpointQuietly();
231
124
  promise.resolve(null);
232
125
  } catch (Throwable t) {
233
- promise.reject("VC_SHUTDOWN", t.getMessage(), t);
126
+ BridgeGuard.reject(promise, "VC_SHUTDOWN", "Failed to shut down VoipCloud", t);
234
127
  }
235
128
  }
236
129
 
237
- // ============== Account ============================================
238
-
239
130
  @ReactMethod
240
131
  public void register(ReadableMap cfg, Promise promise) {
132
+ if (!BridgeGuard.ensureNative(promise)) return;
133
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
134
+
135
+ String uri = BridgeGuard.requireString(cfg, "uri", promise);
136
+ if (uri == null) return;
137
+ String registrar = BridgeGuard.requireString(cfg, "registrar", promise);
138
+ if (registrar == null) return;
139
+ String username = BridgeGuard.requireString(cfg, "username", promise);
140
+ if (username == null) return;
141
+ String password = BridgeGuard.requireString(cfg, "password", promise);
142
+ if (password == null) return;
143
+
241
144
  try {
242
145
  AccountConfig acfg = new AccountConfig();
243
- acfg.setIdUri(cfg.getString("uri"));
244
- acfg.getRegConfig().setRegistrarUri(cfg.getString("registrar"));
146
+ acfg.setIdUri(uri);
147
+ acfg.getRegConfig().setRegistrarUri(registrar);
245
148
 
246
- AuthCredInfo cred = new AuthCredInfo(
247
- "digest", "*",
248
- cfg.getString("username"), 0,
249
- cfg.getString("password"));
149
+ AuthCredInfo cred = new AuthCredInfo("digest", "*", username, 0, password);
250
150
  acfg.getSipConfig().getAuthCreds().add(cred);
251
151
 
252
152
  ManagedAccount acc = new ManagedAccount(ctx, calls);
253
153
  acc.create(acfg);
254
154
  int id = acc.accountId();
155
+ if (id < 0) {
156
+ acc.delete();
157
+ promise.reject("VC_REGISTER", "Account created but returned invalid id");
158
+ return;
159
+ }
255
160
  accounts.put(id, acc);
256
161
  promise.resolve(id);
257
162
  } catch (Throwable t) {
258
- promise.reject("VC_REGISTER", t.getMessage(), t);
163
+ BridgeGuard.reject(promise, "VC_REGISTER", "Failed to register SIP account", t);
259
164
  }
260
165
  }
261
166
 
262
167
  @ReactMethod
263
168
  public void unregister(int accountId, Promise promise) {
169
+ if (!BridgeGuard.ensureNative(promise)) return;
264
170
  try {
265
171
  ManagedAccount acc = accounts.remove(accountId);
266
172
  if (acc != null) {
267
- acc.setRegistration(false);
268
- acc.delete();
173
+ try { acc.setRegistration(false); } catch (Throwable ignored) {}
174
+ try { acc.delete(); } catch (Throwable ignored) {}
269
175
  }
270
176
  promise.resolve(null);
271
177
  } catch (Throwable t) {
272
- promise.reject("VC_UNREGISTER", t.getMessage(), t);
178
+ BridgeGuard.reject(promise, "VC_UNREGISTER", "Failed to unregister account", t);
273
179
  }
274
180
  }
275
181
 
276
- // ============== Call ===============================================
277
-
278
182
  @ReactMethod
279
183
  public void makeCall(int accountId, String uri, Promise promise) {
184
+ if (!BridgeGuard.ensureNative(promise)) return;
185
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
186
+
187
+ if (uri == null || uri.trim().isEmpty()) {
188
+ promise.reject("VC_INVALID_ARGUMENT", "destinationUri must not be empty");
189
+ return;
190
+ }
191
+
280
192
  try {
281
193
  ManagedAccount acc = accounts.get(accountId);
282
- if (acc == null) { promise.reject("VC_NO_ACCOUNT", "unknown accountId"); return; }
194
+ if (acc == null) {
195
+ promise.reject("VC_NO_ACCOUNT", "Unknown accountId " + accountId + ". Call register() first.");
196
+ return;
197
+ }
283
198
  ManagedCall call = new ManagedCall(ctx, acc);
284
- call.makeCall(uri, new CallOpParam(true));
199
+ call.makeCall(uri.trim(), new CallOpParam(true));
200
+ int callId = call.callId();
201
+ if (callId < 0) {
202
+ promise.reject("VC_MAKE_CALL", "Call started but returned invalid callId");
203
+ return;
204
+ }
285
205
  calls.put(call);
286
- promise.resolve(call.callId());
206
+ promise.resolve(callId);
287
207
  } catch (Throwable t) {
288
- promise.reject("VC_MAKE_CALL", t.getMessage(), t);
208
+ BridgeGuard.reject(promise, "VC_MAKE_CALL", "Failed to place call", t);
289
209
  }
290
210
  }
291
211
 
292
212
  @ReactMethod
293
213
  public void answer(int callId, Promise promise) {
214
+ if (!BridgeGuard.ensureNative(promise)) return;
215
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
294
216
  try {
295
217
  ManagedCall call = calls.get(callId);
296
- if (call == null) { promise.reject("VC_NO_CALL", "unknown callId"); return; }
218
+ if (call == null) {
219
+ promise.reject("VC_NO_CALL", "Unknown callId " + callId);
220
+ return;
221
+ }
297
222
  CallOpParam op = new CallOpParam();
298
223
  op.setStatusCode(pjsip_status_code.PJSIP_SC_OK);
299
224
  call.answer(op);
300
225
  promise.resolve(null);
301
226
  } catch (Throwable t) {
302
- promise.reject("VC_ANSWER", t.getMessage(), t);
227
+ BridgeGuard.reject(promise, "VC_ANSWER", "Failed to answer call", t);
303
228
  }
304
229
  }
305
230
 
306
231
  @ReactMethod
307
232
  public void hangup(int callId, Promise promise) {
233
+ if (!BridgeGuard.ensureNative(promise)) return;
308
234
  try {
309
235
  ManagedCall call = calls.get(callId);
310
236
  if (call == null) { promise.resolve(null); return; }
@@ -314,48 +240,55 @@ public class VoipCloudModule extends ReactContextBaseJavaModule {
314
240
  calls.remove(callId);
315
241
  promise.resolve(null);
316
242
  } catch (Throwable t) {
317
- promise.reject("VC_HANGUP", t.getMessage(), t);
243
+ BridgeGuard.reject(promise, "VC_HANGUP", "Failed to hang up call", t);
318
244
  }
319
245
  }
320
246
 
321
247
  @ReactMethod
322
248
  public void hold(int callId, Promise promise) {
249
+ if (!BridgeGuard.ensureNative(promise)) return;
250
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
323
251
  try {
324
- ManagedCall call = calls.get(callId);
325
- if (call == null) { promise.reject("VC_NO_CALL", "unknown callId"); return; }
252
+ ManagedCall call = requireCall(callId, promise);
253
+ if (call == null) return;
326
254
  call.setHold(new CallOpParam(true));
327
255
  WritableMap ev = Arguments.createMap();
328
256
  ev.putInt("callId", callId);
329
257
  Events.emit(getReactApplicationContext(), Events.CALL_HOLD, ev);
330
258
  promise.resolve(null);
331
259
  } catch (Throwable t) {
332
- promise.reject("VC_HOLD", t.getMessage(), t);
260
+ BridgeGuard.reject(promise, "VC_HOLD", "Failed to hold call", t);
333
261
  }
334
262
  }
335
263
 
336
264
  @ReactMethod
337
265
  public void unhold(int callId, Promise promise) {
266
+ if (!BridgeGuard.ensureNative(promise)) return;
267
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
338
268
  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.
269
+ ManagedCall call = requireCall(callId, promise);
270
+ if (call == null) return;
343
271
  call.reinvite(new CallOpParam(true));
344
272
  WritableMap ev = Arguments.createMap();
345
273
  ev.putInt("callId", callId);
346
274
  Events.emit(getReactApplicationContext(), Events.CALL_UNHOLD, ev);
347
275
  promise.resolve(null);
348
276
  } catch (Throwable t) {
349
- promise.reject("VC_UNHOLD", t.getMessage(), t);
277
+ BridgeGuard.reject(promise, "VC_UNHOLD", "Failed to resume call", t);
350
278
  }
351
279
  }
352
280
 
353
281
  @ReactMethod
354
282
  public void mute(int callId, boolean on, Promise promise) {
283
+ if (!BridgeGuard.ensureNative(promise)) return;
284
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
355
285
  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; }
286
+ ManagedCall call = requireCall(callId, promise);
287
+ if (call == null) return;
288
+ if (endpoint == null) {
289
+ promise.reject("VC_NOT_INITIALIZED", "VoipCloud.init() must succeed before mute()");
290
+ return;
291
+ }
359
292
  call.setMuted(endpoint, on);
360
293
  WritableMap ev = Arguments.createMap();
361
294
  ev.putInt("callId", callId);
@@ -363,15 +296,21 @@ public class VoipCloudModule extends ReactContextBaseJavaModule {
363
296
  Events.emit(getReactApplicationContext(), Events.CALL_MUTE, ev);
364
297
  promise.resolve(null);
365
298
  } catch (Throwable t) {
366
- promise.reject("VC_MUTE", t.getMessage(), t);
299
+ BridgeGuard.reject(promise, "VC_MUTE", "Failed to change mute state", t);
367
300
  }
368
301
  }
369
302
 
370
303
  @ReactMethod
371
304
  public void sendDtmf(int callId, String digits, Promise promise) {
305
+ if (!BridgeGuard.ensureNative(promise)) return;
306
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
307
+ if (digits == null || digits.isEmpty()) {
308
+ promise.reject("VC_INVALID_ARGUMENT", "digits must not be empty");
309
+ return;
310
+ }
372
311
  try {
373
- ManagedCall call = calls.get(callId);
374
- if (call == null) { promise.reject("VC_NO_CALL", "unknown callId"); return; }
312
+ ManagedCall call = requireCall(callId, promise);
313
+ if (call == null) return;
375
314
  CallSendDtmfParam p = new CallSendDtmfParam();
376
315
  p.setDigits(digits);
377
316
  call.sendDtmf(p);
@@ -381,48 +320,53 @@ public class VoipCloudModule extends ReactContextBaseJavaModule {
381
320
  Events.emit(getReactApplicationContext(), Events.CALL_DTMF_SENT, ev);
382
321
  promise.resolve(null);
383
322
  } catch (Throwable t) {
384
- promise.reject("VC_DTMF", t.getMessage(), t);
323
+ BridgeGuard.reject(promise, "VC_DTMF", "Failed to send DTMF", t);
385
324
  }
386
325
  }
387
326
 
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
327
  @ReactMethod
394
328
  public void transferCall(int callId, String destinationUri, Promise promise) {
329
+ if (!BridgeGuard.ensureNative(promise)) return;
330
+ if (!BridgeGuard.ensureInited(inited, promise)) return;
331
+ if (destinationUri == null || destinationUri.trim().isEmpty()) {
332
+ promise.reject("VC_INVALID_ARGUMENT", "destinationUri must not be empty");
333
+ return;
334
+ }
395
335
  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));
336
+ ManagedCall call = requireCall(callId, promise);
337
+ if (call == null) return;
338
+ call.xfer(destinationUri.trim(), new CallOpParam(true));
399
339
  WritableMap ev = Arguments.createMap();
400
340
  ev.putInt("callId", callId);
401
- ev.putString("destinationUri", destinationUri);
341
+ ev.putString("destinationUri", destinationUri.trim());
402
342
  Events.emit(getReactApplicationContext(), Events.CALL_TRANSFER, ev);
403
343
  promise.resolve(null);
404
344
  } catch (Throwable t) {
405
- promise.reject("VC_TRANSFER", t.getMessage(), t);
345
+ BridgeGuard.reject(promise, "VC_TRANSFER", "Failed to transfer call", t);
406
346
  }
407
347
  }
408
348
 
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
349
  @ReactMethod
415
350
  public void hangupAllCalls(Promise promise) {
351
+ if (!BridgeGuard.ensureNative(promise)) return;
416
352
  try {
417
- if (endpoint != null) endpoint.hangupAllCalls();
353
+ if (endpoint != null && inited) endpoint.hangupAllCalls();
354
+ calls.clear();
418
355
  promise.resolve(null);
419
356
  } catch (Throwable t) {
420
- promise.reject("VC_HANGUP_ALL", t.getMessage(), t);
357
+ BridgeGuard.reject(promise, "VC_HANGUP_ALL", "Failed to hang up all calls", t);
358
+ }
359
+ }
360
+
361
+ private ManagedCall requireCall(int callId, Promise promise) {
362
+ ManagedCall call = calls.get(callId);
363
+ if (call == null) {
364
+ promise.reject("VC_NO_CALL", "Unknown callId " + callId);
365
+ return null;
421
366
  }
367
+ return call;
422
368
  }
423
369
 
424
- // RN event-emitter contract — required on RN 0.65+ when used with
425
- // NativeEventEmitter on the JS side.
426
370
  @ReactMethod public void addListener(String eventName) { /* no-op */ }
427
371
  @ReactMethod public void removeListeners(int count) { /* no-op */ }
428
372
  }
@@ -0,0 +1,74 @@
1
+ package com.voipcloud.sdk.rn.internal;
2
+
3
+ import com.facebook.react.bridge.Promise;
4
+ import com.facebook.react.bridge.ReadableMap;
5
+
6
+ /**
7
+ * Shared guards and input validation for the RN bridge — reject with clear
8
+ * VC_* codes instead of letting native/JNI exceptions crash the app.
9
+ */
10
+ public final class BridgeGuard {
11
+
12
+ private static volatile boolean nativeLoaded = false;
13
+ private static volatile String nativeLoadError =
14
+ "libvoipcloud.so failed to load (missing ABI, stale build, or AAR not packaged)";
15
+
16
+ /** Loads libvoipcloud.so before any SWIG type (Endpoint, voipcloudJNI). */
17
+ static {
18
+ try {
19
+ System.loadLibrary("voipcloud");
20
+ nativeLoaded = true;
21
+ } catch (UnsatisfiedLinkError e) {
22
+ nativeLoadError = "Failed to load libvoipcloud.so: " + e.getMessage()
23
+ + ". Ensure voipcloud-release.aar is included and the app was rebuilt.";
24
+ } catch (Throwable t) {
25
+ nativeLoadError = "Failed to load libvoipcloud.so: " + t.getMessage();
26
+ }
27
+ }
28
+
29
+ private BridgeGuard() {}
30
+
31
+ public static boolean isNativeLoaded() { return nativeLoaded; }
32
+
33
+ public static String nativeLoadError() { return nativeLoadError; }
34
+
35
+ /** Reject the promise if native .so did not load. Returns false when rejected. */
36
+ public static boolean ensureNative(Promise promise) {
37
+ if (nativeLoaded) return true;
38
+ promise.reject("VC_NATIVE_LOAD", nativeLoadError);
39
+ return false;
40
+ }
41
+
42
+ /** Reject if {@link VoipCloudModule} endpoint is not running. */
43
+ public static boolean ensureInited(boolean inited, Promise promise) {
44
+ if (inited) return true;
45
+ promise.reject("VC_NOT_INITIALIZED",
46
+ "VoipCloud.init() must succeed before calling this method.");
47
+ return false;
48
+ }
49
+
50
+ /**
51
+ * Read a required non-empty string field from a JS config object.
52
+ * Rejects with VC_INVALID_ARGUMENT and returns null on failure.
53
+ */
54
+ public static String requireString(ReadableMap map, String key, Promise promise) {
55
+ if (map == null || !map.hasKey(key) || map.isNull(key)) {
56
+ promise.reject("VC_INVALID_ARGUMENT", "Missing required field: " + key);
57
+ return null;
58
+ }
59
+ String value = map.getString(key);
60
+ if (value == null || value.trim().isEmpty()) {
61
+ promise.reject("VC_INVALID_ARGUMENT", "Field must not be empty: " + key);
62
+ return null;
63
+ }
64
+ return value.trim();
65
+ }
66
+
67
+ /** Reject with a stable code; message includes PJSIP detail when available. */
68
+ public static void reject(Promise promise, String code, String context, Throwable t) {
69
+ String detail = (t != null && t.getMessage() != null && !t.getMessage().isEmpty())
70
+ ? t.getMessage()
71
+ : (t != null ? t.getClass().getSimpleName() : "unknown error");
72
+ promise.reject(code, context + ": " + detail, t);
73
+ }
74
+ }
@@ -32,7 +32,12 @@ public final class Events {
32
32
 
33
33
  public static void emit(ReactContext ctx, String name, WritableMap payload) {
34
34
  if (ctx == null || !ctx.hasActiveReactInstance()) return;
35
- ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
36
- .emit(name, payload);
35
+ // PJSIP fires callbacks on its worker thread — hop to the RN UI queue
36
+ // so DeviceEventEmitter does not crash the app on some RN versions.
37
+ ctx.runOnUiQueueThread(() -> {
38
+ if (!ctx.hasActiveReactInstance()) return;
39
+ ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
40
+ .emit(name, payload);
41
+ });
37
42
  }
38
43
  }