Implement landscape calling.

This commit is contained in:
Alex Hart
2024-07-18 10:35:18 -03:00
committed by Greyson Parrelli
parent 6e55bc04ab
commit e7720640d1
29 changed files with 890 additions and 571 deletions

View File

@@ -22,13 +22,16 @@ import android.annotation.SuppressLint;
import android.app.PictureInPictureParams;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.graphics.Rect;
import android.media.AudioManager;
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;
@@ -39,7 +42,10 @@ import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.content.ContextCompat;
import androidx.core.util.Consumer;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.LiveDataReactiveStreams;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModelProvider;
import androidx.window.java.layout.WindowInfoTrackerCallbackAdapter;
import androidx.window.layout.DisplayFeature;
@@ -58,7 +64,7 @@ import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.IdentityKey;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
import org.thoughtcrime.securesms.components.sensors.Orientation;
import org.thoughtcrime.securesms.components.webrtc.CallLinkProfileKeySender;
import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
@@ -94,8 +100,8 @@ import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.BottomSheetUtil;
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
import org.thoughtcrime.securesms.util.Util;
@@ -147,7 +153,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
private CallStateUpdatePopupWindow callStateUpdatePopupWindow;
private CallOverflowPopupWindow callOverflowPopupWindow;
private WifiToCellularPopupWindow wifiToCellularPopupWindow;
private DeviceOrientationMonitor deviceOrientationMonitor;
private FullscreenHelper fullscreenHelper;
private WebRtcCallView callScreen;
@@ -176,7 +181,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
super.attachBaseContext(newBase);
}
@SuppressLint({ "SourceLockedOrientationActivity", "MissingInflatedId" })
@SuppressLint({ "MissingInflatedId" })
@Override
public void onCreate(Bundle savedInstanceState) {
Log.i(TAG, "onCreate(" + getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
@@ -188,11 +193,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
super.onCreate(savedInstanceState);
boolean isLandscapeEnabled = getResources().getConfiguration().smallestScreenWidthDp >= 480;
if (!isLandscapeEnabled) {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}
requestWindowFeature(Window.FEATURE_NO_TITLE);
setContentView(R.layout.webrtc_call_activity);
@@ -201,7 +201,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
setVolumeControlStream(AudioManager.STREAM_VOICE_CALL);
initializeResources();
initializeViewModel(isLandscapeEnabled);
initializeViewModel();
initializePictureInPictureParams();
controlsAndInfo = new ControlsAndInfoController(this, callScreen, callOverflowPopupWindow, viewModel, controlsAndInfoViewModel);
@@ -254,6 +254,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
});
}
@Override
protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
controlsAndInfo.onStateRestored();
}
@Override
protected void onStart() {
super.onStart();
@@ -307,7 +313,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
Log.i(TAG, "onPause");
super.onPause();
if (!isAskingForPermission && !viewModel.isCallStarting()) {
if (!isAskingForPermission && !viewModel.isCallStarting() && !isChangingConfigurations()) {
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) {
finish();
@@ -329,7 +335,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
AppDependencies.getSignalCallManager().setEnableVideo(false);
if (!viewModel.isCallStarting()) {
if (!viewModel.isCallStarting() && !isChangingConfigurations()) {
CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot();
if (state != null) {
if (state.getCallState().isPreJoinOrNetworkUnavailable()) {
@@ -343,6 +349,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Override
protected void onDestroy() {
Log.d(TAG, "onDestroy");
super.onDestroy();
windowInfoTrackerCallbackAdapter.removeWindowLayoutInfoListener(windowLayoutInfoConsumer);
EventBus.getDefault().unregister(this);
@@ -486,14 +493,30 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
});
}
private void initializeViewModel(boolean isLandscapeEnabled) {
deviceOrientationMonitor = new DeviceOrientationMonitor(this);
getLifecycle().addObserver(deviceOrientationMonitor);
private @NonNull Orientation resolveOrientationFromContext() {
int displayOrientation = getResources().getConfiguration().orientation;
int displayRotation = ContextCompat.getDisplayOrDefault(this).getRotation();
WebRtcCallViewModel.Factory factory = new WebRtcCallViewModel.Factory(deviceOrientationMonitor);
if (displayOrientation == Configuration.ORIENTATION_PORTRAIT) {
return Orientation.PORTRAIT_BOTTOM_EDGE;
} else if (displayRotation == Surface.ROTATION_270) {
return Orientation.LANDSCAPE_RIGHT_EDGE;
} else {
return Orientation.LANDSCAPE_LEFT_EDGE;
}
}
viewModel = new ViewModelProvider(this, factory).get(WebRtcCallViewModel.class);
viewModel.setIsLandscapeEnabled(isLandscapeEnabled);
private void initializeViewModel() {
final Orientation orientation = resolveOrientationFromContext();
if (orientation == PORTRAIT_BOTTOM_EDGE) {
WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_dark_colorSurface2));
WindowUtil.clearTranslucentNavigationBar(getWindow());
}
LiveData<Pair<Orientation, Boolean>> orientationAndLandscapeEnabled = Transformations.map(new MutableLiveData<>(orientation), o -> Pair.create(o, true));
viewModel = new ViewModelProvider(this).get(WebRtcCallViewModel.class);
viewModel.setIsLandscapeEnabled(true);
viewModel.setIsInPipMode(isInPipMode());
viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled);
viewModel.getWebRtcControls().observe(this, controls -> {
@@ -506,7 +529,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
boolean isStartedFromCallLink = getIntent().getBooleanExtra(WebRtcCallActivity.EXTRA_STARTED_FROM_CALL_LINK, false);
LiveDataUtil.combineLatest(LiveDataReactiveStreams.fromPublisher(viewModel.getCallParticipantsState().toFlowable(BackpressureStrategy.LATEST)),
viewModel.getOrientationAndLandscapeEnabled(),
orientationAndLandscapeEnabled,
viewModel.getEphemeralState(),
(s, o, e) -> new CallParticipantsViewState(s, e, o.first == PORTRAIT_BOTTOM_EDGE, o.second, isStartedFromCallLink))
.observe(this, p -> callScreen.updateCallParticipants(p));
@@ -525,8 +548,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
}
});
viewModel.getOrientationAndLandscapeEnabled().observe(this, pair -> AppDependencies.getSignalCallManager().orientationChanged(pair.second, pair.first.getDegrees()));
viewModel.getControlsRotation().observe(this, callScreen::rotateControls);
orientationAndLandscapeEnabled.observe(this, pair -> AppDependencies.getSignalCallManager().orientationChanged(pair.second, pair.first.getDegrees()));
addOnPictureInPictureModeChangedListener(info -> {
viewModel.setIsInPipMode(info.isInPictureInPictureMode());
@@ -1262,8 +1284,6 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
Log.d(TAG, "On WindowLayoutInfo accepted: " + windowLayoutInfo.toString());
Optional<DisplayFeature> feature = windowLayoutInfo.getDisplayFeatures().stream().filter(f -> f instanceof FoldingFeature).findFirst();
viewModel.setIsLandscapeEnabled(feature.isPresent());
setRequestedOrientation(feature.isPresent() ? ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED : ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
if (feature.isPresent()) {
FoldingFeature foldingFeature = (FoldingFeature) feature.get();
Rect bounds = foldingFeature.getBounds();

View File

@@ -1,43 +0,0 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
public class MaxHeightFrameLayout extends FrameLayout {
private final int maxHeight;
public MaxHeightFrameLayout(@NonNull Context context) {
this(context, null);
}
public MaxHeightFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public MaxHeightFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
if (attrs != null) {
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MaxHeightFrameLayout);
maxHeight = a.getDimensionPixelSize(R.styleable.MaxHeightFrameLayout_mhfl_maxHeight, 0);
a.recycle();
} else {
maxHeight = 0;
}
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, Math.min(bottom, top + maxHeight));
}
}

View File

@@ -0,0 +1,70 @@
package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
/**
* FrameLayout which allows user to specify maximum dimensions of itself and therefore its children.
*/
public class MaxSizeFrameLayout extends FrameLayout {
private final int maxHeight;
private final int maxWidth;
public MaxSizeFrameLayout(@NonNull Context context) {
this(context, null);
}
public MaxSizeFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public MaxSizeFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
if (attrs != null) {
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MaxSizeFrameLayout);
maxHeight = a.getDimensionPixelSize(R.styleable.MaxSizeFrameLayout_msfl_maxHeight, 0);
maxWidth = a.getDimensionPixelSize(R.styleable.MaxSizeFrameLayout_msfl_maxWidth, 0);
a.recycle();
} else {
maxHeight = 0;
maxWidth = 0;
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int newWidthSpec = updateMeasureSpecWithMaxSize(widthMeasureSpec, maxWidth);
int newHeightSpec = updateMeasureSpecWithMaxSize(heightMeasureSpec, maxHeight);
super.onMeasure(newWidthSpec, newHeightSpec);
}
private int updateMeasureSpecWithMaxSize(int measureSpec, int maxSize) {
if (maxSize <= 0) {
return measureSpec;
}
int mode = MeasureSpec.getMode(measureSpec);
int size = MeasureSpec.getSize(measureSpec);
if (mode == MeasureSpec.UNSPECIFIED) {
return MeasureSpec.makeMeasureSpec(maxSize, MeasureSpec.AT_MOST);
} else if (mode == MeasureSpec.EXACTLY && size > maxSize) {
return MeasureSpec.makeMeasureSpec(maxSize, MeasureSpec.EXACTLY);
} else if (mode == MeasureSpec.AT_MOST && size > maxSize) {
return MeasureSpec.makeMeasureSpec(maxSize, MeasureSpec.AT_MOST);
} else {
return measureSpec;
}
}
}

View File

@@ -1,117 +0,0 @@
package org.thoughtcrime.securesms.components.sensors;
import android.content.ContentResolver;
import android.content.Context;
import android.hardware.Sensor;
import android.hardware.SensorEvent;
import android.hardware.SensorEventListener;
import android.hardware.SensorManager;
import android.provider.Settings;
import androidx.annotation.NonNull;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import org.thoughtcrime.securesms.util.ServiceUtil;
public final class DeviceOrientationMonitor implements DefaultLifecycleObserver {
private static final float MAGNITUDE_MAXIMUM = 1.5f;
private static final float MAGNITUDE_MINIMUM = 0.75f;
private static final float LANDSCAPE_PITCH_MINIMUM = -0.5f;
private static final float LANDSCAPE_PITCH_MAXIMUM = 0.5f;
private final SensorManager sensorManager;
private final ContentResolver contentResolver;
private final EventListener eventListener = new EventListener();
private final float[] accelerometerReading = new float[3];
private final float[] magnetometerReading = new float[3];
private final float[] rotationMatrix = new float[9];
private final float[] orientationAngles = new float[3];
private final MutableLiveData<Orientation> orientation = new MutableLiveData<>(Orientation.PORTRAIT_BOTTOM_EDGE);
public DeviceOrientationMonitor(@NonNull Context context) {
this.sensorManager = ServiceUtil.getSensorManager(context);
this.contentResolver = context.getContentResolver();
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
Sensor accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
if (accelerometer != null) {
sensorManager.registerListener(eventListener,
accelerometer,
SensorManager.SENSOR_DELAY_NORMAL,
SensorManager.SENSOR_DELAY_UI);
}
Sensor magneticField = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
if (magneticField != null) {
sensorManager.registerListener(eventListener,
magneticField,
SensorManager.SENSOR_DELAY_NORMAL,
SensorManager.SENSOR_DELAY_UI);
}
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
sensorManager.unregisterListener(eventListener);
}
public LiveData<Orientation> getOrientation() {
return Transformations.distinctUntilChanged(orientation);
}
private void updateOrientationAngles() {
int rotationLocked = Settings.System.getInt(contentResolver, Settings.System.ACCELEROMETER_ROTATION, -1);
if (rotationLocked == 0) {
orientation.setValue(Orientation.PORTRAIT_BOTTOM_EDGE);
return;
}
boolean success = SensorManager.getRotationMatrix(rotationMatrix, null, accelerometerReading, magnetometerReading);
if (!success) {
SensorUtil.getRotationMatrixWithoutMagneticSensorData(rotationMatrix, accelerometerReading);
}
SensorManager.getOrientation(rotationMatrix, orientationAngles);
float pitch = orientationAngles[1];
float roll = orientationAngles[2];
float mag = (float) Math.sqrt(Math.pow(pitch, 2) + Math.pow(roll, 2));
if (mag > MAGNITUDE_MAXIMUM || mag < MAGNITUDE_MINIMUM) {
return;
}
if (pitch > LANDSCAPE_PITCH_MINIMUM && pitch < LANDSCAPE_PITCH_MAXIMUM) {
orientation.setValue(roll > 0 ? Orientation.LANDSCAPE_RIGHT_EDGE : Orientation.LANDSCAPE_LEFT_EDGE);
} else {
orientation.setValue(Orientation.PORTRAIT_BOTTOM_EDGE);
}
}
private final class EventListener implements SensorEventListener {
@Override
public void onSensorChanged(SensorEvent event) {
if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
System.arraycopy(event.values, 0, accelerometerReading, 0, accelerometerReading.length);
} else if (event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) {
System.arraycopy(event.values, 0, magnetometerReading, 0, magnetometerReading.length);
}
updateOrientationAngles();
}
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
}
}
}

View File

@@ -47,7 +47,7 @@ public class BroadcastVideoSink implements VideoSink {
this.deviceOrientationDegrees = deviceOrientationDegrees;
this.rotateToRightSide = false;
this.forceRotate = forceRotate;
this.rotateWithDevice = rotateWithDevice;
this.rotateWithDevice = false;
}
public @NonNull EglBaseWrapper getLockableEglBase() {

View File

@@ -194,7 +194,7 @@ data class CallParticipantsState(
}
companion object {
private const val SMALL_GROUP_MAX = 6
const val SMALL_GROUP_MAX = 6
@JvmField
val MAX_OUTGOING_GROUP_RING_DURATION = TimeUnit.MINUTES.toMillis(1)

View File

@@ -78,7 +78,7 @@ class CallStateUpdatePopupWindow(private val parent: ViewGroup) : PopupWindow(
}
private fun show() {
if (!enabled) {
if (!enabled || parent.windowToken == null) {
return
}

View File

@@ -31,10 +31,10 @@ final class PictureInPictureExpansionHelper {
private final View selfPip;
private final ViewGroup parent;
private final Point expandedDimensions;
private State state = State.IS_SHRUNKEN;
private Point defaultDimensions;
private Point expandedDimensions;
public PictureInPictureExpansionHelper(@NonNull View selfPip) {
this.selfPip = selfPip;
@@ -62,6 +62,11 @@ final class PictureInPictureExpansionHelper {
defaultDimensions = dimensions;
int x = (dimensions.x > dimensions.y) ? EXPANDED_PIP_HEIGHT_DP : EXPANDED_PIP_WIDTH_DP;
int y = (dimensions.x > dimensions.y) ? EXPANDED_PIP_WIDTH_DP : EXPANDED_PIP_HEIGHT_DP;
expandedDimensions = new Point(ViewUtil.dpToPx(x), ViewUtil.dpToPx(y));
if (isExpandedOrExpanding()) {
return;
}

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.components.webrtc;
import android.Manifest;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.ColorMatrix;
import android.graphics.ColorMatrixColorFilter;
import android.graphics.Point;
@@ -79,9 +80,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
private static final String TAG = Log.tag(WebRtcCallView.class);
private static final long TRANSITION_DURATION_MILLIS = 250;
private static final int SMALL_ONGOING_CALL_BUTTON_MARGIN_DP = 8;
private static final int LARGE_ONGOING_CALL_BUTTON_MARGIN_DP = 16;
private static final long TRANSITION_DURATION_MILLIS = 250;
private WebRtcAudioOutputToggleButton audioToggle;
private AccessibleToggleButton videoToggle;
@@ -142,7 +141,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
private final Set<View> topViews = new HashSet<>();
private final Set<View> visibleViewSet = new HashSet<>();
private final Set<View> allTimeVisibleViews = new HashSet<>();
private final Set<View> rotatableControls = new HashSet<>();
private final ThrottledDebouncer throttledDebouncer = new ThrottledDebouncer(TRANSITION_DURATION_MILLIS);
private WebRtcControls controls = WebRtcControls.NONE;
@@ -360,18 +358,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
return false;
});
rotatableControls.add(overflow);
rotatableControls.add(hangup);
rotatableControls.add(answer);
rotatableControls.add(answerWithoutVideo);
rotatableControls.add(audioToggle);
rotatableControls.add(micToggle);
rotatableControls.add(videoToggle);
rotatableControls.add(cameraDirectionToggle);
rotatableControls.add(decline);
rotatableControls.add(smallLocalAudioIndicator);
rotatableControls.add(ringToggle);
missingPermissionContainer.setVisibility(hasCameraPermission() ? View.GONE : View.VISIBLE);
allowAccessButton.setOnClickListener(v -> {
@@ -379,10 +365,17 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
});
ConstraintLayout aboveControls = findViewById(R.id.call_controls_floating_parent);
SlideUpWithCallControlsBehavior behavior = (SlideUpWithCallControlsBehavior) ((CoordinatorLayout.LayoutParams) aboveControls.getLayoutParams()).getBehavior();
Objects.requireNonNull(behavior).setOnTopOfControlsChangedListener(topOfControls -> {
pictureInPictureGestureHelper.setBottomVerticalBoundary(topOfControls);
});
if (getContext().getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) {
aboveControls.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
pictureInPictureGestureHelper.setBottomVerticalBoundary(bottom + ViewUtil.getStatusBarHeight(v));
});
} else {
SlideUpWithCallControlsBehavior behavior = (SlideUpWithCallControlsBehavior) ((CoordinatorLayout.LayoutParams) aboveControls.getLayoutParams()).getBehavior();
Objects.requireNonNull(behavior).setOnTopOfControlsChangedListener(topOfControls -> {
pictureInPictureGestureHelper.setBottomVerticalBoundary(topOfControls);
});
}
}
@Override
@@ -408,12 +401,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
}
}
public void rotateControls(int degrees) {
for (View view : rotatableControls) {
view.animate().rotation(degrees);
}
}
public void setControlsListener(@Nullable ControlsListener controlsListener) {
this.controlsListener = controlsListener;
}
@@ -873,12 +860,6 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
constraintSet.setForceId(false);
constraintSet.clone(this);
constraintSet.connect(R.id.call_screen_participants_parent,
ConstraintSet.BOTTOM,
layoutPositions.participantBottomViewId,
layoutPositions.participantBottomViewEndSide,
ViewUtil.dpToPx(layoutPositions.participantBottomMargin));
constraintSet.connect(R.id.call_screen_reactions_feed,
ConstraintSet.BOTTOM,
layoutPositions.reactionBottomViewId,

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.webrtc;
import android.os.Handler;
import android.os.Looper;
import android.util.Pair;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
@@ -12,12 +11,10 @@ import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import org.signal.core.util.ThreadUtil;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
import org.thoughtcrime.securesms.components.sensors.Orientation;
import org.thoughtcrime.securesms.database.model.IdentityRecord;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
@@ -41,7 +38,6 @@ import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
@@ -68,9 +64,7 @@ public class WebRtcCallViewModel extends ViewModel {
private final LiveData<List<GroupMemberEntry.FullMember>> groupMembersChanged = LiveDataUtil.skip(groupMembers, 1);
private final LiveData<Integer> groupMemberCount = Transformations.map(groupMembers, List::size);
private final Observable<Boolean> shouldShowSpeakerHint = participantsState.map(this::shouldShowSpeakerHint);
private final LiveData<Orientation> orientation;
private final MutableLiveData<Boolean> isLandscapeEnabled = new MutableLiveData<>();
private final LiveData<Integer> controlsRotation;
private final Observer<List<GroupMemberEntry.FullMember>> groupMemberStateUpdater = m -> participantsState.onNext(CallParticipantsState.update(participantsState.getValue(), m));
private final MutableLiveData<WebRtcEphemeralState> ephemeralState = new MutableLiveData<>();
@@ -95,27 +89,10 @@ public class WebRtcCallViewModel extends ViewModel {
private final WebRtcCallRepository repository = new WebRtcCallRepository(AppDependencies.getApplication());
private WebRtcCallViewModel(@NonNull DeviceOrientationMonitor deviceOrientationMonitor) {
orientation = deviceOrientationMonitor.getOrientation();
controlsRotation = LiveDataUtil.combineLatest(Transformations.distinctUntilChanged(isLandscapeEnabled),
Transformations.distinctUntilChanged(orientation),
this::resolveRotation);
public WebRtcCallViewModel() {
groupMembers.observeForever(groupMemberStateUpdater);
}
public LiveData<Integer> getControlsRotation() {
return controlsRotation;
}
public LiveData<Orientation> getOrientation() {
return Transformations.distinctUntilChanged(orientation);
}
public LiveData<Pair<Orientation, Boolean>> getOrientationAndLandscapeEnabled() {
return LiveDataUtil.combineLatest(orientation, isLandscapeEnabled, Pair::new);
}
public LiveData<Boolean> getMicrophoneEnabled() {
return Transformations.distinctUntilChanged(microphoneEnabled);
}
@@ -309,7 +286,8 @@ public class WebRtcCallViewModel extends ViewModel {
webRtcViewModel.getAvailableDevices(),
webRtcViewModel.getRemoteDevicesCount().orElse(0),
webRtcViewModel.getParticipantLimit(),
webRtcViewModel.getRecipient().isCallLink());
webRtcViewModel.getRecipient().isCallLink(),
webRtcViewModel.getRemoteParticipants().size() > CallParticipantsState.SMALL_GROUP_MAX);
pendingParticipants.onNext(webRtcViewModel.getPendingParticipants());
@@ -364,23 +342,6 @@ public class WebRtcCallViewModel extends ViewModel {
ephemeralState.setValue(state);
}
private int resolveRotation(boolean isLandscapeEnabled, @NonNull Orientation orientation) {
if (isLandscapeEnabled) {
return 0;
}
switch (orientation) {
case LANDSCAPE_LEFT_EDGE:
return 90;
case LANDSCAPE_RIGHT_EDGE:
return -90;
case PORTRAIT_BOTTOM_EDGE:
return 0;
default:
throw new AssertionError();
}
}
private boolean containsPlaceholders(@NonNull List<CallParticipant> callParticipants) {
return Stream.of(callParticipants).anyMatch(p -> p.getCallParticipantId().getDemuxId() == CallParticipantId.DEFAULT_ID);
}
@@ -396,7 +357,8 @@ public class WebRtcCallViewModel extends ViewModel {
@NonNull Set<SignalAudioManager.AudioDevice> availableDevices,
long remoteDevicesCount,
@Nullable Long participantLimit,
boolean isCallLink)
boolean isCallLink,
boolean hasParticipantOverflow)
{
final WebRtcControls.CallState callState;
@@ -468,7 +430,8 @@ public class WebRtcCallViewModel extends ViewModel {
WebRtcControls.FoldableState.flat(),
activeDevice,
availableDevices,
isCallLink));
isCallLink,
hasParticipantOverflow));
}
private @NonNull WebRtcControls updateControlsFoldableState(@NonNull WebRtcControls.FoldableState foldableState, @NonNull WebRtcControls controls) {
@@ -608,18 +571,4 @@ public class WebRtcCallViewModel extends ViewModel {
return recipientIds;
}
}
public static class Factory implements ViewModelProvider.Factory {
private final DeviceOrientationMonitor deviceOrientationMonitor;
public Factory(@NonNull DeviceOrientationMonitor deviceOrientationMonitor) {
this.deviceOrientationMonitor = deviceOrientationMonitor;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return Objects.requireNonNull(modelClass.cast(new WebRtcCallViewModel(deviceOrientationMonitor)));
}
}
}

