From 82cfb2aad689ff3dcd5d8dafbdfe5fb4acaf3901 Mon Sep 17 00:00:00 2001 From: Nick Hibberd Date: Thu, 20 Aug 2026 10:06:23 -0500 Subject: [PATCH 1/3] fix: guard stale Android call invite acceptance --- CHANGELOG.md | 11 ++++++++++ .../CallInviteModuleProxy.java | 22 +++++++++---------- .../CallRecordDatabase.java | 4 +++- .../twiliovoicereactnative/VoiceService.java | 15 ++++++++++++- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e641256..4680489e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +2.0.0-preview.3 (In Progress) +================================ + +## Fixes + +### Platform Specific Fixes + +#### Android + +- Prevented null pointer crashes when a CallInvite is cancelled while JavaScript acceptance is queued. + 2.0.0-preview.2 (April 29, 2026) ================================ diff --git a/android/src/main/java/com/twiliovoicereactnative/CallInviteModuleProxy.java b/android/src/main/java/com/twiliovoicereactnative/CallInviteModuleProxy.java index d0a6f51d1..1f95b4f86 100644 --- a/android/src/main/java/com/twiliovoicereactnative/CallInviteModuleProxy.java +++ b/android/src/main/java/com/twiliovoicereactnative/CallInviteModuleProxy.java @@ -29,19 +29,19 @@ private void getCallRecord( final UUID uuid = UUID.fromString(uuidStr); - CallRecordDatabase.CallRecord callRecord = VoiceApplicationProxy - .getCallRecordDatabase() - .get(new CallRecordDatabase.CallRecord(uuid)); - - if (null == callRecord || null == callRecord.getCallInvite()) { - final String warningMsg = this.reactApplicationContext - .getString(R.string.missing_callinvite_uuid, uuid); - promise.rejectWithName(CommonConstants.ErrorCodeInvalidArgumentError, warningMsg); - return; - } - mainHandler.post(() -> { logger.debug(String.format(".getCallRecord(%s) > runnable", uuid)); + CallRecordDatabase.CallRecord callRecord = VoiceApplicationProxy + .getCallRecordDatabase() + .get(new CallRecordDatabase.CallRecord(uuid)); + + if (null == callRecord || null == callRecord.getCallInvite()) { + final String warningMsg = this.reactApplicationContext + .getString(R.string.missing_callinvite_uuid, uuid); + promise.rejectWithName(CommonConstants.ErrorCodeInvalidArgumentError, warningMsg); + return; + } + onSuccess.accept(callRecord); }); } diff --git a/android/src/main/java/com/twiliovoicereactnative/CallRecordDatabase.java b/android/src/main/java/com/twiliovoicereactnative/CallRecordDatabase.java index 75ef670fe..a82b94eba 100644 --- a/android/src/main/java/com/twiliovoicereactnative/CallRecordDatabase.java +++ b/android/src/main/java/com/twiliovoicereactnative/CallRecordDatabase.java @@ -5,6 +5,7 @@ import static com.twiliovoicereactnative.CallRecordDatabase.CallRecord.CallInviteState.USED; import java.util.Collection; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.UUID; @@ -82,8 +83,9 @@ public Call getVoiceCall() { return this.voiceCall; } public final Map getCustomParameters() { + final CallInvite currentCallInvite = this.callInvite; if (this.direction == Direction.INCOMING) { - return this.callInvite.getCustomParameters(); + return null == currentCallInvite ? Collections.emptyMap() : currentCallInvite.getCustomParameters(); } return this.customParameters; } diff --git a/android/src/main/java/com/twiliovoicereactnative/VoiceService.java b/android/src/main/java/com/twiliovoicereactnative/VoiceService.java index 3eff434bf..3f11783e3 100644 --- a/android/src/main/java/com/twiliovoicereactnative/VoiceService.java +++ b/android/src/main/java/com/twiliovoicereactnative/VoiceService.java @@ -55,6 +55,7 @@ import com.facebook.react.bridge.WritableMap; import com.twilio.voice.AcceptOptions; import com.twilio.voice.Call; +import com.twilio.voice.CallInvite; import com.twilio.voice.ConnectOptions; import com.twilio.voice.Voice; @@ -205,6 +206,18 @@ private void incomingCall(final CallRecordDatabase.CallRecord callRecord) { } private void acceptCall(final CallRecordDatabase.CallRecord callRecord) { logger.debug("acceptCall: " + callRecord.getUuid()); + final CallInvite callInvite = callRecord.getCallInvite(); + + if (null == callInvite || + CallRecordDatabase.CallRecord.CallInviteState.ACTIVE != callRecord.getCallInviteState()) { + logger.warning("Call not accepted because the CallInvite is no longer active"); + if (null != callRecord.getCallAcceptedPromise()) { + callRecord.getCallAcceptedPromise().rejectWithName( + CommonConstants.ErrorCodeInvalidStateError, + "Attempt to accept a settled call invite"); + } + return; + } // verify that mic permissions have been granted and if not, throw a error if (ActivityCompat.checkSelfPermission(VoiceService.this, @@ -240,7 +253,7 @@ private void acceptCall(final CallRecordDatabase.CallRecord callRecord) { .build(); callRecord.setCall( - callRecord.getCallInvite().accept( + callInvite.accept( VoiceService.this, acceptOptions, new CallListenerProxy(callRecord.getUuid(), VoiceService.this))); From 297bd9a5dccfc9e8f54bec50a8f8a45a53f87b6f Mon Sep 17 00:00:00 2001 From: Nick Hibberd Date: Thu, 20 Aug 2026 15:06:34 -0500 Subject: [PATCH 2/3] fix: ignore stale Android call notifications --- .../CallRecordDatabase.java | 3 +- .../twiliovoicereactnative/VoiceService.java | 99 +++++++++++-------- 2 files changed, 61 insertions(+), 41 deletions(-) diff --git a/android/src/main/java/com/twiliovoicereactnative/CallRecordDatabase.java b/android/src/main/java/com/twiliovoicereactnative/CallRecordDatabase.java index a82b94eba..bdae4486f 100644 --- a/android/src/main/java/com/twiliovoicereactnative/CallRecordDatabase.java +++ b/android/src/main/java/com/twiliovoicereactnative/CallRecordDatabase.java @@ -23,10 +23,11 @@ class CallRecordDatabase { public static class CallRecord { public enum CallInviteState { NONE, ACTIVE, USED } public enum Direction { INCOMING, OUTGOING } + public static final int INVALID_NOTIFICATION_ID = -1; private final UUID uuid; private String callSid = null; private Date timestamp = null; - private int notificationId = -1; + private int notificationId = INVALID_NOTIFICATION_ID; private Call voiceCall = null; private String callRecipient = ""; private CallInvite callInvite = null; diff --git a/android/src/main/java/com/twiliovoicereactnative/VoiceService.java b/android/src/main/java/com/twiliovoicereactnative/VoiceService.java index 3f11783e3..010b29374 100644 --- a/android/src/main/java/com/twiliovoicereactnative/VoiceService.java +++ b/android/src/main/java/com/twiliovoicereactnative/VoiceService.java @@ -103,45 +103,62 @@ public Context getServiceContext() { public int onStartCommand(Intent intent, int flags, int startId) { // apparently the system can recreate the service without sending it an intent so protect // against that case (GH-430). - if (null != intent) { - switch (Objects.requireNonNull(intent.getAction())) { - case ACTION_INCOMING_CALL: - incomingCall(getCallRecord(Objects.requireNonNull(getMessageUUID(intent)))); - break; - case ACTION_ACCEPT_CALL: - try { - acceptCall(getCallRecord(Objects.requireNonNull(getMessageUUID(intent)))); - } catch (SecurityException e) { - sendPermissionsError(); - logger.warning(e, "Cannot accept call, lacking necessary permissions"); - } - break; - case ACTION_REJECT_CALL: - rejectCall(getCallRecord(Objects.requireNonNull(getMessageUUID(intent)))); - break; - case ACTION_CANCEL_CALL: - cancelCall(getCallRecord(Objects.requireNonNull(getMessageUUID(intent)))); - break; - case ACTION_CALL_DISCONNECT: - disconnect(getCallRecord(Objects.requireNonNull(getMessageUUID(intent)))); - break; - case ACTION_RAISE_OUTGOING_CALL_NOTIFICATION: - raiseOutgoingCallNotification(getCallRecord(Objects.requireNonNull(getMessageUUID(intent)))); - break; - case ACTION_CANCEL_ACTIVE_CALL_NOTIFICATION: - cancelActiveCallNotification(getCallRecord(Objects.requireNonNull(getMessageUUID(intent)))); - break; - case ACTION_FOREGROUND_AND_DEPRIORITIZE_INCOMING_CALL_NOTIFICATION: - foregroundAndDeprioritizeIncomingCallNotification( - getCallRecord(Objects.requireNonNull(getMessageUUID(intent)))); - break; - case ACTION_PUSH_APP_TO_FOREGROUND: - logger.warning("VoiceService received foreground request, ignoring"); - break; - default: - logger.log("Unknown notification, ignoring"); - break; + if (null == intent) { + return START_NOT_STICKY; + } + + final int notificationId = getMessageNotificationId(intent); + final String action = intent.getAction(); + final UUID messageUUID = getMessageUUID(intent); + final CallRecordDatabase.CallRecord callRecord = (null == messageUUID) + ? null + : getCallRecordDatabase().get(new CallRecordDatabase.CallRecord(messageUUID)); + + if (null == action || null == messageUUID || null == callRecord) { + logger.warning( + "Ignoring intent; missing action, uuid, or call record. action=" + action + ", uuid=" + messageUUID); + if (CallRecordDatabase.CallRecord.INVALID_NOTIFICATION_ID != notificationId) { + removeNotification(notificationId); } + return START_NOT_STICKY; + } + + switch (action) { + case ACTION_INCOMING_CALL: + incomingCall(callRecord); + break; + case ACTION_ACCEPT_CALL: + try { + acceptCall(callRecord); + } catch (SecurityException e) { + sendPermissionsError(); + logger.warning(e, "Cannot accept call, lacking necessary permissions"); + } + break; + case ACTION_REJECT_CALL: + rejectCall(callRecord); + break; + case ACTION_CANCEL_CALL: + cancelCall(callRecord); + break; + case ACTION_CALL_DISCONNECT: + disconnect(callRecord); + break; + case ACTION_RAISE_OUTGOING_CALL_NOTIFICATION: + raiseOutgoingCallNotification(callRecord); + break; + case ACTION_CANCEL_ACTIVE_CALL_NOTIFICATION: + cancelActiveCallNotification(callRecord); + break; + case ACTION_FOREGROUND_AND_DEPRIORITIZE_INCOMING_CALL_NOTIFICATION: + foregroundAndDeprioritizeIncomingCallNotification(callRecord); + break; + case ACTION_PUSH_APP_TO_FOREGROUND: + logger.warning("VoiceService received foreground request, ignoring"); + break; + default: + logger.log("Unknown notification, ignoring"); + break; } return START_NOT_STICKY; } @@ -399,8 +416,10 @@ private void foregroundNotification(int id, Notification notification) { private static UUID getMessageUUID(@NonNull final Intent intent) { return (UUID)intent.getSerializableExtra(Constants.MSG_KEY_UUID); } - private static CallRecordDatabase.CallRecord getCallRecord(final UUID uuid) { - return Objects.requireNonNull(getCallRecordDatabase().get(new CallRecordDatabase.CallRecord(uuid))); + private static int getMessageNotificationId(@NonNull final Intent intent) { + return intent.getIntExtra( + Constants.MSG_KEY_NOTIFICATION_ID, + CallRecordDatabase.CallRecord.INVALID_NOTIFICATION_ID); } private static void sendJSEvent(@NonNull String scope, @NonNull WritableMap event) { getJSEventEmitter().sendEvent(scope, event); From 64d4a8899092e588732eedecfa1cf9817cf6e778 Mon Sep 17 00:00:00 2001 From: Nick Hibberd Date: Thu, 20 Aug 2026 15:35:15 -0500 Subject: [PATCH 3/3] fix: preserve stale notification metadata --- android/src/main/java/com/twiliovoicereactnative/Constants.java | 1 + .../java/com/twiliovoicereactnative/NotificationUtility.java | 2 ++ 2 files changed, 3 insertions(+) diff --git a/android/src/main/java/com/twiliovoicereactnative/Constants.java b/android/src/main/java/com/twiliovoicereactnative/Constants.java index 6b9a9f026..b29a59b06 100644 --- a/android/src/main/java/com/twiliovoicereactnative/Constants.java +++ b/android/src/main/java/com/twiliovoicereactnative/Constants.java @@ -15,6 +15,7 @@ class Constants { public static final String ACTION_PUSH_APP_TO_FOREGROUND = "ACTION_PUSH_APP_TO_FOREGROUND"; public static final String ACTION_FOREGROUND_AND_DEPRIORITIZE_INCOMING_CALL_NOTIFICATION = "ACTION_FOREGROUND_AND_DEPRIORITIZE_INCOMING_CALL_NOTIFICATION"; public static final String MSG_KEY_UUID = "UUID"; + public static final String MSG_KEY_NOTIFICATION_ID = "NOTIFICATION_ID"; public static final String JS_EVENT_KEY_CALL_INFO = "call"; public static final String JS_EVENT_KEY_CALL_INVITE_INFO = "callInvite"; public static final String JS_EVENT_KEY_CANCELLED_CALL_INVITE_INFO = "cancelledCallInvite"; diff --git a/android/src/main/java/com/twiliovoicereactnative/NotificationUtility.java b/android/src/main/java/com/twiliovoicereactnative/NotificationUtility.java index 782b76e21..519741631 100644 --- a/android/src/main/java/com/twiliovoicereactnative/NotificationUtility.java +++ b/android/src/main/java/com/twiliovoicereactnative/NotificationUtility.java @@ -156,6 +156,7 @@ public static Notification createIncomingCallNotification(@NonNull Context conte Constants.ACTION_REJECT_CALL, VoiceService.class, callRecord.getUuid()); + rejectIntent.putExtra(Constants.MSG_KEY_NOTIFICATION_ID, callRecord.getNotificationId()); PendingIntent piRejectIntent = constructPendingIntentForService(context, rejectIntent); Intent acceptIntent = constructMessage( @@ -163,6 +164,7 @@ public static Notification createIncomingCallNotification(@NonNull Context conte Constants.ACTION_ACCEPT_CALL, Objects.requireNonNull(VoiceApplicationProxy.getMainActivityClass()), callRecord.getUuid()); + acceptIntent.putExtra(Constants.MSG_KEY_NOTIFICATION_ID, callRecord.getNotificationId()); PendingIntent piAcceptIntent = constructPendingIntentForActivity(context, acceptIntent); return constructNotificationBuilder(context, channelImportance)