From 72bd7376ca9d4f1fca6afce36cda50cc97f5fe28 Mon Sep 17 00:00:00 2001 From: wchen342 Date: Thu, 7 May 2026 12:29:27 -0400 Subject: [PATCH] Make it possible to answer incoming calls from messages (#4415) --- CHANGELOG.md | 1 + .../securesms/ConversationItem.java | 13 ++- .../securesms/calls/CallCoordinator.java | 87 ++++++++++++++----- 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc7cdb3f6..0a74c0d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Better incoming call system integration * Calls are not experimental anymore and don't need to be manually enabled +* Calls can be answered by tapping messages * Channels are no longer experimental and are available by default * Display a permanent notification when doing location streaming and get rid of dangerous "Access Location in Background" permission * Autoplay all voice messages in a chat diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java index 38cc4d13c..da451cd20 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java @@ -1091,7 +1091,18 @@ public class ConversationItem extends BaseConversationItem { if (!messageRecord.isOutgoing() && callInfo.state instanceof CallState.Alerting) { int callId = messageRecord.getId(); CallCoordinator coordinator = CallCoordinator.getInstance(context); - coordinator.showIncomingCallScreen(callId); + + if (coordinator.hasActiveCall()) { + coordinator.showIncomingCallScreen(callId); + } else { + if (callInfo.sdpOffer == null) { + Toast.makeText(context, R.string.error, Toast.LENGTH_SHORT).show(); + return; + } + int accId = dcContext.getAccountId(); + coordinator.handleIncomingCallFromConversation( + accId, callId, callInfo.sdpOffer, callInfo.hasVideo); + } } else { if (callInfo.hasVideo) { CallUtil.startVideoCall(getContext(), chatId); diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java b/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java index 45004eda0..9b4414ff5 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java @@ -23,6 +23,7 @@ import android.os.Looper; import android.telecom.DisconnectCause; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.core.app.NotificationManagerCompat; import androidx.core.content.ContextCompat; @@ -32,6 +33,7 @@ import androidx.core.telecom.CallControlScope; import androidx.core.telecom.CallEndpointCompat; import androidx.core.telecom.CallException; import androidx.core.telecom.CallsManager; +import androidx.core.util.Pair; import androidx.lifecycle.FlowLiveDataConversions; import androidx.lifecycle.LiveData; import androidx.lifecycle.MediatorLiveData; @@ -898,32 +900,15 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { int accId, int callId, String offerSdp, boolean startsWithVideo) { Log.d(TAG, "onIncomingCall: accId=" + accId + ", callId=" + callId); - if (hasActiveCall()) { - Log.w(TAG, "Already have an active call, ignoring incoming call"); - return; - } + Pair result = setupIncomingCallState(accId, callId, offerSdp, startsWithVideo); + if (result == null) return; - resetLiveDataForNewCall(); - - this.activeAccId = accId; - this.activeCallId = callId; - this.isIncomingCall = true; - this.startsWithVideo = startsWithVideo; - this.pendingOfferSdp = offerSdp; - - // Get caller info - DcContext dcContext = ApplicationContext.getDcAccounts().getAccount(accId); - int chatId = dcContext.getMsg(callId).getChatId(); - this.activeChatId = chatId; - DcChat dcChat = dcContext.getChat(chatId); - String callerName = getNameFromChat(dcChat); + DcChat dcChat = result.first; + String callerName = result.second; Icon callerIcon = getIconFromChat(this.appContext, dcChat); - displayName.postValue(callerName); displayIcon.postValue(callerIcon); - this.preferredStartingEndpoint = getPreferredStartingEndpoint(startsWithVideo); - // Add to CallsManager CallAttributesCompat callAttributes = createCallAttributes(callerName, callId, true); addCallToTelecom(callAttributes, callerName, callerIcon); @@ -934,6 +919,37 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { startAndBindService(); } + public synchronized void handleIncomingCallFromConversation( + int accId, int callId, String offerSdp, boolean hasVideo) { + Log.d(TAG, "handleIncomingCallFromConversation: accId=" + accId + ", callId=" + callId); + + if (offerSdp == null || offerSdp.isEmpty()) { + Log.e(TAG, "Cannot start incoming call: no SDP offer"); + return; + } + + Pair result = setupIncomingCallState(accId, callId, offerSdp, hasVideo); + if (result == null) return; + + DcChat dcChat = result.first; + String callerName = result.second; + + new Thread( + () -> { + Icon callerIcon = getIconFromChat(this.appContext, dcChat); + displayIcon.postValue(callerIcon); + }) + .start(); + + // Add to CallsManager + CallAttributesCompat callAttributes = createCallAttributes(callerName, callId, true); + addCallToTelecom(callAttributes, callerName, null); + + startAndBindService(); + + launchCallActivity(); + } + private synchronized void onIncomingCallAccepted(int callId, boolean fromThisDevice) { Log.d(TAG, "onIncomingCallAccepted: callId=" + callId + ", fromThisDevice=" + fromThisDevice); @@ -1304,6 +1320,35 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { addCallToTelecom(callAttributes, calleeName, calleeIcon); } + @Nullable + private Pair setupIncomingCallState( + int accId, int callId, String offerSdp, boolean startsWithVideo) { + if (hasActiveCall()) { + Log.w(TAG, "Already have an active call, ignoring incoming call"); + return null; + } + + resetLiveDataForNewCall(); + + this.activeAccId = accId; + this.activeCallId = callId; + this.isIncomingCall = true; + this.startsWithVideo = startsWithVideo; + this.pendingOfferSdp = offerSdp; + + DcContext dcContext = ApplicationContext.getDcAccounts().getAccount(accId); + int chatId = dcContext.getMsg(callId).getChatId(); + this.activeChatId = chatId; + DcChat dcChat = dcContext.getChat(chatId); + String callerName = getNameFromChat(dcChat); + + displayName.postValue(callerName); + + this.preferredStartingEndpoint = getPreferredStartingEndpoint(startsWithVideo); + + return new Pair<>(dcChat, callerName); + } + public synchronized void ensureServiceStarted() { if (isServiceBound || !hasActiveCall()) { return;