Merge branch 'main' into r10s/markfresh

This commit is contained in:
adb
2026-04-18 20:15:57 +02:00
committed by GitHub
66 changed files with 2062 additions and 307 deletions
+16 -2
View File
@@ -396,7 +396,7 @@ public class Rpc {
}
/**
* Gets messages to be processed by the bot and returns their IDs.
* (deprecated) Gets messages to be processed by the bot and returns their IDs.
* <p>
* Only messages with database ID higher than `last_msg_id` config value
* are returned. After processing the messages, the bot should
@@ -404,6 +404,13 @@ public class Rpc {
* or manually updating the value to avoid getting already
* processed messages.
* <p>
* Deprecated 2026-04: This returns the message's id as soon as the first part arrives,
* even if it is not fully downloaded yet.
* The bot needs to wait for the message to be fully downloaded.
* Since this is usually not the desired behavior,
* bots should instead use the #DC_EVENT_INCOMING_MSG / [`types::events::EventType::IncomingMsg`]
* event for getting notified about new messages.
* <p>
* [`markseen_msgs`]: Self::markseen_msgs
*/
public java.util.List<Integer> getNextMsgs(Integer accountId) throws RpcException {
@@ -411,7 +418,7 @@ public class Rpc {
}
/**
* Waits for messages to be processed by the bot and returns their IDs.
* (deprecated) Waits for messages to be processed by the bot and returns their IDs.
* <p>
* This function is similar to [`get_next_msgs`],
* but waits for internal new message notification before returning.
@@ -422,6 +429,13 @@ public class Rpc {
* To shutdown the bot, stopping I/O can be used to interrupt
* pending or next `wait_next_msgs` call.
* <p>
* Deprecated 2026-04: This returns the message's id as soon as the first part arrives,
* even if it is not fully downloaded yet.
* The bot needs to wait for the message to be fully downloaded.
* Since this is usually not the desired behavior,
* bots should instead use the #DC_EVENT_INCOMING_MSG / [`types::events::EventType::IncomingMsg`]
* event for getting notified about new messages.
* <p>
* [`get_next_msgs`]: Self::get_next_msgs
*/
public java.util.List<Integer> waitNextMsgs(Integer accountId) throws RpcException {
@@ -534,8 +534,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity
menu.findItem(R.id.menu_start_call)
.setVisible(
Prefs.isCallsEnabled(this)
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
&& dcChat.canSend()
&& dcChat.isEncrypted()
&& !dcChat.isSelfTalk()
@@ -227,6 +227,7 @@ public class WebxdcActivity extends WebViewActivity implements DcEventCenter.DcE
WebSettings webSettings = webView.getSettings();
webSettings.setJavaScriptEnabled(true);
webSettings.setMediaPlaybackRequiresUserGesture(false);
webSettings.setAllowFileAccess(false);
webSettings.setBlockNetworkLoads(!internetAccess);
webSettings.setAllowContentAccess(false);
@@ -52,7 +52,8 @@ import org.webrtc.VideoTrack;
public class CallActivity extends AppCompatActivity {
private static final String TAG = CallActivity.class.getSimpleName();
private static final int PERMISSION_REQUEST_CODE = 1001;
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";
@@ -94,10 +95,30 @@ public class CallActivity extends AppCompatActivity {
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();
@@ -108,11 +129,6 @@ public class CallActivity extends AppCompatActivity {
initializeProximityWakeLock();
if (!hasRequiredPermissions()) {
requestRequiredPermissions();
return;
}
// PiP listener
addOnPictureInPictureModeChangedListener(
pipModeInfo -> {
@@ -139,47 +155,74 @@ public class CallActivity extends AppCompatActivity {
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 || viewModel == null) {
if (intent == null) {
return;
}
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
if (!coordinator.hasActiveCall()) {
Log.e(TAG, "No active call exists, cannot proceed");
Toast.makeText(this, "No active call", Toast.LENGTH_SHORT).show();
finish();
return;
}
String action = intent.getAction();
Log.d(TAG, "handleIntents: action=" + action);
// Handle notification actions
if (ACTION_ANSWER_CALL.equals(action)) {
Log.d(TAG, "Handling ANSWER_CALL action from notification");
viewModel.handleNotificationAnswer();
return;
}
// Destructive actions without ViewModel
if (ACTION_DECLINE_CALL.equals(action)) {
Log.d(TAG, "Handling DECLINE_CALL action from notification");
viewModel.handleNotificationDecline();
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 from notification");
viewModel.handleNotificationHangup();
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()) {
@@ -783,18 +826,46 @@ public class CallActivity extends AppCompatActivity {
// Permissions
private boolean hasRequiredPermissions() {
return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED
&& ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED;
private boolean hasMicrophonePermission() {
return ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
== PackageManager.PERMISSION_GRANTED;
}
private void requestRequiredPermissions() {
ActivityCompat.requestPermissions(
this,
new String[] {Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO},
PERMISSION_REQUEST_CODE);
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
@@ -802,38 +873,34 @@ public class CallActivity extends AppCompatActivity {
int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == PERMISSION_REQUEST_CODE) {
boolean microphoneGranted = false;
boolean cameraGranted = false;
awaitingPermissionResult = false;
pausedWhileAwaitingPermission = false;
for (int i = 0; i < permissions.length; i++) {
if (permissions[i].equals(Manifest.permission.RECORD_AUDIO)) {
microphoneGranted = (grantResults[i] == PackageManager.PERMISSION_GRANTED);
} else if (permissions[i].equals(Manifest.permission.CAMERA)) {
cameraGranted = (grantResults[i] == PackageManager.PERMISSION_GRANTED);
}
}
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
if (!microphoneGranted) {
Toast.makeText(this, "Microphone permission is required for calls", Toast.LENGTH_LONG)
.show();
finish();
if (requestCode == MIC_PERMISSION_REQUEST_CODE) {
boolean micGranted =
grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
if (!micGranted) {
handleMicPermissionDenied();
return;
}
CallCoordinator coordinator = CallCoordinator.getInstance(getApplication());
} else if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) {
boolean cameraGranted =
grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED;
if (!cameraGranted && coordinator.isStartsWithVideo()) {
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);
}
initializeViewModel();
handleIntents(getIntent());
}
proceedAfterPermissions();
}
// Picture-in-Picture
@@ -842,6 +909,11 @@ public class CallActivity extends AppCompatActivity {
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();
@@ -899,12 +971,44 @@ public class CallActivity extends AppCompatActivity {
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();
@@ -527,22 +527,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
return;
}
// Check microphone and camera permissions
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);
notificationManager.cancel(NOTIFICATION_ID_CALL);
return;
}
if (startsWithVideo && !hasCameraPermission()) {
Log.w(TAG, "Camera permission not granted");
startsWithVideo = false;
}
// Launch CallActivity with answer action
// Launch CallActivity
Intent intent = new Intent(appContext, CallActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
appContext.startActivity(intent);
@@ -1218,7 +1203,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
return;
}
// Check camera and microphone permissions
// Check microphone permission
if (!hasMicrophonePermission()) {
Log.e(TAG, "Microphone permission not granted");
@@ -1228,11 +1213,6 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
return;
}
if (startsWithVideo && !hasCameraPermission()) {
Log.w(TAG, "Camera permission not granted, will start audio-only");
startsWithVideo = false;
}
resetLiveDataForNewCall();
this.activeCallId = -1; // Placeholder call ID for Intent
@@ -1378,10 +1358,8 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
}
// Check full screen intent permission on Android 14+
boolean canUseFullScreen = false;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
canUseFullScreen = canUseFullScreenIntent();
if (!canUseFullScreen) {
if (!canUseFullScreenIntent()) {
Log.w(TAG, "Full screen intent permission not granted, notification will appear normally");
}
}
@@ -1422,7 +1400,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate {
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
Notification.Builder builder;
if (canUseFullScreen && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Android 12+, CallStyle
Person caller =
new Person.Builder().setName(callerName).setIcon(callerIcon).setImportant(true).build();
@@ -142,17 +142,13 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
return;
}
// Check permissions
boolean hasMicrophone = callCoordinator.hasMicrophonePermission();
boolean hasCamera = callCoordinator.hasCameraPermission();
if (!hasMicrophone) {
if (!callCoordinator.hasMicrophonePermission()) {
Log.e(TAG, "Microphone permission not granted, cannot start call");
callCoordinator.reportError("Microphone permission is required for calls");
return;
}
boolean startsWithVideo = callCoordinator.isStartsWithVideo() && hasCamera;
boolean startsWithVideo = callCoordinator.isStartsWithVideo();
Log.d(TAG, "Creating media stream with video: " + startsWithVideo);
@@ -172,8 +168,10 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
VideoTrack localTrack = stream.videoTracks.get(0);
callCoordinator.updateLocalVideoTrack(localTrack);
} else {
Log.w(TAG, "Camera unavailable, call will be audio-only");
callCoordinator.reportError("Camera unavailable, using audio only");
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);
}
@@ -183,7 +181,9 @@ public class CallService extends Service implements WebRTCClient.Callbacks {
@Override
public void onError(String error) {
Log.e(TAG, "Failed to setup media: " + error);
callCoordinator.reportError("Camera/microphone error: " + error);
if (startsWithVideo) {
callCoordinator.reportError("Camera/microphone error: " + error);
}
callCoordinator.setVideoEnabled(false);
}
});
@@ -1,11 +1,14 @@
package org.thoughtcrime.securesms.calls;
import android.Manifest;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.content.ContextCompat;
import org.thoughtcrime.securesms.EglUtils;
import org.webrtc.AudioSource;
import org.webrtc.AudioTrack;
@@ -93,6 +96,12 @@ public class MediaStreamManager {
@Nullable
private VideoCapturer createVideoCapturer() {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED) {
Log.w(TAG, "Camera permission not granted");
return null;
}
Camera2Enumerator enumerator = new Camera2Enumerator(context);
// Try front camera first
@@ -187,24 +187,6 @@ public class AdvancedPreferenceFragment extends ListSummaryPreferenceFragment
});
}
Preference callsEnabled = this.findPreference("pref_calls_enabled");
if (callsEnabled != null) {
callsEnabled.setOnPreferenceChangeListener(
(preference, newValue) -> {
if ((Boolean) newValue) {
new AlertDialog.Builder(requireActivity())
.setTitle("Thanks for helping to debug \"Calls\"!")
.setMessage(
"• You can now debug calls using the \"phone\" icon in one-to-one-chats\n\n"
+ "• The experiment is about making decentralised calls work and reliable at all, not about options or UI. We're happy about focused feedback at support.delta.chat\n\n")
.setCancelable(false)
.setPositiveButton(R.string.ok, null)
.show();
}
return true;
});
}
selfReportingCheckbox = this.findPreference("pref_stats_sending");
if (selfReportingCheckbox != null) {
selfReportingCheckbox.setOnPreferenceChangeListener(
@@ -208,7 +208,6 @@ public class PersistentBlobProvider {
return getFile(context, ContentUris.parseId(uri)).delete();
}
//noinspection SimplifiableIfStatement
if (isExternalBlobUri(context, uri)) {
return new File(uri.getPath()).delete();
}
@@ -287,6 +286,20 @@ public class PersistentBlobProvider {
private static @NonNull File getExternalDir(Context context) throws IOException {
File externalDir = context.getExternalCacheDir();
if (externalDir != null) {
try {
FileProviderUtil.getUriFor(context, new File(externalDir, "test"));
} catch (IllegalArgumentException e) {
Log.w(
TAG,
"External cache dir not resolvable by FileProvider, "
+ "falling back to internal cache",
e);
externalDir = null;
}
}
if (externalDir == null) {
externalDir = context.getCacheDir();
}
@@ -202,10 +202,6 @@ public class Prefs {
return getBooleanPreference(context, "pref_new_broadcast_list", false);
}
public static boolean isCallsEnabled(Context context) {
return getBooleanPreference(context, "pref_calls_enabled", false);
}
// ringtone
public static @NonNull Uri getNotificationRingtone(Context context) {