View File

@@ -28,6 +28,7 @@ public final class WebRtcControls {
FoldableState.flat(),
SignalAudioManager.AudioDevice.NONE,
emptySet(),
false,
false);
private final boolean isRemoteVideoEnabled;
@@ -42,6 +43,7 @@ public final class WebRtcControls {
private final SignalAudioManager.AudioDevice activeDevice;
private final Set<SignalAudioManager.AudioDevice> availableDevices;
private final boolean isCallLink;
private final boolean hasParticipantOverflow;
private WebRtcControls() {
this(false,
@@ -55,6 +57,7 @@ public final class WebRtcControls {
FoldableState.flat(),
SignalAudioManager.AudioDevice.NONE,
emptySet(),
false,
false);
}
@@ -69,7 +72,8 @@ public final class WebRtcControls {
@NonNull FoldableState foldableState,
@NonNull SignalAudioManager.AudioDevice activeDevice,
@NonNull Set<SignalAudioManager.AudioDevice> availableDevices,
boolean isCallLink)
boolean isCallLink,
boolean hasParticipantOverflow)
{
this.isLocalVideoEnabled = isLocalVideoEnabled;
this.isRemoteVideoEnabled = isRemoteVideoEnabled;
@@ -83,6 +87,7 @@ public final class WebRtcControls {
this.activeDevice = activeDevice;
this.availableDevices = availableDevices;
this.isCallLink = isCallLink;
this.hasParticipantOverflow = hasParticipantOverflow;
}
public @NonNull WebRtcControls withFoldableState(FoldableState foldableState) {
@@ -97,7 +102,8 @@ public final class WebRtcControls {
foldableState,
activeDevice,
availableDevices,
isCallLink);
isCallLink,
hasParticipantOverflow);
}
/**
@@ -178,7 +184,7 @@ public final class WebRtcControls {
}
public boolean displayRemoteVideoRecycler() {
return isOngoing();
return isOngoing() && hasParticipantOverflow;
}
public boolean displayAnswerWithoutVideo() {

View File

@@ -8,7 +8,9 @@ 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.os.Handler
import android.os.Parcelable
import android.view.View
import android.widget.FrameLayout
import android.widget.Toast
@@ -40,6 +42,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.addTo
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.parcelize.Parcelize
import org.signal.core.util.dp
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
@@ -61,70 +64,64 @@ import kotlin.math.max
import kotlin.time.Duration.Companion.seconds
/**
* Brain for rendering the call controls and info within a bottom sheet.
* Brain for rendering the call controls and info within a bottom sheet when we display the activity in portrait mode.
*/
class ControlsAndInfoController(
class ControlsAndInfoController private constructor(
private val webRtcCallActivity: WebRtcCallActivity,
private val webRtcCallView: WebRtcCallView,
private val overflowPopupWindow: CallOverflowPopupWindow,
private val viewModel: WebRtcCallViewModel,
private val controlsAndInfoViewModel: ControlsAndInfoViewModel
) : CallInfoView.Callbacks, Disposable {
private val controlsAndInfoViewModel: ControlsAndInfoViewModel,
private val disposables: CompositeDisposable
) : CallInfoView.Callbacks, Disposable by disposables {
constructor(
webRtcCallActivity: WebRtcCallActivity,
webRtcCallView: WebRtcCallView,
overflowPopupWindow: CallOverflowPopupWindow,
viewModel: WebRtcCallViewModel,
controlsAndInfoViewModel: ControlsAndInfoViewModel
) : this(
webRtcCallActivity,
webRtcCallView,
overflowPopupWindow,
viewModel,
controlsAndInfoViewModel,
CompositeDisposable()
)
companion object {
private val TAG = Log.tag(ControlsAndInfoController::class.java)
private const val CONTROL_TRANSITION_DURATION = 250L
private const val CONTROL_FADE_OUT_START = 0f
private const val CONTROL_FADE_OUT_DONE = 0.23f
private const val INFO_FADE_IN_START = CONTROL_FADE_OUT_DONE
private const val INFO_FADE_IN_DONE = 0.8f
private const val CONTROL_TRANSITION_DURATION = 250L
private val INFO_TRANSLATION_DISTANCE = 24f.dp
private val HIDE_CONTROL_DELAY = 5.seconds.inWholeMilliseconds
}
private val disposables = CompositeDisposable()
private val coordinator: CoordinatorLayout = webRtcCallView.findViewById(R.id.call_controls_info_coordinator)
private val callInfoComposeView: ComposeView = webRtcCallView.findViewById(R.id.call_info_compose)
private val frame: FrameLayout = webRtcCallView.findViewById(R.id.call_controls_info_parent)
private val behavior = BottomSheetBehavior.from(frame)
private val raiseHandComposeView: ComposeView = webRtcCallView.findViewById(R.id.call_screen_raise_hand_view)
private val aboveControlsGuideline: Guideline = webRtcCallView.findViewById(R.id.call_screen_above_controls_guideline)
private val toggleCameraDirectionView: View = webRtcCallView.findViewById(R.id.call_screen_camera_direction_toggle)
private val callControls: ConstraintLayout = webRtcCallView.findViewById(R.id.call_controls_constraint_layout)
private val isLandscape = webRtcCallActivity.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
private val coordinator: CoordinatorLayout
private val frame: FrameLayout
private val behavior: BottomSheetBehavior<View>
private val callInfoComposeView: ComposeView
private val raiseHandComposeView: ComposeView
private val callControls: ConstraintLayout
private val aboveControlsGuideline: Guideline
private val bottomSheetVisibilityListeners = mutableSetOf<BottomSheetVisibilityListener>()
private val scheduleHideControlsRunnable: Runnable = Runnable { onScheduledHide() }
private val toggleCameraDirectionView: View
private val bottomSheetVisibilityListeners = mutableSetOf<BottomSheetVisibilityListener>()
private val handler: Handler?
get() = webRtcCallView.handler
private var previousCallControlHeightData = HeightData()
private var controlPeakHeight = 0
private var controlState: WebRtcControls = WebRtcControls.NONE
init {
val infoTranslationDistance = 24f.dp
coordinator = webRtcCallView.findViewById(R.id.call_controls_info_coordinator)
frame = webRtcCallView.findViewById(R.id.call_controls_info_parent)
behavior = BottomSheetBehavior.from(frame)
callInfoComposeView = webRtcCallView.findViewById(R.id.call_info_compose)
callControls = webRtcCallView.findViewById(R.id.call_controls_constraint_layout)
raiseHandComposeView = webRtcCallView.findViewById(R.id.call_screen_raise_hand_view)
aboveControlsGuideline = webRtcCallView.findViewById(R.id.call_screen_above_controls_guideline)
toggleCameraDirectionView = webRtcCallView.findViewById(R.id.call_screen_camera_direction_toggle)
callInfoComposeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
CallInfoView.View(viewModel, controlsAndInfoViewModel, this@ControlsAndInfoController, Modifier.nestedScroll(nestedScrollInterop))
}
}
callInfoComposeView.alpha = 0f
callInfoComposeView.translationY = infoTranslationDistance
raiseHandComposeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
@@ -132,20 +129,6 @@ class ControlsAndInfoController(
}
}
frame.background = MaterialShapeDrawable(
ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
.setTopRightCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
.build()
).apply {
fillColor = ColorStateList.valueOf(ContextCompat.getColor(webRtcCallActivity, R.color.signal_colorSurface))
}
behavior.isHideable = true
behavior.peekHeight = 0
behavior.state = BottomSheetBehavior.STATE_HIDDEN
BottomSheetBehaviorHack.setNestedScrollingChild(behavior, callInfoComposeView)
coordinator.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
webRtcCallView.post { onControlTopChanged() }
}
@@ -154,43 +137,22 @@ class ControlsAndInfoController(
onControlTopChanged()
}
val maxBehaviorHeightPercentage = if (isLandscape) 1f else 0.66f
val minFrameHeightDenominator = if (isLandscape) 1 else 2
callControls.viewTreeObserver.addOnGlobalLayoutListener {
if (callControls.height > 0 && previousCallControlHeightData.hasChanged(callControls.height, coordinator.height)) {
previousCallControlHeightData = HeightData(callControls.height, coordinator.height)
controlPeakHeight = callControls.height + callControls.y.toInt()
val controlPeakHeight = callControls.height + callControls.y.toInt() + 16.dp
behavior.peekHeight = controlPeakHeight
frame.minimumHeight = coordinator.height / 2
behavior.maxHeight = (coordinator.height.toFloat() * 0.66f).toInt()
frame.minimumHeight = coordinator.height / minFrameHeightDenominator
behavior.maxHeight = (coordinator.height.toFloat() * maxBehaviorHeightPercentage).toInt()
webRtcCallView.post { onControlTopChanged() }
}
}
behavior.addBottomSheetCallback(object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
overflowPopupWindow.dismiss()
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
controlsAndInfoViewModel.resetScrollState()
if (controlState.isFadeOutEnabled) {
hide(delay = HIDE_CONTROL_DELAY)
}
} else if (newState == BottomSheetBehavior.STATE_EXPANDED || newState == BottomSheetBehavior.STATE_DRAGGING) {
cancelScheduledHide()
} else if (newState == BottomSheetBehavior.STATE_HIDDEN) {
controlsAndInfoViewModel.resetScrollState()
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
callControls.alpha = alphaControls(slideOffset)
callControls.visible = callControls.alpha > 0f
callInfoComposeView.alpha = alphaCallInfo(slideOffset)
callInfoComposeView.translationY = infoTranslationDistance - (infoTranslationDistance * callInfoComposeView.alpha)
}
})
webRtcCallView.addWindowInsetsListener(object : InsetAwareConstraintLayout.WindowInsetsListener {
override fun onApplyWindowInsets(statusBar: Int, navigationBar: Int, parentStart: Int, parentEnd: Int) {
if (navigationBar > 0) {
@@ -210,20 +172,77 @@ class ControlsAndInfoController(
setName(bundle.getString(resultKey)!!)
}
}
}
fun onControlTopChanged() {
val guidelineTop = max(frame.top, coordinator.height - behavior.peekHeight)
aboveControlsGuideline.setGuidelineBegin(guidelineTop)
webRtcCallView.onControlTopChanged()
frame.background = MaterialShapeDrawable(
ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
.setTopRightCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
.build()
).apply {
fillColor = ColorStateList.valueOf(ContextCompat.getColor(webRtcCallActivity, R.color.signal_colorSurface))
}
behavior.isHideable = true
behavior.peekHeight = 0
behavior.state = BottomSheetBehavior.STATE_HIDDEN
BottomSheetBehaviorHack.setNestedScrollingChild(behavior, callInfoComposeView)
behavior.addBottomSheetCallback(object : BottomSheetCallback() {
override fun onStateChanged(bottomSheet: View, newState: Int) {
overflowPopupWindow.dismiss()
if (newState == BottomSheetBehavior.STATE_COLLAPSED) {
controlsAndInfoViewModel.resetScrollState()
if (controlState.isFadeOutEnabled) {
hide(delay = HIDE_CONTROL_DELAY)
}
} else if (newState == BottomSheetBehavior.STATE_EXPANDED || newState == BottomSheetBehavior.STATE_DRAGGING) {
cancelScheduledHide()
} else if (newState == BottomSheetBehavior.STATE_HIDDEN) {
controlsAndInfoViewModel.resetScrollState()
}
}
override fun onSlide(bottomSheet: View, slideOffset: Float) {
updateCallSheetVisibilities(slideOffset)
}
})
callInfoComposeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
CallInfoView.View(viewModel, controlsAndInfoViewModel, this@ControlsAndInfoController, Modifier.nestedScroll(nestedScrollInterop))
}
}
callInfoComposeView.alpha = 0f
callInfoComposeView.translationY = INFO_TRANSLATION_DISTANCE
}
fun addVisibilityListener(listener: BottomSheetVisibilityListener): Boolean {
return bottomSheetVisibilityListeners.add(listener)
}
fun onStateRestored() {
when (behavior.state) {
BottomSheetBehavior.STATE_EXPANDED -> {
showCallInfo()
updateCallSheetVisibilities(1f)
}
BottomSheetBehavior.STATE_HIDDEN -> {
hide()
updateCallSheetVisibilities(-1f)
}
else -> {
showControls()
updateCallSheetVisibilities(0f)
}
}
}
fun showCallInfo() {
cancelScheduledHide()
behavior.isHideable = false
behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
@@ -282,6 +301,20 @@ class ControlsAndInfoController(
hide(delay = HIDE_CONTROL_DELAY)
}
private fun updateCallSheetVisibilities(slideOffset: Float) {
callControls.alpha = alphaControls(slideOffset)
callControls.visible = callControls.alpha > 0f
callInfoComposeView.alpha = alphaCallInfo(slideOffset)
callInfoComposeView.translationY = INFO_TRANSLATION_DISTANCE - (INFO_TRANSLATION_DISTANCE * callInfoComposeView.alpha)
}
private fun onControlTopChanged() {
val guidelineTop = max(frame.top, coordinator.height - behavior.peekHeight)
aboveControlsGuideline.setGuidelineBegin(guidelineTop)
webRtcCallView.onControlTopChanged()
}
private fun showOrHideControlsOnUpdate(previousState: WebRtcControls) {
if (controlState == WebRtcControls.PIP || controlState.displayErrorControls()) {
hide()
@@ -346,34 +379,6 @@ class ControlsAndInfoController(
handler?.removeCallbacks(scheduleHideControlsRunnable)
}
private fun alphaControls(slideOffset: Float): Float {
return if (slideOffset <= CONTROL_FADE_OUT_START) {
1f
} else if (slideOffset >= CONTROL_FADE_OUT_DONE) {
0f
} else {
1f - (1f * (slideOffset - CONTROL_FADE_OUT_START) / (CONTROL_FADE_OUT_DONE - CONTROL_FADE_OUT_START))
}
}
private fun alphaCallInfo(slideOffset: Float): Float {
return if (slideOffset >= INFO_FADE_IN_DONE) {
1f
} else if (slideOffset <= INFO_FADE_IN_START) {
0f
} else {
(1f * (slideOffset - INFO_FADE_IN_START) / (INFO_FADE_IN_DONE - INFO_FADE_IN_START))
}
}
override fun dispose() {
disposables.dispose()
}
override fun isDisposed(): Boolean {
return disposables.isDisposed
}
override fun onShareLinkClicked() {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(webRtcCallActivity)
@@ -461,10 +466,31 @@ class ControlsAndInfoController(
displayEndCall() != previousState.displayEndCall()
}
private fun alphaControls(slideOffset: Float): Float {
return if (slideOffset <= CONTROL_FADE_OUT_START) {
1f
} else if (slideOffset >= CONTROL_FADE_OUT_DONE) {
0f
} else {
1f - (1f * (slideOffset - CONTROL_FADE_OUT_START) / (CONTROL_FADE_OUT_DONE - CONTROL_FADE_OUT_START))
}
}
private fun alphaCallInfo(slideOffset: Float): Float {
return if (slideOffset >= INFO_FADE_IN_DONE) {
1f
} else if (slideOffset <= INFO_FADE_IN_START) {
0f
} else {
(1f * (slideOffset - INFO_FADE_IN_START) / (INFO_FADE_IN_DONE - INFO_FADE_IN_START))
}
}
@Parcelize
private data class HeightData(
val controlHeight: Int = 0,
val coordinatorHeight: Int = 0
) {
) : Parcelable {
fun hasChanged(controlHeight: Int, coordinatorHeight: Int): Boolean {
return controlHeight != this.controlHeight || coordinatorHeight != this.coordinatorHeight
}

View File

@@ -39,6 +39,10 @@ public final class WindowUtil {
clearSystemUiFlags(window, View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
}
public static void clearTranslucentNavigationBar(@NonNull Window window) {
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION);
}
public static void setLightNavigationBar(@NonNull Window window) {
if (Build.VERSION.SDK_INT < 27) return;