Begin re-architecture of calling screen.

This commit is contained in:
Alex Hart
2024-08-19 15:32:11 -03:00
committed by mtang-signal
parent 71b5a9f865
commit 26e79db057
29 changed files with 2860 additions and 245 deletions

View File

@@ -137,7 +137,19 @@
android:launchMode="singleTask"
android:exported="false" />
<activity android:name=".messagerequests.CalleeMustAcceptMessageRequestActivity"
<activity
android:name=".components.webrtc.v2.CallActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout"
android:excludeFromRecents="true"
android:exported="true"
android:launchMode="singleTask"
android:resizeableActivity="true"
android:supportsPictureInPicture="true"
android:taskAffinity=".calling"
android:theme="@style/TextSecure.DarkTheme.WebRTCCall"
android:windowSoftInputMode="stateAlwaysHidden" />
<activity android:name=".messagerequests.CalleeMustAcceptMessageRequestActivity"
android:theme="@style/TextSecure.DarkNoActionBar"
android:noHistory="true"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"

View File

@@ -30,12 +30,10 @@ import android.os.Build;
import android.os.Bundle;
import android.util.Pair;
import android.util.Rational;
import android.view.MotionEvent;
import android.view.Surface;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
@@ -86,6 +84,9 @@ import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoCont
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel;
import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet;
import org.thoughtcrime.securesms.components.webrtc.v2.CallEvent;
import org.thoughtcrime.securesms.components.webrtc.v2.CallPermissionsDialogController;
import org.thoughtcrime.securesms.components.webrtc.v2.CallControlsChange;
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
@@ -123,7 +124,6 @@ import io.reactivex.rxjava3.core.BackpressureStrategy;
import io.reactivex.rxjava3.disposables.Disposable;
import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE;
import static org.thoughtcrime.securesms.permissions.PermissionDeniedBottomSheet.showPermissionFragment;
public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback, ReactWithAnyEmojiBottomSheetDialogFragment.Callback {
@@ -172,8 +172,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private boolean enterPipOnResume;
private long lastProcessedIntentTimestamp;
private WebRtcViewModel previousEvent = null;
private boolean isAskingForPermission;
private Disposable ephemeralStateDisposable = Disposable.empty();
private Disposable ephemeralStateDisposable = Disposable.empty();
private CallPermissionsDialogController callPermissionsDialogController = new CallPermissionsDialogController();
@Override
protected void attachBaseContext(@NonNull Context newBase) {
@@ -313,7 +313,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
Log.i(TAG, "onPause");
super.onPause();
if (!isAskingForPermission && !viewModel.isCallStarting() && !isChangingConfigurations()) {
if (!callPermissionsDialogController.isAskingForPermission() && !viewModel.isCallStarting() && !isChangingConfigurations()) {
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) {
finish();
@@ -597,18 +597,18 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
}
private void handleViewModelEvent(@NonNull WebRtcCallViewModel.Event event) {
if (event instanceof WebRtcCallViewModel.Event.StartCall) {
startCall(((WebRtcCallViewModel.Event.StartCall) event).isVideoCall());
private void handleViewModelEvent(@NonNull CallEvent event) {
if (event instanceof CallEvent.StartCall) {
startCall(((CallEvent.StartCall) event).isVideoCall());
return;
} else if (event instanceof WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) {
SafetyNumberBottomSheet.forGroupCall(((WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) event).getIdentityRecords())
} else if (event instanceof CallEvent.ShowGroupCallSafetyNumberChange) {
SafetyNumberBottomSheet.forGroupCall(((CallEvent.ShowGroupCallSafetyNumberChange) event).getIdentityRecords())
.show(getSupportFragmentManager());
return;
} else if (event instanceof WebRtcCallViewModel.Event.SwitchToSpeaker) {
} else if (event instanceof CallEvent.SwitchToSpeaker) {
callScreen.switchToSpeakerView();
return;
} else if (event instanceof WebRtcCallViewModel.Event.ShowSwipeToSpeakerHint) {
} else if (event instanceof CallEvent.ShowSwipeToSpeakerHint) {
CallToastPopupWindow.show(callScreen);
return;
}
@@ -617,7 +617,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
return;
}
if (event instanceof WebRtcCallViewModel.Event.ShowVideoTooltip) {
if (event instanceof CallEvent.ShowVideoTooltip) {
if (videoTooltip == null) {
videoTooltip = TooltipPopup.forTarget(callScreen.getVideoTooltipTarget())
.setBackgroundTint(ContextCompat.getColor(this, R.color.core_ultramarine))
@@ -626,14 +626,14 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
.setOnDismissListener(() -> 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);
}
}

View File

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

View File

@@ -50,13 +50,13 @@ public class CallParticipantsLayout extends FlexboxLayout {
super(context, attrs, defStyleAttr);
}
void update(@NonNull List<CallParticipant> callParticipants,
@NonNull CallParticipant focusedParticipant,
boolean shouldRenderInPip,
boolean isPortrait,
boolean hideAvatar,
int navBarBottomInset,
@NonNull LayoutStrategy layoutStrategy)
public void update(@NonNull List<CallParticipant> callParticipants,
@NonNull CallParticipant focusedParticipant,
boolean shouldRenderInPip,
boolean isPortrait,
boolean hideAvatar,
int navBarBottomInset,
@NonNull LayoutStrategy layoutStrategy)
{
this.callParticipants = callParticipants;
this.focusedParticipant = focusedParticipant;

View File

@@ -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<ImageView>(R.id.icon)
private val descriptionView = contentView.findViewById<TextView>(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)
}
}

View File

@@ -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<WebRtcControls.FoldableState> foldableState = new MutableLiveData<>(WebRtcControls.FoldableState.flat());
private final LiveData<WebRtcControls> controlsWithFoldableState = LiveDataUtil.combineLatest(foldableState, webRtcControls, this::updateControlsFoldableState);
private final LiveData<WebRtcControls> realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls);
private final SingleLiveEvent<Event> events = new SingleLiveEvent<>();
private final SingleLiveEvent<CallEvent> events = new SingleLiveEvent<>();
private final BehaviorSubject<Long> elapsed = BehaviorSubject.createDefault(-1L);
private final MutableLiveData<LiveRecipient> liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live());
private final BehaviorSubject<CallParticipantsState> participantsState = BehaviorSubject.createDefault(CallParticipantsState.STARTING_STATE);
@@ -67,6 +75,7 @@ public class WebRtcCallViewModel extends ViewModel {
private final MutableLiveData<Boolean> isLandscapeEnabled = new MutableLiveData<>();
private final Observer<List<GroupMemberEntry.FullMember>> groupMemberStateUpdater = m -> participantsState.onNext(CallParticipantsState.update(participantsState.getValue(), m));
private final MutableLiveData<WebRtcEphemeralState> ephemeralState = new MutableLiveData<>();
private final BehaviorProcessor<RecipientId> recipientId = BehaviorProcessor.createDefault(RecipientId.UNKNOWN);
private final BehaviorSubject<PendingParticipantCollection> pendingParticipants = BehaviorSubject.create();
@@ -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<Event> getEvents() {
public LiveData<CallEvent> getEvents() {
return events;
}
@@ -142,6 +152,26 @@ public class WebRtcCallViewModel extends ViewModel {
).distinctUntilChanged().observeOn(AndroidSchedulers.mainThread());
}
public Flowable<CallControlsState> getCallControlsState(@NonNull LifecycleOwner lifecycleOwner) {
// Calculate this separately so we have a value when the recipient is not a group.
Flowable<Integer> groupSize = recipientId.filter(id -> id != RecipientId.UNKNOWN)
.switchMap(id -> Recipient.observable(id).toFlowable(BackpressureStrategy.LATEST))
.map(recipient -> {
if (recipient.isActiveGroup()) {
return SignalDatabase.groups().getGroupMemberIds(recipient.requireGroupId(), GroupTable.MemberSet.FULL_MEMBERS_INCLUDING_SELF).size();
} else {
return 0;
}
});
return Flowable.combineLatest(
getCallParticipantsState().toFlowable(BackpressureStrategy.LATEST),
LiveDataReactiveStreams.toPublisher(getWebRtcControls(), lifecycleOwner),
groupSize,
CallControlsState::fromViewModelData
);
}
public Observable<CallParticipantsState> getCallParticipantsState() {
return participantsState;
}
@@ -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<IdentityRecord> 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<IdentityRecord> identityRecords;
public ShowGroupCallSafetyNumberChange(@NonNull List<IdentityRecord> identityRecords) {
this.identityRecords = identityRecords;
}
public @NonNull List<IdentityRecord> getIdentityRecords() {
return identityRecords;
}
}
public static class SwitchToSpeaker extends Event {
}
public static class ShowSwipeToSpeakerHint extends Event {
events.postValue(new CallEvent.StartCall(isVideoCall));
}
}

View File

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

View File

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

View File

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

View File

@@ -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 = {}
)
}
}

