From e85ae5247ae72a6ad8afa39e45a24f4c42a7dea3 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Tue, 5 May 2026 15:40:06 +0200 Subject: [PATCH 01/20] add "mark all as read" to account menu --- .../securesms/accounts/AccountSelectionListFragment.java | 6 ++++++ src/main/res/menu/account_item_context.xml | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/src/main/java/org/thoughtcrime/securesms/accounts/AccountSelectionListFragment.java b/src/main/java/org/thoughtcrime/securesms/accounts/AccountSelectionListFragment.java index 8098ef0ee..05893f499 100644 --- a/src/main/java/org/thoughtcrime/securesms/accounts/AccountSelectionListFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/accounts/AccountSelectionListFragment.java @@ -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); + } } } diff --git a/src/main/res/menu/account_item_context.xml b/src/main/res/menu/account_item_context.xml index 1fc476d11..165517152 100644 --- a/src/main/res/menu/account_item_context.xml +++ b/src/main/res/menu/account_item_context.xml @@ -2,6 +2,10 @@ + + From 82262cb1664944a52b5045a1200df612c58ee07d Mon Sep 17 00:00:00 2001 From: adbenitez Date: Tue, 5 May 2026 15:41:55 +0200 Subject: [PATCH 02/20] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00b7fbfce..52bb99220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * Allow to share location for 24 hours * Allow mini-apps to play audio without user interaction * Mark chats as unread (long tap a chat and select the corresponding option from the three-dot-menu) +* Add "Mark all as read" option to profile menu in the profile switcher * Fix process of upgrading from a very old version of the app * Show more recent added stickers at the top of the sticker picker * Allow to open links in messages via actions in TalkBack menu From 9a382a4948b3d957f3b9c231af06c6f4013e1e18 Mon Sep 17 00:00:00 2001 From: "B. Petersen" Date: Wed, 6 May 2026 10:07:22 +0200 Subject: [PATCH 03/20] actually explain what the 'calls notification' option does --- src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 892d6e154..9ee8babbe 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -767,7 +767,7 @@ Calls - Show call screen on incoming calls + Show call screen for accepted contacts Show Priority Enable system notifications for new messages From 653c8688b7124dff7e71dadec756e40b5b643a52 Mon Sep 17 00:00:00 2001 From: "B. Petersen" Date: Wed, 6 May 2026 14:05:56 +0200 Subject: [PATCH 04/20] mark deprecated strings as such --- src/main/res/values/strings.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 9ee8babbe..53ca03c0c 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -61,7 +61,9 @@ Media Apps & Media Profile + All Profiles + Current Profile Main Menu Start Chat @@ -772,6 +774,7 @@ Priority Enable system notifications for new messages Show message content in notification + Shows sender and first words of the message in notifications LED Color Sound From d180704d525dd0856302bdbb7222554c70d28a7e Mon Sep 17 00:00:00 2001 From: adbenitez Date: Wed, 6 May 2026 14:59:39 +0200 Subject: [PATCH 05/20] allow to disable call notifications --- .../preferences/NotificationsPreferenceFragment.java | 12 ++++++++++++ src/main/res/xml/preferences_notifications.xml | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java b/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java index 68d0f599f..f63b69b8d 100644 --- a/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java @@ -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 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); diff --git a/src/main/res/xml/preferences_notifications.xml b/src/main/res/xml/preferences_notifications.xml index 603b5ed28..23964b49a 100644 --- a/src/main/res/xml/preferences_notifications.xml +++ b/src/main/res/xml/preferences_notifications.xml @@ -56,6 +56,12 @@ android:title="@string/pref_in_chat_sounds" android:defaultValue="true" /> + + Date: Wed, 6 May 2026 15:01:51 +0200 Subject: [PATCH 06/20] apply spotless --- .../securesms/preferences/NotificationsPreferenceFragment.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java b/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java index f63b69b8d..17dfd06c2 100644 --- a/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java @@ -144,7 +144,7 @@ public class NotificationsPreferenceFragment extends ListSummaryPreferenceFragme notifyCalls.setOnPreferenceChangeListener( (preference, newValue) -> { boolean enabled = (Boolean) newValue; - dcContext.setConfig("who_can_call_me", enabled? "1" : "2"); + dcContext.setConfig("who_can_call_me", enabled ? "1" : "2"); return true; }); } From 94e2c8dbedd7b1af0703b44998f77579a9fd9b9f Mon Sep 17 00:00:00 2001 From: "B. Petersen" Date: Wed, 6 May 2026 10:36:47 +0200 Subject: [PATCH 07/20] feat: simplify location streaming wording --- .../securesms/geolocation/LocationStreamingService.java | 6 +++--- src/main/res/values/strings.xml | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java index e805e7ce2..961b24958 100644 --- a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java @@ -188,9 +188,9 @@ 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.setDescription("Channel for Location Streaming"); channel.setShowBadge(false); NotificationManager nm = getSystemService(NotificationManager.class); if (nm != null) nm.createNotificationChannel(channel); @@ -209,7 +209,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) diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 53ca03c0c..08a9b9746 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -837,7 +837,7 @@ All Experimental Features These features may be unstable and may be changed or removed - On-demand Location Streaming + Location Streaming Default image Default color Custom image @@ -1079,8 +1079,6 @@ %1$d messages in %2$d chats - Channel for On-demand Location Streaming - Location Streaming You are sharing your location To share your live location with chat members, allow Delta Chat to use your location data.\n\nTo make live location work gaplessly, location data is used even when the app is closed or not in use. From 39aec04feab6aedf17d2e752b527faa74514e96e Mon Sep 17 00:00:00 2001 From: "B. Petersen" Date: Wed, 6 May 2026 15:00:34 +0200 Subject: [PATCH 08/20] remove unhelpful channel description --- .../securesms/geolocation/LocationStreamingService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java index 961b24958..72ada35df 100644 --- a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java @@ -190,7 +190,6 @@ public class LocationStreamingService extends Service { CHANNEL_ID, getString(R.string.pref_on_demand_location_streaming), NotificationManager.IMPORTANCE_LOW); - channel.setDescription("Channel for Location Streaming"); channel.setShowBadge(false); NotificationManager nm = getSystemService(NotificationManager.class); if (nm != null) nm.createNotificationChannel(channel); From 43a3e21495d9317054ac3886c903ecd85b350cf1 Mon Sep 17 00:00:00 2001 From: "B. Petersen" Date: Wed, 6 May 2026 14:54:05 +0200 Subject: [PATCH 09/20] add some context for translators, do not introduce another prefix --- .../java/org/thoughtcrime/securesms/ConversationItem.java | 2 +- src/main/res/values/strings.xml | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java index 4ead84239..38cc4d13c 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java @@ -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, diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 08a9b9746..4466161f8 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1174,6 +1174,8 @@ Message actions Wallpaper preview Disappearing messages activated + + Open %1$s Stop sharing location @@ -1228,9 +1230,6 @@ Tap here to receive messages while Delta Chat is in the background. You already allowed Delta Chat to receive messages in the background.\n\nIf messages still do not arrive in background, please also check your system settings. - - Open %1$s - What\'s new?\n\n💯 End-to-end encryption is reliable and forever now. Padlocks 🔒 are gone!\n\n✉️ Classic email without end-to-end encryption is marked with a letter symbol\n\n😻 New enhanced profile screen for all your contacts\n\n🔲 New button for quick access to apps used in a chat\n\n❤️ Please donate to help us remain independent and continue to bring improvements: %1$s From 613940577cdec1d7202355c891b062965bae66e5 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Wed, 6 May 2026 15:04:30 +0200 Subject: [PATCH 10/20] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52bb99220..dc7cdb3f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * Show more recent added stickers at the top of the sticker picker * Allow to open links in messages via actions in TalkBack menu * Allow to open map if user clicks "Location streaming enabled" system message +* Allow to disable incoming calls notifications * Fix: do not accidentally set draft in chats that don't allow sending messages ## v2.49.0 From 89d77e76382fbbb78024e351d9e8ee51b1c1f8f7 Mon Sep 17 00:00:00 2001 From: wchen342 Date: Thu, 7 May 2026 07:55:46 -0400 Subject: [PATCH 11/20] Call: camera/audio permission related fixes (#4412) * Call: camera/permission related fixes Defer camera initialization until actually needed. Fix missing camera permission which blocks call from initializing correctly. Add dialog when audio permission is not granted. Add redirection dialog when permissions are permanently denied. --- .../securesms/calls/CallActivity.java | 111 ++++++++++++++++-- .../securesms/calls/CallCoordinator.java | 54 ++++++--- .../securesms/calls/CallService.java | 49 ++++---- .../securesms/calls/CallViewModel.java | 41 +++---- .../securesms/calls/MediaStreamManager.java | 99 ++++++++++++---- src/main/res/values/strings.xml | 1 + 6 files changed, 266 insertions(+), 89 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java b/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java index 4ab417ecf..0162cccf5 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java @@ -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 diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java b/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java index 283572f52..45004eda0 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java @@ -97,6 +97,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { private final MutableLiveData outgoingCallPlaced = new MutableLiveData<>(false); private final MutableLiveData answeredElsewhere = new MutableLiveData<>(false); private final MutableLiveData isFrontCamera = new MutableLiveData<>(true); + private final MutableLiveData mediaCaptureReady = new MutableLiveData<>(false); // Audio Routing Support private final MediatorLiveData currentAudioEndpoint = @@ -119,6 +120,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 +230,11 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { mainHandler.removeCallbacks(outgoingRingtoneRunnable); mainHandler.postDelayed(outgoingRingtoneRunnable, 1500); } + + if (pendingMediaCapture) { + pendingMediaCapture = false; + callService.startMediaCapture(); + } } } @@ -336,6 +343,10 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { return isFrontCamera; } + public LiveData getMediaCaptureReady() { + return mediaCaptureReady; + } + // State Update Methods (CallService) public void updateConnectionState(PeerConnection.PeerConnectionState state) { @@ -375,6 +386,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 +403,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 +668,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); } } @@ -1129,6 +1152,7 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { this.pendingOfferSdp = null; this.hasNotifiedBackend = false; this.hasAutoSelectedEarpiece = false; + this.pendingMediaCapture = false; mainHandler.removeCallbacks(outgoingRingtoneRunnable); @@ -1194,6 +1218,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 +1234,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 +1245,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 +1270,9 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { this.preferredStartingEndpoint = getPreferredStartingEndpoint(startsWithVideo); - startAndBindService(); + if (hasMicrophonePermission()) { + startAndBindService(); + } launchCallActivity(); } @@ -1286,6 +1304,14 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { addCallToTelecom(callAttributes, calleeName, calleeIcon); } + 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 { diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallService.java b/src/main/java/org/thoughtcrime/securesms/calls/CallService.java index 5972d470d..b386a8cf0 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallService.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallService.java @@ -126,13 +126,13 @@ public class CallService extends Service implements WebRTCClient.Callbacks { } /** - * Start camera/microphone capture + * Start media capture * *

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) { diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java b/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java index f1750b0e5..07d025263 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java @@ -46,8 +46,8 @@ public class CallViewModel extends AndroidViewModel { private final MediatorLiveData callState; // Observer References for one-time observe - private Observer answerCallObserver; - private Observer startOutgoingCallObserver; + private Observer answerCallObserver; + private Observer startOutgoingCallObserver; private final AtomicBoolean hasCallEnded = new AtomicBoolean(false); @@ -211,25 +211,24 @@ public class CallViewModel extends AndroidViewModel { callCoordinator.startMediaCapture(); // Create one-time observer - LiveData localTrack = callCoordinator.getLocalVideoTrack(); + LiveData mediaReady = callCoordinator.getMediaCaptureReady(); answerCallObserver = - new Observer() { + new Observer() { @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 localTrack = callCoordinator.getLocalVideoTrack(); - VideoTrack currentValue = localTrack.getValue(); + LiveData 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() { + new Observer() { @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; } diff --git a/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java b/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java index 7dd64ba92..7cb136499 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java @@ -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; diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 4466161f8..574cb802f 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -431,6 +431,7 @@ Missed call Already in a call Call answered on another device + Camera permission is required for video calls Microphone permission is required for calls Call with %1$s From 72bd7376ca9d4f1fca6afce36cda50cc97f5fe28 Mon Sep 17 00:00:00 2001 From: wchen342 Date: Thu, 7 May 2026 12:29:27 -0400 Subject: [PATCH 12/20] Make it possible to answer incoming calls from messages (#4415) --- CHANGELOG.md | 1 + .../securesms/ConversationItem.java | 13 ++- .../securesms/calls/CallCoordinator.java | 87 ++++++++++++++----- 3 files changed, 79 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc7cdb3f6..0a74c0d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Better incoming call system integration * Calls are not experimental anymore and don't need to be manually enabled +* Calls can be answered by tapping messages * Channels are no longer experimental and are available by default * Display a permanent notification when doing location streaming and get rid of dangerous "Access Location in Background" permission * Autoplay all voice messages in a chat diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java index 38cc4d13c..da451cd20 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java @@ -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); diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java b/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java index 45004eda0..9b4414ff5 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java @@ -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; @@ -898,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 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); @@ -934,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 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); @@ -1304,6 +1320,35 @@ public class CallCoordinator implements DcEventCenter.DcEventDelegate { addCallToTelecom(callAttributes, calleeName, calleeIcon); } + @Nullable + private Pair 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; From e0459978f7586b3443fbcc02ac0ab80e396ac29a Mon Sep 17 00:00:00 2001 From: "B. Petersen" Date: Thu, 7 May 2026 12:07:47 +0200 Subject: [PATCH 13/20] feat: use consistent colors and contrast --- src/main/res/layout/activity_call.xml | 18 +++++++++--------- src/main/res/values/colors.xml | 11 ++++------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/main/res/layout/activity_call.xml b/src/main/res/layout/activity_call.xml index c337e4edc..0e9f50390 100644 --- a/src/main/res/layout/activity_call.xml +++ b/src/main/res/layout/activity_call.xml @@ -242,8 +242,8 @@ android:layout_height="wrap_content" android:src="@drawable/ic_mic_on" android:contentDescription="Toggle microphone" - app:tint="@color/calls_dark_on_surface" - app:backgroundTint="@color/calls_dark_surface_3" + app:tint="@color/calls_button_foreground" + app:backgroundTint="@color/calls_button_background" app:fabSize="normal" app:shapeAppearance="@style/ShapeAppearance.Material3.Corner.Full" app:borderWidth="0dp" /> @@ -264,8 +264,8 @@ android:layout_height="wrap_content" android:src="@drawable/ic_videocam_on" android:contentDescription="Toggle camera" - app:tint="@color/calls_dark_on_surface" - app:backgroundTint="@color/calls_dark_surface_3" + app:tint="@color/calls_button_foreground" + app:backgroundTint="@color/calls_button_background" app:fabSize="normal" app:shapeAppearance="@style/ShapeAppearance.Material3.Corner.Full" app:borderWidth="0dp" /> @@ -286,7 +286,7 @@ android:layout_height="wrap_content" android:src="@drawable/ic_call_end" android:contentDescription="End call" - app:tint="@android:color/white" + app:tint="@color/calls_button_foreground" app:backgroundTint="@color/calls_red" app:fabSize="normal" app:shapeAppearance="@style/ShapeAppearance.Material3.Corner.Full" @@ -308,8 +308,8 @@ android:layout_height="wrap_content" android:src="@drawable/ic_volume_up" android:contentDescription="Audio output" - app:tint="@color/calls_dark_on_surface" - app:backgroundTint="@color/calls_dark_surface_3" + app:tint="@color/calls_button_foreground" + app:backgroundTint="@color/calls_button_background" app:fabSize="normal" app:shapeAppearance="@style/ShapeAppearance.Material3.Corner.Full" app:borderWidth="0dp" /> @@ -330,8 +330,8 @@ android:layout_height="wrap_content" android:src="@drawable/ic_switch_camera" android:contentDescription="Switch camera" - app:tint="@color/calls_dark_on_surface" - app:backgroundTint="@color/calls_dark_surface_3" + app:tint="@color/calls_button_foreground" + app:backgroundTint="@color/calls_button_background" app:fabSize="normal" app:shapeAppearance="@style/ShapeAppearance.Material3.Corner.Full" app:borderWidth="0dp" /> diff --git a/src/main/res/values/colors.xml b/src/main/res/values/colors.xml index 93cf651ae..e10174150 100644 --- a/src/main/res/values/colors.xml +++ b/src/main/res/values/colors.xml @@ -74,14 +74,11 @@ #eeeeee - #1B1C1F #1B1C1F - #23242A - #272A31 - #2C2F37 #E2E1E5 #BEBFC5 - #B6C5FA - #BA1B1B - #4CAF50 + #FFFFFF + #6B6B6B + #FF0017 + #00AD00 From fccf8f402e87a94d50413989fc35628d18d725f9 Mon Sep 17 00:00:00 2001 From: "B. Petersen" Date: Thu, 7 May 2026 12:33:06 +0200 Subject: [PATCH 14/20] use same theme colors as in camera, editor --- src/main/res/layout/activity_call.xml | 6 +++--- src/main/res/values/colors.xml | 3 --- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/res/layout/activity_call.xml b/src/main/res/layout/activity_call.xml index 0e9f50390..0a16163c6 100644 --- a/src/main/res/layout/activity_call.xml +++ b/src/main/res/layout/activity_call.xml @@ -44,7 +44,7 @@ android:paddingEnd="16dp" android:paddingTop="12dp" android:paddingBottom="12dp" - android:background="@color/calls_dark_surface" + android:background="@android:color/black" app:layout_constraintTop_toTopOf="parent"> @@ -70,7 +70,7 @@ android:id="@+id/status_text" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:textColor="@color/calls_dark_on_surface_variant" + android:textColor="@android:color/white" android:textSize="14sp" tools:text="Connecting..." /> diff --git a/src/main/res/values/colors.xml b/src/main/res/values/colors.xml index e10174150..89434b558 100644 --- a/src/main/res/values/colors.xml +++ b/src/main/res/values/colors.xml @@ -74,9 +74,6 @@ #eeeeee - #1B1C1F - #E2E1E5 - #BEBFC5 #FFFFFF #6B6B6B #FF0017 From 960581e5f24679a61b9c0d35e8b968c24a6235e9 Mon Sep 17 00:00:00 2001 From: "B. Petersen" Date: Thu, 7 May 2026 18:55:41 +0200 Subject: [PATCH 15/20] make call buttons translatable --- src/main/res/layout/activity_call.xml | 18 +++++++++--------- src/main/res/values/strings.xml | 7 +++++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/main/res/layout/activity_call.xml b/src/main/res/layout/activity_call.xml index 0a16163c6..f8e797403 100644 --- a/src/main/res/layout/activity_call.xml +++ b/src/main/res/layout/activity_call.xml @@ -141,7 +141,7 @@ style="?attr/materialButtonOutlinedStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="Audio" + android:text="@string/audio" android:textSize="14sp" android:minWidth="100dp" app:icon="@drawable/ic_call" @@ -153,7 +153,7 @@ style="?attr/materialButtonOutlinedStyle" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="Video" + android:text="@string/video" android:textSize="14sp" android:minWidth="100dp" app:icon="@drawable/ic_videocam_on" @@ -185,7 +185,7 @@ android:layout_height="56dp" android:layout_weight="1" android:layout_marginEnd="8dp" - android:text="Decline" + android:text="@string/end_call" android:textSize="15sp" app:icon="@drawable/ic_call_end" app:iconGravity="textStart" @@ -199,7 +199,7 @@ android:layout_height="56dp" android:layout_weight="1" android:layout_marginStart="8dp" - android:text="Answer" + android:text="@string/answer_call" android:textSize="15sp" app:icon="@drawable/ic_call" app:iconGravity="textStart" @@ -241,7 +241,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/ic_mic_on" - android:contentDescription="Toggle microphone" + android:contentDescription="@string/mute" app:tint="@color/calls_button_foreground" app:backgroundTint="@color/calls_button_background" app:fabSize="normal" @@ -263,7 +263,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/ic_videocam_on" - android:contentDescription="Toggle camera" + android:contentDescription="@string/toggle_camera" app:tint="@color/calls_button_foreground" app:backgroundTint="@color/calls_button_background" app:fabSize="normal" @@ -285,7 +285,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/ic_call_end" - android:contentDescription="End call" + android:contentDescription="@string/end_call2" app:tint="@color/calls_button_foreground" app:backgroundTint="@color/calls_red" app:fabSize="normal" @@ -307,7 +307,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/ic_volume_up" - android:contentDescription="Audio output" + android:contentDescription="@string/switch_speaker" app:tint="@color/calls_button_foreground" app:backgroundTint="@color/calls_button_background" app:fabSize="normal" @@ -329,7 +329,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/ic_switch_camera" - android:contentDescription="Switch camera" + android:contentDescription="@string/switch_camera" app:tint="@color/calls_button_foreground" app:backgroundTint="@color/calls_button_background" app:fabSize="normal" diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 574cb802f..dfef4aa4a 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -206,7 +206,12 @@ Camera Capture + + Switch Speaker + Switch Camera + + Toggle Camera Toggle Full Screen Mode Location Locations @@ -408,6 +413,8 @@ Answer Decline + + End Call Audio call From d373537d6deeac1218493ce9615a7724e9e7e4c3 Mon Sep 17 00:00:00 2001 From: "B. Petersen" Date: Thu, 7 May 2026 19:10:02 +0200 Subject: [PATCH 16/20] avatars can be ignored by talkback, the name is in the title --- src/main/res/layout/activity_call.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/res/layout/activity_call.xml b/src/main/res/layout/activity_call.xml index f8e797403..8b10e8cc4 100644 --- a/src/main/res/layout/activity_call.xml +++ b/src/main/res/layout/activity_call.xml @@ -24,7 +24,7 @@ android:layout_width="0dp" android:layout_height="0dp" android:scaleType="centerCrop" - android:contentDescription="Contact photo" + android:importantForAccessibility="no" app:shapeAppearanceOverlay="@style/ShapeAppearance.Material3.Corner.Full" app:layout_constraintWidth_percent="0.4" app:layout_constraintDimensionRatio="1:1" @@ -115,7 +115,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:scaleType="centerCrop" - android:contentDescription="Caller photo" + android:importantForAccessibility="no" tools:src="@drawable/ic_person" /> From e6350aaec205f7a32e0b4d17f35f142371656b3a Mon Sep 17 00:00:00 2001 From: adbenitez Date: Fri, 8 May 2026 16:18:36 +0200 Subject: [PATCH 17/20] move requestPinShortcut to background --- .../securesms/BaseConversationListFragment.java | 4 +++- .../thoughtcrime/securesms/WebxdcActivity.java | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java b/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java index 05b18c1c2..0ec891f2f 100644 --- a/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java @@ -423,9 +423,11 @@ public abstract class BaseConversationListFragment extends Fragment implements A .setIcon(IconCompat.createWithAdaptiveBitmap(avatar)) .setIntent(intent) .build(); + boolean success = + ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfoCompat, null); Util.runOnMain( () -> { - if (!ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfoCompat, null)) { + if (!success) { Toast.makeText( activity, "ErrAddToHomescreen: requestPinShortcut() failed", diff --git a/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java b/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java index d6f842bed..652799457 100644 --- a/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java @@ -545,11 +545,18 @@ 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( + () -> { + if (!ShortcutManagerCompat.requestPinShortcut(context, shortcutInfoCompat, null)) { + Util.runOnMain( + () -> + Toast.makeText( + context, + "ErrAddToHomescreen: requestPinShortcut() failed", + Toast.LENGTH_LONG) + .show()); + } + }); } catch (Exception e) { Toast.makeText(context, "ErrAddToHomescreen: " + e, Toast.LENGTH_LONG).show(); } From 975ad2e14920ea5f142b49c51024bf7115f89f24 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Fri, 8 May 2026 17:01:47 +0200 Subject: [PATCH 18/20] wrap with try-catch --- .../securesms/BaseConversationListFragment.java | 10 ++++++++-- .../org/thoughtcrime/securesms/WebxdcActivity.java | 9 ++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java b/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java index 0ec891f2f..30d58152f 100644 --- a/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java @@ -423,8 +423,14 @@ public abstract class BaseConversationListFragment extends Fragment implements A .setIcon(IconCompat.createWithAdaptiveBitmap(avatar)) .setIntent(intent) .build(); - boolean success = - ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfoCompat, null); + + boolean success; + try { + success = ShortcutManagerCompat.requestPinShortcut(activity, shortcutInfoCompat, null); + } catch (Exception e) { + Log.e(TAG, "ErrAddToHomescreen: requestPinShortcut() failed", e); + success = false; + } Util.runOnMain( () -> { if (!success) { diff --git a/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java b/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java index 652799457..0a9391d73 100644 --- a/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java @@ -547,7 +547,14 @@ public class WebxdcActivity extends WebViewActivity implements DcEventCenter.DcE Toast.makeText(context, R.string.one_moment, Toast.LENGTH_SHORT).show(); Util.runOnAnyBackgroundThread( () -> { - if (!ShortcutManagerCompat.requestPinShortcut(context, shortcutInfoCompat, null)) { + boolean success; + try { + sucess = ShortcutManagerCompat.requestPinShortcut(context, shortcutInfoCompat, null); + } catch (Exception e) { + Log.e(TAG, "ErrAddToHomescreen: requestPinShortcut() failed", e); + success = false; + } + if (!success) { Util.runOnMain( () -> Toast.makeText( From 9ebd8e4a37ee5c7802347e2884fd1208baa6f7a0 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Fri, 8 May 2026 17:09:01 +0200 Subject: [PATCH 19/20] fix typos and other bugs --- .../thoughtcrime/securesms/BaseConversationListFragment.java | 3 ++- src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java b/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java index 30d58152f..887343520 100644 --- a/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java @@ -431,9 +431,10 @@ public abstract class BaseConversationListFragment extends Fragment implements A Log.e(TAG, "ErrAddToHomescreen: requestPinShortcut() failed", e); success = false; } + boolean finalSuccess = success; Util.runOnMain( () -> { - if (!success) { + if (!finalSuccess) { Toast.makeText( activity, "ErrAddToHomescreen: requestPinShortcut() failed", diff --git a/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java b/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java index 0a9391d73..959e2550a 100644 --- a/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java @@ -549,7 +549,7 @@ public class WebxdcActivity extends WebViewActivity implements DcEventCenter.DcE () -> { boolean success; try { - sucess = ShortcutManagerCompat.requestPinShortcut(context, shortcutInfoCompat, null); + success = ShortcutManagerCompat.requestPinShortcut(context, shortcutInfoCompat, null); } catch (Exception e) { Log.e(TAG, "ErrAddToHomescreen: requestPinShortcut() failed", e); success = false; From 43654fdadb5d7ecb0c5f1f53fd6137e594da9ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?bi=C3=B6rn?= Date: Tue, 12 May 2026 15:00:42 +0200 Subject: [PATCH 20/20] feat: tweak forward confirmation (#4427) * cleanup: remove dead code the special handling of single-chats was to show the email address in the past, it was introduced at https://github.com/deltachat/deltachat-android/pull/1049 . as we do no longer show the email address, this superfluous code now. * feat: show impact of forwarding in confirmation dialog * feat: show 'Forward' verb on button * apply spotless --- .../securesms/ConversationActivity.java | 18 +++++++----------- .../thoughtcrime/securesms/util/ShareUtil.java | 6 +----- src/main/res/values/strings.xml | 6 ++++++ 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index e3b902dc3..384add495 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -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()) diff --git a/src/main/java/org/thoughtcrime/securesms/util/ShareUtil.java b/src/main/java/org/thoughtcrime/securesms/util/ShareUtil.java index 68b01881f..b2af4fd42 100644 --- a/src/main/java/org/thoughtcrime/securesms/util/ShareUtil.java +++ b/src/main/java/org/thoughtcrime/securesms/util/ShareUtil.java @@ -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); - } } diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index dfef4aa4a..c91207711 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -458,6 +458,12 @@ Delete %d message? Delete %d messages? + + + Forward message to %2$s? + Forward %1$d messages to %2$s? + + Forward messages to %1$s? Forward messages to %1$d chats? Exporting attachments will allow other apps on your device to access them.\n\nContinue?