From 26e79db05781d22cd6b5f8a4123870d413b65341 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 19 Aug 2024 15:32:11 -0300 Subject: [PATCH] Begin re-architecture of calling screen. --- app/src/main/AndroidManifest.xml | 14 +- .../securesms/WebRtcCallActivity.java | 130 ++---- .../links/EditCallLinkNameDialogFragment.kt | 2 - .../webrtc/CallParticipantsLayout.java | 14 +- .../webrtc/CallStateUpdatePopupWindow.kt | 40 +- .../webrtc/WebRtcCallViewModel.java | 124 +++--- .../components/webrtc/WebRtcControls.java | 4 +- .../controls/ControlsAndInfoController.kt | 60 +-- .../components/webrtc/v2/CallActivity.kt | 287 +++++++++++++ .../components/webrtc/v2/CallButton.kt | 282 +++++++++++++ .../components/webrtc/v2/CallControls.kt | 203 +++++++++ .../webrtc/v2/CallControlsChange.kt | 96 +++++ .../components/webrtc/v2/CallEvent.kt | 23 ++ .../components/webrtc/v2/CallInfoCallbacks.kt | 97 +++++ .../webrtc/v2/CallParticipantVideoRenderer.kt | 43 ++ .../webrtc/v2/CallParticipantsPager.kt | 67 +++ .../v2/CallPermissionsDialogController.kt | 112 +++++ .../components/webrtc/v2/CallScreen.kt | 389 ++++++++++++++++++ .../components/webrtc/v2/CallScreenState.kt | 31 ++ .../components/webrtc/v2/CallScreenTopBar.kt | 219 ++++++++++ .../components/webrtc/v2/CallString.kt | 40 ++ .../components/webrtc/v2/CallViewModel.kt | 314 ++++++++++++++ .../components/webrtc/v2/PictureInPicture.kt | 200 +++++++++ .../securesms/util/CommunicationActions.java | 9 +- .../securesms/util/RemoteConfig.kt | 8 + app/src/main/res/values/strings.xml | 12 + .../java/org/signal/core/ui/DarkPreview.kt | 16 + .../java/org/signal/core/ui/IconButtons.kt | 141 +++++++ .../androidx/compose/material3/IconButton.kt | 128 ++++++ 29 files changed, 2860 insertions(+), 245 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallActivity.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallButton.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsChange.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallEvent.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallInfoCallbacks.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantVideoRenderer.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallPermissionsDialogController.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenTopBar.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallString.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPicture.kt create mode 100644 core-ui/src/main/java/org/signal/core/ui/DarkPreview.kt create mode 100644 core-ui/src/main/java/org/signal/core/ui/IconButtons.kt create mode 100644 core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/material3/IconButton.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 640a273092..9b62bf9f65 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -137,7 +137,19 @@ android:launchMode="singleTask" android:exported="false" /> - + + viewModel.onDismissedVideoTooltip()) .show(TooltipPopup.POSITION_ABOVE); } - } else if (event instanceof WebRtcCallViewModel.Event.DismissVideoTooltip) { + } else if (event instanceof CallEvent.DismissVideoTooltip) { if (videoTooltip != null) { videoTooltip.dismiss(); videoTooltip = null; } - } else if (event instanceof WebRtcCallViewModel.Event.ShowWifiToCellularPopup) { + } else if (event instanceof CallEvent.ShowWifiToCellularPopup) { wifiToCellularPopupWindow.show(); - } else if (event instanceof WebRtcCallViewModel.Event.ShowSwitchCameraTooltip) { + } else if (event instanceof CallEvent.ShowSwitchCameraTooltip) { if (switchCameraTooltip == null) { switchCameraTooltip = TooltipPopup.forTarget(callScreen.getSwitchCameraTooltipTarget()) .setBackgroundTint(ContextCompat.getColor(this, R.color.core_ultramarine)) @@ -642,7 +642,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan .setOnDismissListener(() -> viewModel.onDismissedSwitchCameraTooltip()) .show(TooltipPopup.POSITION_ABOVE); } - } else if (event instanceof WebRtcCallViewModel.Event.DismissSwitchCameraTooltip) { + } else if (event instanceof CallEvent.DismissSwitchCameraTooltip) { if (switchCameraTooltip != null) { switchCameraTooltip.dismiss(); switchCameraTooltip = null; @@ -1029,74 +1029,30 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan } private void askCameraPermissions(@NonNull Runnable onGranted) { - if (!isAskingForPermission) { - isAskingForPermission = true; - Permissions.with(this) - .request(Manifest.permission.CAMERA) - .ifNecessary() - .withRationaleDialog(getString(R.string.WebRtcCallActivity__allow_access_camera), getString(R.string.WebRtcCallActivity__to_enable_video_allow_camera), false, R.drawable.symbol_video_24) - .onAnyResult(() -> isAskingForPermission = false) - .onAllGranted(() -> { - onGranted.run(); - findViewById(R.id.missing_permissions_container).setVisibility(View.GONE); - }) - .onAnyDenied(() -> Toast.makeText(this, R.string.WebRtcCallActivity__signal_needs_camera_access_enable_video, Toast.LENGTH_LONG).show()) - .onAnyPermanentlyDenied(() -> showPermissionFragment(R.string.WebRtcCallActivity__allow_access_camera, R.string.WebRtcCallActivity__to_enable_video, false).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)) - .execute(); - } + callPermissionsDialogController.requestCameraPermission( + this, + () -> { + onGranted.run(); + findViewById(R.id.missing_permissions_container).setVisibility(View.GONE); + } + ); } private void askAudioPermissions(@NonNull Runnable onGranted) { - if (!isAskingForPermission) { - isAskingForPermission = true; - Permissions.with(this) - .request(Manifest.permission.RECORD_AUDIO) - .ifNecessary() - .withRationaleDialog(getString(R.string.WebRtcCallActivity__allow_access_microphone), getString(R.string.WebRtcCallActivity__to_start_call_microphone), false, R.drawable.ic_mic_24) - .onAnyResult(() -> isAskingForPermission = false) - .onAllGranted(onGranted) - .onAnyDenied(() -> { - Toast.makeText(this, R.string.WebRtcCallActivity__signal_needs_microphone_start_call, Toast.LENGTH_LONG).show(); - handleDenyCall(); - }) - .onAnyPermanentlyDenied(() -> showPermissionFragment(R.string.WebRtcCallActivity__allow_access_microphone, R.string.WebRtcCallActivity__to_start_call, false).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)) - .execute(); - } + callPermissionsDialogController.requestAudioPermission( + this, + onGranted, + this::handleDenyCall + ); } public void askCameraAudioPermissions(@NonNull Runnable onGranted) { - if (!isAskingForPermission) { - isAskingForPermission = true; - Permissions.with(this) - .request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA) - .ifNecessary() - .withRationaleDialog(getString(R.string.WebRtcCallActivity__allow_access_camera_microphone), getString(R.string.WebRtcCallActivity__to_start_call_camera_microphone), false, R.drawable.ic_mic_24, R.drawable.symbol_video_24) - .onAnyResult(() -> isAskingForPermission = false) - .onSomePermanentlyDenied(deniedPermissions -> { - if (deniedPermissions.containsAll(List.of(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))) { - showPermissionFragment(R.string.WebRtcCallActivity__allow_access_camera_microphone, R.string.WebRtcCallActivity__to_start_call, false).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); - } else if (deniedPermissions.contains(Manifest.permission.CAMERA)) { - showPermissionFragment(R.string.WebRtcCallActivity__allow_access_camera, R.string.WebRtcCallActivity__to_enable_video, false).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); - } else { - showPermissionFragment(R.string.WebRtcCallActivity__allow_access_microphone, R.string.WebRtcCallActivity__to_start_call, false).show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); - } - }) - .onAllGranted(onGranted) - .onSomeGranted(permissions -> { - if (permissions.contains(Manifest.permission.CAMERA)) { - findViewById(R.id.missing_permissions_container).setVisibility(View.GONE); - } - }) - .onSomeDenied(deniedPermissions -> { - if (deniedPermissions.contains(Manifest.permission.RECORD_AUDIO)) { - Toast.makeText(this, R.string.WebRtcCallActivity__signal_needs_microphone_start_call, Toast.LENGTH_LONG).show(); - handleDenyCall(); - } else { - Toast.makeText(this, R.string.WebRtcCallActivity__signal_needs_camera_access_enable_video, Toast.LENGTH_LONG).show(); - } - }) - .execute(); - } + callPermissionsDialogController.requestCameraAndAudioPermission( + this, + onGranted, + () -> findViewById(R.id.missing_permissions_container).setVisibility(View.GONE), + this::handleDenyCall + ); } private void startCall(boolean isVideoCall) { @@ -1181,8 +1137,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan @Override public void onMicChanged(boolean isMicEnabled) { Runnable onGranted = () -> { - callStateUpdatePopupWindow.onCallStateUpdate(isMicEnabled ? CallStateUpdatePopupWindow.CallStateUpdate.MIC_ON - : CallStateUpdatePopupWindow.CallStateUpdate.MIC_OFF); + callStateUpdatePopupWindow.onCallStateUpdate(isMicEnabled ? CallControlsChange.MIC_ON + : CallControlsChange.MIC_OFF); handleSetMuteAudio(!isMicEnabled); }; askAudioPermissions(onGranted); @@ -1237,11 +1193,11 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan public void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed) { if (ringingAllowed) { AppDependencies.getSignalCallManager().setRingGroup(ringGroup); - callStateUpdatePopupWindow.onCallStateUpdate(ringGroup ? CallStateUpdatePopupWindow.CallStateUpdate.RINGING_ON - : CallStateUpdatePopupWindow.CallStateUpdate.RINGING_OFF); + callStateUpdatePopupWindow.onCallStateUpdate(ringGroup ? CallControlsChange.RINGING_ON + : CallControlsChange.RINGING_OFF); } else { AppDependencies.getSignalCallManager().setRingGroup(false); - callStateUpdatePopupWindow.onCallStateUpdate(CallStateUpdatePopupWindow.CallStateUpdate.RINGING_DISABLED); + callStateUpdatePopupWindow.onCallStateUpdate(CallControlsChange.RINGING_DISABLED); } } @@ -1259,9 +1215,9 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan private void maybeDisplaySpeakerphonePopup(WebRtcAudioOutput nextOutput) { final WebRtcAudioOutput currentOutput = viewModel.getCurrentAudioOutput(); if (currentOutput == WebRtcAudioOutput.SPEAKER && nextOutput != WebRtcAudioOutput.SPEAKER) { - callStateUpdatePopupWindow.onCallStateUpdate(CallStateUpdatePopupWindow.CallStateUpdate.SPEAKER_OFF); + callStateUpdatePopupWindow.onCallStateUpdate(CallControlsChange.SPEAKER_OFF); } else if (currentOutput != WebRtcAudioOutput.SPEAKER && nextOutput == WebRtcAudioOutput.SPEAKER) { - callStateUpdatePopupWindow.onCallStateUpdate(CallStateUpdatePopupWindow.CallStateUpdate.SPEAKER_ON); + callStateUpdatePopupWindow.onCallStateUpdate(CallControlsChange.SPEAKER_ON); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt index 5c6fae32f7..8ce72c3226 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -61,7 +60,6 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() { return dialog } - @OptIn(ExperimentalMaterial3Api::class) @Preview @Composable override fun DialogContent() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsLayout.java index ff8086d5d3..de99eeb57a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsLayout.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsLayout.java @@ -50,13 +50,13 @@ public class CallParticipantsLayout extends FlexboxLayout { super(context, attrs, defStyleAttr); } - void update(@NonNull List callParticipants, - @NonNull CallParticipant focusedParticipant, - boolean shouldRenderInPip, - boolean isPortrait, - boolean hideAvatar, - int navBarBottomInset, - @NonNull LayoutStrategy layoutStrategy) + public void update(@NonNull List callParticipants, + @NonNull CallParticipant focusedParticipant, + boolean shouldRenderInPip, + boolean isPortrait, + boolean hideAvatar, + int navBarBottomInset, + @NonNull LayoutStrategy layoutStrategy) { this.callParticipants = callParticipants; this.focusedParticipant = focusedParticipant; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallStateUpdatePopupWindow.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallStateUpdatePopupWindow.kt index 84ff088bb8..8c1ae3cce9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallStateUpdatePopupWindow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallStateUpdatePopupWindow.kt @@ -7,11 +7,10 @@ import android.view.ViewGroup import android.widget.ImageView import android.widget.PopupWindow import android.widget.TextView -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes import androidx.core.view.ViewCompat import org.signal.core.util.dp import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.webrtc.v2.CallControlsChange import org.thoughtcrime.securesms.util.Debouncer import org.thoughtcrime.securesms.util.visible import java.util.concurrent.TimeUnit @@ -26,8 +25,8 @@ class CallStateUpdatePopupWindow(private val parent: ViewGroup) : PopupWindow( ) { private var enabled: Boolean = true - private var pendingUpdate: CallStateUpdate? = null - private var lastUpdate: CallStateUpdate? = null + private var pendingUpdate: CallControlsChange? = null + private var lastUpdate: CallControlsChange? = null private val dismissDebouncer = Debouncer(2, TimeUnit.SECONDS) private val iconView = contentView.findViewById(R.id.icon) private val descriptionView = contentView.findViewById(R.id.description) @@ -51,30 +50,30 @@ class CallStateUpdatePopupWindow(private val parent: ViewGroup) : PopupWindow( } } - fun onCallStateUpdate(callStateUpdate: CallStateUpdate) { - if (isShowing && lastUpdate == callStateUpdate) { + fun onCallStateUpdate(callControlsChange: CallControlsChange) { + if (isShowing && lastUpdate == callControlsChange) { dismissDebouncer.publish { dismiss() } } else if (isShowing) { dismissDebouncer.clear() - pendingUpdate = callStateUpdate + pendingUpdate = callControlsChange dismiss() } else { pendingUpdate = null - lastUpdate = callStateUpdate - presentCallState(callStateUpdate) + lastUpdate = callControlsChange + presentCallState(callControlsChange) show() } } - private fun presentCallState(callStateUpdate: CallStateUpdate) { - if (callStateUpdate.iconRes == null) { + private fun presentCallState(callControlsChange: CallControlsChange) { + if (callControlsChange.iconRes == null) { iconView.setImageDrawable(null) } else { - iconView.setImageResource(callStateUpdate.iconRes) + iconView.setImageResource(callControlsChange.iconRes) } - iconView.visible = callStateUpdate.iconRes != null - descriptionView.setText(callStateUpdate.stringRes) + iconView.visible = callControlsChange.iconRes != null + descriptionView.setText(callControlsChange.stringRes) } private fun show() { @@ -105,17 +104,4 @@ class CallStateUpdatePopupWindow(private val parent: ViewGroup) : PopupWindow( View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) ) } - - enum class CallStateUpdate( - @DrawableRes val iconRes: Int?, - @StringRes val stringRes: Int - ) { - RINGING_ON(R.drawable.symbol_bell_ring_compact_16, R.string.CallStateUpdatePopupWindow__ringing_on), - RINGING_OFF(R.drawable.symbol_bell_slash_compact_16, R.string.CallStateUpdatePopupWindow__ringing_off), - RINGING_DISABLED(null, R.string.CallStateUpdatePopupWindow__group_is_too_large), - MIC_ON(R.drawable.symbol_mic_compact_16, R.string.CallStateUpdatePopupWindow__mic_on), - MIC_OFF(R.drawable.symbol_mic_slash_compact_16, R.string.CallStateUpdatePopupWindow__mic_off), - SPEAKER_ON(R.drawable.symbol_speaker_24, R.string.CallStateUpdatePopupWindow__speaker_on), - SPEAKER_OFF(R.drawable.symbol_speaker_slash_24, R.string.CallStateUpdatePopupWindow__speaker_off) - } } 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 index 320725ae12..c9625a67a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java @@ -6,7 +6,9 @@ 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; @@ -15,7 +17,10 @@ import androidx.lifecycle.ViewModel; import com.annimon.stream.Stream; import org.signal.core.util.ThreadUtil; -import org.thoughtcrime.securesms.components.sensors.Orientation; +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; @@ -41,7 +46,10 @@ 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 { @@ -52,7 +60,7 @@ public class WebRtcCallViewModel extends ViewModel { 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 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); @@ -67,6 +75,7 @@ public class WebRtcCallViewModel extends ViewModel { private final MutableLiveData isLandscapeEnabled = new MutableLiveData<>(); 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(); @@ -106,6 +115,7 @@ public class WebRtcCallViewModel extends ViewModel { } public void setRecipient(@NonNull Recipient recipient) { + recipientId.onNext(recipient.getId()); liveRecipient.setValue(recipient.live()); } @@ -115,7 +125,7 @@ public class WebRtcCallViewModel extends ViewModel { ThreadUtil.runOnMain(() -> participantsState.onNext(CallParticipantsState.update(participantsState.getValue(), foldableState))); } - public LiveData getEvents() { + public LiveData getEvents() { return events; } @@ -142,6 +152,26 @@ public class WebRtcCallViewModel extends ViewModel { ).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; } @@ -222,7 +252,7 @@ public class WebRtcCallViewModel extends ViewModel { page == CallParticipantsState.SelectedPage.GRID) { showScreenShareTip = false; - events.setValue(new Event.ShowSwipeToSpeakerHint()); + events.setValue(CallEvent.ShowSwipeToSpeakerHint.INSTANCE); } participantsState.onNext(CallParticipantsState.update(participantsState.getValue(), page)); @@ -261,7 +291,7 @@ public class WebRtcCallViewModel extends ViewModel { participantsState.onNext(newState); if (switchOnFirstScreenShare && !wasScreenSharing && newState.getFocusedParticipant().isScreenSharing()) { switchOnFirstScreenShare = false; - events.setValue(new Event.SwitchToSpeaker()); + events.setValue(CallEvent.SwitchToSpeaker.INSTANCE); } if (webRtcViewModel.getGroupState().isConnected()) { @@ -307,21 +337,38 @@ public class WebRtcCallViewModel extends ViewModel { } } + /* + 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(new Event.DismissVideoTooltip()); + 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(new Event.ShowVideoTooltip()); + events.setValue(CallEvent.ShowVideoTooltip.INSTANCE); } if (canDisplayPopupIfNeeded && webRtcViewModel.isCellularConnection() && NetworkUtil.isConnectedWifi(AppDependencies.getApplication())) { canDisplayPopupIfNeeded = false; - events.setValue(new Event.ShowWifiToCellularPopup()); + events.setValue(CallEvent.ShowWifiToCellularPopup.INSTANCE); } else if (!webRtcViewModel.isCellularConnection()) { canDisplayPopupIfNeeded = true; } @@ -331,9 +378,10 @@ public class WebRtcCallViewModel extends ViewModel { localParticipant.getCameraState().isEnabled() && webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED && !newState.getAllRemoteParticipants().isEmpty() - ) { + ) + { canDisplaySwitchCameraTooltipIfNeeded = false; - events.setValue(new Event.ShowSwitchCameraTooltip()); + events.setValue(CallEvent.ShowSwitchCameraTooltip.INSTANCE); } } @@ -496,63 +544,13 @@ public class WebRtcCallViewModel extends ViewModel { if (identityRecords.isUntrusted(false) || identityRecords.isUnverified(false)) { List records = identityRecords.getUnverifiedRecords(); records.addAll(identityRecords.getUntrustedRecords()); - events.postValue(new Event.ShowGroupCallSafetyNumberChange(records)); + events.postValue(new CallEvent.ShowGroupCallSafetyNumberChange(records)); } else { - events.postValue(new Event.StartCall(isVideoCall)); + events.postValue(new CallEvent.StartCall(isVideoCall)); } }); } else { - events.postValue(new Event.StartCall(isVideoCall)); - } - } - - public static abstract class Event { - private Event() { - } - - public static class ShowVideoTooltip extends Event { - } - - public static class DismissVideoTooltip extends Event { - } - - public static class ShowWifiToCellularPopup extends Event { - } - - public static class ShowSwitchCameraTooltip extends Event { - } - - public static class DismissSwitchCameraTooltip extends Event { - } - - public static class StartCall extends Event { - private final boolean isVideoCall; - - public StartCall(boolean isVideoCall) { - this.isVideoCall = isVideoCall; - } - - public boolean isVideoCall() { - return isVideoCall; - } - } - - public static class ShowGroupCallSafetyNumberChange extends Event { - private final List identityRecords; - - public ShowGroupCallSafetyNumberChange(@NonNull List identityRecords) { - this.identityRecords = identityRecords; - } - - public @NonNull List getIdentityRecords() { - return identityRecords; - } - } - - public static class SwitchToSpeaker extends Event { - } - - public static class ShowSwipeToSpeakerHint extends Event { + events.postValue(new CallEvent.StartCall(isVideoCall)); } } 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 f03c73b47f..d703dcac3a 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 @@ -6,6 +6,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Px; import androidx.annotation.StringRes; +import androidx.annotation.VisibleForTesting; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; @@ -61,7 +62,8 @@ public final class WebRtcControls { false); } - WebRtcControls(boolean isLocalVideoEnabled, + @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) + public WebRtcControls(boolean isLocalVideoEnabled, boolean isRemoteVideoEnabled, boolean isMoreThanOneCameraAvailable, boolean isInPipMode, 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 a41b31bd34..f8910ef955 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 @@ -5,8 +5,6 @@ package org.thoughtcrime.securesms.components.webrtc.controls -import android.content.ActivityNotFoundException -import android.content.Intent import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Color @@ -27,7 +25,6 @@ import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.constraintlayout.widget.Guideline import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.app.ShareCompat import androidx.core.content.ContextCompat import androidx.transition.AutoTransition import androidx.transition.TransitionManager @@ -35,7 +32,6 @@ import androidx.transition.TransitionSet import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback import com.google.android.material.bottomsheet.BottomSheetBehaviorHack -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.progressindicator.CircularProgressIndicatorSpec import com.google.android.material.progressindicator.IndeterminateDrawable import com.google.android.material.shape.CornerFamily @@ -51,16 +47,13 @@ import org.signal.core.util.dp import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.WebRtcCallActivity -import org.thoughtcrime.securesms.calls.links.CallLinks import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment -import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragmentArgs 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.dependencies.AppDependencies -import org.thoughtcrime.securesms.events.CallParticipant +import org.thoughtcrime.securesms.components.webrtc.v2.CallInfoCallbacks import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult import org.thoughtcrime.securesms.util.padding import org.thoughtcrime.securesms.util.visible @@ -77,7 +70,7 @@ class ControlsAndInfoController private constructor( private val viewModel: WebRtcCallViewModel, private val controlsAndInfoViewModel: ControlsAndInfoViewModel, private val disposables: CompositeDisposable -) : CallInfoView.Callbacks, Disposable by disposables { +) : Disposable by disposables { constructor( webRtcCallActivity: WebRtcCallActivity, @@ -139,6 +132,8 @@ class ControlsAndInfoController private constructor( private var previousCallControlHeightData = HeightData() private var controlState: WebRtcControls = WebRtcControls.NONE + private val callInfoCallbacks = CallInfoCallbacks(webRtcCallActivity, controlsAndInfoViewModel, disposables) + init { raiseHandComposeView.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) @@ -229,7 +224,7 @@ class ControlsAndInfoController private constructor( setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { val nestedScrollInterop = rememberNestedScrollInteropConnection() - CallInfoView.View(viewModel, controlsAndInfoViewModel, this@ControlsAndInfoController, Modifier.nestedScroll(nestedScrollInterop)) + CallInfoView.View(viewModel, controlsAndInfoViewModel, callInfoCallbacks, Modifier.nestedScroll(nestedScrollInterop)) } } @@ -404,51 +399,6 @@ class ControlsAndInfoController private constructor( handler?.removeCallbacks(scheduleHideControlsRunnable) } - override fun onShareLinkClicked() { - val mimeType = Intent.normalizeMimeType("text/plain") - val shareIntent = ShareCompat.IntentBuilder(webRtcCallActivity) - .setText(CallLinks.url(controlsAndInfoViewModel.rootKeySnapshot)) - .setType(mimeType) - .createChooserIntent() - - try { - webRtcCallActivity.startActivity(shareIntent) - } catch (e: ActivityNotFoundException) { - Toast.makeText(webRtcCallActivity, R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show() - } - } - - override fun onEditNameClicked(name: String) { - EditCallLinkNameDialogFragment().apply { - arguments = EditCallLinkNameDialogFragmentArgs.Builder(name).build().toBundle() - }.show(webRtcCallActivity.supportFragmentManager, null) - } - - override fun onToggleAdminApprovalClicked(checked: Boolean) { - controlsAndInfoViewModel.setApproveAllMembers(checked) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy(onSuccess = { - if (it !is UpdateCallLinkResult.Update) { - Log.w(TAG, "Failed to change restrictions. $it") - toastFailure() - } - }, onError = handleError("onApproveAllMembersChanged")) - .addTo(disposables) - } - - override fun onBlock(callParticipant: CallParticipant) { - MaterialAlertDialogBuilder(webRtcCallActivity) - .setNegativeButton(android.R.string.cancel, null) - .setMessage(webRtcCallView.resources.getString(R.string.CallLinkInfoSheet__remove_s_from_the_call, callParticipant.recipient.getShortDisplayName(webRtcCallActivity))) - .setPositiveButton(R.string.CallLinkInfoSheet__remove) { _, _ -> - AppDependencies.signalCallManager.removeFromCallLink(callParticipant) - } - .setNeutralButton(R.string.CallLinkInfoSheet__block_from_call) { _, _ -> - AppDependencies.signalCallManager.blockFromCallLink(callParticipant) - } - .show() - } - private fun setName(name: String) { controlsAndInfoViewModel.setName(name) .observeOn(AndroidSchedulers.mainThread()) 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 new file mode 100644 index 0000000000..9c5c9109a3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallActivity.kt @@ -0,0 +1,287 @@ +/* + * 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.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.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rxjava3.subscribeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.lifecycleScope +import io.reactivex.rxjava3.disposables.CompositeDisposable +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.WebRtcCallViewModel +import org.thoughtcrime.securesms.components.webrtc.controls.CallInfoView +import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel +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.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) + + lifecycleDisposable.bindTo(this) + val compositeDisposable = CompositeDisposable() + lifecycleDisposable.add(compositeDisposable) + + val callInfoCallbacks = CallInfoCallbacks(this, controlsAndInfoViewModel, compositeDisposable) + + observeCallEvents() + + 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.collectAsState() + 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() + } + } + + SignalTheme { + Surface { + CallScreen( + callRecipient = recipient ?: Recipient.UNKNOWN, + webRtcCallState = callParticipantsState.callState, + callScreenState = callScreenState, + callControlsState = callControlsState, + callControlsCallback = this, + callParticipantsPagerState = CallParticipantsPagerState( + callParticipants = callParticipantsState.gridParticipants, + focusedParticipant = callParticipantsState.focusedParticipant, + isRenderInPip = callParticipantsState.isInPipMode, + hideAvatar = callParticipantsState.hideAvatar + ), + localParticipant = callParticipantsState.localParticipant, + localRenderState = callParticipantsState.localRenderState, + callInfoView = { + CallInfoView.View( + webRtcCallViewModel = webRtcCallViewModel, + controlsAndInfoViewModel = controlsAndInfoViewModel, + callbacks = callInfoCallbacks, + modifier = Modifier + .alpha(it) + ) + }, + onNavigationClick = { finish() }, + onLocalPictureInPictureClicked = webRtcCallViewModel::onLocalPictureInPictureClicked + ) + } + } + } + } + + 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.") + } + } + } + + /* + TODO + if (enterPipOnResume) { + enterPipOnResume = false; + 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 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() + } + + 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/CallButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallButton.kt new file mode 100644 index 0000000000..e29d690132 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallButton.kt @@ -0,0 +1,282 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.Buttons +import org.signal.core.ui.DarkPreview +import org.signal.core.ui.IconButtons +import org.signal.core.ui.Previews +import org.thoughtcrime.securesms.R + +@Composable +private fun ToggleCallButton( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + painter: Painter, + contentDescription: String?, + modifier: Modifier = Modifier, + checkedPainter: Painter = painter +) { + val buttonSize = dimensionResource(id = R.dimen.webrtc_button_size) + IconButtons.IconToggleButton( + checked = checked, + onCheckedChange = onCheckedChange, + size = buttonSize, + modifier = modifier.size(buttonSize), + colors = IconButtons.iconToggleButtonColors( + checkedContainerColor = MaterialTheme.colorScheme.secondaryContainer, + checkedContentColor = colorResource(id = R.color.signal_light_colorOnPrimary), + containerColor = colorResource(id = R.color.signal_light_colorSecondaryContainer), + contentColor = colorResource(id = R.color.signal_light_colorOnSecondaryContainer) + ) + ) { + Icon( + painter = if (checked) checkedPainter else painter, + contentDescription = contentDescription, + modifier = Modifier.size(28.dp) + ) + } +} + +@Composable +private fun CallButton( + onClick: () -> Unit, + painter: Painter, + contentDescription: String?, + modifier: Modifier = Modifier, + containerColor: Color = MaterialTheme.colorScheme.secondaryContainer, + contentColor: Color = colorResource(id = R.color.signal_light_colorOnPrimary) +) { + val buttonSize = dimensionResource(id = R.dimen.webrtc_button_size) + IconButtons.IconButton( + onClick = onClick, + size = buttonSize, + modifier = modifier.size(buttonSize), + colors = IconButtons.iconButtonColors( + containerColor = containerColor, + contentColor = contentColor + ) + ) { + Icon( + painter = painter, + contentDescription = contentDescription, + modifier = Modifier.size(28.dp) + ) + } +} + +@Composable +fun ToggleVideoButton( + isVideoEnabled: Boolean, + onChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + ToggleCallButton( + checked = isVideoEnabled, + onCheckedChange = onChange, + painter = painterResource(id = R.drawable.symbol_video_slash_fill_24), + checkedPainter = painterResource(id = R.drawable.symbol_video_fill_24), + contentDescription = stringResource(id = R.string.WebRtcCallView__toggle_camera), + modifier = modifier + ) +} + +@Composable +fun ToggleMicButton( + isMicEnabled: Boolean, + onChange: (Boolean) -> Unit, + modifier: Modifier = Modifier +) { + ToggleCallButton( + checked = isMicEnabled, + onCheckedChange = onChange, + painter = painterResource(id = R.drawable.symbol_mic_slash_fill_24), + checkedPainter = painterResource(id = R.drawable.symbol_mic_fill_white_24), + contentDescription = stringResource(id = R.string.WebRtcCallView__toggle_mute), + modifier = modifier + ) +} + +@Composable +fun ToggleRingButton( + isRingEnabled: Boolean, + isRingAllowed: Boolean, + onChange: (Boolean, Boolean) -> Unit, + modifier: Modifier = Modifier +) { + ToggleCallButton( + checked = isRingEnabled, + onCheckedChange = { onChange(it, isRingAllowed) }, + painter = painterResource(id = R.drawable.symbol_bell_slash_fill_24), + checkedPainter = painterResource(id = R.drawable.symbol_bell_ring_fill_white_24), + contentDescription = stringResource(id = R.string.WebRtcCallView__toggle_group_ringing), + modifier = modifier + ) +} + +@Composable +fun AdditionalActionsButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + CallButton( + onClick = onClick, + painter = painterResource(id = R.drawable.symbol_more_white_24), + contentDescription = stringResource(id = R.string.WebRtcCallView__additional_actions), + modifier = modifier + ) +} + +@Composable +fun HangupButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + CallButton( + onClick = onClick, + painter = painterResource(id = R.drawable.symbol_phone_down_fill_24), + contentDescription = stringResource(id = R.string.WebRtcCallView__end_call), + containerColor = colorResource(id = R.color.webrtc_hangup_background), + modifier = modifier + ) +} + +@Composable +fun StartCallButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Buttons.LargePrimary( + onClick = onClick, + modifier = modifier.height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = colorResource(id = R.color.signal_light_colorPrimary), + contentColor = colorResource(id = R.color.signal_light_colorOnPrimary) + ), + contentPadding = PaddingValues(horizontal = 48.dp, vertical = 18.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge + ) + } +} + +@DarkPreview +@Composable +private fun ToggleMicButtonPreview() { + Previews.Preview { + Row { + ToggleMicButton( + isMicEnabled = true, + onChange = {} + ) + + ToggleMicButton( + isMicEnabled = false, + onChange = {} + ) + } + } +} + +@DarkPreview +@Composable +private fun ToggleVideoButtonPreview() { + Previews.Preview { + Row { + ToggleVideoButton( + isVideoEnabled = true, + onChange = {} + ) + + ToggleVideoButton( + isVideoEnabled = false, + onChange = {} + ) + } + } +} + +@DarkPreview +@Composable +private fun ToggleRingButtonPreview() { + Previews.Preview { + Row { + ToggleRingButton( + isRingEnabled = true, + isRingAllowed = true, + onChange = { _, _ -> } + ) + + ToggleRingButton( + isRingEnabled = false, + isRingAllowed = true, + onChange = { _, _ -> } + ) + } + } +} + +@DarkPreview +@Composable +private fun AdditionalActionsButtonPreview() { + Previews.Preview { + AdditionalActionsButton( + onClick = {} + ) + } +} + +@DarkPreview +@Composable +private fun HangupButtonPreview() { + Previews.Preview { + HangupButton( + onClick = {} + ) + } +} + +@DarkPreview +@Composable +private fun StartCallButtonPreview() { + Previews.Preview { + StartCallButton( + stringResource(id = R.string.WebRtcCallView__start_call), + onClick = {} + ) + } +} + +@DarkPreview +@Composable +private fun JoinCallButtonPreview() { + Previews.Preview { + StartCallButton( + stringResource(id = R.string.WebRtcCallView__join_call), + onClick = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt new file mode 100644 index 0000000000..894fdc13dc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControls.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import android.Manifest +import android.content.pm.PackageManager +import android.content.res.Configuration +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import org.signal.core.ui.DarkPreview +import org.signal.core.ui.Previews +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState +import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput +import org.thoughtcrime.securesms.components.webrtc.WebRtcControls +import org.thoughtcrime.securesms.events.WebRtcViewModel +import org.thoughtcrime.securesms.util.RemoteConfig + +/** + * Renders the button strip / start call button in the call screen + * bottom sheet. + */ +@Composable +fun CallControls( + callControlsState: CallControlsState, + callControlsCallback: CallControlsCallback, + modifier: Modifier = Modifier +) { + val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = spacedBy(30.dp), + modifier = modifier.navigationBarsPadding() + ) { + Row( + horizontalArrangement = spacedBy(20.dp) + ) { + // TODO [alex] -- Audio output toggle + + val hasCameraPermission = ContextCompat.checkSelfPermission(LocalContext.current, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + if (callControlsState.displayVideoToggle) { + ToggleVideoButton( + isVideoEnabled = callControlsState.isVideoEnabled && hasCameraPermission, + onChange = callControlsCallback::onVideoToggleClick + ) + } + + val hasRecordAudioPermission = ContextCompat.checkSelfPermission(LocalContext.current, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + if (callControlsState.displayMicToggle) { + ToggleMicButton( + isMicEnabled = callControlsState.isMicEnabled && hasRecordAudioPermission, + onChange = callControlsCallback::onMicToggleClick + ) + } + + if (callControlsState.displayGroupRingingToggle) { + ToggleRingButton( + isRingEnabled = callControlsState.isGroupRingingEnabled, + isRingAllowed = callControlsState.isGroupRingingAllowed, + onChange = callControlsCallback::onGroupRingingToggleClick + ) + } + + if (callControlsState.displayAdditionalActions) { + AdditionalActionsButton(onClick = callControlsCallback::onAdditionalActionsClick) + } + + if (callControlsState.displayEndCallButton) { + HangupButton(onClick = callControlsCallback::onEndCallClick) + } + + if (callControlsState.displayStartCallButton && !isPortrait) { + StartCallButton( + text = stringResource(callControlsState.startCallButtonText), + onClick = { callControlsCallback.onStartCallClick(callControlsState.isVideoEnabled) } + ) + } + } + + if (callControlsState.displayStartCallButton && isPortrait) { + StartCallButton( + text = stringResource(callControlsState.startCallButtonText), + onClick = { callControlsCallback.onStartCallClick(callControlsState.isVideoEnabled) } + ) + } + } +} + +@DarkPreview +@Composable +fun CallControlsPreview() { + Previews.Preview { + CallControls( + callControlsState = CallControlsState( + displayAudioOutputToggle = true, + audioOutput = WebRtcAudioOutput.WIRED_HEADSET, + displayMicToggle = true, + isMicEnabled = true, + displayVideoToggle = true, + isVideoEnabled = true, + displayGroupRingingToggle = true, + isGroupRingingEnabled = true, + displayAdditionalActions = true, + displayStartCallButton = true, + startCallButtonText = R.string.WebRtcCallView__start_call, + displayEndCallButton = true + ), + callControlsCallback = CallControlsCallback.Empty + ) + } +} + +/** + * Callbacks for call controls actions. + */ +interface CallControlsCallback { + fun onVideoToggleClick(enabled: Boolean) + fun onMicToggleClick(enabled: Boolean) + fun onGroupRingingToggleClick(enabled: Boolean, allowed: Boolean) + fun onAdditionalActionsClick() + fun onStartCallClick(isVideoCall: Boolean) + fun onEndCallClick() + + object Empty : CallControlsCallback { + override fun onVideoToggleClick(enabled: Boolean) = Unit + override fun onMicToggleClick(enabled: Boolean) = Unit + override fun onGroupRingingToggleClick(enabled: Boolean, allowed: Boolean) = Unit + override fun onAdditionalActionsClick() = Unit + override fun onStartCallClick(isVideoCall: Boolean) = Unit + override fun onEndCallClick() = Unit + } +} + +/** + * State object representing how the controls should appear. Since these values are + * gleaned from multiple data sources, this object represents the amalgamation of those + * sources so we don't need to listen to multiple here. + */ +data class CallControlsState( + val skipHiddenState: Boolean = true, + val displayAudioOutputToggle: Boolean = false, + val audioOutput: WebRtcAudioOutput = WebRtcAudioOutput.HANDSET, + val displayVideoToggle: Boolean = false, + val isVideoEnabled: Boolean = false, + val displayMicToggle: Boolean = false, + val isMicEnabled: Boolean = false, + val displayGroupRingingToggle: Boolean = false, + val isGroupRingingEnabled: Boolean = false, + val isGroupRingingAllowed: Boolean = false, + val displayAdditionalActions: Boolean = false, + val displayStartCallButton: Boolean = false, + val startCallButtonText: Int = R.string.WebRtcCallView__start_call, + val displayEndCallButton: Boolean = false +) { + companion object { + /** + * Presentation-level method to build out the controls state from legacy objects. + */ + @JvmStatic + fun fromViewModelData( + callParticipantsState: CallParticipantsState, + webRtcControls: WebRtcControls, + groupMemberCount: Int + ): CallControlsState { + val isGroupRingingEnabled = if (callParticipantsState.callState == WebRtcViewModel.State.CALL_PRE_JOIN) { + callParticipantsState.groupCallState.isNotIdle + } else { + callParticipantsState.ringGroup + } + + return CallControlsState( + skipHiddenState = !(webRtcControls.isFadeOutEnabled || webRtcControls == WebRtcControls.PIP || webRtcControls.displayErrorControls()), + displayAudioOutputToggle = webRtcControls.displayAudioToggle(), + audioOutput = webRtcControls.audioOutput, + displayVideoToggle = webRtcControls.displayVideoToggle(), + isVideoEnabled = callParticipantsState.localParticipant.isVideoEnabled, + displayMicToggle = webRtcControls.displayMuteAudio(), + isMicEnabled = callParticipantsState.localParticipant.isMicrophoneEnabled, + displayGroupRingingToggle = webRtcControls.displayRingToggle(), + isGroupRingingEnabled = isGroupRingingEnabled, + isGroupRingingAllowed = groupMemberCount <= RemoteConfig.maxGroupCallRingSize, + displayAdditionalActions = webRtcControls.displayOverflow(), + displayStartCallButton = webRtcControls.displayStartCallControls(), + startCallButtonText = webRtcControls.startCallButtonText, + displayEndCallButton = webRtcControls.displayEndCall() + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsChange.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsChange.kt new file mode 100644 index 0000000000..d488b79342 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallControlsChange.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.DarkPreview +import org.signal.core.ui.Previews +import org.thoughtcrime.securesms.R + +/** + * Enumeration of the different call states we can display in the CallStateUpdate component. + * Shared between V1 and V2 code. + */ +enum class CallControlsChange( + @DrawableRes val iconRes: Int?, + @StringRes val stringRes: Int +) { + RINGING_ON(R.drawable.symbol_bell_ring_compact_16, R.string.CallStateUpdatePopupWindow__ringing_on), + RINGING_OFF(R.drawable.symbol_bell_slash_compact_16, R.string.CallStateUpdatePopupWindow__ringing_off), + RINGING_DISABLED(null, R.string.CallStateUpdatePopupWindow__group_is_too_large), + MIC_ON(R.drawable.symbol_mic_compact_16, R.string.CallStateUpdatePopupWindow__mic_on), + MIC_OFF(R.drawable.symbol_mic_slash_compact_16, R.string.CallStateUpdatePopupWindow__mic_off), + SPEAKER_ON(R.drawable.symbol_speaker_24, R.string.CallStateUpdatePopupWindow__speaker_on), + SPEAKER_OFF(R.drawable.symbol_speaker_slash_24, R.string.CallStateUpdatePopupWindow__speaker_off) +} + +/** + * Small pop-over above controls that is displayed as different controls are toggled. + */ +@Composable +fun CallStateUpdatePopup( + callControlsChange: CallControlsChange, + modifier: Modifier = Modifier +) { + Row( + horizontalArrangement = spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .background( + color = colorResource(id = R.color.signal_light_colorSecondaryContainer), + shape = RoundedCornerShape(50) + ) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + if (callControlsChange.iconRes != null) { + Icon( + painter = painterResource(id = callControlsChange.iconRes), + contentDescription = null, + tint = colorResource(id = R.color.signal_light_colorOnSecondaryContainer), + modifier = Modifier.size(16.dp) + ) + } + + Text( + text = stringResource(id = callControlsChange.stringRes), + style = MaterialTheme.typography.bodyMedium, + color = colorResource(id = R.color.signal_light_colorOnSecondaryContainer) + ) + } +} + +@DarkPreview +@Composable +private fun CallStateUpdatePopupPreview() { + Previews.Preview { + Column( + verticalArrangement = spacedBy(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CallControlsChange.entries.forEach { + CallStateUpdatePopup(callControlsChange = it) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallEvent.kt new file mode 100644 index 0000000000..1dd1d43f96 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallEvent.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import org.thoughtcrime.securesms.database.model.IdentityRecord + +/** + * Replacement sealed class for WebRtcCallViewModel.Event + */ +sealed interface CallEvent { + data object ShowVideoTooltip : CallEvent + data object DismissVideoTooltip : CallEvent + data object ShowWifiToCellularPopup : CallEvent + data object ShowSwitchCameraTooltip : CallEvent + data object DismissSwitchCameraTooltip : CallEvent + data class StartCall(val isVideoCall: Boolean) : CallEvent + data class ShowGroupCallSafetyNumberChange(val identityRecords: List) : CallEvent + data object SwitchToSpeaker : CallEvent + data object ShowSwipeToSpeakerHint : CallEvent +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallInfoCallbacks.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallInfoCallbacks.kt new file mode 100644 index 0000000000..87d5a7ab7f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallInfoCallbacks.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.widget.Toast +import androidx.core.app.ShareCompat +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.addTo +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.BaseActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.calls.links.CallLinks +import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment +import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragmentArgs +import org.thoughtcrime.securesms.components.webrtc.controls.CallInfoView +import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.events.CallParticipant +import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult + +/** + * Callbacks for the CallInfoView, shared between CallActivity and ControlsAndInfoController. + */ +class CallInfoCallbacks( + private val activity: BaseActivity, + private val controlsAndInfoViewModel: ControlsAndInfoViewModel, + private val disposables: CompositeDisposable +) : CallInfoView.Callbacks { + + companion object { + private val TAG = Log.tag(CallInfoCallbacks::class) + } + + override fun onShareLinkClicked() { + val mimeType = Intent.normalizeMimeType("text/plain") + val shareIntent = ShareCompat.IntentBuilder(activity) + .setText(CallLinks.url(controlsAndInfoViewModel.rootKeySnapshot)) + .setType(mimeType) + .createChooserIntent() + + try { + activity.startActivity(shareIntent) + } catch (e: ActivityNotFoundException) { + Toast.makeText(activity, R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show() + } + } + + override fun onEditNameClicked(name: String) { + EditCallLinkNameDialogFragment().apply { + arguments = EditCallLinkNameDialogFragmentArgs.Builder(name).build().toBundle() + }.show(activity.supportFragmentManager, null) + } + + override fun onToggleAdminApprovalClicked(checked: Boolean) { + controlsAndInfoViewModel.setApproveAllMembers(checked) + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy(onSuccess = { + if (it !is UpdateCallLinkResult.Update) { + Log.w(TAG, "Failed to change restrictions. $it") + toastFailure() + } + }, onError = handleError("onApproveAllMembersChanged")) + .addTo(disposables) + } + + override fun onBlock(callParticipant: CallParticipant) { + MaterialAlertDialogBuilder(activity) + .setNegativeButton(android.R.string.cancel, null) + .setMessage(activity.resources.getString(R.string.CallLinkInfoSheet__remove_s_from_the_call, callParticipant.recipient.getShortDisplayName(activity))) + .setPositiveButton(R.string.CallLinkInfoSheet__remove) { _, _ -> + AppDependencies.signalCallManager.removeFromCallLink(callParticipant) + } + .setNeutralButton(R.string.CallLinkInfoSheet__block_from_call) { _, _ -> + AppDependencies.signalCallManager.blockFromCallLink(callParticipant) + } + .show() + } + + private fun handleError(method: String): (throwable: Throwable) -> Unit { + return { + Log.w(TAG, "Failure during $method", it) + toastFailure() + } + } + + private fun toastFailure() { + Toast.makeText(activity, R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantVideoRenderer.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantVideoRenderer.kt new file mode 100644 index 0000000000..60c167be8c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantVideoRenderer.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import org.thoughtcrime.securesms.components.webrtc.TextureViewRenderer +import org.thoughtcrime.securesms.events.CallParticipant +import org.thoughtcrime.securesms.ringrtc.CameraState +import org.webrtc.RendererCommon + +/** + * Displays video for the given participant if attachVideoSink is true. + */ +@Composable +fun CallParticipantVideoRenderer( + callParticipant: CallParticipant, + attachVideoSink: Boolean, + modifier: Modifier = Modifier +) { + AndroidView( + factory = ::TextureViewRenderer, + modifier = modifier, + onRelease = { it.release() } + ) { renderer -> + renderer.setMirror(callParticipant.cameraDirection == CameraState.Direction.FRONT) + renderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL) + + callParticipant.videoSink.lockableEglBase.performWithValidEglBase { + renderer.init(it) + } + + if (attachVideoSink) { + renderer.attachBroadcastVideoSink(callParticipant.videoSink) + } else { + renderer.attachBroadcastVideoSink(null) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt new file mode 100644 index 0000000000..8ac5890767 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import android.content.res.Configuration +import android.view.LayoutInflater +import android.widget.FrameLayout +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.viewinterop.AndroidView +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.webrtc.CallParticipantsLayout +import org.thoughtcrime.securesms.components.webrtc.CallParticipantsLayoutStrategies +import org.thoughtcrime.securesms.events.CallParticipant + +@Composable +fun CallParticipantsPager( + callParticipantsPagerState: CallParticipantsPagerState, + modifier: Modifier = Modifier +) { + CallParticipantsLayoutComponent( + callParticipantsPagerState = callParticipantsPagerState, + modifier = modifier + ) +} + +@Composable +private fun CallParticipantsLayoutComponent( + callParticipantsPagerState: CallParticipantsPagerState, + modifier: Modifier = Modifier +) { + if (callParticipantsPagerState.focusedParticipant == null) { + return + } + + val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT + + AndroidView( + factory = { + LayoutInflater.from(it).inflate(R.layout.webrtc_call_participants_layout, FrameLayout(it), false) as CallParticipantsLayout + }, + modifier = modifier + ) { + it.update( + callParticipantsPagerState.callParticipants, + callParticipantsPagerState.focusedParticipant, + callParticipantsPagerState.isRenderInPip, + isPortrait, + callParticipantsPagerState.hideAvatar, + 0, + CallParticipantsLayoutStrategies.getStrategy(isPortrait, true) + ) + } +} + +@Immutable +data class CallParticipantsPagerState( + val callParticipants: List = emptyList(), + val focusedParticipant: CallParticipant? = null, + val isRenderInPip: Boolean = false, + val hideAvatar: Boolean = false +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallPermissionsDialogController.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallPermissionsDialogController.kt new file mode 100644 index 0000000000..d76339847e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallPermissionsDialogController.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import android.Manifest +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.permissions.PermissionDeniedBottomSheet.Companion.showPermissionFragment +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.util.BottomSheetUtil + +/** + * Shared dialog controller for requesting different permissions specific to calling. + */ +class CallPermissionsDialogController { + + var isAskingForPermission: Boolean = false + private set + + fun requestCameraPermission( + activity: AppCompatActivity, + onAllGranted: Runnable + ) { + if (!isAskingForPermission) { + isAskingForPermission = true + Permissions.with(activity) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withRationaleDialog(activity.getString(R.string.WebRtcCallActivity__allow_access_camera), activity.getString(R.string.WebRtcCallActivity__to_enable_video_allow_camera), false, R.drawable.symbol_video_24) + .onAnyResult { isAskingForPermission = false } + .onAllGranted(onAllGranted) + .onAnyDenied { Toast.makeText(activity, R.string.WebRtcCallActivity__signal_needs_camera_access_enable_video, Toast.LENGTH_LONG).show() } + .onAnyPermanentlyDenied { + showPermissionFragment(R.string.WebRtcCallActivity__allow_access_camera, R.string.WebRtcCallActivity__to_enable_video, false) + .show(activity.supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + .execute() + } + } + + fun requestAudioPermission( + activity: AppCompatActivity, + onGranted: Runnable, + onDenied: Runnable + ) { + if (!isAskingForPermission) { + isAskingForPermission = true + Permissions.with(activity) + .request(Manifest.permission.RECORD_AUDIO) + .ifNecessary() + .withRationaleDialog(activity.getString(R.string.WebRtcCallActivity__allow_access_microphone), activity.getString(R.string.WebRtcCallActivity__to_start_call_microphone), false, R.drawable.ic_mic_24) + .onAnyResult { isAskingForPermission = false } + .onAllGranted(onGranted) + .onAnyDenied { + Toast.makeText(activity, R.string.WebRtcCallActivity__signal_needs_microphone_start_call, Toast.LENGTH_LONG).show() + onDenied.run() + } + .onAnyPermanentlyDenied { + showPermissionFragment(R.string.WebRtcCallActivity__allow_access_microphone, R.string.WebRtcCallActivity__to_start_call, false) + .show(activity.supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + .execute() + } + } + + fun requestCameraAndAudioPermission( + activity: AppCompatActivity, + onAllGranted: Runnable, + onCameraGranted: Runnable, + onAudioDenied: Runnable + ) { + if (!isAskingForPermission) { + isAskingForPermission = true + Permissions.with(activity) + .request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA) + .ifNecessary() + .withRationaleDialog(activity.getString(R.string.WebRtcCallActivity__allow_access_camera_microphone), activity.getString(R.string.WebRtcCallActivity__to_start_call_camera_microphone), false, R.drawable.ic_mic_24, R.drawable.symbol_video_24) + .onAnyResult { isAskingForPermission = false } + .onSomePermanentlyDenied { deniedPermissions: List -> + if (deniedPermissions.containsAll(listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))) { + showPermissionFragment(R.string.WebRtcCallActivity__allow_access_camera_microphone, R.string.WebRtcCallActivity__to_start_call, false) + .show(activity.supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } else if (deniedPermissions.contains(Manifest.permission.CAMERA)) { + showPermissionFragment(R.string.WebRtcCallActivity__allow_access_camera, R.string.WebRtcCallActivity__to_enable_video, false) + .show(activity.supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } else { + showPermissionFragment(R.string.WebRtcCallActivity__allow_access_microphone, R.string.WebRtcCallActivity__to_start_call, false) + .show(activity.supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } + .onAllGranted(onAllGranted) + .onSomeGranted { permissions: List -> + if (permissions.contains(Manifest.permission.CAMERA)) { + onCameraGranted.run() + } + } + .onSomeDenied { deniedPermissions: List -> + if (deniedPermissions.contains(Manifest.permission.RECORD_AUDIO)) { + Toast.makeText(activity, R.string.WebRtcCallActivity__signal_needs_microphone_start_call, Toast.LENGTH_LONG).show() + onAudioDenied.run() + } else { + Toast.makeText(activity, R.string.WebRtcCallActivity__signal_needs_camera_access_enable_video, Toast.LENGTH_LONG).show() + } + } + .execute() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt new file mode 100644 index 0000000000..2472bdf646 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt @@ -0,0 +1,389 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.BottomSheetScaffoldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.Previews +import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState +import org.thoughtcrime.securesms.events.CallParticipant +import org.thoughtcrime.securesms.events.WebRtcViewModel +import org.thoughtcrime.securesms.recipients.Recipient +import kotlin.math.max +import kotlin.math.round + +private const val DRAG_HANDLE_HEIGHT = 22 +private const val SHEET_TOP_PADDING = 9 +private const val SHEET_BOTTOM_PADDING = 16 + +/** + * In-App calling screen displaying controls, info, and participant camera feeds. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CallScreen( + callRecipient: Recipient, + webRtcCallState: WebRtcViewModel.State, + callScreenState: CallScreenState, + callControlsState: CallControlsState, + callControlsCallback: CallControlsCallback = CallControlsCallback.Empty, + callParticipantsPagerState: CallParticipantsPagerState, + localParticipant: CallParticipant, + localRenderState: WebRtcLocalRenderState, + callInfoView: @Composable (Float) -> Unit, + onNavigationClick: () -> Unit, + onLocalPictureInPictureClicked: () -> Unit +) { + var peekPercentage by remember { + mutableFloatStateOf(0f) + } + + val scope = rememberCoroutineScope() + val scaffoldState = rememberBottomSheetScaffoldState( + bottomSheetState = rememberStandardBottomSheetState( + confirmValueChange = { + !(it == SheetValue.Hidden && callControlsState.skipHiddenState) + }, + skipHiddenState = false + ) + ) + val isPortrait = LocalConfiguration.current.orientation == Configuration.ORIENTATION_PORTRAIT + + BoxWithConstraints { + val maxHeight = constraints.maxHeight + val maxSheetHeight = round(constraints.maxHeight * 0.66f) + val maxOffset = maxHeight - maxSheetHeight + + var offset by remember { mutableFloatStateOf(0f) } + var peekHeight by remember { mutableFloatStateOf(88f) } + + BottomSheetScaffold( + scaffoldState = scaffoldState, + sheetDragHandle = null, + sheetPeekHeight = peekHeight.dp, + sheetMaxWidth = 540.dp, + sheetContent = { + BottomSheets.Handle(modifier = Modifier.align(Alignment.CenterHorizontally)) + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = SHEET_TOP_PADDING.dp, bottom = SHEET_BOTTOM_PADDING.dp) + .height(DimensionUnit.PIXELS.toDp(maxSheetHeight).dp) + .onGloballyPositioned { + offset = scaffoldState.bottomSheetState.requireOffset() + val current = maxHeight - offset - DimensionUnit.DP.toPixels(peekHeight) + val maximum = maxHeight - maxOffset - DimensionUnit.DP.toPixels(peekHeight) + + peekPercentage = current / maximum + } + ) { + val callControlsAlpha = max(0f, 1 - peekPercentage) + val callInfoAlpha = max(0f, peekPercentage) + + if (callInfoAlpha > 0f) { + callInfoView(callInfoAlpha) + } + + if (callControlsAlpha > 0f) { + CallControls( + callControlsState = callControlsState, + callControlsCallback = callControlsCallback, + modifier = Modifier + .fillMaxWidth() + .alpha(callControlsAlpha) + .onSizeChanged { + peekHeight = DimensionUnit.PIXELS.toDp(it.height.toFloat()) + DRAG_HANDLE_HEIGHT + SHEET_TOP_PADDING + SHEET_BOTTOM_PADDING + } + ) + } + } + } + ) { + val padding by animateDpAsState( + targetValue = if (scaffoldState.bottomSheetState.targetValue != SheetValue.Hidden) it.calculateBottomPadding() else 0.dp, + label = "animate-as-state" + ) + + if (!isPortrait) { + Viewport( + localParticipant = localParticipant, + localRenderState = localRenderState, + webRtcCallState = webRtcCallState, + callParticipantsPagerState = callParticipantsPagerState, + scaffoldState = scaffoldState, + callControlsState = callControlsState, + onPipClick = onLocalPictureInPictureClicked + ) + } + + Box( + modifier = Modifier + .padding(bottom = padding) + .fillMaxSize() + ) { + if (isPortrait) { + Viewport( + localParticipant = localParticipant, + localRenderState = localRenderState, + webRtcCallState = webRtcCallState, + callParticipantsPagerState = callParticipantsPagerState, + scaffoldState = scaffoldState, + callControlsState = callControlsState, + onPipClick = onLocalPictureInPictureClicked + ) + } + } + + val onCallInfoClick: () -> Unit = { + scope.launch { + if (scaffoldState.bottomSheetState.currentValue == SheetValue.Expanded) { + scaffoldState.bottomSheetState.partialExpand() + } else { + scaffoldState.bottomSheetState.expand() + } + } + } + + if (webRtcCallState.isPassedPreJoin) { + CallScreenTopBar( + callRecipient = callRecipient, + callStatus = callScreenState.callStatus, + onNavigationClick = onNavigationClick, + onCallInfoClick = onCallInfoClick, + modifier = Modifier.padding(bottom = padding) + ) + } else { + CallScreenPreJoinOverlay( + callRecipient = callRecipient, + callStatus = callScreenState.callStatus, + onNavigationClick = onNavigationClick, + onCallInfoClick = onCallInfoClick, + isLocalVideoEnabled = localParticipant.isVideoEnabled, + modifier = Modifier.padding(bottom = padding) + ) + } + + AnimatedCallStateUpdate( + callControlsChange = callScreenState.callControlsChange, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = padding) + .padding(bottom = 20.dp) + ) + } + } +} + +/** + * Primary 'viewport' which will either render content above or behind the controls depending on + * whether we are in landscape or portrait. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Viewport( + localParticipant: CallParticipant, + localRenderState: WebRtcLocalRenderState, + webRtcCallState: WebRtcViewModel.State, + callParticipantsPagerState: CallParticipantsPagerState, + scaffoldState: BottomSheetScaffoldState, + callControlsState: CallControlsState, + onPipClick: () -> Unit +) { + LargeLocalVideoRenderer( + localParticipant = localParticipant, + localRenderState = localRenderState + ) + + if (webRtcCallState.isPassedPreJoin) { + val scope = rememberCoroutineScope() + + CallParticipantsPager( + callParticipantsPagerState = callParticipantsPagerState, + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.extraLarge) + .clickable( + onClick = { + scope.launch { + if (scaffoldState.bottomSheetState.isVisible) { + scaffoldState.bottomSheetState.hide() + } else { + scaffoldState.bottomSheetState.show() + } + } + }, + enabled = !callControlsState.skipHiddenState + ) + ) + } + + if (webRtcCallState.inOngoingCall && localParticipant.isVideoEnabled) { + val padBottom: Dp = if (scaffoldState.bottomSheetState.isVisible) { + 0.dp + } else { + val density = LocalDensity.current + with(density) { WindowInsets.navigationBars.getBottom(density).toDp() } + } + + SmallMoveableLocalVideoRenderer( + localParticipant = localParticipant, + localRenderState = localRenderState, + extraPadBottom = padBottom, + onClick = onPipClick + ) + } +} + +@Composable +private fun LargeLocalVideoRenderer( + localParticipant: CallParticipant, + localRenderState: WebRtcLocalRenderState +) { + CallParticipantVideoRenderer( + callParticipant = localParticipant, + attachVideoSink = localRenderState == WebRtcLocalRenderState.LARGE, + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.extraLarge) + ) +} + +@Composable +private fun SmallMoveableLocalVideoRenderer( + localParticipant: CallParticipant, + localRenderState: WebRtcLocalRenderState, + extraPadBottom: Dp, + onClick: () -> Unit +) { + val smallSize = DpSize(90.dp, 160.dp) + val largeSize = DpSize(180.dp, 320.dp) + + val size = if (localRenderState == WebRtcLocalRenderState.SMALL_RECTANGLE) smallSize else largeSize + + val targetWidth by animateDpAsState(label = "animate-pip-width", targetValue = size.width, animationSpec = tween()) + val targetHeight by animateDpAsState(label = "animate-pip-height", targetValue = size.height, animationSpec = tween()) + val bottomPadding by animateDpAsState(label = "animate-pip-bottom-pad", targetValue = extraPadBottom, animationSpec = tween()) + + PictureInPicture( + contentSize = DpSize(targetWidth, targetHeight), + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .statusBarsPadding() + .padding(bottom = bottomPadding) + ) { + CallParticipantVideoRenderer( + callParticipant = localParticipant, + attachVideoSink = localRenderState == WebRtcLocalRenderState.SMALL_RECTANGLE || localRenderState == WebRtcLocalRenderState.EXPANDED, + modifier = Modifier + .fillMaxSize() + .clip(MaterialTheme.shapes.medium) + .clickable { + onClick() + } + ) + } +} + +@Composable +private fun AnimatedCallStateUpdate( + callControlsChange: CallControlsChange?, + modifier: Modifier = Modifier +) { + AnimatedContent( + label = "call-state-update", + targetState = callControlsChange, + contentAlignment = Alignment.BottomCenter, + transitionSpec = { + ( + fadeIn(animationSpec = tween(220, delayMillis = 90)) + + scaleIn(initialScale = 0.92f, animationSpec = tween(220, delayMillis = 90)) + ) + .togetherWith(fadeOut(animationSpec = tween(90))) + .using(sizeTransform = null) + }, + modifier = modifier + ) { + if (it != null) { + CallStateUpdatePopup( + callControlsChange = it + ) + } + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun CallScreenPreview() { + Previews.Preview { + CallScreen( + callRecipient = Recipient.UNKNOWN, + webRtcCallState = WebRtcViewModel.State.CALL_CONNECTED, + callScreenState = CallScreenState(), + callControlsState = CallControlsState( + displayMicToggle = true, + isMicEnabled = true, + displayVideoToggle = true, + displayGroupRingingToggle = true, + displayStartCallButton = true + ), + callInfoView = { + Text(text = "Call Info View Preview", modifier = Modifier.alpha(it)) + }, + localParticipant = CallParticipant(), + localRenderState = WebRtcLocalRenderState.LARGE, + callParticipantsPagerState = CallParticipantsPagerState(), + onNavigationClick = {}, + onLocalPictureInPictureClicked = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt new file mode 100644 index 0000000000..0ebbe12374 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenState.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.api.messages.calls.HangupMessage +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * This contains higher level information that would have traditionally been directly + * set on views. (Statuses, popups, etc.), allowing us to manage this from CallViewModel + * + * @param status Status text resource to display as call status. + * @param hangup Set on call termination. + * @param callControlsChange Update to display in a CallStateUpdate component. + */ +data class CallScreenState( + val callRecipientId: RecipientId = RecipientId.UNKNOWN, + val hangup: Hangup? = null, + val callControlsChange: CallControlsChange? = null, + val callStatus: CallString? = null +) { + data class Hangup( + val hangupMessageType: HangupMessage.Type, + val delay: Duration = 1.seconds + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenTopBar.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenTopBar.kt new file mode 100644 index 0000000000..6a249dfd5d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreenTopBar.kt @@ -0,0 +1,219 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.DarkPreview +import org.signal.core.ui.Previews +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.AvatarImage +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Post pre-join app bar that displays call information and status. + */ +@Composable +fun CallScreenTopBar( + callRecipient: Recipient, + callStatus: CallString?, + modifier: Modifier = Modifier, + onNavigationClick: () -> Unit = {}, + onCallInfoClick: () -> Unit = {} +) { + Box( + modifier = modifier + .height(240.dp) + .background( + brush = Brush.verticalGradient( + 0.0f to Color(0f, 0f, 0f, 0.7f), + 1.0f to Color.Transparent + ) + ) + ) { + CallScreenTopAppBar( + callRecipient = callRecipient, + callStatus = callStatus, + onNavigationClick = onNavigationClick, + onCallInfoClick = onCallInfoClick + ) + } +} + +@Composable +fun CallScreenPreJoinOverlay( + callRecipient: Recipient, + callStatus: CallString?, + isLocalVideoEnabled: Boolean, + modifier: Modifier = Modifier, + onNavigationClick: () -> Unit = {}, + onCallInfoClick: () -> Unit = {} +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxSize() + .background(color = Color(0f, 0f, 0f, 0.4f)) + ) { + CallScreenTopAppBar( + onNavigationClick = onNavigationClick, + onCallInfoClick = onCallInfoClick + ) + + AvatarImage( + recipient = callRecipient, + modifier = Modifier + .padding(top = 8.dp) + .size(96.dp) + ) + + Text( + text = callRecipient.getDisplayName(LocalContext.current), + style = MaterialTheme.typography.headlineMedium, + color = Color.White, + modifier = Modifier.padding(top = 16.dp) + ) + + if (callStatus != null) { + Text( + text = callStatus.renderToString(), + style = MaterialTheme.typography.bodyMedium, + color = Color.White, + modifier = Modifier.padding(top = 8.dp) + ) + } + + if (!isLocalVideoEnabled) { + Spacer(modifier = Modifier.weight(1f)) + + Icon( + painter = painterResource( + id = R.drawable.symbol_video_slash_24 + ), + contentDescription = null, + tint = Color.White, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Text( + text = stringResource(id = R.string.CallScreenPreJoinOverlay__your_camera_is_off), + color = Color.White + ) + + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CallScreenTopAppBar( + callRecipient: Recipient? = null, + callStatus: CallString? = null, + onNavigationClick: () -> Unit = {}, + onCallInfoClick: () -> Unit = {} +) { + val textShadow = remember { + Shadow( + color = Color(0f, 0f, 0f, 0.25f), + blurRadius = 4f + ) + } + + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors().copy( + containerColor = Color.Transparent + ), + title = { + Column { + if (callRecipient != null) { + Text( + text = callRecipient.getDisplayName(LocalContext.current), + style = MaterialTheme.typography.titleMedium.copy(shadow = textShadow) + ) + } + + if (callStatus != null) { + Text( + text = callStatus.renderToString(), + style = MaterialTheme.typography.bodyMedium.copy(shadow = textShadow), + modifier = Modifier.padding(top = 2.dp) + ) + } + } + }, + navigationIcon = { + IconButton( + onClick = onNavigationClick + ) { + Icon( + painter = painterResource(id = R.drawable.symbol_arrow_left_24), + contentDescription = stringResource(id = R.string.CallScreenTopBar__go_back), + tint = Color.White + ) + } + }, + actions = { + IconButton( + onClick = onCallInfoClick, + modifier = Modifier.padding(16.dp) + ) { + Icon( + painter = painterResource(id = R.drawable.symbol_info_24), + contentDescription = stringResource(id = R.string.CallScreenTopBar__call_information), + tint = Color.White + ) + } + } + ) +} + +@DarkPreview +@Composable +fun CallScreenTopBarPreview() { + Previews.Preview { + CallScreenTopBar( + callRecipient = Recipient(systemContactName = "Test User"), + callStatus = null + ) + } +} + +@DarkPreview +@Composable +fun CallScreenPreJoinOverlayPreview() { + Previews.Preview { + CallScreenPreJoinOverlay( + callRecipient = Recipient(systemContactName = "Test User"), + callStatus = CallString.ResourceString(R.string.Recipient_unknown), + isLocalVideoEnabled = false + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallString.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallString.kt new file mode 100644 index 0000000000..514716550e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallString.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Objects that can be rendered into a string, including Recipient display names + * and group info. This allows us to pass these objects through the view model without + * having to pass around context. + */ +sealed interface CallString { + @Composable + fun renderToString(): String + + data class RecipientDisplayName( + val recipient: Recipient + ) : CallString { + @Composable + override fun renderToString(): String { + return recipient.getDisplayName(LocalContext.current) + } + } + + data class ResourceString( + @StringRes val resource: Int + ) : CallString { + @Composable + override fun renderToString(): String { + return stringResource(id = resource) + } + } +} 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 new file mode 100644 index 0000000000..3a2279e842 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallViewModel.kt @@ -0,0 +1,314 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +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.WebRtcCallViewModel +import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel +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.SignalCallManager +import org.thoughtcrime.securesms.sms.MessageSender +import org.whispersystems.signalservice.api.messages.calls.HangupMessage +import kotlin.time.Duration.Companion.milliseconds + +/** + * 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 val internalCallScreenState = MutableStateFlow(CallScreenState()) + val callScreenState: StateFlow = internalCallScreenState + + 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 onCallEvent(event: CallEvent) { + when (event) { + CallEvent.DismissSwitchCameraTooltip -> Unit // TODO + CallEvent.DismissVideoTooltip -> Unit // TODO + is CallEvent.ShowGroupCallSafetyNumberChange -> Unit // TODO + CallEvent.ShowSwipeToSpeakerHint -> Unit // TODO + CallEvent.ShowSwitchCameraTooltip -> Unit // TODO + CallEvent.ShowVideoTooltip -> Unit // TODO + CallEvent.ShowWifiToCellularPopup -> Unit // TODO + is CallEvent.StartCall -> startCall(event.isVideoCall) + CallEvent.SwitchToSpeaker -> Unit // TODO + } + } + + @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) + } + + // TODO [alex] -- Call link handling block + + val enableVideo = event.localParticipant.cameraState.cameraCount > 0 && enableVideoIfAvailable + webRtcCallViewModel.updateFromWebRtcViewModel(event, enableVideo) + + // TODO [alex] -- handle enable video + + // 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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPicture.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPicture.kt new file mode 100644 index 0000000000..f88a36899e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/PictureInPicture.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +import androidx.compose.animation.core.AnimationVector +import androidx.compose.animation.core.AnimationVector2D +import androidx.compose.animation.core.TwoWayConverter +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.draggable2D +import androidx.compose.foundation.gestures.rememberDraggable2DState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import org.signal.core.ui.DarkPreview +import org.signal.core.ui.Previews +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.math.sqrt + +private const val DECELERATION_RATE = 0.99f + +/** + * Displays moveable content in a bounding box and allows the user to drag it to + * the four corners. Automatically adjusts itself as the bounding box and content + * size changes. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PictureInPicture( + modifier: Modifier = Modifier, + contentSize: DpSize, + content: @Composable () -> Unit +) { + BoxWithConstraints( + modifier = modifier + ) { + val density = LocalDensity.current + val maxHeight = constraints.maxHeight + val maxWidth = constraints.maxWidth + val contentWidth = with(density) { contentSize.width.toPx().roundToInt() } + val contentHeight = with(density) { contentSize.height.toPx().roundToInt() } + + var isDragging by remember { + mutableStateOf(false) + } + + var isAnimating by remember { + mutableStateOf(false) + } + + var offsetX by remember { + mutableIntStateOf(maxWidth - contentWidth) + } + var offsetY by remember { + mutableIntStateOf(maxHeight - contentHeight) + } + + val topLeft = remember { + IntOffset(0, 0) + } + + val topRight = remember(maxWidth, contentWidth) { + IntOffset(maxWidth - contentWidth, 0) + } + + val bottomLeft = remember(maxHeight, contentHeight) { + IntOffset(0, maxHeight - contentHeight) + } + + val bottomRight = remember(maxWidth, maxHeight, contentWidth, contentHeight) { + IntOffset(maxWidth - contentWidth, maxHeight - contentHeight) + } + + DisposableEffect(maxWidth, maxHeight, isAnimating, isDragging, contentWidth, contentHeight) { + if (!isAnimating && !isDragging) { + val projectedCoordinate = IntOffset(offsetX, offsetY) + val closestCorner = getClosestCorner(projectedCoordinate, topLeft, topRight, bottomLeft, bottomRight) + + offsetX = closestCorner.x + offsetY = closestCorner.y + } + + onDispose { } + } + + Box( + modifier = Modifier + .size(contentSize) + .offset { + IntOffset(offsetX, offsetY) + } + .draggable2D( + state = rememberDraggable2DState { offset -> + offsetX += offset.x.roundToInt() + offsetY += offset.y.roundToInt() + }, + onDragStarted = { + isDragging = true + }, + onDragStopped = { velocity -> + isAnimating = true + isDragging = false + + val x = offsetX + project(velocity.x) + val y = offsetY + project(velocity.y) + + val projectedCoordinate = IntOffset(x.roundToInt(), y.roundToInt()) + val cornerCoordinate = getClosestCorner(projectedCoordinate, topLeft, topRight, bottomLeft, bottomRight) + + animate( + typeConverter = IntOffsetConverter, + initialValue = IntOffset(offsetX, offsetY), + targetValue = cornerCoordinate, + initialVelocity = IntOffset(velocity.x.roundToInt(), velocity.y.roundToInt()), + animationSpec = tween() + ) { value, _ -> + offsetX = value.x + offsetY = value.y + } + + isAnimating = false + } + ) + ) { + content() + } + } +} + +private object IntOffsetConverter : TwoWayConverter { + override val convertFromVector: (AnimationVector2D) -> IntOffset = { animationVector -> + IntOffset(animationVector.v1.roundToInt(), animationVector.v2.roundToInt()) + } + override val convertToVector: (IntOffset) -> AnimationVector2D = { intOffset -> + AnimationVector(intOffset.x.toFloat(), intOffset.y.toFloat()) + } +} + +private fun project(velocity: Float): Float { + return (velocity / 1000f) * DECELERATION_RATE / (1f - DECELERATION_RATE) +} + +private fun getClosestCorner(coordinate: IntOffset, topLeft: IntOffset, topRight: IntOffset, bottomLeft: IntOffset, bottomRight: IntOffset): IntOffset { + val distances = mapOf( + topLeft to distance(coordinate, topLeft), + topRight to distance(coordinate, topRight), + bottomLeft to distance(coordinate, bottomLeft), + bottomRight to distance(coordinate, bottomRight) + ) + + return distances.minBy { it.value }.key +} + +private fun distance(a: IntOffset, b: IntOffset): Float { + return sqrt((b.x - a.x).toDouble().pow(2) + (b.y - a.y).toDouble().pow(2)).toFloat() +} + +@DarkPreview +@Composable +fun PictureInPicturePreview() { + Previews.Preview { + PictureInPicture( + contentSize = DpSize(90.dp, 160.dp), + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(color = Color.Red) + .clip(MaterialTheme.shapes.medium) + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java index 6dac5cf20d..546d939856 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -31,6 +31,7 @@ import org.signal.ringrtc.CallLinkRootKey; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.WebRtcCallActivity; import org.thoughtcrime.securesms.calls.links.CallLinks; +import org.thoughtcrime.securesms.components.webrtc.v2.CallActivity; import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery; import org.thoughtcrime.securesms.conversation.ConversationIntents; import org.thoughtcrime.securesms.database.CallLinkTable; @@ -396,7 +397,7 @@ public class CommunicationActions { MessageSender.onMessageSent(); - Intent activityIntent = new Intent(callContext.getContext(), WebRtcCallActivity.class); + Intent activityIntent = new Intent(callContext.getContext(), getCallActivityClass()); activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); @@ -408,7 +409,7 @@ public class CommunicationActions { private static void startVideoCallInternal(@NonNull CallContext callContext, @NonNull Recipient recipient, boolean fromCallLink) { AppDependencies.getSignalCallManager().startPreJoinCall(recipient); - Intent activityIntent = new Intent(callContext.getContext(), WebRtcCallActivity.class); + Intent activityIntent = new Intent(callContext.getContext(), getCallActivityClass()); activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .putExtra(WebRtcCallActivity.EXTRA_ENABLE_VIDEO_IF_AVAILABLE, true) @@ -478,6 +479,10 @@ public class CommunicationActions { }); } + private static Class getCallActivityClass() { + return RemoteConfig.useNewCallApi() ? CallActivity.class : WebRtcCallActivity.class; + } + private interface CallContext { @NonNull Permissions.PermissionsBuilder getPermissionsBuilder(); void startActivity(@NonNull Intent intent); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index 26c8ba75e9..03771a0e7f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -1111,5 +1111,13 @@ object RemoteConfig { hotSwappable = true ) + @JvmStatic + @get:JvmName("useNewCallApi") + val newCallUi: Boolean by remoteBoolean( + key = "android.newCallUi", + defaultValue = false, + hotSwappable = false + ) + // endregion } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ece24b313e..cc9c632965 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2100,6 +2100,16 @@ + + + Go back + + Call information + + + + Your camera is off + Tap here to turn on your video To call %1$s, Signal needs access to your camera @@ -2249,6 +2259,8 @@ Additional actions End call + + Toggle group ringing A UI error occurred. Please report this error to the developers. diff --git a/core-ui/src/main/java/org/signal/core/ui/DarkPreview.kt b/core-ui/src/main/java/org/signal/core/ui/DarkPreview.kt new file mode 100644 index 0000000000..fc7c3840bc --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/DarkPreview.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui + +import android.content.res.Configuration +import androidx.compose.ui.tooling.preview.Preview + +/** + * Only generates a dark preview. Useful for screens that + * are only ever rendered in dark mode (like calling) + */ +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +annotation class DarkPreview() diff --git a/core-ui/src/main/java/org/signal/core/ui/IconButtons.kt b/core-ui/src/main/java/org/signal/core/ui/IconButtons.kt new file mode 100644 index 0000000000..34632d48e9 --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/IconButtons.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.signal.core.ui.copied.androidx.compose.material3.IconButtonColors +import org.signal.core.ui.copied.androidx.compose.material3.IconToggleButtonColors + +object IconButtons { + + @Composable + fun iconButtonColors( + containerColor: Color = Color.Transparent, + contentColor: Color = LocalContentColor.current, + disabledContainerColor: Color = Color.Transparent, + disabledContentColor: Color = + contentColor.copy(alpha = 0.38f) + ): IconButtonColors = + IconButtonColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor + ) + + @Composable + fun iconToggleButtonColors( + containerColor: Color = Color.Transparent, + contentColor: Color = LocalContentColor.current, + disabledContainerColor: Color = Color.Transparent, + disabledContentColor: Color = + contentColor.copy(alpha = 0.38f), + checkedContainerColor: Color = Color.Transparent, + checkedContentColor: Color = MaterialTheme.colorScheme.primary + ): IconToggleButtonColors = + IconToggleButtonColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor, + checkedContainerColor = checkedContainerColor, + checkedContentColor = checkedContentColor + ) + + @Composable + fun IconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Dp = 40.dp, + shape: Shape = CircleShape, + enabled: Boolean = true, + colors: IconButtonColors = iconButtonColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit + ) { + Box( + modifier = modifier + .minimumInteractiveComponentSize() + .size(size) + .clip(shape) + .background(color = colors.containerColor(enabled).value) + .clickable( + onClick = onClick, + enabled = enabled, + role = Role.Button, + interactionSource = interactionSource, + indication = rememberRipple( + bounded = false, + radius = size / 2 + ) + ), + contentAlignment = Alignment.Center + ) { + val contentColor = colors.contentColor(enabled).value + CompositionLocalProvider(LocalContentColor provides contentColor, content = content) + } + } + + @Composable + fun IconToggleButton( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + size: Dp = 40.dp, + shape: Shape = CircleShape, + enabled: Boolean = true, + colors: IconToggleButtonColors = iconToggleButtonColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable () -> Unit + ) { + @Suppress("DEPRECATION_ERROR") + ( + Box( + modifier = modifier + .minimumInteractiveComponentSize() + .size(size) + .clip(shape) + .background(color = colors.containerColor(enabled, checked).value) + .toggleable( + value = checked, + onValueChange = onCheckedChange, + enabled = enabled, + role = Role.Checkbox, + interactionSource = interactionSource, + indication = androidx.compose.material.ripple.rememberRipple( + bounded = false, + radius = size / 2 + ) + ), + contentAlignment = Alignment.Center + ) { + val contentColor = colors.contentColor(enabled, checked).value + CompositionLocalProvider(LocalContentColor provides contentColor, content = content) + } + ) + } +} diff --git a/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/material3/IconButton.kt b/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/material3/IconButton.kt new file mode 100644 index 0000000000..e2a290b553 --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/copied/androidx/compose/material3/IconButton.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.core.ui.copied.androidx.compose.material3 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.State +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.graphics.Color + +@Immutable +class IconButtonColors internal constructor( + private val containerColor: Color, + private val contentColor: Color, + private val disabledContainerColor: Color, + private val disabledContentColor: Color +) { + /** + * Represents the container color for this icon button, depending on [enabled]. + * + * @param enabled whether the icon button is enabled + */ + @Composable + internal fun containerColor(enabled: Boolean): State { + return rememberUpdatedState(if (enabled) containerColor else disabledContainerColor) + } + + /** + * Represents the content color for this icon button, depending on [enabled]. + * + * @param enabled whether the icon button is enabled + */ + @Composable + internal fun contentColor(enabled: Boolean): State { + return rememberUpdatedState(if (enabled) contentColor else disabledContentColor) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || other !is IconButtonColors) return false + + if (containerColor != other.containerColor) return false + if (contentColor != other.contentColor) return false + if (disabledContainerColor != other.disabledContainerColor) return false + if (disabledContentColor != other.disabledContentColor) return false + + return true + } + + override fun hashCode(): Int { + var result = containerColor.hashCode() + result = 31 * result + contentColor.hashCode() + result = 31 * result + disabledContainerColor.hashCode() + result = 31 * result + disabledContentColor.hashCode() + + return result + } +} + +@Immutable +class IconToggleButtonColors internal constructor( + private val containerColor: Color, + private val contentColor: Color, + private val disabledContainerColor: Color, + private val disabledContentColor: Color, + private val checkedContainerColor: Color, + private val checkedContentColor: Color +) { + /** + * Represents the container color for this icon button, depending on [enabled] and [checked]. + * + * @param enabled whether the icon button is enabled + * @param checked whether the icon button is checked + */ + @Composable + internal fun containerColor(enabled: Boolean, checked: Boolean): State { + val target = when { + !enabled -> disabledContainerColor + !checked -> containerColor + else -> checkedContainerColor + } + return rememberUpdatedState(target) + } + + /** + * Represents the content color for this icon button, depending on [enabled] and [checked]. + * + * @param enabled whether the icon button is enabled + * @param checked whether the icon button is checked + */ + @Composable + internal fun contentColor(enabled: Boolean, checked: Boolean): State { + val target = when { + !enabled -> disabledContentColor + !checked -> contentColor + else -> checkedContentColor + } + return rememberUpdatedState(target) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || other !is IconToggleButtonColors) return false + + if (containerColor != other.containerColor) return false + if (contentColor != other.contentColor) return false + if (disabledContainerColor != other.disabledContainerColor) return false + if (disabledContentColor != other.disabledContentColor) return false + if (checkedContainerColor != other.checkedContainerColor) return false + if (checkedContentColor != other.checkedContentColor) return false + + return true + } + + override fun hashCode(): Int { + var result = containerColor.hashCode() + result = 31 * result + contentColor.hashCode() + result = 31 * result + disabledContainerColor.hashCode() + result = 31 * result + disabledContentColor.hashCode() + result = 31 * result + checkedContainerColor.hashCode() + result = 31 * result + checkedContentColor.hashCode() + + return result + } +}