diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c10eb6052..b2ebd28f2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -117,7 +117,9 @@ android:theme="@style/TextSecure.LightTheme.WebRTCCall" android:excludeFromRecents="true" android:screenOrientation="portrait" - android:configChanges="mcc|mnc|locale|touchscreen|keyboard|keyboardHidden|navigation|orientation|screenLayout|screenSize|fontScale" + android:supportsPictureInPicture="true" + android:windowSoftInputMode="stateAlwaysHidden" + android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation" android:launchMode="singleTask"/> viewModel.onDismissedVideoTooltip()) + .show(TooltipPopup.POSITION_ABOVE); + return; + } + break; + case DISMISS_VIDEO_TOOLTIP: + if (videoTooltip != null) { + videoTooltip.dismiss(); + videoTooltip = null; + } + break; + default: + throw new IllegalArgumentException("Unknown event: " + event); + } + } + + private void handleCallTime(long callTime) { + EllapsedTimeFormatter ellapsedTimeFormatter = EllapsedTimeFormatter.fromDurationMillis(callTime); + + if (ellapsedTimeFormatter == null) { + return; + } + + callScreen.setStatus(getString(R.string.WebRtcCallActivity__signal_s, ellapsedTimeFormatter.toString())); } private void handleSetAudioSpeaker(boolean enabled) { @@ -173,10 +274,24 @@ public class WebRtcCallActivity extends Activity { } private void handleSetMuteVideo(boolean muted) { - Intent intent = new Intent(this, WebRtcCallService.class); - intent.setAction(WebRtcCallService.ACTION_SET_ENABLE_VIDEO); - intent.putExtra(WebRtcCallService.EXTRA_ENABLE, !muted); - startService(intent); + Recipient recipient = viewModel.getRecipient().get(); + + if (!recipient.equals(Recipient.UNKNOWN)) { + String recipientDisplayName = recipient.getDisplayName(this); + + Permissions.with(this) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withRationaleDialog(getString(R.string.WebRtcCallActivity__to_call_s_signal_needs_access_to_your_camera, recipientDisplayName), R.drawable.ic_video_solid_24_tinted) + .withPermanentDenialDialog(getString(R.string.WebRtcCallActivity__to_call_s_signal_needs_access_to_your_camera, recipientDisplayName)) + .onAllGranted(() -> { + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_SET_ENABLE_VIDEO); + intent.putExtra(WebRtcCallService.EXTRA_ENABLE, !muted); + startService(intent); + }) + .execute(); + } } private void handleFlipCamera() { @@ -185,18 +300,19 @@ public class WebRtcCallActivity extends Activity { startService(intent); } - private void handleAnswerCall() { - WebRtcViewModel event = EventBus.getDefault().getStickyEvent(WebRtcViewModel.class); + private void handleAnswerWithAudio() { + Recipient recipient = viewModel.getRecipient().get(); - if (event != null) { + if (!recipient.equals(Recipient.UNKNOWN)) { Permissions.with(this) - .request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA) + .request(Manifest.permission.RECORD_AUDIO) .ifNecessary() - .withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, event.getRecipient().toShortString(this)), - R.drawable.ic_mic_solid_24, R.drawable.ic_video_solid_24_tinted) + .withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, recipient.getDisplayName(this)), + R.drawable.ic_mic_solid_24) .withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls)) .onAllGranted(() -> { - callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_answering), event.getLocalRenderer()); + callScreen.setRecipient(recipient); + callScreen.setStatus(getString(R.string.RedPhone_answering)); Intent intent = new Intent(this, WebRtcCallService.class); intent.setAction(WebRtcCallService.ACTION_ACCEPT_CALL); @@ -207,15 +323,42 @@ public class WebRtcCallActivity extends Activity { } } - private void handleDenyCall() { - WebRtcViewModel event = EventBus.getDefault().getStickyEvent(WebRtcViewModel.class); + private void handleAnswerWithVideo() { + Recipient recipient = viewModel.getRecipient().get(); - if (event != null) { + if (!recipient.equals(Recipient.UNKNOWN)) { + Permissions.with(this) + .request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA) + .ifNecessary() + .withRationaleDialog(getString(R.string.WebRtcCallActivity_to_answer_the_call_from_s_give_signal_access_to_your_microphone, recipient.getDisplayName(this)), + R.drawable.ic_mic_solid_24, R.drawable.ic_video_solid_24_tinted) + .withPermanentDenialDialog(getString(R.string.WebRtcCallActivity_signal_requires_microphone_and_camera_permissions_in_order_to_make_or_receive_calls)) + .onAllGranted(() -> { + callScreen.setRecipient(recipient); + callScreen.setStatus(getString(R.string.RedPhone_answering)); + + Intent intent = new Intent(this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_ACCEPT_CALL); + intent.putExtra(WebRtcCallService.EXTRA_ANSWER_WITH_VIDEO, true); + startService(intent); + + handleSetMuteVideo(false); + }) + .onAnyDenied(this::handleDenyCall) + .execute(); + } + } + + private void handleDenyCall() { + Recipient recipient = viewModel.getRecipient().get(); + + if (!recipient.equals(Recipient.UNKNOWN)) { Intent intent = new Intent(this, WebRtcCallService.class); intent.setAction(WebRtcCallService.ACTION_DENY_CALL); startService(intent); - callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_ending_call), event.getLocalRenderer()); + callScreen.setRecipient(recipient); + callScreen.setStatus(getString(R.string.RedPhone_ending_call)); delayedFinish(); } } @@ -228,46 +371,53 @@ public class WebRtcCallActivity extends Activity { } private void handleIncomingCall(@NonNull WebRtcViewModel event) { - callScreen.setIncomingCall(event.getRecipient()); + callScreen.setRecipient(event.getRecipient()); } private void handleOutgoingCall(@NonNull WebRtcViewModel event) { - callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_dialing), event.getLocalRenderer()); + callScreen.setRecipient(event.getRecipient()); + callScreen.setStatus(getString(R.string.WebRtcCallActivity__calling)); } private void handleTerminate(@NonNull Recipient recipient, @NonNull SurfaceViewRenderer localRenderer /*, int terminationType */) { Log.i(TAG, "handleTerminate called"); - callScreen.setActiveCall(recipient, getString(R.string.RedPhone_ending_call), localRenderer); + callScreen.setRecipient(recipient); + callScreen.setStatus(getString(R.string.RedPhone_ending_call)); EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); delayedFinish(); } private void handleCallRinging(@NonNull WebRtcViewModel event) { - callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_ringing), event.getLocalRenderer()); + callScreen.setRecipient(event.getRecipient()); + callScreen.setStatus(getString(R.string.RedPhone_ringing)); } private void handleCallBusy(@NonNull WebRtcViewModel event) { - callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_busy), event.getLocalRenderer()); EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); + callScreen.setRecipient(event.getRecipient()); + callScreen.setStatus(getString(R.string.RedPhone_busy)); + delayedFinish(BUSY_SIGNAL_DELAY_FINISH); } private void handleCallConnected(@NonNull WebRtcViewModel event) { getWindow().addFlags(WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES); - callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_connected), "", event.getLocalRenderer(), event.getRemoteRenderer()); + callScreen.setRecipient(event.getRecipient()); } private void handleRecipientUnavailable(@NonNull WebRtcViewModel event) { - callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_recipient_unavailable), event.getLocalRenderer()); EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); + callScreen.setRecipient(event.getRecipient()); + callScreen.setStatus(getString(R.string.RedPhone_recipient_unavailable)); delayedFinish(); } private void handleServerFailure(@NonNull WebRtcViewModel event) { - callScreen.setActiveCall(event.getRecipient(), getString(R.string.RedPhone_network_failed), event.getLocalRenderer()); EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); + callScreen.setRecipient(event.getRecipient()); + callScreen.setStatus(getString(R.string.RedPhone_network_failed)); delayedFinish(); } @@ -294,31 +444,50 @@ public class WebRtcCallActivity extends Activity { } private void handleUntrustedIdentity(@NonNull WebRtcViewModel event) { - final IdentityKey theirIdentity = event.getIdentityKey(); - final Recipient recipient = event.getRecipient(); + final IdentityKey theirKey = event.getIdentityKey(); + final Recipient recipient = event.getRecipient(); - callScreen.setUntrustedIdentity(recipient, theirIdentity); - callScreen.setAcceptIdentityListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - synchronized (SESSION_LOCK) { - TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(WebRtcCallActivity.this); - identityKeyStore.saveIdentity(new SignalProtocolAddress(recipient.requireServiceId(), 1), theirIdentity, true); - } + if (theirKey == null) { + handleTerminate(recipient, event.getLocalRenderer()); + } - Intent intent = new Intent(WebRtcCallActivity.this, WebRtcCallService.class); - intent.setAction(WebRtcCallService.ACTION_OUTGOING_CALL) - .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(recipient.getId())); - startService(intent); - } - }); + String name = recipient.getDisplayName(this); + String introduction = getString(R.string.WebRtcCallScreen_new_safety_numbers, name, name); + SpannableString spannableString = new SpannableString(introduction + " " + getString(R.string.WebRtcCallScreen_you_may_wish_to_verify_this_contact)); - callScreen.setCancelIdentityButton(new View.OnClickListener() { - @Override - public void onClick(View v) { - handleTerminate(recipient, event.getLocalRenderer()); - } - }); + spannableString.setSpan(new VerifySpan(this, recipient.getId(), theirKey), introduction.length() + 1, spannableString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + AppCompatTextView untrustedIdentityExplanation = new AppCompatTextView(this); + untrustedIdentityExplanation.setText(spannableString); + untrustedIdentityExplanation.setMovementMethod(LinkMovementMethod.getInstance()); + + new AlertDialog.Builder(this) + .setView(untrustedIdentityExplanation) + .setPositiveButton(R.string.WebRtcCallScreen_accept, (d, w) -> { + synchronized (SESSION_LOCK) { + TextSecureIdentityKeyStore identityKeyStore = new TextSecureIdentityKeyStore(WebRtcCallActivity.this); + identityKeyStore.saveIdentity(new SignalProtocolAddress(recipient.requireServiceId(), 1), theirKey, true); + } + + d.dismiss(); + + Intent intent = new Intent(WebRtcCallActivity.this, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_OUTGOING_CALL) + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(recipient.getId())); + + startService(intent); + }) + .setNegativeButton(R.string.WebRtcCallScreen_end_call, (d, w) -> { + d.dismiss(); + handleTerminate(recipient, event.getLocalRenderer()); + }) + .show(); + } + + private boolean deviceSupportsPipMode() { + return Build.VERSION.SDK_INT >= 26 && + FeatureFlags.callingPip() && + getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE); } private void delayedFinish() { @@ -326,17 +495,15 @@ public class WebRtcCallActivity extends Activity { } private void delayedFinish(int delayMillis) { - callScreen.postDelayed(new Runnable() { - public void run() { - WebRtcCallActivity.this.finish(); - } - }, delayMillis); + callScreen.postDelayed(WebRtcCallActivity.this::finish, delayMillis); } @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) public void onEventMainThread(final WebRtcViewModel event) { Log.i(TAG, "Got message from service: " + event); + viewModel.setRecipient(event.getRecipient()); + switch (event.getState()) { case CALL_CONNECTED: handleCallConnected(event); break; case NETWORK_FAILURE: handleServerFailure(event); break; @@ -350,10 +517,10 @@ public class WebRtcCallActivity extends Activity { case UNTRUSTED_IDENTITY: handleUntrustedIdentity(event); break; } - callScreen.setRemoteVideoEnabled(event.isRemoteVideoEnabled()); - callScreen.updateAudioState(event.isBluetoothAvailable(), event.isMicrophoneEnabled()); - callScreen.setControlsEnabled(event.getState() != WebRtcViewModel.State.CALL_INCOMING); - callScreen.setLocalVideoState(event.getLocalCameraState(), event.getLocalRenderer()); + callScreen.setLocalRenderer(event.getLocalRenderer()); + callScreen.setRemoteRenderer(event.getRemoteRenderer()); + + viewModel.updateFromWebRtcViewModel(event); if (event.getLocalCameraState().getCameraCount() > 0 && enableVideoIfAvailable) { enableVideoIfAvailable = false; @@ -361,56 +528,74 @@ public class WebRtcCallActivity extends Activity { } } - private class HangupButtonListener implements WebRtcCallScreen.HangupButtonListener { - public void onClick() { + private final class ControlsListener implements WebRtcCallView.ControlsListener { + + @Override + public void onControlsFadeOut() { + if (videoTooltip != null) { + videoTooltip.dismiss(); + } + } + + @Override + public void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput) { + switch (audioOutput) { + case HANDSET: + handleSetAudioSpeaker(false); + break; + case HEADSET: + handleSetAudioBluetooth(true); + break; + case SPEAKER: + handleSetAudioSpeaker(true); + break; + default: + throw new IllegalStateException("Unknown output: " + audioOutput); + } + } + + @Override + public void onVideoChanged(boolean isVideoEnabled) { + handleSetMuteVideo(!isVideoEnabled); + } + + @Override + public void onMicChanged(boolean isMicEnabled) { + handleSetMuteAudio(!isMicEnabled); + } + + @Override + public void onCameraDirectionChanged() { + handleFlipCamera(); + } + + @Override + public void onEndCallPressed() { handleEndCall(); } - } - private class AudioMuteButtonListener implements WebRtcCallControls.MuteButtonListener { @Override - public void onToggle(boolean isMuted) { - WebRtcCallActivity.this.handleSetMuteAudio(isMuted); - } - } - - private class VideoMuteButtonListener implements WebRtcCallControls.MuteButtonListener { - @Override - public void onToggle(boolean isMuted) { - WebRtcCallActivity.this.handleSetMuteVideo(isMuted); - } - } - - private class CameraFlipButtonListener implements WebRtcCallControls.CameraFlipButtonListener { - @Override - public void onToggle() { - WebRtcCallActivity.this.handleFlipCamera(); - } - } - - private class SpeakerButtonListener implements WebRtcCallControls.SpeakerButtonListener { - @Override - public void onSpeakerChange(boolean isSpeaker) { - WebRtcCallActivity.this.handleSetAudioSpeaker(isSpeaker); - } - } - - private class BluetoothButtonListener implements WebRtcCallControls.BluetoothButtonListener { - @Override - public void onBluetoothChange(boolean isBluetooth) { - WebRtcCallActivity.this.handleSetAudioBluetooth(isBluetooth); - } - } - - private class IncomingCallActionListener implements WebRtcAnswerDeclineButton.AnswerDeclineListener { - @Override - public void onAnswered() { - WebRtcCallActivity.this.handleAnswerCall(); + public void onDenyCallPressed() { + handleDenyCall(); } @Override - public void onDeclined() { - WebRtcCallActivity.this.handleDenyCall(); + public void onAcceptCallWithVoiceOnlyPressed() { + handleAnswerWithAudio(); + } + + @Override + public void onAcceptCallPressed() { + if (viewModel.isAnswerWithVideoAvailable()) { + handleAnswerWithVideo(); + } else { + handleAnswerWithAudio(); + } + } + + @Override + public void onDownCaretPressed() { + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java index 147a4f2e3..c17952cf1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AvatarImageView.java @@ -6,7 +6,6 @@ import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.drawable.Drawable; -import android.provider.ContactsContract; import android.util.AttributeSet; import androidx.annotation.NonNull; @@ -24,7 +23,6 @@ import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientExporter; import org.thoughtcrime.securesms.util.AvatarUtil; import org.thoughtcrime.securesms.util.ThemeUtil; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/AudioOutputAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/AudioOutputAdapter.java new file mode 100644 index 000000000..012bc4929 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/AudioOutputAdapter.java @@ -0,0 +1,59 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.view.LayoutInflater; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; + +import java.util.List; + +final class AudioOutputAdapter extends RecyclerView.Adapter { + + private final Consumer consumer; + private final List audioOutputs; + + AudioOutputAdapter(@NonNull Consumer consumer, @NonNull List audioOutputs) { + this.audioOutputs = audioOutputs; + this.consumer = consumer; + } + + @Override + public @NonNull AudioOutputViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new AudioOutputViewHolder((TextView) LayoutInflater.from(parent.getContext()).inflate(R.layout.audio_output_adapter_item, parent, false), consumer); + } + + @Override + public void onBindViewHolder(@NonNull AudioOutputViewHolder holder, int position) { + WebRtcAudioOutput audioOutput = audioOutputs.get(position); + holder.view.setText(audioOutput.getLabelRes()); + holder.view.setCompoundDrawablesRelativeWithIntrinsicBounds(audioOutput.getIconRes(), 0, 0, 0); + } + + @Override + public int getItemCount() { + return audioOutputs.size(); + } + + final static class AudioOutputViewHolder extends RecyclerView.ViewHolder { + + private final TextView view; + + AudioOutputViewHolder(@NonNull TextView itemView, @NonNull Consumer consumer) { + super(itemView); + + view = itemView; + + itemView.setOnClickListener(v -> { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + consumer.accept(WebRtcAudioOutput.values()[getAdapterPosition()]); + } + }); + } + } + +} 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 new file mode 100644 index 000000000..2c64a7a54 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PictureInPictureGestureHelper.java @@ -0,0 +1,284 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.animation.Animator; +import android.annotation.SuppressLint; +import android.graphics.Point; +import android.view.GestureDetector; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.animation.Interpolator; + +import androidx.annotation.NonNull; +import androidx.core.view.GestureDetectorCompat; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.animation.AnimationCompleteListener; +import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.views.TouchInterceptingFrameLayout; + +import java.util.Arrays; + +public class PictureInPictureGestureHelper extends GestureDetector.SimpleOnGestureListener { + + private static final float DECELERATION_RATE = 0.99f; + + private final ViewGroup parent; + private final View child; + private final int framePadding; + private final int pipWidth; + private final int pipHeight; + + private int activePointerId = MotionEvent.INVALID_POINTER_ID; + private float lastTouchX; + private float lastTouchY; + private boolean isDragging; + private boolean isAnimating; + private int extraPaddingTop; + private int extraPaddingBottom; + private double projectionX; + private double projectionY; + private VelocityTracker velocityTracker; + private int maximumFlingVelocity; + + @SuppressLint("ClickableViewAccessibility") + public static PictureInPictureGestureHelper applyTo(@NonNull View child) { + TouchInterceptingFrameLayout parent = (TouchInterceptingFrameLayout) child.getParent(); + PictureInPictureGestureHelper helper = new PictureInPictureGestureHelper(parent, child); + GestureDetectorCompat gestureDetector = new GestureDetectorCompat(child.getContext(), helper); + + parent.setOnInterceptTouchEventListener((event) -> { + if (helper.velocityTracker == null) { + helper.velocityTracker = VelocityTracker.obtain(); + } + + helper.velocityTracker.addMovement(event); + + return false; + }); + + parent.setOnTouchListener((v, event) -> { + if (helper.velocityTracker != null) { + helper.velocityTracker.recycle(); + helper.velocityTracker = null; + } + + return false; + }); + + child.setOnTouchListener((v, event) -> { + boolean handled = gestureDetector.onTouchEvent(event); + + if (event.getActionMasked() == MotionEvent.ACTION_UP || event.getActionMasked() == MotionEvent.ACTION_CANCEL) { + if (!handled) { + handled = helper.onGestureFinished(event); + } + + if (helper.velocityTracker != null) { + helper.velocityTracker.recycle(); + helper.velocityTracker = null; + } + } + + return handled; + }); + + return helper; + } + + private PictureInPictureGestureHelper(@NonNull ViewGroup parent, @NonNull View child) { + this.parent = parent; + this.child = child; + this.framePadding = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_frame_padding); + this.pipWidth = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_width); + this.pipHeight = child.getResources().getDimensionPixelSize(R.dimen.picture_in_picture_gesture_helper_pip_height); + this.maximumFlingVelocity = ViewConfiguration.get(child.getContext()).getScaledMaximumFlingVelocity(); + } + + public void clearVerticalBoundaries() { + setVerticalBoundaries(0, parent.getMeasuredHeight()); + } + + public void setVerticalBoundaries(int topBoundary, int bottomBoundary) { + extraPaddingTop = topBoundary; + extraPaddingBottom = parent.getMeasuredHeight() - bottomBoundary; + + if (isAnimating) { + fling(); + } else if (!isDragging) { + onFling(null, null, 0, 0); + } + } + + private boolean onGestureFinished(MotionEvent e) { + final int pointerIndex = e.findPointerIndex(activePointerId); + + if (e.getActionIndex() == pointerIndex) { + onFling(e, e, 0, 0); + return true; + } + + return false; + } + + @Override + public boolean onDown(MotionEvent e) { + activePointerId = e.getPointerId(0); + lastTouchX = e.getX(activePointerId) + child.getX(); + lastTouchY = e.getY(activePointerId) + child.getY(); + isDragging = true; + + return true; + } + + @Override + public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) { + int pointerIndex = e2.findPointerIndex(activePointerId); + float x = e2.getX(pointerIndex) + child.getX(); + float y = e2.getY(pointerIndex) + child.getY(); + float dx = x - lastTouchX; + float dy = y - lastTouchY; + + child.setTranslationX(child.getTranslationX() + dx); + child.setTranslationY(child.getTranslationY() + dy); + + lastTouchX = x; + lastTouchY = y; + + return true; + } + + @Override + public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) { + if (velocityTracker != null) { + velocityTracker.computeCurrentVelocity(1000, maximumFlingVelocity); + + projectionX = child.getX() + project(velocityTracker.getXVelocity()); + projectionY = child.getY() + project(velocityTracker.getYVelocity()); + } else { + projectionX = child.getX(); + projectionY = child.getY(); + } + + fling(); + + return true; + } + + private void fling() { + Point projection = new Point((int) projectionX, (int) projectionY); + Point nearestCornerPosition = findNearestCornerPosition(projection); + + isAnimating = true; + isDragging = false; + + child.animate() + .translationX(getTranslationXForPoint(nearestCornerPosition)) + .translationY(getTranslationYForPoint(nearestCornerPosition)) + .setDuration(250) + .setInterpolator(new ViscousFluidInterpolator()) + .setListener(new AnimationCompleteListener() { + @Override + public void onAnimationEnd(Animator animation) { + isAnimating = false; + } + }) + .start(); + } + + private Point findNearestCornerPosition(Point projection) { + Point maxPoint = null; + double maxDistance = Double.MAX_VALUE; + + for (Point point : Arrays.asList(calculateTopLeftCoordinates(), + calculateTopRightCoordinates(parent), + calculateBottomLeftCoordinates(parent), + calculateBottomRightCoordinates(parent))) + { + double distance = distance(point, projection); + + if (distance < maxDistance) { + maxDistance = distance; + maxPoint = point; + } + } + + return maxPoint; + } + + private float getTranslationXForPoint(Point destination) { + return destination.x - child.getLeft(); + } + + private float getTranslationYForPoint(Point destination) { + return destination.y - child.getTop(); + } + + private Point calculateTopLeftCoordinates() { + return new Point(framePadding, + framePadding + extraPaddingTop); + } + + private Point calculateTopRightCoordinates(@NonNull ViewGroup parent) { + return new Point(parent.getMeasuredWidth() - pipWidth - framePadding, + framePadding + extraPaddingTop); + } + + private Point calculateBottomLeftCoordinates(@NonNull ViewGroup parent) { + return new Point(framePadding, + parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom); + } + + private Point calculateBottomRightCoordinates(@NonNull ViewGroup parent) { + return new Point(parent.getMeasuredWidth() - pipWidth - framePadding, + parent.getMeasuredHeight() - pipHeight - framePadding - extraPaddingBottom); + } + + private static float project(float initialVelocity) { + return (initialVelocity / 1000f) * DECELERATION_RATE / (1f - DECELERATION_RATE); + } + + private static double distance(Point a, Point b) { + return Math.sqrt(Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2)); + } + + /** Borrowed from ScrollView */ + private static class ViscousFluidInterpolator implements Interpolator { + /** Controls the viscous fluid effect (how much of it). */ + private static final float VISCOUS_FLUID_SCALE = 8.0f; + + private static final float VISCOUS_FLUID_NORMALIZE; + private static final float VISCOUS_FLUID_OFFSET; + + static { + + // must be set to 1.0 (used in viscousFluid()) + VISCOUS_FLUID_NORMALIZE = 1.0f / viscousFluid(1.0f); + // account for very small floating-point error + VISCOUS_FLUID_OFFSET = 1.0f - VISCOUS_FLUID_NORMALIZE * viscousFluid(1.0f); + } + + private static float viscousFluid(float x) { + x *= VISCOUS_FLUID_SCALE; + if (x < 1.0f) { + x -= (1.0f - (float)Math.exp(-x)); + } else { + float start = 0.36787944117f; // 1/e == exp(-1) + x = 1.0f - (float)Math.exp(1.0f - x); + x = start + x * (1.0f - start); + } + return x; + } + + @Override + public float getInterpolation(float input) { + final float interpolated = VISCOUS_FLUID_NORMALIZE * viscousFluid(input); + if (interpolated > 0) { + return interpolated + VISCOUS_FLUID_OFFSET; + } + return interpolated; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioOutput.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioOutput.java new file mode 100644 index 000000000..ef1bc2c31 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioOutput.java @@ -0,0 +1,28 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import androidx.annotation.DrawableRes; +import androidx.annotation.StringRes; + +import org.thoughtcrime.securesms.R; + +public enum WebRtcAudioOutput { + HANDSET(R.string.WebRtcAudioOutputToggle__phone, R.drawable.ic_phone_right_black_28), + SPEAKER(R.string.WebRtcAudioOutputToggle__speaker, R.drawable.ic_speaker_solid_black_28), + HEADSET(R.string.WebRtcAudioOutputToggle__bluetooth, R.drawable.ic_speaker_bt_solid_black_28); + + private final @StringRes int labelRes; + private final @DrawableRes int iconRes; + + WebRtcAudioOutput(@StringRes int labelRes, @DrawableRes int iconRes) { + this.labelRes = labelRes; + this.iconRes = iconRes; + } + + public int getIconRes() { + return iconRes; + } + + public int getLabelRes() { + return labelRes; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioOutputToggleButton.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioOutputToggleButton.java new file mode 100644 index 000000000..584abb24e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcAudioOutputToggleButton.java @@ -0,0 +1,163 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.content.Context; +import android.os.Bundle; +import android.os.Parcelable; +import android.util.AttributeSet; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; + +import java.util.Arrays; +import java.util.List; + +public class WebRtcAudioOutputToggleButton extends AppCompatImageView { + + private static final String STATE_OUTPUT_INDEX = "audio.output.toggle.state.output.index"; + private static final String STATE_HEADSET_ENABLED = "audio.output.toggle.state.headset.enabled"; + private static final String STATE_PARENT = "audio.output.toggle.state.parent"; + + private static final int[] OUTPUT_HANDSET = { R.attr.state_handset }; + private static final int[] OUTPUT_SPEAKER = { R.attr.state_speaker }; + private static final int[] OUTPUT_HEADSET = { R.attr.state_headset }; + private static final int[][] OUTPUT_ENUM = { OUTPUT_HANDSET, OUTPUT_SPEAKER, OUTPUT_HEADSET }; + private static final List OUTPUT_MODES = Arrays.asList(WebRtcAudioOutput.HANDSET, WebRtcAudioOutput.SPEAKER, WebRtcAudioOutput.HEADSET); + private static final WebRtcAudioOutput OUTPUT_FALLBACK = WebRtcAudioOutput.HANDSET; + + private boolean isHeadsetAvailable; + private int outputIndex; + private OnAudioOutputChangedListener audioOutputChangedListener; + private AlertDialog picker; + + public WebRtcAudioOutputToggleButton(Context context) { + this(context, null); + } + + public WebRtcAudioOutputToggleButton(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public WebRtcAudioOutputToggleButton(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + + super.setOnClickListener((v) -> { + if (isHeadsetAvailable) showPicker(); + else setAudioOutput(OUTPUT_MODES.get((outputIndex + 1) % OUTPUT_ENUM.length)); + }); + } + + @Override + public int[] onCreateDrawableState(int extraSpace) { + final int[] extra = OUTPUT_ENUM[outputIndex]; + final int[] drawableState = super.onCreateDrawableState(extraSpace + extra.length); + mergeDrawableStates(drawableState, extra); + return drawableState; + } + + @Override + public void setOnClickListener(@Nullable OnClickListener l) { + throw new UnsupportedOperationException("This View does not support custom click listeners."); + } + + public void setIsHeadsetAvailable(boolean isHeadsetAvailable) { + this.isHeadsetAvailable = isHeadsetAvailable; + setAudioOutput(OUTPUT_MODES.get(outputIndex)); + } + + public void setAudioOutput(@NonNull WebRtcAudioOutput audioOutput) { + int oldIndex = outputIndex; + outputIndex = resolveAudioOutputIndex(OUTPUT_MODES.indexOf(audioOutput), isHeadsetAvailable); + + if (oldIndex != outputIndex) { + refreshDrawableState(); + notifyListener(); + } + } + + public void setOnAudioOutputChangedListener(@Nullable OnAudioOutputChangedListener listener) { + this.audioOutputChangedListener = listener; + } + + private void showPicker() { + RecyclerView rv = new RecyclerView(getContext()); + rv.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false)); + rv.setAdapter(new AudioOutputAdapter(this::setAudioOutputViaDialog, OUTPUT_MODES)); + + picker = new AlertDialog.Builder(getContext()) + .setView(rv) + .show(); + } + + private void hidePicker() { + if (picker != null) { + picker.dismiss(); + picker = null; + } + } + + @Override + protected Parcelable onSaveInstanceState() { + Parcelable parentState = super.onSaveInstanceState(); + Bundle bundle = new Bundle(); + + bundle.putParcelable(STATE_PARENT, parentState); + bundle.putInt(STATE_OUTPUT_INDEX, outputIndex); + bundle.putBoolean(STATE_HEADSET_ENABLED, isHeadsetAvailable); + return bundle; + } + + @Override + protected void onRestoreInstanceState(Parcelable state) { + if (state instanceof Bundle) { + Bundle savedState = (Bundle) state; + + isHeadsetAvailable = savedState.getBoolean(STATE_HEADSET_ENABLED); + setAudioOutput(OUTPUT_MODES.get( + resolveAudioOutputIndex(savedState.getInt(STATE_OUTPUT_INDEX), isHeadsetAvailable)) + ); + + super.onRestoreInstanceState(savedState.getParcelable(STATE_PARENT)); + } else { + super.onRestoreInstanceState(state); + } + } + + private void notifyListener() { + if (audioOutputChangedListener == null) return; + + audioOutputChangedListener.audioOutputChanged(OUTPUT_MODES.get(outputIndex)); + } + + private void setAudioOutputViaDialog(@NonNull WebRtcAudioOutput audioOutput) { + setAudioOutput(audioOutput); + hidePicker(); + } + + private static int resolveAudioOutputIndex(int desiredAudioOutputIndex, boolean isHeadsetAvailable) { + if (isIllegalAudioOutputIndex(desiredAudioOutputIndex)) { + throw new IllegalArgumentException("Unsupported index: " + desiredAudioOutputIndex); + } + if (isUnsupportedAudioOutput(desiredAudioOutputIndex, isHeadsetAvailable)) { + return OUTPUT_MODES.indexOf(OUTPUT_FALLBACK); + } + return desiredAudioOutputIndex; + } + + private static boolean isIllegalAudioOutputIndex(int desiredFlashIndex) { + return desiredFlashIndex < 0 || desiredFlashIndex > OUTPUT_ENUM.length; + } + + private static boolean isUnsupportedAudioOutput(int desiredAudioOutputIndex, boolean isHeadsetAvailable) { + return OUTPUT_MODES.get(desiredAudioOutputIndex) == WebRtcAudioOutput.HEADSET && !isHeadsetAvailable; + } + + public interface OnAudioOutputChangedListener { + void audioOutputChanged(WebRtcAudioOutput audioOutput); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallControls.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallControls.java deleted file mode 100644 index 4d193b203..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallControls.java +++ /dev/null @@ -1,217 +0,0 @@ -package org.thoughtcrime.securesms.components.webrtc; - - -import android.annotation.TargetApi; -import android.content.Context; -import android.media.AudioManager; -import android.os.Build; -import androidx.annotation.NonNull; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.CompoundButton; -import android.widget.LinearLayout; - -import com.tomergoldst.tooltips.ToolTip; -import com.tomergoldst.tooltips.ToolTipsManager; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.components.AccessibleToggleButton; -import org.thoughtcrime.securesms.util.ServiceUtil; -import org.thoughtcrime.securesms.util.ViewUtil; - -public class WebRtcCallControls extends LinearLayout { - - private static final String TAG = WebRtcCallControls.class.getSimpleName(); - - private AccessibleToggleButton audioMuteButton; - private AccessibleToggleButton videoMuteButton; - private AccessibleToggleButton speakerButton; - private AccessibleToggleButton bluetoothButton; - private AccessibleToggleButton cameraFlipButton; - private boolean cameraFlipAvailable; - - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - public WebRtcCallControls(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - initialize(); - } - - @TargetApi(Build.VERSION_CODES.HONEYCOMB) - public WebRtcCallControls(Context context, AttributeSet attrs, int defStyleAttr) { - super(context, attrs, defStyleAttr); - initialize(); - } - - public WebRtcCallControls(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public WebRtcCallControls(Context context) { - super(context); - initialize(); - } - - private void initialize() { - LayoutInflater inflater = (LayoutInflater)getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE); - inflater.inflate(R.layout.webrtc_call_controls, this, true); - - this.speakerButton = ViewUtil.findById(this, R.id.speakerButton); - this.bluetoothButton = ViewUtil.findById(this, R.id.bluetoothButton); - this.audioMuteButton = ViewUtil.findById(this, R.id.muteButton); - this.videoMuteButton = ViewUtil.findById(this, R.id.video_mute_button); - this.cameraFlipButton = ViewUtil.findById(this, R.id.camera_flip_button); - } - - public void setAudioMuteButtonListener(final MuteButtonListener listener) { - audioMuteButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton compoundButton, boolean b) { - listener.onToggle(b); - } - }); - } - - public void setVideoMuteButtonListener(final MuteButtonListener listener) { - videoMuteButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - boolean videoMuted = !isChecked; - listener.onToggle(videoMuted); - cameraFlipButton.setVisibility(!videoMuted && cameraFlipAvailable ? View.VISIBLE : View.GONE); - } - }); - } - - public void setCameraFlipButtonListener(final CameraFlipButtonListener listener) { - cameraFlipButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - listener.onToggle(); - cameraFlipButton.setEnabled(false); - } - }); - } - - public void setSpeakerButtonListener(final SpeakerButtonListener listener) { - speakerButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - listener.onSpeakerChange(isChecked); - } - }); - } - - public void setBluetoothButtonListener(final BluetoothButtonListener listener) { - bluetoothButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { - @Override - public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { - listener.onBluetoothChange(isChecked); - } - }); - } - - public void updateAudioState(boolean isBluetoothAvailable) { - AudioManager audioManager = ServiceUtil.getAudioManager(getContext()); - - if (!isBluetoothAvailable) { - bluetoothButton.setVisibility(View.GONE); - } else { - bluetoothButton.setVisibility(View.VISIBLE); - } - - if (audioManager.isBluetoothScoOn()) { - bluetoothButton.setChecked(true, false); - speakerButton.setChecked(false, false); - } else if (audioManager.isSpeakerphoneOn()) { - speakerButton.setChecked(true, false); - bluetoothButton.setChecked(false, false); - } else { - speakerButton.setChecked(false, false); - bluetoothButton.setChecked(false, false); - } - } - - public boolean isVideoEnabled() { - return videoMuteButton.isChecked(); - } - - public void setVideoEnabled(boolean enabled) { - videoMuteButton.setChecked(enabled, false); - } - - public void setVideoAvailable(boolean available) { - videoMuteButton.setVisibility(available ? VISIBLE : GONE); - } - - public void setCameraFlipButtonEnabled(boolean enabled) { - cameraFlipButton.setChecked(enabled, false); - } - - public void setCameraFlipAvailable(boolean available) { - cameraFlipAvailable = available; - cameraFlipButton.setVisibility(cameraFlipAvailable && isVideoEnabled() ? View.VISIBLE : View.GONE); - } - - public void setCameraFlipClickable(boolean clickable) { - setControlEnabled(cameraFlipButton, clickable); - } - - public void setMicrophoneEnabled(boolean enabled) { - audioMuteButton.setChecked(!enabled, false); - } - - public void setControlsEnabled(boolean enabled) { - setControlEnabled(speakerButton, enabled); - setControlEnabled(bluetoothButton, enabled); - setControlEnabled(videoMuteButton, enabled); - setControlEnabled(cameraFlipButton, enabled); - setControlEnabled(audioMuteButton, enabled); - } - - private void setControlEnabled(@NonNull View view, boolean enabled) { - if (enabled) { - view.setAlpha(1.0f); - view.setEnabled(true); - } else { - view.setAlpha(0.3f); - view.setEnabled(false); - } - } - - public void displayVideoTooltip(ViewGroup viewGroup) { - if (videoMuteButton.getVisibility() == VISIBLE) { - final ToolTipsManager toolTipsManager = new ToolTipsManager(); - - ToolTip toolTip = new ToolTip.Builder(getContext(), videoMuteButton, viewGroup, - getContext().getString(R.string.WebRtcCallControls_tap_to_enable_your_video), - ToolTip.POSITION_BELOW).build(); - toolTipsManager.show(toolTip); - - videoMuteButton.postDelayed(() -> toolTipsManager.findAndDismiss(videoMuteButton), 4000); - } - } - - public static interface MuteButtonListener { - public void onToggle(boolean isMuted); - } - - public static interface CameraFlipButtonListener { - public void onToggle(); - } - - public static interface SpeakerButtonListener { - public void onSpeakerChange(boolean isSpeaker); - } - - public static interface BluetoothButtonListener { - public void onBluetoothChange(boolean isBluetooth); - } - - - - - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallRepository.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallRepository.java new file mode 100644 index 000000000..8de090def --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallRepository.java @@ -0,0 +1,25 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.media.AudioManager; + +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.util.ServiceUtil; + +class WebRtcCallRepository { + + private final AudioManager audioManager; + + WebRtcCallRepository() { + this.audioManager = ServiceUtil.getAudioManager(ApplicationDependencies.getApplication()); + } + + WebRtcAudioOutput getAudioOutput() { + if (audioManager.isBluetoothScoOn()) { + return WebRtcAudioOutput.HEADSET; + } else if (audioManager.isSpeakerphoneOn()) { + return WebRtcAudioOutput.SPEAKER; + } else { + return WebRtcAudioOutput.HANDSET; + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallScreen.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallScreen.java deleted file mode 100644 index 217761a5e..000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallScreen.java +++ /dev/null @@ -1,433 +0,0 @@ -/* - * Copyright (C) 2016 Open Whisper Systems - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.thoughtcrime.securesms.components.webrtc; - -import android.content.Context; -import android.text.SpannableString; -import android.text.Spanned; -import android.text.method.LinkMovementMethod; -import android.util.AttributeSet; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.RelativeLayout; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.view.ViewCompat; - -import com.bumptech.glide.load.engine.DiskCacheStrategy; -import com.google.android.material.floatingactionbutton.FloatingActionButton; - -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.mms.GlideApp; -import org.thoughtcrime.securesms.recipients.LiveRecipient; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.recipients.RecipientForeverObserver; -import org.thoughtcrime.securesms.ringrtc.CameraState; -import org.thoughtcrime.securesms.util.FeatureFlags; -import org.thoughtcrime.securesms.util.VerifySpan; -import org.thoughtcrime.securesms.util.ViewUtil; -import org.webrtc.RendererCommon; -import org.webrtc.SurfaceViewRenderer; -import org.whispersystems.libsignal.IdentityKey; - -/** - * A UI widget that encapsulates the entire in-call screen - * for both initiators and responders. - * - * @author Moxie Marlinspike - * - */ -public class WebRtcCallScreen extends FrameLayout implements RecipientForeverObserver { - - @SuppressWarnings("unused") - private static final String TAG = WebRtcCallScreen.class.getSimpleName(); - - private ImageView photo; - private SurfaceViewRenderer localRenderer; - private PercentFrameLayout localRenderLayout; - private PercentFrameLayout remoteRenderLayout; - private PercentFrameLayout localLargeRenderLayout; - private TextView name; - private TextView phoneNumber; - private TextView label; - private TextView elapsedTime; - private View untrustedIdentityContainer; - private TextView untrustedIdentityExplanation; - private Button acceptIdentityButton; - private Button cancelIdentityButton; - private TextView status; - private FloatingActionButton endCallButton; - private WebRtcCallControls controls; - private RelativeLayout expandedInfo; - private ViewGroup callHeader; - - private WebRtcAnswerDeclineButton incomingCallButton; - - private LiveRecipient recipient; - private boolean minimized; - - public WebRtcCallScreen(Context context) { - super(context); - initialize(); - } - - public WebRtcCallScreen(Context context, AttributeSet attrs) { - super(context, attrs); - initialize(); - } - - public WebRtcCallScreen(Context context, AttributeSet attrs, int defStyle) { - super(context, attrs, defStyle); - initialize(); - } - - public void setActiveCall(@NonNull Recipient personInfo, @NonNull String message, @Nullable String sas, SurfaceViewRenderer localRenderer, SurfaceViewRenderer remoteRenderer) { - setCard(personInfo, message); - setConnected(localRenderer, remoteRenderer); - incomingCallButton.stopRingingAnimation(); - incomingCallButton.setVisibility(View.GONE); - endCallButton.show(); - } - - public void setActiveCall(@NonNull Recipient personInfo, @NonNull String message, @NonNull SurfaceViewRenderer localRenderer) { - setCard(personInfo, message); - setRinging(localRenderer); - incomingCallButton.stopRingingAnimation(); - incomingCallButton.setVisibility(View.GONE); - endCallButton.show(); - } - - public void setIncomingCall(Recipient personInfo) { - setCard(personInfo, getContext().getString(R.string.CallScreen_Incoming_call)); - endCallButton.hide(); - incomingCallButton.setVisibility(View.VISIBLE); - incomingCallButton.startRingingAnimation(); - } - - public void setUntrustedIdentity(Recipient personInfo, IdentityKey untrustedIdentity) { - String name = recipient.get().toShortString(getContext()); - String introduction = String.format(getContext().getString(R.string.WebRtcCallScreen_new_safety_numbers), name, name); - SpannableString spannableString = new SpannableString(introduction + " " + getContext().getString(R.string.WebRtcCallScreen_you_may_wish_to_verify_this_contact)); - - spannableString.setSpan(new VerifySpan(getContext(), personInfo.getId(), untrustedIdentity), - introduction.length()+1, spannableString.length(), - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - - if (this.recipient != null) this.recipient.removeForeverObserver(this); - this.recipient = personInfo.live(); - this.recipient.observeForever(this); - - setPersonInfo(personInfo); - - incomingCallButton.stopRingingAnimation(); - incomingCallButton.setVisibility(View.GONE); - this.status.setText(R.string.WebRtcCallScreen_new_safety_number_title); - this.untrustedIdentityContainer.setVisibility(View.VISIBLE); - this.untrustedIdentityExplanation.setText(spannableString); - this.untrustedIdentityExplanation.setMovementMethod(LinkMovementMethod.getInstance()); - - this.endCallButton.hide(); - } - - public void setIncomingCallActionListener(WebRtcAnswerDeclineButton.AnswerDeclineListener listener) { - incomingCallButton.setAnswerDeclineListener(listener); - } - - public void setAudioMuteButtonListener(WebRtcCallControls.MuteButtonListener listener) { - this.controls.setAudioMuteButtonListener(listener); - } - - public void setVideoMuteButtonListener(WebRtcCallControls.MuteButtonListener listener) { - this.controls.setVideoMuteButtonListener(listener); - } - - public void setCameraFlipButtonListener(WebRtcCallControls.CameraFlipButtonListener listener) { - this.controls.setCameraFlipButtonListener(listener); - } - - public void setSpeakerButtonListener(WebRtcCallControls.SpeakerButtonListener listener) { - this.controls.setSpeakerButtonListener(listener); - } - - public void setBluetoothButtonListener(WebRtcCallControls.BluetoothButtonListener listener) { - this.controls.setBluetoothButtonListener(listener); - } - - public void setHangupButtonListener(final HangupButtonListener listener) { - endCallButton.setOnClickListener(v -> listener.onClick()); - } - - public void setAcceptIdentityListener(OnClickListener listener) { - this.acceptIdentityButton.setOnClickListener(listener); - } - - public void setCancelIdentityButton(OnClickListener listener) { - this.cancelIdentityButton.setOnClickListener(listener); - } - - public void updateAudioState(boolean isBluetoothAvailable, boolean isMicrophoneEnabled) { - this.controls.updateAudioState(isBluetoothAvailable); - this.controls.setMicrophoneEnabled(isMicrophoneEnabled); - } - - public void setControlsEnabled(boolean enabled) { - this.controls.setControlsEnabled(enabled); - } - - public void setLocalVideoState(@NonNull CameraState cameraState, @NonNull SurfaceViewRenderer localRenderer) { - this.controls.setVideoAvailable(cameraState.getCameraCount() > 0); - this.controls.setVideoEnabled(cameraState.isEnabled()); - this.controls.setCameraFlipAvailable(cameraState.getCameraCount() > 1); - this.controls.setCameraFlipClickable(cameraState.getActiveDirection() != CameraState.Direction.PENDING); - this.controls.setCameraFlipButtonEnabled(cameraState.getActiveDirection() == CameraState.Direction.BACK); - - localRenderer.setMirror(cameraState.getActiveDirection() == CameraState.Direction.FRONT); - localRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT); - - this.localRenderer = localRenderer; - - if (localRenderLayout.getChildCount() != 0) { - displayLocalRendererInSmallLayout(!cameraState.isEnabled()); - } else { - displayLocalRendererInLargeLayout(!cameraState.isEnabled()); - } - - localRenderer.setVisibility(cameraState.isEnabled() ? VISIBLE : INVISIBLE); - } - - public void setRemoteVideoEnabled(boolean enabled) { - if (enabled && this.remoteRenderLayout.isHidden()) { - this.photo.setVisibility(View.INVISIBLE); - setMinimized(true); - - this.remoteRenderLayout.setHidden(false); - this.remoteRenderLayout.requestLayout(); - - if (localRenderLayout.isHidden()) this.controls.displayVideoTooltip(callHeader); - } else if (!enabled && !this.remoteRenderLayout.isHidden()){ - setMinimized(false); - this.photo.setVisibility(View.VISIBLE); - this.remoteRenderLayout.setHidden(true); - this.remoteRenderLayout.requestLayout(); - } - } - - public boolean isVideoEnabled() { - return controls.isVideoEnabled(); - } - - private void displayLocalRendererInLargeLayout(boolean hide) { - if (localLargeRenderLayout.getChildCount() == 0) { - localRenderLayout.removeAllViews(); - - if (localRenderer != null) { - localLargeRenderLayout.addView(localRenderer); - } - } - - localRenderLayout.setHidden(true); - localRenderLayout.requestLayout(); - - localLargeRenderLayout.setHidden(hide); - localLargeRenderLayout.requestLayout(); - - if (hide) { - photo.setVisibility(View.VISIBLE); - } else { - photo.setVisibility(View.INVISIBLE); - } - } - - private void displayLocalRendererInSmallLayout(boolean hide) { - if (localRenderLayout.getChildCount() == 0) { - localLargeRenderLayout.removeAllViews(); - - if (localRenderer != null) { - localRenderLayout.addView(localRenderer); - } - } - - localLargeRenderLayout.setHidden(true); - localLargeRenderLayout.requestLayout(); - - localRenderLayout.setHidden(hide); - localRenderLayout.requestLayout(); - - if (remoteRenderLayout.isHidden()) { - photo.setVisibility(View.VISIBLE); - } - } - - private void initialize() { - LayoutInflater inflater = (LayoutInflater)getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE); - inflater.inflate(R.layout.webrtc_call_screen, this, true); - - this.elapsedTime = findViewById(R.id.elapsedTime); - this.photo = findViewById(R.id.photo); - this.localRenderLayout = findViewById(R.id.local_render_layout); - this.remoteRenderLayout = findViewById(R.id.remote_render_layout); - this.localLargeRenderLayout = findViewById(R.id.local_large_render_layout); - this.phoneNumber = findViewById(R.id.phoneNumber); - this.name = findViewById(R.id.name); - this.label = findViewById(R.id.label); - this.status = findViewById(R.id.callStateLabel); - this.controls = findViewById(R.id.inCallControls); - this.endCallButton = findViewById(R.id.hangup_fab); - this.incomingCallButton = findViewById(R.id.answer_decline_button); - this.untrustedIdentityContainer = findViewById(R.id.untrusted_layout); - this.untrustedIdentityExplanation = findViewById(R.id.untrusted_explanation); - this.acceptIdentityButton = findViewById(R.id.accept_safety_numbers); - this.cancelIdentityButton = findViewById(R.id.cancel_safety_numbers); - this.expandedInfo = findViewById(R.id.expanded_info); - this.callHeader = findViewById(R.id.call_info_1); - - this.localRenderLayout.setHidden(true); - this.remoteRenderLayout.setHidden(true); - this.minimized = false; - - this.remoteRenderLayout.setOnClickListener(v -> { - if (!this.remoteRenderLayout.isHidden()) { - setMinimized(!minimized); - } - }); - } - - private void setRinging(SurfaceViewRenderer localRenderer) { - if (localLargeRenderLayout.getChildCount() == 0) { - if (localRenderer.getParent() != null) { - ((ViewGroup)localRenderer.getParent()).removeView(localRenderer); - } - - localLargeRenderLayout.setPosition(0, 0, 100, 100); - - localRenderer.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT)); - - localRenderer.setMirror(true); - localRenderer.setZOrderMediaOverlay(true); - - localLargeRenderLayout.addView(localRenderer); - - this.localRenderer = localRenderer; - } - } - - private void setConnected(SurfaceViewRenderer localRenderer, SurfaceViewRenderer remoteRenderer) { - if (localRenderLayout.getChildCount() == 0) { - if (localRenderer.getParent() != null) { - ((ViewGroup)localRenderer.getParent()).removeView(localRenderer); - } - - if (remoteRenderer.getParent() != null) { - ((ViewGroup)remoteRenderer.getParent()).removeView(remoteRenderer); - } - - localRenderLayout.setPosition(7, 70, 25, 25); - remoteRenderLayout.setPosition(0, 0, 100, 100); - - localRenderer.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT)); - - remoteRenderer.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT)); - - localRenderer.setMirror(true); - localRenderer.setZOrderMediaOverlay(true); - - localRenderLayout.addView(localRenderer); - remoteRenderLayout.addView(remoteRenderer); - - this.localRenderer = localRenderer; - } - } - - private void setPersonInfo(final @NonNull Recipient recipient) { - GlideApp.with(getContext().getApplicationContext()) - .load(recipient.getContactPhoto()) - .fallback(recipient.getFallbackContactPhoto().asCallCard(getContext())) - .error(recipient.getFallbackContactPhoto().asCallCard(getContext())) - .diskCacheStrategy(DiskCacheStrategy.ALL) - .into(this.photo); - - if (FeatureFlags.profileDisplay()) { - this.name.setText(recipient.getDisplayName(getContext())); - - if (recipient.getE164().isPresent()) { - this.phoneNumber.setText(recipient.requireE164()); - this.phoneNumber.setVisibility(View.VISIBLE); - } else { - this.phoneNumber.setVisibility(View.GONE); - } - } else { - this.name.setText(recipient.getName(getContext())); - - if (recipient.getName(getContext()) == null && !recipient.getProfileName().isEmpty()) { - this.phoneNumber.setText(recipient.requireE164() + " (~" + recipient.getProfileName().toString() + ")"); - } else { - this.phoneNumber.setText(recipient.requireE164()); - } - } - } - - private void setCard(Recipient recipient, String status) { - if (this.recipient != null) this.recipient.removeForeverObserver(this); - this.recipient = recipient.live(); - this.recipient.observeForever(this); - - setPersonInfo(recipient); - - this.status.setText(status); - this.untrustedIdentityContainer.setVisibility(View.GONE); - } - - private void setMinimized(boolean minimized) { - if (minimized) { - ViewCompat.animate(callHeader).translationY(-1 * expandedInfo.getHeight()); - ViewCompat.animate(status).alpha(0); - ViewCompat.animate(endCallButton).translationY(endCallButton.getHeight() + ViewUtil.dpToPx(getContext(), 40)); - ViewCompat.animate(endCallButton).alpha(0); - - this.minimized = true; - } else { - ViewCompat.animate(callHeader).translationY(0); - ViewCompat.animate(status).alpha(1); - ViewCompat.animate(endCallButton).translationY(0); - ViewCompat.animate(endCallButton).alpha(1).withEndAction(() -> { - // Note: This is to work around an Android bug, see #6225 - endCallButton.requestLayout(); - }); - - this.minimized = false; - } - } - - @Override - public void onRecipientChanged(@NonNull Recipient recipient) { - setPersonInfo(recipient); - } - - public interface HangupButtonListener { - void onClick(); - } -} 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 new file mode 100644 index 000000000..d18c2f16a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java @@ -0,0 +1,458 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.constraintlayout.widget.ConstraintSet; +import androidx.constraintlayout.widget.Group; +import androidx.core.util.Consumer; +import androidx.transition.AutoTransition; +import androidx.transition.Transition; +import androidx.transition.TransitionManager; + +import com.bumptech.glide.load.engine.DiskCacheStrategy; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AccessibleToggleButton; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.ringrtc.CameraState; +import org.thoughtcrime.securesms.util.AvatarUtil; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.webrtc.RendererCommon; +import org.webrtc.SurfaceViewRenderer; + +public class WebRtcCallView extends FrameLayout { + + private static final long TRANSITION_DURATION_MILLIS = 250; + private static final FallbackPhotoProvider FALLBACK_PHOTO_PROVIDER = new FallbackPhotoProvider(); + + public static final int FADE_OUT_DELAY = 5000; + + private SurfaceViewRenderer localRenderer; + private Group ongoingCallButtons; + private Group incomingCallButtons; + private Group answerWithVoiceGroup; + private Group topViews; + private View topGradient; + private WebRtcAudioOutputToggleButton speakerToggle; + private AccessibleToggleButton videoToggle; + private AccessibleToggleButton micToggle; + private ViewGroup largeLocalRenderContainer; + private ViewGroup localRenderPipFrame; + private ViewGroup smallLocalRenderContainer; + private ViewGroup remoteRenderContainer; + private TextView recipientName; + private TextView status; + private ConstraintLayout parent; + private AvatarImageView avatar; + private ImageView avatarCard; + private ControlsListener controlsListener; + private RecipientId recipientId; + private CameraState.Direction cameraDirection; + private boolean shouldFadeControls; + private ImageView accept; + private View cameraDirectionToggle; + private PictureInPictureGestureHelper pictureInPictureGestureHelper; + + private final Runnable fadeOutRunnable = () -> { if (isAttachedToWindow()) fadeOutControls(); }; + + public WebRtcCallView(@NonNull Context context) { + this(context, null); + } + + public WebRtcCallView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + + LayoutInflater.from(context).inflate(R.layout.webrtc_call_view, this, true); + } + + @SuppressWarnings("CodeBlock2Expr") + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + ongoingCallButtons = findViewById(R.id.call_screen_in_call_buttons); + incomingCallButtons = findViewById(R.id.call_screen_incoming_call_buttons); + answerWithVoiceGroup = findViewById(R.id.call_screen_answer_with_audio_button); + topViews = findViewById(R.id.call_screen_top_views); + topGradient = findViewById(R.id.call_screen_header_gradient); + speakerToggle = findViewById(R.id.call_screen_speaker_toggle); + videoToggle = findViewById(R.id.call_screen_video_toggle); + micToggle = findViewById(R.id.call_screen_mic_toggle); + localRenderPipFrame = findViewById(R.id.call_screen_pip); + largeLocalRenderContainer = findViewById(R.id.call_screen_large_local_renderer_holder); + smallLocalRenderContainer = findViewById(R.id.call_screen_small_local_renderer_holder); + remoteRenderContainer = findViewById(R.id.call_screen_remote_renderer_holder); + recipientName = findViewById(R.id.call_screen_recipient_name); + status = findViewById(R.id.call_screen_status); + parent = findViewById(R.id.call_screen); + avatar = findViewById(R.id.call_screen_recipient_avatar); + avatarCard = findViewById(R.id.call_screen_recipient_avatar_call_card); + accept = findViewById(R.id.call_screen_answer_call); + cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle); + + View hangup = findViewById(R.id.call_screen_end_call); + View downCaret = findViewById(R.id.call_screen_down_arrow); + View decline = findViewById(R.id.call_screen_decline_call); + View answerWithAudio = findViewById(R.id.call_screen_answer_with_audio); + + speakerToggle.setOnAudioOutputChangedListener(outputMode -> { + runIfNonNull(controlsListener, listener -> listener.onAudioOutputChanged(outputMode)); + }); + + videoToggle.setOnCheckedChangeListener((v, isOn) -> { + runIfNonNull(controlsListener, listener -> listener.onVideoChanged(isOn)); + }); + + micToggle.setOnCheckedChangeListener((v, isOn) -> { + runIfNonNull(controlsListener, listener -> listener.onMicChanged(isOn)); + }); + + cameraDirectionToggle.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onCameraDirectionChanged)); + + hangup.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onEndCallPressed)); + decline.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onDenyCallPressed)); + + downCaret.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onDownCaretPressed)); + + accept.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallPressed)); + answerWithAudio.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onAcceptCallWithVoiceOnlyPressed)); + + setOnClickListener(v -> toggleControls()); + avatar.setOnClickListener(v -> toggleControls()); + + pictureInPictureGestureHelper = PictureInPictureGestureHelper.applyTo(localRenderPipFrame); + + int statusBarHeight = ViewUtil.getStatusBarHeight(this); + MarginLayoutParams params = (MarginLayoutParams) parent.getLayoutParams(); + + params.topMargin = statusBarHeight; + parent.setLayoutParams(params); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + if (shouldFadeControls) { + scheduleFadeOut(); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + cancelFadeOut(); + } + + public void showCameraToggleButton(boolean shouldShowCameraToggleButton) { + cameraDirectionToggle.setVisibility(shouldShowCameraToggleButton ? VISIBLE : GONE); + } + + public void setControlsListener(@Nullable ControlsListener controlsListener) { + this.controlsListener = controlsListener; + } + + public void setMicEnabled(boolean isMicEnabled) { + micToggle.setChecked(isMicEnabled, false); + } + + public void setBluetoothEnabled(boolean isBluetoothEnabled) { + speakerToggle.setIsHeadsetAvailable(isBluetoothEnabled); + } + + public void setAudioOutput(WebRtcAudioOutput output) { + speakerToggle.setAudioOutput(output); + } + + public void setRemoteVideoEnabled(boolean isRemoteVideoEnabled) { + boolean wasRemoteVideoEnabled = remoteRenderContainer.getVisibility() == View.VISIBLE; + + shouldFadeControls = isRemoteVideoEnabled; + + if (isRemoteVideoEnabled) { + remoteRenderContainer.setVisibility(View.VISIBLE); + } else { + remoteRenderContainer.setVisibility(View.GONE); + } + + if (shouldFadeControls && !wasRemoteVideoEnabled) { + fadeInControls(); + } else if (!shouldFadeControls && wasRemoteVideoEnabled) { + fadeOutControls(); + cancelFadeOut(); + } + } + + public void setLocalRenderer(@Nullable SurfaceViewRenderer surfaceViewRenderer) { + if (localRenderer == surfaceViewRenderer) { + return; + } + + localRenderer = surfaceViewRenderer; + + if (surfaceViewRenderer == null) { + setRenderer(largeLocalRenderContainer, null); + setRenderer(smallLocalRenderContainer, null); + } else { + localRenderer.setMirror(cameraDirection == CameraState.Direction.FRONT); + localRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT); + } + } + + public void setRemoteRenderer(@Nullable SurfaceViewRenderer surfaceViewRenderer) { + setRenderer(remoteRenderContainer, surfaceViewRenderer); + } + + public void setLocalRenderState(WebRtcLocalRenderState localRenderState) { + boolean enableZOverlay = localRenderState == WebRtcLocalRenderState.SMALL; + + videoToggle.setChecked(localRenderState != WebRtcLocalRenderState.GONE, false); + + switch (localRenderState) { + case GONE: + localRenderPipFrame.setVisibility(View.GONE); + largeLocalRenderContainer.setVisibility(View.GONE); + cameraDirectionToggle.animate().setDuration(0).alpha(0f); + setRenderer(largeLocalRenderContainer, null); + setRenderer(smallLocalRenderContainer, null); + break; + case LARGE: + localRenderPipFrame.setVisibility(View.GONE); + largeLocalRenderContainer.setVisibility(View.VISIBLE); + cameraDirectionToggle.animate().setDuration(0).alpha(0f); + if (largeLocalRenderContainer.getChildCount() == 0) { + setRenderer(largeLocalRenderContainer, localRenderer); + } + break; + case SMALL: + localRenderPipFrame.setVisibility(View.VISIBLE); + largeLocalRenderContainer.setVisibility(View.GONE); + cameraDirectionToggle.animate() + .setDuration(450) + .alpha(1f); + + if (smallLocalRenderContainer.getChildCount() == 0) { + setRenderer(smallLocalRenderContainer, localRenderer); + } + } + + if (localRenderer != null) { + localRenderer.setZOrderMediaOverlay(enableZOverlay); + } + } + + public void setCameraDirection(@NonNull CameraState.Direction cameraDirection) { + this.cameraDirection = cameraDirection; + + if (localRenderer != null) { + localRenderer.setMirror(cameraDirection == CameraState.Direction.FRONT); + } + } + + public void setRecipient(@NonNull Recipient recipient) { + if (recipient.getId() == recipientId) { + return; + } + + recipientId = recipient.getId(); + recipientName.setText(recipient.getDisplayName(getContext())); + avatar.setFallbackPhotoProvider(FALLBACK_PHOTO_PROVIDER); + avatar.setAvatar(GlideApp.with(this), recipient, false); + AvatarUtil.loadBlurredIconIntoViewBackground(recipient, this); + + setRecipientCallCard(recipient); + } + + public void showCallCard(boolean showCallCard) { + avatarCard.setVisibility(showCallCard ? VISIBLE : GONE); + avatar.setVisibility(showCallCard ? GONE : VISIBLE); + } + + public void setStatus(@NonNull String status) { + this.status.setText(status); + } + + public void setWebRtcControls(WebRtcControls webRtcControls) { + answerWithVoiceGroup.setVisibility(View.GONE); + + switch (webRtcControls) { + case NONE: + ongoingCallButtons.setVisibility(View.GONE); + incomingCallButtons.setVisibility(View.GONE); + setTopViewsVisibility(View.GONE); + break; + case INCOMING_VIDEO: + answerWithVoiceGroup.setVisibility(View.VISIBLE); + setTopViewsVisibility(View.VISIBLE); + ongoingCallButtons.setVisibility(View.GONE); + incomingCallButtons.setVisibility(View.VISIBLE); + status.setText(R.string.WebRtcCallView__signal_video_call); + accept.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer_with_video)); + break; + case INCOMING_AUDIO: + setTopViewsVisibility(View.VISIBLE); + ongoingCallButtons.setVisibility(View.GONE); + incomingCallButtons.setVisibility(View.VISIBLE); + status.setText(R.string.WebRtcCallView__signal_voice_call); + accept.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.webrtc_call_screen_answer)); + break; + case RINGING: + setTopViewsVisibility(View.VISIBLE); + incomingCallButtons.setVisibility(View.GONE); + ongoingCallButtons.setVisibility(View.VISIBLE); + break; + case CONNECTED: + setTopViewsVisibility(View.VISIBLE); + incomingCallButtons.setVisibility(View.GONE); + ongoingCallButtons.setVisibility(View.VISIBLE); + + post(() -> { + pictureInPictureGestureHelper.setVerticalBoundaries(status.getBottom(), speakerToggle.getTop()); + }); + } + } + + private void setTopViewsVisibility(int visibility) { + topViews.setVisibility(visibility); + topGradient.setVisibility(visibility); + } + + public @NonNull View getVideoTooltipTarget() { + return videoToggle; + } + + private void toggleControls() { + if (shouldFadeControls) { + if (status.getVisibility() == VISIBLE) { + fadeOutControls(); + } else { + fadeInControls(); + } + } + } + + private void fadeOutControls() { + fadeControls(ConstraintSet.GONE); + controlsListener.onControlsFadeOut(); + pictureInPictureGestureHelper.clearVerticalBoundaries(); + } + + private void fadeInControls() { + fadeControls(ConstraintSet.VISIBLE); + pictureInPictureGestureHelper.setVerticalBoundaries(status.getBottom(), speakerToggle.getTop()); + + scheduleFadeOut(); + } + + private void fadeControls(int visibility) { + Transition transition = new AutoTransition().setDuration(TRANSITION_DURATION_MILLIS); + + TransitionManager.beginDelayedTransition(parent, transition); + + ConstraintSet constraintSet = new ConstraintSet(); + constraintSet.clone(parent); + + constraintSet.setVisibility(R.id.call_screen_in_call_buttons, visibility); + constraintSet.setVisibility(R.id.call_screen_top_views, visibility); + + constraintSet.applyTo(parent); + + topGradient.animate() + .alpha(visibility == ConstraintSet.VISIBLE ? 1f : 0f) + .setDuration(TRANSITION_DURATION_MILLIS) + .start(); + } + + private void scheduleFadeOut() { + cancelFadeOut(); + shouldFadeControls = true; + + if (getHandler() == null) return; + getHandler().postDelayed(fadeOutRunnable, FADE_OUT_DELAY); + } + + private void cancelFadeOut() { + shouldFadeControls = false; + + if (getHandler() == null) return; + getHandler().removeCallbacks(fadeOutRunnable); + } + + private static void runIfNonNull(@Nullable ControlsListener controlsListener, @NonNull Consumer controlsListenerConsumer) { + if (controlsListener != null) { + controlsListenerConsumer.accept(controlsListener); + } + } + + private static void setRenderer(@NonNull ViewGroup container, @Nullable View renderer) { + if (renderer == null) { + container.removeAllViews(); + return; + } + + ViewParent parent = renderer.getParent(); + if (parent != null && parent != container) { + ((ViewGroup) parent).removeAllViews(); + } + + if (parent == container) { + return; + } + + container.addView(renderer); + } + + private void setRecipientCallCard(@NonNull Recipient recipient) { + ContactPhoto contactPhoto = recipient.getContactPhoto(); + FallbackContactPhoto fallbackPhoto = recipient.getFallbackContactPhoto(FALLBACK_PHOTO_PROVIDER); + + GlideApp.with(this).load(contactPhoto) + .fallback(fallbackPhoto.asCallCard(getContext())) + .error(fallbackPhoto.asCallCard(getContext())) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .into(this.avatarCard); + + if (contactPhoto == null) this.avatarCard.setScaleType(ImageView.ScaleType.CENTER_INSIDE); + else this.avatarCard.setScaleType(ImageView.ScaleType.CENTER_CROP); + + this.avatarCard.setBackgroundColor(recipient.getColor().toActionBarColor(getContext())); + } + + private static final class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider { + @Override + public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { + return new ResourceContactPhoto(R.drawable.ic_profile_outline_120); + } + } + + public interface ControlsListener { + void onControlsFadeOut(); + void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput); + void onVideoChanged(boolean isVideoEnabled); + void onMicChanged(boolean isMicEnabled); + void onCameraDirectionChanged(); + void onEndCallPressed(); + void onDenyCallPressed(); + void onAcceptCallWithVoiceOnlyPressed(); + void onAcceptCallPressed(); + void onDownCaretPressed(); + } +} 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 new file mode 100644 index 000000000..29c6332a8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java @@ -0,0 +1,228 @@ +package org.thoughtcrime.securesms.components.webrtc; + +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.Transformations; +import androidx.lifecycle.ViewModel; + +import org.thoughtcrime.securesms.events.WebRtcViewModel; +import org.thoughtcrime.securesms.recipients.LiveRecipient; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.ringrtc.CameraState; +import org.thoughtcrime.securesms.service.WebRtcCallService; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.livedata.LiveDataPair; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +public class WebRtcCallViewModel extends ViewModel { + + private final MutableLiveData remoteVideoEnabled = new MutableLiveData<>(false); + private final MutableLiveData audioOutput = new MutableLiveData<>(); + private final MutableLiveData bluetoothEnabled = 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 MutableLiveData hasMultipleCameras = new MutableLiveData<>(false); + 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 long callConnectedTime = -1; + private Handler ellapsedTimeHandler = new Handler(Looper.getMainLooper()); + private boolean answerWithVideoAvailable = false; + private Runnable ellapsedTimeRunnable = this::handleTick; + + + private final WebRtcCallRepository repository = new WebRtcCallRepository(); + + public WebRtcCallViewModel() { + audioOutput.setValue(repository.getAudioOutput()); + } + + public LiveData getRemoteVideoEnabled() { + return Transformations.distinctUntilChanged(remoteVideoEnabled); + } + + public LiveData getAudioOutput() { + return Transformations.distinctUntilChanged(audioOutput); + } + + public LiveData getBluetoothEnabled() { + return Transformations.distinctUntilChanged(bluetoothEnabled); + } + + 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; + } + + public LiveRecipient getRecipient() { + return liveRecipient.getValue(); + } + + public void setRecipient(@NonNull Recipient recipient) { + liveRecipient.setValue(recipient.live()); + } + + public LiveData getEvents() { + return events; + } + + public LiveData getCallTime() { + return Transformations.map(ellapsed, timeInCall -> callConnectedTime == -1 ? -1 : timeInCall); + } + + public LiveData isMoreThanOneCameraAvailable() { + return hasMultipleCameras; + } + + public boolean isAnswerWithVideoAvailable() { + return answerWithVideoAvailable; + } + + @MainThread + public void setIsInPipMode(boolean isInPipMode) { + this.isInPipMode.setValue(isInPipMode); + } + + public void onDismissedVideoTooltip() { + canDisplayTooltipIfNeeded = false; + } + + @MainThread + public void updateFromWebRtcViewModel(@NonNull WebRtcViewModel webRtcViewModel) { + remoteVideoEnabled.setValue(webRtcViewModel.isRemoteVideoEnabled()); + bluetoothEnabled.setValue(webRtcViewModel.isBluetoothAvailable()); + audioOutput.setValue(repository.getAudioOutput()); + microphoneEnabled.setValue(webRtcViewModel.isMicrophoneEnabled()); + + if (isValidCameraDirectionForUi(webRtcViewModel.getLocalCameraState().getActiveDirection())) { + cameraDirection.setValue(webRtcViewModel.getLocalCameraState().getActiveDirection()); + } + + hasMultipleCameras.setValue(webRtcViewModel.getLocalCameraState().getCameraCount() > 0); + localVideoEnabled.setValue(webRtcViewModel.getLocalCameraState().isEnabled()); + updateLocalRenderState(webRtcViewModel.getState()); + updateWebRtcControls(webRtcViewModel.getState(), webRtcViewModel.isRemoteVideoOffer()); + + if (webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED && callConnectedTime == -1) { + callConnectedTime = System.currentTimeMillis(); + startTimer(); + } else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_CONNECTED) { + callConnectedTime = -1; + cancelTimer(); + } + + if (webRtcViewModel.getLocalCameraState().isEnabled()) { + canDisplayTooltipIfNeeded = false; + hasEnabledLocalVideo = true; + events.setValue(Event.DISMISS_VIDEO_TOOLTIP); + } + + // If remote video is enabled and we a) haven't shown our video and b) have not dismissed the popup + if (canDisplayTooltipIfNeeded && webRtcViewModel.isRemoteVideoEnabled() && !hasEnabledLocalVideo) { + canDisplayTooltipIfNeeded = false; + events.setValue(Event.SHOW_VIDEO_TOOLTIP); + } + } + + private boolean isValidCameraDirectionForUi(CameraState.Direction direction) { + return direction == CameraState.Direction.FRONT || direction == CameraState.Direction.BACK; + } + + private void updateLocalRenderState(WebRtcViewModel.State state) { + if (state == WebRtcViewModel.State.CALL_CONNECTED) { + localRenderState.setValue(WebRtcLocalRenderState.SMALL); + } else { + localRenderState.setValue(WebRtcLocalRenderState.LARGE); + } + } + + private void updateWebRtcControls(WebRtcViewModel.State state, boolean isRemoteVideoOffer) { + switch (state) { + case CALL_INCOMING: + webRtcControls.setValue(isRemoteVideoOffer ? WebRtcControls.INCOMING_VIDEO : WebRtcControls.INCOMING_AUDIO); + answerWithVideoAvailable = isRemoteVideoOffer; + break; + case CALL_CONNECTED: + webRtcControls.setValue(WebRtcControls.CONNECTED); + break; + case CALL_OUTGOING: + webRtcControls.setValue(WebRtcControls.RINGING); + break; + default: + webRtcControls.setValue(WebRtcControls.ONGOING); + } + } + + private @NonNull WebRtcLocalRenderState getRealLocalRenderState(boolean shouldDisplayLocalVideo, @NonNull WebRtcLocalRenderState state) { + if (shouldDisplayLocalVideo) return state; + else return WebRtcLocalRenderState.GONE; + } + + private @NonNull WebRtcControls getRealWebRtcControls(boolean neverDisplayControls, @NonNull WebRtcControls controls) { + if (neverDisplayControls) return WebRtcControls.NONE; + else return controls; + } + + private void startTimer() { + cancelTimer(); + + ellapsedTimeHandler.post(ellapsedTimeRunnable); + } + + private void handleTick() { + if (callConnectedTime == -1) { + return; + } + + long newValue = (System.currentTimeMillis() - callConnectedTime) / 1000; + + ellapsed.postValue(newValue); + + ellapsedTimeHandler.postDelayed(ellapsedTimeRunnable, 1000); + } + + private void cancelTimer() { + ellapsedTimeHandler.removeCallbacks(ellapsedTimeRunnable); + } + + @Override + protected void onCleared() { + super.onCleared(); + cancelTimer(); + } + + public enum Event { + SHOW_VIDEO_TOOLTIP, + DISMISS_VIDEO_TOOLTIP + } +} 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 new file mode 100644 index 000000000..7f290f356 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcControls.java @@ -0,0 +1,10 @@ +package org.thoughtcrime.securesms.components.webrtc; + +public enum WebRtcControls { + NONE, + ONGOING, + RINGING, + CONNECTED, + INCOMING_AUDIO, + INCOMING_VIDEO +} 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 new file mode 100644 index 000000000..6e4873b6d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcLocalRenderState.java @@ -0,0 +1,7 @@ +package org.thoughtcrime.securesms.components.webrtc; + +public enum WebRtcLocalRenderState { + GONE, + SMALL, + LARGE +} 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 433e82b59..f5944719d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.java @@ -35,6 +35,7 @@ public class WebRtcViewModel { private final boolean isBluetoothAvailable; private final boolean isMicrophoneEnabled; + private final boolean isRemoteVideoOffer; private final CameraState localCameraState; private final SurfaceViewRenderer localRenderer; @@ -47,7 +48,8 @@ public class WebRtcViewModel { @NonNull SurfaceViewRenderer remoteRenderer, boolean remoteVideoEnabled, boolean isBluetoothAvailable, - boolean isMicrophoneEnabled) + boolean isMicrophoneEnabled, + boolean isRemoteVideoOffer) { this(state, recipient, @@ -57,7 +59,8 @@ public class WebRtcViewModel { remoteRenderer, remoteVideoEnabled, isBluetoothAvailable, - isMicrophoneEnabled); + isMicrophoneEnabled, + isRemoteVideoOffer); } public WebRtcViewModel(@NonNull State state, @@ -68,7 +71,8 @@ public class WebRtcViewModel { @NonNull SurfaceViewRenderer remoteRenderer, boolean remoteVideoEnabled, boolean isBluetoothAvailable, - boolean isMicrophoneEnabled) + boolean isMicrophoneEnabled, + boolean isRemoteVideoOffer) { this.state = state; this.recipient = recipient; @@ -79,6 +83,7 @@ public class WebRtcViewModel { this.remoteVideoEnabled = remoteVideoEnabled; this.isBluetoothAvailable = isBluetoothAvailable; this.isMicrophoneEnabled = isMicrophoneEnabled; + this.isRemoteVideoOffer = isRemoteVideoOffer; } public @NonNull State getState() { @@ -109,6 +114,10 @@ public class WebRtcViewModel { return isMicrophoneEnabled; } + public boolean isRemoteVideoOffer() { + return isRemoteVideoOffer; + } + public SurfaceViewRenderer getLocalRenderer() { return localRenderer; } @@ -118,6 +127,6 @@ public class WebRtcViewModel { } public @NonNull String toString() { - return "[State: " + state + ", recipient: " + recipient.getId().serialize() + ", identity: " + identityKey + ", remoteVideo: " + remoteVideoEnabled + ", localVideo: " + localCameraState.isEnabled() + "]"; + return "[State: " + state + ", recipient: " + recipient.getId().serialize() + ", identity: " + identityKey + ", remoteVideo: " + remoteVideoEnabled + ", localVideo: " + localCameraState.isEnabled() + ", isRemoteVideoOffer: " + isRemoteVideoOffer + "]"; } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java index e8ca2caa4..438fda5c0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushProcessMessageJob.java @@ -407,7 +407,8 @@ public final class PushProcessMessageJob extends BaseJob { .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, remotePeer) .putExtra(WebRtcCallService.EXTRA_REMOTE_DEVICE, content.getSenderDevice()) .putExtra(WebRtcCallService.EXTRA_OFFER_DESCRIPTION, message.getDescription()) - .putExtra(WebRtcCallService.EXTRA_TIMESTAMP, content.getTimestamp()); + .putExtra(WebRtcCallService.EXTRA_TIMESTAMP, content.getTimestamp()) + .putExtra(WebRtcCallService.EXTRA_OFFER_TYPE, message.getType().getCode()); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(intent); else context.startService(intent); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/CalleeMustAcceptMessageRequestDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/CalleeMustAcceptMessageRequestDialogFragment.java new file mode 100644 index 000000000..1ba3a8437 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/CalleeMustAcceptMessageRequestDialogFragment.java @@ -0,0 +1,102 @@ +package org.thoughtcrime.securesms.messagerequests; + +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.lifecycle.ViewModelProviders; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.AvatarImageView; +import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.ResourceContactPhoto; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +import java.util.concurrent.TimeUnit; + +public class CalleeMustAcceptMessageRequestDialogFragment extends DialogFragment { + + private static final long TIMEOUT_MS = TimeUnit.SECONDS.toMillis(10); + private static final String ARG_RECIPIENT_ID = "arg.recipient.id"; + + private TextView description; + private AvatarImageView avatar; + private View okay; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private final Runnable dismisser = this::dismiss; + + public static DialogFragment create(@NonNull RecipientId recipientId) { + DialogFragment fragment = new CalleeMustAcceptMessageRequestDialogFragment(); + Bundle args = new Bundle(); + + args.putParcelable(ARG_RECIPIENT_ID, recipientId); + + fragment.setArguments(args); + + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setStyle(DialogFragment.STYLE_NO_FRAME, R.style.TextSecure_DarkNoActionBar); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.callee_must_accept_message_request_dialog_fragment, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + description = view.findViewById(R.id.description); + avatar = view.findViewById(R.id.avatar); + okay = view.findViewById(R.id.okay); + + avatar.setFallbackPhotoProvider(new FallbackPhotoProvider()); + okay.setOnClickListener(v -> dismiss()); + + RecipientId recipientId = requireArguments().getParcelable(ARG_RECIPIENT_ID); + CalleeMustAcceptMessageRequestViewModel.Factory factory = new CalleeMustAcceptMessageRequestViewModel.Factory(recipientId); + CalleeMustAcceptMessageRequestViewModel viewModel = ViewModelProviders.of(this, factory).get(CalleeMustAcceptMessageRequestViewModel.class); + + viewModel.getRecipient().observe(getViewLifecycleOwner(), recipient -> { + description.setText(getString(R.string.CalleeMustAcceptMessageRequestDialogFragment__s_will_get_a_message_request_from_you, recipient.getDisplayName(requireContext()))); + avatar.setAvatar(GlideApp.with(this), recipient, false); + }); + } + + @Override + public void onResume() { + super.onResume(); + + handler.postDelayed(dismisser, TIMEOUT_MS); + } + + @Override + public void onPause() { + super.onPause(); + + handler.removeCallbacks(dismisser); + } + + private static class FallbackPhotoProvider extends Recipient.FallbackPhotoProvider { + @Override + public @NonNull FallbackContactPhoto getPhotoForRecipientWithoutName() { + return new ResourceContactPhoto(R.drawable.ic_profile_80); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagerequests/CalleeMustAcceptMessageRequestViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/CalleeMustAcceptMessageRequestViewModel.java new file mode 100644 index 000000000..587f9334c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagerequests/CalleeMustAcceptMessageRequestViewModel.java @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.messagerequests; + +import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; + +public class CalleeMustAcceptMessageRequestViewModel extends ViewModel { + + private final LiveData recipient; + + private CalleeMustAcceptMessageRequestViewModel(@NonNull RecipientId recipientId) { + recipient = Recipient.live(recipientId).getLiveData(); + } + + public LiveData getRecipient() { + return recipient; + } + + public static class Factory implements ViewModelProvider.Factory { + + private final RecipientId recipientId; + + public Factory(@NonNull RecipientId recipientId) { + this.recipientId = recipientId; + } + + @Override + public @NonNull T create(@NonNull Class modelClass) { + //noinspection ConstantConditions + return modelClass.cast(new CalleeMustAcceptMessageRequestViewModel(recipientId)); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java index b18a15a6b..ffc96b3f2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/bottomsheet/RecipientDialogViewModel.java @@ -78,7 +78,7 @@ final class RecipientDialogViewModel extends ViewModel { recipientDialogRepository.getRecipient(recipient -> CommunicationActions.startConversation(activity, recipient, null)); } - void onSecureCallClicked(@NonNull Activity activity) { + void onSecureCallClicked(@NonNull FragmentActivity activity) { recipientDialogRepository.getRecipient(recipient -> CommunicationActions.startVoiceCall(activity, recipient)); } 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 061082dcf..86eaeb951 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.java @@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.ringrtc.CameraEventListener; import org.thoughtcrime.securesms.ringrtc.CameraState; import org.thoughtcrime.securesms.ringrtc.IceCandidateParcel; import org.thoughtcrime.securesms.ringrtc.RemotePeer; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.FutureTaskListener; import org.thoughtcrime.securesms.util.ListenableFutureTask; import org.thoughtcrime.securesms.util.ServiceUtil; @@ -105,10 +106,12 @@ public class WebRtcCallService extends Service implements CallManager.Observer, public static final String EXTRA_REMOTE_PEER = "remote_peer"; public static final String EXTRA_REMOTE_DEVICE = "remote_device"; public static final String EXTRA_OFFER_DESCRIPTION = "offer_description"; + public static final String EXTRA_OFFER_TYPE = "offer_type"; public static final String EXTRA_ANSWER_DESCRIPTION = "answer_description"; public static final String EXTRA_ICE_CANDIDATES = "ice_candidates"; public static final String EXTRA_ENABLE = "enable_value"; public static final String EXTRA_BROADCAST = "broadcast"; + public static final String EXTRA_ANSWER_WITH_VIDEO = "enable_video"; public static final String ACTION_OUTGOING_CALL = "CALL_OUTGOING"; public static final String ACTION_DENY_CALL = "DENY_CALL"; @@ -155,6 +158,8 @@ public class WebRtcCallService extends Service implements CallManager.Observer, private boolean remoteVideoEnabled = false; private boolean bluetoothAvailable = false; private boolean enableVideoOnCreate = false; + private boolean isRemoteVideoOffer = false; + private boolean acceptWithVideo = false; private SignalServiceMessageSender messageSender; private SignalServiceAccountManager accountManager; @@ -299,7 +304,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); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -367,11 +372,12 @@ public class WebRtcCallService extends Service implements CallManager.Observer, // Handlers private void handleReceivedOffer(Intent intent) { - CallId callId = getCallId(intent); - RemotePeer remotePeer = getRemotePeer(intent); - Integer remoteDevice = intent.getIntExtra(EXTRA_REMOTE_DEVICE, -1); - String offer = intent.getStringExtra(EXTRA_OFFER_DESCRIPTION); - Long timeStamp = intent.getLongExtra(EXTRA_TIMESTAMP, -1); + CallId callId = getCallId(intent); + RemotePeer remotePeer = getRemotePeer(intent); + Integer remoteDevice = intent.getIntExtra(EXTRA_REMOTE_DEVICE, -1); + String offer = intent.getStringExtra(EXTRA_OFFER_DESCRIPTION); + Long timeStamp = intent.getLongExtra(EXTRA_TIMESTAMP, -1); + OfferMessage.Type offerType = OfferMessage.Type.fromCode(intent.getStringExtra(EXTRA_OFFER_TYPE)); Log.i(TAG, "handleReceivedOffer(): id: " + callId.format(remoteDevice)); @@ -383,6 +389,16 @@ public class WebRtcCallService extends Service implements CallManager.Observer, return; } + if (offerType == OfferMessage.Type.NEED_PERMISSION || FeatureFlags.profileForCalling() && !remotePeer.getRecipient().resolve().isProfileSharing()) { + Log.i(TAG, "handleReceivedOffer(): Caller is untrusted."); + intent.putExtra(EXTRA_BROADCAST, true); + handleSendHangup(intent); + insertMissedCall(remotePeer, true); + return; + } + + isRemoteVideoOffer = offerType == OfferMessage.Type.VIDEO_CALL; + try { callManager.receivedOffer(callId, remotePeer, remoteDevice, offer, timeStamp); } catch (CallException e) { @@ -458,7 +474,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, } if (activePeer != null) { - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -479,7 +495,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, } if (activePeer != null) { - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -501,7 +517,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, } if (activePeer != null) { - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -512,7 +528,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); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } } @@ -521,7 +537,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); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -544,7 +560,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, audioManager.setSpeakerphoneOn(true); } - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -561,7 +577,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, private void handleStartOutgoingCall(Intent intent) { Log.i(TAG, "handleStartOutgoingCall(): callId: " + activePeer.getCallId()); - sendMessage(WebRtcViewModel.State.CALL_OUTGOING, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(WebRtcViewModel.State.CALL_OUTGOING, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); lockManager.updatePhoneState(getInCallPhoneState()); audioManager.initializeAudioForCall(); audioManager.startOutgoingRinger(OutgoingRinger.Type.RINGING); @@ -598,7 +614,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, localCameraState = camera.getCameraState(); if (activePeer != null) { - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } }); @@ -642,7 +658,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); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } }); @@ -659,6 +675,8 @@ public class WebRtcCallService extends Service implements CallManager.Observer, DatabaseFactory.getSmsDatabase(this).insertReceivedCall(activePeer.getId()); + acceptWithVideo = intent.getBooleanExtra(EXTRA_ANSWER_WITH_VIDEO, false); + try { callManager.acceptCall(activePeer.getCallId()); } catch (CallException e) { @@ -667,15 +685,21 @@ public class WebRtcCallService extends Service implements CallManager.Observer, } private void handleSendOffer(Intent intent) { - RemotePeer remotePeer = getRemotePeer(intent); - CallId callId = getCallId(intent); - Integer remoteDevice = intent.getIntExtra(EXTRA_REMOTE_DEVICE, -1); - Boolean broadcast = intent.getBooleanExtra(EXTRA_BROADCAST, false); - String offer = intent.getStringExtra(EXTRA_OFFER_DESCRIPTION); + RemotePeer remotePeer = getRemotePeer(intent); + CallId callId = getCallId(intent); + Integer remoteDevice = intent.getIntExtra(EXTRA_REMOTE_DEVICE, -1); + Boolean broadcast = intent.getBooleanExtra(EXTRA_BROADCAST, false); + String offer = intent.getStringExtra(EXTRA_OFFER_DESCRIPTION); + OfferMessage.Type offerType = OfferMessage.Type.fromCode(intent.getStringExtra(EXTRA_OFFER_TYPE)); Log.i(TAG, "handleSendOffer: id: " + callId.format(remoteDevice)); - OfferMessage offerMessage = new OfferMessage(callId.longValue(), offer); + if (FeatureFlags.profileForCalling() && remotePeer.getRecipient().resolve().getProfileKey() == null) { + offer = ""; + offerType = OfferMessage.Type.NEED_PERMISSION; + } + + OfferMessage offerMessage = new OfferMessage(callId.longValue(), offer, offerType); SignalServiceCallMessage callMessage = SignalServiceCallMessage.forOffer(offerMessage); sendCallMessage(remotePeer, remoteDevice, broadcast, callMessage); @@ -816,7 +840,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); + sendMessage(WebRtcViewModel.State.CALL_INCOMING, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); boolean shouldDisturbUserWithCall = DoNotDisturbUtil.shouldDisturbUserWithCall(getApplicationContext(), recipient); if (shouldDisturbUserWithCall) { startCallCardActivityIfPossible(); @@ -850,7 +874,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, } activePeer.remoteRinging(); - sendMessage(WebRtcViewModel.State.CALL_RINGING, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(WebRtcViewModel.State.CALL_RINGING, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } private void handleCallConnected(Intent intent) { @@ -874,7 +898,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, lockManager.updatePhoneState(getInCallPhoneState()); } - sendMessage(WebRtcViewModel.State.CALL_CONNECTED, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(WebRtcViewModel.State.CALL_CONNECTED, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); unregisterPowerButtonReceiver(); @@ -889,6 +913,10 @@ public class WebRtcCallService extends Service implements CallManager.Observer, } catch (CallException e) { callFailure("Enabling audio/video failed: ", e); } + + if (acceptWithVideo) { + handleSetEnableVideo(new Intent().putExtra(EXTRA_ENABLE, true)); + } } private void handleRemoteVideoEnable(Intent intent) { @@ -902,7 +930,7 @@ 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); + sendMessage(WebRtcViewModel.State.CALL_CONNECTED, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } @@ -945,7 +973,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, audioManager.setSpeakerphoneOn(true); } - sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(viewModelStateFor(activePeer), activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } private void handleLocalHangup(Intent intent) { @@ -957,13 +985,13 @@ public class WebRtcCallService extends Service implements CallManager.Observer, Log.i(TAG, "handleLocalHangup(): call_id: " + activePeer.getCallId()); if (activePeer.getState() == CallState.RECEIVED_BUSY) { - sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); terminate(); } else { accountManager.cancelInFlightRequests(); messageSender.cancelInFlightRequests(); - sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); try { callManager.hangup(); @@ -1011,9 +1039,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); + sendMessage(WebRtcViewModel.State.RECIPIENT_UNAVAILABLE, remotePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } else { - sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, remotePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, remotePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -1042,7 +1070,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, } activePeer.receivedBusy(); - sendMessage(WebRtcViewModel.State.CALL_BUSY, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(WebRtcViewModel.State.CALL_BUSY, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); audioManager.startOutgoingRinger(OutgoingRinger.Type.BUSY); Util.runOnMainDelayed(() -> { @@ -1062,7 +1090,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); + sendMessage(WebRtcViewModel.State.NETWORK_FAILURE, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } if (remotePeer.getState() == CallState.ANSWERING || remotePeer.getState() == CallState.LOCAL_RINGING) { @@ -1182,7 +1210,8 @@ public class WebRtcCallService extends Service implements CallManager.Observer, @NonNull CameraState localCameraState, boolean remoteVideoEnabled, boolean bluetoothAvailable, - boolean microphoneEnabled) + boolean microphoneEnabled, + boolean isRemoteVideoOffer) { EventBus.getDefault().postSticky(new WebRtcViewModel(state, remotePeer.getRecipient(), @@ -1191,7 +1220,8 @@ public class WebRtcCallService extends Service implements CallManager.Observer, remoteRenderer, remoteVideoEnabled, bluetoothAvailable, - microphoneEnabled)); + microphoneEnabled, + isRemoteVideoOffer)); } private void sendMessage(@NonNull WebRtcViewModel.State state, @@ -1200,7 +1230,8 @@ public class WebRtcCallService extends Service implements CallManager.Observer, @NonNull CameraState localCameraState, boolean remoteVideoEnabled, boolean bluetoothAvailable, - boolean microphoneEnabled) + boolean microphoneEnabled, + boolean isRemoteVideoOffer) { EventBus.getDefault().postSticky(new WebRtcViewModel(state, remotePeer.getRecipient(), @@ -1210,7 +1241,8 @@ public class WebRtcCallService extends Service implements CallManager.Observer, remoteRenderer, remoteVideoEnabled, bluetoothAvailable, - microphoneEnabled)); + microphoneEnabled, + isRemoteVideoOffer)); } private ListenableFutureTask sendMessage(@NonNull final RemotePeer remotePeer, @@ -1259,7 +1291,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer, Log.w(TAG, message, error); if (activePeer != null) { - sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(WebRtcViewModel.State.CALL_DISCONNECTED, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } if (callManager != null) { @@ -1496,11 +1528,11 @@ 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); + sendMessage(WebRtcViewModel.State.UNTRUSTED_IDENTITY, activePeer, ((UntrustedIdentityException)error).getIdentityKey(), localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } else if (error instanceof UnregisteredUserException) { - sendMessage(WebRtcViewModel.State.NO_SUCH_USER, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(WebRtcViewModel.State.NO_SUCH_USER, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } else if (error instanceof IOException) { - sendMessage(WebRtcViewModel.State.NETWORK_FAILURE, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled); + sendMessage(WebRtcViewModel.State.NETWORK_FAILURE, activePeer, localCameraState, remoteVideoEnabled, bluetoothAvailable, microphoneEnabled, isRemoteVideoOffer); } } @@ -1655,7 +1687,8 @@ public class WebRtcCallService extends Service implements CallManager.Observer, .putExtra(EXTRA_REMOTE_PEER, remotePeer) .putExtra(EXTRA_REMOTE_DEVICE, remoteDevice) .putExtra(EXTRA_BROADCAST, broadcast) - .putExtra(EXTRA_OFFER_DESCRIPTION, offer); + .putExtra(EXTRA_OFFER_DESCRIPTION, offer) + .putExtra(EXTRA_OFFER_TYPE, (enableVideoOnCreate ? OfferMessage.Type.VIDEO_CALL : OfferMessage.Type.AUDIO_CALL).getCode()); startService(intent); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java index 2028013de..86428e774 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java @@ -3,14 +3,20 @@ package org.thoughtcrime.securesms.util; import android.content.Context; import android.graphics.drawable.Drawable; import android.text.TextUtils; +import android.view.View; import android.widget.ImageView; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; +import androidx.core.content.ContextCompat; import androidx.core.graphics.drawable.IconCompat; import com.bumptech.glide.load.engine.DiskCacheStrategy; +import com.bumptech.glide.load.resource.bitmap.CenterCrop; +import com.bumptech.glide.request.target.CustomViewTarget; +import com.bumptech.glide.request.transition.Transition; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.color.MaterialColor; @@ -29,6 +35,35 @@ public final class AvatarUtil { private AvatarUtil() { } + public static void loadBlurredIconIntoViewBackground(@NonNull Recipient recipient, @NonNull View target) { + Context context = target.getContext(); + + if (recipient.getContactPhoto() == null) { + target.setBackgroundColor(ContextCompat.getColor(target.getContext(), R.color.black)); + return; + } + + GlideApp.with(target) + .load(recipient.getContactPhoto()) + .transform(new CenterCrop(), new BlurTransformation(context, 0.25f, BlurTransformation.MAX_RADIUS)) + .into(new CustomViewTarget(target) { + @Override + public void onLoadFailed(@Nullable Drawable errorDrawable) { + target.setBackgroundColor(ContextCompat.getColor(target.getContext(), R.color.black)); + } + + @Override + public void onResourceReady(@NonNull Drawable resource, @Nullable Transition transition) { + target.setBackground(resource); + } + + @Override + protected void onResourceCleared(@Nullable Drawable placeholder) { + target.setBackground(placeholder); + } + }); + } + public static void loadIconIntoImageView(@NonNull Recipient recipient, @NonNull ImageView target) { Context context = target.getContext(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BlurTransformation.java b/app/src/main/java/org/thoughtcrime/securesms/util/BlurTransformation.java new file mode 100644 index 000000000..c5f21c3c3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BlurTransformation.java @@ -0,0 +1,61 @@ +package org.thoughtcrime.securesms.util; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Matrix; +import android.renderscript.Allocation; +import android.renderscript.Element; +import android.renderscript.RenderScript; +import android.renderscript.ScriptIntrinsicBlur; + +import androidx.annotation.NonNull; + +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool; +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation; + +import org.whispersystems.libsignal.util.guava.Preconditions; + +import java.security.MessageDigest; +import java.util.Locale; + +public final class BlurTransformation extends BitmapTransformation { + + public static final float MAX_RADIUS = 25f; + + private final RenderScript rs; + private final float bitmapScaleFactor; + private final float blurRadius; + + public BlurTransformation(@NonNull Context context, float bitmapScaleFactor, float blurRadius) { + rs = RenderScript.create(context); + + Preconditions.checkArgument(blurRadius >= 0 && blurRadius <= 25, "Blur radius must be a non-negative value less than or equal to 25."); + Preconditions.checkArgument(bitmapScaleFactor > 0, "Bitmap scale factor must be a non-negative value"); + + this.bitmapScaleFactor = bitmapScaleFactor; + this.blurRadius = blurRadius; + } + + @Override + protected Bitmap transform(BitmapPool pool, Bitmap toTransform, int outWidth, int outHeight) { + Matrix scaleMatrix = new Matrix(); + scaleMatrix.setScale(bitmapScaleFactor, bitmapScaleFactor); + + Bitmap blurredBitmap = Bitmap.createBitmap(toTransform, 0, 0, outWidth, outHeight, scaleMatrix, true); + Allocation input = Allocation.createFromBitmap(rs, blurredBitmap, Allocation.MipmapControl.MIPMAP_FULL, Allocation.USAGE_SHARED); + Allocation output = Allocation.createTyped(rs, input.getType()); + ScriptIntrinsicBlur script = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); + + script.setInput(input); + script.setRadius(blurRadius); + script.forEach(output); + output.copyTo(blurredBitmap); + + return blurredBitmap; + } + + @Override + public void updateDiskCacheKey(@NonNull MessageDigest messageDigest) { + messageDigest.update(String.format(Locale.US, "blur-%f-%f", bitmapScaleFactor, blurRadius).getBytes()); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java index 19ed656b4..e435ed863 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -18,23 +18,26 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.core.app.TaskStackBuilder; +import androidx.fragment.app.FragmentActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.WebRtcCallActivity; import org.thoughtcrime.securesms.conversation.ConversationActivity; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.messagerequests.CalleeMustAcceptMessageRequestDialogFragment; 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.whispersystems.signalservice.api.messages.calls.OfferMessage; public class CommunicationActions { private static final String TAG = Log.tag(CommunicationActions.class); - public static void startVoiceCall(@NonNull Activity activity, @NonNull Recipient recipient) { + public static void startVoiceCall(@NonNull FragmentActivity activity, @NonNull Recipient recipient) { if (TelephonyUtil.isAnyPstnLineBusy(activity)) { Toast.makeText(activity, R.string.CommunicationActions_a_cellular_call_is_already_in_progress, @@ -60,7 +63,7 @@ public class CommunicationActions { }); } - public static void startVideoCall(@NonNull Activity activity, @NonNull Recipient recipient) { + public static void startVideoCall(@NonNull FragmentActivity activity, @NonNull Recipient recipient) { if (TelephonyUtil.isAnyPstnLineBusy(activity)) { Toast.makeText(activity, R.string.CommunicationActions_a_cellular_call_is_already_in_progress, @@ -173,29 +176,69 @@ public class CommunicationActions { } } - private static void startCallInternal(@NonNull Activity activity, @NonNull Recipient recipient, boolean isVideo) { + private static void startCallInternal(@NonNull FragmentActivity activity, @NonNull Recipient recipient, boolean isVideo) { + if (isVideo) startVideoCallInternal(activity, recipient); + else startAudioCallInternal(activity, recipient); + } + + private static void startAudioCallInternal(@NonNull FragmentActivity activity, @NonNull Recipient recipient) { + Permissions.with(activity) + .request(Manifest.permission.RECORD_AUDIO) + .ifNecessary() + .withRationaleDialog(activity.getString(R.string.ConversationActivity__to_call_s_signal_needs_access_to_your_microphone, recipient.getDisplayName(activity)), + R.drawable.ic_mic_solid_24) + .withPermanentDenialDialog(activity.getString(R.string.ConversationActivity__to_call_s_signal_needs_access_to_your_microphone, recipient.getDisplayName(activity))) + .onAllGranted(() -> { + Intent intent = new Intent(activity, WebRtcCallService.class); + intent.setAction(WebRtcCallService.ACTION_OUTGOING_CALL) + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(recipient.getId())) + .putExtra(WebRtcCallService.EXTRA_OFFER_TYPE, OfferMessage.Type.AUDIO_CALL.getCode()); + activity.startService(intent); + + MessageSender.onMessageSent(); + + if (FeatureFlags.profileForCalling() && recipient.resolve().getProfileKey() == null) { + CalleeMustAcceptMessageRequestDialogFragment.create(recipient.getId()) + .show(activity.getSupportFragmentManager(), null); + } else { + Intent activityIntent = new Intent(activity, WebRtcCallActivity.class); + + activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + activity.startActivity(activityIntent); + } + }) + .execute(); + } + + private static void startVideoCallInternal(@NonNull FragmentActivity activity, @NonNull Recipient recipient) { Permissions.with(activity) .request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA) .ifNecessary() - .withRationaleDialog(activity.getString(R.string.ConversationActivity_to_call_s_signal_needs_access_to_your_microphone_and_camera, recipient.getDisplayName(activity)), + .withRationaleDialog(activity.getString(R.string.ConversationActivity_signal_needs_the_microphone_and_camera_permissions_in_order_to_call_s, recipient.getDisplayName(activity)), R.drawable.ic_mic_solid_24, R.drawable.ic_video_solid_24_tinted) .withPermanentDenialDialog(activity.getString(R.string.ConversationActivity_signal_needs_the_microphone_and_camera_permissions_in_order_to_call_s, recipient.getDisplayName(activity))) .onAllGranted(() -> { Intent intent = new Intent(activity, WebRtcCallService.class); intent.setAction(WebRtcCallService.ACTION_OUTGOING_CALL) - .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(recipient.getId())); + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(recipient.getId())) + .putExtra(WebRtcCallService.EXTRA_OFFER_TYPE, OfferMessage.Type.VIDEO_CALL.getCode()); activity.startService(intent); - Intent activityIntent = new Intent(activity, WebRtcCallActivity.class); - activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - if (isVideo) { - activityIntent.putExtra(WebRtcCallActivity.EXTRA_ENABLE_VIDEO_IF_AVAILABLE, true); - } - MessageSender.onMessageSent(); - activity.startActivity(activityIntent); + + if (FeatureFlags.profileForCalling() && recipient.resolve().getProfileKey() == null) { + CalleeMustAcceptMessageRequestDialogFragment.create(recipient.getId()) + .show(activity.getSupportFragmentManager(), null); + } else { + Intent activityIntent = new Intent(activity, WebRtcCallActivity.class); + + activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(WebRtcCallActivity.EXTRA_ENABLE_VIDEO_IF_AVAILABLE, true); + + activity.startActivity(activityIntent); + } }) .execute(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/EllapsedTimeFormatter.java b/app/src/main/java/org/thoughtcrime/securesms/util/EllapsedTimeFormatter.java new file mode 100644 index 000000000..843f80a3f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/EllapsedTimeFormatter.java @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.util; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Locale; + +public class EllapsedTimeFormatter { + private final long hours; + private final long minutes; + private final long seconds; + + private EllapsedTimeFormatter(long durationMillis) { + hours = durationMillis / 3600; + minutes = durationMillis % 3600 / 60; + seconds = durationMillis % 3600 % 60; + } + + @Override + public @NonNull String toString() { + if (hours > 0) { + return String.format(Locale.US, "%02d:%02d:%02d", hours, minutes, seconds); + } else { + return String.format(Locale.US, "%02d:%02d", minutes, seconds); + } + } + + public static @Nullable EllapsedTimeFormatter fromDurationMillis(long durationMillis) { + if (durationMillis == -1) { + return null; + } + + return new EllapsedTimeFormatter(durationMillis); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index c0e111c29..ab455a366 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -56,6 +56,9 @@ public final class FeatureFlags { private static final String PROFILE_NAMES_MEGAPHONE = "android.profileNamesMegaphone"; private static final String ATTACHMENTS_V3 = "android.attachmentsV3"; private static final String REMOTE_DELETE = "android.remoteDelete"; + private static final String PROFILE_FOR_CALLING = "android.profileForCalling"; + private static final String CALLING_PIP = "android.callingPip"; + private static final String NEW_GROUP_UI = "android.newGroupUI"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -70,7 +73,10 @@ public final class FeatureFlags { PROFILE_NAMES_MEGAPHONE, MESSAGE_REQUESTS, ATTACHMENTS_V3, - REMOTE_DELETE + REMOTE_DELETE, + PROFILE_FOR_CALLING, + CALLING_PIP, + NEW_GROUP_UI ); /** @@ -226,6 +232,16 @@ public final class FeatureFlags { return getValue(REMOTE_DELETE, false); } + /** Whether or not profile sharing is required for calling */ + public static boolean profileForCalling() { + return messageRequests() && getValue(PROFILE_FOR_CALLING, false); + } + + /** Whether or not to display Calling PIP */ + public static boolean callingPip() { + return getValue(CALLING_PIP, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java index 140ccdcba..82c89d812 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewUtil.java @@ -198,6 +198,10 @@ public class ViewUtil { } } + public static float pxToDp(float px) { + return px / Resources.getSystem().getDisplayMetrics().density; + } + public static int dpToPx(Context context, int dp) { return (int)((dp * context.getResources().getDisplayMetrics().density) + 0.5); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/views/TouchInterceptingFrameLayout.java b/app/src/main/java/org/thoughtcrime/securesms/util/views/TouchInterceptingFrameLayout.java new file mode 100644 index 000000000..de42ebc89 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/views/TouchInterceptingFrameLayout.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.util.views; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class TouchInterceptingFrameLayout extends FrameLayout { + + private OnInterceptTouchEventListener listener; + + public TouchInterceptingFrameLayout(@NonNull Context context) { + super(context); + } + + public TouchInterceptingFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public TouchInterceptingFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (listener != null) { + return listener.onInterceptTouchEvent(ev); + } else { + return super.onInterceptTouchEvent(ev); + } + } + + public void setOnInterceptTouchEventListener(@Nullable OnInterceptTouchEventListener listener) { + this.listener = listener; + } + + public interface OnInterceptTouchEventListener { + boolean onInterceptTouchEvent(MotionEvent ev); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/VoiceCallShare.java b/app/src/main/java/org/thoughtcrime/securesms/webrtc/VoiceCallShare.java index 77f43b553..881c24627 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/VoiceCallShare.java +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/VoiceCallShare.java @@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.ringrtc.RemotePeer; import org.thoughtcrime.securesms.service.WebRtcCallService; import org.thoughtcrime.securesms.util.concurrent.SimpleTask; +import org.whispersystems.signalservice.api.messages.calls.OfferMessage; public class VoiceCallShare extends Activity { @@ -34,7 +35,8 @@ public class VoiceCallShare extends Activity { if (!TextUtils.isEmpty(destination)) { Intent serviceIntent = new Intent(this, WebRtcCallService.class); serviceIntent.setAction(WebRtcCallService.ACTION_OUTGOING_CALL) - .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(recipient.getId())); + .putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, new RemotePeer(recipient.getId())) + .putExtra(WebRtcCallService.EXTRA_OFFER_TYPE, OfferMessage.Type.AUDIO_CALL.getCode()); startService(serviceIntent); Intent activityIntent = new Intent(this, WebRtcCallActivity.class); diff --git a/app/src/main/res/drawable-v21/webrtc_call_screen_circle_green.xml b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_green.xml new file mode 100644 index 000000000..f9261b282 --- /dev/null +++ b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_green.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable-v21/webrtc_call_screen_circle_grey_selector.xml b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_grey_selector.xml new file mode 100644 index 000000000..4b1c0cae1 --- /dev/null +++ b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_grey_selector.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable-v21/webrtc_call_screen_circle_red.xml b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_red.xml new file mode 100644 index 000000000..2e938e6c3 --- /dev/null +++ b/app/src/main/res/drawable-v21/webrtc_call_screen_circle_red.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/callee_dialog_button_background.xml b/app/src/main/res/drawable/callee_dialog_button_background.xml new file mode 100644 index 000000000..4406e7961 --- /dev/null +++ b/app/src/main/res/drawable/callee_dialog_button_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_mic_off_solid_28.xml b/app/src/main/res/drawable/ic_mic_off_solid_28.xml new file mode 100644 index 000000000..4c2601963 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_off_solid_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_solid_28.xml b/app/src/main/res/drawable/ic_mic_solid_28.xml new file mode 100644 index 000000000..c57b7a672 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_solid_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_phone_down_28.xml b/app/src/main/res/drawable/ic_phone_down_28.xml new file mode 100644 index 000000000..be65a1aa7 --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_down_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_phone_right_black_28.xml b/app/src/main/res/drawable/ic_phone_right_black_28.xml new file mode 100644 index 000000000..8365231af --- /dev/null +++ b/app/src/main/res/drawable/ic_phone_right_black_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_profile_outline_120.xml b/app/src/main/res/drawable/ic_profile_outline_120.xml new file mode 100644 index 000000000..124d8919f --- /dev/null +++ b/app/src/main/res/drawable/ic_profile_outline_120.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_speaker_bt_solid_28.xml b/app/src/main/res/drawable/ic_speaker_bt_solid_28.xml new file mode 100644 index 000000000..1c14c424e --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker_bt_solid_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_speaker_bt_solid_black_28.xml b/app/src/main/res/drawable/ic_speaker_bt_solid_black_28.xml new file mode 100644 index 000000000..0b42179ba --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker_bt_solid_black_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_speaker_solid_28.xml b/app/src/main/res/drawable/ic_speaker_solid_28.xml new file mode 100644 index 000000000..bbebce47a --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker_solid_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_speaker_solid_black_28.xml b/app/src/main/res/drawable/ic_speaker_solid_black_28.xml new file mode 100644 index 000000000..0d7525bcd --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker_solid_black_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_speaker_solid_white_28.xml b/app/src/main/res/drawable/ic_speaker_solid_white_28.xml new file mode 100644 index 000000000..220d9d058 --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker_solid_white_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_off_solid_28.xml b/app/src/main/res/drawable/ic_video_off_solid_28.xml new file mode 100644 index 000000000..0302272e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_off_solid_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_off_solid_white_28.xml b/app/src/main/res/drawable/ic_video_off_solid_white_28.xml new file mode 100644 index 000000000..90edf9003 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_off_solid_white_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_video_solid_28.xml b/app/src/main/res/drawable/ic_video_solid_28.xml new file mode 100644 index 000000000..0fccfd857 --- /dev/null +++ b/app/src/main/res/drawable/ic_video_solid_28.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/webrtc_call_screen_answer.xml b/app/src/main/res/drawable/webrtc_call_screen_answer.xml new file mode 100644 index 000000000..125e498dd --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_answer.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_answer_with_video.xml b/app/src/main/res/drawable/webrtc_call_screen_answer_with_video.xml new file mode 100644 index 000000000..8e3e6edc7 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_answer_with_video.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_answer_without_video.xml b/app/src/main/res/drawable/webrtc_call_screen_answer_without_video.xml new file mode 100644 index 000000000..dbe0d8298 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_answer_without_video.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_circle_green.xml b/app/src/main/res/drawable/webrtc_call_screen_circle_green.xml new file mode 100644 index 000000000..c7902555f --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_circle_green.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/webrtc_call_screen_circle_grey.xml b/app/src/main/res/drawable/webrtc_call_screen_circle_grey.xml new file mode 100644 index 000000000..1f5a81ece --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_circle_grey.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/webrtc_call_screen_circle_grey_selector.xml b/app/src/main/res/drawable/webrtc_call_screen_circle_grey_selector.xml new file mode 100644 index 000000000..a271ea196 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_circle_grey_selector.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/webrtc_call_screen_circle_red.xml b/app/src/main/res/drawable/webrtc_call_screen_circle_red.xml new file mode 100644 index 000000000..29275bbe0 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_circle_red.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/webrtc_call_screen_hangup.xml b/app/src/main/res/drawable/webrtc_call_screen_hangup.xml new file mode 100644 index 000000000..c514308bb --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_hangup.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_header_gradient.xml b/app/src/main/res/drawable/webrtc_call_screen_header_gradient.xml new file mode 100644 index 000000000..6877f48bb --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_header_gradient.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_mic_toggle.xml b/app/src/main/res/drawable/webrtc_call_screen_mic_toggle.xml new file mode 100644 index 000000000..82f17a8c8 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_mic_toggle.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_speaker_toggle.xml b/app/src/main/res/drawable/webrtc_call_screen_speaker_toggle.xml new file mode 100644 index 000000000..ae79a9d9b --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_speaker_toggle.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/webrtc_call_screen_video_toggle.xml b/app/src/main/res/drawable/webrtc_call_screen_video_toggle.xml new file mode 100644 index 000000000..19f37ceb8 --- /dev/null +++ b/app/src/main/res/drawable/webrtc_call_screen_video_toggle.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/audio_output_adapter_item.xml b/app/src/main/res/layout/audio_output_adapter_item.xml new file mode 100644 index 000000000..65a64acf3 --- /dev/null +++ b/app/src/main/res/layout/audio_output_adapter_item.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/callee_must_accept_message_request_dialog_fragment.xml b/app/src/main/res/layout/callee_must_accept_message_request_dialog_fragment.xml new file mode 100644 index 000000000..1b0e41c7f --- /dev/null +++ b/app/src/main/res/layout/callee_must_accept_message_request_dialog_fragment.xml @@ -0,0 +1,37 @@ + + + + + + + +