package org.thoughtcrime.securesms.calls; import android.app.Application; 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; import androidx.lifecycle.AndroidViewModel; import androidx.lifecycle.LiveData; import androidx.lifecycle.MediatorLiveData; import androidx.lifecycle.Observer; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import org.webrtc.PeerConnection; import org.webrtc.VideoTrack; @RequiresApi(api = Build.VERSION_CODES.O) public class CallViewModel extends AndroidViewModel { private static final String TAG = "CallViewModel"; private final CallCoordinator callCoordinator; // UI State LiveData (from Coordinator) private final LiveData localVideoTrack; private final LiveData remoteVideoTrack; private final LiveData localAudioEnabled; private final LiveData localVideoEnabled; private final LiveData remoteAudioEnabled; private final LiveData remoteVideoEnabled; private final LiveData isRelayUsed; private final LiveData errorMessage; private final LiveData displayName; private final LiveData displayIcon; private final LiveData outgoingCallPlaced; private final LiveData answeredElsewhere; private final LiveData currentAudioEndpoint; private final LiveData> availableAudioEndpoints; private final LiveData isFrontCamera; // Translated from coordinator's connectionState private final MediatorLiveData callState; // Observer References for one-time observe private Observer answerCallObserver; private Observer startOutgoingCallObserver; private final AtomicBoolean hasCallEnded = new AtomicBoolean(false); // User-facing call states public enum CallState { INITIALIZING, PROMPTING_USER_ACCEPT, RINGING, CONNECTING, CONNECTED, RECONNECTING, ANSWERED_ELSEWHERE, ENDED, ERROR } public CallViewModel(@NonNull Application application) { super(application); this.callCoordinator = CallCoordinator.getInstance(application); // Setup LiveData observations this.localVideoTrack = callCoordinator.getLocalVideoTrack(); this.remoteVideoTrack = callCoordinator.getRemoteVideoTrack(); this.localAudioEnabled = callCoordinator.getLocalAudioEnabled(); this.localVideoEnabled = callCoordinator.getLocalVideoEnabled(); this.remoteAudioEnabled = callCoordinator.getRemoteAudioEnabled(); this.remoteVideoEnabled = callCoordinator.getRemoteVideoEnabled(); this.isRelayUsed = callCoordinator.getIsRelayUsed(); this.errorMessage = callCoordinator.getErrorMessage(); this.displayName = callCoordinator.getDisplayName(); this.displayIcon = callCoordinator.getDisplayIcon(); this.outgoingCallPlaced = callCoordinator.getOutgoingCallPlaced(); this.answeredElsewhere = callCoordinator.getAnsweredElsewhere(); this.currentAudioEndpoint = callCoordinator.getCurrentAudioEndpoint(); this.availableAudioEndpoints = callCoordinator.getAvailableAudioEndpoints(); this.isFrontCamera = callCoordinator.getIsFrontCamera(); this.callState = new MediatorLiveData<>(CallState.INITIALIZING); setupConnectionStateObserver(); Log.d(TAG, "CallViewModel created"); } public void initialize() { Log.d(TAG, "Initializing CallViewModel"); callCoordinator.setActiveCallViewModel(this); if (callCoordinator.isIncomingCall()) { callState.setValue(CallState.PROMPTING_USER_ACCEPT); } else { callState.setValue(CallState.CONNECTING); } Log.d(TAG, "CallViewModel initialized"); } private void setupConnectionStateObserver() { callState.addSource( callCoordinator.getConnectionState(), state -> { if (callState.getValue() == CallState.ANSWERED_ELSEWHERE) { return; } CallState newState = translateConnectionState(state); if (callState.getValue() != newState) { callState.setValue(newState); } if (state == PeerConnection.PeerConnectionState.FAILED || state == PeerConnection.PeerConnectionState.CLOSED) { hasCallEnded.set(true); } }); callState.addSource( outgoingCallPlaced, placed -> { if (Boolean.TRUE.equals(placed) && !callCoordinator.isIncomingCall()) { if (callState.getValue() == CallState.CONNECTING) { callState.setValue(CallState.RINGING); } } }); callState.addSource( answeredElsewhere, value -> { if (Boolean.TRUE.equals(value)) { hasCallEnded.set(true); callState.setValue(CallState.ANSWERED_ELSEWHERE); } }); } private CallState translateConnectionState(PeerConnection.PeerConnectionState state) { switch (state) { case NEW: if (callCoordinator.isIncomingCall()) { return CallState.PROMPTING_USER_ACCEPT; } else { return CallState.RINGING; } case CONNECTING: if (callCoordinator.isIncomingCall()) { return CallState.CONNECTING; } else { return Boolean.TRUE.equals(outgoingCallPlaced.getValue()) ? CallState.RINGING : CallState.CONNECTING; } case CONNECTED: return CallState.CONNECTED; case DISCONNECTED: return CallState.RECONNECTING; case FAILED: return CallState.ERROR; case CLOSED: return CallState.ENDED; default: return CallState.INITIALIZING; } } // Call Control public void answerCall() { Log.d(TAG, "answerCall"); if (!callCoordinator.isIncomingCall()) { Log.w(TAG, "answerCall() called but this is not an incoming call"); return; } // System integration callCoordinator.handleCallControlScopeAnswer(); answerCallWhenReady(); } /** Answer incoming call (WebRTC only) Used when system answer already happened */ public void answerCallWhenReady() { Log.d(TAG, "answerCallWhenReady"); if (callCoordinator.hasOngoingCall()) { Log.w(TAG, "Call already ongoing, not starting duplicate"); return; } // Start media capture callCoordinator.startMediaCapture(); // Create one-time observer LiveData localTrack = callCoordinator.getLocalVideoTrack(); answerCallObserver = new Observer() { @Override public void onChanged(VideoTrack videoTrack) { if (videoTrack != null) { // Media is ready, remove observer localTrack.removeObserver(this); answerCallObserver = null; Log.d(TAG, "Local video ready, answering call (WebRTC)"); callCoordinator.answerWebRTC(); } } }; localTrack.observeForever(answerCallObserver); } /** Start outgoing call with media capture Called by Activity for outgoing calls */ public void startOutgoingCallWhenReady() { Log.d(TAG, "startOutgoingCallWhenReady"); if (callCoordinator.hasOngoingCall()) { Log.w(TAG, "Call already ongoing, not starting duplicate"); return; } callCoordinator.startMediaCapture(); // Create one-time observer LiveData localTrack = callCoordinator.getLocalVideoTrack(); VideoTrack currentValue = localTrack.getValue(); if (currentValue != null) { Log.d(TAG, "Media already ready, starting call immediately"); callCoordinator.startOutgoingCall(); } else { startOutgoingCallObserver = new Observer() { @Override public void onChanged(VideoTrack videoTrack) { if (videoTrack != null) { // Media is ready, remove observer localTrack.removeObserver(this); startOutgoingCallObserver = null; Log.d(TAG, "Local video ready, starting outgoing call"); callCoordinator.startOutgoingCall(); } } }; localTrack.observeForever(startOutgoingCallObserver); } } public void declineCall() { Log.d(TAG, "declineCall"); if (!hasCallEnded.compareAndSet(false, true)) { Log.w(TAG, "Call already ended"); return; } callCoordinator.declineCall(); } public void hangUp() { Log.d(TAG, "hangUp"); if (!hasCallEnded.compareAndSet(false, true)) { Log.w(TAG, "Call already ended"); return; } callCoordinator.hangUp(); } public void toggleAudio() { Boolean current = localAudioEnabled.getValue(); boolean newState = current == null || !current; Log.d(TAG, "toggleAudio: " + newState); callCoordinator.setAudioEnabled(newState); } public void toggleVideo() { Boolean current = localVideoEnabled.getValue(); boolean newState = current == null || !current; Log.d(TAG, "toggleVideo: " + newState); callCoordinator.setVideoEnabled(newState); } public void switchCamera() { Log.d(TAG, "switchCamera"); callCoordinator.switchCamera(); } public void selectAudioDevice(CallEndpointCompat endpoint) { Log.d(TAG, "selectAudioDevice: " + endpoint.getName()); callCoordinator.requestAudioEndpointChange(endpoint); } public void switchToEarpiece(boolean shallUseEarpiece) { Log.d(TAG, "switchToEarpiece: shallUseEarpiece: " + shallUseEarpiece); List endpoints = availableAudioEndpoints.getValue(); if (endpoints == null) { Log.w(TAG, "No available audio endpoint"); return; } CallEndpointCompat targetEndpoint = null; for (CallEndpointCompat endpoint : endpoints) { boolean isEarpiece = endpoint.getType() == CallEndpointCompat.TYPE_EARPIECE; if (isEarpiece == shallUseEarpiece) { targetEndpoint = endpoint; break; } } if (targetEndpoint != null) { selectAudioDevice(targetEndpoint); } } public void setStartsWithVideo(boolean startsWithVideo) { Log.d(TAG, "setStartsWithVideo: " + startsWithVideo); callCoordinator.setStartsWithVideo(startsWithVideo); } // CallControlScope Callbacks public void onCallAnswered() { Log.d(TAG, "onCallAnswered callback from CallControlScope"); } public void onCallActive() { Log.d(TAG, "onCallActive callback from CallControlScope"); } public void onCallInactive() { Log.d(TAG, "onCallInactive callback from CallControlScope"); } public void onCallDisconnected(DisconnectCause disconnectCause) { Log.d(TAG, "onCallDisconnected callback from CallControlScope, cause: " + disconnectCause); if (hasCallEnded.compareAndSet(false, true)) { callState.postValue(CallState.ENDED); } } // LiveData Getters public LiveData getCallState() { return callState; } public LiveData getAudioEnabled() { return localAudioEnabled; } public LiveData getVideoEnabled() { return localVideoEnabled; } public LiveData getRemoteAudioEnabled() { return remoteAudioEnabled; } public LiveData getRemoteVideoEnabled() { return remoteVideoEnabled; } public LiveData getLocalVideoTrack() { return localVideoTrack; } public LiveData getRemoteVideoTrack() { return remoteVideoTrack; } public LiveData getErrorMessage() { return errorMessage; } public LiveData getIsRelayUsed() { return isRelayUsed; } public LiveData getDisplayName() { return displayName; } public LiveData getDisplayIcon() { return displayIcon; } public LiveData getCurrentAudioEndpoint() { return currentAudioEndpoint; } public LiveData> getAvailableAudioEndpoints() { return availableAudioEndpoints; } public LiveData getIsFrontCamera() { return isFrontCamera; } // Notification Action Handlers public void handleNotificationAnswer() { Log.d(TAG, "handleNotificationAnswer"); answerCall(); } public void handleNotificationDecline() { Log.d(TAG, "handleNotificationDecline"); declineCall(); } public void handleNotificationHangup() { Log.d(TAG, "handleNotificationHangup"); hangUp(); } // Cleanup @Override protected void onCleared() { super.onCleared(); Log.d(TAG, "CallViewModel cleared"); if (answerCallObserver != null) { callCoordinator.getLocalVideoTrack().removeObserver(answerCallObserver); answerCallObserver = null; } if (startOutgoingCallObserver != null) { callCoordinator.getLocalVideoTrack().removeObserver(startOutgoingCallObserver); startOutgoingCallObserver = null; } callCoordinator.clearActiveCallViewModel(); } }