Implement new call screen UI/UX.

This commit is contained in:
Alex Hart
2020-04-23 16:20:59 -03:00
committed by Greyson Parrelli
parent 33e3f78be6
commit d5419ec9fa
73 changed files with 2793 additions and 1142 deletions

View File

@@ -6,7 +6,6 @@ import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.drawable.Drawable;
import android.provider.ContactsContract;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
@@ -24,7 +23,6 @@ import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientExporter;
import org.thoughtcrime.securesms.util.AvatarUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;

View File

@@ -0,0 +1,59 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import java.util.List;
final class AudioOutputAdapter extends RecyclerView.Adapter<AudioOutputAdapter.AudioOutputViewHolder> {
private final Consumer<WebRtcAudioOutput> consumer;
private final List<WebRtcAudioOutput> audioOutputs;
AudioOutputAdapter(@NonNull Consumer<WebRtcAudioOutput> consumer, @NonNull List<WebRtcAudioOutput> audioOutputs) {
this.audioOutputs = audioOutputs;
this.consumer = consumer;
}
@Override
public @NonNull AudioOutputViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new AudioOutputViewHolder((TextView) LayoutInflater.from(parent.getContext()).inflate(R.layout.audio_output_adapter_item, parent, false), consumer);
}
@Override
public void onBindViewHolder(@NonNull AudioOutputViewHolder holder, int position) {
WebRtcAudioOutput audioOutput = audioOutputs.get(position);
holder.view.setText(audioOutput.getLabelRes());
holder.view.setCompoundDrawablesRelativeWithIntrinsicBounds(audioOutput.getIconRes(), 0, 0, 0);
}
@Override
public int getItemCount() {
return audioOutputs.size();
}
final static class AudioOutputViewHolder extends RecyclerView.ViewHolder {
private final TextView view;
AudioOutputViewHolder(@NonNull TextView itemView, @NonNull Consumer<WebRtcAudioOutput> consumer) {
super(itemView);
view = itemView;
itemView.setOnClickListener(v -> {
if (getAdapterPosition() != RecyclerView.NO_POSITION) {
consumer.accept(WebRtcAudioOutput.values()[getAdapterPosition()]);
}
});
}
}
}

View File

@@ -0,0 +1,284 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.animation.Animator;
import android.annotation.SuppressLint;
import android.graphics.Point;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.VelocityTracker;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.view.animation.Interpolator;
import androidx.annotation.NonNull;
import androidx.core.view.GestureDetectorCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout;
import java.util.Arrays;
public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestureListener {
private static final float DECELERATION_RATE = 0.99f;
private final ViewGroup parent;
private final View child;
private final int framePadding;
private final int pipWidth;
private final int pipHeight;
private int activePointerId = MotionEvent.INVALID_POINTER_ID;
private float lastTouchX;
private float lastTouchY;
private boolean isDragging;
private boolean isAnimating;
private int extraPaddingTop;
private int extraPaddingBottom;
private double projectionX;
private double projectionY;
private VelocityTracker velocityTracker;
private int maximumFlingVelocity;
@SuppressLint("ClickableViewAccessibility")
public static PictureInPictureGestureHelper applyTo(@NonNull View child) {
TouchInterceptingFrameLayout parent = (TouchInterceptingFrameLayout) child.getParent();
PictureInPictureGestureHelper helper = new PictureInPictureGestureHelper(parent, child);
GestureDetectorCompat gestureDetector = new GestureDetectorCompat(child.getContext(), helper);
parent.setOnInterceptTouchEventListener((event) -> {
if (helper.velocityTracker == null) {
helper.velocityTracker = VelocityTracker.obtain();
}
helper.velocityTracker.addMovement(event);
return false;
});
parent.setOnTouchListener((v, event) -> {
if (helper.velocityTracker != null) {
helper.velocityTracker.recycle();
helper.velocityTracker = null;
}
return false;
});
child.setOnTouchListener((v, event) -> {
boolean handled = gestureDetector.onTouchEvent(event);
if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMasked() == MotionEvent.ACTION_CANCEL) {
if (!handled) {
handled = helper.onGestureFinished(event);
}
if (helper.velocityTracker != null) {
helper.velocityTracker.recycle();
helper.velocityTracker = null;
}
}
return handled;
});
return helper;
}
private PictureInPictureGestureHelper(@NonNull ViewGroup parent, @NonNull View child) {
this.parent = parent;
this.child = child;
this.framePadding = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_frame_padding);
this.pipWidth = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_width);
this.pipHeight = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_height);
this.maximumFlingVelocity = ViewConfiguration.get(child.getContext()).getScaledMaximumFlingVelocity();
}
public void clearVerticalBoundaries() {
setVerticalBoundaries(0, parent.getMeasuredHeight());
}
public void setVerticalBoundaries(int topBoundary, int bottomBoundary) {
extraPaddingTop = topBoundary;
extraPaddingBottom = parent.getMeasuredHeight() - bottomBoundary;
if (isAnimating) {
fling();
} else if (!isDragging) {
onFling(null, null, 0, 0);
}
}
private boolean onGestureFinished(MotionEvent e) {
final int pointerIndex = e.findPointerIndex(activePointerId);
if (e.getActionIndex() == pointerIndex) {
onFling(e, e, 0, 0);
return true;
}
return false;
}
@Override
public boolean onDown(MotionEvent e) {
activePointerId = e.getPointerId(0);
lastTouchX = e.getX(activePointerId) + child.getX();
lastTouchY = e.getY(activePointerId) + child.getY();
isDragging = true;
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
int pointerIndex = e2.findPointerIndex(activePointerId);
float x = e2.getX(pointerIndex) + child.getX();
float y = e2.getY(pointerIndex) + child.getY();
float dx = x - lastTouchX;
float dy = y - lastTouchY;
child.setTranslationX(child.getTranslationX() + dx);
child.setTranslationY(child.getTranslationY() + dy);
lastTouchX = x;
lastTouchY = y;
return true;
}
@Override
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
if (velocityTracker != null) {
velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity);
projectionX = child.getX() + project(velocityTracker.getXVelocity());
projectionY = child.getY() + project(velocityTracker.getYVelocity());
} else {
projectionX = child.getX();
projectionY = child.getY();
}
fling();
return true;
}
private void fling() {
Point projection = new Point((int) projectionX, (int) projectionY);
Point nearestCornerPosition = findNearestCornerPosition(projection);
isAnimating = true;
isDragging = false;
child.animate()
.translationX(getTranslationXForPoint(nearestCornerPosition))
.translationY(getTranslationYForPoint(nearestCornerPosition))
.setDuration(250)
.setInterpolator(new ViscousFluidInterpolator())
.setListener(new AnimationCompleteListener() {
@Override
public void onAnimationEnd(Animator animation) {
isAnimating = false;
}
})
.start();
}
private Point findNearestCornerPosition(Point projection) {
Point maxPoint = null;
double maxDistance = Double.MAX_VALUE;
for (Point point : Arrays.asList(calculateTopLeftCoordinates(),
calculateTopRightCoordinates(parent),
calculateBottomLeftCoordinates(parent),
calculateBottomRightCoordinates(parent)))
{
double distance = distance(point, projection);
if (distance < maxDistance) {
maxDistance = distance;
maxPoint = point;
}
}
return maxPoint;
}
private float getTranslationXForPoint(Point destination) {
return destination.x - child.getLeft();
}
private float getTranslationYForPoint(Point destination) {
return destination.y - child.getTop();
}
private Point calculateTopLeftCoordinates() {
return new Point(framePadding,
framePadding + extraPaddingTop);
}
private Point calculateTopRightCoordinates(@NonNull ViewGroup parent) {
return new Point(parent.getMeasuredWidth() - pipWidth - framePadding,
framePadding + extraPaddingTop);
}
private Point calculateBottomLeftCoordinates(@NonNull ViewGroup parent) {
return new Point(framePadding,
parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom);
}
private Point calculateBottomRightCoordinates(@NonNull ViewGroup parent) {
return new Point(parent.getMeasuredWidth() - pipWidth - framePadding,
parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom);
}
private static float project(float initialVelocity) {
return (initialVelocity / 1000f) * DECELERATION_RATE / (1f - DECELERATION_RATE);
}
private static double distance(Point a, Point b) {
return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2));
}
/** Borrowed from ScrollView */
private static class ViscousFluidInterpolator implements Interpolator {
/** Controls the viscous fluid effect (how much of it). */
private static final float VISCOUS_FLUID_SCALE = 8.0f;
private static final float VISCOUS_FLUID_NORMALIZE;
private static final float VISCOUS_FLUID_OFFSET;
static {
// must be set to 1.0 (used in viscousFluid())
VISCOUS_FLUID_NORMALIZE = 1.0f / viscousFluid(1.0f);
// account for very small floating-point error
VISCOUS_FLUID_OFFSET = 1.0f - VISCOUS_FLUID_NORMALIZE * viscousFluid(1.0f);
}
private static float viscousFluid(float x) {
x *= VISCOUS_FLUID_SCALE;
if (x < 1.0f) {
x -= (1.0f - (float)Math.exp(-x));
} else {
float start = 0.36787944117f; // 1/e == exp(-1)
x = 1.0f - (float)Math.exp(1.0f - x);
x = start + x * (1.0f - start);
}
return x;
}
@Override
public float getInterpolation(float input) {
final float interpolated = VISCOUS_FLUID_NORMALIZE * viscousFluid(input);
if (interpolated > 0) {
return interpolated + VISCOUS_FLUID_OFFSET;
}
return interpolated;
}
}
}

