diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8c3a3028fe..991048644b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -139,18 +139,6 @@
android:launchMode="singleTask"
android:exported="false" />
-
-
consumer) {
+ public static void getIdentityRecords(@NonNull Recipient recipient, @NonNull Consumer consumer) {
SignalExecutors.BOUNDED.execute(() -> {
List recipients;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java
deleted file mode 100644
index e37f0962bb..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java
+++ /dev/null
@@ -1,583 +0,0 @@
-package org.thoughtcrime.securesms.components.webrtc;
-
-import android.os.Handler;
-import android.os.Looper;
-
-import androidx.annotation.MainThread;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.lifecycle.LifecycleOwner;
-import androidx.lifecycle.LiveData;
-import androidx.lifecycle.LiveDataReactiveStreams;
-import androidx.lifecycle.MutableLiveData;
-import androidx.lifecycle.Observer;
-import androidx.lifecycle.Transformations;
-import androidx.lifecycle.ViewModel;
-
-import com.annimon.stream.Stream;
-
-import org.signal.core.util.ThreadUtil;
-import org.thoughtcrime.securesms.components.webrtc.v2.CallControlsState;
-import org.thoughtcrime.securesms.components.webrtc.v2.CallEvent;
-import org.thoughtcrime.securesms.database.GroupTable;
-import org.thoughtcrime.securesms.database.SignalDatabase;
-import org.thoughtcrime.securesms.database.model.IdentityRecord;
-import org.thoughtcrime.securesms.dependencies.AppDependencies;
-import org.thoughtcrime.securesms.events.CallParticipant;
-import org.thoughtcrime.securesms.events.CallParticipantId;
-import org.thoughtcrime.securesms.events.WebRtcViewModel;
-import org.thoughtcrime.securesms.groups.LiveGroup;
-import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry;
-import org.thoughtcrime.securesms.keyvalue.SignalStore;
-import org.thoughtcrime.securesms.recipients.LiveRecipient;
-import org.thoughtcrime.securesms.recipients.Recipient;
-import org.thoughtcrime.securesms.recipients.RecipientId;
-import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection;
-import org.thoughtcrime.securesms.service.webrtc.state.PendingParticipantsState;
-import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState;
-import org.thoughtcrime.securesms.util.NetworkUtil;
-import org.thoughtcrime.securesms.util.SingleLiveEvent;
-import org.thoughtcrime.securesms.util.Util;
-import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
-import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
-
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Set;
-
-import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
-import io.reactivex.rxjava3.core.BackpressureStrategy;
-import io.reactivex.rxjava3.core.Flowable;
-import io.reactivex.rxjava3.core.Observable;
-import io.reactivex.rxjava3.processors.BehaviorProcessor;
-import io.reactivex.rxjava3.subjects.BehaviorSubject;
-
-public class WebRtcCallViewModel extends ViewModel {
-
- private final MutableLiveData microphoneEnabled = new MutableLiveData<>(true);
- private final MutableLiveData isInPipMode = new MutableLiveData<>(false);
- private final MutableLiveData webRtcControls = new MutableLiveData<>(WebRtcControls.NONE);
- private final MutableLiveData foldableState = new MutableLiveData<>(WebRtcControls.FoldableState.flat());
- private final LiveData controlsWithFoldableState = LiveDataUtil.combineLatest(foldableState, webRtcControls, this::updateControlsFoldableState);
- private final LiveData realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls);
- private final SingleLiveEvent events = new SingleLiveEvent<>();
- private final BehaviorSubject elapsed = BehaviorSubject.createDefault(-1L);
- private final MutableLiveData liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live());
- private final BehaviorSubject participantsState = BehaviorSubject.createDefault(CallParticipantsState.STARTING_STATE);
- private final SingleLiveEvent callParticipantListUpdate = new SingleLiveEvent<>();
- private final MutableLiveData> identityChangedRecipients = new MutableLiveData<>(Collections.emptyList());
- private final LiveData safetyNumberChangeEvent = LiveDataUtil.combineLatest(isInPipMode, identityChangedRecipients, SafetyNumberChangeEvent::new);
- private final LiveData groupRecipient = LiveDataUtil.filter(Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData), Recipient::isActiveGroup);
- private final LiveData> groupMembers = Transformations.switchMap(groupRecipient, r -> Transformations.distinctUntilChanged(new LiveGroup(r.requireGroupId()).getFullMembers()));
- private final LiveData> groupMembersChanged = LiveDataUtil.skip(groupMembers, 1);
- private final LiveData groupMemberCount = Transformations.map(groupMembers, List::size);
- private final Observable shouldShowSpeakerHint = participantsState.map(this::shouldShowSpeakerHint);
- private final MutableLiveData isLandscapeEnabled = new MutableLiveData<>();
- private final MutableLiveData canEnterPipMode = new MutableLiveData<>(false);
- private final Observer> groupMemberStateUpdater = m -> participantsState.onNext(CallParticipantsState.update(participantsState.getValue(), m));
- private final MutableLiveData ephemeralState = new MutableLiveData<>();
- private final BehaviorProcessor recipientId = BehaviorProcessor.createDefault(RecipientId.UNKNOWN);
-
- private final BehaviorSubject pendingParticipants = BehaviorSubject.create();
-
- private final Handler elapsedTimeHandler = new Handler(Looper.getMainLooper());
- private final Runnable elapsedTimeRunnable = this::handleTick;
- private final Runnable stopOutgoingRingingMode = this::stopOutgoingRingingMode;
-
- private boolean canDisplayTooltipIfNeeded = true;
- private boolean canDisplaySwitchCameraTooltipIfNeeded = true;
- private boolean canDisplayPopupIfNeeded = true;
- private boolean hasEnabledLocalVideo = false;
- private boolean wasInOutgoingRingingMode = false;
- private long callConnectedTime = -1;
- private boolean answerWithVideoAvailable = false;
- private List previousParticipantsList = Collections.emptyList();
- private boolean callStarting = false;
- private boolean switchOnFirstScreenShare = true;
- private boolean showScreenShareTip = true;
-
- private final WebRtcCallRepository repository = new WebRtcCallRepository(AppDependencies.getApplication());
-
- public WebRtcCallViewModel() {
- groupMembers.observeForever(groupMemberStateUpdater);
- }
-
- public LiveData getMicrophoneEnabled() {
- return Transformations.distinctUntilChanged(microphoneEnabled);
- }
-
- public LiveData getWebRtcControls() {
- return realWebRtcControls;
- }
-
- public LiveRecipient getRecipient() {
- return liveRecipient.getValue();
- }
-
- public Flowable getRecipientFlowable() {
- return recipientId.switchMap(id -> Recipient.observable(id).toFlowable(BackpressureStrategy.LATEST)).observeOn(AndroidSchedulers.mainThread());
- }
-
- public void setRecipient(@NonNull Recipient recipient) {
- recipientId.onNext(recipient.getId());
- liveRecipient.setValue(recipient.live());
- }
-
- public void setFoldableState(@NonNull WebRtcControls.FoldableState foldableState) {
- this.foldableState.postValue(foldableState);
-
- ThreadUtil.runOnMain(() -> participantsState.onNext(CallParticipantsState.update(participantsState.getValue(), foldableState)));
- }
-
- public LiveData getEvents() {
- return events;
- }
-
- public Observable getInCallstatus() {
- Observable elapsedTime = elapsed.map(timeInCall -> callConnectedTime == -1 ? -1 : timeInCall);
-
- return Observable.combineLatest(
- elapsedTime,
- pendingParticipants,
- participantsState,
- (time, pendingParticipants, participantsState) -> {
- if (!getRecipient().get().isCallLink()) {
- return new InCallStatus.ElapsedTime(time);
- }
-
- Set pending = pendingParticipants.getUnresolvedPendingParticipants();
-
- if (!pending.isEmpty()) {
- return new InCallStatus.PendingCallLinkUsers(pending.size());
- } else {
- return new InCallStatus.JoinedCallLinkUsers((int) participantsState.getParticipantCount().orElse(0));
- }
- }
- ).distinctUntilChanged().observeOn(AndroidSchedulers.mainThread());
- }
-
- public Flowable getCallControlsState(@NonNull LifecycleOwner lifecycleOwner) {
- // Calculate this separately so we have a value when the recipient is not a group.
- Flowable groupSize = recipientId.filter(id -> id != RecipientId.UNKNOWN)
- .switchMap(id -> Recipient.observable(id).toFlowable(BackpressureStrategy.LATEST))
- .map(recipient -> {
- if (recipient.isActiveGroup()) {
- return SignalDatabase.groups().getGroupMemberIds(recipient.requireGroupId(), GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF).size();
- } else {
- return 0;
- }
- });
-
- return Flowable.combineLatest(
- getCallParticipantsState().toFlowable(BackpressureStrategy.LATEST),
- LiveDataReactiveStreams.toPublisher(getWebRtcControls(), lifecycleOwner),
- groupSize,
- CallControlsState::fromViewModelData
- );
- }
-
- public Observable getCallParticipantsState() {
- return participantsState;
- }
-
- public @Nullable CallParticipantsState getCallParticipantsStateSnapshot() {
- return participantsState.getValue();
- }
-
- public LiveData getCallParticipantListUpdate() {
- return callParticipantListUpdate;
- }
-
- public LiveData getSafetyNumberChangeEvent() {
- return safetyNumberChangeEvent;
- }
-
- public LiveData> getGroupMembersChanged() {
- return groupMembersChanged;
- }
-
- public LiveData getGroupMemberCount() {
- return groupMemberCount;
- }
-
- public Observable shouldShowSpeakerHint() {
- return shouldShowSpeakerHint.observeOn(AndroidSchedulers.mainThread());
- }
-
- public WebRtcAudioOutput getCurrentAudioOutput() {
- return getWebRtcControls().getValue().getAudioOutput();
- }
-
- public LiveData getEphemeralState() {
- return ephemeralState;
- }
-
- public LiveData canEnterPipMode() {
- return canEnterPipMode;
- }
-
- public boolean isAnswerWithVideoAvailable() {
- return answerWithVideoAvailable;
- }
-
- public boolean isCallStarting() {
- return callStarting;
- }
-
- public @NonNull Observable getPendingParticipants() {
- Observable isInPipMode = participantsState
- .map(CallParticipantsState::isInPipMode)
- .distinctUntilChanged();
-
- return Observable.combineLatest(pendingParticipants, isInPipMode, PendingParticipantsState::new);
- }
-
- public @NonNull PendingParticipantCollection getPendingParticipantsSnapshot() {
- return pendingParticipants.getValue();
- }
-
- @MainThread
- public void setIsInPipMode(boolean isInPipMode) {
- this.isInPipMode.setValue(isInPipMode);
-
- participantsState.onNext(CallParticipantsState.update(participantsState.getValue(), isInPipMode));
- }
-
- public void setIsLandscapeEnabled(boolean isLandscapeEnabled) {
- this.isLandscapeEnabled.postValue(isLandscapeEnabled);
- }
-
- @MainThread
- public void setIsViewingFocusedParticipant(@NonNull CallParticipantsState.SelectedPage page) {
- if (page == CallParticipantsState.SelectedPage.FOCUSED) {
- SignalStore.tooltips().markGroupCallSpeakerViewSeen();
- }
-
- CallParticipantsState state = participantsState.getValue();
- if (showScreenShareTip &&
- state.getFocusedParticipant().isScreenSharing() &&
- state.isViewingFocusedParticipant() &&
- page == CallParticipantsState.SelectedPage.GRID)
- {
- showScreenShareTip = false;
- events.setValue(CallEvent.ShowSwipeToSpeakerHint.INSTANCE);
- }
-
- participantsState.onNext(CallParticipantsState.update(participantsState.getValue(), page));
- }
-
- public void onLocalPictureInPictureClicked() {
- CallParticipantsState state = participantsState.getValue();
- participantsState.onNext(CallParticipantsState.setExpanded(participantsState.getValue(),
- state.getLocalRenderState() != WebRtcLocalRenderState.EXPANDED));
- }
-
- public void onDismissedVideoTooltip() {
- canDisplayTooltipIfNeeded = false;
- }
-
- public void onDismissedSwitchCameraTooltip() {
- canDisplaySwitchCameraTooltipIfNeeded = false;
- SignalStore.tooltips().markCallingSwitchCameraTooltipSeen();
- }
-
- @MainThread
- public void updateFromWebRtcViewModel(@NonNull WebRtcViewModel webRtcViewModel, boolean enableVideo) {
- canEnterPipMode.setValue(!webRtcViewModel.getState().isPreJoinOrNetworkUnavailable());
- if (callStarting && webRtcViewModel.getState().isPassedPreJoin()) {
- callStarting = false;
- }
-
- CallParticipant localParticipant = webRtcViewModel.getLocalParticipant();
-
- microphoneEnabled.setValue(localParticipant.isMicrophoneEnabled());
-
- CallParticipantsState state = participantsState.getValue();
- boolean wasScreenSharing = state.getFocusedParticipant().isScreenSharing();
- CallParticipantsState newState = CallParticipantsState.update(state, webRtcViewModel, enableVideo);
-
- participantsState.onNext(newState);
- if (switchOnFirstScreenShare && !wasScreenSharing && newState.getFocusedParticipant().isScreenSharing()) {
- switchOnFirstScreenShare = false;
- events.setValue(CallEvent.SwitchToSpeaker.INSTANCE);
- }
-
- if (webRtcViewModel.getGroupState().isConnected()) {
- if (!containsPlaceholders(previousParticipantsList)) {
- CallParticipantListUpdate update = CallParticipantListUpdate.computeDeltaUpdate(previousParticipantsList, webRtcViewModel.getRemoteParticipants());
- callParticipantListUpdate.setValue(update);
- }
-
- previousParticipantsList = webRtcViewModel.getRemoteParticipants();
-
- identityChangedRecipients.setValue(webRtcViewModel.getIdentityChangedParticipants());
- }
-
- updateWebRtcControls(webRtcViewModel.getState(),
- webRtcViewModel.getGroupState(),
- localParticipant.getCameraState().isEnabled(),
- webRtcViewModel.isRemoteVideoEnabled(),
- webRtcViewModel.isRemoteVideoOffer(),
- localParticipant.isMoreThanOneCameraAvailable(),
- webRtcViewModel.hasAtLeastOneRemote(),
- webRtcViewModel.getActiveDevice(),
- webRtcViewModel.getAvailableDevices(),
- webRtcViewModel.getRemoteDevicesCount().orElse(0),
- webRtcViewModel.getParticipantLimit(),
- webRtcViewModel.getRecipient().isCallLink(),
- webRtcViewModel.getRemoteParticipants().size() > CallParticipantsState.SMALL_GROUP_MAX);
-
- pendingParticipants.onNext(webRtcViewModel.getPendingParticipants());
-
- if (newState.isInOutgoingRingingMode()) {
- cancelTimer();
- if (!wasInOutgoingRingingMode) {
- elapsedTimeHandler.postDelayed(stopOutgoingRingingMode, CallParticipantsState.MAX_OUTGOING_GROUP_RING_DURATION);
- }
- wasInOutgoingRingingMode = true;
- } else {
- if (webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED && callConnectedTime == -1) {
- callConnectedTime = wasInOutgoingRingingMode ? System.currentTimeMillis() : webRtcViewModel.getCallConnectedTime();
- startTimer();
- } else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_CONNECTED || webRtcViewModel.getGroupState().isNotIdleOrConnected()) {
- cancelTimer();
- callConnectedTime = -1;
- }
- }
-
- /*
- if (event.getGroupState().isNotIdle()) {
- callScreen.setRingGroup(event.shouldRingGroup());
-
- if (event.shouldRingGroup() && event.areRemoteDevicesInCall()) {
- AppDependencies.getSignalCallManager().setRingGroup(false);
- }
- }
- */
- if (webRtcViewModel.getState() == WebRtcViewModel.State.CALL_PRE_JOIN && webRtcViewModel.getGroupState().isNotIdle()) {
- // Set flag
-
- if (webRtcViewModel.shouldRingGroup() && webRtcViewModel.areRemoteDevicesInCall()) {
- AppDependencies.getSignalCallManager().setRingGroup(false);
- }
- }
-
- if (localParticipant.getCameraState().isEnabled()) {
- canDisplayTooltipIfNeeded = false;
- hasEnabledLocalVideo = true;
- events.setValue(CallEvent.DismissVideoTooltip.INSTANCE);
- }
-
- // If remote video is enabled and we a) haven't shown our video and b) have not dismissed the popup
- if (canDisplayTooltipIfNeeded && webRtcViewModel.isRemoteVideoEnabled() && !hasEnabledLocalVideo) {
- canDisplayTooltipIfNeeded = false;
- events.setValue(CallEvent.ShowVideoTooltip.INSTANCE);
- }
-
- if (canDisplayPopupIfNeeded && webRtcViewModel.isCellularConnection() && NetworkUtil.isConnectedWifi(AppDependencies.getApplication())) {
- canDisplayPopupIfNeeded = false;
- events.setValue(CallEvent.ShowWifiToCellularPopup.INSTANCE);
- } else if (!webRtcViewModel.isCellularConnection()) {
- canDisplayPopupIfNeeded = true;
- }
-
- if (SignalStore.tooltips().showCallingSwitchCameraTooltip() &&
- canDisplaySwitchCameraTooltipIfNeeded &&
- localParticipant.getCameraState().isEnabled() &&
- webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED &&
- !newState.getAllRemoteParticipants().isEmpty()
- )
- {
- canDisplaySwitchCameraTooltipIfNeeded = false;
- events.setValue(CallEvent.ShowSwitchCameraTooltip.INSTANCE);
- }
- }
-
- @MainThread
- public void updateFromEphemeralState(@NonNull WebRtcEphemeralState state) {
- ephemeralState.setValue(state);
- }
-
- private boolean containsPlaceholders(@NonNull List callParticipants) {
- return Stream.of(callParticipants).anyMatch(p -> p.getCallParticipantId().getDemuxId() == CallParticipantId.DEFAULT_ID);
- }
-
- private void updateWebRtcControls(@NonNull WebRtcViewModel.State state,
- @NonNull WebRtcViewModel.GroupCallState groupState,
- boolean isLocalVideoEnabled,
- boolean isRemoteVideoEnabled,
- boolean isRemoteVideoOffer,
- boolean isMoreThanOneCameraAvailable,
- boolean hasAtLeastOneRemote,
- @NonNull SignalAudioManager.AudioDevice activeDevice,
- @NonNull Set availableDevices,
- long remoteDevicesCount,
- @Nullable Long participantLimit,
- boolean isCallLink,
- boolean hasParticipantOverflow)
- {
- final WebRtcControls.CallState callState;
-
- switch (state) {
- case CALL_PRE_JOIN:
- callState = WebRtcControls.CallState.PRE_JOIN;
- break;
- case CALL_INCOMING:
- callState = WebRtcControls.CallState.INCOMING;
- answerWithVideoAvailable = isRemoteVideoOffer;
- break;
- case CALL_OUTGOING:
- case CALL_RINGING:
- callState = WebRtcControls.CallState.OUTGOING;
- break;
- case CALL_ACCEPTED_ELSEWHERE:
- case CALL_DECLINED_ELSEWHERE:
- case CALL_ONGOING_ELSEWHERE:
- callState = WebRtcControls.CallState.HANDLED_ELSEWHERE;
- break;
- case CALL_NEEDS_PERMISSION:
- case CALL_BUSY:
- case CALL_DISCONNECTED:
- callState = WebRtcControls.CallState.ENDING;
- break;
- case CALL_DISCONNECTED_GLARE:
- callState = WebRtcControls.CallState.INCOMING;
- break;
- case NETWORK_FAILURE:
- callState = WebRtcControls.CallState.ERROR;
- break;
- case CALL_RECONNECTING:
- callState = WebRtcControls.CallState.RECONNECTING;
- break;
- default:
- callState = WebRtcControls.CallState.ONGOING;
- }
-
- final WebRtcControls.GroupCallState groupCallState;
-
- switch (groupState) {
- case DISCONNECTED:
- groupCallState = WebRtcControls.GroupCallState.DISCONNECTED;
- break;
- case CONNECTING:
- case RECONNECTING:
- groupCallState = (participantLimit == null || remoteDevicesCount < participantLimit) ? WebRtcControls.GroupCallState.CONNECTING
- : WebRtcControls.GroupCallState.FULL;
- break;
- case CONNECTED_AND_PENDING:
- groupCallState = WebRtcControls.GroupCallState.PENDING;
- break;
- case CONNECTED:
- case CONNECTED_AND_JOINING:
- case CONNECTED_AND_JOINED:
- groupCallState = WebRtcControls.GroupCallState.CONNECTED;
- break;
- default:
- groupCallState = WebRtcControls.GroupCallState.NONE;
- break;
- }
-
- webRtcControls.setValue(new WebRtcControls(isLocalVideoEnabled,
- isRemoteVideoEnabled || isRemoteVideoOffer,
- isMoreThanOneCameraAvailable,
- Boolean.TRUE.equals(isInPipMode.getValue()),
- hasAtLeastOneRemote,
- callState,
- groupCallState,
- participantLimit,
- WebRtcControls.FoldableState.flat(),
- activeDevice,
- availableDevices,
- isCallLink,
- hasParticipantOverflow));
- }
-
- private @NonNull WebRtcControls updateControlsFoldableState(@NonNull WebRtcControls.FoldableState foldableState, @NonNull WebRtcControls controls) {
- return controls.withFoldableState(foldableState);
- }
-
- private @NonNull WebRtcControls getRealWebRtcControls(boolean isInPipMode, @NonNull WebRtcControls controls) {
- return isInPipMode ? WebRtcControls.PIP : controls;
- }
-
- private boolean shouldShowSpeakerHint(@NonNull CallParticipantsState state) {
- return !state.isInPipMode() &&
- state.getRemoteDevicesCount().orElse(0) > 1 &&
- state.getGroupCallState().isConnected() &&
- !SignalStore.tooltips().hasSeenGroupCallSpeakerView();
- }
-
- private void startTimer() {
- cancelTimer();
- elapsedTimeHandler.removeCallbacks(stopOutgoingRingingMode);
-
- elapsedTimeHandler.post(elapsedTimeRunnable);
- }
-
- private void stopOutgoingRingingMode() {
- if (callConnectedTime == -1) {
- callConnectedTime = System.currentTimeMillis();
- startTimer();
- }
- }
-
- private void handleTick() {
- if (callConnectedTime == -1) {
- return;
- }
-
- long newValue = (System.currentTimeMillis() - callConnectedTime) / 1000;
-
- elapsed.onNext(newValue);
-
- elapsedTimeHandler.postDelayed(elapsedTimeRunnable, 1000);
- }
-
- private void cancelTimer() {
- elapsedTimeHandler.removeCallbacks(elapsedTimeRunnable);
- }
-
- @Override
- protected void onCleared() {
- super.onCleared();
- cancelTimer();
- groupMembers.removeObserver(groupMemberStateUpdater);
- }
-
- public void startCall(boolean isVideoCall) {
- callStarting = true;
- Recipient recipient = getRecipient().get();
- if (recipient.isGroup()) {
- repository.getIdentityRecords(recipient, identityRecords -> {
- if (identityRecords.isUntrusted(false) || identityRecords.isUnverified(false)) {
- List records = identityRecords.getUnverifiedRecords();
- records.addAll(identityRecords.getUntrustedRecords());
- events.postValue(new CallEvent.ShowGroupCallSafetyNumberChange(records));
- } else {
- events.postValue(new CallEvent.StartCall(isVideoCall));
- }
- });
- } else {
- events.postValue(new CallEvent.StartCall(isVideoCall));
- }
- }
-
- public static class SafetyNumberChangeEvent {
- private final boolean isInPipMode;
- private final Collection recipientIds;
-
- private SafetyNumberChangeEvent(boolean isInPipMode, @NonNull Collection recipientIds) {
- this.isInPipMode = isInPipMode;
- this.recipientIds = recipientIds;
- }
-
- public boolean isInPipMode() {
- return isInPipMode;
- }
-
- public @NonNull Collection getRecipientIds() {
- return recipientIds;
- }
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java
index 03be3ef668..5a0b05e0c3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java
@@ -63,7 +63,6 @@ public final class WebRtcControls {
false);
}
- @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public WebRtcControls(boolean isLocalVideoEnabled,
boolean isRemoteVideoEnabled,
boolean isMoreThanOneCameraAvailable,
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt
index b2e61a704d..1cb6dcdfce 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt
@@ -61,7 +61,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatar
import org.thoughtcrime.securesms.avatar.fallback.FallbackAvatarImage
import org.thoughtcrime.securesms.components.AvatarImageView
-import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
+import org.thoughtcrime.securesms.components.webrtc.v2.WebRtcCallViewModel
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.events.CallParticipant
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt
index 3d587ad131..425e882f0d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt
@@ -52,9 +52,9 @@ import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout
import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView
-import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls
import org.thoughtcrime.securesms.components.webrtc.v2.CallInfoCallbacks
+import org.thoughtcrime.securesms.components.webrtc.v2.WebRtcCallViewModel
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.util.padding
import org.thoughtcrime.securesms.util.visible
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/RaiseHandSnackbar.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/RaiseHandSnackbar.kt
index 39d90a21f8..7520a06457 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/RaiseHandSnackbar.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/RaiseHandSnackbar.kt
@@ -47,7 +47,7 @@ import io.reactivex.rxjava3.core.BackpressureStrategy
import kotlinx.coroutines.delay
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
-import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
+import org.thoughtcrime.securesms.components.webrtc.v2.WebRtcCallViewModel
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListDialog.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListDialog.java
index 961ce980c5..00a86f9848 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListDialog.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListDialog.java
@@ -21,7 +21,7 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
-import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
+import org.thoughtcrime.securesms.components.webrtc.v2.WebRtcCallViewModel;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallActivity.kt
deleted file mode 100644
index 17493f1f5c..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallActivity.kt
+++ /dev/null
@@ -1,344 +0,0 @@
-/*
- * Copyright 2024 Signal Messenger, LLC
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-package org.thoughtcrime.securesms.components.webrtc.v2
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.content.Intent
-import android.content.pm.PackageManager
-import android.os.Build
-import android.os.Bundle
-import android.view.WindowManager
-import androidx.activity.compose.setContent
-import androidx.activity.viewModels
-import androidx.appcompat.app.AppCompatDelegate
-import androidx.compose.material3.Surface
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rxjava3.subscribeAsState
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.compose.LocalLifecycleOwner
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.repeatOnLifecycle
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import org.greenrobot.eventbus.EventBus
-import org.signal.core.ui.theme.SignalTheme
-import org.signal.core.util.concurrent.LifecycleDisposable
-import org.signal.core.util.logging.Log
-import org.thoughtcrime.securesms.BaseActivity
-import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState
-import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioDevice
-import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
-import org.thoughtcrime.securesms.components.webrtc.controls.CallInfoView
-import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel
-import org.thoughtcrime.securesms.components.webrtc.controls.RaiseHandSnackbar
-import org.thoughtcrime.securesms.dependencies.AppDependencies
-import org.thoughtcrime.securesms.events.WebRtcViewModel
-import org.thoughtcrime.securesms.messagerequests.CalleeMustAcceptMessageRequestActivity
-import org.thoughtcrime.securesms.permissions.Permissions
-import org.thoughtcrime.securesms.recipients.Recipient
-import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
-import org.thoughtcrime.securesms.util.FullscreenHelper
-import org.thoughtcrime.securesms.util.VibrateUtil
-import org.thoughtcrime.securesms.util.viewModel
-import org.whispersystems.signalservice.api.messages.calls.HangupMessage
-import kotlin.time.Duration.Companion.seconds
-
-/**
- * Entry-point for receiving and making Signal calls.
- */
-class CallActivity : BaseActivity(), CallControlsCallback {
-
- companion object {
- private val TAG = Log.tag(CallActivity::class.java)
-
- private const val VIBRATE_DURATION = 50
- }
-
- private val callPermissionsDialogController = CallPermissionsDialogController()
- private val lifecycleDisposable = LifecycleDisposable()
-
- private val webRtcCallViewModel: WebRtcCallViewModel by viewModels()
- private val controlsAndInfoViewModel: ControlsAndInfoViewModel by viewModels()
- private val viewModel: CallViewModel by viewModel {
- CallViewModel(
- webRtcCallViewModel,
- controlsAndInfoViewModel
- )
- }
-
- override fun attachBaseContext(newBase: Context) {
- delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
- super.attachBaseContext(newBase)
- }
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- val fullscreenHelper = FullscreenHelper(this)
-
- lifecycleDisposable.bindTo(this)
-
- val callInfoCallbacks = CallInfoCallbacks(this, controlsAndInfoViewModel)
-
- observeCallEvents()
- viewModel.processCallIntent(CallIntent(intent))
-
- lifecycleScope.launch {
- repeatOnLifecycle(Lifecycle.State.CREATED) {
- viewModel.callActions.collect {
- when (it) {
- CallViewModel.Action.EnableVideo -> onVideoToggleClick(true)
- is CallViewModel.Action.ShowGroupCallSafetyNumberChangeDialog -> SafetyNumberBottomSheet.forGroupCall(it.untrustedIdentities).show(supportFragmentManager)
- CallViewModel.Action.SwitchToSpeaker -> Unit // TODO - Switch user to speaker view.
- }
- }
- }
- }
-
- setContent {
- val lifecycleOwner = LocalLifecycleOwner.current
- val callControlsState by webRtcCallViewModel.getCallControlsState(lifecycleOwner).subscribeAsState(initial = CallControlsState())
- val callParticipantsState by webRtcCallViewModel.callParticipantsState.subscribeAsState(initial = CallParticipantsState())
- val callScreenState by viewModel.callScreenState.collectAsStateWithLifecycle()
- val recipient by remember(callScreenState.callRecipientId) {
- Recipient.observable(callScreenState.callRecipientId)
- }.subscribeAsState(Recipient.UNKNOWN)
-
- LaunchedEffect(callControlsState.isGroupRingingAllowed) {
- viewModel.onGroupRingAllowedChanged(callControlsState.isGroupRingingAllowed)
- }
-
- LaunchedEffect(callParticipantsState.callState) {
- if (callParticipantsState.callState == WebRtcViewModel.State.CALL_CONNECTED) {
- window.addFlags(WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES)
- }
-
- if (callParticipantsState.callState == WebRtcViewModel.State.CALL_RECONNECTING) {
- VibrateUtil.vibrate(this@CallActivity, VIBRATE_DURATION)
- }
- }
-
- LaunchedEffect(callScreenState.hangup) {
- val hangup = callScreenState.hangup
- if (hangup != null) {
- if (hangup.hangupMessageType == HangupMessage.Type.NEED_PERMISSION) {
- startActivity(CalleeMustAcceptMessageRequestActivity.createIntent(this@CallActivity, callParticipantsState.recipient.id))
- } else {
- delay(hangup.delay)
- }
- finish()
- }
- }
-
- var areControlsVisible by remember { mutableStateOf(true) }
-
- LaunchedEffect(areControlsVisible) {
- if (areControlsVisible) {
- fullscreenHelper.showSystemUI()
- } else {
- fullscreenHelper.hideSystemUI()
- }
- }
-
- val callScreenDialogType by viewModel.dialog.collectAsStateWithLifecycle(CallScreenDialogType.NONE)
-
- SignalTheme {
- Surface {
- CallScreen(
- callRecipient = recipient,
- webRtcCallState = callParticipantsState.callState,
- callScreenState = callScreenState,
- callControlsState = callControlsState,
- callControlsCallback = this,
- callParticipantsPagerState = CallParticipantsPagerState(
- callParticipants = callParticipantsState.gridParticipants,
- focusedParticipant = callParticipantsState.focusedParticipant,
- isRenderInPip = callParticipantsState.isInPipMode,
- hideAvatar = callParticipantsState.hideAvatar
- ),
- overflowParticipants = callParticipantsState.listParticipants,
- localParticipant = callParticipantsState.localParticipant,
- localRenderState = callParticipantsState.localRenderState,
- callScreenDialogType = callScreenDialogType,
- callInfoView = {
- CallInfoView.View(
- webRtcCallViewModel = webRtcCallViewModel,
- controlsAndInfoViewModel = controlsAndInfoViewModel,
- callbacks = callInfoCallbacks,
- modifier = Modifier
- .alpha(it)
- )
- },
- raiseHandSnackbar = {
- RaiseHandSnackbar.View(
- webRtcCallViewModel = webRtcCallViewModel,
- showCallInfoListener = { /*TODO*/ },
- modifier = it
- )
- },
- onNavigationClick = { finish() },
- onLocalPictureInPictureClicked = webRtcCallViewModel::onLocalPictureInPictureClicked,
- onControlsToggled = { areControlsVisible = it },
- onCallScreenDialogDismissed = viewModel::onCallScreenDialogDismissed
- )
- }
- }
- }
- }
-
- override fun onNewIntent(intent: Intent) {
- super.onNewIntent(intent)
- viewModel.processCallIntent(CallIntent(intent))
- }
-
- override fun onResume() {
- Log.i(TAG, "onResume")
- super.onResume()
-
- if (!EventBus.getDefault().isRegistered(viewModel)) {
- EventBus.getDefault().register(viewModel)
- }
-
- val stickyEvent = EventBus.getDefault().getStickyEvent(WebRtcViewModel::class.java)
- if (stickyEvent == null) {
- Log.w(TAG, "Activity resumed without service event, perform delay destroy.")
-
- lifecycleScope.launch {
- delay(1.seconds)
- val retryEvent = EventBus.getDefault().getStickyEvent(WebRtcViewModel::class.java)
- if (retryEvent == null) {
- Log.w(TAG, "Activity still without service event, finishing.")
- finish()
- } else {
- Log.i(TAG, "Event found after delay.")
- }
- }
- }
-
- if (viewModel.consumeEnterPipOnResume()) {
- // TODO enterPipModeIfPossible()
- }
- }
-
- override fun onPause() {
- Log.i(TAG, "onPause")
- super.onPause()
-
- if (!callPermissionsDialogController.isAskingForPermission && !webRtcCallViewModel.isCallStarting && !isChangingConfigurations) {
- val state = webRtcCallViewModel.callParticipantsStateSnapshot
- if (state != null && state.callState.isPreJoinOrNetworkUnavailable) {
- finish()
- }
- }
- }
-
- override fun onStop() {
- Log.i(TAG, "onStop")
- super.onStop()
-
- /*
- TODO
- ephemeralStateDisposable.dispose();
- */
-
- if (!isInPipMode() || isFinishing) {
- viewModel.unregisterEventBus()
- // TODO
- // requestNewSizesThrottle.clear();
- }
-
- AppDependencies.signalCallManager.setEnableVideo(false)
-
- if (!webRtcCallViewModel.isCallStarting && !isChangingConfigurations) {
- val state = webRtcCallViewModel.callParticipantsStateSnapshot
- if (state != null) {
- if (state.callState.isPreJoinOrNetworkUnavailable) {
- AppDependencies.signalCallManager.cancelPreJoin()
- } else if (state.callState.inOngoingCall && isInPipMode()) {
- AppDependencies.signalCallManager.relaunchPipOnForeground()
- }
- }
- }
- }
-
- override fun onDestroy() {
- super.onDestroy()
- // TODO windowInfoTrackerCallbackAdapter.removeWindowLayoutInfoListener(windowLayoutInfoConsumer);
- viewModel.unregisterEventBus()
- }
-
- @SuppressLint("MissingSuperCall")
- override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
- Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
- }
-
- override fun onAudioDeviceSheetDisplayChanged(displayed: Boolean) {
- viewModel.onAudioDeviceSheetDisplayChanged(displayed)
- }
-
- override fun onSelectedAudioDeviceChanged(audioDevice: WebRtcAudioDevice) {
- viewModel.onSelectedAudioDeviceChanged(audioDevice)
- }
-
- override fun onVideoToggleClick(enabled: Boolean) {
- if (webRtcCallViewModel.recipient.get() != Recipient.UNKNOWN) {
- callPermissionsDialogController.requestCameraPermission(
- activity = this,
- onAllGranted = { viewModel.onVideoToggleChanged(enabled) }
- )
- }
- }
-
- override fun onMicToggleClick(enabled: Boolean) {
- callPermissionsDialogController.requestAudioPermission(
- activity = this,
- onGranted = { viewModel.onMicToggledChanged(enabled) },
- onDenied = { viewModel.deny() }
- )
- }
-
- override fun onGroupRingingToggleClick(enabled: Boolean, allowed: Boolean) {
- viewModel.onGroupRingToggleChanged(enabled, allowed)
- }
-
- override fun onAdditionalActionsClick() {
- viewModel.onAdditionalActionsClick()
- }
-
- override fun onStartCallClick(isVideoCall: Boolean) {
- webRtcCallViewModel.startCall(isVideoCall)
- }
-
- override fun onEndCallClick() {
- viewModel.hangup()
- }
-
- override fun onVideoTooltipDismissed() {
- viewModel.onVideoTooltipDismissed()
- }
-
- private fun observeCallEvents() {
- webRtcCallViewModel.events.observe(this) { event ->
- viewModel.onCallEvent(event)
- }
- }
-
- private fun isInPipMode(): Boolean {
- return isSystemPipEnabledAndAvailable() && isInPictureInPictureMode
- }
-
- private fun isSystemPipEnabledAndAvailable(): Boolean {
- return Build.VERSION.SDK_INT >= 26 && packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallIntent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallIntent.kt
index 09878b0e04..baa0a6757c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallIntent.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallIntent.kt
@@ -8,8 +8,6 @@ package org.thoughtcrime.securesms.components.webrtc.v2
import android.app.Activity
import android.content.Context
import android.content.Intent
-import org.thoughtcrime.securesms.keyvalue.SignalStore
-import org.thoughtcrime.securesms.util.RemoteConfig
/**
* CallIntent wraps an intent inside one of the call activities to allow for easy typed access to the necessary data within it.
@@ -23,11 +21,7 @@ class CallIntent(
private const val CALL_INTENT_PREFIX = "CallIntent"
@JvmStatic
- fun getActivityClass(): Class = if (RemoteConfig.newCallUi || SignalStore.internal.newCallingUi) {
- CallActivity::class.java
- } else {
- WebRtcCallActivity::class.java
- }
+ fun getActivityClass(): Class = WebRtcCallActivity::class.java
private fun getActionString(action: Action): String {
return "$CALL_INTENT_PREFIX.${action.code}"
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallViewModel.kt
deleted file mode 100644
index d767445edb..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallViewModel.kt
+++ /dev/null
@@ -1,441 +0,0 @@
-/*
- * Copyright 2024 Signal Messenger, LLC
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-package org.thoughtcrime.securesms.components.webrtc.v2
-
-import android.os.Build
-import androidx.annotation.StringRes
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.viewModelScope
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import org.greenrobot.eventbus.EventBus
-import org.greenrobot.eventbus.Subscribe
-import org.greenrobot.eventbus.ThreadMode
-import org.signal.core.util.logging.Log
-import org.thoughtcrime.securesms.R
-import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioDevice
-import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput
-import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
-import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel
-import org.thoughtcrime.securesms.database.model.IdentityRecord
-import org.thoughtcrime.securesms.dependencies.AppDependencies
-import org.thoughtcrime.securesms.events.WebRtcViewModel
-import org.thoughtcrime.securesms.recipients.Recipient
-import org.thoughtcrime.securesms.recipients.RecipientId
-import org.thoughtcrime.securesms.service.webrtc.CallLinkDisconnectReason
-import org.thoughtcrime.securesms.service.webrtc.SignalCallManager
-import org.thoughtcrime.securesms.sms.MessageSender
-import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
-import org.whispersystems.signalservice.api.messages.calls.HangupMessage
-import kotlin.time.Duration.Companion.milliseconds
-import kotlin.time.Duration.Companion.seconds
-
-/**
- * Presentation logic and state holder for information that was generally done
- * in-activity for the V1 call screen.
- */
-class CallViewModel(
- private val webRtcCallViewModel: WebRtcCallViewModel,
- private val controlsAndInfoViewModel: ControlsAndInfoViewModel
-) : ViewModel() {
-
- companion object {
- private val TAG = Log.tag(CallViewModel::class)
- }
-
- private var previousEvent: WebRtcViewModel? = null
-
- private var enableVideoIfAvailable = false
-
- private var lastProcessedIntentTimestamp = 0L
- private var lastCallLinkDisconnectDialogShowTime = 0L
-
- private var enterPipOnResume = false
-
- private val internalCallScreenState = MutableStateFlow(CallScreenState())
- val callScreenState: StateFlow = internalCallScreenState
-
- private val internalDialog = MutableStateFlow(CallScreenDialogType.NONE)
- val dialog: StateFlow = internalDialog
-
- private val internalCallActions = MutableSharedFlow()
- val callActions: Flow = internalCallActions
-
- fun consumeEnterPipOnResume(): Boolean {
- val enter = enterPipOnResume
- enterPipOnResume = false
- return enter
- }
-
- fun unregisterEventBus() {
- EventBus.getDefault().unregister(this)
- }
-
- fun onMicToggledChanged(enabled: Boolean) {
- AppDependencies.signalCallManager.setMuteAudio(!enabled)
-
- val update = if (enabled) CallControlsChange.MIC_ON else CallControlsChange.MIC_OFF
- performCallStateUpdateChange(update)
- }
-
- fun onVideoToggleChanged(enabled: Boolean) {
- AppDependencies.signalCallManager.setEnableVideo(enabled)
- }
-
- fun onGroupRingAllowedChanged(allowed: Boolean) {
- AppDependencies.signalCallManager.setRingGroup(allowed)
- }
-
- fun onAdditionalActionsClick() {
- // TODO Toggle overflow popup
- }
-
- /**
- * Denies the call. If successful, returns true.
- */
- fun deny() {
- val recipient = webRtcCallViewModel.recipient.get()
- if (recipient != Recipient.UNKNOWN) {
- AppDependencies.signalCallManager.denyCall()
-
- internalCallScreenState.update {
- it.copy(
- callStatus = CallString.ResourceString(R.string.RedPhone_ending_call),
- hangup = CallScreenState.Hangup(
- hangupMessageType = HangupMessage.Type.NORMAL
- )
- )
- }
- }
- }
-
- fun hangup() {
- Log.i(TAG, "Hangup pressed, handling termination now...")
- AppDependencies.signalCallManager.localHangup()
-
- internalCallScreenState.update {
- it.copy(
- hangup = CallScreenState.Hangup(
- hangupMessageType = HangupMessage.Type.NORMAL
- )
- )
- }
- }
-
- fun onGroupRingToggleChanged(enabled: Boolean, allowed: Boolean) {
- if (allowed) {
- AppDependencies.signalCallManager.setRingGroup(enabled)
- val update = if (enabled) CallControlsChange.RINGING_ON else CallControlsChange.RINGING_OFF
- performCallStateUpdateChange(update)
- } else {
- AppDependencies.signalCallManager.setRingGroup(false)
- performCallStateUpdateChange(CallControlsChange.RINGING_DISABLED)
- }
- }
-
- fun onCallScreenDialogDismissed() {
- internalDialog.update { CallScreenDialogType.NONE }
- }
-
- fun onVideoTooltipDismissed() {
- webRtcCallViewModel.onDismissedVideoTooltip()
- internalCallScreenState.update { it.copy(displayVideoTooltip = false) }
- }
-
- fun onCallEvent(event: CallEvent) {
- when (event) {
- CallEvent.DismissSwitchCameraTooltip -> internalCallScreenState.update { it.copy(displaySwitchCameraTooltip = false) }
- CallEvent.DismissVideoTooltip -> internalCallScreenState.update { it.copy(displayVideoTooltip = false) }
- is CallEvent.ShowGroupCallSafetyNumberChange -> {
- viewModelScope.launch {
- internalCallActions.emit(Action.ShowGroupCallSafetyNumberChangeDialog(event.identityRecords))
- }
- }
- CallEvent.ShowSwipeToSpeakerHint -> internalCallScreenState.update { it.copy(displaySwipeToSpeakerHint = true) }
- CallEvent.ShowSwitchCameraTooltip -> internalCallScreenState.update { it.copy(displaySwitchCameraTooltip = true) }
- CallEvent.ShowVideoTooltip -> internalCallScreenState.update { it.copy(displayVideoTooltip = true) }
- CallEvent.ShowWifiToCellularPopup -> internalCallScreenState.update { it.copy(displayWifiToCellularPopup = true) }
- is CallEvent.StartCall -> startCall(event.isVideoCall)
- CallEvent.SwitchToSpeaker -> {
- viewModelScope.launch {
- internalCallActions.emit(Action.SwitchToSpeaker)
- }
- }
- }
- }
-
- @Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
- fun onWebRtcEvent(event: WebRtcViewModel) {
- Log.i(TAG, "Got message from service: " + event.describeDifference(previousEvent))
- previousEvent = event
-
- webRtcCallViewModel.setRecipient(event.recipient)
- internalCallScreenState.update {
- it.copy(
- callRecipientId = event.recipient.id
- )
- }
- controlsAndInfoViewModel.setRecipient(event.recipient)
-
- when (event.state) {
- WebRtcViewModel.State.IDLE -> Unit
- WebRtcViewModel.State.CALL_PRE_JOIN -> handlePreJoin(event)
- WebRtcViewModel.State.CALL_INCOMING -> Unit
- WebRtcViewModel.State.CALL_OUTGOING -> handleOutgoing(event)
- WebRtcViewModel.State.CALL_CONNECTED -> handleConnected(event)
- WebRtcViewModel.State.CALL_RINGING -> handleRinging()
- WebRtcViewModel.State.CALL_BUSY -> handleBusy()
- WebRtcViewModel.State.CALL_DISCONNECTED -> handleCallTerminated(HangupMessage.Type.NORMAL)
- WebRtcViewModel.State.CALL_DISCONNECTED_GLARE -> handleGlare(event.recipient.id)
- WebRtcViewModel.State.CALL_NEEDS_PERMISSION -> handleCallTerminated(HangupMessage.Type.NEED_PERMISSION)
- WebRtcViewModel.State.CALL_RECONNECTING -> handleReconnecting()
- WebRtcViewModel.State.NETWORK_FAILURE -> handleNetworkFailure()
- WebRtcViewModel.State.RECIPIENT_UNAVAILABLE -> handleRecipientUnavailable()
- WebRtcViewModel.State.NO_SUCH_USER -> Unit // TODO
- WebRtcViewModel.State.UNTRUSTED_IDENTITY -> Unit // TODO
- WebRtcViewModel.State.CALL_ACCEPTED_ELSEWHERE -> handleCallTerminated(HangupMessage.Type.ACCEPTED)
- WebRtcViewModel.State.CALL_DECLINED_ELSEWHERE -> handleCallTerminated(HangupMessage.Type.DECLINED)
- WebRtcViewModel.State.CALL_ONGOING_ELSEWHERE -> handleCallTerminated(HangupMessage.Type.BUSY)
- }
-
- if (event.callLinkDisconnectReason != null && event.callLinkDisconnectReason.postedAt > lastCallLinkDisconnectDialogShowTime) {
- lastCallLinkDisconnectDialogShowTime = System.currentTimeMillis()
-
- when (event.callLinkDisconnectReason) {
- is CallLinkDisconnectReason.RemovedFromCall -> internalDialog.update { CallScreenDialogType.REMOVED_FROM_CALL_LINK }
- is CallLinkDisconnectReason.DeniedRequestToJoinCall -> internalDialog.update { CallScreenDialogType.DENIED_REQUEST_TO_JOIN_CALL_LINK }
- }
- }
-
- val enableVideo = event.localParticipant.cameraState.cameraCount > 0 && enableVideoIfAvailable
- webRtcCallViewModel.updateFromWebRtcViewModel(event, enableVideo)
-
- if (enableVideo) {
- enableVideoIfAvailable = false
- viewModelScope.launch {
- internalCallActions.emit(Action.EnableVideo)
- }
- }
-
- // TODO [alex] -- handle denied bluetooth permission
- }
-
- private fun startCall(isVideoCall: Boolean) {
- enableVideoIfAvailable = isVideoCall
-
- if (isVideoCall) {
- AppDependencies.signalCallManager.startOutgoingVideoCall(webRtcCallViewModel.recipient.get())
- } else {
- AppDependencies.signalCallManager.startOutgoingAudioCall(webRtcCallViewModel.recipient.get())
- }
-
- MessageSender.onMessageSent()
- }
-
- private fun performCallStateUpdateChange(update: CallControlsChange) {
- viewModelScope.launch {
- internalCallScreenState.update {
- it.copy(callControlsChange = update)
- }
-
- delay(1000)
-
- internalCallScreenState.update {
- if (it.callControlsChange == update) {
- it.copy(callControlsChange = null)
- } else {
- it
- }
- }
- }
- }
-
- private fun handlePreJoin(event: WebRtcViewModel) {
- if (event.groupState.isNotIdle && event.ringGroup && event.areRemoteDevicesInCall()) {
- AppDependencies.signalCallManager.setRingGroup(false)
- }
- }
-
- private fun handleOutgoing(event: WebRtcViewModel) {
- val status = if (event.groupState.isNotIdle) {
- getStatusFromGroupState(event.groupState)
- } else {
- CallString.ResourceString(R.string.WebRtcCallActivity__calling)
- }
-
- internalCallScreenState.update {
- it.copy(callStatus = status)
- }
- }
-
- private fun handleConnected(event: WebRtcViewModel) {
- if (event.groupState.isNotIdleOrConnected) {
- val status = getStatusFromGroupState(event.groupState)
-
- internalCallScreenState.update {
- it.copy(callStatus = status)
- }
- }
- }
-
- private fun handleRinging() {
- internalCallScreenState.update {
- it.copy(callStatus = CallString.ResourceString(R.string.RedPhone_ringing))
- }
- }
-
- private fun handleBusy() {
- EventBus.getDefault().removeStickyEvent(WebRtcViewModel::class.java)
- internalCallScreenState.update {
- it.copy(callStatus = CallString.ResourceString(R.string.RedPhone_busy))
- }
-
- internalCallScreenState.update {
- it.copy(
- hangup = CallScreenState.Hangup(
- hangupMessageType = HangupMessage.Type.BUSY,
- delay = SignalCallManager.BUSY_TONE_LENGTH.milliseconds
- )
- )
- }
- }
-
- private fun handleGlare(recipientId: RecipientId) {
- Log.i(TAG, "handleGlare: $recipientId")
-
- internalCallScreenState.update {
- it.copy(callStatus = null)
- }
- }
-
- private fun handleReconnecting() {
- internalCallScreenState.update {
- it.copy(callStatus = CallString.ResourceString(R.string.WebRtcCallView__reconnecting))
- }
- }
-
- private fun handleNetworkFailure() {
- EventBus.getDefault().removeStickyEvent(WebRtcViewModel::class.java)
-
- internalCallScreenState.update {
- it.copy(callStatus = CallString.ResourceString(R.string.RedPhone_network_failed))
- }
- }
-
- private fun handleRecipientUnavailable() {
- }
-
- private fun handleCallTerminated(hangupType: HangupMessage.Type) {
- Log.i(TAG, "handleTerminate called: " + hangupType.name)
-
- EventBus.getDefault().removeStickyEvent(WebRtcViewModel::class.java)
-
- internalCallScreenState.update {
- it.copy(
- callStatus = CallString.ResourceString(getStatusFromHangupType(hangupType)),
- hangup = CallScreenState.Hangup(
- hangupMessageType = hangupType
- )
- )
- }
- }
-
- @StringRes
- private fun getStatusFromHangupType(hangupType: HangupMessage.Type): Int {
- return when (hangupType) {
- HangupMessage.Type.NORMAL, HangupMessage.Type.NEED_PERMISSION -> R.string.RedPhone_ending_call
- HangupMessage.Type.ACCEPTED -> R.string.WebRtcCallActivity__answered_on_a_linked_device
- HangupMessage.Type.DECLINED -> R.string.WebRtcCallActivity__declined_on_a_linked_device
- HangupMessage.Type.BUSY -> R.string.WebRtcCallActivity__busy_on_a_linked_device
- }
- }
-
- private fun getStatusFromGroupState(groupState: WebRtcViewModel.GroupCallState): CallString? {
- return when (groupState) {
- WebRtcViewModel.GroupCallState.DISCONNECTED -> CallString.ResourceString(R.string.WebRtcCallView__disconnected)
- WebRtcViewModel.GroupCallState.RECONNECTING -> CallString.ResourceString(R.string.WebRtcCallView__reconnecting)
- WebRtcViewModel.GroupCallState.CONNECTED_AND_PENDING -> CallString.ResourceString(R.string.WebRtcCallView__joining)
- WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINING -> CallString.ResourceString(R.string.WebRtcCallView__waiting_to_be_let_in)
- else -> null
- }
- }
-
- fun onAudioDeviceSheetDisplayChanged(displayed: Boolean) {
- internalCallScreenState.update {
- it.copy(isDisplayingAudioToggleSheet = displayed)
- }
- }
-
- fun onSelectedAudioDeviceChanged(audioDevice: WebRtcAudioDevice) {
- // TODO [alex] maybeDisplaySpeakerphonePopup(audioOutput);
- if (Build.VERSION.SDK_INT >= 31) {
- AppDependencies.signalCallManager.selectAudioDevice(SignalAudioManager.ChosenAudioDeviceIdentifier(audioDevice.deviceId!!))
- } else {
- val managerDevice = when (audioDevice.webRtcAudioOutput) {
- WebRtcAudioOutput.HANDSET -> SignalAudioManager.AudioDevice.EARPIECE
- WebRtcAudioOutput.SPEAKER -> SignalAudioManager.AudioDevice.SPEAKER_PHONE
- WebRtcAudioOutput.BLUETOOTH_HEADSET -> SignalAudioManager.AudioDevice.BLUETOOTH
- WebRtcAudioOutput.WIRED_HEADSET -> SignalAudioManager.AudioDevice.WIRED_HEADSET
- }
-
- AppDependencies.signalCallManager.selectAudioDevice(SignalAudioManager.ChosenAudioDeviceIdentifier(managerDevice))
- }
- }
-
- fun processCallIntent(callIntent: CallIntent) {
- if (callIntent.action == CallIntent.Action.ANSWER_VIDEO) {
- enableVideoIfAvailable = true
- } else if (callIntent.action == CallIntent.Action.ANSWER_AUDIO || callIntent.isStartedFromFullScreen) {
- enableVideoIfAvailable = false
- } else {
- enableVideoIfAvailable = callIntent.shouldEnableVideoIfAvailable
- callIntent.shouldEnableVideoIfAvailable = false
- }
-
- when (callIntent.action) {
- CallIntent.Action.ANSWER_AUDIO -> startCall(false)
- CallIntent.Action.ANSWER_VIDEO -> startCall(true)
- CallIntent.Action.DENY -> deny()
- CallIntent.Action.END_CALL -> hangup()
- CallIntent.Action.VIEW -> Unit
- }
-
- // Prevents some issues around intent re-use when dealing with picture-in-picture.
- val now = System.currentTimeMillis()
- if (now - lastProcessedIntentTimestamp > 1.seconds.inWholeMilliseconds) {
- enterPipOnResume = callIntent.shouldLaunchInPip
- }
-
- lastProcessedIntentTimestamp = now
- }
-
- /**
- * Actions that require activity-level context (for example, to request permissions.)
- */
- sealed interface Action {
- /**
- * Tries to enable local video via the normal toggle callback. Should display permissions
- * dialogs as necessary.
- */
- data object EnableVideo : Action
-
- /**
- * Display the safety number change dialog for the given untrusted identities. Since this dialog
- * is not in compose-land, we delegate this as an action instead of embedding it in the screen state.
- */
- data class ShowGroupCallSafetyNumberChangeDialog(val untrustedIdentities: List) : Action
-
- /**
- * Immediately switch the user to speaker view
- */
- data object SwitchToSpeaker : Action
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt
index 3aa76a31e7..84b2ee9136 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt
@@ -60,8 +60,6 @@ import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsView
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioDevice
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView
-import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
-import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel.SafetyNumberChangeEvent
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls
import org.thoughtcrime.securesms.components.webrtc.WifiToCellularPopupWindow
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoController
@@ -487,27 +485,27 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
viewModel.setIsLandscapeEnabled(true)
viewModel.setIsInPipMode(isInPipMode())
viewModel.microphoneEnabled.observe(this, callScreen::setMicEnabled)
- viewModel.webRtcControls.observe(this) { controls ->
+ viewModel.getWebRtcControls().observe(this) { controls ->
callScreen.setWebRtcControls(controls)
controlsAndInfo.updateControls(controls)
}
- viewModel.events.observe(this, this::handleViewModelEvent)
+ viewModel.getEvents().observe(this, this::handleViewModelEvent)
- lifecycleDisposable.add(viewModel.inCallstatus.subscribe(this::handleInCallStatus))
- lifecycleDisposable.add(viewModel.recipientFlowable.subscribe(callScreen::setRecipient))
+ lifecycleDisposable.add(viewModel.getInCallStatus().subscribe(this::handleInCallStatus))
+ lifecycleDisposable.add(viewModel.getRecipientFlowable().subscribe(callScreen::setRecipient))
val isStartedFromCallLink = getCallIntent().isStartedFromCallLink
LiveDataUtil.combineLatest(
viewModel.callParticipantsState.toFlowable(BackpressureStrategy.LATEST).toLiveData(),
- viewModel.ephemeralState
+ viewModel.getEphemeralState()
) { state, ephemeralState ->
CallParticipantsViewState(state, ephemeralState, orientation == Orientation.PORTRAIT_BOTTOM_EDGE, true, isStartedFromCallLink)
}.observe(this, callScreen::updateCallParticipants)
- viewModel.callParticipantListUpdate.observe(this, participantUpdateWindow::addCallParticipantListUpdate)
- viewModel.safetyNumberChangeEvent.observe(this, this::handleSafetyNumberChangeEvent)
- viewModel.groupMembersChanged.observe(this) { updateGroupMembersForGroupCall() }
- viewModel.groupMemberCount.observe(this, this::handleGroupMemberCountChange)
+ viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate)
+ viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent)
+ viewModel.getGroupMembersChanged().observe(this) { updateGroupMembersForGroupCall() }
+ viewModel.getGroupMemberCount().observe(this, this::handleGroupMemberCountChange)
lifecycleDisposable.add(viewModel.shouldShowSpeakerHint().subscribe(this::updateSpeakerHint))
callScreen.viewTreeObserver.addOnGlobalLayoutListener {
@@ -530,7 +528,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
}
callScreen.setPendingParticipantsViewListener(PendingParticipantsViewListener())
- lifecycleDisposable += viewModel.pendingParticipants.subscribe(callScreen::updatePendingParticipantsList)
+ lifecycleDisposable += viewModel.getPendingParticipants().subscribe(callScreen::updatePendingParticipantsList)
}
private fun initializePictureInPictureParams() {
@@ -589,7 +587,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
this
) { _, result ->
val action: PendingParticipantsBottomSheet.Action = PendingParticipantsBottomSheet.getAction(result)
- val recipientIds = viewModel.pendingParticipantsSnapshot.getUnresolvedPendingParticipants().map { it.recipient.id }
+ val recipientIds = viewModel.getPendingParticipantsSnapshot().getUnresolvedPendingParticipants().map { it.recipient.id }
when (action) {
PendingParticipantsBottomSheet.Action.NONE -> Unit
@@ -911,7 +909,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
}
}
- private fun handleSafetyNumberChangeEvent(safetyNumberChangeEvent: SafetyNumberChangeEvent) {
+ private fun handleSafetyNumberChangeEvent(safetyNumberChangeEvent: WebRtcCallViewModel.SafetyNumberChangeEvent) {
if (safetyNumberChangeEvent.recipientIds.isNotEmpty()) {
if (safetyNumberChangeEvent.isInPipMode) {
GroupCallSafetyNumberChangeNotificationUtil.showNotification(this, viewModel.recipient.get())
@@ -1033,7 +1031,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
}
private fun maybeDisplaySpeakerphonePopup(nextOutput: WebRtcAudioOutput) {
- val currentOutput = viewModel.currentAudioOutput
+ val currentOutput = viewModel.getCurrentAudioOutput()
if (currentOutput == WebRtcAudioOutput.SPEAKER && nextOutput != WebRtcAudioOutput.SPEAKER) {
callStateUpdatePopupWindow.onCallStateUpdate(CallControlsChange.SPEAKER_OFF)
} else if (currentOutput != WebRtcAudioOutput.SPEAKER && nextOutput == WebRtcAudioOutput.SPEAKER) {
@@ -1065,7 +1063,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
}
override fun onHidden() {
- val controlState = viewModel.webRtcControls.value
+ val controlState = viewModel.getWebRtcControls().value
if (controlState == null || !controlState.displayErrorControls()) {
fullscreenHelper.hideSystemUI()
videoTooltip?.dismiss()
@@ -1130,7 +1128,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
}
override fun onAcceptCallPressed() {
- if (viewModel.isAnswerWithVideoAvailable) {
+ if (viewModel.isAnswerWithVideoAvailable()) {
handleAnswerWithVideo()
} else {
handleAnswerWithAudio()
@@ -1165,7 +1163,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
}
override fun toggleControls() {
- val controlState = viewModel.webRtcControls.value
+ val controlState = viewModel.getWebRtcControls().value
if (controlState != null && !controlState.displayIncomingCallButtons() && !controlState.displayErrorControls()) {
controlsAndInfo.toggleControls()
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt
new file mode 100644
index 0000000000..8032c15fcf
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt
@@ -0,0 +1,499 @@
+/*
+ * Copyright 2025 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.components.webrtc.v2
+
+import android.os.Handler
+import android.os.Looper
+import androidx.annotation.MainThread
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.Observer
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.distinctUntilChanged
+import androidx.lifecycle.map
+import androidx.lifecycle.switchMap
+import androidx.lifecycle.toPublisher
+import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
+import io.reactivex.rxjava3.core.BackpressureStrategy
+import io.reactivex.rxjava3.core.Flowable
+import io.reactivex.rxjava3.core.Observable
+import io.reactivex.rxjava3.processors.BehaviorProcessor
+import io.reactivex.rxjava3.subjects.BehaviorSubject
+import org.signal.core.util.ThreadUtil
+import org.thoughtcrime.securesms.components.webrtc.CallParticipantListUpdate
+import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState
+import org.thoughtcrime.securesms.components.webrtc.InCallStatus
+import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput
+import org.thoughtcrime.securesms.components.webrtc.WebRtcCallRepository
+import org.thoughtcrime.securesms.components.webrtc.WebRtcControls
+import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState
+import org.thoughtcrime.securesms.database.GroupTable
+import org.thoughtcrime.securesms.database.SignalDatabase
+import org.thoughtcrime.securesms.dependencies.AppDependencies
+import org.thoughtcrime.securesms.events.CallParticipant
+import org.thoughtcrime.securesms.events.CallParticipantId
+import org.thoughtcrime.securesms.events.WebRtcViewModel
+import org.thoughtcrime.securesms.groups.LiveGroup
+import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.recipients.LiveRecipient
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection
+import org.thoughtcrime.securesms.service.webrtc.state.PendingParticipantsState
+import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState
+import org.thoughtcrime.securesms.util.NetworkUtil
+import org.thoughtcrime.securesms.util.SingleLiveEvent
+import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
+import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
+import java.util.Collections
+
+class WebRtcCallViewModel : ViewModel() {
+ private val internalMicrophoneEnabled = MutableLiveData(true)
+ private val isInPipMode = MutableLiveData(false)
+ private val webRtcControls = MutableLiveData(WebRtcControls.NONE)
+ private val foldableState = MutableLiveData(WebRtcControls.FoldableState.flat())
+ private val controlsWithFoldableState: LiveData = LiveDataUtil.combineLatest(foldableState, webRtcControls, this::updateControlsFoldableState)
+ private val realWebRtcControls: LiveData = LiveDataUtil.combineLatest(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls)
+ private val events = SingleLiveEvent()
+ private val elapsed = BehaviorSubject.createDefault(-1L)
+ private val liveRecipient = MutableLiveData(Recipient.UNKNOWN.live())
+ private val participantsState = BehaviorSubject.createDefault(CallParticipantsState.STARTING_STATE)
+ private val callParticipantListUpdate = SingleLiveEvent()
+ private val identityChangedRecipients = MutableLiveData>(Collections.emptyList())
+ private val safetyNumberChangeEvent: LiveData = LiveDataUtil.combineLatest(isInPipMode, identityChangedRecipients, ::SafetyNumberChangeEvent)
+ private val groupRecipient: LiveData = LiveDataUtil.filter(liveRecipient.switchMap(LiveRecipient::getLiveData), Recipient::isActiveGroup)
+ private val groupMembers: LiveData> = groupRecipient.switchMap { r -> LiveGroup(r.requireGroupId()).fullMembers }
+ private val groupMembersChanged: LiveData> = LiveDataUtil.skip(groupMembers, 1)
+ private val groupMembersCount: LiveData = groupMembers.map { it.size }
+ private val shouldShowSpeakerHint: Observable = participantsState.map(this::shouldShowSpeakerHint)
+ private val isLandscapeEnabled = MutableLiveData()
+ private val canEnterPipMode = MutableLiveData(false)
+ private val groupMemberStateUpdater = Observer> { m -> participantsState.onNext(CallParticipantsState.update(participantsState.value!!, m)) }
+ private val ephemeralState = MutableLiveData()
+ private val recipientId = BehaviorProcessor.createDefault(RecipientId.UNKNOWN)
+
+ private val pendingParticipants = BehaviorSubject.create()
+
+ private val elapsedTimeHandler = Handler(Looper.getMainLooper())
+ private val elapsedTimeRunnable = Runnable { handleTick() }
+ private val stopOutgoingRingingMode = Runnable { stopOutgoingRingingMode() }
+
+ private var canDisplayTooltipIfNeeded = true
+ private var canDisplaySwitchCameraTooltipIfNeeded = true
+ private var canDisplayPopupIfNeeded = true
+ private var hasEnabledLocalVideo = false
+ private var wasInOutgoingRingingMode = false
+ private var callConnectedTime = -1L
+ private var answerWithVideoAvailable = false
+ private var previousParticipantList = Collections.emptyList()
+ private var switchOnFirstScreenShare = true
+ private var showScreenShareTip = true
+
+ var isCallStarting = false
+ private set
+
+ init {
+ groupMembers.observeForever(groupMemberStateUpdater)
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ cancelTimer()
+ groupMembers.removeObserver(groupMemberStateUpdater)
+ }
+
+ val microphoneEnabled: LiveData get() = internalMicrophoneEnabled.distinctUntilChanged()
+
+ fun getWebRtcControls(): LiveData = realWebRtcControls
+
+ val recipient: LiveRecipient get() = liveRecipient.value!!
+
+ fun getRecipientFlowable(): Flowable {
+ return recipientId
+ .switchMap { Recipient.observable(it).toFlowable(BackpressureStrategy.LATEST) }
+ .observeOn(AndroidSchedulers.mainThread())
+ }
+
+ fun setRecipient(recipient: Recipient) {
+ recipientId.onNext(recipient.id)
+ liveRecipient.value = recipient.live()
+ }
+
+ fun setFoldableState(foldableState: WebRtcControls.FoldableState) {
+ this.foldableState.postValue(foldableState)
+ ThreadUtil.runOnMain { participantsState.onNext(CallParticipantsState.update(participantsState.value!!, foldableState)) }
+ }
+
+ fun getEvents(): LiveData {
+ return events
+ }
+
+ fun getInCallStatus(): Observable {
+ val elapsedTime: Observable = elapsed.map { timeInCall -> if (callConnectedTime == -1L) -1L else timeInCall }
+
+ return Observable.combineLatest(
+ elapsedTime,
+ pendingParticipants,
+ participantsState
+ ) { time, pendingParticipants, participantsState ->
+ if (!recipient.get().isCallLink) {
+ return@combineLatest InCallStatus.ElapsedTime(time)
+ }
+
+ val pending: Set = pendingParticipants.getUnresolvedPendingParticipants()
+
+ if (pending.isNotEmpty()) {
+ InCallStatus.PendingCallLinkUsers(pending.size)
+ } else {
+ InCallStatus.JoinedCallLinkUsers(participantsState.participantCount.orElse(0L).toInt())
+ }
+ }.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread())
+ }
+
+ fun getCallControlsState(lifecycleOwner: LifecycleOwner): Flowable {
+ val groupSize: Flowable = recipientId.filter { it != RecipientId.UNKNOWN }
+ .switchMap { Recipient.observable(it).toFlowable(BackpressureStrategy.LATEST) }
+ .map {
+ if (it.isActiveGroup) {
+ SignalDatabase.groups.getGroupMemberIds(it.requireGroupId(), GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF).size
+ } else {
+ 0
+ }
+ }
+
+ return Flowable.combineLatest(
+ callParticipantsState.toFlowable(BackpressureStrategy.LATEST),
+ getWebRtcControls().toPublisher(lifecycleOwner),
+ groupSize,
+ CallControlsState::fromViewModelData
+ )
+ }
+
+ val callParticipantsState: Observable get() = participantsState
+
+ val callParticipantsStateSnapshot: CallParticipantsState? get() = participantsState.value
+
+ fun getCallParticipantListUpdate(): LiveData {
+ return callParticipantListUpdate
+ }
+
+ fun getSafetyNumberChangeEvent(): LiveData {
+ return safetyNumberChangeEvent
+ }
+
+ fun getGroupMembersChanged(): LiveData> {
+ return groupMembersChanged
+ }
+
+ fun getGroupMemberCount(): LiveData {
+ return groupMembersCount
+ }
+
+ fun shouldShowSpeakerHint(): Observable {
+ return shouldShowSpeakerHint.observeOn(AndroidSchedulers.mainThread())
+ }
+
+ fun getCurrentAudioOutput(): WebRtcAudioOutput {
+ return getWebRtcControls().value!!.audioOutput
+ }
+
+ fun getEphemeralState(): LiveData {
+ return ephemeralState
+ }
+
+ fun canEnterPipMode(): LiveData {
+ return canEnterPipMode
+ }
+
+ fun isAnswerWithVideoAvailable(): Boolean {
+ return answerWithVideoAvailable
+ }
+
+ fun getPendingParticipants(): Observable {
+ val isInPipMode: Observable = participantsState.map { it.isInPipMode }.distinctUntilChanged()
+ return Observable.combineLatest(pendingParticipants, isInPipMode, ::PendingParticipantsState)
+ }
+
+ fun getPendingParticipantsSnapshot(): PendingParticipantCollection {
+ return pendingParticipants.value!!
+ }
+
+ fun setIsInPipMode(isInPipMode: Boolean) {
+ this.isInPipMode.value = isInPipMode
+ participantsState.onNext(CallParticipantsState.update(participantsState.value!!, isInPipMode))
+ }
+
+ fun setIsLandscapeEnabled(isLandscapeEnabled: Boolean) {
+ this.isLandscapeEnabled.postValue(isLandscapeEnabled)
+ }
+
+ @MainThread
+ fun setIsViewingFocusedParticipant(page: CallParticipantsState.SelectedPage) {
+ if (page == CallParticipantsState.SelectedPage.FOCUSED) {
+ SignalStore.tooltips.markGroupCallSpeakerViewSeen()
+ }
+
+ val state = participantsState.value!!
+ if (showScreenShareTip &&
+ state.focusedParticipant.isScreenSharing &&
+ state.isViewingFocusedParticipant &&
+ page == CallParticipantsState.SelectedPage.GRID
+ ) {
+ showScreenShareTip = false
+ events.value = CallEvent.ShowSwipeToSpeakerHint
+ }
+
+ participantsState.onNext(CallParticipantsState.update(participantsState.value!!, page))
+ }
+
+ fun onLocalPictureInPictureClicked() {
+ val state = participantsState.value!!
+ participantsState.onNext(CallParticipantsState.setExpanded(participantsState.value!!, state.localRenderState != WebRtcLocalRenderState.EXPANDED))
+ }
+
+ fun onDismissedVideoTooltip() {
+ canDisplayTooltipIfNeeded = false
+ }
+
+ fun onDismissedSwitchCameraTooltip() {
+ canDisplaySwitchCameraTooltipIfNeeded = false
+ SignalStore.tooltips.markCallingSwitchCameraTooltipSeen()
+ }
+
+ @MainThread
+ fun updateFromWebRtcViewModel(webRtcViewModel: WebRtcViewModel, enableVideo: Boolean) {
+ canEnterPipMode.value = !webRtcViewModel.state.isPreJoinOrNetworkUnavailable
+ if (isCallStarting && webRtcViewModel.state.isPassedPreJoin) {
+ isCallStarting = false
+ }
+
+ val localParticipant = webRtcViewModel.localParticipant
+
+ internalMicrophoneEnabled.value = localParticipant.isMicrophoneEnabled
+
+ val state: CallParticipantsState = participantsState.value!!
+ val wasScreenSharing: Boolean = state.focusedParticipant.isScreenSharing
+ val newState: CallParticipantsState = CallParticipantsState.update(state, webRtcViewModel, enableVideo)
+
+ participantsState.onNext(newState)
+ if (switchOnFirstScreenShare && !wasScreenSharing && newState.focusedParticipant.isScreenSharing) {
+ switchOnFirstScreenShare = false
+ events.value = CallEvent.SwitchToSpeaker
+ }
+
+ if (webRtcViewModel.groupState.isConnected) {
+ if (!containsPlaceholders(previousParticipantList)) {
+ val update = CallParticipantListUpdate.computeDeltaUpdate(previousParticipantList, webRtcViewModel.remoteParticipants)
+ callParticipantListUpdate.value = update
+ }
+
+ previousParticipantList = webRtcViewModel.remoteParticipants
+ identityChangedRecipients.value = webRtcViewModel.identityChangedParticipants
+ }
+
+ updateWebRtcControls(
+ webRtcViewModel.state,
+ webRtcViewModel.groupState,
+ localParticipant.cameraState.isEnabled,
+ webRtcViewModel.isRemoteVideoEnabled,
+ webRtcViewModel.isRemoteVideoOffer,
+ localParticipant.isMoreThanOneCameraAvailable,
+ webRtcViewModel.hasAtLeastOneRemote,
+ webRtcViewModel.activeDevice,
+ webRtcViewModel.availableDevices,
+ webRtcViewModel.remoteDevicesCount.orElse(0L),
+ webRtcViewModel.participantLimit,
+ webRtcViewModel.recipient.isCallLink,
+ webRtcViewModel.remoteParticipants.size > CallParticipantsState.SMALL_GROUP_MAX
+ )
+
+ pendingParticipants.onNext(webRtcViewModel.pendingParticipants)
+
+ if (newState.isInOutgoingRingingMode) {
+ cancelTimer()
+ if (!wasInOutgoingRingingMode) {
+ elapsedTimeHandler.postDelayed(stopOutgoingRingingMode, CallParticipantsState.MAX_OUTGOING_GROUP_RING_DURATION)
+ }
+ wasInOutgoingRingingMode = true
+ } else {
+ if (webRtcViewModel.state == WebRtcViewModel.State.CALL_CONNECTED && callConnectedTime == -1L) {
+ callConnectedTime = if (wasInOutgoingRingingMode) System.currentTimeMillis() else webRtcViewModel.callConnectedTime
+ startTimer()
+ } else if (webRtcViewModel.state != WebRtcViewModel.State.CALL_CONNECTED || webRtcViewModel.groupState.isNotIdleOrConnected) {
+ cancelTimer()
+ callConnectedTime = -1L
+ }
+ }
+
+ if (webRtcViewModel.state == WebRtcViewModel.State.CALL_PRE_JOIN && webRtcViewModel.groupState.isNotIdle) {
+ // Set flag
+
+ if (webRtcViewModel.ringGroup && webRtcViewModel.areRemoteDevicesInCall()) {
+ AppDependencies.signalCallManager.setRingGroup(false)
+ }
+ }
+
+ if (localParticipant.cameraState.isEnabled) {
+ canDisplayTooltipIfNeeded = false
+ hasEnabledLocalVideo = true
+ events.value = CallEvent.DismissVideoTooltip
+ }
+
+ if (canDisplayTooltipIfNeeded && webRtcViewModel.isRemoteVideoEnabled && !hasEnabledLocalVideo) {
+ canDisplayTooltipIfNeeded = false
+ events.value = CallEvent.ShowVideoTooltip
+ }
+
+ if (canDisplayPopupIfNeeded && webRtcViewModel.isCellularConnection && NetworkUtil.isConnectedWifi(AppDependencies.application)) {
+ canDisplayPopupIfNeeded = false
+ events.value = CallEvent.ShowWifiToCellularPopup
+ } else if (!webRtcViewModel.isCellularConnection) {
+ canDisplayPopupIfNeeded = true
+ }
+
+ if (SignalStore.tooltips.showCallingSwitchCameraTooltip() &&
+ canDisplaySwitchCameraTooltipIfNeeded &&
+ localParticipant.cameraState.isEnabled &&
+ webRtcViewModel.state == WebRtcViewModel.State.CALL_CONNECTED &&
+ newState.allRemoteParticipants.isNotEmpty()
+ ) {
+ canDisplaySwitchCameraTooltipIfNeeded = false
+ events.value = CallEvent.ShowSwitchCameraTooltip
+ }
+ }
+
+ @MainThread
+ fun updateFromEphemeralState(state: WebRtcEphemeralState) {
+ ephemeralState.value = state
+ }
+
+ fun startCall(isVideoCall: Boolean) {
+ isCallStarting = true
+ val recipient = recipient.get()
+ if (recipient.isGroup) {
+ WebRtcCallRepository.getIdentityRecords(recipient) { identityRecords ->
+ if (identityRecords.isUntrusted(false) || identityRecords.isUnverified(false)) {
+ val records = identityRecords.unverifiedRecords + identityRecords.untrustedRecords
+ events.postValue(CallEvent.ShowGroupCallSafetyNumberChange(records))
+ } else {
+ events.postValue(CallEvent.StartCall(isVideoCall))
+ }
+ }
+ } else {
+ events.postValue(CallEvent.StartCall(isVideoCall))
+ }
+ }
+
+ private fun stopOutgoingRingingMode() {
+ if (callConnectedTime == -1L) {
+ callConnectedTime = System.currentTimeMillis()
+ startTimer()
+ }
+ }
+
+ private fun handleTick() {
+ if (callConnectedTime == -1L) {
+ return
+ }
+
+ val newValue = (System.currentTimeMillis() - callConnectedTime) / 1000
+ elapsed.onNext(newValue)
+ elapsedTimeHandler.postDelayed(elapsedTimeRunnable, 1000)
+ }
+
+ private fun updateWebRtcControls(
+ state: WebRtcViewModel.State,
+ groupState: WebRtcViewModel.GroupCallState,
+ isLocalVideoEnabled: Boolean,
+ isRemoteVideoEnabled: Boolean,
+ isRemoteVideoOffer: Boolean,
+ isMoreThanOneCameraAvailable: Boolean,
+ hasAtLeastOneRemote: Boolean,
+ activeDevice: SignalAudioManager.AudioDevice,
+ availableDevices: Set,
+ remoteDevicesCount: Long,
+ participantLimit: Long?,
+ isCallLink: Boolean,
+ hasParticipantOverflow: Boolean
+ ) {
+ val callState = when (state) {
+ WebRtcViewModel.State.CALL_PRE_JOIN -> WebRtcControls.CallState.PRE_JOIN
+ WebRtcViewModel.State.CALL_INCOMING -> {
+ answerWithVideoAvailable = isRemoteVideoOffer
+ WebRtcControls.CallState.INCOMING
+ }
+ WebRtcViewModel.State.CALL_OUTGOING, WebRtcViewModel.State.CALL_RINGING -> WebRtcControls.CallState.OUTGOING
+ WebRtcViewModel.State.CALL_BUSY, WebRtcViewModel.State.CALL_NEEDS_PERMISSION, WebRtcViewModel.State.CALL_DISCONNECTED -> WebRtcControls.CallState.ENDING
+ WebRtcViewModel.State.CALL_DISCONNECTED_GLARE -> WebRtcControls.CallState.INCOMING
+ WebRtcViewModel.State.CALL_RECONNECTING -> WebRtcControls.CallState.RECONNECTING
+ WebRtcViewModel.State.NETWORK_FAILURE -> WebRtcControls.CallState.ERROR
+ WebRtcViewModel.State.CALL_ACCEPTED_ELSEWHERE, WebRtcViewModel.State.CALL_DECLINED_ELSEWHERE, WebRtcViewModel.State.CALL_ONGOING_ELSEWHERE -> WebRtcControls.CallState.HANDLED_ELSEWHERE
+ else -> WebRtcControls.CallState.ONGOING
+ }
+
+ val groupCallState = when (groupState) {
+ WebRtcViewModel.GroupCallState.DISCONNECTED -> WebRtcControls.GroupCallState.DISCONNECTED
+ WebRtcViewModel.GroupCallState.CONNECTING, WebRtcViewModel.GroupCallState.RECONNECTING -> {
+ if (participantLimit == null || remoteDevicesCount < participantLimit) WebRtcControls.GroupCallState.CONNECTING else WebRtcControls.GroupCallState.FULL
+ }
+ WebRtcViewModel.GroupCallState.CONNECTED, WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINING, WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED -> WebRtcControls.GroupCallState.CONNECTED
+ WebRtcViewModel.GroupCallState.CONNECTED_AND_PENDING -> WebRtcControls.GroupCallState.PENDING
+ else -> WebRtcControls.GroupCallState.NONE
+ }
+
+ webRtcControls.value = WebRtcControls(
+ isLocalVideoEnabled,
+ isRemoteVideoEnabled || isRemoteVideoOffer,
+ isMoreThanOneCameraAvailable,
+ isInPipMode.value == true,
+ hasAtLeastOneRemote,
+ callState,
+ groupCallState,
+ participantLimit,
+ WebRtcControls.FoldableState.flat(),
+ activeDevice,
+ availableDevices,
+ isCallLink,
+ hasParticipantOverflow
+ )
+ }
+
+ private fun updateControlsFoldableState(foldableState: WebRtcControls.FoldableState, controls: WebRtcControls): WebRtcControls {
+ return controls.withFoldableState(foldableState)
+ }
+
+ private fun getRealWebRtcControls(isInPipMode: Boolean, controls: WebRtcControls): WebRtcControls {
+ return if (isInPipMode) WebRtcControls.PIP else controls
+ }
+
+ private fun shouldShowSpeakerHint(state: CallParticipantsState): Boolean {
+ return !state.isInPipMode &&
+ state.remoteDevicesCount.orElse(0L) > 1L &&
+ state.groupCallState.isConnected &&
+ !SignalStore.tooltips.hasSeenGroupCallSpeakerView()
+ }
+
+ private fun startTimer() {
+ cancelTimer()
+ elapsedTimeHandler.removeCallbacks(stopOutgoingRingingMode)
+ elapsedTimeHandler.post(elapsedTimeRunnable)
+ }
+
+ private fun cancelTimer() {
+ return elapsedTimeHandler.removeCallbacks(elapsedTimeRunnable)
+ }
+
+ private fun containsPlaceholders(callParticipants: List): Boolean {
+ return callParticipants.any { it.callParticipantId.demuxId == CallParticipantId.DEFAULT_ID }
+ }
+
+ class SafetyNumberChangeEvent(
+ val isInPipMode: Boolean,
+ val recipientIds: Collection
+ )
+}