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 + ) +}