View File

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

View File

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

View File

@@ -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<IdentityRecord>) : CallEvent
data object SwitchToSpeaker : CallEvent
data object ShowSwipeToSpeakerHint : CallEvent
}

View File

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

View File

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

View File

@@ -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<CallParticipant> = emptyList(),
val focusedParticipant: CallParticipant? = null,
val isRenderInPip: Boolean = false,
val hideAvatar: Boolean = false
)

View File

@@ -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<String?> ->
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<String?> ->
if (permissions.contains(Manifest.permission.CAMERA)) {
onCameraGranted.run()
}
}
.onSomeDenied { deniedPermissions: List<String?> ->
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()
}
}
}

View File

@@ -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 = {}
)
}
}

View File

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

View File

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

View File

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

View File

@@ -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<CallScreenState> = 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
}
}
}

View File

@@ -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<IntOffset, AnimationVector2D> {
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)
)
}
}
}

View File

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

View File

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

View File

@@ -2100,6 +2100,16 @@
<!-- Title text for the Valentine\'s Day donation megaphone. The placeholder will always be a heart emoji. Needs to be a placeholder for Android reasons. -->
<!-- Body text for the Valentine\'s Day donation megaphone. -->
<!-- CallScreenTopBar -->
<!-- Content description for navigation icon -->
<string name="CallScreenTopBar__go_back">Go back</string>
<!-- Content description for info icon -->
<string name="CallScreenTopBar__call_information">Call information</string>
<!-- CallScreenPreJoinOverlay -->
<!-- Displayed when users camera is disabled -->
<string name="CallScreenPreJoinOverlay__your_camera_is_off">Your camera is off</string>
<!-- WebRtcCallActivity -->
<string name="WebRtcCallActivity__tap_here_to_turn_on_your_video">Tap here to turn on your video</string>
<string name="WebRtcCallActivity__to_call_s_signal_needs_access_to_your_camera">To call %1$s, Signal needs access to your camera</string>
@@ -2249,6 +2259,8 @@
<string name="WebRtcCallView__additional_actions">Additional actions</string>
<!-- Content description for end-call button -->
<string name="WebRtcCallView__end_call">End call</string>
<!-- Content description for toggling group ring state -->
<string name="WebRtcCallView__toggle_group_ringing">Toggle group ringing</string>
<!-- Error message when the developer added a button in the wrong place. -->
<string name="WebRtcAudioOutputToggleButton_fragment_activity_error">A UI error occurred. Please report this error to the developers.</string>

View File

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

View File

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

View File

@@ -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<Color> {
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<Color> {
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<Color> {
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<Color> {
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
}
}