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 extends Activity> 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
+ }
+}