mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 04:58:45 +00:00
Begin re-architecture of calling screen.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
16
core-ui/src/main/java/org/signal/core/ui/DarkPreview.kt
Normal file
16
core-ui/src/main/java/org/signal/core/ui/DarkPreview.kt
Normal 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()
|
||||
141
core-ui/src/main/java/org/signal/core/ui/IconButtons.kt
Normal file
141
core-ui/src/main/java/org/signal/core/ui/IconButtons.kt
Normal 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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user