Merge branch 'main' into adb/issue-4401

This commit is contained in:
adb
2026-05-12 15:18:17 +02:00
committed by GitHub
19 changed files with 462 additions and 177 deletions
@@ -423,9 +423,18 @@ public abstract class BaseConversationListFragment extends Fragment implements A
.setIcon(IconCompat.createWithAdaptiveBitmap(avatar))
.setIntent(intent)
.build();
boolean success;
try {
success = ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfoCompat, null);
} catch (Exception e) {
Log.e(TAG, "ErrAddToHomescreen: requestPinShortcut() failed", e);
success = false;
}
boolean finalSuccess = success;
Util.runOnMain(
() -> {
if (!ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfoCompat, null)) {
if (!finalSuccess) {
Toast.makeText(
activity,
"ErrAddToHomescreen: requestPinShortcut() failed",
@@ -198,7 +198,6 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
private final boolean isSecureText = true;
private boolean isDefaultSms = true;
private boolean isSecurityInitialized = false;
private boolean successfulForwardingAttempt = false;
private boolean isEditing = false;
private boolean switchedProfile = false;
@@ -844,20 +843,17 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
if (dcChat.isSelfTalk()) {
SendRelayedMessageUtil.immediatelyRelay(this, chatId);
} else {
String name = dcChat.getName();
if (!dcChat.isMultiUser()) {
int[] contactIds = dcContext.getChatContacts(chatId);
if (contactIds.length == 1 || contactIds.length == 2) {
name = dcContext.getContact(contactIds[0]).getDisplayName();
}
}
int messageIds[] = ShareUtil.getForwardedMessageIDs(this);
int messageCount = messageIds == null ? 0 : messageIds.length;
new AlertDialog.Builder(this)
.setMessage(getString(R.string.ask_forward, name))
.setMessage(
getResources()
.getQuantityString(
R.plurals.ask_forward_messages, messageCount, messageCount, dcChat.getName()))
.setPositiveButton(
R.string.ok,
R.string.forward,
(dialogInterface, i) -> {
SendRelayedMessageUtil.immediatelyRelay(this, chatId);
successfulForwardingAttempt = true;
})
.setNegativeButton(R.string.cancel, (dialogInterface, i) -> finish())
.setOnCancelListener(dialog -> finish())
@@ -457,7 +457,7 @@ public class ConversationItem extends BaseConversationItem {
int end = spanned.getSpanEnd(span);
if (start >= 0 && end > start && end <= spanned.length()) {
String linkText = spanned.subSequence(start, end).toString();
String label = context.getString(R.string.accessibility_link_action, linkText);
String label = context.getString(R.string.open_link, linkText);
linkActionIds.add(
ViewCompat.addAccessibilityAction(
this,
@@ -1091,7 +1091,18 @@ public class ConversationItem extends BaseConversationItem {
if (!messageRecord.isOutgoing() && callInfo.state instanceof CallState.Alerting) {
int callId = messageRecord.getId();
CallCoordinator coordinator = CallCoordinator.getInstance(context);
coordinator.showIncomingCallScreen(callId);
if (coordinator.hasActiveCall()) {
coordinator.showIncomingCallScreen(callId);
} else {
if (callInfo.sdpOffer == null) {
Toast.makeText(context, R.string.error, Toast.LENGTH_SHORT).show();
return;
}
int accId = dcContext.getAccountId();
coordinator.handleIncomingCallFromConversation(
accId, callId, callInfo.sdpOffer, callInfo.hasVideo);
}
} else {
if (callInfo.hasVideo) {
CallUtil.startVideoCall(getContext(), chatId);
@@ -545,11 +545,25 @@ public class WebxdcActivity extends WebViewActivity implements DcEventCenter.DcE
.build();
Toast.makeText(context, R.string.one_moment, Toast.LENGTH_SHORT).show();
if (!ShortcutManagerCompat.requestPinShortcut(context, shortcutInfoCompat, null)) {
Toast.makeText(
context, "ErrAddToHomescreen: requestPinShortcut() failed", Toast.LENGTH_LONG)
.show();
}
Util.runOnAnyBackgroundThread(
() -> {
boolean success;
try {
success = ShortcutManagerCompat.requestPinShortcut(context, shortcutInfoCompat, null);
} catch (Exception e) {
Log.e(TAG, "ErrAddToHomescreen: requestPinShortcut() failed", e);
success = false;
}
if (!success) {
Util.runOnMain(
() ->
Toast.makeText(
context,
"ErrAddToHomescreen: requestPinShortcut() failed",
Toast.LENGTH_LONG)
.show());
}
});
} catch (Exception e) {
Toast.makeText(context, "ErrAddToHomescreen: " + e, Toast.LENGTH_LONG).show();
}
@@ -157,6 +157,12 @@ public class AccountSelectionListFragment extends DialogFragment
onSetTag(accountId);
} else if (itemId == R.id.menu_move_to_top) {
onMoveToTop(accountId);
} else if (itemId == R.id.menu_mark_all_as_read) {
try {
DcHelper.getRpc(requireActivity()).marknoticedAllChats(accountId);
} catch (RpcException e) {
Log.e(TAG, "Error calling rpc.marknoticedAllChats()", e);
}
}
}
@@ -6,11 +6,13 @@ import android.app.PictureInPictureParams;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.PowerManager;
import android.provider.Settings;
import android.util.Log;
import android.util.Rational;
import android.view.View;
@@ -22,7 +24,9 @@ import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.cardview.widget.CardView;
import androidx.constraintlayout.widget.ConstraintLayout;
@@ -54,6 +58,7 @@ public class CallActivity extends AppCompatActivity {
private static final String TAG = "CallActivity";
private static final int MIC_PERMISSION_REQUEST_CODE = 1001;
private static final int CAMERA_PERMISSION_REQUEST_CODE = 1002;
private static final int CAMERA_MID_CALL_PERMISSION_REQUEST_CODE = 1003;
public static final String ACTION_ANSWER_CALL = BuildConfig.APPLICATION_ID + ".ANSWER_CALL";
public static final String ACTION_DECLINE_CALL = BuildConfig.APPLICATION_ID + ".DECLINE_CALL";
@@ -99,6 +104,7 @@ public class CallActivity extends AppCompatActivity {
private boolean awaitingPermissionResult = false;
private boolean pausedWhileAwaitingPermission = false;
private boolean intentHandled = false;
private boolean doNotAutoFinish = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -227,6 +233,7 @@ public class CallActivity extends AppCompatActivity {
Log.d(TAG, "Resuming existing call");
} else if (!coordinator.isIncomingCall()) {
Log.d(TAG, "Starting outgoing call");
coordinator.ensureServiceStarted();
viewModel.startOutgoingCallWhenReady();
}
}
@@ -374,6 +381,18 @@ public class CallActivity extends AppCompatActivity {
videoButton.setOnClickListener(
v -> {
if (viewModel != null) {
Boolean currentEnabled = viewModel.getVideoEnabled().getValue();
boolean needToEnable = currentEnabled == null || !currentEnabled;
if (needToEnable && !hasCameraPermission()) {
awaitingPermissionResult = true;
ActivityCompat.requestPermissions(
this,
new String[] {Manifest.permission.CAMERA},
CAMERA_MID_CALL_PERMISSION_REQUEST_CODE);
return;
}
viewModel.toggleVideo();
}
});
@@ -634,7 +653,7 @@ public class CallActivity extends AppCompatActivity {
new Handler(Looper.getMainLooper())
.postDelayed(
() -> {
if (!isFinishing()) {
if (!isFinishing() && !doNotAutoFinish) {
finish();
}
},
@@ -643,7 +662,9 @@ public class CallActivity extends AppCompatActivity {
case ENDED:
statusText.setText(R.string.call_ended);
finish();
if (!doNotAutoFinish) {
finish();
}
break;
case ERROR:
@@ -656,7 +677,7 @@ public class CallActivity extends AppCompatActivity {
new Handler(Looper.getMainLooper())
.postDelayed(
() -> {
if (!isFinishing()) {
if (!isFinishing() && !doNotAutoFinish) {
finish();
}
},
@@ -842,16 +863,28 @@ public class CallActivity extends AppCompatActivity {
}
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();
if (coordinator.hasActiveCall() && !coordinator.hasOngoingCall()) {
if (coordinator.isIncomingCall()) {
coordinator.declineCall();
} else {
coordinator.hangUp();
}
}
finish();
if (!shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)) {
doNotAutoFinish = true;
showPermissionSettingsDialog(
getString(R.string.call_requires_mic_permission),
() -> {
doNotAutoFinish = false;
if (!isFinishing()) finish();
});
} else {
Toast.makeText(this, R.string.call_requires_mic_permission, Toast.LENGTH_LONG).show();
finish();
}
}
private void proceedAfterPermissions() {
@@ -878,6 +911,22 @@ public class CallActivity extends AppCompatActivity {
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
if (requestCode == CAMERA_MID_CALL_PERMISSION_REQUEST_CODE) {
boolean cameraGranted =
grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
if (cameraGranted && viewModel != null) {
viewModel.toggleVideo();
} else {
if (!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
showPermissionSettingsDialog(getString(R.string.call_requires_camera_permission), null);
} else {
Toast.makeText(this, R.string.call_requires_camera_permission, Toast.LENGTH_SHORT).show();
}
}
return;
}
if (requestCode == MIC_PERMISSION_REQUEST_CODE) {
boolean micGranted =
grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
@@ -893,9 +942,17 @@ public class CallActivity extends AppCompatActivity {
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();
if (!shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
Toast.makeText(
this,
"Camera permission permanently denied. Enable in app settings for video calls.",
Toast.LENGTH_LONG)
.show();
} else {
Toast.makeText(
this, "Starting audio-only call (camera permission denied)", Toast.LENGTH_SHORT)
.show();
}
coordinator.setStartsWithVideo(false);
}
}
@@ -903,6 +960,34 @@ public class CallActivity extends AppCompatActivity {
proceedAfterPermissions();
}
private void showPermissionSettingsDialog(String message, @Nullable Runnable onDismissAction) {
if (isFinishing() || isDestroyed()) {
if (onDismissAction != null) onDismissAction.run();
return;
}
new AlertDialog.Builder(this)
.setTitle("Permission Required")
.setMessage(message)
.setPositiveButton(
"Open Settings",
(dialog, which) -> {
Intent intent =
new Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", getPackageName(), null));
startActivity(intent);
})
.setNegativeButton(android.R.string.cancel, null)
.setOnDismissListener(
dialog -> {
if (onDismissAction != null) {
onDismissAction.run();
}
})
.show();
}
// Picture-in-Picture
@Override
@@ -23,6 +23,7 @@ import android.os.Looper;
import android.telecom.DisconnectCause;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
@@ -32,6 +33,7 @@ import androidx.core.telecom.CallControlScope;
import androidx.core.telecom.CallEndpointCompat;
import androidx.core.telecom.CallException;
import androidx.core.telecom.CallsManager;
import androidx.core.util.Pair;
import androidx.lifecycle.FlowLiveDataConversions;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;
@@ -97,6 +99,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
private final MutableLiveData<Boolean> outgoingCallPlaced = new MutableLiveData<>(false);
private final MutableLiveData<Boolean> answeredElsewhere = new MutableLiveData<>(false);
private final MutableLiveData<Boolean> isFrontCamera = new MutableLiveData<>(true);
private final MutableLiveData<Boolean> mediaCaptureReady = new MutableLiveData<>(false);
// Audio Routing Support
private final MediatorLiveData<CallEndpointCompat> currentAudioEndpoint =
@@ -119,6 +122,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
private String pendingOfferSdp;
private boolean hasNotifiedBackend = false;
private boolean hasAutoSelectedEarpiece = false;
private boolean pendingMediaCapture = false;
private CallControlScope activeCallControlScope;
private CallViewModel activeCallViewModel;
@@ -228,6 +232,11 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
mainHandler.removeCallbacks(outgoingRingtoneRunnable);
mainHandler.postDelayed(outgoingRingtoneRunnable, 1500);
}
if (pendingMediaCapture) {
pendingMediaCapture = false;
callService.startMediaCapture();
}
}
}
@@ -336,6 +345,10 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
return isFrontCamera;
}
public LiveData<Boolean> getMediaCaptureReady() {
return mediaCaptureReady;
}
// State Update Methods (CallService)
public void updateConnectionState(PeerConnection.PeerConnectionState state) {
@@ -375,6 +388,11 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
isFrontCamera.postValue(front);
}
public void updateMediaCaptureReady(boolean ready) {
Log.d(TAG, "updateMediaCaptureReady: " + ready);
mediaCaptureReady.postValue(ready);
}
public void reportError(String error) {
Log.e(TAG, "reportError: " + error);
errorMessage.postValue(error);
@@ -387,7 +405,8 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
if (callService != null) {
callService.startMediaCapture();
} else {
Log.w(TAG, "Cannot start media capture, service not ready");
Log.d(TAG, "Service not ready, deferring media capture");
pendingMediaCapture = true;
}
}
@@ -651,11 +670,17 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
public synchronized void setVideoEnabled(boolean enabled) {
Log.d(TAG, "setVideoEnabled: " + enabled);
if (callService != null) {
boolean success = callService.setVideoEnabled(enabled);
if (!success && enabled) {
enabled = false;
reportError("Camera unavailable");
}
}
localVideoEnabled.postValue(enabled);
if (callService != null) {
callService.setVideoEnabled(enabled);
callService.sendMutedState(Boolean.TRUE.equals(localAudioEnabled.getValue()), enabled);
}
}
@@ -875,32 +900,15 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
int accId, int callId, String offerSdp, boolean startsWithVideo) {
Log.d(TAG, "onIncomingCall: accId=" + accId + ", callId=" + callId);
if (hasActiveCall()) {
Log.w(TAG, "Already have an active call, ignoring incoming call");
return;
}
Pair<DcChat, String> result = setupIncomingCallState(accId, callId, offerSdp, startsWithVideo);
if (result == null) return;
resetLiveDataForNewCall();
this.activeAccId = accId;
this.activeCallId = callId;
this.isIncomingCall = true;
this.startsWithVideo = startsWithVideo;
this.pendingOfferSdp = offerSdp;
// Get caller info
DcContext dcContext = ApplicationContext.getDcAccounts().getAccount(accId);
int chatId = dcContext.getMsg(callId).getChatId();
this.activeChatId = chatId;
DcChat dcChat = dcContext.getChat(chatId);
String callerName = getNameFromChat(dcChat);
DcChat dcChat = result.first;
String callerName = result.second;
Icon callerIcon = getIconFromChat(this.appContext, dcChat);
displayName.postValue(callerName);
displayIcon.postValue(callerIcon);
this.preferredStartingEndpoint = getPreferredStartingEndpoint(startsWithVideo);
// Add to CallsManager
CallAttributesCompat callAttributes = createCallAttributes(callerName, callId, true);
addCallToTelecom(callAttributes, callerName, callerIcon);
@@ -911,6 +919,37 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
startAndBindService();
}
public synchronized void handleIncomingCallFromConversation(
int accId, int callId, String offerSdp, boolean hasVideo) {
Log.d(TAG, "handleIncomingCallFromConversation: accId=" + accId + ", callId=" + callId);
if (offerSdp == null || offerSdp.isEmpty()) {
Log.e(TAG, "Cannot start incoming call: no SDP offer");
return;
}
Pair<DcChat, String> result = setupIncomingCallState(accId, callId, offerSdp, hasVideo);
if (result == null) return;
DcChat dcChat = result.first;
String callerName = result.second;
new Thread(
() -> {
Icon callerIcon = getIconFromChat(this.appContext, dcChat);
displayIcon.postValue(callerIcon);
})
.start();
// Add to CallsManager
CallAttributesCompat callAttributes = createCallAttributes(callerName, callId, true);
addCallToTelecom(callAttributes, callerName, null);
startAndBindService();
launchCallActivity();
}
private synchronized void onIncomingCallAccepted(int callId, boolean fromThisDevice) {
Log.d(TAG, "onIncomingCallAccepted: callId=" + callId + ", fromThisDevice=" + fromThisDevice);
@@ -1129,6 +1168,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
this.pendingOfferSdp = null;
this.hasNotifiedBackend = false;
this.hasAutoSelectedEarpiece = false;
this.pendingMediaCapture = false;
mainHandler.removeCallbacks(outgoingRingtoneRunnable);
@@ -1194,6 +1234,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
private void resetLiveDataForNewCall() {
connectionState.postValue(PeerConnection.PeerConnectionState.NEW);
answeredElsewhere.postValue(false); // clearLiveData() must not reset answeredElsewhere
mediaCaptureReady.postValue(false);
clearLiveData();
}
@@ -1209,6 +1250,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
outgoingCallPlaced.postValue(false);
currentAudioEndpoint.postValue(null);
availableAudioEndpoints.postValue(null);
mediaCaptureReady.postValue(false);
}
public synchronized void initiateOutgoingCall(int accId, int chatId, boolean startsWithVideo) {
@@ -1219,16 +1261,6 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
return;
}
// Check microphone permission
if (!hasMicrophonePermission()) {
Log.e(TAG, "Microphone permission not granted");
Intent intent = new Intent(appContext, CallActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
appContext.startActivity(intent);
return;
}
resetLiveDataForNewCall();
this.activeCallId = -1; // Placeholder call ID for Intent
@@ -1254,7 +1286,9 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
this.preferredStartingEndpoint = getPreferredStartingEndpoint(startsWithVideo);
startAndBindService();
if (hasMicrophonePermission()) {
startAndBindService();
}
launchCallActivity();
}
@@ -1286,6 +1320,43 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
addCallToTelecom(callAttributes, calleeName, calleeIcon);
}
@Nullable
private Pair<DcChat, String> setupIncomingCallState(
int accId, int callId, String offerSdp, boolean startsWithVideo) {
if (hasActiveCall()) {
Log.w(TAG, "Already have an active call, ignoring incoming call");
return null;
}
resetLiveDataForNewCall();
this.activeAccId = accId;
this.activeCallId = callId;
this.isIncomingCall = true;
this.startsWithVideo = startsWithVideo;
this.pendingOfferSdp = offerSdp;
DcContext dcContext = ApplicationContext.getDcAccounts().getAccount(accId);
int chatId = dcContext.getMsg(callId).getChatId();
this.activeChatId = chatId;
DcChat dcChat = dcContext.getChat(chatId);
String callerName = getNameFromChat(dcChat);
displayName.postValue(callerName);
this.preferredStartingEndpoint = getPreferredStartingEndpoint(startsWithVideo);
return new Pair<>(dcChat, callerName);
}
public synchronized void ensureServiceStarted() {
if (isServiceBound || !hasActiveCall()) {
return;
}
Log.d(TAG, "Starting service after permission grant");
startAndBindService();
}
private void addCallToTelecom(
CallAttributesCompat callAttributes, String displayName, Icon icon) {
try {
@@ -126,13 +126,13 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
}
/**
* Start camera/microphone capture
* Start media capture
*
* <p>Must be called when app is in foreground. Called by coordinator when ViewModel/Activity is
* ready.
*/
public void startMediaCapture() {
Log.d(TAG, "startMediaCapture (Camera/Microphone)");
Log.d(TAG, "startMediaCapture");
if (webRTCClient != null && webRTCClient.hasLocalMediaStream()) {
Log.w(TAG, "Media already initialized, skipping");
@@ -153,7 +153,7 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
boolean startsWithVideo = callCoordinator.isStartsWithVideo();
Log.d(TAG, "Creating media stream with video: " + startsWithVideo);
Log.d(TAG, "Creating media stream");
mediaStreamManager.createMediaStream(
new MediaStreamManager.Callback() {
@@ -163,31 +163,22 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
webRTCClient.setLocalMediaStream(stream);
callCoordinator.updateFrontCamera(mediaStreamManager.isFrontCamera());
callCoordinator.setVideoEnabled(startsWithVideo);
if (!stream.videoTracks.isEmpty()) {
VideoTrack localTrack = stream.videoTracks.get(0);
callCoordinator.updateLocalVideoTrack(localTrack);
} else {
Log.w(TAG, "No video track in stream, call will be audio-only");
if (startsWithVideo) {
callCoordinator.reportError("Camera unavailable, using audio only");
}
callCoordinator.setVideoEnabled(false);
}
callCoordinator.setVideoEnabled(startsWithVideo);
callCoordinator.updateMediaCaptureReady(true);
Log.d(TAG, "Media capture complete, ready for call");
}
@Override
public void onError(String error) {
Log.e(TAG, "Failed to setup media: " + error);
if (startsWithVideo) {
callCoordinator.reportError("Camera/microphone error: " + error);
}
callCoordinator.setVideoEnabled(false);
callCoordinator.reportError("Camera/microphone error: " + error);
}
});
}
@@ -395,12 +386,30 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
}
}
public void setVideoEnabled(boolean enabled) {
public boolean setVideoEnabled(boolean enabled) {
Log.d(TAG, "setVideoEnabled: " + enabled);
if (webRTCClient != null) {
webRTCClient.setVideoEnabled(enabled);
if (enabled) {
if (mediaStreamManager != null) {
boolean captureReady = mediaStreamManager.startVideoCapture();
if (!captureReady) {
Log.w(TAG, "Failed to start video capture");
return false;
}
callCoordinator.updateFrontCamera(mediaStreamManager.isFrontCamera());
}
if (webRTCClient != null) {
webRTCClient.setVideoEnabled(true);
}
} else {
if (webRTCClient != null) {
webRTCClient.setVideoEnabled(false);
}
if (mediaStreamManager != null) {
mediaStreamManager.stopVideoCapture();
}
}
return true;
}
public void sendMutedState(boolean audioEnabled, boolean videoEnabled) {
@@ -46,8 +46,8 @@ public class CallViewModel extends AndroidViewModel {
private final MediatorLiveData<CallState> callState;
// Observer References for one-time observe
private Observer<VideoTrack> answerCallObserver;
private Observer<VideoTrack> startOutgoingCallObserver;
private Observer<Boolean> answerCallObserver;
private Observer<Boolean> startOutgoingCallObserver;
private final AtomicBoolean hasCallEnded = new AtomicBoolean(false);
@@ -211,25 +211,24 @@ public class CallViewModel extends AndroidViewModel {
callCoordinator.startMediaCapture();
// Create one-time observer
LiveData<VideoTrack> localTrack = callCoordinator.getLocalVideoTrack();
LiveData<Boolean> mediaReady = callCoordinator.getMediaCaptureReady();
answerCallObserver =
new Observer<VideoTrack>() {
new Observer<Boolean>() {
@Override
public void onChanged(VideoTrack videoTrack) {
if (videoTrack != null) {
// Media is ready, remove observer
localTrack.removeObserver(this);
public void onChanged(Boolean ready) {
if (Boolean.TRUE.equals(ready)) {
mediaReady.removeObserver(this);
answerCallObserver = null;
Log.d(TAG, "Local video ready, answering call (WebRTC)");
Log.d(TAG, "Media capture ready, answering call (WebRTC)");
callCoordinator.answerWebRTC();
}
}
};
localTrack.observeForever(answerCallObserver);
mediaReady.observeForever(answerCallObserver);
}
/** Start outgoing call with media capture Called by Activity for outgoing calls */
@@ -244,30 +243,28 @@ public class CallViewModel extends AndroidViewModel {
callCoordinator.startMediaCapture();
// Create one-time observer
LiveData<VideoTrack> localTrack = callCoordinator.getLocalVideoTrack();
VideoTrack currentValue = localTrack.getValue();
LiveData<Boolean> mediaReady = callCoordinator.getMediaCaptureReady();
if (currentValue != null) {
if (Boolean.TRUE.equals(mediaReady.getValue())) {
Log.d(TAG, "Media already ready, starting call immediately");
callCoordinator.startOutgoingCall();
} else {
startOutgoingCallObserver =
new Observer<VideoTrack>() {
new Observer<Boolean>() {
@Override
public void onChanged(VideoTrack videoTrack) {
if (videoTrack != null) {
// Media is ready, remove observer
localTrack.removeObserver(this);
public void onChanged(Boolean ready) {
if (Boolean.TRUE.equals(ready)) {
mediaReady.removeObserver(this);
startOutgoingCallObserver = null;
Log.d(TAG, "Local video ready, starting outgoing call");
Log.d(TAG, "Media capture ready, starting outgoing call");
callCoordinator.startOutgoingCall();
}
}
};
localTrack.observeForever(startOutgoingCallObserver);
mediaReady.observeForever(startOutgoingCallObserver);
}
}
@@ -460,12 +457,12 @@ public class CallViewModel extends AndroidViewModel {
Log.d(TAG, "CallViewModel cleared");
if (answerCallObserver != null) {
callCoordinator.getLocalVideoTrack().removeObserver(answerCallObserver);
callCoordinator.getMediaCaptureReady().removeObserver(answerCallObserver);
answerCallObserver = null;
}
if (startOutgoingCallObserver != null) {
callCoordinator.getLocalVideoTrack().removeObserver(startOutgoingCallObserver);
callCoordinator.getMediaCaptureReady().removeObserver(startOutgoingCallObserver);
startOutgoingCallObserver = null;
}
@@ -29,6 +29,10 @@ public class MediaStreamManager {
private static final String AUDIO_TRACK_ID = "audio_track";
private static final String VIDEO_TRACK_ID = "video_track";
private static final int VIDEO_WIDTH = 1280;
private static final int VIDEO_HEIGHT = 720;
private static final int VIDEO_FPS = 30;
private final Context context;
private final PeerConnectionFactory peerConnectionFactory;
@@ -37,6 +41,7 @@ public class MediaStreamManager {
private AudioSource audioSource;
private SurfaceTextureHelper surfaceTextureHelper;
private volatile boolean isFrontCamera = true;
private volatile boolean isCapturing = false;
public interface Callback {
void onMediaStreamReady(MediaStream stream);
@@ -56,9 +61,8 @@ public class MediaStreamManager {
this.peerConnectionFactory = peerConnectionFactory;
}
/** Create media stream with audio and optionally video */
@RequiresApi(api = Build.VERSION_CODES.M)
public void createMediaStream(Callback callback) {
/** Create a media stream with an audio track and a video track. */
public synchronized void createMediaStream(Callback callback) {
try {
MediaStream mediaStream = peerConnectionFactory.createLocalMediaStream(STREAM_ID);
@@ -68,24 +72,11 @@ public class MediaStreamManager {
AudioTrack audioTrack = peerConnectionFactory.createAudioTrack(AUDIO_TRACK_ID, audioSource);
mediaStream.addTrack(audioTrack);
// Create video track
videoCapturer = createVideoCapturer();
if (videoCapturer == null) {
callback.onError("No camera available");
callback.onMediaStreamReady(mediaStream);
return;
}
videoSource = peerConnectionFactory.createVideoSource(videoCapturer.isScreencast());
// Create video source and track
videoSource = peerConnectionFactory.createVideoSource(false);
VideoTrack videoTrack = peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, videoSource);
mediaStream.addTrack(videoTrack);
// Start capturing
surfaceTextureHelper =
SurfaceTextureHelper.create("CaptureThread", EglUtils.getEglBase().getEglBaseContext());
videoCapturer.initialize(surfaceTextureHelper, context, videoSource.getCapturerObserver());
videoCapturer.startCapture(1280, 720, 30);
callback.onMediaStreamReady(mediaStream);
} catch (Exception e) {
@@ -94,6 +85,62 @@ public class MediaStreamManager {
}
}
/**
* Open the camera and start sending frames to VideoSource.
*
* @return true if the camera is capturing, false if it could not be started
*/
@RequiresApi(api = Build.VERSION_CODES.M)
public synchronized boolean startVideoCapture() {
if (isCapturing) {
return true;
}
if (videoSource == null) {
Log.e(TAG, "VideoSource not initialized");
return false;
}
if (videoCapturer == null) {
videoCapturer = createVideoCapturer();
if (videoCapturer == null) {
Log.w(TAG, "Cannot start video capture: no camera available");
return false;
}
if (surfaceTextureHelper == null) {
surfaceTextureHelper =
SurfaceTextureHelper.create("CaptureThread", EglUtils.getEglBase().getEglBaseContext());
}
videoCapturer.initialize(surfaceTextureHelper, context, videoSource.getCapturerObserver());
}
videoCapturer.startCapture(VIDEO_WIDTH, VIDEO_HEIGHT, VIDEO_FPS);
isCapturing = true;
Log.d(TAG, "Video capture started");
return true;
}
/** Stop the camera. The capturer is kept alive. */
public synchronized void stopVideoCapture() {
if (!isCapturing) {
return;
}
if (videoCapturer != null) {
try {
videoCapturer.stopCapture();
Log.d(TAG, "Video capture stopped");
} catch (InterruptedException e) {
Log.e(TAG, "Interrupted while stopping capture", e);
Thread.currentThread().interrupt();
}
}
isCapturing = false;
}
@Nullable
private VideoCapturer createVideoCapturer() {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
@@ -129,6 +176,14 @@ public class MediaStreamManager {
}
public void switchCamera(@Nullable CameraSwitchCallback callback) {
if (!isCapturing) {
Log.w(TAG, "Cannot switch camera while not capturing");
if (callback != null) {
callback.onError("Camera not active");
}
return;
}
if (!(videoCapturer instanceof CameraVideoCapturer)) {
Log.e(TAG, "switchCamera called but videoCapturer is not a CameraVideoCapturer");
return;
@@ -186,10 +241,12 @@ public class MediaStreamManager {
}
/** Cleanup resources */
public void dispose() {
public synchronized void dispose() {
if (videoCapturer != null) {
try {
videoCapturer.stopCapture();
if (isCapturing) {
videoCapturer.stopCapture();
}
} catch (InterruptedException e) {
Log.e(TAG, "Error stopping capture", e);
}
@@ -197,6 +254,8 @@ public class MediaStreamManager {
videoCapturer = null;
}
isCapturing = false;
if (surfaceTextureHelper != null) {
surfaceTextureHelper.dispose();
surfaceTextureHelper = null;
@@ -188,9 +188,8 @@ public class LocationStreamingService extends Service {
NotificationChannel channel =
new NotificationChannel(
CHANNEL_ID,
getString(R.string.location_streaming_notification_title),
getString(R.string.pref_on_demand_location_streaming),
NotificationManager.IMPORTANCE_LOW);
channel.setDescription(getString(R.string.location_streaming_channel_desc));
channel.setShowBadge(false);
NotificationManager nm = getSystemService(NotificationManager.class);
if (nm != null) nm.createNotificationChannel(channel);
@@ -209,7 +208,7 @@ public class LocationStreamingService extends Service {
PendingIntent.getService(this, 1, stopIntent, PendingIntent.FLAG_IMMUTABLE);
return new NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.location_streaming_notification_title))
.setContentTitle(getString(R.string.pref_on_demand_location_streaming))
.setContentText(getString(R.string.location_streaming_notification_text))
.setSmallIcon(R.drawable.ic_location_on_white_24dp)
.setOngoing(true)
@@ -39,6 +39,7 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme
private CheckBoxPreference ignoreBattery;
private CheckBoxPreference notificationsEnabled;
private CheckBoxPreference mentionNotifEnabled;
private CheckBoxPreference notifyCalls;
private CheckBoxPreference reliableService;
private ActivityResultLauncher<Intent> ringtonePickerLauncher;
@@ -137,6 +138,16 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme
return true;
});
}
notifyCalls = this.findPreference("pref_notify_calls");
if (notifyCalls != null) {
notifyCalls.setOnPreferenceChangeListener(
(preference, newValue) -> {
boolean enabled = (Boolean) newValue;
dcContext.setConfig("who_can_call_me", enabled ? "1" : "2");
return true;
});
}
}
@Override
@@ -156,6 +167,7 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme
notificationsEnabled.setChecked(!dcContext.isMuted());
notificationsEnabled.setSummary(getSummary(getContext(), false));
mentionNotifEnabled.setChecked(dcContext.isMentionsEnabled());
notifyCalls.setChecked(dcContext.getConfig("who_can_call_me") != "2");
// set without altering "unset" state of the preference
reliableService.setOnPreferenceChangeListener(null);
@@ -66,7 +66,7 @@ public class ShareUtil {
}
}
static int[] getForwardedMessageIDs(Activity activity) {
public static int[] getForwardedMessageIDs(Activity activity) {
try {
return activity.getIntent().getIntArrayExtra(FORWARDED_MESSAGE_IDS);
} catch (NullPointerException npe) {
@@ -174,8 +174,4 @@ public class ShareUtil {
public static void setSharedTitle(Intent composeIntent, String text) {
composeIntent.putExtra(SHARED_TITLE, text);
}
public static void setDirectSharing(Intent composeIntent, int chatId) {
composeIntent.putExtra(DIRECT_SHARING_CHAT_ID, chatId);
}
}