View File

@@ -0,0 +1,28 @@
package org.thoughtcrime.securesms.components.webrtc;
import androidx.annotation.DrawableRes;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
public enum WebRtcAudioOutput {
HANDSET(R.string.WebRtcAudioOutputToggle__phone, R.drawable.ic_phone_right_black_28),
SPEAKER(R.string.WebRtcAudioOutputToggle__speaker, R.drawable.ic_speaker_solid_black_28),
HEADSET(R.string.WebRtcAudioOutputToggle__bluetooth, R.drawable.ic_speaker_bt_solid_black_28);
private final @StringRes int labelRes;
private final @DrawableRes int iconRes;
WebRtcAudioOutput(@StringRes int labelRes, @DrawableRes int iconRes) {
this.labelRes = labelRes;
this.iconRes = iconRes;
}
public int getIconRes() {
return iconRes;
}
public int getLabelRes() {
return labelRes;
}
}

View File

@@ -0,0 +1,163 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import android.os.Bundle;
import android.os.Parcelable;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.AppCompatImageView;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import java.util.Arrays;
import java.util.List;
public class WebRtcAudioOutputToggleButton extends AppCompatImageView {
private static final String STATE_OUTPUT_INDEX = "audio.output.toggle.state.output.index";
private static final String STATE_HEADSET_ENABLED = "audio.output.toggle.state.headset.enabled";
private static final String STATE_PARENT = "audio.output.toggle.state.parent";
private static final int[] OUTPUT_HANDSET = { R.attr.state_handset };
private static final int[] OUTPUT_SPEAKER = { R.attr.state_speaker };
private static final int[] OUTPUT_HEADSET = { R.attr.state_headset };
private static final int[][] OUTPUT_ENUM = { OUTPUT_HANDSET, OUTPUT_SPEAKER, OUTPUT_HEADSET };
private static final List<WebRtcAudioOutput> OUTPUT_MODES = Arrays.asList(WebRtcAudioOutput.HANDSET, WebRtcAudioOutput.SPEAKER, WebRtcAudioOutput.HEADSET);
private static final WebRtcAudioOutput OUTPUT_FALLBACK = WebRtcAudioOutput.HANDSET;
private boolean isHeadsetAvailable;
private int outputIndex;
private OnAudioOutputChangedListener audioOutputChangedListener;
private AlertDialog picker;
public WebRtcAudioOutputToggleButton(Context context) {
this(context, null);
}
public WebRtcAudioOutputToggleButton(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public WebRtcAudioOutputToggleButton(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
super.setOnClickListener((v) -> {
if (isHeadsetAvailable) showPicker();
else setAudioOutput(OUTPUT_MODES.get((outputIndex + 1) % OUTPUT_ENUM.length));
});
}
@Override
public int[] onCreateDrawableState(int extraSpace) {
final int[] extra = OUTPUT_ENUM[outputIndex];
final int[] drawableState = super.onCreateDrawableState(extraSpace + extra.length);
mergeDrawableStates(drawableState, extra);
return drawableState;
}
@Override
public void setOnClickListener(@Nullable OnClickListener l) {
throw new UnsupportedOperationException("This View does not support custom click listeners.");
}
public void setIsHeadsetAvailable(boolean isHeadsetAvailable) {
this.isHeadsetAvailable = isHeadsetAvailable;
setAudioOutput(OUTPUT_MODES.get(outputIndex));
}
public void setAudioOutput(@NonNull WebRtcAudioOutput audioOutput) {
int oldIndex = outputIndex;
outputIndex = resolveAudioOutputIndex(OUTPUT_MODES.indexOf(audioOutput), isHeadsetAvailable);
if (oldIndex != outputIndex) {
refreshDrawableState();
notifyListener();
}
}
public void setOnAudioOutputChangedListener(@Nullable OnAudioOutputChangedListener listener) {
this.audioOutputChangedListener = listener;
}
private void showPicker() {
RecyclerView rv = new RecyclerView(getContext());
rv.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false));
rv.setAdapter(new AudioOutputAdapter(this::setAudioOutputViaDialog, OUTPUT_MODES));
picker = new AlertDialog.Builder(getContext())
.setView(rv)
.show();
}
private void hidePicker() {
if (picker != null) {
picker.dismiss();
picker = null;
}
}
@Override
protected Parcelable onSaveInstanceState() {
Parcelable parentState = super.onSaveInstanceState();
Bundle bundle = new Bundle();
bundle.putParcelable(STATE_PARENT, parentState);
bundle.putInt(STATE_OUTPUT_INDEX, outputIndex);
bundle.putBoolean(STATE_HEADSET_ENABLED, isHeadsetAvailable);
return bundle;
}
@Override
protected void onRestoreInstanceState(Parcelable state) {
if (state instanceof Bundle) {
Bundle savedState = (Bundle) state;
isHeadsetAvailable = savedState.getBoolean(STATE_HEADSET_ENABLED);
setAudioOutput(OUTPUT_MODES.get(
resolveAudioOutputIndex(savedState.getInt(STATE_OUTPUT_INDEX), isHeadsetAvailable))
);
super.onRestoreInstanceState(savedState.getParcelable(STATE_PARENT));
} else {
super.onRestoreInstanceState(state);
}
}
private void notifyListener() {
if (audioOutputChangedListener == null) return;
audioOutputChangedListener.audioOutputChanged(OUTPUT_MODES.get(outputIndex));
}
private void setAudioOutputViaDialog(@NonNull WebRtcAudioOutput audioOutput) {
setAudioOutput(audioOutput);
hidePicker();
}
private static int resolveAudioOutputIndex(int desiredAudioOutputIndex, boolean isHeadsetAvailable) {
if (isIllegalAudioOutputIndex(desiredAudioOutputIndex)) {
throw new IllegalArgumentException("Unsupported index: " + desiredAudioOutputIndex);
}
if (isUnsupportedAudioOutput(desiredAudioOutputIndex, isHeadsetAvailable)) {
return OUTPUT_MODES.indexOf(OUTPUT_FALLBACK);
}
return desiredAudioOutputIndex;
}
private static boolean isIllegalAudioOutputIndex(int desiredFlashIndex) {
return desiredFlashIndex < 0 || desiredFlashIndex > OUTPUT_ENUM.length;
}
private static boolean isUnsupportedAudioOutput(int desiredAudioOutputIndex, boolean isHeadsetAvailable) {
return OUTPUT_MODES.get(desiredAudioOutputIndex) == WebRtcAudioOutput.HEADSET && !isHeadsetAvailable;
}
public interface OnAudioOutputChangedListener {
void audioOutputChanged(WebRtcAudioOutput audioOutput);
}
}

