From ea91075107f0cff7c5e58154f94a1423ced7eb89 Mon Sep 17 00:00:00 2001 From: wch423 Date: Mon, 30 Mar 2026 17:29:43 +0200 Subject: [PATCH] Mirror self video during calls; Minor thread-safe fixes --- .../securesms/calls/CallActivity.java | 25 +++++++----- .../securesms/calls/CallCoordinator.java | 22 ++++++++--- .../securesms/calls/CallService.java | 14 ++++++- .../securesms/calls/CallViewModel.java | 27 +++++++------ .../securesms/calls/MediaStreamManager.java | 39 +++++++++++++++++-- .../securesms/webrtc/WebRTCClient.java | 36 ++++++++--------- 6 files changed, 115 insertions(+), 48 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java b/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java index d8f5a2f67..0c1e77d4f 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java @@ -504,6 +504,7 @@ public class CallActivity extends AppCompatActivity { viewModel.getVideoEnabled(), v -> videoConfigChanged.setValue(true)); videoConfigChanged.addSource( viewModel.getRemoteVideoEnabled(), v -> videoConfigChanged.setValue(true)); + videoConfigChanged.addSource(viewModel.getIsFrontCamera(), v -> videoConfigChanged.setValue(true)); // Video layout videoConfigChanged.observe( @@ -705,17 +706,22 @@ public class CallActivity extends AppCompatActivity { VideoTrack localTrack = viewModel.getLocalVideoTrack().getValue(); VideoTrack remoteTrack = viewModel.getRemoteVideoTrack().getValue(); + boolean isFront = Boolean.TRUE.equals(viewModel.getIsFrontCamera().getValue()); + boolean showFullScreen = false; if (state == CallViewModel.CallState.CONNECTED - && remoteTrack != null - && Boolean.TRUE.equals(remoteVideoEnabled)) { + && remoteTrack != null + && Boolean.TRUE.equals(remoteVideoEnabled)) { + remoteVideoView.setMirror(false); remoteTrack.addSink(remoteVideoView); showFullScreen = true; } else if (!coordinator.isIncomingCall() - && (state == CallViewModel.CallState.RINGING || state == CallViewModel.CallState.CONNECTING) - && localTrack != null - && Boolean.TRUE.equals(videoEnabled)) { + && (state == CallViewModel.CallState.RINGING + || state == CallViewModel.CallState.CONNECTING) + && localTrack != null + && Boolean.TRUE.equals(videoEnabled)) { + remoteVideoView.setMirror(isFront); localTrack.addSink(remoteVideoView); showFullScreen = true; } @@ -723,12 +729,13 @@ public class CallActivity extends AppCompatActivity { remoteVideoView.setVisibility(showFullScreen ? View.VISIBLE : View.GONE); boolean showCorner = - state == CallViewModel.CallState.CONNECTED - && localTrack != null - && Boolean.TRUE.equals(videoEnabled) - && !isInPictureInPictureMode(); + state == CallViewModel.CallState.CONNECTED + && localTrack != null + && Boolean.TRUE.equals(videoEnabled) + && !isInPictureInPictureMode(); if (showCorner) { + localVideoView.setMirror(isFront); localTrack.addSink(localVideoView); } diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java b/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java index 7c3a2565e..7c5ac3db2 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java @@ -95,6 +95,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { private final MutableLiveData displayName = new MutableLiveData<>(); private final MutableLiveData displayIcon = new MutableLiveData<>(); private final MutableLiveData outgoingCallPlaced = new MutableLiveData<>(false); + private final MutableLiveData isFrontCamera = new MutableLiveData<>(true); // Audio Routing Support private final MediatorLiveData currentAudioEndpoint = @@ -325,6 +326,10 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { return availableAudioEndpoints; } + public LiveData getIsFrontCamera() { + return isFrontCamera; + } + // State Update Methods (CallService) public void updateConnectionState(PeerConnection.PeerConnectionState state) { @@ -359,6 +364,11 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { isRelayUsed.postValue(isRelay); } + public void updateFrontCamera(boolean front) { + Log.d(TAG, "updateFrontCamera: " + front); + isFrontCamera.postValue(front); + } + public void reportError(String error) { Log.e(TAG, "reportError: " + error); errorMessage.postValue(error); @@ -366,7 +376,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { // Delayed Media Initialization Support - public void startMediaCapture() { + public synchronized void startMediaCapture() { Log.d(TAG, "startMediaCapture"); if (callService != null) { callService.startMediaCapture(); @@ -375,7 +385,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { } } - public void handleCallControlScopeAnswer() { + public synchronized void handleCallControlScopeAnswer() { Log.d(TAG, "handleCallControlScopeAnswer"); if (!isIncomingCall) { @@ -628,7 +638,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { cleanupCall(activeAccId, activeCallId); } - public void setAudioEnabled(boolean enabled) { + public synchronized void setAudioEnabled(boolean enabled) { Log.d(TAG, "setAudioEnabled: " + enabled); localAudioEnabled.postValue(enabled); @@ -640,7 +650,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { } } - public void setVideoEnabled(boolean enabled) { + public synchronized void setVideoEnabled(boolean enabled) { Log.d(TAG, "setVideoEnabled: " + enabled); localVideoEnabled.postValue(enabled); @@ -652,14 +662,14 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { } } - public void switchCamera() { + public synchronized void switchCamera() { Log.d(TAG, "switchCamera"); if (callService != null) { callService.switchCamera(); } } - public void startOutgoingCall() { + public synchronized void startOutgoingCall() { Log.d(TAG, "startOutgoingCall"); if (callService != null) { callService.startOutgoingCall(); diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallService.java b/src/main/java/org/thoughtcrime/securesms/calls/CallService.java index fa18aabad..3265a5c74 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallService.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallService.java @@ -164,6 +164,8 @@ public class CallService extends Service implements WebRTCClient.Callbacks { webRTCClient.setLocalMediaStream(stream); + callCoordinator.updateFrontCamera(mediaStreamManager.isFrontCamera()); + callCoordinator.setVideoEnabled(startsWithVideo); if (!stream.videoTracks.isEmpty()) { @@ -410,7 +412,17 @@ public class CallService extends Service implements WebRTCClient.Callbacks { Log.d(TAG, "switchCamera"); if (mediaStreamManager != null) { - mediaStreamManager.switchCamera(); + mediaStreamManager.switchCamera(new MediaStreamManager.CameraSwitchCallback() { + @Override + public void onCameraSwitch(boolean isFrontCamera) { + callCoordinator.updateFrontCamera(isFrontCamera); + } + + @Override + public void onError(String error) { + Log.e(TAG, "Camera switch failed: " + error); + } + }); } } diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java b/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java index 74e25f5de..fdf392cf9 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java @@ -5,6 +5,7 @@ import android.graphics.drawable.Icon; import android.os.Build; import android.telecom.DisconnectCause; import android.util.Log; + import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.core.telecom.CallEndpointCompat; @@ -12,10 +13,13 @@ import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; import androidx.lifecycle.MediatorLiveData; import androidx.lifecycle.Observer; -import java.util.List; + import org.webrtc.PeerConnection; import org.webrtc.VideoTrack; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + @RequiresApi(api = Build.VERSION_CODES.O) public class CallViewModel extends AndroidViewModel { @@ -38,6 +42,7 @@ public class CallViewModel extends AndroidViewModel { private final LiveData outgoingCallPlaced; private final LiveData currentAudioEndpoint; private final LiveData> availableAudioEndpoints; + private final LiveData isFrontCamera; // Translated from coordinator's connectionState private final MediatorLiveData callState; @@ -46,7 +51,7 @@ public class CallViewModel extends AndroidViewModel { private Observer answerCallObserver; private Observer startOutgoingCallObserver; - private boolean hasCallEnded = false; + private final AtomicBoolean hasCallEnded = new AtomicBoolean(false); // User-facing call states public enum CallState { @@ -79,6 +84,7 @@ public class CallViewModel extends AndroidViewModel { this.outgoingCallPlaced = callCoordinator.getOutgoingCallPlaced(); this.currentAudioEndpoint = callCoordinator.getCurrentAudioEndpoint(); this.availableAudioEndpoints = callCoordinator.getAvailableAudioEndpoints(); + this.isFrontCamera = callCoordinator.getIsFrontCamera(); this.callState = new MediatorLiveData<>(CallState.INITIALIZING); @@ -113,9 +119,7 @@ public class CallViewModel extends AndroidViewModel { if (state == PeerConnection.PeerConnectionState.FAILED || state == PeerConnection.PeerConnectionState.CLOSED) { - if (!hasCallEnded) { - hasCallEnded = true; - } + hasCallEnded.set(true); } }); @@ -257,11 +261,10 @@ public class CallViewModel extends AndroidViewModel { public void declineCall() { Log.d(TAG, "declineCall"); - if (hasCallEnded) { + if (!hasCallEnded.compareAndSet(false, true)) { Log.w(TAG, "Call already ended"); return; } - hasCallEnded = true; callCoordinator.declineCall(); } @@ -269,11 +272,10 @@ public class CallViewModel extends AndroidViewModel { public void hangUp() { Log.d(TAG, "hangUp"); - if (hasCallEnded) { + if (!hasCallEnded.compareAndSet(false, true)) { Log.w(TAG, "Call already ended"); return; } - hasCallEnded = true; callCoordinator.hangUp(); } @@ -354,8 +356,7 @@ public class CallViewModel extends AndroidViewModel { public void onCallDisconnected(DisconnectCause disconnectCause) { Log.d(TAG, "onCallDisconnected callback from CallControlScope, cause: " + disconnectCause); - if (!hasCallEnded) { - hasCallEnded = true; + if (hasCallEnded.compareAndSet(false, true)) { callState.postValue(CallState.ENDED); } } @@ -414,6 +415,10 @@ public class CallViewModel extends AndroidViewModel { return availableAudioEndpoints; } + public LiveData getIsFrontCamera() { + return isFrontCamera; + } + // Notification Action Handlers public void handleNotificationAnswer() { diff --git a/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java b/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java index 44a436782..f0517f1cb 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java @@ -1,9 +1,12 @@ package org.thoughtcrime.securesms.calls; import android.content.Context; +import android.os.Build; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + import org.thoughtcrime.securesms.EglUtils; import org.webrtc.AudioSource; import org.webrtc.AudioTrack; @@ -31,6 +34,7 @@ public class MediaStreamManager { private VideoSource videoSource; private AudioSource audioSource; private SurfaceTextureHelper surfaceTextureHelper; + private volatile boolean isFrontCamera = true; public interface Callback { void onMediaStreamReady(MediaStream stream); @@ -38,6 +42,11 @@ public class MediaStreamManager { void onError(String error); } + public interface CameraSwitchCallback { + void onCameraSwitch(boolean isFrontCamera); + void onError(String error); + } + public MediaStreamManager(@NonNull Context context, PeerConnectionFactory peerConnectionFactory) { this.context = context.getApplicationContext(); @@ -45,6 +54,7 @@ public class MediaStreamManager { } /** Create media stream with audio and optionally video */ + @RequiresApi(api = Build.VERSION_CODES.M) public void createMediaStream(Callback callback) { try { MediaStream mediaStream = peerConnectionFactory.createLocalMediaStream(STREAM_ID); @@ -91,6 +101,7 @@ public class MediaStreamManager { if (enumerator.isFrontFacing(deviceName)) { VideoCapturer capturer = enumerator.createCapturer(deviceName, null); if (capturer != null) { + isFrontCamera = true; return capturer; } } @@ -100,6 +111,7 @@ public class MediaStreamManager { for (String deviceName : deviceNames) { VideoCapturer capturer = enumerator.createCapturer(deviceName, null); if (capturer != null) { + isFrontCamera = enumerator.isFrontFacing(deviceName); return capturer; } } @@ -107,14 +119,35 @@ public class MediaStreamManager { return null; } - public void switchCamera() { + public void switchCamera(@Nullable CameraSwitchCallback callback) { if (videoCapturer instanceof CameraVideoCapturer) { CameraVideoCapturer cameraVideoCapturer = (CameraVideoCapturer) videoCapturer; - cameraVideoCapturer.switchCamera(null); - Log.d(TAG, "Camera switched"); + cameraVideoCapturer.switchCamera(new CameraVideoCapturer.CameraSwitchHandler() { + @Override + public void onCameraSwitchDone(boolean isFront) { + isFrontCamera = isFront; + Log.d(TAG, "Camera switched, isFront: " + isFront); + if (callback != null) { + callback.onCameraSwitch(isFront); + } + } + + @Override + public void onCameraSwitchError(String errorDescription) { + Log.e(TAG, "Camera switch error: " + errorDescription); + if (callback != null) { + callback.onError(errorDescription); + } + } + }); + Log.d(TAG, "Camera switch requested"); } } + public boolean isFrontCamera() { + return isFrontCamera; + } + /** Cleanup resources */ public void dispose() { if (videoCapturer != null) { diff --git a/src/main/java/org/thoughtcrime/securesms/webrtc/WebRTCClient.java b/src/main/java/org/thoughtcrime/securesms/webrtc/WebRTCClient.java index f64fd2f59..b31be1d99 100644 --- a/src/main/java/org/thoughtcrime/securesms/webrtc/WebRTCClient.java +++ b/src/main/java/org/thoughtcrime/securesms/webrtc/WebRTCClient.java @@ -15,6 +15,8 @@ import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -57,34 +59,34 @@ public class WebRTCClient { private final Context context; private final Handler mainHandler; private PeerConnectionFactory peerConnectionFactory; - private PeerConnection peerConnection; - private List iceServers; + private volatile PeerConnection peerConnection; + private volatile List iceServers; private DataChannel iceTricklingDataChannel; private DataChannel mutedStateDataChannel; // ICE candidate private final List iceCandidateBuffer; - private boolean iceTricklingChannelOpen; - private boolean mutedStateChannelOpen; - private boolean enableIceTrickling; - private boolean isEnded = false; + private volatile boolean iceTricklingChannelOpen; + private volatile boolean mutedStateChannelOpen; + private volatile boolean enableIceTrickling; + private final AtomicBoolean isEnded = new AtomicBoolean(false); // ICE gathering private volatile boolean isIceGatheringComplete; private volatile boolean hasRelayCandidate; private volatile boolean hasSrflxCandidate; private volatile boolean hasHostCandidate; - private CountDownLatch iceGatheringLatch; - private CountDownLatch relayCandidateLatch; - private CountDownLatch srflxCandidateLatch; + private volatile CountDownLatch iceGatheringLatch; + private volatile CountDownLatch relayCandidateLatch; + private volatile CountDownLatch srflxCandidateLatch; // Media private MediaStream localStream; - private VideoTrack localVideoTrack; - private AudioTrack localAudioTrack; - private VideoTrack remoteVideoTrack; - private AudioTrack remoteAudioTrack; + private volatile VideoTrack localVideoTrack; + private volatile AudioTrack localAudioTrack; + private volatile VideoTrack remoteVideoTrack; + private volatile AudioTrack remoteAudioTrack; // Callbacks to ViewModel private final Callbacks callbacks; @@ -644,7 +646,7 @@ public class WebRTCClient { boolean gotIce = waitForEnoughIce(); synchronized (WebRTCClient.this) { - if (isEnded || peerConnection == null) { + if (isEnded.get() || peerConnection == null) { Log.d(TAG, "Call ended during ICE gathering, aborting"); return; } @@ -803,7 +805,7 @@ public class WebRTCClient { boolean gotIce = waitForEnoughIce(); synchronized (WebRTCClient.this) { - if (isEnded || peerConnection == null) { + if (isEnded.get() || peerConnection == null) { Log.d(TAG, "Call ended during ICE gathering, aborting"); return; } @@ -939,12 +941,10 @@ public class WebRTCClient { // Cleanup public synchronized void endCall() { - if (isEnded) { + if (!isEnded.compareAndSet(false, true)) { Log.d(TAG, "endCall() already called, skipping"); return; } - - isEnded = true; Log.d(TAG, "Ending call"); // Unblock any thread waiting in waitForEnoughIce()