Convert WebRtcCallViewModel into Kotlin.

This commit is contained in:
Alex Hart
2025-02-03 10:31:20 -04:00
committed by Greyson Parrelli
parent 27a3cc0305
commit eac44de527
14 changed files with 525 additions and 1418 deletions

View File

@@ -45,6 +45,7 @@ import org.signal.core.ui.DarkPreview
import org.signal.core.ui.Previews
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.webrtc.v2.WebRtcCallViewModel
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.Recipient
@@ -80,7 +81,7 @@ class PendingParticipantsBottomSheet : ComposeBottomSheetDialogFragment() {
System.currentTimeMillis().milliseconds
}
val participants = viewModel.pendingParticipants
val participants = viewModel.getPendingParticipants()
.map { it.pendingParticipantCollection.getAllPendingParticipants(launchTime).toList() }
.subscribeAsState(initial = emptyList())

View File

@@ -16,16 +16,12 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.Collections;
import java.util.List;
class WebRtcCallRepository {
public final class WebRtcCallRepository {
private final Context context;
WebRtcCallRepository(@NonNull Context context) {
this.context = context;
}
private WebRtcCallRepository() {}
@WorkerThread
void getIdentityRecords(@NonNull Recipient recipient, @NonNull Consumer<IdentityRecordList> consumer) {
public static void getIdentityRecords(@NonNull Recipient recipient, @NonNull Consumer<IdentityRecordList> consumer) {
SignalExecutors.BOUNDED.execute(() -> {
List<Recipient> recipients;

View File

@@ -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<Boolean> microphoneEnabled = new MutableLiveData<>(true);
private final MutableLiveData<Boolean> isInPipMode = new MutableLiveData<>(false);
private final MutableLiveData<WebRtcControls> webRtcControls = new MutableLiveData<>(WebRtcControls.NONE);
private final MutableLiveData<WebRtcControls.FoldableState> foldableState = new MutableLiveData<>(WebRtcControls.FoldableState.flat());
private final LiveData<WebRtcControls> controlsWithFoldableState = LiveDataUtil.combineLatest(foldableState, webRtcControls, this::updateControlsFoldableState);
private final LiveData<WebRtcControls> realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls);
private final SingleLiveEvent<CallEvent> events = new SingleLiveEvent<>();
private final BehaviorSubject<Long> elapsed = BehaviorSubject.createDefault(-1L);
private final MutableLiveData<LiveRecipient> liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live());
private final BehaviorSubject<CallParticipantsState> participantsState = BehaviorSubject.createDefault(CallParticipantsState.STARTING_STATE);
private final SingleLiveEvent<CallParticipantListUpdate> callParticipantListUpdate = new SingleLiveEvent<>();
private final MutableLiveData<Collection<RecipientId>> identityChangedRecipients = new MutableLiveData<>(Collections.emptyList());
private final LiveData<SafetyNumberChangeEvent> safetyNumberChangeEvent = LiveDataUtil.combineLatest(isInPipMode, identityChangedRecipients, SafetyNumberChangeEvent::new);
private final LiveData<Recipient> groupRecipient = LiveDataUtil.filter(Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData), Recipient::isActiveGroup);
private final LiveData<List<GroupMemberEntry.FullMember>> groupMembers = Transformations.switchMap(groupRecipient, r -> Transformations.distinctUntilChanged(new LiveGroup(r.requireGroupId()).getFullMembers()));
private final LiveData<List<GroupMemberEntry.FullMember>> groupMembersChanged = LiveDataUtil.skip(groupMembers, 1);
private final LiveData<Integer> groupMemberCount = Transformations.map(groupMembers, List::size);
private final Observable<Boolean> shouldShowSpeakerHint = participantsState.map(this::shouldShowSpeakerHint);
private final MutableLiveData<Boolean> isLandscapeEnabled = new MutableLiveData<>();
private final MutableLiveData<Boolean> canEnterPipMode = new MutableLiveData<>(false);
private final Observer<List<GroupMemberEntry.FullMember>> groupMemberStateUpdater = m -> participantsState.onNext(CallParticipantsState.update(participantsState.getValue(), m));
private final MutableLiveData<WebRtcEphemeralState> ephemeralState = new MutableLiveData<>();
private final BehaviorProcessor<RecipientId> recipientId = BehaviorProcessor.createDefault(RecipientId.UNKNOWN);
private final BehaviorSubject<PendingParticipantCollection> 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<CallParticipant> 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<Boolean> getMicrophoneEnabled() {
return Transformations.distinctUntilChanged(microphoneEnabled);
}
public LiveData<WebRtcControls> getWebRtcControls() {
return realWebRtcControls;
}
public LiveRecipient getRecipient() {
return liveRecipient.getValue();
}
public Flowable<Recipient> 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<CallEvent> getEvents() {
return events;
}
public Observable<InCallStatus> getInCallstatus() {
Observable<Long> 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<PendingParticipantCollection.Entry> 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<CallControlsState> getCallControlsState(@NonNull LifecycleOwner lifecycleOwner) {
// Calculate this separately so we have a value when the recipient is not a group.
Flowable<Integer> 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<CallParticipantsState> getCallParticipantsState() {
return participantsState;
}
public @Nullable CallParticipantsState getCallParticipantsStateSnapshot() {
return participantsState.getValue();
}
public LiveData<CallParticipantListUpdate> getCallParticipantListUpdate() {
return callParticipantListUpdate;
}
public LiveData<SafetyNumberChangeEvent> getSafetyNumberChangeEvent() {
return safetyNumberChangeEvent;
}
public LiveData<List<GroupMemberEntry.FullMember>> getGroupMembersChanged() {
return groupMembersChanged;
}
public LiveData<Integer> getGroupMemberCount() {
return groupMemberCount;
}
public Observable<Boolean> shouldShowSpeakerHint() {
return shouldShowSpeakerHint.observeOn(AndroidSchedulers.mainThread());
}
public WebRtcAudioOutput getCurrentAudioOutput() {
return getWebRtcControls().getValue().getAudioOutput();
}
public LiveData<WebRtcEphemeralState> getEphemeralState() {
return ephemeralState;
}
public LiveData<Boolean> canEnterPipMode() {
return canEnterPipMode;
}
public boolean isAnswerWithVideoAvailable() {
return answerWithVideoAvailable;
}
public boolean isCallStarting() {
return callStarting;
}
public @NonNull Observable<PendingParticipantsState> getPendingParticipants() {
Observable<Boolean> 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<CallParticipant> 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<SignalAudioManager.AudioDevice> 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<IdentityRecord> 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<RecipientId> recipientIds;
private SafetyNumberChangeEvent(boolean isInPipMode, @NonNull Collection<RecipientId> recipientIds) {
this.isInPipMode = isInPipMode;
this.recipientIds = recipientIds;
}
public boolean isInPipMode() {
return isInPipMode;
}
public @NonNull Collection<RecipientId> getRecipientIds() {
return recipientIds;
}
}
}

View File

@@ -63,7 +63,6 @@ public final class WebRtcControls {
false);
}
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public WebRtcControls(boolean isLocalVideoEnabled,
boolean isRemoteVideoEnabled,
boolean isMoreThanOneCameraAvailable,

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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<String>, 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)
}
}

View File

@@ -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<out Activity> = if (RemoteConfig.newCallUi || SignalStore.internal.newCallingUi) {
CallActivity::class.java
} else {
WebRtcCallActivity::class.java
}
fun getActivityClass(): Class<out Activity> = WebRtcCallActivity::class.java
private fun getActionString(action: Action): String {
return "$CALL_INTENT_PREFIX.${action.code}"

View File

@@ -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<CallScreenState> = internalCallScreenState
private val internalDialog = MutableStateFlow(CallScreenDialogType.NONE)
val dialog: StateFlow<CallScreenDialogType> = internalDialog
private val internalCallActions = MutableSharedFlow<Action>()
val callActions: Flow<Action> = 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<IdentityRecord>) : Action
/**
* Immediately switch the user to speaker view
*/
data object SwitchToSpeaker : Action
}
}