View File

@@ -1,217 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.annotation.TargetApi;
import android.content.Context;
import android.media.AudioManager;
import android.os.Build;
import androidx.annotation.NonNull;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CompoundButton;
import android.widget.LinearLayout;
import com.tomergoldst.tooltips.ToolTip;
import com.tomergoldst.tooltips.ToolTipsManager;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AccessibleToggleButton;
import org.thoughtcrime.securesms.util.ServiceUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
public class WebRtcCallControls extends LinearLayout {
private static final String TAG = WebRtcCallControls.class.getSimpleName();
private AccessibleToggleButton audioMuteButton;
private AccessibleToggleButton videoMuteButton;
private AccessibleToggleButton speakerButton;
private AccessibleToggleButton bluetoothButton;
private AccessibleToggleButton cameraFlipButton;
private boolean cameraFlipAvailable;
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
public WebRtcCallControls(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initialize();
}
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
public WebRtcCallControls(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initialize();
}
public WebRtcCallControls(Context context, AttributeSet attrs) {
super(context, attrs);
initialize();
}
public WebRtcCallControls(Context context) {
super(context);
initialize();
}
private void initialize() {
LayoutInflater inflater = (LayoutInflater)getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
inflater.inflate(R.layout.webrtc_call_controls, this, true);
this.speakerButton = ViewUtil.findById(this, R.id.speakerButton);
this.bluetoothButton = ViewUtil.findById(this, R.id.bluetoothButton);
this.audioMuteButton = ViewUtil.findById(this, R.id.muteButton);
this.videoMuteButton = ViewUtil.findById(this, R.id.video_mute_button);
this.cameraFlipButton = ViewUtil.findById(this, R.id.camera_flip_button);
}
public void setAudioMuteButtonListener(final MuteButtonListener listener) {
audioMuteButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
listener.onToggle(b);
}
});
}
public void setVideoMuteButtonListener(final MuteButtonListener listener) {
videoMuteButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
boolean videoMuted = !isChecked;
listener.onToggle(videoMuted);
cameraFlipButton.setVisibility(!videoMuted && cameraFlipAvailable ? View.VISIBLE : View.GONE);
}
});
}
public void setCameraFlipButtonListener(final CameraFlipButtonListener listener) {
cameraFlipButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
listener.onToggle();
cameraFlipButton.setEnabled(false);
}
});
}
public void setSpeakerButtonListener(final SpeakerButtonListener listener) {
speakerButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
listener.onSpeakerChange(isChecked);
}
});
}
public void setBluetoothButtonListener(final BluetoothButtonListener listener) {
bluetoothButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
listener.onBluetoothChange(isChecked);
}
});
}
public void updateAudioState(boolean isBluetoothAvailable) {
AudioManager audioManager = ServiceUtil.getAudioManager(getContext());
if (!isBluetoothAvailable) {
bluetoothButton.setVisibility(View.GONE);
} else {
bluetoothButton.setVisibility(View.VISIBLE);
}
if (audioManager.isBluetoothScoOn()) {
bluetoothButton.setChecked(true, false);
speakerButton.setChecked(false, false);
} else if (audioManager.isSpeakerphoneOn()) {
speakerButton.setChecked(true, false);
bluetoothButton.setChecked(false, false);
} else {
speakerButton.setChecked(false, false);
bluetoothButton.setChecked(false, false);
}
}
public boolean isVideoEnabled() {
return videoMuteButton.isChecked();
}
public void setVideoEnabled(boolean enabled) {
videoMuteButton.setChecked(enabled, false);
}
public void setVideoAvailable(boolean available) {
videoMuteButton.setVisibility(available ? VISIBLE : GONE);
}
public void setCameraFlipButtonEnabled(boolean enabled) {
cameraFlipButton.setChecked(enabled, false);
}
public void setCameraFlipAvailable(boolean available) {
cameraFlipAvailable = available;
cameraFlipButton.setVisibility(cameraFlipAvailable && isVideoEnabled() ? View.VISIBLE : View.GONE);
}
public void setCameraFlipClickable(boolean clickable) {
setControlEnabled(cameraFlipButton, clickable);
}
public void setMicrophoneEnabled(boolean enabled) {
audioMuteButton.setChecked(!enabled, false);
}
public void setControlsEnabled(boolean enabled) {
setControlEnabled(speakerButton, enabled);
setControlEnabled(bluetoothButton, enabled);
setControlEnabled(videoMuteButton, enabled);
setControlEnabled(cameraFlipButton, enabled);
setControlEnabled(audioMuteButton, enabled);
}
private void setControlEnabled(@NonNull View view, boolean enabled) {
if (enabled) {
view.setAlpha(1.0f);
view.setEnabled(true);
} else {
view.setAlpha(0.3f);
view.setEnabled(false);
}
}
public void displayVideoTooltip(ViewGroup viewGroup) {
if (videoMuteButton.getVisibility() == VISIBLE) {
final ToolTipsManager toolTipsManager = new ToolTipsManager();
ToolTip toolTip = new ToolTip.Builder(getContext(), videoMuteButton, viewGroup,
getContext().getString(R.string.WebRtcCallControls_tap_to_enable_your_video),
ToolTip.POSITION_BELOW).build();
toolTipsManager.show(toolTip);
videoMuteButton.postDelayed(() -> toolTipsManager.findAndDismiss(videoMuteButton), 4000);
}
}
public static interface MuteButtonListener {
public void onToggle(boolean isMuted);
}
public static interface CameraFlipButtonListener {
public void onToggle();
}
public static interface SpeakerButtonListener {
public void onSpeakerChange(boolean isSpeaker);
}
public static interface BluetoothButtonListener {
public void onBluetoothChange(boolean isBluetooth);
}
}

View File

@@ -0,0 +1,25 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.media.AudioManager;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.util.ServiceUtil;
class WebRtcCallRepository {
private final AudioManager audioManager;
WebRtcCallRepository() {
this.audioManager = ServiceUtil.getAudioManager(ApplicationDependencies.getApplication());
}
WebRtcAudioOutput getAudioOutput() {
if (audioManager.isBluetoothScoOn()) {
return WebRtcAudioOutput.HEADSET;
} else if (audioManager.isSpeakerphoneOn()) {
return WebRtcAudioOutput.SPEAKER;
} else {
return WebRtcAudioOutput.HANDSET;
}
}
}

