Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
================================

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,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;
Expand Down Expand Up @@ -82,8 +84,9 @@ public Call getVoiceCall() {
return this.voiceCall;
}
public final Map<String, String> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,15 @@ 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(
context,
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)
Expand Down
114 changes: 73 additions & 41 deletions android/src/main/java/com/twiliovoicereactnative/VoiceService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -102,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;
}
Expand Down Expand Up @@ -205,6 +223,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,
Expand Down Expand Up @@ -240,7 +270,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)));
Expand Down Expand Up @@ -386,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);
Expand Down