View File

@@ -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()
}

View File

@@ -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<WebRtcControls> = LiveDataUtil.combineLatest(foldableState, webRtcControls, this::updateControlsFoldableState)
private val realWebRtcControls: LiveData<WebRtcControls> = LiveDataUtil.combineLatest(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls)
private val events = SingleLiveEvent<CallEvent>()
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<CallParticipantListUpdate>()
private val identityChangedRecipients = MutableLiveData<Collection<RecipientId>>(Collections.emptyList())
private val safetyNumberChangeEvent: LiveData<SafetyNumberChangeEvent> = LiveDataUtil.combineLatest(isInPipMode, identityChangedRecipients, ::SafetyNumberChangeEvent)
private val groupRecipient: LiveData<Recipient> = LiveDataUtil.filter(liveRecipient.switchMap(LiveRecipient::getLiveData), Recipient::isActiveGroup)
private val groupMembers: LiveData<List<GroupMemberEntry.FullMember>> = groupRecipient.switchMap { r -> LiveGroup(r.requireGroupId()).fullMembers }
private val groupMembersChanged: LiveData<List<GroupMemberEntry.FullMember>> = LiveDataUtil.skip(groupMembers, 1)
private val groupMembersCount: LiveData<Int> = groupMembers.map { it.size }
private val shouldShowSpeakerHint: Observable<Boolean> = participantsState.map(this::shouldShowSpeakerHint)
private val isLandscapeEnabled = MutableLiveData<Boolean>()
private val canEnterPipMode = MutableLiveData(false)
private val groupMemberStateUpdater = Observer<List<GroupMemberEntry.FullMember>> { m -> participantsState.onNext(CallParticipantsState.update(participantsState.value!!, m)) }
private val ephemeralState = MutableLiveData<WebRtcEphemeralState>()
private val recipientId = BehaviorProcessor.createDefault(RecipientId.UNKNOWN)
private val pendingParticipants = BehaviorSubject.create<PendingParticipantCollection>()
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<CallParticipant>()
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<Boolean> get() = internalMicrophoneEnabled.distinctUntilChanged()
fun getWebRtcControls(): LiveData<WebRtcControls> = realWebRtcControls
val recipient: LiveRecipient get() = liveRecipient.value!!
fun getRecipientFlowable(): Flowable<Recipient> {
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<CallEvent> {
return events
}
fun getInCallStatus(): Observable<InCallStatus> {
val elapsedTime: Observable<Long> = 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<PendingParticipantCollection.Entry> = 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<CallControlsState> {
val groupSize: Flowable<Int> = 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<CallParticipantsState> get() = participantsState
val callParticipantsStateSnapshot: CallParticipantsState? get() = participantsState.value
fun getCallParticipantListUpdate(): LiveData<CallParticipantListUpdate> {
return callParticipantListUpdate
}
fun getSafetyNumberChangeEvent(): LiveData<SafetyNumberChangeEvent> {
return safetyNumberChangeEvent
}
fun getGroupMembersChanged(): LiveData<List<GroupMemberEntry.FullMember>> {
return groupMembersChanged
}
fun getGroupMemberCount(): LiveData<Int> {
return groupMembersCount
}
fun shouldShowSpeakerHint(): Observable<Boolean> {
return shouldShowSpeakerHint.observeOn(AndroidSchedulers.mainThread())
}
fun getCurrentAudioOutput(): WebRtcAudioOutput {
return getWebRtcControls().value!!.audioOutput
}
fun getEphemeralState(): LiveData<WebRtcEphemeralState> {
return ephemeralState
}
fun canEnterPipMode(): LiveData<Boolean> {
return canEnterPipMode
}
fun isAnswerWithVideoAvailable(): Boolean {
return answerWithVideoAvailable
}
fun getPendingParticipants(): Observable<PendingParticipantsState> {
val isInPipMode: Observable<Boolean> = 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<SignalAudioManager.AudioDevice>,
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<CallParticipant>): Boolean {
return callParticipants.any { it.callParticipantId.demuxId == CallParticipantId.DEFAULT_ID }
}
class SafetyNumberChangeEvent(
val isInPipMode: Boolean,
val recipientIds: Collection<RecipientId>
)
}