View File

@@ -1,433 +0,0 @@
/*
* Copyright (C) 2016 Open Whisper Systems
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.ringrtc.CameraState;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.VerifySpan;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.webrtc.RendererCommon;
import org.webrtc.SurfaceViewRenderer;
import org.whispersystems.libsignal.IdentityKey;
/**
* A UI widget that encapsulates the entire in-call screen
* for both initiators and responders.
*
* @author Moxie Marlinspike
*
*/
public class WebRtcCallScreen extends FrameLayout implements RecipientForeverObserver {
@SuppressWarnings("unused")
private static final String TAG = WebRtcCallScreen.class.getSimpleName();
private ImageView photo;
private SurfaceViewRenderer localRenderer;
private PercentFrameLayout localRenderLayout;
private PercentFrameLayout remoteRenderLayout;
private PercentFrameLayout localLargeRenderLayout;
private TextView name;
private TextView phoneNumber;
private TextView label;
private TextView elapsedTime;
private View untrustedIdentityContainer;
private TextView untrustedIdentityExplanation;
private Button acceptIdentityButton;
private Button cancelIdentityButton;
private TextView status;
private FloatingActionButton endCallButton;
private WebRtcCallControls controls;
private RelativeLayout expandedInfo;
private ViewGroup callHeader;
private WebRtcAnswerDeclineButton incomingCallButton;
private LiveRecipient recipient;
private boolean minimized;
public WebRtcCallScreen(Context context) {
super(context);
initialize();
}
public WebRtcCallScreen(Context context, AttributeSet attrs) {
super(context, attrs);
initialize();
}
public WebRtcCallScreen(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
initialize();
}
public void setActiveCall(@NonNull Recipient personInfo, @NonNull String message, @Nullable String sas, SurfaceViewRenderer localRenderer, SurfaceViewRenderer remoteRenderer) {
setCard(personInfo, message);
setConnected(localRenderer, remoteRenderer);
incomingCallButton.stopRingingAnimation();
incomingCallButton.setVisibility(View.GONE);
endCallButton.show();
}
public void setActiveCall(@NonNull Recipient personInfo, @NonNull String message, @NonNull SurfaceViewRenderer localRenderer) {
setCard(personInfo, message);
setRinging(localRenderer);
incomingCallButton.stopRingingAnimation();
incomingCallButton.setVisibility(View.GONE);
endCallButton.show();
}
public void setIncomingCall(Recipient personInfo) {
setCard(personInfo, getContext().getString(R.string.CallScreen_Incoming_call));
endCallButton.hide();
incomingCallButton.setVisibility(View.VISIBLE);
incomingCallButton.startRingingAnimation();
}
public void setUntrustedIdentity(Recipient personInfo, IdentityKey untrustedIdentity) {
String name = recipient.get().toShortString(getContext());
String introduction = String.format(getContext().getString(R.string.WebRtcCallScreen_new_safety_numbers), name, name);
SpannableString spannableString = new SpannableString(introduction + " " + getContext().getString(R.string.WebRtcCallScreen_you_may_wish_to_verify_this_contact));
spannableString.setSpan(new VerifySpan(getContext(), personInfo.getId(), untrustedIdentity),
introduction.length()+1, spannableString.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (this.recipient != null) this.recipient.removeForeverObserver(this);
this.recipient = personInfo.live();
this.recipient.observeForever(this);
setPersonInfo(personInfo);
incomingCallButton.stopRingingAnimation();
incomingCallButton.setVisibility(View.GONE);
this.status.setText(R.string.WebRtcCallScreen_new_safety_number_title);
this.untrustedIdentityContainer.setVisibility(View.VISIBLE);
this.untrustedIdentityExplanation.setText(spannableString);
this.untrustedIdentityExplanation.setMovementMethod(LinkMovementMethod.getInstance());
this.endCallButton.hide();
}
public void setIncomingCallActionListener(WebRtcAnswerDeclineButton.AnswerDeclineListener listener) {
incomingCallButton.setAnswerDeclineListener(listener);
}
public void setAudioMuteButtonListener(WebRtcCallControls.MuteButtonListener listener) {
this.controls.setAudioMuteButtonListener(listener);
}
public void setVideoMuteButtonListener(WebRtcCallControls.MuteButtonListener listener) {
this.controls.setVideoMuteButtonListener(listener);
}
public void setCameraFlipButtonListener(WebRtcCallControls.CameraFlipButtonListener listener) {
this.controls.setCameraFlipButtonListener(listener);
}
public void setSpeakerButtonListener(WebRtcCallControls.SpeakerButtonListener listener) {
this.controls.setSpeakerButtonListener(listener);
}
public void setBluetoothButtonListener(WebRtcCallControls.BluetoothButtonListener listener) {
this.controls.setBluetoothButtonListener(listener);
}
public void setHangupButtonListener(final HangupButtonListener listener) {
endCallButton.setOnClickListener(v -> listener.onClick());
}
public void setAcceptIdentityListener(OnClickListener listener) {
this.acceptIdentityButton.setOnClickListener(listener);
}
public void setCancelIdentityButton(OnClickListener listener) {
this.cancelIdentityButton.setOnClickListener(listener);
}
public void updateAudioState(boolean isBluetoothAvailable, boolean isMicrophoneEnabled) {
this.controls.updateAudioState(isBluetoothAvailable);
this.controls.setMicrophoneEnabled(isMicrophoneEnabled);
}
public void setControlsEnabled(boolean enabled) {
this.controls.setControlsEnabled(enabled);
}
public void setLocalVideoState(@NonNull CameraState cameraState, @NonNull SurfaceViewRenderer localRenderer) {
this.controls.setVideoAvailable(cameraState.getCameraCount() > 0);
this.controls.setVideoEnabled(cameraState.isEnabled());
this.controls.setCameraFlipAvailable(cameraState.getCameraCount() > 1);
this.controls.setCameraFlipClickable(cameraState.getActiveDirection() != CameraState.Direction.PENDING);
this.controls.setCameraFlipButtonEnabled(cameraState.getActiveDirection() == CameraState.Direction.BACK);
localRenderer.setMirror(cameraState.getActiveDirection() == CameraState.Direction.FRONT);
localRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
this.localRenderer = localRenderer;
if (localRenderLayout.getChildCount() != 0) {
displayLocalRendererInSmallLayout(!cameraState.isEnabled());
} else {
displayLocalRendererInLargeLayout(!cameraState.isEnabled());
}
localRenderer.setVisibility(cameraState.isEnabled() ? VISIBLE : INVISIBLE);
}
public void setRemoteVideoEnabled(boolean enabled) {
if (enabled && this.remoteRenderLayout.isHidden()) {
this.photo.setVisibility(View.INVISIBLE);
setMinimized(true);
this.remoteRenderLayout.setHidden(false);
this.remoteRenderLayout.requestLayout();
if (localRenderLayout.isHidden()) this.controls.displayVideoTooltip(callHeader);
} else if (!enabled && !this.remoteRenderLayout.isHidden()){
setMinimized(false);
this.photo.setVisibility(View.VISIBLE);
this.remoteRenderLayout.setHidden(true);
this.remoteRenderLayout.requestLayout();
}
}
public boolean isVideoEnabled() {
return controls.isVideoEnabled();
}
private void displayLocalRendererInLargeLayout(boolean hide) {
if (localLargeRenderLayout.getChildCount() == 0) {
localRenderLayout.removeAllViews();
if (localRenderer != null) {
localLargeRenderLayout.addView(localRenderer);
}
}
localRenderLayout.setHidden(true);
localRenderLayout.requestLayout();
localLargeRenderLayout.setHidden(hide);
localLargeRenderLayout.requestLayout();
if (hide) {
photo.setVisibility(View.VISIBLE);
} else {
photo.setVisibility(View.INVISIBLE);
}
}
private void displayLocalRendererInSmallLayout(boolean hide) {
if (localRenderLayout.getChildCount() == 0) {
localLargeRenderLayout.removeAllViews();
if (localRenderer != null) {
localRenderLayout.addView(localRenderer);
}
}
localLargeRenderLayout.setHidden(true);
localLargeRenderLayout.requestLayout();
localRenderLayout.setHidden(hide);
localRenderLayout.requestLayout();
if (remoteRenderLayout.isHidden()) {
photo.setVisibility(View.VISIBLE);
}
}
private void initialize() {
LayoutInflater inflater = (LayoutInflater)getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
inflater.inflate(R.layout.webrtc_call_screen, this, true);
this.elapsedTime = findViewById(R.id.elapsedTime);
this.photo = findViewById(R.id.photo);
this.localRenderLayout = findViewById(R.id.local_render_layout);
this.remoteRenderLayout = findViewById(R.id.remote_render_layout);
this.localLargeRenderLayout = findViewById(R.id.local_large_render_layout);
this.phoneNumber = findViewById(R.id.phoneNumber);
this.name = findViewById(R.id.name);
this.label = findViewById(R.id.label);
this.status = findViewById(R.id.callStateLabel);
this.controls = findViewById(R.id.inCallControls);
this.endCallButton = findViewById(R.id.hangup_fab);
this.incomingCallButton = findViewById(R.id.answer_decline_button);
this.untrustedIdentityContainer = findViewById(R.id.untrusted_layout);
this.untrustedIdentityExplanation = findViewById(R.id.untrusted_explanation);
this.acceptIdentityButton = findViewById(R.id.accept_safety_numbers);
this.cancelIdentityButton = findViewById(R.id.cancel_safety_numbers);
this.expandedInfo = findViewById(R.id.expanded_info);
this.callHeader = findViewById(R.id.call_info_1);
this.localRenderLayout.setHidden(true);
this.remoteRenderLayout.setHidden(true);
this.minimized = false;
this.remoteRenderLayout.setOnClickListener(v -> {
if (!this.remoteRenderLayout.isHidden()) {
setMinimized(!minimized);
}
});
}
private void setRinging(SurfaceViewRenderer localRenderer) {
if (localLargeRenderLayout.getChildCount() == 0) {
if (localRenderer.getParent() != null) {
((ViewGroup)localRenderer.getParent()).removeView(localRenderer);
}
localLargeRenderLayout.setPosition(0, 0, 100, 100);
localRenderer.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
localRenderer.setMirror(true);
localRenderer.setZOrderMediaOverlay(true);
localLargeRenderLayout.addView(localRenderer);
this.localRenderer = localRenderer;
}
}
private void setConnected(SurfaceViewRenderer localRenderer, SurfaceViewRenderer remoteRenderer) {
if (localRenderLayout.getChildCount() == 0) {
if (localRenderer.getParent() != null) {
((ViewGroup)localRenderer.getParent()).removeView(localRenderer);
}
if (remoteRenderer.getParent() != null) {
((ViewGroup)remoteRenderer.getParent()).removeView(remoteRenderer);
}
localRenderLayout.setPosition(7, 70, 25, 25);
remoteRenderLayout.setPosition(0, 0, 100, 100);
localRenderer.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
remoteRenderer.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT));
localRenderer.setMirror(true);
localRenderer.setZOrderMediaOverlay(true);
localRenderLayout.addView(localRenderer);
remoteRenderLayout.addView(remoteRenderer);
this.localRenderer = localRenderer;
}
}
private void setPersonInfo(final @NonNull Recipient recipient) {
GlideApp.with(getContext().getApplicationContext())
.load(recipient.getContactPhoto())
.fallback(recipient.getFallbackContactPhoto().asCallCard(getContext()))
.error(recipient.getFallbackContactPhoto().asCallCard(getContext()))
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(this.photo);
if (FeatureFlags.profileDisplay()) {
this.name.setText(recipient.getDisplayName(getContext()));
if (recipient.getE164().isPresent()) {
this.phoneNumber.setText(recipient.requireE164());
this.phoneNumber.setVisibility(View.VISIBLE);
} else {
this.phoneNumber.setVisibility(View.GONE);
}
} else {
this.name.setText(recipient.getName(getContext()));
if (recipient.getName(getContext()) == null && !recipient.getProfileName().isEmpty()) {
this.phoneNumber.setText(recipient.requireE164() + " (~" + recipient.getProfileName().toString() + ")");
} else {
this.phoneNumber.setText(recipient.requireE164());
}
}
}
private void setCard(Recipient recipient, String status) {
if (this.recipient != null) this.recipient.removeForeverObserver(this);
this.recipient = recipient.live();
this.recipient.observeForever(this);
setPersonInfo(recipient);
this.status.setText(status);
this.untrustedIdentityContainer.setVisibility(View.GONE);
}
private void setMinimized(boolean minimized) {
if (minimized) {
ViewCompat.animate(callHeader).translationY(-1 * expandedInfo.getHeight());
ViewCompat.animate(status).alpha(0);
ViewCompat.animate(endCallButton).translationY(endCallButton.getHeight() + ViewUtil.dpToPx(getContext(), 40));
ViewCompat.animate(endCallButton).alpha(0);
this.minimized = true;
} else {
ViewCompat.animate(callHeader).translationY(0);
ViewCompat.animate(status).alpha(1);
ViewCompat.animate(endCallButton).translationY(0);
ViewCompat.animate(endCallButton).alpha(1).withEndAction(() -> {
// Note: This is to work around an Android bug, see #6225
endCallButton.requestLayout();
});
this.minimized = false;
}
}
@Override
public void onRecipientChanged(@NonNull Recipient recipient) {
setPersonInfo(recipient);
}
public interface HangupButtonListener {
void onClick();
}
}

