From fee7d20cc67308b100dc01c2faa3832e087660a0 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 31 Jan 2025 12:52:45 -0400 Subject: [PATCH] Convert WebRtcCallingActivity to Kotlin. --- app/src/main/AndroidManifest.xml | 2 +- .../securesms/WebRtcCallActivity.java | 1331 ----------------- .../webrtc/CallOverflowPopupWindow.kt | 2 +- ...allSafetyNumberChangeNotificationUtil.java | 4 +- .../controls/ControlsAndInfoController.kt | 26 +- .../components/webrtc/v2/CallIntent.kt | 4 +- .../webrtc/v2/WebRtcCallActivity.kt | 1196 +++++++++++++++ .../service/webrtc/SignalCallManager.java | 3 +- .../securesms/util/CommunicationActions.java | 2 - .../securesms/webrtc/VoiceCallShare.java | 6 +- 10 files changed, 1219 insertions(+), 1357 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8995b88543..8c3a3028fe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -128,7 +128,7 @@ - . - */ - -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.signal.ringrtc.GroupCall; -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.keyvalue.SignalStore; -import org.thoughtcrime.securesms.messagerequests.CalleeMustAcceptMessageRequestActivity; -import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment; -import org.thoughtcrime.securesms.ratelimit.RecaptchaRequiredEvent; -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.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, RecaptchaProofBottomSheetFragment.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); - - if (savedInstanceState == null) { - 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); - } else { - Log.d(TAG, "Activity likely rotated, not processing intent"); - } - - 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(); - } - - if (SignalStore.rateLimit().needsRecaptcha()) { - RecaptchaProofBottomSheetFragment.show(getSupportFragmentManager()); - } - } - - @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 (!isInPipMode() || isFinishing()) { - EventBus.getDefault().unregister(this); - } - - if (!callPermissionsDialogController.isAskingForPermission() && !viewModel.isCallStarting() && !isChangingConfigurations()) { - CallParticipantsState state = viewModel.getCallParticipantsStateSnapshot(); - if (state != null && (state.getCallState().isPreJoinOrNetworkUnavailable() || state.getCallState().isIncomingOrHandledElsewhere())) { - 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() || state.getCallState().isIncomingOrHandledElsewhere()) { - 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); - } - - @Subscribe(threadMode = ThreadMode.MAIN) - public void onRecaptchaRequiredEvent(RecaptchaRequiredEvent recaptchaRequiredEvent) { - RecaptchaProofBottomSheetFragment.show(getSupportFragmentManager()); - } - - @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() { - 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_not_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 handleGroupCallHasMaxDevices(@NonNull Recipient recipient) { - new MaterialAlertDialogBuilder(this) - .setMessage(R.string.WebRtcCallView__call_is_full) - .setPositiveButton(android.R.string.ok, (d, w) -> handleTerminate(recipient, HangupMessage.Type.NORMAL)) - .show(); - } - - 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)); - - WebRtcViewModel.State previousCallState = previousEvent != null ? previousEvent.getState() : null; - - previousEvent = event; - - viewModel.setRecipient(event.getRecipient()); - controlsAndInfoViewModel.setRecipient(event.getRecipient()); - - switch (event.getState()) { - case CALL_INCOMING: - if (previousCallState == WebRtcViewModel.State.NETWORK_FAILURE) { - Log.d(TAG, "Incoming call directly from network failure state. Recreating activity."); - recreate(); - return; - } - - break; - 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: - if (event.getGroupCallEndReason() == GroupCall.GroupCallEndReason.HAS_MAX_DEVICES) { - handleGroupCallHasMaxDevices(event.getRecipient()); - } else { - 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(); - } - - @Override - public void onProofCompleted() { - AppDependencies.getSignalCallManager().resendMediaKeys(); - } - - 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(); - } - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallOverflowPopupWindow.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallOverflowPopupWindow.kt index f79ded7a80..9fa67b1de1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallOverflowPopupWindow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallOverflowPopupWindow.kt @@ -68,7 +68,7 @@ class CallOverflowPopupWindow(private val activity: FragmentActivity, parentView PopupWindowCompat.showAsDropDown(this, anchor, xOffset, yOffset, Gravity.NO_GRAVITY) } - interface RaisedHandDelegate { + fun interface RaisedHandDelegate { fun isSelfHandRaised(): Boolean } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/GroupCallSafetyNumberChangeNotificationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/GroupCallSafetyNumberChangeNotificationUtil.java index 24236bc86e..a06909e90b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/GroupCallSafetyNumberChangeNotificationUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/GroupCallSafetyNumberChangeNotificationUtil.java @@ -11,7 +11,7 @@ import androidx.core.app.NotificationManagerCompat; import org.signal.core.util.PendingIntentFlags; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.WebRtcCallActivity; +import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent; import org.thoughtcrime.securesms.notifications.NotificationChannels; import org.thoughtcrime.securesms.recipients.Recipient; @@ -26,7 +26,7 @@ public final class GroupCallSafetyNumberChangeNotificationUtil { } public static void showNotification(@NonNull Context context, @NonNull Recipient recipient) { - Intent contentIntent = new Intent(context, WebRtcCallActivity.class); + Intent contentIntent = new Intent(context, CallIntent.getActivityClass()); contentIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, contentIntent, PendingIntentFlags.mutable()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt index b9bc2391bd..3d587ad131 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoController.kt @@ -46,8 +46,8 @@ import io.reactivex.rxjava3.kotlin.subscribeBy import kotlinx.parcelize.Parcelize import org.signal.core.util.dp import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.BaseActivity import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.WebRtcCallActivity import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow @@ -65,7 +65,7 @@ import kotlin.time.Duration.Companion.seconds * Brain for rendering the call controls and info within a bottom sheet when we display the activity in portrait mode. */ class ControlsAndInfoController private constructor( - private val webRtcCallActivity: WebRtcCallActivity, + private val activity: BaseActivity, private val webRtcCallView: WebRtcCallView, private val overflowPopupWindow: CallOverflowPopupWindow, private val viewModel: WebRtcCallViewModel, @@ -74,13 +74,13 @@ class ControlsAndInfoController private constructor( ) : Disposable by disposables { constructor( - webRtcCallActivity: WebRtcCallActivity, + activity: BaseActivity, webRtcCallView: WebRtcCallView, overflowPopupWindow: CallOverflowPopupWindow, viewModel: WebRtcCallViewModel, controlsAndInfoViewModel: ControlsAndInfoViewModel ) : this( - webRtcCallActivity, + activity, webRtcCallView, overflowPopupWindow, viewModel, @@ -108,15 +108,15 @@ class ControlsAndInfoController private constructor( private val aboveControlsGuideline: Guideline = webRtcCallView.findViewById(R.id.call_screen_above_controls_guideline) private val toggleCameraDirectionView: View = webRtcCallView.findViewById(R.id.call_screen_camera_direction_toggle) private val callControls: ConstraintLayout = webRtcCallView.findViewById(R.id.call_controls_constraint_layout) - private val isLandscape = webRtcCallActivity.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + private val isLandscape = activity.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE private val waitingToBeLetInProgressDrawable = IndeterminateDrawable.createCircularDrawable( - webRtcCallActivity, - CircularProgressIndicatorSpec(webRtcCallActivity, null).apply { + activity, + CircularProgressIndicatorSpec(activity, null).apply { indicatorSize = 20.dp indicatorInset = 0.dp trackThickness = 2.dp trackCornerRadius = 1.dp - indicatorColors = intArrayOf(ContextCompat.getColor(webRtcCallActivity, R.color.signal_colorOnBackground)) + indicatorColors = intArrayOf(ContextCompat.getColor(activity, R.color.signal_colorOnBackground)) trackColor = Color.TRANSPARENT } ) @@ -133,7 +133,7 @@ class ControlsAndInfoController private constructor( private var previousCallControlHeightData = HeightData() private var controlState: WebRtcControls = WebRtcControls.NONE - private val callInfoCallbacks = CallInfoCallbacks(webRtcCallActivity, controlsAndInfoViewModel) + private val callInfoCallbacks = CallInfoCallbacks(activity, controlsAndInfoViewModel) init { raiseHandComposeView.apply { @@ -179,9 +179,9 @@ class ControlsAndInfoController private constructor( hide(delay = HIDE_CONTROL_DELAY) } - webRtcCallActivity + activity .supportFragmentManager - .setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, webRtcCallActivity) { resultKey, bundle -> + .setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, activity) { resultKey, bundle -> if (bundle.containsKey(resultKey)) { setName(bundle.getString(resultKey)!!) } @@ -193,7 +193,7 @@ class ControlsAndInfoController private constructor( .setTopRightCorner(CornerFamily.ROUNDED, 18.dp.toFloat()) .build() ).apply { - fillColor = ColorStateList.valueOf(ContextCompat.getColor(webRtcCallActivity, R.color.signal_colorSurface)) + fillColor = ColorStateList.valueOf(ContextCompat.getColor(activity, R.color.signal_colorSurface)) } behavior.isHideable = true @@ -440,7 +440,7 @@ class ControlsAndInfoController private constructor( } private fun toastFailure() { - Toast.makeText(webRtcCallActivity, R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show() + Toast.makeText(activity, R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show() } private fun ConstraintSet.setControlConstraints(@IdRes viewId: Int, visible: Boolean, @Px horizontalMargins: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallIntent.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallIntent.kt index d79d1fc67c..09878b0e04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallIntent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallIntent.kt @@ -8,7 +8,6 @@ package org.thoughtcrime.securesms.components.webrtc.v2 import android.app.Activity import android.content.Context import android.content.Intent -import org.thoughtcrime.securesms.WebRtcCallActivity import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.RemoteConfig @@ -23,7 +22,8 @@ class CallIntent( private const val CALL_INTENT_PREFIX = "CallIntent" - private fun getActivityClass(): Class = if (RemoteConfig.newCallUi || SignalStore.internal.newCallingUi) { + @JvmStatic + fun getActivityClass(): Class = if (RemoteConfig.newCallUi || SignalStore.internal.newCallingUi) { CallActivity::class.java } else { WebRtcCallActivity::class.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt new file mode 100644 index 0000000000..3aa76a31e7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallActivity.kt @@ -0,0 +1,1196 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.v2 + +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.media.AudioManager +import android.os.Build +import android.os.Bundle +import android.util.Rational +import android.view.Surface +import android.view.View +import android.view.Window +import android.view.WindowManager +import androidx.activity.viewModels +import androidx.annotation.RequiresApi +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.content.ContextCompat +import androidx.core.util.Consumer +import androidx.lifecycle.toLiveData +import androidx.window.java.layout.WindowInfoTrackerCallbackAdapter +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +import androidx.window.layout.WindowLayoutInfo +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.BackpressureStrategy +import io.reactivex.rxjava3.disposables.Disposable +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.ringrtc.GroupCall +import org.thoughtcrime.securesms.BaseActivity +import org.thoughtcrime.securesms.R +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.WebRtcCallViewModel.SafetyNumberChangeEvent +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.conversation.ui.error.SafetyNumberChangeDialog +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.events.WebRtcViewModel +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.messagerequests.CalleeMustAcceptMessageRequestActivity +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment +import org.thoughtcrime.securesms.ratelimit.RecaptchaRequiredEvent +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.VibrateUtil +import org.thoughtcrime.securesms.util.WindowUtil +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil +import org.thoughtcrime.securesms.util.visible +import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager +import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager.ChosenAudioDeviceIdentifier +import org.whispersystems.signalservice.api.messages.calls.HangupMessage +import kotlin.time.Duration.Companion.seconds + +/** Conversion */ +class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, ReactWithAnyEmojiBottomSheetDialogFragment.Callback, RecaptchaProofBottomSheetFragment.Callback { + + companion object { + val TAG = Log.tag(WebRtcCallActivity::class) + + private const val STANDARD_DELAY_FINISH = 1000L + private const val VIBRATE_DURATION = 50 + } + + private lateinit var participantUpdateWindow: CallParticipantsListUpdatePopupWindow + private lateinit var callStateUpdatePopupWindow: CallStateUpdatePopupWindow + private lateinit var callOverflowPopupWindow: CallOverflowPopupWindow + private lateinit var wifiToCellularPopupWindow: WifiToCellularPopupWindow + + private lateinit var fullscreenHelper: FullscreenHelper + private lateinit var callScreen: WebRtcCallView + private var videoTooltip: TooltipPopup? = null + private var switchCameraTooltip: TooltipPopup? = null + private val viewModel: WebRtcCallViewModel by viewModels() + private val controlsAndInfoViewModel: ControlsAndInfoViewModel by viewModels() + private var enableVideoIfAvailable: Boolean = false + private var hasWarnedAboutBluetooth: Boolean = false + private lateinit var windowLayoutInfoConsumer: WindowLayoutInfoConsumer + private lateinit var windowInfoTrackerCallbackAdapter: WindowInfoTrackerCallbackAdapter + private lateinit var requestNewSizesThrottle: ThrottledDebouncer + private lateinit var pipBuilderParams: PictureInPictureParams.Builder + private val lifecycleDisposable = LifecycleDisposable() + private var lastCallLinkDisconnectDialogShowTime: Long = 0L + private lateinit var controlsAndInfo: ControlsAndInfoController + private var enterPipOnResume: Boolean = false + private var lastProcessedIntentTimestamp = 0L + private var previousEvent: WebRtcViewModel? = null + private var ephemeralStateDisposable = Disposable.empty() + private val callPermissionsDialogController = CallPermissionsDialogController() + + override fun attachBaseContext(newBase: Context) { + delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES + super.attachBaseContext(newBase) + } + + @SuppressLint("MissingInflatedId") + override fun onCreate(savedInstanceState: Bundle?) { + val callIntent: CallIntent = getCallIntent() + Log.i(TAG, "onCreate(${callIntent.isStartedFromFullScreen})") + + lifecycleDisposable.bindTo(this) + + window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + super.onCreate(savedInstanceState) + + requestWindowFeature(Window.FEATURE_NO_TITLE) + setContentView(R.layout.webrtc_call_activity) + + fullscreenHelper = FullscreenHelper(this) + + volumeControlStream = AudioManager.STREAM_VOICE_CALL + + initializeResources() + initializeViewModel() + initializePictureInPictureParams() + + controlsAndInfo = ControlsAndInfoController(this, callScreen, callOverflowPopupWindow, viewModel, controlsAndInfoViewModel) + controlsAndInfo.addVisibilityListener(FadeCallback()) + + fullscreenHelper.showAndHideWithSystemUI( + window, + 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) + + if (savedInstanceState == null) { + logIntent(callIntent) + + if (callIntent.action == CallIntent.Action.ANSWER_VIDEO) { + enableVideoIfAvailable = true + } else if (callIntent.action == CallIntent.Action.ANSWER_AUDIO || callIntent.isStartedFromFullScreen) { + enableVideoIfAvailable = false + } else { + enableVideoIfAvailable = callIntent.shouldEnableVideoIfAvailable + callIntent.shouldEnableVideoIfAvailable = false + } + + processIntent(callIntent) + } else { + Log.d(TAG, "Activity likely rotated, not processing intent") + } + + registerSystemPipChangeListeners() + + windowLayoutInfoConsumer = WindowLayoutInfoConsumer() + + windowInfoTrackerCallbackAdapter = WindowInfoTrackerCallbackAdapter(WindowInfoTracker.getOrCreate(this)) + windowInfoTrackerCallbackAdapter.addWindowLayoutInfoListener(this, SignalExecutors.BOUNDED, windowLayoutInfoConsumer) + + requestNewSizesThrottle = ThrottledDebouncer(1.seconds.inWholeMilliseconds) + + initializePendingParticipantFragmentListener() + + WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_dark_colorSurface)) + + if (!hasCameraPermission() && !hasAudioPermission()) { + askCameraAudioPermissions { + callScreen.setMicEnabled(viewModel.microphoneEnabled.value ?: false) + handleSetMuteVideo(false) + } + } else if (!hasAudioPermission()) { + askAudioPermissions { + callScreen.setMicEnabled(viewModel.microphoneEnabled.value ?: false) + } + } + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + controlsAndInfo.onStateRestored() + } + + override fun onStart() { + super.onStart() + + ephemeralStateDisposable = AppDependencies.signalCallManager + .ephemeralStates() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(viewModel::updateFromEphemeralState) + } + + override fun onResume() { + Log.i(TAG, "onResume()") + super.onResume() + + initializeScreenshotSecurity() + + if (!EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().register(this) + } + + val rtcViewModel = EventBus.getDefault().getStickyEvent(WebRtcViewModel::class.java) + if (rtcViewModel == null) { + Log.w(TAG, "Activity resumed without service event, perform delay destroy") + ThreadUtil.runOnMainDelayed({ + val delayedViewModel = EventBus.getDefault().getStickyEvent(WebRtcViewModel::class.java) + if (delayedViewModel == null) { + Log.w(TAG, "Activity still without service event, finishing activity") + finish() + } else { + Log.i(TAG, "Event found after delay") + } + }, 1.seconds.inWholeMilliseconds) + } + + if (enterPipOnResume) { + enterPipOnResume = false + enterPipModeIfPossible() + } + + if (SignalStore.rateLimit.needsRecaptcha()) { + RecaptchaProofBottomSheetFragment.show(supportFragmentManager) + } + } + + override fun onNewIntent(intent: Intent) { + val callIntent = getCallIntent() + Log.i(TAG, "onNewIntent(${callIntent.isStartedFromFullScreen})") + super.onNewIntent(intent) + logIntent(callIntent) + processIntent(callIntent) + } + + override fun onPause() { + Log.i(TAG, "onPause") + super.onPause() + + if (!isInPipMode() || isFinishing) { + EventBus.getDefault().unregister(this) + } + + if (!callPermissionsDialogController.isAskingForPermission && !viewModel.isCallStarting && !isChangingConfigurations) { + val state = viewModel.callParticipantsStateSnapshot + if (state != null && (state.callState.isPreJoinOrNetworkUnavailable || state.callState.isIncomingOrHandledElsewhere)) { + finish() + } + } + } + + override fun onStop() { + Log.i(TAG, "onStop") + super.onStop() + + ephemeralStateDisposable.dispose() + + if (!isInPipMode() || isFinishing) { + EventBus.getDefault().unregister(this) + requestNewSizesThrottle.clear() + } + + AppDependencies.signalCallManager.setEnableVideo(false) + + if (!viewModel.isCallStarting && !isChangingConfigurations) { + val state = viewModel.callParticipantsStateSnapshot + if (state != null) { + if (state.callState.isPreJoinOrNetworkUnavailable || state.callState.isIncomingOrHandledElsewhere) { + AppDependencies.signalCallManager.cancelPreJoin() + } else if (state.callState.inOngoingCall && isInPipMode()) { + AppDependencies.signalCallManager.relaunchPipOnForeground() + } + } + } + } + + override fun onDestroy() { + Log.d(TAG, "onDestroy") + super.onDestroy() + windowInfoTrackerCallbackAdapter.removeWindowLayoutInfoListener(windowLayoutInfoConsumer) + EventBus.getDefault().unregister(this) + } + + @SuppressLint("MissingSuperCall") + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } + + @SuppressLint("MissingSuperCall") + override fun onUserLeaveHint() { + super.onUserLeaveHint() + enterPipModeIfPossible() + } + + override fun onBackPressed() { + if (!enterPipModeIfPossible()) { + super.onBackPressed() + } + } + + override fun onSendAnywayAfterSafetyNumberChange(changedRecipients: MutableList) { + val state: CallParticipantsState = viewModel.callParticipantsStateSnapshot ?: return + + if (state.isCallLink) { + CallLinkProfileKeySender.onSendAnyway(HashSet(changedRecipients)) + } + + if (state.groupCallState.isConnected) { + AppDependencies.signalCallManager.groupApproveSafetyChange(changedRecipients) + } else { + viewModel.startCall(state.localParticipant.isVideoEnabled) + } + } + + override fun onMessageResentAfterSafetyNumberChange() = Unit + + override fun onCanceled() { + val state: CallParticipantsState? = viewModel.callParticipantsStateSnapshot + if (state != null && state.groupCallState.isNotIdle) { + if (state.callState.isPreJoinOrNetworkUnavailable) { + AppDependencies.signalCallManager.cancelPreJoin() + finish() + } else { + handleEndCall() + } + } else { + handleTerminate(viewModel.recipient.get(), HangupMessage.Type.NORMAL) + } + } + + override fun onReactWithAnyEmojiDialogDismissed() = Unit + + override fun onReactWithAnyEmojiSelected(emoji: String) { + AppDependencies.signalCallManager.react(emoji) + callOverflowPopupWindow.dismiss() + } + + override fun onProofCompleted() { + AppDependencies.signalCallManager.resendMediaKeys() + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onRecaptchaRequiredEvent(recaptchaRequiredEvent: RecaptchaRequiredEvent) { + RecaptchaProofBottomSheetFragment.show(supportFragmentManager) + } + + @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) + fun onEventMainThread(event: WebRtcViewModel) { + Log.i(TAG, "Got message from service: ${event.describeDifference(previousEvent)}") + + val previousCallState: WebRtcViewModel.State? = previousEvent?.state + + previousEvent = event + + viewModel.setRecipient(event.recipient) + controlsAndInfoViewModel.setRecipient(event.recipient) + + when (event.state) { + WebRtcViewModel.State.IDLE -> Unit + WebRtcViewModel.State.CALL_PRE_JOIN -> handleCallPreJoin(event) + WebRtcViewModel.State.CALL_INCOMING -> { + if (previousCallState == WebRtcViewModel.State.NETWORK_FAILURE) { + Log.d(TAG, "Incoming call directly from network failure state. Recreating activity.") + recreate() + return + } + } + WebRtcViewModel.State.CALL_OUTGOING -> handleOutgoingCall(event) + WebRtcViewModel.State.CALL_CONNECTED -> handleCallConnected(event) + WebRtcViewModel.State.CALL_RINGING -> handleCallRinging() + WebRtcViewModel.State.CALL_BUSY -> handleCallBusy() + WebRtcViewModel.State.CALL_DISCONNECTED -> { + if (event.groupCallEndReason == GroupCall.GroupCallEndReason.HAS_MAX_DEVICES) { + handleGroupCallHasMaxDevices(event.recipient) + } else { + handleTerminate(event.recipient, HangupMessage.Type.NORMAL) + } + } + WebRtcViewModel.State.CALL_DISCONNECTED_GLARE -> handleGlare(event.recipient) + WebRtcViewModel.State.CALL_NEEDS_PERMISSION -> handleTerminate(event.recipient, HangupMessage.Type.NEED_PERMISSION) + WebRtcViewModel.State.CALL_RECONNECTING -> handleCallReconnecting() + WebRtcViewModel.State.NETWORK_FAILURE -> handleServerFailure() + WebRtcViewModel.State.RECIPIENT_UNAVAILABLE -> handleRecipientUnavailable() + WebRtcViewModel.State.NO_SUCH_USER -> handleNoSuchUser(event) + WebRtcViewModel.State.UNTRUSTED_IDENTITY -> handleUntrustedIdentity(event) + WebRtcViewModel.State.CALL_ACCEPTED_ELSEWHERE -> handleTerminate(event.recipient, HangupMessage.Type.ACCEPTED) + WebRtcViewModel.State.CALL_DECLINED_ELSEWHERE -> handleTerminate(event.recipient, HangupMessage.Type.DECLINED) + WebRtcViewModel.State.CALL_ONGOING_ELSEWHERE -> handleTerminate(event.recipient, HangupMessage.Type.BUSY) + } + + if (event.callLinkDisconnectReason != null && event.callLinkDisconnectReason.postedAt > lastCallLinkDisconnectDialogShowTime) { + lastCallLinkDisconnectDialogShowTime = System.currentTimeMillis() + + when (event.callLinkDisconnectReason) { + is CallLinkDisconnectReason.RemovedFromCall -> displayRemovedFromCallLinkDialog() + is CallLinkDisconnectReason.DeniedRequestToJoinCall -> displayDeniedRequestToJoinCallLinkDialog() + } + } + + val enableVideo = event.localParticipant.cameraState.cameraCount > 0 && enableVideoIfAvailable + viewModel.updateFromWebRtcViewModel(event, enableVideo) + + if (enableVideo) { + enableVideoIfAvailable = false + handleSetMuteVideo(false) + } + + if (event.bluetoothPermissionDenied && !hasWarnedAboutBluetooth && !isFinishing) { + 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) { _, _ -> startActivity(Permissions.getApplicationSettingsIntent(this)) } + .setNegativeButton(R.string.WebRtcCallActivity__not_now, null) + .show() + + hasWarnedAboutBluetooth = true + } + } + + private fun getCallIntent(): CallIntent { + return CallIntent(intent) + } + + private fun initializeResources() { + callScreen = findViewById(R.id.callScreen) + callScreen.setControlsListener(ControlsListener()) + + participantUpdateWindow = CallParticipantsListUpdatePopupWindow(callScreen) + callStateUpdatePopupWindow = CallStateUpdatePopupWindow(callScreen) + wifiToCellularPopupWindow = WifiToCellularPopupWindow(callScreen) + callOverflowPopupWindow = CallOverflowPopupWindow(this, callScreen) { + val state: CallParticipantsState? = viewModel.callParticipantsStateSnapshot + state?.localParticipant?.isHandRaised ?: false + } + + lifecycle.addObserver(participantUpdateWindow) + } + + private fun initializeViewModel() { + val orientation: Orientation = resolveOrientationFromContext() + if (orientation == Orientation.PORTRAIT_BOTTOM_EDGE) { + WindowUtil.setNavigationBarColor(this, ContextCompat.getColor(this, R.color.signal_dark_colorSurface2)) + WindowUtil.clearTranslucentNavigationBar(window) + } + + AppDependencies.signalCallManager.orientationChanged(true, orientation.degrees) + + viewModel.setIsLandscapeEnabled(true) + viewModel.setIsInPipMode(isInPipMode()) + viewModel.microphoneEnabled.observe(this, callScreen::setMicEnabled) + viewModel.webRtcControls.observe(this) { controls -> + callScreen.setWebRtcControls(controls) + controlsAndInfo.updateControls(controls) + } + viewModel.events.observe(this, this::handleViewModelEvent) + + lifecycleDisposable.add(viewModel.inCallstatus.subscribe(this::handleInCallStatus)) + lifecycleDisposable.add(viewModel.recipientFlowable.subscribe(callScreen::setRecipient)) + + val isStartedFromCallLink = getCallIntent().isStartedFromCallLink + LiveDataUtil.combineLatest( + viewModel.callParticipantsState.toFlowable(BackpressureStrategy.LATEST).toLiveData(), + viewModel.ephemeralState + ) { state, ephemeralState -> + CallParticipantsViewState(state, ephemeralState, orientation == Orientation.PORTRAIT_BOTTOM_EDGE, true, isStartedFromCallLink) + }.observe(this, callScreen::updateCallParticipants) + + viewModel.callParticipantListUpdate.observe(this, participantUpdateWindow::addCallParticipantListUpdate) + viewModel.safetyNumberChangeEvent.observe(this, this::handleSafetyNumberChangeEvent) + viewModel.groupMembersChanged.observe(this) { updateGroupMembersForGroupCall() } + viewModel.groupMemberCount.observe(this, this::handleGroupMemberCountChange) + lifecycleDisposable.add(viewModel.shouldShowSpeakerHint().subscribe(this::updateSpeakerHint)) + + callScreen.viewTreeObserver.addOnGlobalLayoutListener { + val state = viewModel.callParticipantsStateSnapshot + if (state != null) { + if (state.needsNewRequestSizes()) { + requestNewSizesThrottle.publish { AppDependencies.signalCallManager.updateRenderedResolutions() } + } + } + } + + 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(PendingParticipantsViewListener()) + lifecycleDisposable += viewModel.pendingParticipants.subscribe(callScreen::updatePendingParticipantsList) + } + + private fun initializePictureInPictureParams() { + if (isSystemPipEnabledAndAvailable()) { + val orientation = resolveOrientationFromContext() + val aspectRatio = if (orientation == Orientation.PORTRAIT_BOTTOM_EDGE) { + Rational(9, 16) + } else { + Rational(16, 9) + } + + pipBuilderParams = PictureInPictureParams.Builder() + pipBuilderParams.setAspectRatio(aspectRatio) + + if (Build.VERSION.SDK_INT >= 31) { + viewModel.canEnterPipMode().observe(this) { + pipBuilderParams.setAutoEnterEnabled(it) + tryToSetPictureInPictureParams() + } + } else { + tryToSetPictureInPictureParams() + } + } + } + + private fun logIntent(callIntent: CallIntent) { + Log.d(TAG, callIntent.toString()) + } + + private fun processIntent(callIntent: CallIntent) { + when (callIntent.action) { + CallIntent.Action.ANSWER_AUDIO -> handleAnswerWithAudio() + CallIntent.Action.ANSWER_VIDEO -> handleAnswerWithVideo() + CallIntent.Action.DENY -> handleDenyCall() + CallIntent.Action.END_CALL -> handleEndCall() + else -> Unit + } + + if (System.currentTimeMillis() - lastProcessedIntentTimestamp > 1.seconds.inWholeMilliseconds) { + enterPipOnResume = callIntent.shouldLaunchInPip + } + + lastProcessedIntentTimestamp = System.currentTimeMillis() + } + + private fun registerSystemPipChangeListeners() { + addOnPictureInPictureModeChangedListener { + CallParticipantsListDialog.dismiss(supportFragmentManager) + CallReactionScrubber.dismissCustomEmojiBottomSheet(supportFragmentManager) + } + } + + private fun initializePendingParticipantFragmentListener() { + supportFragmentManager.setFragmentResultListener( + PendingParticipantsBottomSheet.REQUEST_KEY, + this + ) { _, result -> + val action: PendingParticipantsBottomSheet.Action = PendingParticipantsBottomSheet.getAction(result) + val recipientIds = viewModel.pendingParticipantsSnapshot.getUnresolvedPendingParticipants().map { it.recipient.id } + + when (action) { + PendingParticipantsBottomSheet.Action.NONE -> Unit + PendingParticipantsBottomSheet.Action.APPROVE_ALL -> { + MaterialAlertDialogBuilder(this) + .setTitle(resources.getQuantityString(R.plurals.WebRtcCallActivity__approve_d_requests, recipientIds.size, recipientIds.size)) + .setMessage(resources.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) { _, _ -> + for (id in recipientIds) { + AppDependencies.signalCallManager.setCallLinkJoinRequestAccepted(id) + } + } + .show() + } + + PendingParticipantsBottomSheet.Action.DENY_ALL -> { + MaterialAlertDialogBuilder(this) + .setTitle(resources.getQuantityString(R.plurals.WebRtcCallActivity__deny_d_requests, recipientIds.size, recipientIds.size)) + .setMessage(resources.getQuantityString(R.plurals.WebRtcCallActivity__d_people_will_not_be_added_to_the_call, recipientIds.size, recipientIds.size)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.WebRtcCallActivity__deny_all) { _, _ -> + for (id in recipientIds) { + AppDependencies.signalCallManager.setCallLinkJoinRequestRejected(id) + } + } + .show() + } + } + } + } + + private fun hasCameraPermission(): Boolean { + return Permissions.hasAll(this, Manifest.permission.CAMERA) + } + + private fun hasAudioPermission(): Boolean { + return Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO) + } + + private fun askCameraAudioPermissions(onGranted: () -> Unit) { + callPermissionsDialogController.requestCameraAndAudioPermission( + activity = this, + onAllGranted = onGranted, + onCameraGranted = { findViewById(R.id.missing_permissions_container).visible = false }, + onAudioDenied = this::handleDenyCall + ) + } + + private fun askCameraPermissions(onGranted: () -> Unit) { + callPermissionsDialogController.requestCameraPermission(this) { + onGranted() + findViewById(R.id.missing_permissions_container).visible = false + } + } + + private fun askAudioPermissions(onGranted: () -> Unit) { + callPermissionsDialogController.requestAudioPermission( + activity = this, + onGranted = onGranted, + onDenied = this::handleDenyCall + ) + } + + private fun handleSetAudioHandset() { + AppDependencies.signalCallManager.selectAudioDevice(ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.EARPIECE)) + } + + private fun handleSetAudioSpeaker() { + AppDependencies.signalCallManager.selectAudioDevice(ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.SPEAKER_PHONE)) + } + + private fun handleSetAudioBluetooth() { + AppDependencies.signalCallManager.selectAudioDevice(ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.BLUETOOTH)) + } + + private fun handleSetAudioWiredHeadset() { + AppDependencies.signalCallManager.selectAudioDevice(ChosenAudioDeviceIdentifier(SignalAudioManager.AudioDevice.WIRED_HEADSET)) + } + + private fun handleSetMuteAudio(enabled: Boolean) { + AppDependencies.signalCallManager.setMuteAudio(enabled) + } + + private fun handleSetMuteVideo(muted: Boolean) { + val recipient = viewModel.recipient.get() + + if (recipient != Recipient.UNKNOWN) { + askCameraPermissions { + AppDependencies.signalCallManager.setEnableVideo(!muted) + } + } + } + + private fun handleFlipCamera() { + AppDependencies.signalCallManager.flipCamera() + } + + private fun handleAnswerWithAudio() { + askAudioPermissions { + callScreen.setStatus(getString(R.string.RedPhone_answering)) + AppDependencies.signalCallManager.acceptCall(false) + } + } + + private fun handleAnswerWithVideo() { + val onGranted: () -> Unit = { + callScreen.setStatus(getString(R.string.RedPhone_answering)) + AppDependencies.signalCallManager.acceptCall(true) + handleSetMuteVideo(false) + } + + if (!hasCameraPermission() && !hasAudioPermission()) { + askCameraAudioPermissions(onGranted) + } else if (!hasAudioPermission()) { + askAudioPermissions(onGranted) + } else { + askCameraPermissions(onGranted) + } + } + + private fun handleDenyCall() { + val recipient = viewModel.recipient.get() + if (recipient != Recipient.UNKNOWN) { + AppDependencies.signalCallManager.denyCall() + + callScreen.setRecipient(recipient) + callScreen.setStatus(getString(R.string.RedPhone_ending_call)) + delayedFinish() + } + } + + private fun handleEndCall() { + Log.i(TAG, "Hangup pressed, handling termination now...") + AppDependencies.signalCallManager.localHangup() + } + + private fun handleOutgoingCall(event: WebRtcViewModel) { + if (event.groupState.isNotIdle) { + callScreen.setStatusFromGroupCallState(event.groupState) + } else { + callScreen.setStatus(getString(R.string.WebRtcCallActivity__calling)) + } + } + + private fun handleGroupCallHasMaxDevices(recipient: Recipient) { + MaterialAlertDialogBuilder(this) + .setMessage(R.string.WebRtcCallView__call_is_full) + .setPositiveButton(android.R.string.ok) { _, _ -> handleTerminate(recipient, HangupMessage.Type.NORMAL) } + .show() + } + + private fun handleTerminate(recipient: Recipient, hangupType: HangupMessage.Type) { + Log.i(TAG, "handleTerminate called: $hangupType") + + callScreen.setStatusFromHangupType(hangupType) + + EventBus.getDefault().removeStickyEvent(WebRtcViewModel::class.java) + + if (hangupType == HangupMessage.Type.NEED_PERMISSION) { + startActivity(CalleeMustAcceptMessageRequestActivity.createIntent(this, recipient.id)) + } + + delayedFinish() + } + + private fun handleGlare(recipient: Recipient) { + Log.i(TAG, "handleGlare: ${recipient.id}") + callScreen.setStatus("") + } + + private fun handleCallRinging() { + callScreen.setStatus(getString(R.string.RedPhone_ringing)) + } + + private fun handleCallBusy() { + EventBus.getDefault().removeStickyEvent(WebRtcViewModel::class.java) + callScreen.setStatus(getString(R.string.RedPhone_busy)) + delayedFinish(SignalCallManager.BUSY_TONE_LENGTH.toLong()) + } + + private fun handleCallPreJoin(event: WebRtcViewModel) { + if (event.groupState.isNotIdle) { + callScreen.setRingGroup(event.ringGroup) + + if (event.ringGroup && event.areRemoteDevicesInCall()) { + AppDependencies.signalCallManager.setRingGroup(false) + } + } + } + + private fun handleCallConnected(event: WebRtcViewModel) { + window.addFlags(WindowManager.LayoutParams.FLAG_IGNORE_CHEEK_PRESSES) + if (event.groupState.isNotIdleOrConnected) { + callScreen.setStatusFromGroupCallState(event.groupState) + } + } + + private fun handleCallReconnecting() { + callScreen.setStatus(getString(R.string.WebRtcCallView__reconnecting)) + VibrateUtil.vibrate(this, VIBRATE_DURATION) + } + + private fun handleRecipientUnavailable() { + EventBus.getDefault().removeStickyEvent(WebRtcViewModel::class.java) + callScreen.setStatus(getString(R.string.RedPhone_recipient_unavailable)) + delayedFinish() + } + + private fun handleServerFailure() { + EventBus.getDefault().removeStickyEvent(WebRtcViewModel::class.java) + callScreen.setStatus(getString(R.string.RedPhone_network_failed)) + } + + private fun handleNoSuchUser(event: WebRtcViewModel) { + if (isFinishing) return + 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) { _, _ -> handleTerminate(event.recipient, HangupMessage.Type.NORMAL) } + .setOnCancelListener { handleTerminate(event.recipient, HangupMessage.Type.NORMAL) } + .show() + } + + private fun handleUntrustedIdentity(event: WebRtcViewModel) { + val theirKey = event.remoteParticipants[0].identityKey + val recipient = event.remoteParticipants[0].recipient + + if (theirKey == null) { + Log.w(TAG, "Untrusted identity without an identity key.") + } + + SafetyNumberBottomSheet.forCall(recipient.id).show(supportFragmentManager) + } + + private fun handleViewModelEvent(event: CallEvent) { + when (event) { + is CallEvent.StartCall -> startCall(event.isVideoCall) + is CallEvent.ShowGroupCallSafetyNumberChange -> SafetyNumberBottomSheet.forGroupCall(event.identityRecords).show(supportFragmentManager) + is CallEvent.SwitchToSpeaker -> callScreen.switchToSpeakerView() + is CallEvent.ShowSwipeToSpeakerHint -> CallToastPopupWindow.show(callScreen) + is CallEvent.ShowVideoTooltip -> { + if (isInPipMode()) return + + if (videoTooltip == null) { + videoTooltip = TooltipPopup.forTarget(callScreen.videoTooltipTarget) + .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) + } + } + + is CallEvent.DismissVideoTooltip -> { + if (isInPipMode()) return + + videoTooltip?.dismiss() + videoTooltip = null + } + + is CallEvent.ShowWifiToCellularPopup -> { + if (isInPipMode()) return + wifiToCellularPopupWindow.show() + } + + is CallEvent.ShowSwitchCameraTooltip -> { + if (isInPipMode()) return + + if (switchCameraTooltip == null) { + switchCameraTooltip = TooltipPopup.forTarget(callScreen.switchCameraTooltipTarget) + .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) + } + } + + is CallEvent.DismissSwitchCameraTooltip -> { + if (isInPipMode()) return + + switchCameraTooltip?.dismiss() + switchCameraTooltip = null + } + } + } + + private fun handleInCallStatus(inCallStatus: InCallStatus) { + when (inCallStatus) { + is InCallStatus.ElapsedTime -> { + val formatter: EllapsedTimeFormatter = EllapsedTimeFormatter.fromDurationMillis(inCallStatus.elapsedTime) ?: return + callScreen.setStatus(getString(R.string.WebRtcCallActivity__signal_s, formatter.toString())) + } + is InCallStatus.PendingCallLinkUsers -> { + val waiting = inCallStatus.pendingUserCount + + callScreen.setStatus( + resources.getQuantityString( + R.plurals.WebRtcCallActivity__d_people_waiting, + waiting, + waiting + ) + ) + } + is InCallStatus.JoinedCallLinkUsers -> { + val joined = inCallStatus.joinedUserCount + + callScreen.setStatus( + resources.getQuantityString( + R.plurals.WebRtcCallActivity__d_people, + joined, + joined + ) + ) + } + } + } + + private fun handleSafetyNumberChangeEvent(safetyNumberChangeEvent: SafetyNumberChangeEvent) { + if (safetyNumberChangeEvent.recipientIds.isNotEmpty()) { + if (safetyNumberChangeEvent.isInPipMode) { + GroupCallSafetyNumberChangeNotificationUtil.showNotification(this, viewModel.recipient.get()) + } else { + GroupCallSafetyNumberChangeNotificationUtil.cancelNotification(this, viewModel.recipient.get()) + SafetyNumberBottomSheet.forDuringGroupCall(safetyNumberChangeEvent.recipientIds).show(supportFragmentManager) + } + } + } + + private fun updateGroupMembersForGroupCall() { + AppDependencies.signalCallManager.requestUpdateGroupMembers() + } + + private fun handleGroupMemberCountChange(count: Int) { + val canRing = count <= RemoteConfig.maxGroupCallRingSize + callScreen.enableRingGroup(canRing) + AppDependencies.signalCallManager.setRingGroup(canRing) + } + + private fun updateSpeakerHint(enabled: Boolean) { + if (enabled) { + callScreen.showSpeakerViewHint() + } else { + callScreen.hideSpeakerViewHint() + } + } + + private fun initializeScreenshotSecurity() { + if (TextSecurePreferences.isScreenSecurityEnabled(this)) { + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + + private fun enterPipModeIfPossible(): Boolean { + if (isSystemPipEnabledAndAvailable()) { + if (viewModel.canEnterPipMode().value == true) { + try { + enterPictureInPictureMode(pipBuilderParams.build()) + } catch (e: Exception) { + 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 fun isInPipMode(): Boolean { + return isSystemPipEnabledAndAvailable() && isInPictureInPictureMode + } + + private fun isSystemPipEnabledAndAvailable(): Boolean { + return Build.VERSION.SDK_INT >= 26 && packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + } + + private fun resolveOrientationFromContext(): Orientation { + val displayOrientation = resources.configuration.orientation + val displayRotation = ContextCompat.getDisplayOrDefault(this).rotation + + return if (displayOrientation == Configuration.ORIENTATION_PORTRAIT) { + Orientation.PORTRAIT_BOTTOM_EDGE + } else if (displayRotation == Surface.ROTATION_270) { + Orientation.LANDSCAPE_RIGHT_EDGE + } else { + Orientation.LANDSCAPE_LEFT_EDGE + } + } + + private fun tryToSetPictureInPictureParams() { + if (Build.VERSION.SDK_INT >= 26) { + try { + setPictureInPictureParams(pipBuilderParams.build()) + } catch (e: Exception) { + Log.w(TAG, "System lied about having PiP available.", e) + } + } + } + + private fun startCall(isVideoCall: Boolean) { + enableVideoIfAvailable = isVideoCall + + if (isVideoCall) { + AppDependencies.signalCallManager.startOutgoingVideoCall(viewModel.recipient.get()) + } else { + AppDependencies.signalCallManager.startOutgoingAudioCall(viewModel.recipient.get()) + } + + MessageSender.onMessageSent() + } + + private fun delayedFinish(delayMillis: Long = STANDARD_DELAY_FINISH) { + callScreen.postDelayed(this::finish, delayMillis) + } + + private fun displayRemovedFromCallLinkDialog() { + 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 fun displayDeniedRequestToJoinCallLinkDialog() { + 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 fun maybeDisplaySpeakerphonePopup(nextOutput: WebRtcAudioOutput) { + val currentOutput = viewModel.currentAudioOutput + 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 inner class WindowLayoutInfoConsumer : Consumer { + override fun accept(value: WindowLayoutInfo) { + Log.d(TAG, "On WindowLayoutInfo accepted: $value") + + val feature: FoldingFeature? = value.displayFeatures.filterIsInstance().firstOrNull() + if (feature != null) { + val bounds = feature.bounds + if (feature.isSeparating) { + Log.d(TAG, "OnWindowLayoutInfo accepted: ensure call view is in table-top diplay 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 inner class FadeCallback : ControlsAndInfoController.BottomSheetVisibilityListener { + override fun onShown() { + fullscreenHelper.showSystemUI() + } + + override fun onHidden() { + val controlState = viewModel.webRtcControls.value + if (controlState == null || !controlState.displayErrorControls()) { + fullscreenHelper.hideSystemUI() + videoTooltip?.dismiss() + } + } + } + + private inner class ControlsListener : WebRtcCallView.ControlsListener { + override fun onStartCall(isVideoCall: Boolean) { + viewModel.startCall(isVideoCall) + } + + override fun onCancelStartCall() { + finish() + } + + override fun onAudioOutputChanged(audioOutput: WebRtcAudioOutput) { + maybeDisplaySpeakerphonePopup(audioOutput) + when (audioOutput) { + WebRtcAudioOutput.HANDSET -> handleSetAudioHandset() + WebRtcAudioOutput.BLUETOOTH_HEADSET -> handleSetAudioBluetooth() + WebRtcAudioOutput.SPEAKER -> handleSetAudioSpeaker() + WebRtcAudioOutput.WIRED_HEADSET -> handleSetAudioWiredHeadset() + } + } + + @RequiresApi(31) + override fun onAudioOutputChanged31(audioOutput: WebRtcAudioDevice) { + maybeDisplaySpeakerphonePopup(audioOutput.webRtcAudioOutput) + AppDependencies.signalCallManager.selectAudioDevice(ChosenAudioDeviceIdentifier(audioOutput.deviceId!!)) + } + + override fun onVideoChanged(isVideoEnabled: Boolean) { + handleSetMuteVideo(!isVideoEnabled) + } + + override fun onMicChanged(isMicEnabled: Boolean) { + askAudioPermissions { + callStateUpdatePopupWindow.onCallStateUpdate(if (isMicEnabled) CallControlsChange.MIC_ON else CallControlsChange.MIC_OFF) + handleSetMuteAudio(!isMicEnabled) + } + } + + override fun onOverflowClicked() { + controlsAndInfo.toggleOverflowPopup() + } + + override fun onCameraDirectionChanged() { + handleFlipCamera() + } + + override fun onEndCallPressed() { + handleEndCall() + } + + override fun onDenyCallPressed() { + handleDenyCall() + } + + override fun onAcceptCallWithVoiceOnlyPressed() { + handleAnswerWithAudio() + } + + override fun onAcceptCallPressed() { + if (viewModel.isAnswerWithVideoAvailable) { + handleAnswerWithVideo() + } else { + handleAnswerWithAudio() + } + } + + override fun onPageChanged(page: CallParticipantsState.SelectedPage) { + viewModel.setIsViewingFocusedParticipant(page) + } + + override fun onLocalPictureInPictureClicked() { + viewModel.onLocalPictureInPictureClicked() + controlsAndInfo.restartHideControlsTimer() + } + + override fun onRingGroupChanged(ringGroup: Boolean, ringingAllowed: Boolean) { + if (ringingAllowed) { + AppDependencies.signalCallManager.setRingGroup(ringGroup) + callStateUpdatePopupWindow.onCallStateUpdate(if (ringGroup) CallControlsChange.RINGING_ON else CallControlsChange.RINGING_OFF) + } else { + AppDependencies.signalCallManager.setRingGroup(false) + callStateUpdatePopupWindow.onCallStateUpdate(CallControlsChange.RINGING_DISABLED) + } + } + + override fun onCallInfoClicked() { + controlsAndInfo.showCallInfo() + } + + override fun onNavigateUpClicked() { + onBackPressed() + } + + override fun toggleControls() { + val controlState = viewModel.webRtcControls.value + if (controlState != null && !controlState.displayIncomingCallButtons() && !controlState.displayErrorControls()) { + controlsAndInfo.toggleControls() + } + } + + override fun onAudioPermissionsRequested(onGranted: Runnable?) { + askAudioPermissions { onGranted?.run() } + } + } + + private inner class PendingParticipantsViewListener : PendingParticipantsView.Listener { + override fun onLaunchRecipientSheet(pendingRecipient: Recipient) { + CallLinkIncomingRequestSheet.show(supportFragmentManager, pendingRecipient.id) + } + + override fun onAllowPendingRecipient(pendingRecipient: Recipient) { + AppDependencies.signalCallManager.setCallLinkJoinRequestAccepted(pendingRecipient.id) + } + + override fun onRejectPendingRecipient(pendingRecipient: Recipient) { + AppDependencies.signalCallManager.setCallLinkJoinRequestRejected(pendingRecipient.id) + } + + override fun onLaunchPendingRequestsSheet() { + PendingParticipantsBottomSheet().show(supportFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java index 26fca87010..2e5b7ca039 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java @@ -34,7 +34,6 @@ import org.signal.ringrtc.NetworkRoute; import org.signal.ringrtc.PeekInfo; import org.signal.ringrtc.Remote; import org.signal.storageservice.protos.groups.GroupExternalCredential; -import org.thoughtcrime.securesms.WebRtcCallActivity; import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent; import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil; import org.thoughtcrime.securesms.database.CallLinkTable; @@ -538,7 +537,7 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. return false; } - context.startActivity(new Intent(context, WebRtcCallActivity.class).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); + context.startActivity(new Intent(context, CallIntent.getActivityClass()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); return true; } 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 e98c6eb48d..f6b005dcc4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -29,9 +29,7 @@ import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.logging.Log; import org.signal.ringrtc.CallLinkRootKey; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.WebRtcCallActivity; import org.thoughtcrime.securesms.calls.links.CallLinks; -import org.thoughtcrime.securesms.components.webrtc.v2.CallActivity; import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent; import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery; import org.thoughtcrime.securesms.conversation.ConversationIntents; 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 0547fabf58..821d9d5146 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/VoiceCallShare.java +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/VoiceCallShare.java @@ -7,11 +7,11 @@ import android.os.Bundle; import android.provider.ContactsContract; import android.text.TextUtils; +import org.signal.core.util.concurrent.SimpleTask; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.WebRtcCallActivity; +import org.thoughtcrime.securesms.components.webrtc.v2.CallIntent; import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.recipients.Recipient; -import org.signal.core.util.concurrent.SimpleTask; public class VoiceCallShare extends Activity { @@ -40,7 +40,7 @@ public class VoiceCallShare extends Activity { AppDependencies.getSignalCallManager().startOutgoingAudioCall(recipient); } - Intent activityIntent = new Intent(this, WebRtcCallActivity.class); + Intent activityIntent = new Intent(this, CallIntent.getActivityClass()); activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(activityIntent); }