Mirror self video during calls; Minor thread-safe fixes

This commit is contained in:
wch423
2026-03-30 17:29:43 +02:00
parent f7244c2152
commit ea91075107
6 changed files with 115 additions and 48 deletions
@@ -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);
}
@@ -95,6 +95,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
private final MutableLiveData<String> displayName = new MutableLiveData<>();
private final MutableLiveData<Icon> displayIcon = new MutableLiveData<>();
private final MutableLiveData<Boolean> outgoingCallPlaced = new MutableLiveData<>(false);
private final MutableLiveData<Boolean> isFrontCamera = new MutableLiveData<>(true);
// Audio Routing Support
private final MediatorLiveData<CallEndpointCompat> currentAudioEndpoint =
@@ -325,6 +326,10 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
return availableAudioEndpoints;
}
public LiveData<Boolean> 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();
@@ -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);
}
});
}
}
@@ -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<Boolean> outgoingCallPlaced;
private final LiveData<CallEndpointCompat> currentAudioEndpoint;
private final LiveData<List<CallEndpointCompat>> availableAudioEndpoints;
private final LiveData<Boolean> isFrontCamera;
// Translated from coordinator's connectionState
private final MediatorLiveData<CallState> callState;
@@ -46,7 +51,7 @@ public class CallViewModel extends AndroidViewModel {
private Observer<VideoTrack> answerCallObserver;
private Observer<VideoTrack> 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<Boolean> getIsFrontCamera() {
return isFrontCamera;
}
// Notification Action Handlers
public void handleNotificationAnswer() {
@@ -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) {
@@ -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<PeerConnection.IceServer> iceServers;
private volatile PeerConnection peerConnection;
private volatile List<PeerConnection.IceServer> iceServers;
private DataChannel iceTricklingDataChannel;
private DataChannel mutedStateDataChannel;
// ICE candidate
private final List<IceCandidate> 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()