View File

@@ -0,0 +1,458 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.constraintlayout.widget.Group;
import androidx.core.util.Consumer;
import androidx.transition.AutoTransition;
import androidx.transition.Transition;
import androidx.transition.TransitionManager;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AccessibleToggleButton;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto;
import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.ringrtc.CameraState;
import org.thoughtcrime.securesms.util.AvatarUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.webrtc.RendererCommon;
import org.webrtc.SurfaceViewRenderer;
public class WebRtcCallView extends FrameLayout {
private static final long TRANSITION_DURATION_MILLIS = 250;
private static final FallbackPhotoProvider FALLBACK_PHOTO_PROVIDER = new FallbackPhotoProvider();
public static final int FADE_OUT_DELAY = 5000;
private SurfaceViewRenderer localRenderer;
private Group ongoingCallButtons;
private Group incomingCallButtons;
private Group answerWithVoiceGroup;
private Group topViews;
private View topGradient;
private WebRtcAudioOutputToggleButton speakerToggle;
private AccessibleToggleButton videoToggle;
private AccessibleToggleButton micToggle;
private ViewGroup largeLocalRenderContainer;
private ViewGroup localRenderPipFrame;
private ViewGroup smallLocalRenderContainer;
private ViewGroup remoteRenderContainer;
private TextView recipientName;
private TextView status;
private ConstraintLayout parent;
private AvatarImageView avatar;
private ImageView avatarCard;
private ControlsListener controlsListener;
private RecipientId recipientId;
private CameraState.Direction cameraDirection;
private boolean shouldFadeControls;
private ImageView accept;
private View cameraDirectionToggle;
private PictureInPictureGestureHelper pictureInPictureGestureHelper;
private final Runnable fadeOutRunnable = () -> { if (isAttachedToWindow()) fadeOutControls(); };
public WebRtcCallView(@NonNull Context context) {
this(context, null);
}
public WebRtcCallView(@NonNull Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
LayoutInflater.from(context).inflate(R.layout.webrtc_call_view, this, true);
}
@SuppressWarnings("CodeBlock2Expr")
@Override
protected void onFinishInflate() {
super.onFinishInflate();
ongoingCallButtons = findViewById(R.id.call_screen_in_call_buttons);
incomingCallButtons = findViewById(R.id.call_screen_incoming_call_buttons);
answerWithVoiceGroup = findViewById(R.id.call_screen_answer_with_audio_button);
topViews = findViewById(R.id.call_screen_top_views);
topGradient = findViewById(R.id.call_screen_header_gradient);
speakerToggle = findViewById(R.id.call_screen_speaker_toggle);
videoToggle = findViewById(R.id.call_screen_video_toggle);
micToggle = findViewById(R.id.call_screen_mic_toggle);
localRenderPipFrame = findViewById(R.id.call_screen_pip);
largeLocalRenderContainer = findViewById(R.id.call_screen_large_local_renderer_holder);
smallLocalRenderContainer = findViewById(R.id.call_screen_small_local_renderer_holder);
remoteRenderContainer = findViewById(R.id.call_screen_remote_renderer_holder);
recipientName = findViewById(R.id.call_screen_recipient_name);
status = findViewById(R.id.call_screen_status);
parent = findViewById(R.id.call_screen);
avatar = findViewById(R.id.call_screen_recipient_avatar);
avatarCard = findViewById(R.id.call_screen_recipient_avatar_call_card);
accept = findViewById(R.id.call_screen_answer_call);
cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle);
View hangup = findViewById(R.id.call_screen_end_call);
View downCaret = findViewById(R.id.call_screen_down_arrow);
View decline = findViewById(R.id.call_screen_decline_call);
View answerWithAudio = findViewById(R.id.call_screen_answer_with_audio);
speakerToggle.setOnAudioOutputChangedListener(outputMode -> {
runIfNonNull(controlsListener, listener -> listener.onAudioOutputChanged(outputMode));
});
videoToggle.setOnCheckedChangeListener((v, isOn) -> {
runIfNonNull(controlsListener, listener -> listener.onVideoChanged(isOn));
});
micToggle.setOnCheckedChangeListener((v, isOn) -> {
runIfNonNull(controlsListener, listener -> listener.onMicChanged(isOn));
});
cameraDirectionToggle.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onCameraDirectionChanged));
hangup.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onEndCallPressed));
decline.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onDenyCallPressed));
downCaret.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onDownCaretPressed));
accept.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallPressed));
answerWithAudio.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed));
setOnClickListener(v -> toggleControls());
avatar.setOnClickListener(v -> toggleControls());
pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(localRenderPipFrame);
int statusBarHeight = ViewUtil.getStatusBarHeight(this);
MarginLayoutParams params = (MarginLayoutParams) parent.getLayoutParams();
params.topMargin = statusBarHeight;
parent.setLayoutParams(params);
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
if (shouldFadeControls) {
scheduleFadeOut();
}
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
cancelFadeOut();
}
public void showCameraToggleButton(boolean shouldShowCameraToggleButton) {
cameraDirectionToggle.setVisibility(shouldShowCameraToggleButton ? VISIBLE : GONE);
}
public void setControlsListener(@Nullable ControlsListener controlsListener) {
this.controlsListener = controlsListener;
}
public void setMicEnabled(boolean isMicEnabled) {
micToggle.setChecked(isMicEnabled, false);
}
public void setBluetoothEnabled(boolean isBluetoothEnabled) {
speakerToggle.setIsHeadsetAvailable(isBluetoothEnabled);
}
public void setAudioOutput(WebRtcAudioOutput output) {
speakerToggle.setAudioOutput(output);
}
public void setRemoteVideoEnabled(boolean isRemoteVideoEnabled) {
boolean wasRemoteVideoEnabled = remoteRenderContainer.getVisibility() == View.VISIBLE;
shouldFadeControls = isRemoteVideoEnabled;
if (isRemoteVideoEnabled) {
remoteRenderContainer.setVisibility(View.VISIBLE);
} else {
remoteRenderContainer.setVisibility(View.GONE);
}
if (shouldFadeControls && !wasRemoteVideoEnabled) {
fadeInControls();
} else if (!shouldFadeControls && wasRemoteVideoEnabled) {
fadeOutControls();
cancelFadeOut();
}
}
public void setLocalRenderer(@Nullable SurfaceViewRenderer surfaceViewRenderer) {
if (localRenderer == surfaceViewRenderer) {
return;
}
localRenderer = surfaceViewRenderer;
if (surfaceViewRenderer == null) {
setRenderer(largeLocalRenderContainer, null);
setRenderer(smallLocalRenderContainer, null);
} else {
localRenderer.setMirror(cameraDirection == CameraState.Direction.FRONT);
localRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
}
}
public void setRemoteRenderer(@Nullable SurfaceViewRenderer surfaceViewRenderer) {
setRenderer(remoteRenderContainer, surfaceViewRenderer);
}
public void setLocalRenderState(WebRtcLocalRenderState localRenderState) {
boolean enableZOverlay = localRenderState == WebRtcLocalRenderState.SMALL;
videoToggle.setChecked(localRenderState != WebRtcLocalRenderState.GONE, false);
switch (localRenderState) {
case GONE:
localRenderPipFrame.setVisibility(View.GONE);
largeLocalRenderContainer.setVisibility(View.GONE);
cameraDirectionToggle.animate().setDuration(0).alpha(0f);
setRenderer(largeLocalRenderContainer, null);
setRenderer(smallLocalRenderContainer, null);
break;
case LARGE:
localRenderPipFrame.setVisibility(View.GONE);
largeLocalRenderContainer.setVisibility(View.VISIBLE);
cameraDirectionToggle.animate().setDuration(0).alpha(0f);
if (largeLocalRenderContainer.getChildCount() == 0) {
setRenderer(largeLocalRenderContainer, localRenderer);
}
break;
case SMALL:
localRenderPipFrame.setVisibility(View.VISIBLE);
largeLocalRenderContainer.setVisibility(View.GONE);
cameraDirectionToggle.animate()
.setDuration(450)
.alpha(1f);
if (smallLocalRenderContainer.getChildCount() == 0) {
setRenderer(smallLocalRenderContainer, localRenderer);
}
}
if (localRenderer != null) {
localRenderer.setZOrderMediaOverlay(enableZOverlay);
}
}
public void setCameraDirection(@NonNull CameraState.Direction cameraDirection) {
this.cameraDirection = cameraDirection;
if (localRenderer != null) {
localRenderer.setMirror(cameraDirection == CameraState.Direction.FRONT);
}
}
public void setRecipient(@NonNull Recipient recipient) {
if (recipient.getId() == recipientId) {
return;
}
recipientId = recipient.getId();
recipientName.setText(recipient.getDisplayName(getContext()));
avatar.setFallbackPhotoProvider(FALLBACK_PHOTO_PROVIDER);
avatar.setAvatar(GlideApp.with(this), recipient, false);
AvatarUtil.loadBlurredIconIntoViewBackground(recipient, this);
setRecipientCallCard(recipient);
}
public void showCallCard(boolean showCallCard) {
avatarCard.setVisibility(showCallCard ? VISIBLE : GONE);
avatar.setVisibility(showCallCard ? GONE : VISIBLE);
}
public void setStatus(@NonNull String status) {
this.status.setText(status);
}
public void setWebRtcControls(WebRtcControls webRtcControls) {
answerWithVoiceGroup.setVisibility(View.GONE);
switch (webRtcControls) {
case NONE:
ongoingCallButtons.setVisibility(View.GONE);
incomingCallButtons.setVisibility(View.GONE);
setTopViewsVisibility(View.GONE);
break;
case INCOMING_VIDEO:
answerWithVoiceGroup.setVisibility(View.VISIBLE);
setTopViewsVisibility(View.VISIBLE);
ongoingCallButtons.setVisibility(View.GONE);
incomingCallButtons.setVisibility(View.VISIBLE);
status.setText(R.string.WebRtcCallView__signal_video_call);
accept.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer_with_video));
break;
case INCOMING_AUDIO:
setTopViewsVisibility(View.VISIBLE);
ongoingCallButtons.setVisibility(View.GONE);
incomingCallButtons.setVisibility(View.VISIBLE);
status.setText(R.string.WebRtcCallView__signal_voice_call);
accept.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer));
break;
case RINGING:
setTopViewsVisibility(View.VISIBLE);
incomingCallButtons.setVisibility(View.GONE);
ongoingCallButtons.setVisibility(View.VISIBLE);
break;
case CONNECTED:
setTopViewsVisibility(View.VISIBLE);
incomingCallButtons.setVisibility(View.GONE);
ongoingCallButtons.setVisibility(View.VISIBLE);
post(() -> {
pictureInPictureGestureHelper.setVerticalBoundaries(status.getBottom(), speakerToggle.getTop());
});
}
}
private void setTopViewsVisibility(int visibility) {
topViews.setVisibility(visibility);
topGradient.setVisibility(visibility);
}
public @NonNull View getVideoTooltipTarget() {
return videoToggle;
}
private void toggleControls() {
if (shouldFadeControls) {
if (status.getVisibility() == VISIBLE) {
fadeOutControls();
} else {
fadeInControls();
}
}
}
private void fadeOutControls() {
fadeControls(ConstraintSet.GONE);
controlsListener.onControlsFadeOut();
pictureInPictureGestureHelper.clearVerticalBoundaries();
}
private void fadeInControls() {
fadeControls(ConstraintSet.VISIBLE);
pictureInPictureGestureHelper.setVerticalBoundaries(status.getBottom(), speakerToggle.getTop());
scheduleFadeOut();
}
private void fadeControls(int visibility) {
Transition transition = new AutoTransition().setDuration(TRANSITION_DURATION_MILLIS);
TransitionManager.beginDelayedTransition(parent, transition);
ConstraintSet constraintSet = new ConstraintSet();
constraintSet.clone(parent);
constraintSet.setVisibility(R.id.call_screen_in_call_buttons, visibility);
constraintSet.setVisibility(R.id.call_screen_top_views, visibility);
constraintSet.applyTo(parent);
topGradient.animate()
.alpha(visibility == ConstraintSet.VISIBLE ? 1f : 0f)
.setDuration(TRANSITION_DURATION_MILLIS)
.start();
}
private void scheduleFadeOut() {
cancelFadeOut();
shouldFadeControls = true;
if (getHandler() == null) return;
getHandler().postDelayed(fadeOutRunnable, FADE_OUT_DELAY);
}
private void cancelFadeOut() {
shouldFadeControls = false;
if (getHandler() == null) return;
getHandler().removeCallbacks(fadeOutRunnable);
}
private static void runIfNonNull(@Nullable ControlsListener controlsListener, @NonNull Consumer<ControlsListener> controlsListenerConsumer) {
if (controlsListener != null) {
controlsListenerConsumer.accept(controlsListener);
}
}
private static void setRenderer(@NonNull ViewGroup container, @Nullable View renderer) {
if (renderer == null) {
container.removeAllViews();
return;
}
ViewParent parent = renderer.getParent();
if (parent != null && parent != container) {
((ViewGroup) parent).removeAllViews();
}
if (parent == container) {
return;
}
container.addView(renderer);
}
private void setRecipientCallCard(@NonNull Recipient recipient) {
ContactPhoto contactPhoto = recipient.getContactPhoto();
FallbackContactPhoto fallbackPhoto = recipient.getFallbackContactPhoto(FALLBACK_PHOTO_PROVIDER);
GlideApp.with(this).load(contactPhoto)
.fallback(fallbackPhoto.asCallCard(getContext()))
.error(fallbackPhoto.asCallCard(getContext()))
.diskCacheStrategy(DiskCacheStrategy.ALL)
.into(this.avatarCard);
if (contactPhoto == null) this.avatarCard.setScaleType(ImageView.ScaleType.CENTER_INSIDE);
else this.avatarCard.setScaleType(ImageView.ScaleType.CENTER_CROP);
this.avatarCard.setBackgroundColor(recipient.getColor().toActionBarColor(getContext()));
}
private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider {
@Override
public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() {
return new ResourceContactPhoto(R.drawable.ic_profile_outline_120);
}
}
public interface ControlsListener {
void onControlsFadeOut();
void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput);
void onVideoChanged(boolean isVideoEnabled);
void onMicChanged(boolean isMicEnabled);
void onCameraDirectionChanged();
void onEndCallPressed();
void onDenyCallPressed();
void onAcceptCallWithVoiceOnlyPressed();
void onAcceptCallPressed();
void onDownCaretPressed();
}
}

