/* * 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; import android.Manifest; import android.annotation.SuppressLint; import android.app.PictureInPictureParams; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.res.Configuration; import android.graphics.Rect; import android.media.AudioManager; import android.os.Build; import android.os.Bundle; import android.util.Pair; import android.util.Rational; import android.view.Surface; import android.view.View; import android.view.Window; import android.view.WindowManager; import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.appcompat.app.AppCompatDelegate; import androidx.core.content.ContextCompat; import androidx.core.util.Consumer; import androidx.lifecycle.LiveData; import androidx.lifecycle.LiveDataReactiveStreams; import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.Transformations; import androidx.lifecycle.ViewModelProvider; import androidx.window.java.layout.WindowInfoTrackerCallbackAdapter; import androidx.window.layout.DisplayFeature; import androidx.window.layout.FoldingFeature; import androidx.window.layout.WindowInfoTracker; import androidx.window.layout.WindowLayoutInfo; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import org.signal.core.util.ThreadUtil; import org.signal.core.util.concurrent.LifecycleDisposable; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; import org.signal.libsignal.protocol.IdentityKey; import org.thoughtcrime.securesms.components.TooltipPopup; import org.thoughtcrime.securesms.components.sensors.Orientation; import org.thoughtcrime.securesms.components.webrtc.CallLinkProfileKeySender; import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow; import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow; import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState; import org.thoughtcrime.securesms.components.webrtc.CallReactionScrubber; import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow; import org.thoughtcrime.securesms.components.webrtc.CallToastPopupWindow; import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil; import org.thoughtcrime.securesms.components.webrtc.InCallStatus; import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsBottomSheet; import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsView; import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioDevice; import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput; import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView; import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel; import org.thoughtcrime.securesms.components.webrtc.WebRtcControls; import org.thoughtcrime.securesms.components.webrtc.WifiToCellularPopupWindow; import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoController; import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel; import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog; import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet; import org.thoughtcrime.securesms.components.webrtc.v2.CallControlsChange; import org.thoughtcrime.securesms.components.webrtc.v2.CallEvent; import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent; import org.thoughtcrime.securesms.components.webrtc.v2.CallPermissionsDialogController; import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.messagerequests.CalleeMustAcceptMessageRequestActivity; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet; import org.thoughtcrime.securesms.service.webrtc.CallLinkDisconnectReason; import org.thoughtcrime.securesms.service.webrtc.SignalCallManager; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.BottomSheetUtil; import org.thoughtcrime.securesms.util.EllapsedTimeFormatter; import org.thoughtcrime.securesms.util.FullscreenHelper; import org.thoughtcrime.securesms.util.RemoteConfig; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.ThrottledDebouncer; import org.thoughtcrime.securesms.util.Util; import org.thoughtcrime.securesms.util.VibrateUtil; import org.thoughtcrime.securesms.util.WindowUtil; import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState; import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager; import org.whispersystems.signalservice.api.messages.calls.HangupMessage; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.BackpressureStrategy; import io.reactivex.rxjava3.disposables.Disposable; import static org.thoughtcrime.securesms.components.sensors.Orientation.PORTRAIT_BOTTOM_EDGE; public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChangeDialog.Callback, ReactWithAnyEmojiBottomSheetDialogFragment.Callback { private static final String TAG = Log.tag(WebRtcCallActivity.class); private static final int STANDARD_DELAY_FINISH = 1000; private static final int VIBRATE_DURATION = 50; private CallParticipantsListUpdatePopupWindow participantUpdateWindow; private CallStateUpdatePopupWindow callStateUpdatePopupWindow; private CallOverflowPopupWindow callOverflowPopupWindow; private WifiToCellularPopupWindow wifiToCellularPopupWindow; private FullscreenHelper fullscreenHelper; private WebRtcCallView callScreen; private TooltipPopup videoTooltip; private TooltipPopup switchCameraTooltip; private WebRtcCallViewModel viewModel; private ControlsAndInfoViewModel controlsAndInfoViewModel; private boolean enableVideoIfAvailable; private boolean hasWarnedAboutBluetooth; private WindowLayoutInfoConsumer windowLayoutInfoConsumer; private WindowInfoTrackerCallbackAdapter windowInfoTrackerCallbackAdapter; private ThrottledDebouncer requestNewSizesThrottle; private PictureInPictureParams.Builder pipBuilderParams; private LifecycleDisposable lifecycleDisposable; private long lastCallLinkDisconnectDialogShowTime; private ControlsAndInfoController controlsAndInfo; private boolean enterPipOnResume; private long lastProcessedIntentTimestamp; private WebRtcViewModel previousEvent = null; private Disposable ephemeralStateDisposable = Disposable.empty(); private CallPermissionsDialogController callPermissionsDialogController = new CallPermissionsDialogController(); @Override protected void attachBaseContext(@NonNull Context newBase) { getDelegate().setLocalNightMode(AppCompatDelegate.MODE_NIGHT_YES); super.attachBaseContext(newBase); } @SuppressLint({ "MissingInflatedId" }) @Override public void onCreate(Bundle savedInstanceState) { CallIntent callIntent = getCallIntent(); Log.i(TAG, "onCreate(" + callIntent.isStartedFromFullScreen() + ")"); lifecycleDisposable = new LifecycleDisposable(); lifecycleDisposable.bindTo(this); getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); super.onCreate(savedInstanceState); requestWindowFeature(Window.FEATURE_NO_TITLE); setContentView(R.layout.webrtc_call_activity); fullscreenHelper = new FullscreenHelper(this); setVolumeControlStream(AudioManager.STREAM_VOICE_CALL); initializeResources(); initializeViewModel(); initializePictureInPictureParams(); controlsAndInfo = new ControlsAndInfoController(this, callScreen, callOverflowPopupWindow, viewModel, controlsAndInfoViewModel); controlsAndInfo.addVisibilityListener(new FadeCallback()); fullscreenHelper.showAndHideWithSystemUI(getWindow(), findViewById(R.id.call_screen_header_gradient), findViewById(R.id.webrtc_call_view_toolbar_text), findViewById(R.id.webrtc_call_view_toolbar_no_text)); lifecycleDisposable.add(controlsAndInfo); logIntent(callIntent); if (callIntent.getAction() == CallIntent.Action.ANSWER_VIDEO) { enableVideoIfAvailable = true; } else if (callIntent.getAction() == CallIntent.Action.ANSWER_AUDIO || callIntent.isStartedFromFullScreen()) { enableVideoIfAvailable = false; } else { enableVideoIfAvailable = callIntent.shouldEnableVideoIfAvailable(); callIntent.setShouldEnableVideoIfAvailable(false); } processIntent(callIntent); registerSystemPipChangeListeners(); windowLayoutInfoConsumer = new WindowLayoutInfoConsumer(); windowInfoTrackerCallbackAdapter = new WindowInfoTrackerCallbackAdapter(WindowInfoTracker.getOrCreate(this)); windowInfoTrackerCallbackAdapter.addWindowLayoutInfoListener(this, SignalExecutors.BOUNDED, windowLayoutInfoConsumer); requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1)); initializePendingParticipantFragmentListener(); WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_dark_colorSurface)); if (!hasCameraPermission() & !hasAudioPermission()) { askCameraAudioPermissions(() -> { callScreen.setMicEnabled(viewModel.getMicrophoneEnabled().getValue()); handleSetMuteVideo(false); }); } else if (!hasAudioPermission()) { askAudioPermissions(() -> callScreen.setMicEnabled(viewModel.getMicrophoneEnabled().getValue())); } } private void registerSystemPipChangeListeners() { addOnPictureInPictureModeChangedListener(pictureInPictureModeChangedInfo -> { CallParticipantsListDialog.dismiss(getSupportFragmentManager()); CallReactionScrubber.dismissCustomEmojiBottomSheet(getSupportFragmentManager()); }); } @Override protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); controlsAndInfo.onStateRestored(); } @Override protected void onStart() { super.onStart(); ephemeralStateDisposable = AppDependencies.getSignalCallManager() .ephemeralStates() .observeOn(AndroidSchedulers.mainThread()) .subscribe(viewModel::updateFromEphemeralState); } @Override public void onResume() { Log.i(TAG, "onResume()"); super.onResume(); initializeScreenshotSecurity(); if (!EventBus.getDefault().isRegistered(this)) { EventBus.getDefault().register(this); } WebRtcViewModel rtcViewModel = EventBus.getDefault().getStickyEvent(WebRtcViewModel.class); if (rtcViewModel == null) { Log.w(TAG, "Activity resumed without service event, perform delay destroy"); ThreadUtil.runOnMainDelayed(() -> { WebRtcViewModel delayRtcViewModel = EventBus.getDefault().getStickyEvent(WebRtcViewModel.class); if (delayRtcViewModel == null) { Log.w(TAG, "Activity still without service event, finishing activity"); finish(); } else { Log.i(TAG, "Event found after delay"); } }, TimeUnit.SECONDS.toMillis(1)); } if (enterPipOnResume) { enterPipOnResume = false; enterPipModeIfPossible(); } } @Override public void onNewIntent(Intent intent) { CallIntent callIntent = getCallIntent(); Log.i(TAG, "onNewIntent(" + callIntent.isStartedFromFullScreen() + ")"); super.onNewIntent(intent); logIntent(callIntent); processIntent(callIntent); } @Override public void onPause() { Log.i(TAG, "onPause"); super.onPause(); if (!callPermissionsDialogController.isAskingForPermission() && !viewModel.isCallStarting() && !isChangingConfigurations()) { CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot(); if (state != null && state.getCallState().isPreJoinOrNetworkUnavailable()) { finish(); } } } @Override protected void onStop() { Log.i(TAG, "onStop"); super.onStop(); ephemeralStateDisposable.dispose(); if (!isInPipMode() || isFinishing()) { EventBus.getDefault().unregister(this); requestNewSizesThrottle.clear(); } AppDependencies.getSignalCallManager().setEnableVideo(false); if (!viewModel.isCallStarting() && !isChangingConfigurations()) { CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot(); if (state != null) { if (state.getCallState().isPreJoinOrNetworkUnavailable()) { AppDependencies.getSignalCallManager().cancelPreJoin(); } else if (state.getCallState().getInOngoingCall() && isInPipMode()) { AppDependencies.getSignalCallManager().relaunchPipOnForeground(); } } } } @Override protected void onDestroy() { Log.d(TAG, "onDestroy"); super.onDestroy(); windowInfoTrackerCallbackAdapter.removeWindowLayoutInfoListener(windowLayoutInfoConsumer); EventBus.getDefault().unregister(this); } @SuppressLint("MissingSuperCall") @Override public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); } @Override protected void onUserLeaveHint() { super.onUserLeaveHint(); enterPipModeIfPossible(); } @Override public void onBackPressed() { if (!enterPipModeIfPossible()) { super.onBackPressed(); } } private @NonNull CallIntent getCallIntent() { return new CallIntent(getIntent()); } private boolean enterPipModeIfPossible() { if (isSystemPipEnabledAndAvailable()) { if (Boolean.TRUE.equals(viewModel.canEnterPipMode().getValue())) { try { enterPictureInPictureMode(pipBuilderParams.build()); } catch (Exception e) { Log.w(TAG, "Device lied to us about supporting PiP.", e); return false; } return true; } if (Build.VERSION.SDK_INT >= 31) { pipBuilderParams.setAutoEnterEnabled(false); } } return false; } private boolean isInPipMode() { return isSystemPipEnabledAndAvailable() && isInPictureInPictureMode(); } private void logIntent(@NonNull CallIntent intent) { Log.d(TAG, intent.toString()); } private void processIntent(@NonNull CallIntent intent) { switch (intent.getAction()) { case ANSWER_AUDIO -> handleAnswerWithAudio(); case ANSWER_VIDEO -> handleAnswerWithVideo(); case DENY -> handleDenyCall(); case END_CALL -> handleEndCall(); } if (System.currentTimeMillis() - lastProcessedIntentTimestamp > TimeUnit.SECONDS.toMillis(1)) { enterPipOnResume = intent.shouldLaunchInPip(); } lastProcessedIntentTimestamp = System.currentTimeMillis(); } private void initializePendingParticipantFragmentListener() { if (!RemoteConfig.adHocCalling()) { return; } getSupportFragmentManager().setFragmentResultListener( PendingParticipantsBottomSheet.REQUEST_KEY, this, (requestKey, result) -> { PendingParticipantsBottomSheet.Action action = PendingParticipantsBottomSheet.getAction(result); List recipientIds = viewModel.getPendingParticipantsSnapshot() .getUnresolvedPendingParticipants() .stream() .map(r -> r.getRecipient().getId()) .collect(Collectors.toList()); switch (action) { case NONE: break; case APPROVE_ALL: new MaterialAlertDialogBuilder(this) .setTitle(getResources().getQuantityString(R.plurals.WebRtcCallActivity__approve_d_requests, recipientIds.size(), recipientIds.size())) .setMessage(getResources().getQuantityString(R.plurals.WebRtcCallActivity__d_people_will_be_added_to_the_call, recipientIds.size(), recipientIds.size())) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(R.string.WebRtcCallActivity__approve_all, (dialog, which) -> { for (RecipientId id : recipientIds) { AppDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(id); } }) .show(); break; case DENY_ALL: new MaterialAlertDialogBuilder(this) .setTitle(getResources().getQuantityString(R.plurals.WebRtcCallActivity__deny_d_requests, recipientIds.size(), recipientIds.size())) .setMessage(getResources().getQuantityString(R.plurals.WebRtcCallActivity__d_people_will_be_added_to_the_call, recipientIds.size(), recipientIds.size())) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(R.string.WebRtcCallActivity__deny_all, (dialog, which) -> { for (RecipientId id : recipientIds) { AppDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(id); } }) .show(); break; } } ); } private void initializeScreenshotSecurity() { if (TextSecurePreferences.isScreenSecurityEnabled(this)) { getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); } else { getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE); } } private void initializeResources() { callScreen = findViewById(R.id.callScreen); callScreen.setControlsListener(new ControlsListener()); participantUpdateWindow = new CallParticipantsListUpdatePopupWindow(callScreen); callStateUpdatePopupWindow = new CallStateUpdatePopupWindow(callScreen); wifiToCellularPopupWindow = new WifiToCellularPopupWindow(callScreen); callOverflowPopupWindow = new CallOverflowPopupWindow(this, callScreen, () -> { CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot(); if (state == null) { return false; } return state.getLocalParticipant().isHandRaised(); }); getLifecycle().addObserver(participantUpdateWindow); } private @NonNull Orientation resolveOrientationFromContext() { int displayOrientation = getResources().getConfiguration().orientation; int displayRotation = ContextCompat.getDisplayOrDefault(this).getRotation(); if (displayOrientation == Configuration.ORIENTATION_PORTRAIT) { return Orientation.PORTRAIT_BOTTOM_EDGE; } else if (displayRotation == Surface.ROTATION_270) { return Orientation.LANDSCAPE_RIGHT_EDGE; } else { return Orientation.LANDSCAPE_LEFT_EDGE; } } private void initializeViewModel() { final Orientation orientation = resolveOrientationFromContext(); if (orientation == PORTRAIT_BOTTOM_EDGE) { WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_dark_colorSurface2)); WindowUtil.clearTranslucentNavigationBar(getWindow()); } LiveData> orientationAndLandscapeEnabled = Transformations.map(new MutableLiveData<>(orientation), o -> Pair.create(o, true)); viewModel = new ViewModelProvider(this).get(WebRtcCallViewModel.class); viewModel.setIsLandscapeEnabled(true); viewModel.setIsInPipMode(isInPipMode()); viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled); viewModel.getWebRtcControls().observe(this, controls -> { callScreen.setWebRtcControls(controls); controlsAndInfo.updateControls(controls); }); viewModel.getEvents().observe(this, this::handleViewModelEvent); lifecycleDisposable.add(viewModel.getInCallstatus().subscribe(this::handleInCallStatus)); lifecycleDisposable.add(viewModel.getRecipientFlowable().subscribe(callScreen::setRecipient)); boolean isStartedFromCallLink = getCallIntent().isStartedFromCallLink(); LiveDataUtil.combineLatest(LiveDataReactiveStreams.fromPublisher(viewModel.getCallParticipantsState().toFlowable(BackpressureStrategy.LATEST)), orientationAndLandscapeEnabled, viewModel.getEphemeralState(), (s, o, e) -> new CallParticipantsViewState(s, e, o.first == PORTRAIT_BOTTOM_EDGE, o.second, isStartedFromCallLink)) .observe(this, p -> callScreen.updateCallParticipants(p)); viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate); viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent); viewModel.getGroupMembersChanged().observe(this, unused -> updateGroupMembersForGroupCall()); viewModel.getGroupMemberCount().observe(this, this::handleGroupMemberCountChange); lifecycleDisposable.add(viewModel.shouldShowSpeakerHint().subscribe(this::updateSpeakerHint)); callScreen.getViewTreeObserver().addOnGlobalLayoutListener(() -> { CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot(); if (state != null) { if (state.needsNewRequestSizes()) { requestNewSizesThrottle.publish(() -> AppDependencies.getSignalCallManager().updateRenderedResolutions()); } } }); orientationAndLandscapeEnabled.observe(this, pair -> AppDependencies.getSignalCallManager().orientationChanged(pair.second, pair.first.getDegrees())); addOnPictureInPictureModeChangedListener(info -> { viewModel.setIsInPipMode(info.isInPictureInPictureMode()); participantUpdateWindow.setEnabled(!info.isInPictureInPictureMode()); callStateUpdatePopupWindow.setEnabled(!info.isInPictureInPictureMode()); if (info.isInPictureInPictureMode()) { callScreen.maybeDismissAudioPicker(); } viewModel.setIsLandscapeEnabled(info.isInPictureInPictureMode()); }); callScreen.setPendingParticipantsViewListener(new PendingParticipantsViewListener()); Disposable disposable = viewModel.getPendingParticipants() .subscribe(callScreen::updatePendingParticipantsList); lifecycleDisposable.add(disposable); controlsAndInfoViewModel = new ViewModelProvider(this).get(ControlsAndInfoViewModel.class); } private void initializePictureInPictureParams() { if (isSystemPipEnabledAndAvailable()) { final Orientation orientation = resolveOrientationFromContext(); final Rational aspectRatio; if (orientation == PORTRAIT_BOTTOM_EDGE) { aspectRatio = new Rational(9, 16); } else { aspectRatio = new Rational(16, 9); } pipBuilderParams = new PictureInPictureParams.Builder(); pipBuilderParams.setAspectRatio(aspectRatio); if (Build.VERSION.SDK_INT >= 31) { viewModel.canEnterPipMode().observe(this, canEnterPipMode -> { pipBuilderParams.setAutoEnterEnabled(canEnterPipMode); tryToSetPictureInPictureParams(); }); } else { tryToSetPictureInPictureParams(); } } } private void tryToSetPictureInPictureParams() { if (Build.VERSION.SDK_INT >= 26) { try { setPictureInPictureParams(pipBuilderParams.build()); } catch (Exception e) { Log.w(TAG, "System lied about having PiP available.", e); } } } private void handleViewModelEvent(@NonNull CallEvent event) { if (event instanceof CallEvent.StartCall) { startCall(((CallEvent.StartCall) event).isVideoCall()); return; } else if (event instanceof CallEvent.ShowGroupCallSafetyNumberChange) { SafetyNumberBottomSheet.forGroupCall(((CallEvent.ShowGroupCallSafetyNumberChange) event).getIdentityRecords()) .show(getSupportFragmentManager()); return; } else if (event instanceof CallEvent.SwitchToSpeaker) { callScreen.switchToSpeakerView(); return; } else if (event instanceof CallEvent.ShowSwipeToSpeakerHint) { CallToastPopupWindow.show(callScreen); return; } if (isInPipMode()) { return; } if (event instanceof CallEvent.ShowVideoTooltip) { if (videoTooltip == null) { videoTooltip = TooltipPopup.forTarget(callScreen.getVideoTooltipTarget()) .setBackgroundTint(ContextCompat.getColor(this, R.color.core_ultramarine)) .setTextColor(ContextCompat.getColor(this, R.color.core_white)) .setText(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video) .setOnDismissListener(() -> viewModel.onDismissedVideoTooltip()) .show(TooltipPopup.POSITION_ABOVE); } } else if (event instanceof CallEvent.DismissVideoTooltip) { if (videoTooltip != null) { videoTooltip.dismiss(); videoTooltip = null; } } else if (event instanceof CallEvent.ShowWifiToCellularPopup) { wifiToCellularPopupWindow.show(); } else if (event instanceof CallEvent.ShowSwitchCameraTooltip) { if (switchCameraTooltip == null) { switchCameraTooltip = TooltipPopup.forTarget(callScreen.getSwitchCameraTooltipTarget()) .setBackgroundTint(ContextCompat.getColor(this, R.color.core_ultramarine)) .setTextColor(ContextCompat.getColor(this, R.color.core_white)) .setText(R.string.WebRtcCallActivity__flip_camera_tooltip) .setOnDismissListener(() -> viewModel.onDismissedSwitchCameraTooltip()) .show(TooltipPopup.POSITION_ABOVE); } } else if (event instanceof CallEvent.DismissSwitchCameraTooltip) { if (switchCameraTooltip != null) { switchCameraTooltip.dismiss(); switchCameraTooltip = null; } } else { throw new IllegalArgumentException("Unknown event: " + event); } } private void handleInCallStatus(@NonNull InCallStatus inCallStatus) { if (inCallStatus instanceof InCallStatus.ElapsedTime) { EllapsedTimeFormatter ellapsedTimeFormatter = EllapsedTimeFormatter.fromDurationMillis(((InCallStatus.ElapsedTime) inCallStatus).getElapsedTime()); if (ellapsedTimeFormatter == null) { return; } callScreen.setStatus(getString(R.string.WebRtcCallActivity__signal_s, ellapsedTimeFormatter.toString())); } else if (inCallStatus instanceof InCallStatus.PendingCallLinkUsers) { int waiting = ((InCallStatus.PendingCallLinkUsers) inCallStatus).getPendingUserCount(); callScreen.setStatus(getResources().getQuantityString( R.plurals.WebRtcCallActivity__d_people_waiting, waiting, waiting )); } else if (inCallStatus instanceof InCallStatus.JoinedCallLinkUsers) { int joined = ((InCallStatus.JoinedCallLinkUsers) inCallStatus).getJoinedUserCount(); callScreen.setStatus(getResources().getQuantityString( R.plurals.WebRtcCallActivity__d_people, joined, joined )); }else { throw new AssertionError(); } } private void handleSetAudioHandset() { AppDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.EARPIECE)); } private void handleSetAudioSpeaker() { AppDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.SPEAKER_PHONE)); } private void handleSetAudioBluetooth() { AppDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.BLUETOOTH)); } private void handleSetAudioWiredHeadset() { AppDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.WIRED_HEADSET)); } private void handleSetMuteAudio(boolean enabled) { AppDependencies.getSignalCallManager().setMuteAudio(enabled); } private void handleSetMuteVideo(boolean muted) { Recipient recipient = viewModel.getRecipient().get(); if (!recipient.equals(Recipient.UNKNOWN)) { Runnable onGranted = () -> AppDependencies.getSignalCallManager().setEnableVideo(!muted); askCameraPermissions(onGranted); } } private void handleFlipCamera() { AppDependencies.getSignalCallManager().flipCamera(); } private void handleAnswerWithAudio() { Runnable onGranted = () -> { callScreen.setStatus(getString(R.string.RedPhone_answering)); AppDependencies.getSignalCallManager().acceptCall(false); }; askAudioPermissions(onGranted); } private void handleAnswerWithVideo() { Runnable onGranted = () -> { callScreen.setStatus(getString(R.string.RedPhone_answering)); AppDependencies.getSignalCallManager().acceptCall(true); handleSetMuteVideo(false); }; if (!hasCameraPermission() &!hasAudioPermission()) { askCameraAudioPermissions(onGranted); } else if (!hasAudioPermission()) { askAudioPermissions(onGranted); } else { askCameraPermissions(onGranted); } } private void handleDenyCall() { Recipient recipient = viewModel.getRecipient().get(); if (!recipient.equals(Recipient.UNKNOWN)) { AppDependencies.getSignalCallManager().denyCall(); callScreen.setRecipient(recipient); callScreen.setStatus(getString(R.string.RedPhone_ending_call)); delayedFinish(); } } private void handleEndCall() { Log.i(TAG, "Hangup pressed, handling termination now..."); AppDependencies.getSignalCallManager().localHangup(); } private void handleOutgoingCall(@NonNull WebRtcViewModel event) { if (event.getGroupState().isNotIdle()) { callScreen.setStatusFromGroupCallState(event.getGroupState()); } else { callScreen.setStatus(getString(R.string.WebRtcCallActivity__calling)); } } private void handleTerminate(@NonNull Recipient recipient, @NonNull HangupMessage.Type hangupType) { Log.i(TAG, "handleTerminate called: " + hangupType.name()); callScreen.setStatusFromHangupType(hangupType); EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); if (hangupType == HangupMessage.Type.NEED_PERMISSION) { startActivity(CalleeMustAcceptMessageRequestActivity.createIntent(this, recipient.getId())); } delayedFinish(); } private void handleGlare(@NonNull Recipient recipient) { Log.i(TAG, "handleGlare: " + recipient.getId()); callScreen.setStatus(""); } private void handleCallRinging() { callScreen.setStatus(getString(R.string.RedPhone_ringing)); } private void handleCallBusy() { EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); callScreen.setStatus(getString(R.string.RedPhone_busy)); delayedFinish(SignalCallManager.BUSY_TONE_LENGTH); } private void handleCallConnected(@NonNull WebRtcViewModel event) { getWindow().addFlags(WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES); if (event.getGroupState().isNotIdleOrConnected()) { callScreen.setStatusFromGroupCallState(event.getGroupState()); } } private void handleCallReconnecting() { callScreen.setStatus(getString(R.string.WebRtcCallActivity__reconnecting)); VibrateUtil.vibrate(this, VIBRATE_DURATION); } private void handleRecipientUnavailable() { EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); callScreen.setStatus(getString(R.string.RedPhone_recipient_unavailable)); delayedFinish(); } private void handleServerFailure() { EventBus.getDefault().removeStickyEvent(WebRtcViewModel.class); callScreen.setStatus(getString(R.string.RedPhone_network_failed)); } private void handleNoSuchUser(final @NonNull WebRtcViewModel event) { if (isFinishing()) return; // XXX Stuart added this check above, not sure why, so I'm repeating in ignorance. - moxie new MaterialAlertDialogBuilder(this) .setTitle(R.string.RedPhone_number_not_registered) .setIcon(R.drawable.symbol_error_triangle_fill_24) .setMessage(R.string.RedPhone_the_number_you_dialed_does_not_support_secure_voice) .setCancelable(true) .setPositiveButton(R.string.RedPhone_got_it, (d, w) -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL)) .setOnCancelListener(d -> handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL)) .show(); } private void handleUntrustedIdentity(@NonNull WebRtcViewModel event) { final IdentityKey theirKey = event.getRemoteParticipants().get(0).getIdentityKey(); final Recipient recipient = event.getRemoteParticipants().get(0).getRecipient(); if (theirKey == null) { Log.w(TAG, "Untrusted identity without an identity key."); } SafetyNumberBottomSheet.forCall(recipient.getId()).show(getSupportFragmentManager()); } public void handleSafetyNumberChangeEvent(@NonNull WebRtcCallViewModel.SafetyNumberChangeEvent safetyNumberChangeEvent) { if (Util.hasItems(safetyNumberChangeEvent.getRecipientIds())) { if (safetyNumberChangeEvent.isInPipMode()) { GroupCallSafetyNumberChangeNotificationUtil.showNotification(this, viewModel.getRecipient().get()); } else { GroupCallSafetyNumberChangeNotificationUtil.cancelNotification(this, viewModel.getRecipient().get()); SafetyNumberBottomSheet.forDuringGroupCall(safetyNumberChangeEvent.getRecipientIds()).show(getSupportFragmentManager()); } } } private void updateGroupMembersForGroupCall() { AppDependencies.getSignalCallManager().requestUpdateGroupMembers(); } public void handleGroupMemberCountChange(int count) { boolean canRing = count <= RemoteConfig.maxGroupCallRingSize(); callScreen.enableRingGroup(canRing); AppDependencies.getSignalCallManager().setRingGroup(canRing); } private void updateSpeakerHint(boolean showSpeakerHint) { if (showSpeakerHint) { callScreen.showSpeakerViewHint(); } else { callScreen.hideSpeakerViewHint(); } } @Override public void onSendAnywayAfterSafetyNumberChange(@NonNull List changedRecipients) { CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot(); if (state == null) { return; } if (state.isCallLink()) { CallLinkProfileKeySender.onSendAnyway(new HashSet<>(changedRecipients)); } if (state.getGroupCallState().isConnected()) { AppDependencies.getSignalCallManager().groupApproveSafetyChange(changedRecipients); } else { viewModel.startCall(state.getLocalParticipant().isVideoEnabled()); } } @Override public void onMessageResentAfterSafetyNumberChange() {} @Override public void onCanceled() { CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot(); if (state != null && state.getGroupCallState().isNotIdle()) { if (state.getCallState().isPreJoinOrNetworkUnavailable()) { AppDependencies.getSignalCallManager().cancelPreJoin(); finish(); } else { handleEndCall(); } } else { handleTerminate(viewModel.getRecipient().get(), HangupMessage.Type.NORMAL); } } private boolean isSystemPipEnabledAndAvailable() { return Build.VERSION.SDK_INT >= 26 && getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE); } private void delayedFinish() { delayedFinish(STANDARD_DELAY_FINISH); } private void delayedFinish(int delayMillis) { callScreen.postDelayed(WebRtcCallActivity.this::finish, delayMillis); } @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) public void onEventMainThread(@NonNull WebRtcViewModel event) { Log.i(TAG, "Got message from service: " + event.describeDifference(previousEvent)); previousEvent = event; viewModel.setRecipient(event.getRecipient()); controlsAndInfoViewModel.setRecipient(event.getRecipient()); switch (event.getState()) { case CALL_PRE_JOIN: handleCallPreJoin(event); break; case CALL_CONNECTED: handleCallConnected(event); break; case CALL_RECONNECTING: handleCallReconnecting(); break; case NETWORK_FAILURE: handleServerFailure(); break; case CALL_RINGING: handleCallRinging(); break; case CALL_DISCONNECTED: handleTerminate(event.getRecipient(), HangupMessage.Type.NORMAL); break; case CALL_DISCONNECTED_GLARE: handleGlare(event.getRecipient()); break; case CALL_ACCEPTED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.ACCEPTED); break; case CALL_DECLINED_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.DECLINED); break; case CALL_ONGOING_ELSEWHERE: handleTerminate(event.getRecipient(), HangupMessage.Type.BUSY); break; case CALL_NEEDS_PERMISSION: handleTerminate(event.getRecipient(), HangupMessage.Type.NEED_PERMISSION); break; case NO_SUCH_USER: handleNoSuchUser(event); break; case RECIPIENT_UNAVAILABLE: handleRecipientUnavailable(); break; case CALL_OUTGOING: handleOutgoingCall(event); break; case CALL_BUSY: handleCallBusy(); break; case UNTRUSTED_IDENTITY: handleUntrustedIdentity(event); break; } if (event.getCallLinkDisconnectReason() != null && event.getCallLinkDisconnectReason().getPostedAt() > lastCallLinkDisconnectDialogShowTime) { lastCallLinkDisconnectDialogShowTime = System.currentTimeMillis(); if (event.getCallLinkDisconnectReason() instanceof CallLinkDisconnectReason.RemovedFromCall) { displayRemovedFromCallLinkDialog(); } else if (event.getCallLinkDisconnectReason() instanceof CallLinkDisconnectReason.DeniedRequestToJoinCall) { displayDeniedRequestToJoinCallLinkDialog(); } else { throw new AssertionError("Unexpected reason: " + event.getCallLinkDisconnectReason()); } } boolean enableVideo = event.getLocalParticipant().getCameraState().getCameraCount() > 0 && enableVideoIfAvailable; viewModel.updateFromWebRtcViewModel(event, enableVideo); if (enableVideo) { enableVideoIfAvailable = false; handleSetMuteVideo(false); } if (event.getBluetoothPermissionDenied() && !hasWarnedAboutBluetooth && !isFinishing()) { new MaterialAlertDialogBuilder(this) .setTitle(R.string.WebRtcCallActivity__bluetooth_permission_denied) .setMessage(R.string.WebRtcCallActivity__please_enable_the_nearby_devices_permission_to_use_bluetooth_during_a_call) .setPositiveButton(R.string.WebRtcCallActivity__open_settings, (d, w) -> startActivity(Permissions.getApplicationSettingsIntent(this))) .setNegativeButton(R.string.WebRtcCallActivity__not_now, null) .show(); hasWarnedAboutBluetooth = true; } } private void displayRemovedFromCallLinkDialog() { new MaterialAlertDialogBuilder(this) .setTitle(R.string.WebRtcCallActivity__removed_from_call) .setMessage(R.string.WebRtcCallActivity__someone_has_removed_you_from_the_call) .setPositiveButton(android.R.string.ok, null) .show(); } private void displayDeniedRequestToJoinCallLinkDialog() { new MaterialAlertDialogBuilder(this) .setTitle(R.string.WebRtcCallActivity__join_request_denied) .setMessage(R.string.WebRtcCallActivity__your_request_to_join_this_call_has_been_denied) .setPositiveButton(android.R.string.ok, null) .show(); } private void handleCallPreJoin(@NonNull WebRtcViewModel event) { if (event.getGroupState().isNotIdle()) { callScreen.setRingGroup(event.shouldRingGroup()); if (event.shouldRingGroup() && event.areRemoteDevicesInCall()) { AppDependencies.getSignalCallManager().setRingGroup(false); } } } private boolean hasCameraPermission() { return Permissions.hasAll(this, Manifest.permission.CAMERA); } private boolean hasAudioPermission() { return Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO); } private void askCameraPermissions(@NonNull Runnable onGranted) { callPermissionsDialogController.requestCameraPermission( this, () -> { onGranted.run(); findViewById(R.id.missing_permissions_container).setVisibility(View.GONE); } ); } private void askAudioPermissions(@NonNull Runnable onGranted) { callPermissionsDialogController.requestAudioPermission( this, onGranted, this::handleDenyCall ); } public void askCameraAudioPermissions(@NonNull Runnable onGranted) { callPermissionsDialogController.requestCameraAndAudioPermission( this, onGranted, () -> findViewById(R.id.missing_permissions_container).setVisibility(View.GONE), this::handleDenyCall ); } private void startCall(boolean isVideoCall) { enableVideoIfAvailable = isVideoCall; if (isVideoCall) { AppDependencies.getSignalCallManager().startOutgoingVideoCall(viewModel.getRecipient().get()); } else { AppDependencies.getSignalCallManager().startOutgoingAudioCall(viewModel.getRecipient().get()); } MessageSender.onMessageSent(); } @Override public void onReactWithAnyEmojiDialogDismissed() { /* no-op */ } @Override public void onReactWithAnyEmojiSelected(@NonNull String emoji) { AppDependencies.getSignalCallManager().react(emoji); callOverflowPopupWindow.dismiss(); } private final class ControlsListener implements WebRtcCallView.ControlsListener { @Override public void onStartCall(boolean isVideoCall) { viewModel.startCall(isVideoCall); } @Override public void onCancelStartCall() { finish(); } @Override public void toggleControls() { WebRtcControls controlState = viewModel.getWebRtcControls().getValue(); if (controlState != null && !controlState.displayIncomingCallButtons() && !controlState.displayErrorControls()) { controlsAndInfo.toggleControls(); } } @Override public void onAudioPermissionsRequested(Runnable onGranted) { askAudioPermissions(onGranted); } @Override public void onAudioOutputChanged(@NonNull WebRtcAudioOutput audioOutput) { maybeDisplaySpeakerphonePopup(audioOutput); switch (audioOutput) { case HANDSET: handleSetAudioHandset(); break; case BLUETOOTH_HEADSET: handleSetAudioBluetooth(); break; case SPEAKER: handleSetAudioSpeaker(); break; case WIRED_HEADSET: handleSetAudioWiredHeadset(); break; default: throw new IllegalStateException("Unknown output: " + audioOutput); } } @RequiresApi(31) @Override public void onAudioOutputChanged31(@NonNull WebRtcAudioDevice audioOutput) { maybeDisplaySpeakerphonePopup(audioOutput.getWebRtcAudioOutput()); AppDependencies.getSignalCallManager().selectAudioDevice(new SignalAudioManager.ChosenAudioDeviceIdentifier(audioOutput.getDeviceId())); } @Override public void onVideoChanged(boolean isVideoEnabled) { handleSetMuteVideo(!isVideoEnabled); } @Override public void onMicChanged(boolean isMicEnabled) { Runnable onGranted = () -> { callStateUpdatePopupWindow.onCallStateUpdate(isMicEnabled ? CallControlsChange.MIC_ON : CallControlsChange.MIC_OFF); handleSetMuteAudio(!isMicEnabled); }; askAudioPermissions(onGranted); } @Override public void onCameraDirectionChanged() { handleFlipCamera(); } @Override public void onEndCallPressed() { handleEndCall(); } @Override public void onDenyCallPressed() { handleDenyCall(); } @Override public void onAcceptCallWithVoiceOnlyPressed() { handleAnswerWithAudio(); } @Override public void onOverflowClicked() { controlsAndInfo.toggleOverflowPopup(); } @Override public void onAcceptCallPressed() { if (viewModel.isAnswerWithVideoAvailable()) { handleAnswerWithVideo(); } else { handleAnswerWithAudio(); } } @Override public void onPageChanged(@NonNull CallParticipantsState.SelectedPage page) { viewModel.setIsViewingFocusedParticipant(page); } @Override public void onLocalPictureInPictureClicked() { viewModel.onLocalPictureInPictureClicked(); controlsAndInfo.restartHideControlsTimer(); } @Override public void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed) { if (ringingAllowed) { AppDependencies.getSignalCallManager().setRingGroup(ringGroup); callStateUpdatePopupWindow.onCallStateUpdate(ringGroup ? CallControlsChange.RINGING_ON : CallControlsChange.RINGING_OFF); } else { AppDependencies.getSignalCallManager().setRingGroup(false); callStateUpdatePopupWindow.onCallStateUpdate(CallControlsChange.RINGING_DISABLED); } } @Override public void onCallInfoClicked() { controlsAndInfo.showCallInfo(); } @Override public void onNavigateUpClicked() { onBackPressed(); } } private void maybeDisplaySpeakerphonePopup(WebRtcAudioOutput nextOutput) { final WebRtcAudioOutput currentOutput = viewModel.getCurrentAudioOutput(); if (currentOutput == WebRtcAudioOutput.SPEAKER && nextOutput != WebRtcAudioOutput.SPEAKER) { callStateUpdatePopupWindow.onCallStateUpdate(CallControlsChange.SPEAKER_OFF); } else if (currentOutput != WebRtcAudioOutput.SPEAKER && nextOutput == WebRtcAudioOutput.SPEAKER) { callStateUpdatePopupWindow.onCallStateUpdate(CallControlsChange.SPEAKER_ON); } } private class PendingParticipantsViewListener implements PendingParticipantsView.Listener { @Override public void onAllowPendingRecipient(@NonNull Recipient pendingRecipient) { AppDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(pendingRecipient.getId()); } @Override public void onRejectPendingRecipient(@NonNull Recipient pendingRecipient) { AppDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(pendingRecipient.getId()); } @Override public void onLaunchPendingRequestsSheet() { new PendingParticipantsBottomSheet().show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); } @Override public void onLaunchRecipientSheet(@NonNull Recipient pendingRecipient) { CallLinkIncomingRequestSheet.show(getSupportFragmentManager(), pendingRecipient.getId()); } } private class WindowLayoutInfoConsumer implements Consumer { @Override public void accept(WindowLayoutInfo windowLayoutInfo) { Log.d(TAG, "On WindowLayoutInfo accepted: " + windowLayoutInfo.toString()); Optional feature = windowLayoutInfo.getDisplayFeatures().stream().filter(f -> f instanceof FoldingFeature).findFirst(); if (feature.isPresent()) { FoldingFeature foldingFeature = (FoldingFeature) feature.get(); Rect bounds = foldingFeature.getBounds(); if (foldingFeature.isSeparating()) { Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in table-top display mode"); viewModel.setFoldableState(WebRtcControls.FoldableState.folded(bounds.top)); } else { Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in flat display mode"); viewModel.setFoldableState(WebRtcControls.FoldableState.flat()); } } } } private class FadeCallback implements ControlsAndInfoController.BottomSheetVisibilityListener { @Override public void onShown() { fullscreenHelper.showSystemUI(); } @Override public void onHidden() { WebRtcControls controlState = viewModel.getWebRtcControls().getValue(); if (controlState == null || !controlState.displayErrorControls()) { fullscreenHelper.hideSystemUI(); if (videoTooltip != null) { videoTooltip.dismiss(); } } } } }