diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java index 24dac2be57..c5d63a8c80 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java @@ -41,6 +41,8 @@ import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import org.thoughtcrime.securesms.components.TooltipPopup; +import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState; +import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog; import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput; import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView; import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel; @@ -52,11 +54,13 @@ import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.WebRtcCallService; +import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.EllapsedTimeFormatter; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.ViewUtil; import org.whispersystems.libsignal.IdentityKey; import org.whispersystems.signalservice.api.messages.calls.HangupMessage; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumberChangeDialog.Callback { @@ -162,7 +166,7 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe } private boolean enterPipModeIfPossible() { - if (isSystemPipEnabledAndAvailable()) { + if (viewModel.canEnterPipMode() && isSystemPipEnabledAndAvailable()) { PictureInPictureParams params = new PictureInPictureParams.Builder() .setAspectRatio(new Rational(9, 16)) .build(); @@ -203,14 +207,11 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe private void initializeViewModel() { viewModel = ViewModelProviders.of(this).get(WebRtcCallViewModel.class); viewModel.setIsInPipMode(isInPipMode()); - viewModel.getRemoteVideoEnabled().observe(this,callScreen::setRemoteVideoEnabled); viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled); - viewModel.getCameraDirection().observe(this, callScreen::setCameraDirection); - viewModel.getLocalRenderState().observe(this, callScreen::setLocalRenderState); viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls); viewModel.getEvents().observe(this, this::handleViewModelEvent); viewModel.getCallTime().observe(this, this::handleCallTime); - viewModel.displaySquareCallCard().observe(this, callScreen::showCallCard); + viewModel.getCallParticipantsState().observe(this, callScreen::updateCallParticipants); } private void handleViewModelEvent(@NonNull WebRtcCallViewModel.Event event) { @@ -375,19 +376,13 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe startService(intent); } - private void handleIncomingCall(@NonNull WebRtcViewModel event) { - callScreen.setRecipient(event.getRecipient()); - } - private void handleOutgoingCall(@NonNull WebRtcViewModel event) { - callScreen.setRecipient(event.getRecipient()); callScreen.setStatus(getString(R.string.WebRtcCallActivity__calling)); } private void handleTerminate(@NonNull Recipient recipient, @NonNull HangupMessage.Type hangupType) { Log.i(TAG, "handleTerminate called: " + hangupType.name()); - callScreen.setRecipient(recipient); callScreen.setStatusFromHangupType(hangupType); EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); @@ -399,32 +394,27 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe } private void handleCallRinging(@NonNull WebRtcViewModel event) { - callScreen.setRecipient(event.getRecipient()); callScreen.setStatus(getString(R.string.RedPhone_ringing)); } private void handleCallBusy(@NonNull WebRtcViewModel event) { EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); - callScreen.setRecipient(event.getRecipient()); callScreen.setStatus(getString(R.string.RedPhone_busy)); delayedFinish(WebRtcCallService.BUSY_TONE_LENGTH); } private void handleCallConnected(@NonNull WebRtcViewModel event) { getWindow().addFlags(WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES); - callScreen.setRecipient(event.getRecipient()); } private void handleRecipientUnavailable(@NonNull WebRtcViewModel event) { EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); - callScreen.setRecipient(event.getRecipient()); callScreen.setStatus(getString(R.string.RedPhone_recipient_unavailable)); delayedFinish(); } private void handleServerFailure(@NonNull WebRtcViewModel event) { EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); - callScreen.setRecipient(event.getRecipient()); callScreen.setStatus(getString(R.string.RedPhone_network_failed)); delayedFinish(); } @@ -452,8 +442,8 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe } private void handleUntrustedIdentity(@NonNull WebRtcViewModel event) { - final IdentityKey theirKey = event.getIdentityKey(); - final Recipient recipient = event.getRecipient(); + final IdentityKey theirKey = event.getRemoteParticipants().get(0).getIdentityKey(); + final Recipient recipient = event.getRemoteParticipants().get(0).getRecipient(); if (theirKey == null) { handleTerminate(recipient, HangupMessage.Type.NORMAL); @@ -493,10 +483,11 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe } @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) - public void onEventMainThread(final WebRtcViewModel event) { + public void onEventMainThread(@NonNull WebRtcViewModel event) { Log.i(TAG, "Got message from service: " + event); viewModel.setRecipient(event.getRecipient()); + callScreen.setRecipient(event.getRecipient()); switch (event.getState()) { case CALL_CONNECTED: handleCallConnected(event); break; @@ -509,16 +500,12 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe case CALL_NEEDS_PERMISSION: handleTerminate(event.getRecipient(), HangupMessage.Type.NEED_PERMISSION); break; case NO_SUCH_USER: handleNoSuchUser(event); break; case RECIPIENT_UNAVAILABLE: handleRecipientUnavailable(event); break; - case CALL_INCOMING: handleIncomingCall(event); break; case CALL_OUTGOING: handleOutgoingCall(event); break; case CALL_BUSY: handleCallBusy(event); break; case UNTRUSTED_IDENTITY: handleUntrustedIdentity(event); break; } - callScreen.setLocalRenderer(event.getLocalRenderer()); - callScreen.setRemoteRenderer(event.getRemoteRenderer()); - - boolean enableVideo = event.getLocalCameraState().getCameraCount() > 0 && enableVideoIfAvailable; + boolean enableVideo = event.getLocalParticipant().getCameraState().getCameraCount() > 0 && enableVideoIfAvailable; viewModel.updateFromWebRtcViewModel(event, enableVideo); @@ -530,6 +517,22 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe private final class ControlsListener implements WebRtcCallView.ControlsListener { + @Override + public void onStartCall() { + Intent intent = new Intent(WebRtcCallActivity.this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_OUTGOING_CALL) + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(viewModel.getRecipient().getId())) + .putExtra(WebRtcCallService.EXTRA_OFFER_TYPE, OfferMessage.Type.VIDEO_CALL.getCode()); + WebRtcCallActivity.this.startService(intent); + + MessageSender.onMessageSent(); + } + + @Override + public void onCancelStartCall() { + finish(); + } + @Override public void onControlsFadeOut() { if (videoTooltip != null) { @@ -594,8 +597,13 @@ public class WebRtcCallActivity extends AppCompatActivity implements SafetyNumbe } @Override - public void onDownCaretPressed() { + public void onShowParticipantsList() { + CallParticipantsListDialog.show(getSupportFragmentManager()); + } + @Override + public void onPageChanged(@NonNull CallParticipantsState.SelectedPage page) { + viewModel.setIsViewingFocusedParticipant(page); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/animation/ResizeAnimation.java b/app/src/main/java/org/thoughtcrime/securesms/animation/ResizeAnimation.java new file mode 100644 index 0000000000..5308484398 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/animation/ResizeAnimation.java @@ -0,0 +1,50 @@ +package org.thoughtcrime.securesms.animation; + +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.Transformation; + +import androidx.annotation.NonNull; + +public class ResizeAnimation extends Animation { + + private final View target; + private final int targetWidthPx; + private final int targetHeightPx; + + private int startWidth; + private int startHeight; + + public ResizeAnimation(@NonNull View target, int targetWidthPx, int targetHeightPx) { + this.target = target; + this.targetWidthPx = targetWidthPx; + this.targetHeightPx = targetHeightPx; + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + int newWidth = (int) (startWidth + (targetWidthPx - startWidth) * interpolatedTime); + int newHeight = (int) (startHeight + (targetHeightPx - startHeight) * interpolatedTime); + + ViewGroup.LayoutParams params = target.getLayoutParams(); + + params.width = newWidth; + params.height = newHeight; + + target.setLayoutParams(params); + } + + @Override + public void initialize(int width, int height, int parentWidth, int parentHeight) { + super.initialize(width, height, parentWidth, parentHeight); + + this.startWidth = width; + this.startHeight = height; + } + + @Override + public boolean willChangeBounds() { + return true; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/BroadcastVideoSink.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/BroadcastVideoSink.java new file mode 100644 index 0000000000..c029934ee7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/BroadcastVideoSink.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.webrtc.EglBase; +import org.webrtc.VideoFrame; +import org.webrtc.VideoSink; + +import java.lang.ref.WeakReference; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.WeakHashMap; + +public class BroadcastVideoSink implements VideoSink { + + private final EglBase eglBase; + private final WeakHashMap sinks; + + public BroadcastVideoSink(@Nullable EglBase eglBase) { + this.eglBase = eglBase; + this.sinks = new WeakHashMap<>(); + } + + public @Nullable EglBase getEglBase() { + return eglBase; + } + + public void addSink(@NonNull VideoSink sink) { + sinks.put(sink, true); + } + + public void removeSink(@NonNull VideoSink sink) { + sinks.remove(sink); + } + + @Override + public void onFrame(@NonNull VideoFrame videoFrame) { + for (VideoSink sink : sinks.keySet()) { + sink.onFrame(videoFrame); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java new file mode 100644 index 0000000000..06aed83cb8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantView.java @@ -0,0 +1,115 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.R; +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.events.CallParticipant; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.AvatarUtil; + +import java.util.Objects; + +/** + * Encapsulates views needed to show a call participant including their + * avatar in full screen or pip mode, and their video feed. + */ +public class CallParticipantView extends ConstraintLayout { + + private static final FallbackPhotoProvider FALLBACK_PHOTO_PROVIDER = new FallbackPhotoProvider(); + + private RecipientId recipientId; + private AvatarImageView avatar; + private TextureViewRenderer renderer; + private ImageView pipAvatar; + private ContactPhoto contactPhoto; + + public CallParticipantView(@NonNull Context context) { + super(context); + onFinishInflate(); + } + + public CallParticipantView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public CallParticipantView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + avatar = findViewById(R.id.call_participant_item_avatar); + pipAvatar = findViewById(R.id.call_participant_item_pip_avatar); + renderer = findViewById(R.id.call_participant_renderer); + + avatar.setFallbackPhotoProvider(FALLBACK_PHOTO_PROVIDER); + } + + void setCallParticipant(@NonNull CallParticipant participant) { + boolean participantChanged = recipientId == null || !recipientId.equals(participant.getRecipient().getId()); + recipientId = participant.getRecipient().getId(); + + renderer.setVisibility(participant.isVideoEnabled() ? View.VISIBLE : View.GONE); + + if (participant.isVideoEnabled()) { + if (participant.getVideoSink().getEglBase() != null) { + renderer.init(participant.getVideoSink().getEglBase()); + } + renderer.attachBroadcastVideoSink(participant.getVideoSink()); + } else { + renderer.attachBroadcastVideoSink(null); + } + + if (participantChanged || !Objects.equals(contactPhoto, participant.getRecipient().getContactPhoto())) { + avatar.setAvatar(participant.getRecipient()); + AvatarUtil.loadBlurredIconIntoViewBackground(participant.getRecipient(), this); + setPipAvatar(participant.getRecipient()); + contactPhoto = participant.getRecipient().getContactPhoto(); + } + } + + void setRenderInPip(boolean shouldRenderInPip) { + avatar.setVisibility(shouldRenderInPip ? View.GONE : View.VISIBLE); + pipAvatar.setVisibility(shouldRenderInPip ? View.VISIBLE : View.GONE); + } + + private void setPipAvatar(@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(pipAvatar); + + pipAvatar.setScaleType(contactPhoto == null ? ImageView.ScaleType.CENTER_INSIDE : ImageView.ScaleType.CENTER_CROP); + pipAvatar.setBackgroundColor(recipient.getColor().toActionBarColor(getContext())); + } + + private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider { + @Override + public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { + ResourceContactPhoto photo = new ResourceContactPhoto(R.drawable.ic_profile_outline_120); + photo.setScaleType(ImageView.ScaleType.CENTER_CROP); + return photo; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsLayout.java new file mode 100644 index 0000000000..51563751cb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsLayout.java @@ -0,0 +1,104 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.flexbox.AlignItems; +import com.google.android.flexbox.FlexboxLayout; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.util.Util; + +import java.util.Collections; +import java.util.List; + +/** + * Can dynamically render a collection of call participants, adjusting their + * sizing and layout depending on the total number of participants. + */ +public class CallParticipantsLayout extends FlexboxLayout { + + private List callParticipants = Collections.emptyList(); + private boolean shouldRenderInPip; + + public CallParticipantsLayout(@NonNull Context context) { + super(context); + } + + public CallParticipantsLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public CallParticipantsLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + void update(@NonNull List callParticipants, boolean shouldRenderInPip) { + this.callParticipants = callParticipants; + this.shouldRenderInPip = shouldRenderInPip; + updateLayout(); + } + + private void updateLayout() { + if (shouldRenderInPip && Util.hasItems(callParticipants)) { + updateChildrenCount(1); + update(0, callParticipants.get(0)); + } else { + int count = callParticipants.size(); + updateChildrenCount(count); + + for (int i = 0; i < callParticipants.size(); i++) { + update(i, callParticipants.get(i)); + } + } + } + + private void updateChildrenCount(int count) { + int childCount = getChildCount(); + if (childCount < count) { + for (int i = childCount; i < count; i++) { + addCallParticipantView(); + } + } else if (childCount > count) { + for (int i = count; i < childCount; i++) { + removeViewAt(count); + } + } + } + + private void update(int index, @NonNull CallParticipant participant) { + CallParticipantView callParticipantView = (CallParticipantView) getChildAt(index); + callParticipantView.setCallParticipant(participant); + callParticipantView.setRenderInPip(shouldRenderInPip); + setChildLayoutParams(callParticipantView, index, getChildCount()); + } + + private void addCallParticipantView() { + View view = LayoutInflater.from(getContext()).inflate(R.layout.call_participant_item, this, false); + FlexboxLayout.LayoutParams params = (FlexboxLayout.LayoutParams) view.getLayoutParams(); + + params.setAlignSelf(AlignItems.STRETCH); + view.setLayoutParams(params); + addView(view); + } + + private void setChildLayoutParams(@NonNull View child, int childPosition, int childCount) { + FlexboxLayout.LayoutParams params = (FlexboxLayout.LayoutParams) child.getLayoutParams(); + if (childCount < 3) { + params.setFlexBasisPercent(1f); + } else { + if ((childCount % 2) != 0 && childPosition == childCount - 1) { + params.setFlexBasisPercent(1f); + } else { + params.setFlexBasisPercent(0.5f); + } + } + child.setLayoutParams(params); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java new file mode 100644 index 0000000000..68306dac90 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.java @@ -0,0 +1,194 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.ringrtc.CameraState; + +import java.util.Collections; +import java.util.List; + +/** + * Represents the state of all participants, remote and local, combined with view state + * needed to properly render the participants. The view state primarily consists of + * if we are in System PIP mode and if we should show our video for an outgoing call. + */ +public final class CallParticipantsState { + + public static final CallParticipantsState STARTING_STATE = new CallParticipantsState(WebRtcViewModel.State.CALL_DISCONNECTED, + Collections.emptyList(), + CallParticipant.createLocal(CameraState.UNKNOWN, new BroadcastVideoSink(null), false), + null, + WebRtcLocalRenderState.GONE, + false, + false, + false); + + private final WebRtcViewModel.State callState; + private final List remoteParticipants; + private final CallParticipant localParticipant; + private final CallParticipant focusedParticipant; + private final WebRtcLocalRenderState localRenderState; + private final boolean isInPipMode; + private final boolean showVideoForOutgoing; + private final boolean isViewingFocusedParticipant; + + public CallParticipantsState(@NonNull WebRtcViewModel.State callState, + @NonNull List remoteParticipants, + @NonNull CallParticipant localParticipant, + @Nullable CallParticipant focusedParticipant, + @NonNull WebRtcLocalRenderState localRenderState, + boolean isInPipMode, + boolean showVideoForOutgoing, + boolean isViewingFocusedParticipant) + { + this.callState = callState; + this.remoteParticipants = remoteParticipants; + this.localParticipant = localParticipant; + this.localRenderState = localRenderState; + this.focusedParticipant = focusedParticipant; + this.isInPipMode = isInPipMode; + this.showVideoForOutgoing = showVideoForOutgoing; + this.isViewingFocusedParticipant = isViewingFocusedParticipant; + } + + public @NonNull List getGridParticipants() { + if (getAllRemoteParticipants().size() > 6) { + return getAllRemoteParticipants().subList(0, 6); + } else { + return getAllRemoteParticipants(); + } + } + + public @NonNull List getListParticipants() { + if (isViewingFocusedParticipant && getAllRemoteParticipants().size() > 1) { + return getAllRemoteParticipants().subList(1, getAllRemoteParticipants().size()); + } else if (getAllRemoteParticipants().size() > 6) { + return getAllRemoteParticipants().subList(6, getAllRemoteParticipants().size()); + } else { + return Collections.emptyList(); + } + } + + public @NonNull List getAllRemoteParticipants() { + return remoteParticipants; + } + + public @NonNull CallParticipant getLocalParticipant() { + return localParticipant; + } + + public @Nullable CallParticipant getFocusedParticipant() { + return focusedParticipant; + } + + public @NonNull WebRtcLocalRenderState getLocalRenderState() { + return localRenderState; + } + + public boolean isInPipMode() { + return isInPipMode; + } + + public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, + @NonNull WebRtcViewModel webRtcViewModel, + boolean enableVideo) + { + boolean newShowVideoForOutgoing = oldState.showVideoForOutgoing; + if (enableVideo) { + newShowVideoForOutgoing = webRtcViewModel.getState() == WebRtcViewModel.State.CALL_OUTGOING; + } else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_OUTGOING) { + newShowVideoForOutgoing = false; + } + + WebRtcLocalRenderState localRenderState = determineLocalRenderMode(webRtcViewModel.getLocalParticipant(), + oldState.isInPipMode, + newShowVideoForOutgoing, + webRtcViewModel.getState(), + oldState.getAllRemoteParticipants().size(), + oldState.isViewingFocusedParticipant); + + CallParticipant focused = oldState.remoteParticipants.isEmpty() ? null : oldState.remoteParticipants.get(0); + + return new CallParticipantsState(webRtcViewModel.getState(), + webRtcViewModel.getRemoteParticipants(), + webRtcViewModel.getLocalParticipant(), + focused, + localRenderState, + oldState.isInPipMode, + newShowVideoForOutgoing, + oldState.isViewingFocusedParticipant); + } + + public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, boolean isInPip) { + WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant, + isInPip, + oldState.showVideoForOutgoing, + oldState.callState, + oldState.getAllRemoteParticipants().size(), + oldState.isViewingFocusedParticipant); + + CallParticipant focused = oldState.remoteParticipants.isEmpty() ? null : oldState.remoteParticipants.get(0); + + return new CallParticipantsState(oldState.callState, + oldState.remoteParticipants, + oldState.localParticipant, + focused, + localRenderState, + isInPip, + oldState.showVideoForOutgoing, + oldState.isViewingFocusedParticipant); + } + + public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, @NonNull SelectedPage selectedPage) { + CallParticipant focused = oldState.remoteParticipants.isEmpty() ? null : oldState.remoteParticipants.get(0); + + WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant, + oldState.isInPipMode, + oldState.showVideoForOutgoing, + oldState.callState, + oldState.getAllRemoteParticipants().size(), + selectedPage == SelectedPage.FOCUSED); + + return new CallParticipantsState(oldState.callState, + oldState.remoteParticipants, + oldState.localParticipant, + focused, + localRenderState, + oldState.isInPipMode, + oldState.showVideoForOutgoing, + selectedPage == SelectedPage.FOCUSED); + } + + private static @NonNull WebRtcLocalRenderState determineLocalRenderMode(@NonNull CallParticipant localParticipant, + boolean isInPip, + boolean showVideoForOutgoing, + @NonNull WebRtcViewModel.State callState, + int numberOfRemoteParticipants, + boolean isViewingFocusedParticipant) + { + boolean displayLocal = !isInPip && localParticipant.isVideoEnabled(); + WebRtcLocalRenderState localRenderState = WebRtcLocalRenderState.GONE; + + if (displayLocal || showVideoForOutgoing) { + if (callState == WebRtcViewModel.State.CALL_CONNECTED) { + if (isViewingFocusedParticipant || numberOfRemoteParticipants > 3) { + localRenderState = WebRtcLocalRenderState.SMALL_SQUARE; + } else { + localRenderState = WebRtcLocalRenderState.SMALL_RECTANGLE; + } + } else { + localRenderState = WebRtcLocalRenderState.LARGE; + } + } + + return localRenderState; + } + + public enum SelectedPage { + GRID, + FOCUSED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java index 45bbec70a2..600ac7ce30 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java @@ -20,6 +20,9 @@ import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout; import java.util.Arrays; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.Queue; public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestureListener { @@ -28,9 +31,9 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu private final ViewGroup parent; private final View child; private final int framePadding; - private final int pipWidth; - private final int pipHeight; + private int pipWidth; + private int pipHeight; private int activePointerId = MotionEvent.INVALID_POINTER_ID; private float lastTouchX; private float lastTouchY; @@ -42,6 +45,8 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu private double projectionY; private VelocityTracker velocityTracker; private int maximumFlingVelocity; + private boolean isLockedToBottomEnd; + private Queue runAfterFling; @SuppressLint("ClickableViewAccessibility") public static PictureInPictureGestureHelper applyTo(@NonNull View child) { @@ -95,6 +100,7 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu 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(); + this.runAfterFling = new LinkedList<>(); } public void clearVerticalBoundaries() { @@ -105,11 +111,7 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu extraPaddingTop = topBoundary - parent.getTop(); extraPaddingBottom = parent.getMeasuredHeight() + parent.getTop() - bottomBoundary; - if (isAnimating) { - fling(); - } else if (!isDragging) { - onFling(null, null, 0, 0); - } + adjustPip(); } private boolean onGestureFinished(MotionEvent e) { @@ -123,12 +125,41 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu return false; } + public void adjustPip() { + pipWidth = child.getMeasuredWidth(); + pipHeight = child.getMeasuredHeight(); + + if (isAnimating) { + fling(); + } else if (!isDragging) { + onFling(null, null, 0, 0); + } + } + + public void lockToBottomEnd() { + isLockedToBottomEnd = true; + } + + public void enableCorners() { + isLockedToBottomEnd = false; + } + + public void performAfterFling(@NonNull Runnable runnable) { + if (isAnimating) { + runAfterFling.add(runnable); + } else { + runnable.run(); + } + } + @Override public boolean onDown(MotionEvent e) { activePointerId = e.getPointerId(0); lastTouchX = e.getX(activePointerId) + child.getX(); lastTouchY = e.getY(activePointerId) + child.getY(); isDragging = true; + pipWidth = child.getMeasuredWidth(); + pipHeight = child.getMeasuredHeight(); return true; } @@ -167,6 +198,13 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu return true; } + @Override + public boolean onSingleTapUp(MotionEvent e) { + child.performClick(); + + return true; + } + private void fling() { Point projection = new Point((int) projectionX, (int) projectionY); Point nearestCornerPosition = findNearestCornerPosition(projection); @@ -183,12 +221,25 @@ public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestu @Override public void onAnimationEnd(Animator animation) { isAnimating = false; + + Iterator afterFlingRunnables = runAfterFling.iterator(); + while (afterFlingRunnables.hasNext()) { + Runnable runnable = afterFlingRunnables.next(); + + runnable.run(); + afterFlingRunnables.remove(); + } } }) .start(); } private Point findNearestCornerPosition(Point projection) { + if (isLockedToBottomEnd) { + return parent.getLayoutDirection() == View.LAYOUT_DIRECTION_LTR ? calculateBottomRightCoordinates(parent) + : calculateBottomLeftCoordinates(parent); + } + Point maxPoint = null; double maxDistance = Double.MAX_VALUE; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java index b017a246a2..5415152a87 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/TextureViewRenderer.java @@ -36,6 +36,8 @@ public class TextureViewRenderer extends TextureView implements TextureView.Surf private boolean enableFixedSize; private int surfaceWidth; private int surfaceHeight; + private boolean isInitialized; + private BroadcastVideoSink attachedVideoSink; public TextureViewRenderer(@NonNull Context context) { super(context); @@ -49,8 +51,12 @@ public class TextureViewRenderer extends TextureView implements TextureView.Surf this.setSurfaceTextureListener(this); } - public void init(@NonNull EglBase.Context sharedContext, @NonNull RendererCommon.RendererEvents rendererEvents) { - this.init(sharedContext, rendererEvents, EglBase.CONFIG_PLAIN, new GlRectDrawer()); + public void init(@NonNull EglBase eglBase) { + if (isInitialized) return; + + isInitialized = true; + + this.init(eglBase.getEglBaseContext(), null, EglBase.CONFIG_PLAIN, new GlRectDrawer()); } public void init(@NonNull EglBase.Context sharedContext, @NonNull RendererCommon.RendererEvents rendererEvents, @NonNull int[] configAttributes, @NonNull RendererCommon.GlDrawer drawer) { @@ -63,6 +69,24 @@ public class TextureViewRenderer extends TextureView implements TextureView.Surf this.eglRenderer.init(sharedContext, this, configAttributes, drawer); } + public void attachBroadcastVideoSink(@Nullable BroadcastVideoSink videoSink) { + if (attachedVideoSink != null) { + attachedVideoSink.removeSink(this); + } + + if (videoSink != null) { + videoSink.addSink(this); + } + + attachedVideoSink = videoSink; + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + release(); + } + public void release() { eglRenderer.release(); } @@ -125,6 +149,9 @@ public class TextureViewRenderer extends TextureView implements TextureView.Surf protected void onMeasure(int widthSpec, int heightSpec) { ThreadUtils.checkIsOnMainThread(); + widthSpec = MeasureSpec.makeMeasureSpec(resolveSizeAndState(0, widthSpec, 0), MeasureSpec.AT_MOST); + heightSpec = MeasureSpec.makeMeasureSpec(resolveSizeAndState(0, heightSpec, 0), MeasureSpec.AT_MOST); + Point size = videoLayoutMeasure.measure(widthSpec, heightSpec, this.rotatedFrameWidth, this.rotatedFrameHeight); setMeasuredDimension(size.x, size.y); @@ -205,7 +232,9 @@ public class TextureViewRenderer extends TextureView implements TextureView.Surf @Override public void onFrame(VideoFrame videoFrame) { - eglRenderer.onFrame(videoFrame); + if (isShown()) { + eglRenderer.onFrame(videoFrame); + } } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsPage.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsPage.java new file mode 100644 index 0000000000..357f94ca48 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsPage.java @@ -0,0 +1,64 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.events.CallParticipant; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +class WebRtcCallParticipantsPage { + + private final List callParticipants; + private final boolean isSpeaker; + private final boolean isRenderInPip; + + static WebRtcCallParticipantsPage forMultipleParticipants(@NonNull List callParticipants, + boolean isRenderInPip) + { + return new WebRtcCallParticipantsPage(callParticipants, false, isRenderInPip); + } + + static WebRtcCallParticipantsPage forSingleParticipant(@NonNull CallParticipant singleParticipant, + boolean isRenderInPip) + { + return new WebRtcCallParticipantsPage(Collections.singletonList(singleParticipant), true, isRenderInPip); + } + + private WebRtcCallParticipantsPage(@NonNull List callParticipants, + boolean isSpeaker, + boolean isRenderInPip) + { + this.callParticipants = callParticipants; + this.isSpeaker = isSpeaker; + this.isRenderInPip = isRenderInPip; + } + + public @NonNull List getCallParticipants() { + return callParticipants; + } + + public boolean isRenderInPip() { + return isRenderInPip; + } + + public boolean isSpeaker() { + return isSpeaker; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + WebRtcCallParticipantsPage that = (WebRtcCallParticipantsPage) o; + return isSpeaker == that.isSpeaker && + isRenderInPip == that.isRenderInPip && + callParticipants.equals(that.callParticipants); + } + + @Override + public int hashCode() { + return Objects.hash(callParticipants, isSpeaker); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsPagerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsPagerAdapter.java new file mode 100644 index 0000000000..c53e201c62 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsPagerAdapter.java @@ -0,0 +1,127 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; + +class WebRtcCallParticipantsPagerAdapter extends ListAdapter { + + private static final int VIEW_TYPE_MULTI = 0; + private static final int VIEW_TYPE_SINGLE = 1; + + private final Runnable onPageClicked; + + WebRtcCallParticipantsPagerAdapter(@NonNull Runnable onPageClicked) { + super(new DiffCallback()); + this.onPageClicked = onPageClicked; + } + + @Override + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { + super.onAttachedToRecyclerView(recyclerView); + recyclerView.setOverScrollMode(View.OVER_SCROLL_NEVER); + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final ViewHolder viewHolder; + + switch (viewType) { + case VIEW_TYPE_SINGLE: + viewHolder = new SingleParticipantViewHolder((CallParticipantView) LayoutInflater.from(parent.getContext()) + .inflate(R.layout.call_participant_item, + parent, + false)); + break; + case VIEW_TYPE_MULTI: + viewHolder = new MultipleParticipantViewHolder((CallParticipantsLayout) LayoutInflater.from(parent.getContext()) + .inflate(R.layout.webrtc_call_participants_layout, + parent, + false)); + break; + default: + throw new IllegalArgumentException("Unsupported viewType: " + viewType); + } + + viewHolder.itemView.setOnClickListener(unused -> onPageClicked.run()); + + return viewHolder; + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.bind(getItem(position)); + } + + @Override + public int getItemViewType(int position) { + return getItem(position).isSpeaker() ? VIEW_TYPE_SINGLE : VIEW_TYPE_MULTI; + } + + static abstract class ViewHolder extends RecyclerView.ViewHolder { + public ViewHolder(@NonNull View itemView) { + super(itemView); + } + + abstract void bind(WebRtcCallParticipantsPage page); + } + + private static class MultipleParticipantViewHolder extends ViewHolder { + + private final CallParticipantsLayout callParticipantsLayout; + + private MultipleParticipantViewHolder(@NonNull CallParticipantsLayout callParticipantsLayout) { + super(callParticipantsLayout); + this.callParticipantsLayout = callParticipantsLayout; + } + + @Override + void bind(WebRtcCallParticipantsPage page) { + callParticipantsLayout.update(page.getCallParticipants(), page.isRenderInPip()); + } + } + + private static class SingleParticipantViewHolder extends ViewHolder { + + private final CallParticipantView callParticipantView; + + private SingleParticipantViewHolder(CallParticipantView callParticipantView) { + super(callParticipantView); + this.callParticipantView = callParticipantView; + + ViewGroup.LayoutParams params = callParticipantView.getLayoutParams(); + + params.height = ViewGroup.LayoutParams.MATCH_PARENT; + params.width = ViewGroup.LayoutParams.MATCH_PARENT; + + callParticipantView.setLayoutParams(params); + } + + + @Override + void bind(WebRtcCallParticipantsPage page) { + callParticipantView.setCallParticipant(page.getCallParticipants().get(0)); + callParticipantView.setRenderInPip(page.isRenderInPip()); + } + } + + private static final class DiffCallback extends DiffUtil.ItemCallback { + @Override + public boolean areItemsTheSame(@NonNull WebRtcCallParticipantsPage oldItem, @NonNull WebRtcCallParticipantsPage newItem) { + return oldItem.isSpeaker() == newItem.isSpeaker(); + } + + @Override + public boolean areContentsTheSame(@NonNull WebRtcCallParticipantsPage oldItem, @NonNull WebRtcCallParticipantsPage newItem) { + return oldItem.equals(newItem); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsRecyclerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsRecyclerAdapter.java new file mode 100644 index 0000000000..381ae18ed8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallParticipantsRecyclerAdapter.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.events.CallParticipant; + +class WebRtcCallParticipantsRecyclerAdapter extends ListAdapter { + + protected WebRtcCallParticipantsRecyclerAdapter() { + super(new DiffCallback()); + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.webrtc_call_participant_recycler_item, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.bind(getItem(position)); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + + private final CallParticipantView callParticipantView; + + ViewHolder(@NonNull View itemView) { + super(itemView); + callParticipantView = itemView.findViewById(R.id.call_participant); + } + + void bind(@NonNull CallParticipant callParticipant) { + callParticipantView.setCallParticipant(callParticipant); + } + } + + private static class DiffCallback extends DiffUtil.ItemCallback { + + @Override + public boolean areItemsTheSame(@NonNull CallParticipant oldItem, @NonNull CallParticipant newItem) { + return oldItem.getRecipient().equals(newItem.getRecipient()); + } + + @Override + public boolean areContentsTheSame(@NonNull CallParticipant oldItem, @NonNull CallParticipant newItem) { + return oldItem.equals(newItem); + } + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java index 30da868690..a90470ef9b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java @@ -5,7 +5,7 @@ import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.view.ViewParent; +import android.view.animation.Animation; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.TextView; @@ -13,60 +13,57 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.Toolbar; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.constraintlayout.widget.ConstraintSet; import androidx.constraintlayout.widget.Guideline; import androidx.core.util.Consumer; +import androidx.recyclerview.widget.RecyclerView; import androidx.transition.AutoTransition; import androidx.transition.Transition; import androidx.transition.TransitionManager; - -import com.bumptech.glide.load.engine.DiskCacheStrategy; +import androidx.viewpager2.widget.MarginPageTransformer; +import androidx.viewpager2.widget.ViewPager2; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.animation.ResizeAnimation; 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.events.CallParticipant; +import org.thoughtcrime.securesms.mediasend.SimpleAnimationListener; 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.SetUtil; import org.thoughtcrime.securesms.util.ViewUtil; import org.webrtc.RendererCommon; import org.whispersystems.signalservice.api.messages.calls.HangupMessage; +import java.util.ArrayList; import java.util.HashSet; +import java.util.List; import java.util.Set; public class WebRtcCallView extends FrameLayout { - 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 FallbackPhotoProvider FALLBACK_PHOTO_PROVIDER = new FallbackPhotoProvider(); + 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; - public static final int FADE_OUT_DELAY = 5000; + public static final int FADE_OUT_DELAY = 5000; + public static final int PIP_RESIZE_DURATION = 300; - private TextureViewRenderer localRenderer; private WebRtcAudioOutputToggleButton audioToggle; private AccessibleToggleButton videoToggle; private AccessibleToggleButton micToggle; - private ViewGroup largeLocalRenderContainer; private ViewGroup localRenderPipFrame; - private ViewGroup smallLocalRenderContainer; - private ViewGroup remoteRenderContainer; + private TextureViewRenderer smallLocalRender; + private View largeLocalRenderFrame; + private TextureViewRenderer largeLocalRender; 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 ImageView answer; private ImageView cameraDirectionToggle; private PictureInPictureGestureHelper pictureInPictureGestureHelper; @@ -74,6 +71,13 @@ public class WebRtcCallView extends FrameLayout { private View answerWithAudio; private View answerWithAudioLabel; private View ongoingFooterGradient; + private View startCallControls; + private ViewPager2 callParticipantsPager; + private RecyclerView callParticipantsRecycler; + private Toolbar toolbar; + + private WebRtcCallParticipantsPagerAdapter pagerAdapter; + private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter; private final Set incomingCallViews = new HashSet<>(); private final Set topViews = new HashSet<>(); @@ -82,7 +86,8 @@ public class WebRtcCallView extends FrameLayout { private WebRtcControls controls = WebRtcControls.NONE; private final Runnable fadeOutRunnable = () -> { - if (isAttachedToWindow() && controls.isFadeOutEnabled()) fadeOutControls(); }; + if (isAttachedToWindow() && controls.isFadeOutEnabled()) fadeOutControls(); + }; public WebRtcCallView(@NonNull Context context) { this(context, null); @@ -99,36 +104,53 @@ public class WebRtcCallView extends FrameLayout { protected void onFinishInflate() { super.onFinishInflate(); - audioToggle = findViewById(R.id.call_screen_speaker_toggle); - videoToggle = findViewById(R.id.call_screen_video_toggle); - micToggle = findViewById(R.id.call_screen_audio_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); - answer = findViewById(R.id.call_screen_answer_call); - cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle); - hangup = findViewById(R.id.call_screen_end_call); - answerWithAudio = findViewById(R.id.call_screen_answer_with_audio); - answerWithAudioLabel = findViewById(R.id.call_screen_answer_with_audio_label); - ongoingFooterGradient = findViewById(R.id.call_screen_ongoing_footer_gradient); + audioToggle = findViewById(R.id.call_screen_speaker_toggle); + videoToggle = findViewById(R.id.call_screen_video_toggle); + micToggle = findViewById(R.id.call_screen_audio_mic_toggle); + localRenderPipFrame = findViewById(R.id.call_screen_pip); + smallLocalRender = findViewById(R.id.call_screen_small_local_renderer); + largeLocalRenderFrame = findViewById(R.id.call_screen_large_local_renderer_frame); + largeLocalRender = findViewById(R.id.call_screen_large_local_renderer); + recipientName = findViewById(R.id.call_screen_recipient_name); + status = findViewById(R.id.call_screen_status); + parent = findViewById(R.id.call_screen); + answer = findViewById(R.id.call_screen_answer_call); + cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle); + hangup = findViewById(R.id.call_screen_end_call); + answerWithAudio = findViewById(R.id.call_screen_answer_with_audio); + answerWithAudioLabel = findViewById(R.id.call_screen_answer_with_audio_label); + ongoingFooterGradient = findViewById(R.id.call_screen_ongoing_footer_gradient); + startCallControls = findViewById(R.id.call_screen_start_call_controls); + callParticipantsPager = findViewById(R.id.call_screen_participants_pager); + callParticipantsRecycler = findViewById(R.id.call_screen_participants_recycler); + toolbar = findViewById(R.id.call_screen_toolbar); View topGradient = findViewById(R.id.call_screen_header_gradient); - View downCaret = findViewById(R.id.call_screen_down_arrow); View decline = findViewById(R.id.call_screen_decline_call); View answerLabel = findViewById(R.id.call_screen_answer_call_label); View declineLabel = findViewById(R.id.call_screen_decline_call_label); View incomingFooterGradient = findViewById(R.id.call_screen_incoming_footer_gradient); Guideline statusBarGuideline = findViewById(R.id.call_screen_status_bar_guideline); + View startCall = findViewById(R.id.call_screen_start_call_start_call); + View cancelStartCall = findViewById(R.id.call_screen_start_call_cancel); - topViews.add(status); + callParticipantsPager.setPageTransformer(new MarginPageTransformer(ViewUtil.dpToPx(4))); + + pagerAdapter = new WebRtcCallParticipantsPagerAdapter(this::toggleControls); + recyclerAdapter = new WebRtcCallParticipantsRecyclerAdapter(); + + callParticipantsPager.setAdapter(pagerAdapter); + callParticipantsRecycler.setAdapter(recyclerAdapter); + + callParticipantsPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() { + @Override + public void onPageSelected(int position) { + runIfNonNull(controlsListener, listener -> listener.onPageChanged(position == 0 ? CallParticipantsState.SelectedPage.GRID : CallParticipantsState.SelectedPage.FOCUSED)); + } + }); + + topViews.add(toolbar); topViews.add(topGradient); - topViews.add(recipientName); incomingCallViews.add(answer); incomingCallViews.add(answerLabel); @@ -158,16 +180,14 @@ public class WebRtcCallView extends FrameLayout { hangup.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onEndCallPressed)); decline.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onDenyCallPressed)); - downCaret.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onDownCaretPressed)); - answer.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallPressed)); answerWithAudio.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed)); - setOnClickListener(v -> toggleControls()); - avatar.setOnClickListener(v -> toggleControls()); - pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(localRenderPipFrame); + startCall.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onStartCall)); + cancelStartCall.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onCancelStartCall)); + int statusBarHeight = ViewUtil.getStatusBarHeight(this); statusBarGuideline.setGuidelineBegin(statusBarHeight); } @@ -195,67 +215,57 @@ public class WebRtcCallView extends FrameLayout { micToggle.setChecked(isMicEnabled, false); } - public void setRemoteVideoEnabled(boolean isRemoteVideoEnabled) { - if (isRemoteVideoEnabled) { - remoteRenderContainer.setVisibility(View.VISIBLE); - } else { - remoteRenderContainer.setVisibility(View.GONE); - } - } + public void updateCallParticipants(@NonNull CallParticipantsState state) { + List pages = new ArrayList<>(2); - public void setLocalRenderer(@Nullable TextureViewRenderer surfaceViewRenderer) { - if (localRenderer == surfaceViewRenderer) { - return; + if (!state.getGridParticipants().isEmpty()) { + pages.add(WebRtcCallParticipantsPage.forMultipleParticipants(state.getGridParticipants(), state.isInPipMode())); } - 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); + if (state.getFocusedParticipant() != null && state.getAllRemoteParticipants().size() > 1) { + pages.add(WebRtcCallParticipantsPage.forSingleParticipant(state.getFocusedParticipant(), state.isInPipMode())); } + + pagerAdapter.submitList(pages); + recyclerAdapter.submitList(state.getListParticipants()); + updateLocalCallParticipant(state.getLocalRenderState(), state.getLocalParticipant()); } - public void setRemoteRenderer(@Nullable TextureViewRenderer remoteRenderer) { - setRenderer(remoteRenderContainer, remoteRenderer); - } + public void updateLocalCallParticipant(@NonNull WebRtcLocalRenderState state, @NonNull CallParticipant localCallParticipant) { + videoToggle.setChecked(state != WebRtcLocalRenderState.GONE, false); - public void setLocalRenderState(WebRtcLocalRenderState localRenderState) { + smallLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT); + largeLocalRender.setMirror(localCallParticipant.getCameraDirection() == CameraState.Direction.FRONT); - videoToggle.setChecked(localRenderState != WebRtcLocalRenderState.GONE, false); + smallLocalRender.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL); + largeLocalRender.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL); - switch (localRenderState) { - case GONE: - localRenderPipFrame.setVisibility(View.GONE); - largeLocalRenderContainer.setVisibility(View.GONE); - setRenderer(largeLocalRenderContainer, null); - setRenderer(smallLocalRenderContainer, null); - break; + if (localCallParticipant.getVideoSink().getEglBase() != null) { + smallLocalRender.init(localCallParticipant.getVideoSink().getEglBase()); + largeLocalRender.init(localCallParticipant.getVideoSink().getEglBase()); + } + + smallLocalRender.attachBroadcastVideoSink(localCallParticipant.getVideoSink()); + largeLocalRender.attachBroadcastVideoSink(localCallParticipant.getVideoSink()); + + switch (state) { case LARGE: + largeLocalRenderFrame.setVisibility(View.VISIBLE); localRenderPipFrame.setVisibility(View.GONE); - largeLocalRenderContainer.setVisibility(View.VISIBLE); - if (largeLocalRenderContainer.getChildCount() == 0) { - setRenderer(largeLocalRenderContainer, localRenderer); - } break; - case SMALL: + case GONE: + largeLocalRenderFrame.setVisibility(View.GONE); + localRenderPipFrame.setVisibility(View.GONE); + break; + case SMALL_RECTANGLE: + largeLocalRenderFrame.setVisibility(View.GONE); localRenderPipFrame.setVisibility(View.VISIBLE); - largeLocalRenderContainer.setVisibility(View.GONE); - - if (smallLocalRenderContainer.getChildCount() == 0) { - setRenderer(smallLocalRenderContainer, localRenderer); - } - } - } - - public void setCameraDirection(@NonNull CameraState.Direction cameraDirection) { - this.cameraDirection = cameraDirection; - - if (localRenderer != null) { - localRenderer.setMirror(cameraDirection == CameraState.Direction.FRONT); + animatePipToRectangle(); + break; + case SMALL_SQUARE: + largeLocalRenderFrame.setVisibility(View.GONE); + localRenderPipFrame.setVisibility(View.VISIBLE); + animatePipToSquare(); } } @@ -265,17 +275,16 @@ public class WebRtcCallView extends FrameLayout { } 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); + if (recipient.isGroup()) { + recipientName.setText(R.string.WebRtcCallView__group_call); + if (toolbar.getMenu().findItem(R.id.menu_group_call_participants_list) == null) { + toolbar.inflateMenu(R.menu.group_call); + toolbar.setOnMenuItemClickListener(unused -> showParticipantsList()); + } + } else { + recipientName.setText(recipient.getDisplayName(getContext())); + } } public void setStatus(@NonNull String status) { @@ -302,11 +311,15 @@ public class WebRtcCallView extends FrameLayout { } } - public void setWebRtcControls(WebRtcControls webRtcControls) { + public void setWebRtcControls(@NonNull WebRtcControls webRtcControls) { Set lastVisibleSet = new HashSet<>(visibleViewSet); visibleViewSet.clear(); + if (webRtcControls.displayStartCallControls()) { + visibleViewSet.add(startCallControls); + } + if (webRtcControls.displayTopViews()) { visibleViewSet.addAll(topViews); } @@ -378,8 +391,39 @@ public class WebRtcCallView extends FrameLayout { return videoToggle; } + private void animatePipToRectangle() { + ResizeAnimation animation = new ResizeAnimation(localRenderPipFrame, ViewUtil.dpToPx(90), ViewUtil.dpToPx(160)); + animation.setDuration(PIP_RESIZE_DURATION); + animation.setAnimationListener(new SimpleAnimationListener() { + @Override + public void onAnimationEnd(Animation animation) { + pictureInPictureGestureHelper.enableCorners(); + pictureInPictureGestureHelper.adjustPip(); + } + }); + + localRenderPipFrame.startAnimation(animation); + } + + private void animatePipToSquare() { + pictureInPictureGestureHelper.lockToBottomEnd(); + + pictureInPictureGestureHelper.performAfterFling(() -> { + ResizeAnimation animation = new ResizeAnimation(localRenderPipFrame, ViewUtil.dpToPx(72), ViewUtil.dpToPx(72)); + animation.setDuration(PIP_RESIZE_DURATION); + animation.setAnimationListener(new SimpleAnimationListener() { + @Override + public void onAnimationEnd(Animation animation) { + pictureInPictureGestureHelper.adjustPip(); + } + }); + + localRenderPipFrame.startAnimation(animation); + }); + } + private void toggleControls() { - if (controls.isFadeOutEnabled() && status.getVisibility() == VISIBLE) { + if (controls.isFadeOutEnabled() && toolbar.getVisibility() == VISIBLE) { fadeOutControls(); } else { fadeInControls(); @@ -458,40 +502,6 @@ public class WebRtcCallView extends FrameLayout { } } - 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 void updateButtonStateForLargeButtons() { cameraDirectionToggle.setImageResource(R.drawable.webrtc_call_screen_camera_toggle); hangup.setImageResource(R.drawable.webrtc_call_screen_hangup); @@ -508,14 +518,14 @@ public class WebRtcCallView extends FrameLayout { audioToggle.setImageResource(R.drawable.webrtc_call_screen_speaker_toggle_small); } - private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider { - @Override - public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { - return new ResourceContactPhoto(R.drawable.ic_profile_outline_120); - } + private boolean showParticipantsList() { + controlsListener.onShowParticipantsList(); + return true; } public interface ControlsListener { + void onStartCall(); + void onCancelStartCall(); void onControlsFadeOut(); void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput); void onVideoChanged(boolean isVideoEnabled); @@ -525,6 +535,7 @@ public class WebRtcCallView extends FrameLayout { void onDenyCallPressed(); void onAcceptCallWithVoiceOnlyPressed(); void onAcceptCallPressed(); - void onDownCaretPressed(); + void onShowParticipantsList(); + void onPageChanged(@NonNull CallParticipantsState.SelectedPage page); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java index 81c843e296..aa2e42fbe6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java @@ -10,60 +10,38 @@ import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Transformations; import androidx.lifecycle.ViewModel; +import org.thoughtcrime.securesms.events.CallParticipant; 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.util.SingleLiveEvent; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; public class WebRtcCallViewModel extends ViewModel { - private final MutableLiveData remoteVideoEnabled = new MutableLiveData<>(false); - private final MutableLiveData microphoneEnabled = new MutableLiveData<>(true); - private final MutableLiveData localRenderState = new MutableLiveData<>(WebRtcLocalRenderState.GONE); - private final MutableLiveData isInPipMode = new MutableLiveData<>(false); - private final MutableLiveData localVideoEnabled = new MutableLiveData<>(false); - private final MutableLiveData cameraDirection = new MutableLiveData<>(CameraState.Direction.FRONT); - private final LiveData shouldDisplayLocal = LiveDataUtil.combineLatest(isInPipMode, localVideoEnabled, (a, b) -> !a && b); - private final LiveData realLocalRenderState = LiveDataUtil.combineLatest(shouldDisplayLocal, localRenderState, this::getRealLocalRenderState); - private final MutableLiveData webRtcControls = new MutableLiveData<>(WebRtcControls.NONE); - private final LiveData realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, webRtcControls, this::getRealWebRtcControls); - private final SingleLiveEvent events = new SingleLiveEvent(); - private final MutableLiveData ellapsed = new MutableLiveData<>(-1L); - private final MutableLiveData liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live()); - - private boolean canDisplayTooltipIfNeeded = true; - private boolean hasEnabledLocalVideo = false; - private boolean showVideoForOutgoing = false; - private long callConnectedTime = -1; - private Handler ellapsedTimeHandler = new Handler(Looper.getMainLooper()); - private boolean answerWithVideoAvailable = false; - private Runnable ellapsedTimeRunnable = this::handleTick; + private final MutableLiveData microphoneEnabled = new MutableLiveData<>(true); + private final MutableLiveData isInPipMode = new MutableLiveData<>(false); + private final MutableLiveData webRtcControls = new MutableLiveData<>(WebRtcControls.NONE); + private final LiveData realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, webRtcControls, this::getRealWebRtcControls); + private final SingleLiveEvent events = new SingleLiveEvent(); + private final MutableLiveData elapsed = new MutableLiveData<>(-1L); + private final MutableLiveData liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live()); + private final MutableLiveData participantsState = new MutableLiveData<>(CallParticipantsState.STARTING_STATE); + private boolean canDisplayTooltipIfNeeded = true; + private boolean hasEnabledLocalVideo = false; + private long callConnectedTime = -1; + private Handler elapsedTimeHandler = new Handler(Looper.getMainLooper()); + private boolean answerWithVideoAvailable = false; + private Runnable elapsedTimeRunnable = this::handleTick; + private boolean canEnterPipMode = false; private final WebRtcCallRepository repository = new WebRtcCallRepository(); - public LiveData getRemoteVideoEnabled() { - return Transformations.distinctUntilChanged(remoteVideoEnabled); - } - public LiveData getMicrophoneEnabled() { return Transformations.distinctUntilChanged(microphoneEnabled); } - public LiveData getCameraDirection() { - return Transformations.distinctUntilChanged(cameraDirection); - } - - public LiveData displaySquareCallCard() { - return isInPipMode; - } - - public LiveData getLocalRenderState() { - return realLocalRenderState; - } - public LiveData getWebRtcControls() { return realWebRtcControls; } @@ -81,7 +59,15 @@ public class WebRtcCallViewModel extends ViewModel { } public LiveData getCallTime() { - return Transformations.map(ellapsed, timeInCall -> callConnectedTime == -1 ? -1 : timeInCall); + return Transformations.map(elapsed, timeInCall -> callConnectedTime == -1 ? -1 : timeInCall); + } + + public LiveData getCallParticipantsState() { + return participantsState; + } + + public boolean canEnterPipMode() { + return canEnterPipMode; } public boolean isAnswerWithVideoAvailable() { @@ -91,6 +77,15 @@ public class WebRtcCallViewModel extends ViewModel { @MainThread public void setIsInPipMode(boolean isInPipMode) { this.isInPipMode.setValue(isInPipMode); + + //noinspection ConstantConditions + participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), isInPipMode)); + } + + @MainThread + public void setIsViewingFocusedParticipant(@NonNull CallParticipantsState.SelectedPage page) { + //noinspection ConstantConditions + participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), page)); } public void onDismissedVideoTooltip() { @@ -99,27 +94,20 @@ public class WebRtcCallViewModel extends ViewModel { @MainThread public void updateFromWebRtcViewModel(@NonNull WebRtcViewModel webRtcViewModel, boolean enableVideo) { - remoteVideoEnabled.setValue(webRtcViewModel.isRemoteVideoEnabled()); - microphoneEnabled.setValue(webRtcViewModel.isMicrophoneEnabled()); + canEnterPipMode = true; - if (isValidCameraDirectionForUi(webRtcViewModel.getLocalCameraState().getActiveDirection())) { - cameraDirection.setValue(webRtcViewModel.getLocalCameraState().getActiveDirection()); - } + CallParticipant localParticipant = webRtcViewModel.getLocalParticipant(); - localVideoEnabled.setValue(webRtcViewModel.getLocalCameraState().isEnabled()); + microphoneEnabled.setValue(localParticipant.isMicrophoneEnabled()); - if (enableVideo) { - showVideoForOutgoing = webRtcViewModel.getState() == WebRtcViewModel.State.CALL_OUTGOING; - } else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_OUTGOING) { - showVideoForOutgoing = false; - } + //noinspection ConstantConditions + participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), webRtcViewModel, enableVideo)); - updateLocalRenderState(webRtcViewModel.getState()); updateWebRtcControls(webRtcViewModel.getState(), - webRtcViewModel.getLocalCameraState().isEnabled(), + localParticipant.getCameraState().isEnabled(), webRtcViewModel.isRemoteVideoEnabled(), webRtcViewModel.isRemoteVideoOffer(), - webRtcViewModel.getLocalCameraState().getCameraCount() > 1, + localParticipant.isMoreThanOneCameraAvailable(), webRtcViewModel.isBluetoothAvailable(), repository.getAudioOutput()); @@ -131,9 +119,9 @@ public class WebRtcCallViewModel extends ViewModel { callConnectedTime = -1; } - if (webRtcViewModel.getLocalCameraState().isEnabled()) { + if (localParticipant.getCameraState().isEnabled()) { canDisplayTooltipIfNeeded = false; - hasEnabledLocalVideo = true; + hasEnabledLocalVideo = true; events.setValue(Event.DISMISS_VIDEO_TOOLTIP); } @@ -144,27 +132,14 @@ public class WebRtcCallViewModel extends ViewModel { } } - 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, + private void updateWebRtcControls(@NonNull WebRtcViewModel.State state, boolean isLocalVideoEnabled, boolean isRemoteVideoEnabled, boolean isRemoteVideoOffer, boolean isMoreThanOneCameraAvailable, boolean isBluetoothAvailable, - WebRtcAudioOutput audioOutput) + @NonNull WebRtcAudioOutput audioOutput) { - final WebRtcControls.CallState callState; switch (state) { @@ -185,20 +160,14 @@ public class WebRtcCallViewModel extends ViewModel { audioOutput)); } - private @NonNull WebRtcLocalRenderState getRealLocalRenderState(boolean shouldDisplayLocalVideo, @NonNull WebRtcLocalRenderState state) { - if (shouldDisplayLocalVideo || showVideoForOutgoing) return state; - else return WebRtcLocalRenderState.GONE; - } - private @NonNull WebRtcControls getRealWebRtcControls(boolean isInPipMode, @NonNull WebRtcControls controls) { - if (isInPipMode) return WebRtcControls.PIP; - else return controls; + return isInPipMode ? WebRtcControls.PIP : controls; } private void startTimer() { cancelTimer(); - ellapsedTimeHandler.post(ellapsedTimeRunnable); + elapsedTimeHandler.post(elapsedTimeRunnable); } private void handleTick() { @@ -208,13 +177,13 @@ public class WebRtcCallViewModel extends ViewModel { long newValue = (System.currentTimeMillis() - callConnectedTime) / 1000; - ellapsed.postValue(newValue); + elapsed.postValue(newValue); - ellapsedTimeHandler.postDelayed(ellapsedTimeRunnable, 1000); + elapsedTimeHandler.postDelayed(elapsedTimeRunnable, 1000); } private void cancelTimer() { - ellapsedTimeHandler.removeCallbacks(ellapsedTimeRunnable); + elapsedTimeHandler.removeCallbacks(elapsedTimeRunnable); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java index 4472a00c49..ea72165b6d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java @@ -36,6 +36,10 @@ public final class WebRtcControls { this.audioOutput = audioOutput; } + boolean displayStartCallControls() { + return false; + } + boolean displayEndCall() { return isOngoing(); } @@ -88,7 +92,7 @@ public final class WebRtcControls { return !isInPipMode; } - WebRtcAudioOutput getAudioOutput() { + @NonNull WebRtcAudioOutput getAudioOutput() { return audioOutput; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java index 6e4873b6de..13f5fa2d93 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.webrtc; public enum WebRtcLocalRenderState { GONE, - SMALL, + SMALL_RECTANGLE, + SMALL_SQUARE, LARGE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewHolder.java new file mode 100644 index 0000000000..bb792fb576 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewHolder.java @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.components.webrtc.participantslist; + +import android.view.View; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder; + +public class CallParticipantViewHolder extends RecipientViewHolder { + public CallParticipantViewHolder(@NonNull View itemView) { + super(itemView, null); + } + + @Override + public void bind(@NonNull CallParticipantViewState model) { + super.bind(model); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewState.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewState.java new file mode 100644 index 0000000000..579c31b91d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantViewState.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms.components.webrtc.participantslist; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel; + +public final class CallParticipantViewState extends RecipientMappingModel { + + private final CallParticipant callParticipant; + + CallParticipantViewState(@NonNull CallParticipant callParticipant) { + this.callParticipant = callParticipant; + } + + @Override + public @NonNull Recipient getRecipient() { + return callParticipant.getRecipient(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListAdapter.java new file mode 100644 index 0000000000..33002292b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListAdapter.java @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.components.webrtc.participantslist; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingAdapter; + +public class CallParticipantsListAdapter extends MappingAdapter { + + CallParticipantsListAdapter() { + registerFactory(CallParticipantsListHeader.class, new LayoutFactory<>(CallParticipantsListHeaderViewHolder::new, R.layout.call_participants_list_header)); + registerFactory(CallParticipantViewState.class, new LayoutFactory<>(CallParticipantViewHolder::new, R.layout.call_participants_list_item)); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListDialog.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListDialog.java new file mode 100644 index 0000000000..0d6a7b96a6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListDialog.java @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.components.webrtc.participantslist; + +import android.os.Bundle; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.bottomsheet.BottomSheetDialogFragment; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState; +import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel; +import org.thoughtcrime.securesms.events.CallParticipant; +import org.thoughtcrime.securesms.util.BottomSheetUtil; +import org.thoughtcrime.securesms.util.MappingModel; + +import java.util.ArrayList; +import java.util.List; + +public class CallParticipantsListDialog extends BottomSheetDialogFragment { + + private RecyclerView participantList; + private CallParticipantsListAdapter adapter; + + public static void show(@NonNull FragmentManager manager) { + CallParticipantsListDialog fragment = new CallParticipantsListDialog(); + + fragment.show(manager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); + } + + @Override + public void show(@NonNull FragmentManager manager, @Nullable String tag) { + BottomSheetUtil.show(manager, tag, this); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + setStyle(DialogFragment.STYLE_NORMAL, R.style.Theme_Signal_RoundedBottomSheet); + super.onCreate(savedInstanceState); + } + + @Override + public @NonNull View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + ContextThemeWrapper contextThemeWrapper = new ContextThemeWrapper(inflater.getContext(), R.style.TextSecure_DarkTheme); + LayoutInflater themedInflater = LayoutInflater.from(contextThemeWrapper); + + participantList = (RecyclerView) themedInflater.inflate(R.layout.call_participants_list_dialog, container, false); + + return participantList; + } + + @Override + public void onActivityCreated(@Nullable Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + final WebRtcCallViewModel viewModel = ViewModelProviders.of(requireActivity()).get(WebRtcCallViewModel.class); + + initializeList(); + + viewModel.getCallParticipantsState().observe(getViewLifecycleOwner(), this::updateList); + } + + private void initializeList() { + adapter = new CallParticipantsListAdapter(); + + participantList.setLayoutManager(new LinearLayoutManager(requireContext())); + participantList.setAdapter(adapter); + } + + private void updateList(@NonNull CallParticipantsState callParticipantsState) { + List> items = new ArrayList<>(); + + items.add(new CallParticipantsListHeader(callParticipantsState.getAllRemoteParticipants().size() + 1)); + + items.add(new CallParticipantViewState(callParticipantsState.getLocalParticipant())); + for (CallParticipant callParticipant : callParticipantsState.getAllRemoteParticipants()) { + items.add(new CallParticipantViewState(callParticipant)); + } + + adapter.submitList(items); + } + +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeader.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeader.java new file mode 100644 index 0000000000..5e46b44690 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeader.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.components.webrtc.participantslist; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingModel; + +public class CallParticipantsListHeader implements MappingModel { + + private int participantCount; + + public CallParticipantsListHeader(int participantCount) { + this.participantCount = participantCount; + } + + @NonNull String getHeader(@NonNull Context context) { + return context.getResources().getQuantityString(R.plurals.CallParticipantsListDialog_in_this_call_d_people, participantCount, participantCount); + } + + @Override + public boolean areItemsTheSame(@NonNull CallParticipantsListHeader newItem) { + return true; + } + + @Override + public boolean areContentsTheSame(@NonNull CallParticipantsListHeader newItem) { + return participantCount == newItem.participantCount; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeaderViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeaderViewHolder.java new file mode 100644 index 0000000000..3d5ec7c04d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/participantslist/CallParticipantsListHeaderViewHolder.java @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.components.webrtc.participantslist; + +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.MappingViewHolder; + +public class CallParticipantsListHeaderViewHolder extends MappingViewHolder { + + private final TextView headerText; + + public CallParticipantsListHeaderViewHolder(@NonNull View itemView) { + super(itemView); + headerText = findViewById(R.id.call_participants_list_header); + } + + @Override + public void bind(@NonNull CallParticipantsListHeader model) { + headerText.setText(model.getHeader(getContext())); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java index 1d69397aac..68ba94386b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/avatars/ResourceContactPhoto.java @@ -8,6 +8,8 @@ import android.graphics.drawable.LayerDrawable; import android.widget.ImageView; import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; import com.amulyakhare.textdrawable.TextDrawable; @@ -22,6 +24,8 @@ public class ResourceContactPhoto implements FallbackContactPhoto { private final int smallResourceId; private final int callCardResourceId; + private ImageView.ScaleType scaleType = ImageView.ScaleType.CENTER; + public ResourceContactPhoto(@DrawableRes int resourceId) { this(resourceId, resourceId, resourceId); } @@ -36,26 +40,31 @@ public class ResourceContactPhoto implements FallbackContactPhoto { this.smallResourceId = smallResourceId; } + public void setScaleType(@NonNull ImageView.ScaleType scaleType) { + this.scaleType = scaleType; + } + @Override - public Drawable asDrawable(Context context, int color) { + public @NonNull Drawable asDrawable(@NonNull Context context, int color) { return asDrawable(context, color, false); } @Override - public Drawable asDrawable(Context context, int color, boolean inverted) { + public @NonNull Drawable asDrawable(@NonNull Context context, int color, boolean inverted) { return buildDrawable(context, resourceId, color, inverted); } @Override - public Drawable asSmallDrawable(Context context, int color, boolean inverted) { + public @NonNull Drawable asSmallDrawable(@NonNull Context context, int color, boolean inverted) { return buildDrawable(context, smallResourceId, color, inverted); } - private Drawable buildDrawable(Context context, int resourceId, int color, boolean inverted) { + private @NonNull Drawable buildDrawable(@NonNull Context context, int resourceId, int color, boolean inverted) { Drawable background = TextDrawable.builder().buildRound(" ", inverted ? Color.WHITE : color); RoundedDrawable foreground = (RoundedDrawable) RoundedDrawable.fromDrawable(AppCompatResources.getDrawable(context, resourceId)); - foreground.setScaleType(ImageView.ScaleType.CENTER); + //noinspection ConstantConditions + foreground.setScaleType(scaleType); if (inverted) { foreground.setColorFilter(color, PorterDuff.Mode.SRC_ATOP); @@ -68,12 +77,12 @@ public class ResourceContactPhoto implements FallbackContactPhoto { } @Override - public Drawable asCallCard(Context context) { + public @Nullable Drawable asCallCard(@NonNull Context context) { return AppCompatResources.getDrawable(context, callCardResourceId); } private static class ExpandingLayerDrawable extends LayerDrawable { - public ExpandingLayerDrawable(Drawable[] layers) { + public ExpandingLayerDrawable(@NonNull Drawable[] layers) { super(layers); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewHolder.java deleted file mode 100644 index b05b899fc0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewHolder.java +++ /dev/null @@ -1,48 +0,0 @@ -package org.thoughtcrime.securesms.conversation.ui.mentions; - -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.components.AvatarImageView; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.MappingAdapter; -import org.thoughtcrime.securesms.util.MappingViewHolder; - -public class MentionViewHolder extends MappingViewHolder { - - private final AvatarImageView avatar; - private final TextView name; - - @Nullable private final MentionEventsListener mentionEventsListener; - - public MentionViewHolder(@NonNull View itemView, @Nullable MentionEventsListener mentionEventsListener) { - super(itemView); - this.mentionEventsListener = mentionEventsListener; - - avatar = findViewById(R.id.mention_recipient_avatar); - name = findViewById(R.id.mention_recipient_name); - } - - @Override - public void bind(@NonNull MentionViewState model) { - avatar.setRecipient(model.getRecipient()); - name.setText(model.getName(context)); - itemView.setOnClickListener(v -> { - if (mentionEventsListener != null) { - mentionEventsListener.onMentionClicked(model.getRecipient()); - } - }); - } - - public interface MentionEventsListener { - void onMentionClicked(@NonNull Recipient recipient); - } - - public static MappingAdapter.Factory createFactory(@Nullable MentionEventsListener mentionEventsListener) { - return new MappingAdapter.LayoutFactory<>(view -> new MentionViewHolder(view, mentionEventsListener), R.layout.mentions_picker_recipient_list_item); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewState.java index 8a596c5350..72f9a75429 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionViewState.java @@ -1,17 +1,11 @@ package org.thoughtcrime.securesms.conversation.ui.mentions; -import android.content.Context; - import androidx.annotation.NonNull; -import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.MappingModel; -import org.thoughtcrime.securesms.util.Util; +import org.thoughtcrime.securesms.util.viewholders.RecipientMappingModel; -import java.util.Objects; - -public final class MentionViewState implements MappingModel { +public final class MentionViewState extends RecipientMappingModel { private final Recipient recipient; @@ -19,23 +13,8 @@ public final class MentionViewState implements MappingModel { this.recipient = recipient; } - @NonNull String getName(@NonNull Context context) { - return recipient.getDisplayName(context); - } - - @NonNull Recipient getRecipient() { + @Override + public @NonNull Recipient getRecipient() { return recipient; } - - @Override - public boolean areItemsTheSame(@NonNull MentionViewState newItem) { - return recipient.getId().equals(newItem.recipient.getId()); - } - - @Override - public boolean areContentsTheSame(@NonNull MentionViewState newItem) { - Context context = ApplicationDependencies.getApplication(); - return recipient.getDisplayName(context).equals(newItem.recipient.getDisplayName(context)) && - Objects.equals(recipient.getProfileAvatar(), newItem.recipient.getProfileAvatar()); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerAdapter.java index eb4b3be6c2..06682cad3b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/mentions/MentionsPickerAdapter.java @@ -3,18 +3,20 @@ package org.thoughtcrime.securesms.conversation.ui.mentions; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import org.thoughtcrime.securesms.conversation.ui.mentions.MentionViewHolder.MentionEventsListener; +import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.util.MappingAdapter; import org.thoughtcrime.securesms.util.MappingModel; +import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder; +import org.thoughtcrime.securesms.util.viewholders.RecipientViewHolder.EventListener; import java.util.List; public class MentionsPickerAdapter extends MappingAdapter { private final Runnable currentListChangedListener; - public MentionsPickerAdapter(@Nullable MentionEventsListener mentionEventsListener, @NonNull Runnable currentListChangedListener) { + public MentionsPickerAdapter(@Nullable EventListener listener, @NonNull Runnable currentListChangedListener) { this.currentListChangedListener = currentListChangedListener; - registerFactory(MentionViewState.class, MentionViewHolder.createFactory(mentionEventsListener)); + registerFactory(MentionViewState.class, RecipientViewHolder.createFactory(R.layout.mentions_picker_recipient_list_item, listener)); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.java b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.java new file mode 100644 index 0000000000..aa5ad1a341 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipant.java @@ -0,0 +1,117 @@ +package org.thoughtcrime.securesms.events; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.ringrtc.CameraState; +import org.whispersystems.libsignal.IdentityKey; + +import java.util.Objects; + +public class CallParticipant { + + private final @NonNull CameraState cameraState; + private final @NonNull Recipient recipient; + private final @Nullable IdentityKey identityKey; + private final @NonNull BroadcastVideoSink videoSink; + private final boolean videoEnabled; + private final boolean microphoneEnabled; + + public static @NonNull CallParticipant createLocal(@NonNull CameraState cameraState, + @NonNull BroadcastVideoSink renderer, + boolean microphoneEnabled) + { + return new CallParticipant(Recipient.self(), + null, + renderer, + cameraState, + cameraState.isEnabled() && cameraState.getCameraCount() > 0, + microphoneEnabled); + } + + public static @NonNull CallParticipant createRemote(@NonNull Recipient recipient, + @Nullable IdentityKey identityKey, + @NonNull BroadcastVideoSink renderer, + boolean videoEnabled) + { + return new CallParticipant(recipient, identityKey, renderer, CameraState.UNKNOWN, videoEnabled, true); + } + + private CallParticipant(@NonNull Recipient recipient, + @Nullable IdentityKey identityKey, + @NonNull BroadcastVideoSink videoSink, + @NonNull CameraState cameraState, + boolean videoEnabled, + boolean microphoneEnabled) + { + this.recipient = recipient; + this.identityKey = identityKey; + this.videoSink = videoSink; + this.cameraState = cameraState; + this.videoEnabled = videoEnabled; + this.microphoneEnabled = microphoneEnabled; + } + + public @NonNull CallParticipant withIdentityKey(@NonNull IdentityKey identityKey) { + return new CallParticipant(recipient, identityKey, videoSink, cameraState, videoEnabled, microphoneEnabled); + } + + public @NonNull CallParticipant withVideoEnabled(boolean videoEnabled) { + return new CallParticipant(recipient, identityKey, videoSink, cameraState, videoEnabled, microphoneEnabled); + } + + public @NonNull Recipient getRecipient() { + return recipient; + } + + public @Nullable IdentityKey getIdentityKey() { + return identityKey; + } + + public @NonNull BroadcastVideoSink getVideoSink() { + return videoSink; + } + + public @NonNull CameraState getCameraState() { + return cameraState; + } + + public boolean isVideoEnabled() { + return videoEnabled; + } + + public boolean isMicrophoneEnabled() { + return microphoneEnabled; + } + + public @NonNull CameraState.Direction getCameraDirection() { + if (cameraState.getActiveDirection() == CameraState.Direction.BACK) { + return cameraState.getActiveDirection(); + } + return CameraState.Direction.FRONT; + } + + public boolean isMoreThanOneCameraAvailable() { + return cameraState.getCameraCount() > 1; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CallParticipant that = (CallParticipant) o; + return videoEnabled == that.videoEnabled && + microphoneEnabled == that.microphoneEnabled && + cameraState.equals(that.cameraState) && + recipient.equals(that.recipient) && + Objects.equals(identityKey, that.identityKey) && + Objects.equals(videoSink, that.videoSink); + } + + @Override + public int hashCode() { + return Objects.hash(cameraState, recipient, identityKey, videoSink, videoEnabled, microphoneEnabled); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java index 0a154a25e6..08dfdff91d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java @@ -1,13 +1,14 @@ package org.thoughtcrime.securesms.events; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import org.thoughtcrime.securesms.components.webrtc.TextureViewRenderer; +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.ringrtc.CameraState; -import org.webrtc.SurfaceViewRenderer; -import org.whispersystems.libsignal.IdentityKey; + +import java.util.List; public class WebRtcViewModel { @@ -33,70 +34,34 @@ public class WebRtcViewModel { CALL_ONGOING_ELSEWHERE } - - private final @NonNull State state; - private final @NonNull Recipient recipient; - private final @Nullable IdentityKey identityKey; - - private final boolean remoteVideoEnabled; + private final @NonNull State state; + private final @NonNull Recipient recipient; private final boolean isBluetoothAvailable; - private final boolean isMicrophoneEnabled; private final boolean isRemoteVideoOffer; + private final long callConnectedTime; - private final CameraState localCameraState; - private final TextureViewRenderer localRenderer; - private final TextureViewRenderer remoteRenderer; + private final CallParticipant localParticipant; + private final List remoteParticipants; - private final long callConnectedTime; - - public WebRtcViewModel(@NonNull State state, - @NonNull Recipient recipient, - @NonNull CameraState localCameraState, - @NonNull TextureViewRenderer localRenderer, - @NonNull TextureViewRenderer remoteRenderer, - boolean remoteVideoEnabled, - boolean isBluetoothAvailable, - boolean isMicrophoneEnabled, - boolean isRemoteVideoOffer, - long callConnectedTime) - { - this(state, - recipient, - null, - localCameraState, - localRenderer, - remoteRenderer, - remoteVideoEnabled, - isBluetoothAvailable, - isMicrophoneEnabled, - isRemoteVideoOffer, - callConnectedTime); - } - - public WebRtcViewModel(@NonNull State state, - @NonNull Recipient recipient, - @Nullable IdentityKey identityKey, - @NonNull CameraState localCameraState, - @NonNull TextureViewRenderer localRenderer, - @NonNull TextureViewRenderer remoteRenderer, - boolean remoteVideoEnabled, - boolean isBluetoothAvailable, - boolean isMicrophoneEnabled, - boolean isRemoteVideoOffer, - long callConnectedTime) + public WebRtcViewModel(@NonNull State state, + @NonNull Recipient recipient, + @NonNull CameraState localCameraState, + @NonNull BroadcastVideoSink localSink, + boolean isBluetoothAvailable, + boolean isMicrophoneEnabled, + boolean isRemoteVideoOffer, + long callConnectedTime, + @NonNull List remoteParticipants) { this.state = state; this.recipient = recipient; - this.localCameraState = localCameraState; - this.localRenderer = localRenderer; - this.remoteRenderer = remoteRenderer; - this.identityKey = identityKey; - this.remoteVideoEnabled = remoteVideoEnabled; this.isBluetoothAvailable = isBluetoothAvailable; - this.isMicrophoneEnabled = isMicrophoneEnabled; this.isRemoteVideoOffer = isRemoteVideoOffer; this.callConnectedTime = callConnectedTime; + this.remoteParticipants = remoteParticipants; + + localParticipant = CallParticipant.createLocal(localCameraState, localSink, isMicrophoneEnabled); } public @NonNull State getState() { @@ -107,50 +72,28 @@ public class WebRtcViewModel { return recipient; } - public @NonNull CameraState getLocalCameraState() { - return localCameraState; - } - - public @Nullable IdentityKey getIdentityKey() { - return identityKey; - } - public boolean isRemoteVideoEnabled() { - return remoteVideoEnabled; + return Stream.of(remoteParticipants).anyMatch(CallParticipant::isVideoEnabled); } public boolean isBluetoothAvailable() { return isBluetoothAvailable; } - public boolean isMicrophoneEnabled() { - return isMicrophoneEnabled; - } - public boolean isRemoteVideoOffer() { return isRemoteVideoOffer; } - public TextureViewRenderer getLocalRenderer() { - return localRenderer; - } - - public TextureViewRenderer getRemoteRenderer() { - return remoteRenderer; - } - public long getCallConnectedTime() { return callConnectedTime; } - public @NonNull String toString() { - return "[State: " + state + - ", recipient: " + recipient.getId().serialize() + - ", identity: " + identityKey + - ", remoteVideo: " + remoteVideoEnabled + - ", localVideo: " + localCameraState.isEnabled() + - ", isRemoteVideoOffer: " + isRemoteVideoOffer + - ", callConnectedTime: " + callConnectedTime + - "]"; + public @NonNull CallParticipant getLocalParticipant() { + return localParticipant; } + + public @NonNull List getRemoteParticipants() { + return remoteParticipants; + } + } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java index e0e69fd32e..dcb996e86f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java @@ -26,11 +26,12 @@ import org.signal.ringrtc.IceCandidate; import org.signal.ringrtc.Remote; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.WebRtcCallActivity; -import org.thoughtcrime.securesms.components.webrtc.TextureViewRenderer; +import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink; import org.thoughtcrime.securesms.crypto.UnidentifiedAccessUtil; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.notifications.DoNotDisturbUtil; @@ -48,7 +49,6 @@ import org.thoughtcrime.securesms.util.ServiceUtil; import org.thoughtcrime.securesms.util.TelephonyUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; -import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder; import org.thoughtcrime.securesms.webrtc.IncomingPstnCallReceiver; import org.thoughtcrime.securesms.webrtc.UncaughtExceptionHandlerManager; @@ -58,7 +58,6 @@ import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; import org.thoughtcrime.securesms.webrtc.locks.LockManager; import org.webrtc.EglBase; import org.webrtc.PeerConnection; -import org.whispersystems.libsignal.IdentityKey; import org.whispersystems.libsignal.util.Pair; import org.whispersystems.signalservice.api.SignalServiceAccountManager; import org.whispersystems.signalservice.api.SignalServiceMessageSender; @@ -74,8 +73,11 @@ import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserExce import java.io.IOException; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; @@ -167,7 +169,6 @@ public class WebRtcCallService extends Service implements CallManager.Observer, private CameraState localCameraState = CameraState.UNKNOWN; private boolean microphoneEnabled = true; - private boolean remoteVideoEnabled = false; private boolean bluetoothAvailable = false; private boolean enableVideoOnCreate = false; private boolean isRemoteVideoOffer = false; @@ -189,10 +190,12 @@ public class WebRtcCallService extends Service implements CallManager.Observer, @Nullable private RemotePeer activePeer; @Nullable private RemotePeer busyPeer; @Nullable private SparseArray peerMap; - @Nullable private TextureViewRenderer localRenderer; - @Nullable private TextureViewRenderer remoteRenderer; - @Nullable private EglBase eglBase; - @Nullable private Camera camera; + + @Nullable private EglBase eglBase; + @Nullable private BroadcastVideoSink localSink; + @Nullable private Camera camera; + + private final Map remoteParticipantMap = new LinkedHashMap<>(); private final ExecutorService serviceExecutor = Executors.newSingleThreadExecutor(); private final ExecutorService networkExecutor = Executors.newSingleThreadExecutor(); @@ -323,7 +326,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, public void onCameraSwitchCompleted(@NonNull CameraState newCameraState) { localCameraState = newCameraState; if (activePeer != null) { - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -448,6 +451,13 @@ public class WebRtcCallService extends Service implements CallManager.Observer, initializeVideo(); + remoteParticipantMap.put(remotePeer.getRecipient(), CallParticipant.createRemote( + remotePeer.getRecipient(), + null, + new BroadcastVideoSink(eglBase), + false + )); + OfferMessage.Type offerType = OfferMessage.Type.fromCode(intent.getStringExtra(EXTRA_OFFER_TYPE)); CallManager.CallMediaType callMediaType = getCallMediaTypeFromOfferType(offerType); @@ -505,7 +515,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, } if (activePeer != null) { - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -519,7 +529,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, } if (activePeer != null) { - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -541,7 +551,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, } if (activePeer != null) { - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -552,7 +562,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, camera.flip(); localCameraState = camera.getCameraState(); if (activePeer != null) { - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } } @@ -561,7 +571,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, bluetoothAvailable = intent.getBooleanExtra(EXTRA_AVAILABLE, false); if (activePeer != null) { - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -584,7 +594,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, audioManager.setSpeakerphoneOn(true); } - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -613,7 +623,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, AudioManager androidAudioManager = ServiceUtil.getAudioManager(this); androidAudioManager.setSpeakerphoneOn(false); - sendMessage(WebRtcViewModel.State.CALL_OUTGOING, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.CALL_OUTGOING, activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); lockManager.updatePhoneState(getInCallPhoneState()); audioManager.initializeAudioForCall(); audioManager.startOutgoingRinger(OutgoingRinger.Type.RINGING); @@ -633,8 +643,8 @@ public class WebRtcCallService extends Service implements CallManager.Observer, callManager.proceed(activePeer.getCallId(), WebRtcCallService.this, eglBase, - localRenderer, - remoteRenderer, + localSink, + remoteParticipantMap.get(activePeer.getRecipient()).getVideoSink(), camera, iceServers, isAlwaysTurn, @@ -645,7 +655,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, localCameraState = camera.getCameraState(); if (activePeer != null) { - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } }); @@ -668,6 +678,13 @@ public class WebRtcCallService extends Service implements CallManager.Observer, initializeVideo(); + remoteParticipantMap.put(remotePeer.getRecipient(), CallParticipant.createRemote( + remotePeer.getRecipient(), + null, + new BroadcastVideoSink(eglBase), + false + )); + setCallInProgressNotification(TYPE_INCOMING_CONNECTING, activePeer); retrieveTurnServers().addListener(new SuccessOnlyListener>(this.activePeer.getState(), this.activePeer.getCallId()) { @@ -680,8 +697,8 @@ public class WebRtcCallService extends Service implements CallManager.Observer, callManager.proceed(activePeer.getCallId(), WebRtcCallService.this, eglBase, - localRenderer, - remoteRenderer, + localSink, + remoteParticipantMap.get(activePeer.getRecipient()).getVideoSink(), camera, iceServers, hideIp, @@ -692,7 +709,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, lockManager.updatePhoneState(LockManager.PhoneState.PROCESSING); if (activePeer != null) { - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } }); @@ -883,7 +900,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, activePeer.localRinging(); lockManager.updatePhoneState(LockManager.PhoneState.INTERACTIVE); - sendMessage(WebRtcViewModel.State.CALL_INCOMING, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.CALL_INCOMING, activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); boolean shouldDisturbUserWithCall = DoNotDisturbUtil.shouldDisturbUserWithCall(getApplicationContext(), recipient); if (shouldDisturbUserWithCall) { startCallCardActivityIfPossible(); @@ -914,7 +931,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, Log.i(TAG, "handleRemoteRinging(): call_id: " + remotePeer.getCallId()); activePeer.remoteRinging(); - sendMessage(WebRtcViewModel.State.CALL_RINGING, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.CALL_RINGING, activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } private void handleCallConnected(Intent intent) { @@ -940,7 +957,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, callConnectedTime = System.currentTimeMillis(); - sendMessage(WebRtcViewModel.State.CALL_CONNECTED, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.CALL_CONNECTED, activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); unregisterPowerButtonReceiver(); @@ -969,8 +986,11 @@ public class WebRtcCallService extends Service implements CallManager.Observer, Log.i(TAG, "handleRemoteVideoEnable(): call_id: " + activePeer.getCallId()); - remoteVideoEnabled = enable; - sendMessage(WebRtcViewModel.State.CALL_CONNECTED, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + CallParticipant oldParticipant = Objects.requireNonNull(remoteParticipantMap.get(activePeer.getRecipient())); + CallParticipant newParticipant = oldParticipant.withVideoEnabled(enable); + remoteParticipantMap.put(activePeer.getRecipient(), newParticipant); + + sendMessage(WebRtcViewModel.State.CALL_CONNECTED, activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } @@ -1022,13 +1042,13 @@ public class WebRtcCallService extends Service implements CallManager.Observer, audioManager.setSpeakerphoneOn(true); } - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } private void handleLocalHangup(Intent intent) { if (activePeer == null) { if (busyPeer != null) { - sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, busyPeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, busyPeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); busyPeer = null; } @@ -1043,7 +1063,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, try { callManager.hangup(); - sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); terminate(activePeer); } catch (CallException e) { callFailure("hangup() failed: ", e); @@ -1097,9 +1117,9 @@ public class WebRtcCallService extends Service implements CallManager.Observer, if (remotePeer.callIdEquals(activePeer)) { boolean outgoingBeforeAccept = remotePeer.getState() == CallState.DIALING || remotePeer.getState() == CallState.REMOTE_RINGING; if (outgoingBeforeAccept) { - sendMessage(WebRtcViewModel.State.RECIPIENT_UNAVAILABLE, remotePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.RECIPIENT_UNAVAILABLE, remotePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } else { - sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, remotePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, remotePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -1117,7 +1137,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, Log.i(TAG, "handleEndedRemoteHangupAccepted(): call_id: " + remotePeer.getCallId()); if (remotePeer.callIdEquals(activePeer)) { - sendMessage(WebRtcViewModel.State.CALL_ACCEPTED_ELSEWHERE, remotePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.CALL_ACCEPTED_ELSEWHERE, remotePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } terminate(remotePeer); @@ -1129,7 +1149,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, Log.i(TAG, "handleEndedRemoteHangupBusy(): call_id: " + remotePeer.getCallId()); if (remotePeer.callIdEquals(activePeer)) { - sendMessage(WebRtcViewModel.State.CALL_ONGOING_ELSEWHERE, remotePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.CALL_ONGOING_ELSEWHERE, remotePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } terminate(remotePeer); @@ -1141,7 +1161,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, Log.i(TAG, "handleEndedRemoteHangupDeclined(): call_id: " + remotePeer.getCallId()); if (remotePeer.callIdEquals(activePeer)) { - sendMessage(WebRtcViewModel.State.CALL_DECLINED_ELSEWHERE, remotePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.CALL_DECLINED_ELSEWHERE, remotePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } terminate(remotePeer); @@ -1163,7 +1183,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, busyPeer = null; }, BUSY_TONE_LENGTH); - sendMessage(WebRtcViewModel.State.CALL_BUSY, remotePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.CALL_BUSY, remotePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } terminate(remotePeer); @@ -1175,7 +1195,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, Log.i(TAG, "handleEndedRemoteNeedPermission(): call_id: " + remotePeer.getCallId()); if (remotePeer.callIdEquals(activePeer)) { - sendMessage(WebRtcViewModel.State.CALL_NEEDS_PERMISSION, remotePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.CALL_NEEDS_PERMISSION, remotePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } terminate(remotePeer); @@ -1187,7 +1207,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, Log.i(TAG, "handleEndedRemoteGlare(): call_id: " + remotePeer.getCallId()); if (remotePeer.callIdEquals(activePeer)) { - sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, remotePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, remotePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } boolean incomingBeforeAccept = remotePeer.getState() == CallState.ANSWERING || remotePeer.getState() == CallState.LOCAL_RINGING; @@ -1204,7 +1224,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, Log.i(TAG, "handleEndedFailure(): call_id: " + remotePeer.getCallId()); if (remotePeer.callIdEquals(activePeer)) { - sendMessage(WebRtcViewModel.State.NETWORK_FAILURE, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.NETWORK_FAILURE, activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } if (remotePeer.getState() == CallState.ANSWERING || remotePeer.getState() == CallState.LOCAL_RINGING) { @@ -1254,14 +1274,8 @@ public class WebRtcCallService extends Service implements CallManager.Observer, private void initializeVideo() { Util.runOnMainSync(() -> { - - eglBase = EglBase.create(); - localRenderer = new TextureViewRenderer(WebRtcCallService.this); - remoteRenderer = new TextureViewRenderer(WebRtcCallService.this); - - localRenderer.init(eglBase.getEglBaseContext(), null); - remoteRenderer.init(eglBase.getEglBaseContext(), null); - + eglBase = EglBase.create(); + localSink = new BroadcastVideoSink(eglBase); camera = new Camera(WebRtcCallService.this, WebRtcCallService.this, eglBase); localCameraState = camera.getCameraState(); }); @@ -1299,31 +1313,28 @@ public class WebRtcCallService extends Service implements CallManager.Observer, camera = null; } - if (eglBase != null && localRenderer != null && remoteRenderer != null) { - localRenderer.release(); - remoteRenderer.release(); + if (eglBase != null) { eglBase.release(); - - localRenderer = null; - remoteRenderer = null; - eglBase = null; + eglBase = null; } this.localCameraState = CameraState.UNKNOWN; this.microphoneEnabled = true; - this.remoteVideoEnabled = false; this.enableVideoOnCreate = false; Log.i(TAG, "clear activePeer callId: " + activePeer.getCallId() + " key: " + activePeer.hashCode()); this.activePeer = null; + for (CallParticipant participant : remoteParticipantMap.values()) { + remoteParticipantMap.put(participant.getRecipient(), participant.withVideoEnabled(false)); + } + lockManager.updatePhoneState(LockManager.PhoneState.IDLE); } private void sendMessage(@NonNull WebRtcViewModel.State state, @NonNull RemotePeer remotePeer, @NonNull CameraState localCameraState, - boolean remoteVideoEnabled, boolean bluetoothAvailable, boolean microphoneEnabled, boolean isRemoteVideoOffer) @@ -1331,35 +1342,12 @@ public class WebRtcCallService extends Service implements CallManager.Observer, EventBus.getDefault().postSticky(new WebRtcViewModel(state, remotePeer.getRecipient(), localCameraState, - localRenderer, - remoteRenderer, - remoteVideoEnabled, + localSink, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer, - callConnectedTime)); - } - - private void sendMessage(@NonNull WebRtcViewModel.State state, - @NonNull RemotePeer remotePeer, - @NonNull IdentityKey identityKey, - @NonNull CameraState localCameraState, - boolean remoteVideoEnabled, - boolean bluetoothAvailable, - boolean microphoneEnabled, - boolean isRemoteVideoOffer) - { - EventBus.getDefault().postSticky(new WebRtcViewModel(state, - remotePeer.getRecipient(), - identityKey, - localCameraState, - localRenderer, - remoteRenderer, - remoteVideoEnabled, - bluetoothAvailable, - microphoneEnabled, - isRemoteVideoOffer, - callConnectedTime)); + callConnectedTime, + new ArrayList<>(remoteParticipantMap.values()))); } private ListenableFutureTask sendMessage(@NonNull final RemotePeer remotePeer, @@ -1429,7 +1417,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, Log.w(TAG, "callFailure(): " + message, error); if (activePeer != null) { - sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } if (callManager != null) { @@ -1710,11 +1698,16 @@ public class WebRtcCallService extends Service implements CallManager.Observer, } if (error instanceof UntrustedIdentityException) { - sendMessage(WebRtcViewModel.State.UNTRUSTED_IDENTITY, activePeer, ((UntrustedIdentityException)error).getIdentityKey(), localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + CallParticipant participant = Objects.requireNonNull(remoteParticipantMap.get(activePeer.getRecipient())); + CallParticipant untrusted = participant.withIdentityKey(((UntrustedIdentityException) error).getIdentityKey()); + + remoteParticipantMap.put(activePeer.getRecipient(), untrusted); + + sendMessage(WebRtcViewModel.State.UNTRUSTED_IDENTITY, activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } else if (error instanceof UnregisteredUserException) { - sendMessage(WebRtcViewModel.State.NO_SUCH_USER, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.NO_SUCH_USER, activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } else if (error instanceof IOException) { - sendMessage(WebRtcViewModel.State.NETWORK_FAILURE, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); + sendMessage(WebRtcViewModel.State.NETWORK_FAILURE, activePeer, localCameraState, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java index 07a250c29d..daf5aa0e83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingAdapter.java @@ -113,20 +113,20 @@ public class MappingAdapter extends ListAdapter, MappingViewHold } public interface Factory> { - @NonNull MappingViewHolder createViewHolder(ViewGroup parent); + @NonNull MappingViewHolder createViewHolder(@NonNull ViewGroup parent); } public static class LayoutFactory> implements Factory { private Function> creator; private final int layout; - public LayoutFactory(Function> creator, @LayoutRes int layout) { + public LayoutFactory(@NonNull Function> creator, @LayoutRes int layout) { this.creator = creator; this.layout = layout; } @Override - public @NonNull MappingViewHolder createViewHolder(ViewGroup parent) { + public @NonNull MappingViewHolder createViewHolder(@NonNull ViewGroup parent) { return creator.apply(LayoutInflater.from(parent.getContext()).inflate(layout, parent, false)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java index 13ae30abc1..4b716a867d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MappingViewHolder.java @@ -23,5 +23,9 @@ public abstract class MappingViewHolder> exten return itemView.findViewById(id); } + public @NonNull Context getContext() { + return itemView.getContext(); + } + public abstract void bind(@NonNull Model model); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientMappingModel.java b/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientMappingModel.java new file mode 100644 index 0000000000..e07ee76c51 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientMappingModel.java @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.util.viewholders; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.MappingModel; + +import java.util.Objects; + +public abstract class RecipientMappingModel> implements MappingModel { + + public abstract @NonNull Recipient getRecipient(); + + public @NonNull String getName(@NonNull Context context) { + return getRecipient().getDisplayName(context); + } + + @Override + public boolean areItemsTheSame(@NonNull T newItem) { + return getRecipient().getId().equals(newItem.getRecipient().getId()); + } + + @Override + public boolean areContentsTheSame(@NonNull T newItem) { + Context context = ApplicationDependencies.getApplication(); + return getName(context).equals(newItem.getName(context)) && Objects.equals(getRecipient().getContactPhoto(), newItem.getRecipient().getContactPhoto()); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientViewHolder.java new file mode 100644 index 0000000000..de667bada9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/viewholders/RecipientViewHolder.java @@ -0,0 +1,58 @@ +package org.thoughtcrime.securesms.util.viewholders; + +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.MappingAdapter; +import org.thoughtcrime.securesms.util.MappingViewHolder; + +public class RecipientViewHolder> extends MappingViewHolder { + + protected final @Nullable AvatarImageView avatar; + protected final @Nullable TextView name; + protected final @Nullable EventListener eventListener; + + public RecipientViewHolder(@NonNull View itemView, @Nullable EventListener eventListener) { + super(itemView); + this.eventListener = eventListener; + + avatar = findViewById(R.id.recipient_view_avatar); + name = findViewById(R.id.recipient_view_name); + } + + @Override + public void bind(@NonNull T model) { + if (avatar != null) { + avatar.setRecipient(model.getRecipient()); + } + + if (name != null) { + name.setText(model.getName(context)); + } + + if (eventListener != null) { + itemView.setOnClickListener(v -> eventListener.onModelClick(model)); + } else { + itemView.setOnClickListener(null); + } + } + + public static @NonNull > MappingAdapter.Factory createFactory(@LayoutRes int layout, @Nullable EventListener listener) { + return new MappingAdapter.LayoutFactory<>(view -> new RecipientViewHolder<>(view, listener), layout); + } + + public interface EventListener> { + default void onModelClick(@NonNull T model) { + onClick(model.getRecipient()); + } + + void onClick(@NonNull Recipient recipient); + } +} diff --git a/app/src/main/res/layout/call_participant_item.xml b/app/src/main/res/layout/call_participant_item.xml new file mode 100644 index 0000000000..74268ab7e4 --- /dev/null +++ b/app/src/main/res/layout/call_participant_item.xml @@ -0,0 +1,43 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/call_participants_list_dialog.xml b/app/src/main/res/layout/call_participants_list_dialog.xml new file mode 100644 index 0000000000..ac7784ae55 --- /dev/null +++ b/app/src/main/res/layout/call_participants_list_dialog.xml @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/call_participants_list_header.xml b/app/src/main/res/layout/call_participants_list_header.xml new file mode 100644 index 0000000000..d9a724743e --- /dev/null +++ b/app/src/main/res/layout/call_participants_list_header.xml @@ -0,0 +1,12 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/call_participants_list_item.xml b/app/src/main/res/layout/call_participants_list_item.xml new file mode 100644 index 0000000000..7e90ef2df9 --- /dev/null +++ b/app/src/main/res/layout/call_participants_list_item.xml @@ -0,0 +1,34 @@ + + + + + + + + diff --git a/app/src/main/res/layout/mentions_picker_recipient_list_item.xml b/app/src/main/res/layout/mentions_picker_recipient_list_item.xml index a162e9032f..61623988e8 100644 --- a/app/src/main/res/layout/mentions_picker_recipient_list_item.xml +++ b/app/src/main/res/layout/mentions_picker_recipient_list_item.xml @@ -12,14 +12,14 @@ android:paddingBottom="8dp"> + + + + + diff --git a/app/src/main/res/layout/webrtc_call_participants_layout.xml b/app/src/main/res/layout/webrtc_call_participants_layout.xml new file mode 100644 index 0000000000..cf2184691c --- /dev/null +++ b/app/src/main/res/layout/webrtc_call_participants_layout.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/webrtc_call_view.xml b/app/src/main/res/layout/webrtc_call_view.xml index 9551337a74..7308c41b71 100644 --- a/app/src/main/res/layout/webrtc_call_view.xml +++ b/app/src/main/res/layout/webrtc_call_view.xml @@ -10,31 +10,47 @@ android:layout_height="match_parent" android:background="@color/transparent_black_40" /> - + - + + + + + + android:background="@color/black"> - + + + - - - - - - + app:layout_constraintTop_toTopOf="@id/call_screen_status_bar_guideline" /> + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/webrtc_call_view_toolbar.xml b/app/src/main/res/layout/webrtc_call_view_toolbar.xml new file mode 100644 index 0000000000..f225808e24 --- /dev/null +++ b/app/src/main/res/layout/webrtc_call_view_toolbar.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/group_call.xml b/app/src/main/res/menu/group_call.xml new file mode 100644 index 0000000000..b982475d50 --- /dev/null +++ b/app/src/main/res/menu/group_call.xml @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 88c143ece8..eb47652b46 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1199,10 +1199,20 @@ To call %1$s, Signal needs access to your camera Signal %1$s Calling… + Group Call Signal voice call… Signal video call… + Start Call + Group Call + View participants + + + + In this call · %1$d person + In this call · %1$d people + Select your country diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index f1341369de..a7c7c8d538 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -513,4 +513,10 @@ rounded 50% + +