View File

@@ -0,0 +1,228 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.os.Handler;
import android.os.Looper;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.ringrtc.CameraState;
import org.thoughtcrime.securesms.service.WebRtcCallService;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.livedata.LiveDataPair;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
public class WebRtcCallViewModel extends ViewModel {
private final MutableLiveData<Boolean> remoteVideoEnabled = new MutableLiveData<>(false);
private final MutableLiveData<WebRtcAudioOutput> audioOutput = new MutableLiveData<>();
private final MutableLiveData<Boolean> bluetoothEnabled = new MutableLiveData<>(false);
private final MutableLiveData<Boolean> microphoneEnabled = new MutableLiveData<>(true);
private final MutableLiveData<WebRtcLocalRenderState> localRenderState = new MutableLiveData<>(WebRtcLocalRenderState.GONE);
private final MutableLiveData<Boolean> isInPipMode = new MutableLiveData<>(false);
private final MutableLiveData<Boolean> localVideoEnabled = new MutableLiveData<>(false);
private final MutableLiveData<CameraState.Direction> cameraDirection = new MutableLiveData<>(CameraState.Direction.FRONT);
private final MutableLiveData<Boolean> hasMultipleCameras = new MutableLiveData<>(false);
private final LiveData<Boolean> shouldDisplayLocal = LiveDataUtil.combineLatest(isInPipMode, localVideoEnabled, (a, b) -> !a && b);
private final LiveData<WebRtcLocalRenderState> realLocalRenderState = LiveDataUtil.combineLatest(shouldDisplayLocal, localRenderState, this::getRealLocalRenderState);
private final MutableLiveData<WebRtcControls> webRtcControls = new MutableLiveData<>(WebRtcControls.NONE);
private final LiveData<WebRtcControls> realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, webRtcControls, this::getRealWebRtcControls);
private final SingleLiveEvent<Event> events = new SingleLiveEvent<Event>();
private final MutableLiveData<Long> ellapsed = new MutableLiveData<>(-1L);
private final MutableLiveData<LiveRecipient> liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live());
private boolean canDisplayTooltipIfNeeded = true;
private boolean hasEnabledLocalVideo = false;
private long callConnectedTime = -1;
private Handler ellapsedTimeHandler = new Handler(Looper.getMainLooper());
private boolean answerWithVideoAvailable = false;
private Runnable ellapsedTimeRunnable = this::handleTick;
private final WebRtcCallRepository repository = new WebRtcCallRepository();
public WebRtcCallViewModel() {
audioOutput.setValue(repository.getAudioOutput());
}
public LiveData<Boolean> getRemoteVideoEnabled() {
return Transformations.distinctUntilChanged(remoteVideoEnabled);
}
public LiveData<WebRtcAudioOutput> getAudioOutput() {
return Transformations.distinctUntilChanged(audioOutput);
}
public LiveData<Boolean> getBluetoothEnabled() {
return Transformations.distinctUntilChanged(bluetoothEnabled);
}
public LiveData<Boolean> getMicrophoneEnabled() {
return Transformations.distinctUntilChanged(microphoneEnabled);
}
public LiveData<CameraState.Direction> getCameraDirection() {
return Transformations.distinctUntilChanged(cameraDirection);
}
public LiveData<Boolean> displaySquareCallCard() {
return isInPipMode;
}
public LiveData<WebRtcLocalRenderState> getLocalRenderState() {
return realLocalRenderState;
}
public LiveData<WebRtcControls> getWebRtcControls() {
return realWebRtcControls;
}
public LiveRecipient getRecipient() {
return liveRecipient.getValue();
}
public void setRecipient(@NonNull Recipient recipient) {
liveRecipient.setValue(recipient.live());
}
public LiveData<Event> getEvents() {
return events;
}
public LiveData<Long> getCallTime() {
return Transformations.map(ellapsed, timeInCall -> callConnectedTime == -1 ? -1 : timeInCall);
}
public LiveData<Boolean> isMoreThanOneCameraAvailable() {
return hasMultipleCameras;
}
public boolean isAnswerWithVideoAvailable() {
return answerWithVideoAvailable;
}
@MainThread
public void setIsInPipMode(boolean isInPipMode) {
this.isInPipMode.setValue(isInPipMode);
}
public void onDismissedVideoTooltip() {
canDisplayTooltipIfNeeded = false;
}
@MainThread
public void updateFromWebRtcViewModel(@NonNull WebRtcViewModel webRtcViewModel) {
remoteVideoEnabled.setValue(webRtcViewModel.isRemoteVideoEnabled());
bluetoothEnabled.setValue(webRtcViewModel.isBluetoothAvailable());
audioOutput.setValue(repository.getAudioOutput());
microphoneEnabled.setValue(webRtcViewModel.isMicrophoneEnabled());
if (isValidCameraDirectionForUi(webRtcViewModel.getLocalCameraState().getActiveDirection())) {
cameraDirection.setValue(webRtcViewModel.getLocalCameraState().getActiveDirection());
}
hasMultipleCameras.setValue(webRtcViewModel.getLocalCameraState().getCameraCount() > 0);
localVideoEnabled.setValue(webRtcViewModel.getLocalCameraState().isEnabled());
updateLocalRenderState(webRtcViewModel.getState());
updateWebRtcControls(webRtcViewModel.getState(), webRtcViewModel.isRemoteVideoOffer());
if (webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED && callConnectedTime == -1) {
callConnectedTime = System.currentTimeMillis();
startTimer();
} else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_CONNECTED) {
callConnectedTime = -1;
cancelTimer();
}
if (webRtcViewModel.getLocalCameraState().isEnabled()) {
canDisplayTooltipIfNeeded = false;
hasEnabledLocalVideo = true;
events.setValue(Event.DISMISS_VIDEO_TOOLTIP);
}
// 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(Event.SHOW_VIDEO_TOOLTIP);
}
}
private boolean isValidCameraDirectionForUi(CameraState.Direction direction) {
return direction == CameraState.Direction.FRONT || direction == CameraState.Direction.BACK;
}
private void updateLocalRenderState(WebRtcViewModel.State state) {
if (state == WebRtcViewModel.State.CALL_CONNECTED) {
localRenderState.setValue(WebRtcLocalRenderState.SMALL);
} else {
localRenderState.setValue(WebRtcLocalRenderState.LARGE);
}
}
private void updateWebRtcControls(WebRtcViewModel.State state, boolean isRemoteVideoOffer) {
switch (state) {
case CALL_INCOMING:
webRtcControls.setValue(isRemoteVideoOffer ? WebRtcControls.INCOMING_VIDEO : WebRtcControls.INCOMING_AUDIO);
answerWithVideoAvailable = isRemoteVideoOffer;
break;
case CALL_CONNECTED:
webRtcControls.setValue(WebRtcControls.CONNECTED);
break;
case CALL_OUTGOING:
webRtcControls.setValue(WebRtcControls.RINGING);
break;
default:
webRtcControls.setValue(WebRtcControls.ONGOING);
}
}
private @NonNull WebRtcLocalRenderState getRealLocalRenderState(boolean shouldDisplayLocalVideo, @NonNull WebRtcLocalRenderState state) {
if (shouldDisplayLocalVideo) return state;
else return WebRtcLocalRenderState.GONE;
}
private @NonNull WebRtcControls getRealWebRtcControls(boolean neverDisplayControls, @NonNull WebRtcControls controls) {
if (neverDisplayControls) return WebRtcControls.NONE;
else return controls;
}
private void startTimer() {
cancelTimer();
ellapsedTimeHandler.post(ellapsedTimeRunnable);
}
private void handleTick() {
if (callConnectedTime == -1) {
return;
}
long newValue = (System.currentTimeMillis() - callConnectedTime) / 1000;
ellapsed.postValue(newValue);
ellapsedTimeHandler.postDelayed(ellapsedTimeRunnable, 1000);
}
private void cancelTimer() {
ellapsedTimeHandler.removeCallbacks(ellapsedTimeRunnable);
}
@Override
protected void onCleared() {
super.onCleared();
cancelTimer();
}
public enum Event {
SHOW_VIDEO_TOOLTIP,
DISMISS_VIDEO_TOOLTIP
}
}

View File

@@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.components.webrtc;
public enum WebRtcControls {
NONE,
ONGOING,
RINGING,
CONNECTED,
INCOMING_AUDIO,
INCOMING_VIDEO
}

View File

@@ -0,0 +1,7 @@
package org.thoughtcrime.securesms.components.webrtc;
public enum WebRtcLocalRenderState {
GONE,
SMALL,
LARGE
}