mirror of
https://github.com/ArcaneChat/android.git
synced 2026-07-03 14:05:24 +02:00
462 lines
12 KiB
Java
462 lines
12 KiB
Java
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 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 {
|
|
|
|
private static final String TAG = CallViewModel.class.getSimpleName();
|
|
|
|
private final CallCoordinator callCoordinator;
|
|
|
|
// UI State LiveData (from Coordinator)
|
|
|
|
private final LiveData<VideoTrack> localVideoTrack;
|
|
private final LiveData<VideoTrack> remoteVideoTrack;
|
|
private final LiveData<Boolean> localAudioEnabled;
|
|
private final LiveData<Boolean> localVideoEnabled;
|
|
private final LiveData<Boolean> remoteAudioEnabled;
|
|
private final LiveData<Boolean> remoteVideoEnabled;
|
|
private final LiveData<Boolean> isRelayUsed;
|
|
private final LiveData<String> errorMessage;
|
|
private final LiveData<String> displayName;
|
|
private final LiveData<Icon> displayIcon;
|
|
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;
|
|
|
|
// Observer References for one-time observe
|
|
private Observer<VideoTrack> answerCallObserver;
|
|
private Observer<VideoTrack> startOutgoingCallObserver;
|
|
|
|
private final AtomicBoolean hasCallEnded = new AtomicBoolean(false);
|
|
|
|
// User-facing call states
|
|
public enum CallState {
|
|
INITIALIZING,
|
|
PROMPTING_USER_ACCEPT,
|
|
RINGING,
|
|
CONNECTING,
|
|
CONNECTED,
|
|
RECONNECTING,
|
|
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.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 -> {
|
|
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);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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<VideoTrack> localTrack = callCoordinator.getLocalVideoTrack();
|
|
|
|
answerCallObserver =
|
|
new Observer<VideoTrack>() {
|
|
@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<VideoTrack> 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<VideoTrack>() {
|
|
@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<CallEndpointCompat> 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<CallState> getCallState() {
|
|
return callState;
|
|
}
|
|
|
|
public LiveData<Boolean> getAudioEnabled() {
|
|
return localAudioEnabled;
|
|
}
|
|
|
|
public LiveData<Boolean> getVideoEnabled() {
|
|
return localVideoEnabled;
|
|
}
|
|
|
|
public LiveData<Boolean> getRemoteAudioEnabled() {
|
|
return remoteAudioEnabled;
|
|
}
|
|
|
|
public LiveData<Boolean> getRemoteVideoEnabled() {
|
|
return remoteVideoEnabled;
|
|
}
|
|
|
|
public LiveData<VideoTrack> getLocalVideoTrack() {
|
|
return localVideoTrack;
|
|
}
|
|
|
|
public LiveData<VideoTrack> getRemoteVideoTrack() {
|
|
return remoteVideoTrack;
|
|
}
|
|
|
|
public LiveData<String> getErrorMessage() {
|
|
return errorMessage;
|
|
}
|
|
|
|
public LiveData<Boolean> getIsRelayUsed() {
|
|
return isRelayUsed;
|
|
}
|
|
|
|
public LiveData<String> getDisplayName() {
|
|
return displayName;
|
|
}
|
|
|
|
public LiveData<Icon> getDisplayIcon() {
|
|
return displayIcon;
|
|
}
|
|
|
|
public LiveData<CallEndpointCompat> getCurrentAudioEndpoint() {
|
|
return currentAudioEndpoint;
|
|
}
|
|
|
|
public LiveData<List<CallEndpointCompat>> getAvailableAudioEndpoints() {
|
|
return availableAudioEndpoints;
|
|
}
|
|
|
|
public LiveData<Boolean> 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();
|
|
}
|
|
}
|