diff --git a/CHANGELOG.md b/CHANGELOG.md index 00b7fbfce..0a74c0d19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,19 @@ * 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 * 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 * 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 diff --git a/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java b/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java index 05b18c1c2..887343520 100644 --- a/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/BaseConversationListFragment.java @@ -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", 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/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java index 4ead84239..da451cd20 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, @@ -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/WebxdcActivity.java b/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java index d6f842bed..959e2550a 100644 --- a/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/WebxdcActivity.java @@ -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(); } 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/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..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; @@ -97,6 +99,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 +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 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 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 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 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 { 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/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java index e805e7ce2..72ada35df 100644 --- a/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java +++ b/src/main/java/org/thoughtcrime/securesms/geolocation/LocationStreamingService.java @@ -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) diff --git a/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java b/src/main/java/org/thoughtcrime/securesms/preferences/NotificationsPreferenceFragment.java index 68d0f599f..17dfd06c2 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/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/layout/activity_call.xml b/src/main/res/layout/activity_call.xml index c337e4edc..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" @@ -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..." /> @@ -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" /> @@ -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,9 +241,9 @@ android:layout_width="wrap_content" 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" + android:contentDescription="@string/mute" + 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" /> @@ -263,9 +263,9 @@ android:layout_width="wrap_content" 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" + android:contentDescription="@string/toggle_camera" + 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" /> @@ -285,8 +285,8 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:src="@drawable/ic_call_end" - android:contentDescription="End call" - app:tint="@android:color/white" + android:contentDescription="@string/end_call2" + app:tint="@color/calls_button_foreground" app:backgroundTint="@color/calls_red" app:fabSize="normal" app:shapeAppearance="@style/ShapeAppearance.Material3.Corner.Full" @@ -307,9 +307,9 @@ android:layout_width="wrap_content" 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" + android:contentDescription="@string/switch_speaker" + 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" /> @@ -329,9 +329,9 @@ android:layout_width="wrap_content" 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" + android:contentDescription="@string/switch_camera" + 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/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 @@

+ + diff --git a/src/main/res/values/colors.xml b/src/main/res/values/colors.xml index 93cf651ae..89434b558 100644 --- a/src/main/res/values/colors.xml +++ b/src/main/res/values/colors.xml @@ -74,14 +74,8 @@ #eeeeee - #1B1C1F - #1B1C1F - #23242A - #272A31 - #2C2F37 - #E2E1E5 - #BEBFC5 - #B6C5FA - #BA1B1B - #4CAF50 + #FFFFFF + #6B6B6B + #FF0017 + #00AD00 diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 892d6e154..c91207711 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 @@ -204,7 +206,12 @@ Camera Capture + + Switch Speaker + Switch Camera + + Toggle Camera Toggle Full Screen Mode Location Locations @@ -406,6 +413,8 @@ Answer Decline + + End Call Audio call @@ -429,6 +438,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 @@ -448,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? @@ -767,11 +783,12 @@ Calls - Show call screen on incoming calls + Show call screen for accepted contacts Show 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 @@ -834,7 +851,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 @@ -1076,8 +1093,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. @@ -1173,6 +1188,8 @@ Message actions Wallpaper preview Disappearing messages activated + + Open %1$s Stop sharing location @@ -1227,9 +1244,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 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" /> + +