Files
arcanechat-android/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java
T

1031 lines
31 KiB
Java

package org.thoughtcrime.securesms.calls;
import android.Manifest;
import android.app.KeyguardManager;
import android.app.PictureInPictureParams;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.PowerManager;
import android.util.Log;
import android.util.Rational;
import android.view.View;
import android.view.WindowManager;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AppCompatActivity;
import androidx.cardview.widget.CardView;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
import androidx.core.telecom.CallEndpointCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.lifecycle.MediatorLiveData;
import androidx.lifecycle.ViewModelProvider;
import com.google.android.material.button.MaterialButton;
import com.google.android.material.button.MaterialButtonToggleGroup;
import java.util.ArrayList;
import java.util.List;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.EglUtils;
import org.thoughtcrime.securesms.R;
import org.webrtc.RendererCommon;
import org.webrtc.SurfaceViewRenderer;
import org.webrtc.VideoTrack;
/** Full-screen call activity for VoIP calls */
@RequiresApi(api = Build.VERSION_CODES.O)
public class CallActivity extends AppCompatActivity {
private static final String TAG = CallActivity.class.getSimpleName();
private static final int MIC_PERMISSION_REQUEST_CODE = 1001;
private static final int CAMERA_PERMISSION_REQUEST_CODE = 1002;
public static final String ACTION_ANSWER_CALL = BuildConfig.APPLICATION_ID + ".ANSWER_CALL";
public static final String ACTION_DECLINE_CALL = BuildConfig.APPLICATION_ID + ".DECLINE_CALL";
public static final String ACTION_HANGUP_CALL = BuildConfig.APPLICATION_ID + ".HANGUP_CALL";
// Views
private CallViewModel viewModel;
private SurfaceViewRenderer localVideoView;
private SurfaceViewRenderer remoteVideoView;
// Status and info
private TextView statusText;
private TextView displayNameText;
private View incomingCallPrompt;
private View callerIconContainer;
private ImageView callerIconView;
private ImageView remoteAvatarView;
private MaterialButtonToggleGroup answerModeSelector;
// Layouts and elements
private ConstraintLayout topBar;
private LinearLayout bottomLayoutContainer;
private CardView localVideoContainer;
private ImageButton endCallButton;
private MaterialButton answerButton;
private MaterialButton declineButton;
private ImageButton muteButton;
private ImageButton videoButton;
private ImageButton speakerButton;
private ImageButton switchCameraButton;
private final MediatorLiveData<Boolean> videoConfigChanged = new MediatorLiveData<>();
// Misc
private PowerManager.WakeLock proximityWakeLock;
// States
private boolean awaitingPermissionResult = false;
private boolean pausedWhileAwaitingPermission = false;
private boolean intentHandled = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Destructive actions need nothing
String action = getIntent() != null ? getIntent().getAction() : null;
if (ACTION_DECLINE_CALL.equals(action)) {
Log.d(TAG, "Handling DECLINE_CALL action from notification");
CallCoordinator.getInstance(getApplication()).declineCall();
finish();
return;
}
if (ACTION_HANGUP_CALL.equals(action)) {
Log.d(TAG, "Handling HANGUP_CALL action from notification");
CallCoordinator.getInstance(getApplication()).hangUp();
finish();
return;
}
setContentView(R.layout.activity_call);
setupWindowFlags();
initializeViews();
setupInsets();
initializeProximityWakeLock();
// PiP listener
addOnPictureInPictureModeChangedListener(
pipModeInfo -> {
boolean isInPipMode = pipModeInfo.isInPictureInPictureMode();
if (isInPipMode) {
topBar.setVisibility(View.GONE);
bottomLayoutContainer.setVisibility(View.GONE);
incomingCallPrompt.setVisibility(View.GONE);
} else {
topBar.setVisibility(View.VISIBLE);
bottomLayoutContainer.setVisibility(View.VISIBLE);
if (viewModel != null) {
CallViewModel.CallState state = viewModel.getCallState().getValue();
if (state != null) {
updateUIForState(state);
}
}
}
layoutVideos();
updateProximityWakeLock();
});
initializeViewModel();
// Intent handling needs permissions
if (!hasMicrophonePermission()) {
awaitingPermissionResult = true;
ActivityCompat.requestPermissions(
this, new String[] {Manifest.permission.RECORD_AUDIO}, MIC_PERMISSION_REQUEST_CODE);
return;
}
if (shouldRequestCameraPermission()) {
awaitingPermissionResult = true;
ActivityCompat.requestPermissions(
this, new String[] {Manifest.permission.CAMERA}, CAMERA_PERMISSION_REQUEST_CODE);
return;
}
handleIntents(getIntent());
intentHandled = true;
}
private void handleIntents(Intent intent) {
if (intent == null) {
return;
}
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
String action = intent.getAction();
Log.d(TAG, "handleIntents: action=" + action);
// Destructive actions without ViewModel
if (ACTION_DECLINE_CALL.equals(action)) {
Log.d(TAG, "Handling DECLINE_CALL action");
if (viewModel != null) {
viewModel.handleNotificationDecline();
} else {
coordinator.declineCall();
}
finish();
return;
}
if (ACTION_HANGUP_CALL.equals(action)) {
Log.d(TAG, "Handling HANGUP_CALL action");
if (viewModel != null) {
viewModel.handleNotificationHangup();
} else {
coordinator.hangUp();
}
finish();
return;
}
if (viewModel == null) {
return;
}
if (!coordinator.hasActiveCall()) {
Log.e(TAG, "No active call exists, cannot proceed");
finish();
return;
}
if (ACTION_ANSWER_CALL.equals(action)) {
Log.d(TAG, "Handling ANSWER_CALL action from notification");
viewModel.handleNotificationAnswer();
return;
}
if (coordinator.hasOngoingCall()) {
Log.d(TAG, "Resuming existing call");
} else if (!coordinator.isIncomingCall()) {
Log.d(TAG, "Starting outgoing call");
viewModel.startOutgoingCallWhenReady();
}
}
private void setupInsets() {
View rootView = findViewById(R.id.call_root_view);
ViewCompat.setOnApplyWindowInsetsListener(
rootView,
(v, windowInsets) -> {
Insets systemBars = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return windowInsets;
});
}
private void setupWindowFlags() {
WindowCompat.enableEdgeToEdge(getWindow());
// Show when locked
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true);
setTurnScreenOn(true);
KeyguardManager keyguardManager =
(KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
if (keyguardManager != null) {
keyguardManager.requestDismissKeyguard(this, null);
}
} else {
getWindow()
.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON);
}
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
// Control status bar icon colors
WindowInsetsControllerCompat controller =
WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView());
controller.setAppearanceLightStatusBars(false);
controller.setAppearanceLightNavigationBars(false);
}
private void initializeProximityWakeLock() {
PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
if (powerManager == null) {
Log.w(TAG, "PowerManager not available");
return;
}
if (powerManager.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) {
proximityWakeLock =
powerManager.newWakeLock(
PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "DeltaChat:ProximityLock");
proximityWakeLock.setReferenceCounted(false);
Log.d(TAG, "Proximity wake lock initialized");
} else {
Log.w(TAG, "Proximity wake lock not supported on this device");
}
}
private void initializeViews() {
topBar = findViewById(R.id.top_bar);
bottomLayoutContainer = findViewById(R.id.call_controls_layout);
localVideoContainer = findViewById(R.id.local_video_container);
localVideoView = findViewById(R.id.local_video_view);
remoteVideoView = findViewById(R.id.remote_video_view);
statusText = findViewById(R.id.status_text);
displayNameText = findViewById(R.id.display_name_text);
incomingCallPrompt = findViewById(R.id.incoming_call_prompt);
callerIconContainer = findViewById(R.id.caller_icon_container);
callerIconView = findViewById(R.id.caller_icon);
remoteAvatarView = findViewById(R.id.remote_avatar_view);
answerModeSelector = findViewById(R.id.answer_mode_selector);
endCallButton = findViewById(R.id.end_call_button);
answerButton = findViewById(R.id.answer_button);
declineButton = findViewById(R.id.decline_button);
muteButton = findViewById(R.id.mute_button);
videoButton = findViewById(R.id.video_button);
speakerButton = findViewById(R.id.speaker_button);
switchCameraButton = findViewById(R.id.switch_camera_button);
initializeVideoRenderers();
setupButtonListeners();
}
private void initializeVideoRenderers() {
// Local video (the small one)
localVideoView.init(EglUtils.getEglBase().getEglBaseContext(), null);
localVideoView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
localVideoView.setZOrderMediaOverlay(true);
localVideoView.setEnableHardwareScaler(true);
// Remote video (full screen one)
remoteVideoView.init(EglUtils.getEglBase().getEglBaseContext(), null);
remoteVideoView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL);
remoteVideoView.setEnableHardwareScaler(true);
Log.d(TAG, "Video renderers initialized");
}
private void setupButtonListeners() {
answerButton.setOnClickListener(
v -> {
if (viewModel != null) {
viewModel.answerCall();
}
});
declineButton.setOnClickListener(
v -> {
if (viewModel != null) {
viewModel.declineCall();
}
finish();
});
endCallButton.setOnClickListener(
v -> {
if (viewModel != null) {
viewModel.hangUp();
}
finish();
});
muteButton.setOnClickListener(
v -> {
if (viewModel != null) {
viewModel.toggleAudio();
}
});
videoButton.setOnClickListener(
v -> {
if (viewModel != null) {
viewModel.toggleVideo();
}
});
speakerButton.setOnClickListener(
v -> {
if (viewModel != null) {
showAudioDevicePicker();
}
});
switchCameraButton.setOnClickListener(
v -> {
if (viewModel != null) {
viewModel.switchCamera();
}
});
getOnBackPressedDispatcher()
.addCallback(
this,
new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
CallViewModel.CallState state = viewModel.getCallState().getValue();
if (isDeviceLocked() || state == null) {
finish();
return;
}
switch (state) {
case RINGING:
case CONNECTING:
case CONNECTED:
case RECONNECTING:
enterPictureInPictureMode(createPipParams());
break;
case INITIALIZING:
case PROMPTING_USER_ACCEPT:
case ENDED:
case ANSWERED_ELSEWHERE:
case ERROR:
default:
finish();
break;
}
}
});
}
private void initializeViewModel() {
viewModel = new ViewModelProvider(this).get(CallViewModel.class);
observeViewModel();
viewModel.initialize();
Log.d(TAG, "CallViewModel initialized");
}
private void observeViewModel() {
// Call state
viewModel.getCallState().observe(this, this::updateUIForState);
// Display info
viewModel
.getDisplayName()
.observe(
this,
name -> {
if (name != null) {
displayNameText.setText(name);
} else {
displayNameText.setText(R.string.unknown);
}
});
viewModel
.getDisplayIcon()
.observe(
this,
icon -> {
if (callerIconView != null) {
if (icon != null) {
callerIconView.setImageIcon(icon);
} else {
callerIconView.setImageResource(R.drawable.ic_person);
}
}
if (remoteAvatarView != null) {
if (icon != null) {
remoteAvatarView.setImageIcon(icon);
} else {
remoteAvatarView.setImageResource(R.drawable.ic_person);
}
}
});
// Audio/video state
viewModel
.getAudioEnabled()
.observe(
this,
enabled -> {
muteButton.setSelected(!enabled);
muteButton.setImageResource(enabled ? R.drawable.ic_mic_on : R.drawable.ic_mic_off);
});
viewModel
.getVideoEnabled()
.observe(
this,
enabled -> {
videoButton.setSelected(!enabled);
videoButton.setImageResource(
enabled ? R.drawable.ic_videocam_on : R.drawable.ic_videocam_off);
});
viewModel
.getCurrentAudioEndpoint()
.observe(
this,
endpoint -> {
updateSpeakerButton(endpoint);
updateProximityWakeLock();
});
viewModel
.getAvailableAudioEndpoints()
.observe(
this,
endpoints -> {
// Need observe to trigger flow emit
Log.d(
TAG,
"Available endpoints updated, count: "
+ (endpoints != null ? endpoints.size() : 0));
});
// Errors
viewModel
.getErrorMessage()
.observe(
this,
error -> {
if (error != null && !error.isEmpty()) {
Toast.makeText(this, error, Toast.LENGTH_LONG).show();
}
});
// Relay usage
viewModel
.getIsRelayUsed()
.observe(
this,
isRelayUsed -> {
if (isRelayUsed != null) {
Log.d(TAG, "TURN relay is " + (isRelayUsed ? "being used" : "NOT being used"));
}
});
// Set up LiveData sources
videoConfigChanged.addSource(viewModel.getCallState(), v -> videoConfigChanged.setValue(true));
videoConfigChanged.addSource(
viewModel.getLocalVideoTrack(), v -> videoConfigChanged.setValue(true));
videoConfigChanged.addSource(
viewModel.getRemoteVideoTrack(), v -> videoConfigChanged.setValue(true));
videoConfigChanged.addSource(
viewModel.getVideoEnabled(), v -> videoConfigChanged.setValue(true));
videoConfigChanged.addSource(
viewModel.getRemoteVideoEnabled(), v -> videoConfigChanged.setValue(true));
videoConfigChanged.addSource(
viewModel.getIsFrontCamera(), v -> videoConfigChanged.setValue(true));
// Video layout
videoConfigChanged.observe(
this,
changed -> {
if (!isFinishing() && !isDestroyed()) {
layoutVideos();
}
});
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
handleIntents(intent);
Log.d(TAG, "onNewIntent called - activity reused");
}
private void updateUIForState(CallViewModel.CallState state) {
Log.d(TAG, "Call state: " + state);
if (isInPictureInPictureMode()
&& state != CallViewModel.CallState.ENDED
&& state != CallViewModel.CallState.ERROR) {
return;
}
switch (state) {
case INITIALIZING:
statusText.setText(R.string.call_initializing);
incomingCallPrompt.setVisibility(View.GONE);
bottomLayoutContainer.setVisibility(View.GONE);
callerIconContainer.setVisibility(View.GONE);
answerModeSelector.setVisibility(View.GONE);
break;
case PROMPTING_USER_ACCEPT:
statusText.setText(R.string.call_incoming);
incomingCallPrompt.setVisibility(View.VISIBLE);
bottomLayoutContainer.setVisibility(View.GONE);
callerIconContainer.setVisibility(View.VISIBLE);
answerModeSelector.setVisibility(View.VISIBLE);
initializeAnswerModeSelector();
break;
case RINGING:
statusText.setText(R.string.call_ringing);
incomingCallPrompt.setVisibility(View.GONE);
bottomLayoutContainer.setVisibility(View.VISIBLE);
callerIconContainer.setVisibility(View.GONE);
answerModeSelector.setVisibility(View.GONE);
break;
case CONNECTING:
statusText.setText(R.string.connectivity_connecting);
incomingCallPrompt.setVisibility(View.GONE);
bottomLayoutContainer.setVisibility(View.VISIBLE);
callerIconContainer.setVisibility(View.GONE);
answerModeSelector.setVisibility(View.GONE);
break;
case CONNECTED:
statusText.setText(R.string.connectivity_connected);
incomingCallPrompt.setVisibility(View.GONE);
bottomLayoutContainer.setVisibility(View.VISIBLE);
callerIconContainer.setVisibility(View.GONE);
answerModeSelector.setVisibility(View.GONE);
break;
case RECONNECTING:
statusText.setText(R.string.call_reconnecting);
break;
case ANSWERED_ELSEWHERE:
statusText.setText(R.string.call_answered_elsewhere);
incomingCallPrompt.setVisibility(View.GONE);
bottomLayoutContainer.setVisibility(View.GONE);
callerIconContainer.setVisibility(View.GONE);
answerModeSelector.setVisibility(View.GONE);
new Handler(Looper.getMainLooper())
.postDelayed(
() -> {
if (!isFinishing()) {
finish();
}
},
1500);
break;
case ENDED:
statusText.setText(R.string.call_ended);
finish();
break;
case ERROR:
statusText.setText(R.string.call_failed);
incomingCallPrompt.setVisibility(View.GONE);
bottomLayoutContainer.setVisibility(View.GONE);
callerIconContainer.setVisibility(View.GONE);
answerModeSelector.setVisibility(View.GONE);
new Handler(Looper.getMainLooper())
.postDelayed(
() -> {
if (!isFinishing()) {
finish();
}
},
2500);
break;
}
updateProximityWakeLock();
}
private void updateSpeakerButton(CallEndpointCompat endpoint) {
if (endpoint == null) {
speakerButton.setImageResource(R.drawable.ic_volume_up);
return;
}
int iconRes = CallUtil.getIconResByCallEndpoint(endpoint);
speakerButton.setImageResource(iconRes);
Log.d(TAG, "Speaker button updated for endpoint: " + endpoint.getName());
}
private void showAudioDevicePicker() {
List<CallEndpointCompat> endpoints = viewModel.getAvailableAudioEndpoints().getValue();
CallEndpointCompat currentEndpoint = viewModel.getCurrentAudioEndpoint().getValue();
if (endpoints == null || endpoints.isEmpty()) {
Log.w(TAG, "No audio endpoints available");
Toast.makeText(this, "No audio devices available", Toast.LENGTH_SHORT).show();
return;
}
// Create and show BottomSheetDialog
AudioDevicePickerDialog dialog =
new AudioDevicePickerDialog(
this,
endpoints,
currentEndpoint,
selectedEndpoint -> {
viewModel.selectAudioDevice(selectedEndpoint);
});
dialog.show();
}
private void updateProximityWakeLock() {
if (proximityWakeLock == null) {
return;
}
boolean shouldHoldLock = shouldHoldLock();
// Acquire or release based on conditions
if (shouldHoldLock && !proximityWakeLock.isHeld()) {
proximityWakeLock.acquire(10 * 60 * 1000L);
Log.d(TAG, "Proximity wake lock acquired");
} else if (!shouldHoldLock && proximityWakeLock.isHeld()) {
// Prevent screen from turning on immediately if phone is still near face
proximityWakeLock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY);
Log.d(TAG, "Proximity wake lock released with wait flag");
}
}
private boolean shouldHoldLock() {
CallViewModel.CallState state = viewModel.getCallState().getValue();
CallEndpointCompat endpoint = viewModel.getCurrentAudioEndpoint().getValue();
return (state == CallViewModel.CallState.CONNECTED
|| state == CallViewModel.CallState.RINGING
|| state == CallViewModel.CallState.CONNECTING
|| state == CallViewModel.CallState.RECONNECTING)
&& endpoint != null
&& endpoint.getType() == CallEndpointCompat.TYPE_EARPIECE
&& !isInPictureInPictureMode();
}
private void initializeAnswerModeSelector() {
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
// Set initial selection without triggering listener
answerModeSelector.clearOnButtonCheckedListeners();
answerModeSelector.check(
coordinator.isStartsWithVideo() ? R.id.answer_video_button : R.id.answer_audio_only_button);
// Set listener
answerModeSelector.addOnButtonCheckedListener(
(group, checkedId, isChecked) -> {
if (isChecked && viewModel != null) {
boolean startsWithVideo = (checkedId == R.id.answer_video_button);
viewModel.setStartsWithVideo(startsWithVideo);
viewModel.switchToEarpiece(!startsWithVideo);
Log.d(TAG, "Answer mode changed to: " + (startsWithVideo ? "Video" : "Audio"));
}
});
}
private void layoutVideos() {
if (isFinishing() || isDestroyed()) return;
if (viewModel == null) return;
CallViewModel.CallState state = viewModel.getCallState().getValue();
if (state == CallViewModel.CallState.ENDED || state == CallViewModel.CallState.ERROR) return;
detachAllTracks();
Boolean videoEnabled = viewModel.getVideoEnabled().getValue();
Boolean remoteVideoEnabled = viewModel.getRemoteVideoEnabled().getValue();
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
VideoTrack localTrack = viewModel.getLocalVideoTrack().getValue();
VideoTrack remoteTrack = viewModel.getRemoteVideoTrack().getValue();
boolean isFront = Boolean.TRUE.equals(viewModel.getIsFrontCamera().getValue());
boolean showFullScreen = false;
if (state == CallViewModel.CallState.CONNECTED
&& remoteTrack != null
&& Boolean.TRUE.equals(remoteVideoEnabled)) {
remoteVideoView.setMirror(false);
remoteTrack.addSink(remoteVideoView);
showFullScreen = true;
} else if (!coordinator.isIncomingCall()
&& (state == CallViewModel.CallState.RINGING || state == CallViewModel.CallState.CONNECTING)
&& localTrack != null
&& Boolean.TRUE.equals(videoEnabled)) {
remoteVideoView.setMirror(isFront);
localTrack.addSink(remoteVideoView);
showFullScreen = true;
}
remoteVideoView.setVisibility(showFullScreen ? View.VISIBLE : View.GONE);
boolean showCorner =
state == CallViewModel.CallState.CONNECTED
&& localTrack != null
&& Boolean.TRUE.equals(videoEnabled)
&& !isInPictureInPictureMode();
if (showCorner) {
localVideoView.setMirror(isFront);
localTrack.addSink(localVideoView);
}
localVideoContainer.setVisibility(showCorner ? View.VISIBLE : View.GONE);
boolean showAvatar =
!showFullScreen
&& state != CallViewModel.CallState.PROMPTING_USER_ACCEPT
&& state != CallViewModel.CallState.INITIALIZING;
remoteAvatarView.setVisibility(showAvatar ? View.VISIBLE : View.GONE);
}
private void detachAllTracks() {
VideoTrack localTrack = viewModel.getLocalVideoTrack().getValue();
VideoTrack remoteTrack = viewModel.getRemoteVideoTrack().getValue();
// Clear all sinks first
if (localTrack != null) {
localTrack.removeSink(localVideoView);
localTrack.removeSink(remoteVideoView);
}
if (remoteTrack != null) {
remoteTrack.removeSink(remoteVideoView);
}
}
// Permissions
private boolean hasMicrophonePermission() {
return ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED;
}
private boolean hasCameraPermission() {
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED;
}
private boolean shouldRequestCameraPermission() {
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
return coordinator.isStartsWithVideo() && !hasCameraPermission();
}
private void handleMicPermissionDenied() {
Toast.makeText(this, R.string.call_requires_mic_permission, Toast.LENGTH_LONG).show();
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
if (coordinator.hasActiveCall()
&& coordinator.isIncomingCall()
&& !coordinator.hasOngoingCall()) {
coordinator.declineCall();
}
finish();
}
private void proceedAfterPermissions() {
if (intentHandled) return;
if (shouldRequestCameraPermission()) {
awaitingPermissionResult = true;
ActivityCompat.requestPermissions(
this, new String[] {Manifest.permission.CAMERA}, CAMERA_PERMISSION_REQUEST_CODE);
return;
}
handleIntents(getIntent());
intentHandled = true;
}
@Override
public void onRequestPermissionsResult(
int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
awaitingPermissionResult = false;
pausedWhileAwaitingPermission = false;
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
if (requestCode == MIC_PERMISSION_REQUEST_CODE) {
boolean micGranted =
grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
if (!micGranted) {
handleMicPermissionDenied();
return;
}
} else if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) {
boolean cameraGranted =
grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
if (!cameraGranted) {
Log.w(TAG, "Camera permission denied, switching to audio-only");
Toast.makeText(
this, "Starting audio-only call (camera permission denied)", Toast.LENGTH_SHORT)
.show();
coordinator.setStartsWithVideo(false);
}
}
proceedAfterPermissions();
}
// Picture-in-Picture
@Override
public void onUserLeaveHint() {
super.onUserLeaveHint();
// Do not finish activity when a permission request is pending
if (awaitingPermissionResult) {
return;
}
// Enter PiP mode when user presses home button during active call
if (viewModel != null) {
CallViewModel.CallState state = viewModel.getCallState().getValue();
if (state != null) {
switch (state) {
case RINGING:
case CONNECTING:
case CONNECTED:
case RECONNECTING:
enterPictureInPictureMode(createPipParams());
break;
case INITIALIZING:
case PROMPTING_USER_ACCEPT:
case ENDED:
case ANSWERED_ELSEWHERE:
case ERROR:
default:
finish();
break;
}
}
} else {
Log.w(TAG, "No View Model exists");
}
}
private PictureInPictureParams createPipParams() {
Rational aspectRatio = new Rational(9, 16);
PictureInPictureParams.Builder builder =
new PictureInPictureParams.Builder()
.setAspectRatio(aspectRatio)
.setActions(new ArrayList<>());
// FIXME: PiP currently shows media controls.
// Will fix later: to fix this, we may need changes to audio playback implementation.
return builder.build();
}
// Helper
private boolean isDeviceLocked() {
KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE);
if (keyguardManager != null) {
return keyguardManager.isKeyguardLocked();
}
return false;
}
// Cleanup
@Override
protected void onPause() {
super.onPause();
if (awaitingPermissionResult) {
pausedWhileAwaitingPermission = true;
}
if (proximityWakeLock != null && proximityWakeLock.isHeld()) {
proximityWakeLock.release();
Log.d(TAG, "Proximity wake lock released in onDestroy");
}
}
@Override
protected void onResume() {
super.onResume();
// Fallback for Android 16 bug: onRequestPermissionsResult not called
if (awaitingPermissionResult && pausedWhileAwaitingPermission) {
Log.w(TAG, "Permission result callback not received, handling in onResume");
awaitingPermissionResult = false;
pausedWhileAwaitingPermission = false;
if (!hasMicrophonePermission()) {
handleMicPermissionDenied();
return;
}
// Mic was granted without callback
if (shouldRequestCameraPermission()) {
awaitingPermissionResult = true;
ActivityCompat.requestPermissions(
this, new String[] {Manifest.permission.CAMERA}, CAMERA_PERMISSION_REQUEST_CODE);
return;
}
proceedAfterPermissions();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
if (viewModel != null) {
detachAllTracks();
}
// Release video renderers
if (localVideoView != null) {
localVideoView.release();
}
if (remoteVideoView != null) {
remoteVideoView.release();
}
Log.d(TAG, "CallActivity destroyed");
}
}