Compare commits

...

63 Commits

Author SHA1 Message Date
Greyson Parrelli dfd2f7baf9 Bump version to 8.7.2 2026-04-09 22:44:57 -04:00
Greyson Parrelli 5de17a971d Update translations and other static files. 2026-04-09 22:44:34 -04:00
Greyson Parrelli 001896d244 Fix image transition animation. 2026-04-09 22:02:46 -04:00
Michelle Tang 1844b128e1 Use server timestamp for admin delete. 2026-04-09 17:17:55 -04:00
Michelle Tang 08623cc0c4 Use proper sender for early messages. 2026-04-09 15:44:35 -04:00
Greyson Parrelli f93a948169 Fix PIN creation loop during registration. 2026-04-09 13:48:46 -04:00
Cody Henthorne 76476191be Show better error ux for group calls you cannot start. 2026-04-09 10:27:44 -04:00
Greyson Parrelli d00bb28ee4 Bump version to 8.7.1 2026-04-08 22:10:47 -04:00
Greyson Parrelli 453e5bede7 Fix bad bubble tints for chats with wallpapers. 2026-04-08 22:04:17 -04:00
Michelle Tang c7c108bd77 Fix missing gallery photos. 2026-04-08 19:40:13 -04:00
Greyson Parrelli fb81574d35 Bump version to 8.7.0 2026-04-08 16:39:21 -04:00
Greyson Parrelli e6d3de091c Update translations and other static files. 2026-04-08 16:39:21 -04:00
Greyson Parrelli 99b8a6020d Fix flaky registration tests. 2026-04-08 16:39:21 -04:00
Greyson Parrelli 88b21b6113 Improve validator testing. 2026-04-08 16:39:21 -04:00
Greyson Parrelli 256ee9b1aa Delete unused apns database. 2026-04-08 16:39:21 -04:00
Alex Hart e2feaaf74c Add initial working E2E flow for MediaSendV3. 2026-04-08 16:39:21 -04:00
jeffrey-signal 17def87c17 Fix compose preview rendering when using Emojifier. 2026-04-08 16:39:20 -04:00
Alex Hart d90e9919ae Adaptive welcome screen with compact, medium, and large layouts. 2026-04-08 16:39:20 -04:00
Jim Gustafson 38baf17938 Update to RingRTC v2.67.2 2026-04-08 16:39:20 -04:00
scueZ 3f7707985f Skip confusing delete dialog body text in Note to Self.
Resolves #14708
2026-04-08 16:39:20 -04:00
jeffrey-signal a61072b249 Member label performance optimizations. 2026-04-08 16:39:20 -04:00
jeffrey-signal 80ff64ddd3 Prevent unregistered accounts from showing in group call participants. 2026-04-08 16:39:20 -04:00
Cody Henthorne 95c0467bda Show unanswered outgoing calls as unanswered. 2026-04-08 16:39:20 -04:00
Greyson Parrelli ff88d259fd Use long for key id. 2026-04-08 16:39:20 -04:00
Alex Hart 6e747019d4 Fix NPE in toPendingOneTimeDonation when waitForAuth is null.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-08 16:29:15 -04:00
Greyson Parrelli 9e7a40a63d Extend proper base activity. 2026-04-08 16:29:14 -04:00
Greyson Parrelli 38eed43046 Add long-press context menu in all media screen. 2026-04-08 15:50:43 -04:00
Greyson Parrelli 4c76cb682e Give a media/no-media choice in labs plaintext export. 2026-04-08 15:50:43 -04:00
Michelle Tang c47adb7482 Update padding sizes of update items. 2026-04-08 15:50:43 -04:00
Alex Hart 3c2ccef9a8 Fix upgrade card text color not adapting to dark mode.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-08 15:50:43 -04:00
jeffrey-signal fb0c4757f2 Fix media count indicator button colors so they match the chat color. 2026-04-08 15:50:43 -04:00
jeffrey-signal b8b9a632b5 Always prefetch wallpaper before opening a conversation. 2026-04-08 15:50:43 -04:00
Greyson Parrelli 9b4a13a491 Potential fix to configuration cache issues with translations. 2026-04-08 15:50:43 -04:00
Cody Henthorne 1cdd49721d Add logging around rotate storage id failures during storage sync. 2026-04-08 15:50:43 -04:00
Cody Henthorne 8b895738c0 Update telecom to 1.1.0-alpha04 2026-04-07 10:05:42 -04:00
Cody Henthorne 6ab3cd3390 Don't show terminated groups after storage service restore. 2026-04-07 09:39:06 -04:00
Alex Hart 11c8a726ec Increment localPlaintextExport flag to lock version. 2026-04-06 16:47:01 -04:00
Alex Hart 264447a6d9 Add breakpoint helper and expand device previews.
Co-authored-by: jeffrey-signal <jeffrey@signal.org>
2026-04-06 16:47:01 -04:00
Michelle Tang a7bb2831f8 Fix possible misuse of mp4 sanitizer. 2026-04-06 16:47:01 -04:00
Greyson Parrelli e05586a1c9 Convert RegistrationNetworkResult to RequestResult. 2026-04-06 16:47:01 -04:00
Greyson Parrelli 0e8dedf4d0 App ability to regV5 in the main app, behind compile flag. 2026-04-06 16:47:01 -04:00
Michelle Tang 0e11a1fe3e Add logs for voice note proximity. 2026-04-06 16:47:01 -04:00
adel-signal f1ebd2dc81 Add CallingAssetsDownloadJob to app startup to init calling assets
Co-authored-by: Greyson Parrelli <greyson@signal.org>
2026-04-06 16:47:01 -04:00
Michelle Tang 8ea90c8a43 Cancel checking for messages on foreground. 2026-04-06 16:46:05 -04:00
Michelle Tang 6456dcf657 Fix potential edit message race condition. 2026-04-06 16:46:05 -04:00
Greyson Parrelli bb151c91e9 Add basic infra for regV5 local restore. 2026-04-06 16:46:05 -04:00
Greyson Parrelli ce6f39ae68 Update to the new attachment upload form libsignal method. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 58e8ea08c2 Bump to libsignal v0.91.0 2026-04-06 16:46:05 -04:00
Michelle Tang 4dd74d9ab4 Fix collapsed tests. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 3ef3a516b3 Prevent repeated storage-full notifications during backup.
When remote backup storage is full, hundreds of CopyAttachmentToArchiveJob
instances each independently call markOutOfRemoteStorageSpaceError(), which
re-posts the notification every time. Even though the notification ID is the
same, each call re-alerts the user with sound and vibration.

Guard markOutOfRemoteStorageSpaceError() to only post the notification once
by checking the flag before proceeding, and move the flag-set before the
notification post to prevent races. Also add an early exit in
CopyAttachmentToArchiveJob to skip the network quota check when already
marked as out of storage space.
2026-04-06 16:46:05 -04:00
Greyson Parrelli 518a81c7fa Do not start a call while one is in progress. 2026-04-06 16:46:05 -04:00
Michelle Tang f81325e7ca Pause voice notes when joining calls. 2026-04-06 16:46:05 -04:00
Greyson Parrelli cc847cb229 Fix potential glide lifecycle issue with transition animation. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 7320a0ef46 Guard against potential crash when reacting to a message. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 7c45686440 Fix potential missing ACI crash in verify screen. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 8b5b83e974 Remove unnecessary transaction in LocalMetricsDatabase.
There was a native crash associated with it, unclear the cause, but
maybe this will help.
2026-04-06 16:46:05 -04:00
Michelle Tang a4a3861398 Disable proximity sensor when using headsets for voice notes. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 01bdaaea84 Improve ANR stack trace perf. 2026-04-06 16:46:05 -04:00
Greyson Parrelli 1f02fba696 Include captcha info in support email template. 2026-04-06 16:46:04 -04:00
Greyson Parrelli aeb9054a63 Bump version to 8.6.2 2026-04-06 16:39:51 -04:00
Greyson Parrelli bb33945a93 Update translations and other static files. 2026-04-06 16:35:11 -04:00
Greyson Parrelli 3d2ceef47f Don't let the date validator starve the chat search. 2026-04-06 16:15:56 -04:00
Michelle Tang 892e6bd853 Fix OOM in collapse backfill job. 2026-04-06 12:15:35 -04:00
322 changed files with 15679 additions and 4160 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1674
val canonicalVersionName = "8.6.1"
val canonicalVersionCode = 1678
val canonicalVersionName = "8.7.2"
val currentHotfixVersion = 0
val maxHotfixVersions = 100
@@ -75,8 +75,8 @@ object MockProvider {
val device = PreKeyResponseItem().apply {
this.deviceId = deviceId
registrationId = KeyHelper.generateRegistrationId(false)
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
preKey = PreKeyEntity(oneTimePreKey.id, oneTimePreKey.keyPair.publicKey)
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
preKey = PreKeyEntity(oneTimePreKey.id.toLong(), oneTimePreKey.keyPair.publicKey)
}
return PreKeyResponse().apply {
+1 -1
View File
@@ -482,7 +482,7 @@
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
<activity
android:name="org.signal.mediasend.MediaSendActivity"
android:name=".mediasend.v3.MediaSendV3Activity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
android:exported="false"
android:launchMode="singleTop"
Binary file not shown.
@@ -62,6 +62,7 @@ import org.thoughtcrime.securesms.jobs.AccountConsistencyWorkerJob;
import org.thoughtcrime.securesms.jobs.BackupRefreshJob;
import org.thoughtcrime.securesms.jobs.BackupSubscriptionCheckJob;
import org.thoughtcrime.securesms.jobs.BuildExpirationConfirmationJob;
import org.thoughtcrime.securesms.jobs.CallingAssetsDownloadJob;
import org.thoughtcrime.securesms.jobs.CheckKeyTransparencyJob;
import org.thoughtcrime.securesms.jobs.CheckServiceReachabilityJob;
import org.thoughtcrime.securesms.jobs.DownloadLatestEmojiDataJob;
@@ -102,12 +103,14 @@ import org.thoughtcrime.securesms.service.MessageBackupListener;
import org.thoughtcrime.securesms.service.RotateSenderCertificateListener;
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener;
import org.thoughtcrime.securesms.service.webrtc.ActiveCallManager;
import org.thoughtcrime.securesms.service.webrtc.CallingAssets;
import org.thoughtcrime.securesms.service.webrtc.AndroidTelecomUtil;
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
@@ -226,6 +229,7 @@ public class ApplicationContext extends Application implements AppForegroundObse
.addPostRender(RetrieveRemoteAnnouncementsJob::enqueue)
.addPostRender(AndroidTelecomUtil::registerPhoneAccount)
.addPostRender(() -> AppDependencies.getJobManager().add(new FontDownloaderJob()))
.addPostRender(() -> AppDependencies.getJobManager().add(new CallingAssetsDownloadJob()))
.addPostRender(CheckServiceReachabilityJob::enqueueIfNecessary)
.addPostRender(GroupV2UpdateSelfProfileKeyJob::enqueueForGroupsIfNecessary)
.addPostRender(StoryOnboardingDownloadJob.Companion::enqueueIfNeeded)
@@ -400,6 +404,20 @@ public class ApplicationContext extends Application implements AppForegroundObse
AppDependencies.init(this, new ApplicationDependencyProvider(this));
}
AppForegroundObserver.begin();
if (Environment.USE_NEW_REGISTRATION) {
initializeRegistrationDependencies();
}
}
private void initializeRegistrationDependencies() {
org.signal.registration.RegistrationDependencies.Companion.provide(
new org.signal.registration.RegistrationDependencies(
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
null
)
);
}
private void initializeFirstEverAppLaunch() {
@@ -163,6 +163,7 @@ import org.thoughtcrime.securesms.main.rememberFocusRequester
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.mediaSendLauncher
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
import org.thoughtcrime.securesms.megaphone.Megaphones
@@ -271,7 +272,7 @@ class MainActivity :
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
private lateinit var mediaActivityLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
private lateinit var mediaSendLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
@@ -298,7 +299,7 @@ class MainActivity :
super.onCreate(savedInstanceState, ready)
navigator = MainNavigator(this, mainNavigationViewModel)
mediaActivityLauncher = registerForActivityResult(MediaSendActivityContract()) { }
mediaSendLauncher = mediaSendLauncher()
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
override fun onForeground() {
@@ -1124,7 +1125,7 @@ class MainActivity :
if (isForQuickRestore) {
startActivity(MediaSelectionActivity.cameraForQuickRestore(context = this@MainActivity))
} else if (SignalStore.internal.useNewMediaActivity) {
mediaActivityLauncher.launch(
mediaSendLauncher.launch(
MediaSendActivityContract.Args(
isCameraFirst = false,
isStory = destination == MainNavigationListLocation.STORIES
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.restore.RestoreActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
@@ -134,8 +135,12 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
Intent intent = getIntentForState(applicationState);
if (intent != null) {
Log.d(TAG, "routeApplicationState(), intent: " + intent.getComponent());
startActivity(intent);
finish();
if (applicationState == STATE_WELCOME_PUSH_SCREEN && Environment.USE_NEW_REGISTRATION) {
startActivity(intent);
} else {
startActivity(intent);
finish();
}
}
}
@@ -173,7 +178,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
return STATE_ENTER_SIGNAL_PIN;
} else if (userMustSetProfileName()) {
return STATE_CREATE_PROFILE_NAME;
} else if (userMustCreateSignalPin()) {
} else if (userMustCreateSignalPin() && getClass() != CreateSvrPinActivity.class) {
return STATE_CREATE_SIGNAL_PIN;
} else if (EventBus.getDefault().getStickyEvent(TransferStatus.class) != null && getClass() != OldDeviceTransferActivity.class) {
return STATE_TRANSFER_ONGOING;
@@ -221,7 +226,11 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getPushRegistrationIntent() {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
if (Environment.USE_NEW_REGISTRATION) {
return org.signal.registration.RegistrationActivity.createIntent(this);
} else {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
}
private Intent getEnterSignalPinIntent() {
@@ -424,6 +424,12 @@ object BackupRepository {
}
fun markOutOfRemoteStorageSpaceError() {
if (SignalStore.backup.isNotEnoughRemoteStorageSpace) {
return
}
SignalStore.backup.markNotEnoughRemoteStorageSpace()
val context = AppDependencies.application
val pendingIntent = PendingIntent.getActivity(context, 0, AppSettingsActivity.remoteBackups(context), cancelCurrent())
@@ -436,8 +442,6 @@ object BackupRepository {
.build()
ServiceUtil.getNotificationManager(context).notify(NotificationIds.OUT_OF_REMOTE_STORAGE, notification)
SignalStore.backup.markNotEnoughRemoteStorageSpace()
}
fun clearOutOfRemoteStorageSpaceError() {
@@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil
import java.util.concurrent.Executor
import kotlin.math.max
import kotlin.math.min
@@ -67,7 +68,7 @@ class CallEventCache(
val output = mutableListOf<CallLogRow.Call>()
val groupCallStateMap = mutableMapOf<Long, CallLogRow.GroupCallState>()
val canUserBeginCallMap = mutableMapOf<Long, Boolean>()
val canUserBeginCallMap = mutableMapOf<Long, CallLogRow.CanStartCall>()
val callLinksSeen = hashSetOf<Long>()
while (recordIterator.hasNext()) {
@@ -85,7 +86,7 @@ class CallEventCache(
private fun ListIterator<CacheRecord>.readNextCallLog(
filterState: FilterState,
groupCallStateMap: MutableMap<Long, CallLogRow.GroupCallState>,
canUserBeginCallMap: MutableMap<Long, Boolean>,
canUserBeginCallMap: MutableMap<Long, CallLogRow.CanStartCall>,
callLinksSeen: MutableSet<Long>
): CallLogRow.Call? {
val parent = next()
@@ -143,14 +144,16 @@ class CallEventCache(
return (child.timestamp - parent.timestamp) <= 4.hours.inWholeMilliseconds
}
private fun canUserBeginCall(peer: Recipient, decryptedGroup: ByteArray?): Boolean {
return if (peer.isGroup && decryptedGroup != null) {
private fun canUserBeginCall(peer: Recipient, decryptedGroup: ByteArray?): CallLogRow.CanStartCall {
if (peer.isGroup && decryptedGroup != null) {
val proto = DecryptedGroup.ADAPTER.decode(decryptedGroup)
return proto.isAnnouncementGroup != EnabledState.ENABLED ||
proto.members.firstOrNull() { it.aciBytes == SignalStore.account.aci?.toByteString() }?.role == Member.Role.ADMINISTRATOR
} else {
true
when {
proto.terminated -> return CallLogRow.CanStartCall.GROUP_TERMINATED
DecryptedGroupUtil.findMemberByAci(proto.members, SignalStore.account.requireAci()).isEmpty -> return CallLogRow.CanStartCall.NOT_A_MEMBER
proto.isAnnouncementGroup == EnabledState.ENABLED && proto.members.firstOrNull { it.aciBytes == SignalStore.account.aci?.toByteString() }?.role != Member.Role.ADMINISTRATOR -> return CallLogRow.CanStartCall.ADMIN_ONLY
}
}
return CallLogRow.CanStartCall.ALLOWED
}
private fun getGroupCallState(body: String?): CallLogRow.GroupCallState {
@@ -167,7 +170,7 @@ class CallEventCache(
children: Set<Long>,
filterState: FilterState,
groupCallStateCache: MutableMap<Long, CallLogRow.GroupCallState>,
canUserBeginCallMap: MutableMap<Long, Boolean>
canUserBeginCallMap: MutableMap<Long, CallLogRow.CanStartCall>
): CallLogRow.Call {
val peer = Recipient.resolved(RecipientId.from(parent.peer))
return CallLogRow.Call(
@@ -195,10 +198,10 @@ class CallEventCache(
searchQuery = filterState.query,
callLinkPeekInfo = AppDependencies.signalCallManager.peekInfoSnapshot[peer.id],
canUserBeginCall = if (peer.isGroup) {
if (peer.isActiveGroup) {
canUserBeginCallMap.getOrPut(parent.peer) { canUserBeginCall(peer, parent.decryptedGroupBytes) }
} else false
} else true
canUserBeginCallMap.getOrPut(parent.peer) { canUserBeginCall(peer, parent.decryptedGroupBytes) }
} else {
CallLogRow.CanStartCall.ALLOWED
}
)
}
@@ -223,7 +223,7 @@ class CallLogAdapter(
binding: CallLogAdapterItemBinding,
private val onCallLinkClicked: (CallLogRow.CallLink) -> Unit,
private val onCallLinkLongClicked: (View, CallLogRow.CallLink) -> Boolean,
private val onStartVideoCallClicked: (Recipient, Boolean) -> Unit
private val onStartVideoCallClicked: (Recipient, CallLogRow.CanStartCall) -> Unit
) : BindingViewHolder<CallLinkModel, CallLogAdapterItemBinding>(binding) {
override fun bind(model: CallLinkModel) {
if (payload.size == 1 && payload.contains(PAYLOAD_TIMESTAMP)) {
@@ -280,7 +280,7 @@ class CallLogAdapter(
}
)
binding.groupCallButton.setOnClickListener {
onStartVideoCallClicked(model.callLink.recipient, true)
onStartVideoCallClicked(model.callLink.recipient, CallLogRow.CanStartCall.ALLOWED)
}
binding.callType.visible = false
binding.groupCallButton.visible = true
@@ -288,7 +288,7 @@ class CallLogAdapter(
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
binding.callType.setOnClickListener {
onStartVideoCallClicked(model.callLink.recipient, true)
onStartVideoCallClicked(model.callLink.recipient, CallLogRow.CanStartCall.ALLOWED)
}
binding.callType.visible = true
binding.groupCallButton.visible = false
@@ -301,7 +301,7 @@ class CallLogAdapter(
private val onCallClicked: (CallLogRow.Call) -> Unit,
private val onCallLongClicked: (View, CallLogRow.Call) -> Boolean,
private val onStartAudioCallClicked: (Recipient) -> Unit,
private val onStartVideoCallClicked: (Recipient, Boolean) -> Unit
private val onStartVideoCallClicked: (Recipient, CallLogRow.CanStartCall) -> Unit
) : BindingViewHolder<CallModel, CallLogAdapterItemBinding>(binding) {
override fun bind(model: CallModel) {
itemView.setOnClickListener {
@@ -401,7 +401,7 @@ class CallLogAdapter(
CallTable.Type.VIDEO_CALL -> {
binding.callType.setImageResource(R.drawable.symbol_video_24)
binding.callType.contentDescription = context.getString(R.string.CallLogAdapter__start_a_video_call)
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer, true) }
binding.callType.setOnClickListener { onStartVideoCallClicked(model.call.peer, CallLogRow.CanStartCall.ALLOWED) }
binding.callType.visible = true
binding.groupCallButton.visible = false
}
@@ -574,6 +574,6 @@ class CallLogAdapter(
/**
* Invoked when user presses the video icon
*/
fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean)
fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: CallLogRow.CanStartCall)
}
}
@@ -364,18 +364,21 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
}
override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean) {
if (canUserBeginCall) {
CommunicationActions.startVideoCall(this, recipient) {
mainNavigationViewModel.snackbarRegistry.emit(
SnackbarState(
getString(R.string.CommunicationActions__you_are_already_in_a_call),
hostKey = MainSnackbarHostKey.MainChrome
override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: CallLogRow.CanStartCall) {
when (canUserBeginCall) {
CallLogRow.CanStartCall.ALLOWED -> {
CommunicationActions.startVideoCall(this, recipient) {
mainNavigationViewModel.snackbarRegistry.emit(
SnackbarState(
getString(R.string.CommunicationActions__you_are_already_in_a_call),
hostKey = MainSnackbarHostKey.MainChrome
)
)
)
}
}
} else {
ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
CallLogRow.CanStartCall.GROUP_TERMINATED -> ConversationDialogs.displayCannotStartGroupCallDueToGroupEndedDialog(requireContext())
CallLogRow.CanStartCall.NOT_A_MEMBER -> ConversationDialogs.displayCannotStartGroupCallDueToNoLongerAMemberDialog(requireContext())
CallLogRow.CanStartCall.ADMIN_ONLY -> ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext())
}
}
@@ -41,7 +41,7 @@ sealed class CallLogRow {
val children: Set<Long>,
val searchQuery: String?,
val callLinkPeekInfo: CallLinkPeekInfo?,
val canUserBeginCall: Boolean,
val canUserBeginCall: CanStartCall,
override val id: Id = Id.Call(children)
) : CallLogRow()
@@ -111,4 +111,11 @@ sealed class CallLogRow {
}
}
}
enum class CanStartCall {
ALLOWED,
ADMIN_ONLY,
NOT_A_MEMBER,
GROUP_TERMINATED
}
}
@@ -32,14 +32,14 @@ import org.thoughtcrime.securesms.components.emoji.parsing.EmojiParser
import org.thoughtcrime.securesms.keyvalue.SignalStore
/**
* Applies Signal or System emoji to the given content based off user settings.
* Applies Signal or System emoji to the given content based on user settings.
*
* Text is transformed and passed to content as an annotated string and inline content map.
*/
@Composable
fun Emojifier(
text: String,
useSystemEmoji: Boolean = !LocalInspectionMode.current && SignalStore.settings.isPreferSystemEmoji,
useSystemEmoji: Boolean = LocalInspectionMode.current || SignalStore.settings.isPreferSystemEmoji,
content: @Composable (AnnotatedString, Map<String, InlineTextContent>) -> Unit = { annotatedText, inlineContent ->
Text(
text = annotatedText,
@@ -335,7 +335,7 @@ class ChangeNumberRepository(
} else {
PreKeyUtil.generateSignedPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey)
}
devicePniSignedPreKeys[deviceId] = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
devicePniSignedPreKeys[deviceId] = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
// Last-resort kyber prekeys
val lastResortKyberPreKeyRecord: KyberPreKeyRecord = if (deviceId == primaryDeviceId) {
@@ -343,7 +343,7 @@ class ChangeNumberRepository(
} else {
PreKeyUtil.generateLastResortKyberPreKey(SecureRandom().nextInt(Medium.MAX_VALUE), pniIdentity.privateKey)
}
devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id, lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature)
devicePniLastResortKyberPreKeys[deviceId] = KyberPreKeyEntity(lastResortKyberPreKeyRecord.id.toLong(), lastResortKyberPreKeyRecord.keyPair.publicKey, lastResortKyberPreKeyRecord.signature)
// Registration Ids
var pniRegistrationId = -1
@@ -383,8 +383,8 @@ class ChangeNumberRepository(
previousPni = SignalStore.account.pni!!.toByteString(),
pniIdentityKeyPair = pniIdentity.serialize().toByteString(),
pniRegistrationId = pniRegistrationIds[primaryDeviceId]!!,
pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId,
pniLastResortKyberPreKeyId = devicePniLastResortKyberPreKeys[primaryDeviceId]!!.keyId,
pniSignedPreKeyId = devicePniSignedPreKeys[primaryDeviceId]!!.keyId.toInt(),
pniLastResortKyberPreKeyId = devicePniLastResortKyberPreKeys[primaryDeviceId]!!.keyId.toInt(),
previousE164 = SignalStore.account.requireE164(),
newE164 = newE164
)
@@ -235,6 +235,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
title = DSLSettingsText.from("Collapse chat updates"),
summary = DSLSettingsText.from("Collapses certain consecutive chat updates - cannot be undone."),
onClick = {
SignalStore.misc.completedCollapsedEventsMigration = false
AppDependencies.jobManager.add(BackfillCollapsedMessageJob())
}
)
@@ -664,7 +664,7 @@ object InAppPaymentsRepository {
timestamp = insertedAt.inWholeMilliseconds,
error = null,
pendingVerification = true,
checkedVerification = data.waitForAuth!!.checkedVerification
checkedVerification = data.waitForAuth?.checkedVerification ?: false
)
}
@@ -66,8 +66,8 @@ object CallPreference {
MessageTypes.MISSED_VIDEO_CALL_TYPE -> getMissedCallString(true, call.event)
MessageTypes.INCOMING_AUDIO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) getMissedCallString(false, call.event) else R.string.MessageRecord_incoming_voice_call
MessageTypes.INCOMING_VIDEO_CALL_TYPE -> if (call.isDisplayedAsMissedCallInUi) getMissedCallString(true, call.event) else R.string.MessageRecord_incoming_video_call
MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> R.string.MessageRecord_outgoing_voice_call
MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> R.string.MessageRecord_outgoing_video_call
MessageTypes.OUTGOING_AUDIO_CALL_TYPE -> if (call.event == CallTable.Event.NOT_ACCEPTED) R.string.MessageRecord_unanswered_voice_call else R.string.MessageRecord_outgoing_voice_call
MessageTypes.OUTGOING_VIDEO_CALL_TYPE -> if (call.event == CallTable.Event.NOT_ACCEPTED) R.string.MessageRecord_unanswered_video_call else R.string.MessageRecord_outgoing_video_call
MessageTypes.GROUP_CALL_TYPE -> when {
call.isDisplayedAsMissedCallInUi -> if (call.event == CallTable.Event.MISSED_NOTIFICATION_PROFILE) R.string.CallPreference__missed_group_call_notification_profile else R.string.CallPreference__missed_group_call
call.event == CallTable.Event.GENERIC_GROUP_CALL || call.event == CallTable.Event.JOINED -> R.string.CallPreference__group_call
@@ -14,6 +14,7 @@ import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.audio.AudioSink
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.video.exo.SignalMediaSourceFactory
/**
@@ -35,6 +36,10 @@ class VoiceNotePlayer @JvmOverloads constructor(
.setHandleAudioBecomingNoisy(true).build()
) : ForwardingPlayer(internalPlayer) {
companion object {
private val TAG = Log.tag(VoiceNotePlayer::class.java)
}
init {
val audioManager = ContextCompat.getSystemService(context, AudioManager::class.java)
@@ -47,6 +52,10 @@ class VoiceNotePlayer @JvmOverloads constructor(
.build()
)
.setOnAudioFocusChangeListener {
if (it == AudioManager.AUDIOFOCUS_LOSS || it == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) {
Log.d(TAG, "Audio focus change to $it. Pausing.")
this.pause()
}
}
.build()
} else {
@@ -207,6 +207,8 @@ class VoiceNotePlayerCallback(val context: Context, val player: VoiceNotePlayer)
player.setAudioAttributes(attributes, newStreamType == AudioManager.STREAM_MUSIC)
if (newStreamType == AudioManager.STREAM_VOICE_CALL) {
player.playWhenReady = true
} else {
Log.i(TAG, "Audio stream set to $newStreamType. Not playing when ready.")
}
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
@@ -15,7 +15,9 @@ import androidx.media3.common.Player
import androidx.media3.session.MediaController
import androidx.media3.session.SessionCommand
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.util.ServiceUtil
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat
import java.util.concurrent.TimeUnit
private val TAG = Log.tag(VoiceNoteProximityWakeLockManager::class.java)
@@ -31,6 +33,7 @@ class VoiceNoteProximityWakeLockManager(
private val wakeLock: PowerManager.WakeLock? = ServiceUtil.getPowerManager(activity.applicationContext).newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, TAG)
private val audioManager: AudioManagerCompat = AppDependencies.androidCallAudioManager
private val sensorManager: SensorManager = ServiceUtil.getSensorManager(activity)
private val proximitySensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY)
@@ -58,7 +61,7 @@ class VoiceNoteProximityWakeLockManager(
}
fun unregisterCallbacksAndRelease() {
mediaController.addListener(mediaControllerCallback)
mediaController.removeListener(mediaControllerCallback)
cleanUpWakeLock()
}
@@ -91,20 +94,24 @@ class VoiceNoteProximityWakeLockManager(
inner class ProximityListener : Player.Listener {
override fun onEvents(player: Player, events: Player.Events) {
super.onEvents(player, events)
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
if (events.containsAny(Player.EVENT_PLAYBACK_STATE_CHANGED, Player.EVENT_IS_PLAYING_CHANGED)) {
if (!isActivityResumed()) {
return
}
if (player.isPlaying) {
if (startTime == -1L) {
Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.")
startTime = System.currentTimeMillis()
if (wakeLock?.isHeld == false) {
Log.d(TAG, "[onPlaybackStateChanged] Acquiring wakelock")
wakeLock.acquire(TimeUnit.MINUTES.toMillis(30))
if (audioManager.isHeadsetConnected) {
Log.d(TAG, "[onPlaybackStateChanged] Headset connected, skipping proximity sensor registration.")
} else {
Log.d(TAG, "[onPlaybackStateChanged] Player became active with start time $startTime, registering sensor listener.")
startTime = System.currentTimeMillis()
if (wakeLock?.isHeld == false) {
Log.d(TAG, "[onPlaybackStateChanged] Acquiring wakelock")
wakeLock.acquire(TimeUnit.MINUTES.toMillis(30))
}
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
}
sensorManager.registerListener(hardwareSensorEventListener, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL)
} else {
Log.d(TAG, "[onPlaybackStateChanged] Player became active without start time, skipping sensor registration")
}
@@ -118,11 +125,14 @@ class VoiceNoteProximityWakeLockManager(
inner class HardwareSensorEventListener : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
if (startTime == -1L ||
System.currentTimeMillis() - startTime <= 500 ||
if (System.currentTimeMillis() - startTime <= 500) {
Log.i(TAG, "Ignoring sensor change because it's too close to start time.")
return
} else if (startTime == -1L ||
!isActivityResumed() ||
!mediaController.isPlaying ||
event.sensor.type != Sensor.TYPE_PROXIMITY
event.sensor.type != Sensor.TYPE_PROXIMITY ||
audioManager.isHeadsetConnected()
) {
return
}
@@ -358,7 +358,7 @@ data class CallParticipantsState(
@PluralsRes multipleParticipants: Int,
members: List<GroupMemberEntry.FullMember>
): String {
val eligibleMembers: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf || it.member.isBlocked }
val eligibleMembers: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf || it.member.isBlocked || it.member.isUnregistered }
return when (eligibleMembers.size) {
0 -> noParticipants?.let { context.getString(noParticipants) } ?: ""
@@ -12,7 +12,9 @@ import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -125,11 +127,23 @@ public class ConversationRepository {
@NonNull
public Single<ConversationMessage> resolveMessageToEdit(@NonNull ConversationMessage message) {
return Single.fromCallable(() -> {
MessageRecord messageRecord = message.getMessageRecord();
ConversationMessage latestMessage = message;
MessageRecord messageRecord = latestMessage.getMessageRecord();
MessageId latestRevisionId = messageRecord.isMms() ? ((MmsMessageRecord) messageRecord).getLatestRevisionId() : null;
if (latestRevisionId != null) {
MessageRecord latestRecord = SignalDatabase.messages().getMessageRecordOrNull(latestRevisionId.getId());
if (latestRecord != null) {
Log.e(TAG, "Resolving edit to latest revision: " + latestRevisionId.getId() + " (was: " + messageRecord.getId() + ")");
messageRecord = latestRecord;
latestMessage = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, messageRecord.getDisplayBody(context).toString(), message.getThreadRecipient());
}
}
if (MessageRecordUtil.hasTextSlide(messageRecord)) {
TextSlide textSlide = MessageRecordUtil.requireTextSlide(messageRecord);
if (textSlide.getUri() == null) {
return message;
return latestMessage;
}
try (InputStream stream = PartAuthority.getAttachmentStream(context, textSlide.getUri())) {
@@ -139,7 +153,7 @@ public class ConversationRepository {
Log.w(TAG, "Failed to read text slide data.");
}
}
return message;
return latestMessage;
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
@@ -47,6 +47,7 @@ object PlaintextExportRepository {
threadId: Long,
directoryUri: Uri,
chatName: String,
includeMedia: Boolean,
progressListener: ProgressListener,
cancellationSignal: CancellationSignal
): Boolean {
@@ -70,9 +71,13 @@ object PlaintextExportRepository {
return false
}
val mediaDir = chatDir.createDirectory("media") ?: run {
Log.w(TAG, "Could not create media directory")
return false
val mediaDir = if (includeMedia) {
chatDir.createDirectory("media") ?: run {
Log.w(TAG, "Could not create media directory")
return false
}
} else {
null
}
val chatFile = chatDir.createFile("text/plain", "chat.txt") ?: run {
@@ -117,11 +122,15 @@ object PlaintextExportRepository {
for (message in batch) {
if (cancellationSignal.isCancelled()) return false
writer.writeMessage(context, message, extraData, dateFormat, attachmentDateFormat, pendingAttachments)
writer.writeMessage(context, message, extraData, dateFormat, attachmentDateFormat, pendingAttachments, includeMedia)
writer.newLine()
messagesProcessed++
progressListener.onProgress(messagesProcessed, stats.messageCount, 0, stats.attachmentCount)
if (includeMedia) {
progressListener.onProgress(messagesProcessed, stats.messageCount, 0, stats.attachmentCount)
} else {
progressListener.onProgress(messagesProcessed, stats.messageCount, 0, 0)
}
}
eventTimer.emit("messages")
}
@@ -136,32 +145,34 @@ object PlaintextExportRepository {
// Attachments — use createFile directly (like LocalArchiver's FilesFileSystem) to avoid
// the extra content resolver queries that newFile/findFile perform.
val totalAttachments = pendingAttachments.size
var attachmentsProcessed = 0
for (pending in pendingAttachments) {
if (cancellationSignal.isCancelled()) return false
if (includeMedia && mediaDir != null) {
val totalAttachments = pendingAttachments.size
var attachmentsProcessed = 0
for (pending in pendingAttachments) {
if (cancellationSignal.isCancelled()) return false
try {
val outputStream = mediaDir.createFile("application/octet-stream", pending.exportedName)?.let { it.outputStream(context) }
if (outputStream == null) {
Log.w(TAG, "Could not create attachment file: ${pending.exportedName}")
attachmentsProcessed++
progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments)
continue
}
outputStream.use { out ->
SignalDatabase.attachments.getAttachmentStream(pending.attachment.attachmentId, 0).use { input ->
input.copyTo(out)
try {
val outputStream = mediaDir.createFile("application/octet-stream", pending.exportedName)?.let { it.outputStream(context) }
if (outputStream == null) {
Log.w(TAG, "Could not create attachment file: ${pending.exportedName}")
attachmentsProcessed++
progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments)
continue
}
}
} catch (e: Exception) {
Log.w(TAG, "Error exporting attachment: ${pending.exportedName}", e)
}
attachmentsProcessed++
progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments)
eventTimer.emit("media")
outputStream.use { out ->
SignalDatabase.attachments.getAttachmentStream(pending.attachment.attachmentId, 0).use { input ->
input.copyTo(out)
}
}
} catch (e: Exception) {
Log.w(TAG, "Error exporting attachment: ${pending.exportedName}", e)
}
attachmentsProcessed++
progressListener.onProgress(stats.messageCount, stats.messageCount, attachmentsProcessed, totalAttachments)
eventTimer.emit("media")
}
}
Log.d(TAG, "[PlaintextExport] ${eventTimer.stop().summary}")
@@ -222,7 +233,8 @@ object PlaintextExportRepository {
extraData: ExtraMessageData,
dateFormat: SimpleDateFormat,
attachmentDateFormat: SimpleDateFormat,
pendingAttachments: MutableList<PendingAttachment>
pendingAttachments: MutableList<PendingAttachment>,
includeMedia: Boolean
) {
val timestamp = dateFormat.format(Date(message.dateSent))
@@ -262,7 +274,7 @@ object PlaintextExportRepository {
}
if (stickerAttachment != null) {
this.writeSticker(stickerAttachment, prefix, hasQuote, attachmentDateFormat, pendingAttachments)
this.writeSticker(stickerAttachment, prefix, hasQuote, attachmentDateFormat, pendingAttachments, includeMedia)
return
}
@@ -282,7 +294,7 @@ object PlaintextExportRepository {
}
val wrotePrefix = !body.isNullOrEmpty() || hasQuote
this.writeAttachments(mainAttachments, prefix, wrotePrefix, attachmentDateFormat, pendingAttachments)
this.writeAttachments(mainAttachments, prefix, wrotePrefix, attachmentDateFormat, pendingAttachments, includeMedia)
}
private fun BufferedWriter.writeUpdateMessage(context: Context, message: MmsMessageRecord, timestamp: String) {
@@ -323,15 +335,20 @@ object PlaintextExportRepository {
prefix: String,
hasQuote: Boolean,
attachmentDateFormat: SimpleDateFormat,
pendingAttachments: MutableList<PendingAttachment>
pendingAttachments: MutableList<PendingAttachment>,
includeMedia: Boolean
) {
val emoji = stickerAttachment.stickerLocator?.emoji ?: ""
val exportedName = buildAttachmentFileName(stickerAttachment, attachmentDateFormat)
pendingAttachments.add(PendingAttachment(stickerAttachment, exportedName))
if (!hasQuote) {
this.write(prefix)
}
this.write("(Sticker) $emoji [See: media/$exportedName]")
if (includeMedia) {
val exportedName = buildAttachmentFileName(stickerAttachment, attachmentDateFormat)
pendingAttachments.add(PendingAttachment(stickerAttachment, exportedName))
this.write("(Sticker) $emoji [See: media/$exportedName]")
} else {
this.write("(Sticker) $emoji")
}
this.newLine()
}
@@ -340,23 +357,33 @@ object PlaintextExportRepository {
prefix: String,
wrotePrefix: Boolean,
attachmentDateFormat: SimpleDateFormat,
pendingAttachments: MutableList<PendingAttachment>
pendingAttachments: MutableList<PendingAttachment>,
includeMedia: Boolean
) {
for ((index, attachment) in attachments.withIndex()) {
val exportedName = buildAttachmentFileName(attachment, attachmentDateFormat)
pendingAttachments.add(PendingAttachment(attachment, exportedName))
val label = getAttachmentLabel(attachment)
if (!wrotePrefix && index == 0) {
this.write(prefix)
}
val caption = attachment.caption
if (caption != null) {
this.write("[$label: media/$exportedName] $caption")
if (includeMedia) {
val exportedName = buildAttachmentFileName(attachment, attachmentDateFormat)
pendingAttachments.add(PendingAttachment(attachment, exportedName))
val caption = attachment.caption
if (caption != null) {
this.write("[$label: media/$exportedName] $caption")
} else {
this.write("[$label: media/$exportedName]")
}
} else {
this.write("[$label: media/$exportedName]")
val caption = attachment.caption
if (caption != null) {
this.write("[$label] $caption")
} else {
this.write("[$label]")
}
}
this.newLine()
}
@@ -33,6 +33,20 @@ object ConversationDialogs {
.show()
}
fun displayCannotStartGroupCallDueToNoLongerAMemberDialog(context: Context) {
MaterialAlertDialogBuilder(context).setTitle(R.string.ConversationActivity_cant_start_group_call)
.setMessage(R.string.CallLogFragment__cant_start_call_no_longer_a_member)
.setPositiveButton(android.R.string.ok) { d: DialogInterface, _: Int -> d.dismiss() }
.show()
}
fun displayCannotStartGroupCallDueToGroupEndedDialog(context: Context) {
MaterialAlertDialogBuilder(context).setTitle(R.string.ConversationActivity_cant_start_group_call)
.setMessage(R.string.conversation_activity__group_action_not_allowed_group_ended)
.setPositiveButton(android.R.string.ok) { d: DialogInterface, _: Int -> d.dismiss() }
.show()
}
fun displayChatSessionRefreshLearnMoreDialog(context: Context) {
MaterialAlertDialogBuilder(context)
.setView(R.layout.decryption_failed_dialog)
@@ -93,6 +93,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -552,6 +553,7 @@ class ConversationFragment :
private lateinit var giphyMp4ProjectionRecycler: GiphyMp4ProjectionRecycler
private lateinit var addToContactsLauncher: ActivityResultLauncher<Intent>
private lateinit var plaintextExportDirectoryLauncher: ActivityResultLauncher<Uri?>
private var exportWithMedia = false
private lateinit var conversationActivityResultContracts: ConversationActivityResultContracts
private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate
private lateinit var adapter: ConversationAdapterV2
@@ -1324,10 +1326,11 @@ class ConversationFragment :
lifecycleScope.launch {
viewModel
.pinnedMessages
.combine(viewModel.wallpaper) { messages, wallpaper -> messages to wallpaper }
.flowWithLifecycle(viewLifecycleOwner.lifecycle)
.flowOn(Dispatchers.Main)
.collect {
presentPinnedMessage(it, args.wallpaper != null)
.collect { (messages, wallpaper) ->
presentPinnedMessage(pinnedMessages = messages, hasWallpaper = wallpaper != null)
}
}
@@ -1605,7 +1608,7 @@ class ConversationFragment :
if (uri != null) {
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
viewModel.startPlaintextExport(requireContext().applicationContext, uri)
viewModel.startPlaintextExport(requireContext().applicationContext, uri, exportWithMedia)
}
}
conversationActivityResultContracts = ConversationActivityResultContracts(this, ActivityResultCallbacks())
@@ -1679,7 +1682,6 @@ class ConversationFragment :
presentConversationTitle(recipient)
presentChatColors(recipient.chatColors)
invalidateOptionsMenu()
updateMessageRequestAcceptedState(!viewModel.hasMessageRequestState)
}
@@ -3949,6 +3951,10 @@ class ConversationFragment :
selectedConversationModel,
object : OnHideListener {
override fun startHide(focusedView: View?) {
if (!lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) || activity == null || activity?.isFinishing == true) {
return
}
multiselectItemDecoration.hideShade(binding.conversationItemRecycler)
ViewUtil.fadeOut(binding.reactionsShade, resources.getInteger(R.integer.reaction_scrubber_hide_duration), View.GONE)
@@ -4285,7 +4291,19 @@ class ConversationFragment :
}
override fun handleExportChat() {
plaintextExportDirectoryLauncher.launch(null)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.ChatExportDialogs__export_chat_history_title)
.setMessage(R.string.ChatExportDialogs__export_confirm_body)
.setPositiveButton(R.string.ChatExportDialogs__export_with_media) { _, _ ->
exportWithMedia = true
plaintextExportDirectoryLauncher.launch(null)
}
.setNeutralButton(R.string.ChatExportDialogs__export_without_media) { _, _ ->
exportWithMedia = false
plaintextExportDirectoryLauncher.launch(null)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
}
@@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
@@ -102,6 +103,7 @@ import org.thoughtcrime.securesms.util.hasGiftBadge
import org.thoughtcrime.securesms.util.rx.RxStore
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.time.Duration
/**
@@ -172,6 +174,8 @@ class ConversationViewModel(
val isPushAvailable: Boolean
get() = recipientSnapshot?.isRegistered == true && Recipient.self().isRegistered
val wallpaper: Flow<ChatWallpaper?> = recipient.asFlow().map { it.wallpaper }.distinctUntilChanged()
val wallpaperSnapshot: ChatWallpaper?
get() = recipientSnapshot?.wallpaper
@@ -216,7 +220,7 @@ class ConversationViewModel(
private val _plaintextExportState = MutableStateFlow<PlaintextExportState>(PlaintextExportState.None)
val plaintextExportState: StateFlow<PlaintextExportState> = _plaintextExportState
private val plaintextExportCancelled = java.util.concurrent.atomic.AtomicBoolean(false)
private val plaintextExportCancelled = AtomicBoolean(false)
init {
disposables += recipient
@@ -759,7 +763,7 @@ class ConversationViewModel(
}
}
fun startPlaintextExport(context: Context, directoryUri: Uri) {
fun startPlaintextExport(context: Context, directoryUri: Uri, withMedia: Boolean) {
val recipient = recipientSnapshot ?: return
val chatName = if (recipient.isSelf) context.getString(R.string.note_to_self) else recipient.getDisplayName(context)
@@ -772,12 +776,17 @@ class ConversationViewModel(
threadId = threadId,
directoryUri = directoryUri,
chatName = chatName,
includeMedia = withMedia,
progressListener = { messagesProcessed, messageCount, attachmentsProcessed, attachmentCount ->
val messagePercent = if (messageCount > 0) (messagesProcessed * 25) / messageCount else 25
val attachmentPercent = if (attachmentCount > 0) (attachmentsProcessed * 75) / attachmentCount else 75
val percent = messagePercent + attachmentPercent
val percent = if (withMedia) {
val messagePercent = if (messageCount > 0) (messagesProcessed * 25) / messageCount else 25
val attachmentPercent = if (attachmentCount > 0) (attachmentsProcessed * 75) / attachmentCount else 75
messagePercent + attachmentPercent
} else {
if (messageCount > 0) (messagesProcessed * 100) / messageCount else 100
}
val status = if (attachmentsProcessed > 0 || messagesProcessed >= messageCount) {
val status = if (withMedia && (attachmentsProcessed > 0 || messagesProcessed >= messageCount)) {
"Exporting media ($attachmentsProcessed/$attachmentCount)..."
} else {
"Exporting messages ($messagesProcessed/$messageCount)..."
@@ -9,6 +9,7 @@ import com.google.android.material.datepicker.CalendarConstraints.DateValidator
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.signal.core.util.LRUCache
import org.signal.core.util.ThreadUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.thoughtcrime.securesms.database.SignalDatabase
import java.time.Instant
@@ -36,7 +37,7 @@ private typealias MessageDateLookup = (Collection<Long>) -> Map<Long, Boolean>
class JumpToDateValidator private constructor(
private val threadId: Long,
@IgnoredOnParcel private val messageExistanceLookup: MessageDateLookup = createDefaultLookup(threadId),
@IgnoredOnParcel private val executor: Executor = SignalExecutors.BOUNDED,
@IgnoredOnParcel private val executor: Executor,
private val zoneId: ZoneId = ZoneId.systemDefault()
) : DateValidator {
@@ -51,7 +52,7 @@ class JumpToDateValidator private constructor(
return JumpToDateValidator(
threadId = threadId,
messageExistanceLookup = createDefaultLookup(threadId),
executor = SignalExecutors.BOUNDED,
executor = SignalExecutors.newCachedSingleThreadExecutor("jump-to-date-validator", ThreadUtil.PRIORITY_BACKGROUND_THREAD),
zoneId = ZoneId.systemDefault()
).also {
it.performInitialPrefetch()
@@ -168,7 +168,6 @@ import org.thoughtcrime.securesms.util.SnapToTopDataObserver;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter;
import org.thoughtcrime.securesms.verify.SelfVerificationFailureSheet;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import org.signal.core.ui.WindowSizeClassExtensionsKt;
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState;
@@ -1305,15 +1304,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
}
private void handleCreateConversation(long threadId, Recipient recipient, int distributionType) {
SimpleTask.run(getLifecycle(), () -> {
ChatWallpaper wallpaper = recipient.resolve().getWallpaper();
if (wallpaper != null && !wallpaper.prefetch(requireContext(), 250)) {
Log.w(TAG, "Failed to prefetch wallpaper.");
}
return null;
}, (nothing) -> {
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1);
});
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1);
}
private void handleOpenIncognito(@NonNull Conversation conversation) {
@@ -1321,15 +1312,7 @@ public class ConversationListFragment extends MainFragment implements Conversati
Recipient recipient = conversation.getThreadRecord().getRecipient();
int distributionType = conversation.getThreadRecord().getDistributionType();
SimpleTask.run(getLifecycle(), () -> {
ChatWallpaper wallpaper = recipient.resolve().getWallpaper();
if (wallpaper != null && !wallpaper.prefetch(requireContext(), 250)) {
Log.w(TAG, "Failed to prefetch wallpaper.");
}
return null;
}, (nothing) -> {
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1, true);
});
getNavigator().goToConversation(recipient.getId(), threadId, distributionType, -1, true);
}
private void startActionModeIfNotActive() {
@@ -7,7 +7,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
*/
object CollapsibleEvents {
const val MAX_SIZE = 50
val MAX_SIZE = 50
@JvmStatic
fun isCollapsibleType(type: Long, messageExtras: MessageExtras?): Boolean {
@@ -164,35 +164,24 @@ class LocalMetricsDatabase private constructor(
}
fun getMetrics(): List<EventMetrics> {
val db = readableDatabase
val events: Map<String, List<String>> = getUniqueEventNames()
db.beginTransaction()
try {
val events: Map<String, List<String>> = getUniqueEventNames()
val metrics: List<EventMetrics> = events.map { (eventName: String, splits: List<String>) ->
EventMetrics(
name = eventName,
count = getCount(eventName),
p50 = eventPercent(eventName, 50),
p90 = eventPercent(eventName, 90),
p99 = eventPercent(eventName, 99),
splits = splits.map { splitName ->
SplitMetrics(
name = splitName,
p50 = splitPercent(eventName, splitName, 50),
p90 = splitPercent(eventName, splitName, 90),
p99 = splitPercent(eventName, splitName, 99)
)
}
)
}
db.setTransactionSuccessful()
return metrics
} finally {
db.endTransaction()
return events.map { (eventName: String, splits: List<String>) ->
EventMetrics(
name = eventName,
count = getCount(eventName),
p50 = eventPercent(eventName, 50),
p90 = eventPercent(eventName, 90),
p99 = eventPercent(eventName, 99),
splits = splits.map { splitName ->
SplitMetrics(
name = splitName,
p50 = splitPercent(eventName, splitName, 50),
p90 = splitPercent(eventName, splitName, 90),
p99 = splitPercent(eventName, splitName, 99)
)
}
)
}
}
@@ -1290,7 +1290,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
if (key == null) {
Log.w(TAG, "Needed to repair storageId for $recipientId (group $id)")
rotateStorageId(existing.id)
rotateStorageId(existing.id, logFailure = true)
existing = getRecordForSync(recipientId) ?: throw AssertionError("Failed to find recipient record for second fetch!")
key = existing.storageId ?: throw AssertionError("StorageId not present immediately after setting it!")
}
@@ -4006,7 +4006,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
* Will *not* give storageIds to those that shouldn't get them (e.g. MMS groups, unregistered
* users).
*/
fun rotateStorageId(recipientId: RecipientId) {
fun rotateStorageId(recipientId: RecipientId, logFailure: Boolean = false) {
val selfId = Recipient.self().id
val values = ContentValues(1).apply {
@@ -4018,6 +4018,16 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
writableDatabase.update(TABLE_NAME, values, query, args).also { updateCount ->
Log.d(TAG, "[rotateStorageId] updateCount: $updateCount")
if (logFailure && updateCount == 0) {
val typeRegistered = readableDatabase
.select(TYPE, REGISTERED)
.from(TABLE_NAME)
.where(ID_WHERE, recipientId)
.run()
.readToSingleObject { it.requireInt(TYPE) to it.requireInt(REGISTERED) }
Log.w(TAG, "[rotateStorageId] No records updated for $recipientId, exists=${typeRegistered != null} type=${typeRegistered?.first} registered=${typeRegistered?.second}")
}
}
}
@@ -151,10 +151,11 @@ public final class ThreadBodyUtil {
if (call != null) {
boolean accepted = call.getEvent() == CallTable.Event.ACCEPTED;
if (call.getDirection() == CallTable.Direction.OUTGOING) {
if (call.getType() == CallTable.Type.AUDIO_CALL) {
return context.getString(R.string.MessageRecord_outgoing_voice_call);
boolean isVideoCall = call.getType() == CallTable.Type.VIDEO_CALL;
if (call.getEvent() == CallTable.Event.NOT_ACCEPTED) {
return context.getString(isVideoCall ? R.string.MessageRecord_unanswered_video_call : R.string.MessageRecord_unanswered_voice_call);
} else {
return context.getString(R.string.MessageRecord_outgoing_video_call);
return context.getString(isVideoCall ? R.string.MessageRecord_outgoing_video_call : R.string.MessageRecord_outgoing_voice_call);
}
} else {
boolean isVideoCall = call.getType() == CallTable.Type.VIDEO_CALL;
@@ -260,12 +260,18 @@ public class MmsMessageRecord extends MessageRecord {
String callDateString = getCallDateString(context);
if (call.getDirection() == CallTable.Direction.OUTGOING) {
if (call.getType() == CallTable.Type.AUDIO_CALL) {
int updateString = R.string.MessageRecord_outgoing_voice_call;
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), Glyph.PHONE);
boolean isVideoCall = call.getType() == CallTable.Type.VIDEO_CALL;
Glyph icon = isVideoCall ? Glyph.VIDEO_CAMERA : Glyph.PHONE;
if (call.getEvent() == CallTable.Event.NOT_ACCEPTED) {
int message = isVideoCall ? R.string.MessageRecord_unanswered_video_call : R.string.MessageRecord_unanswered_voice_call;
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(message), callDateString),
icon,
ContextCompat.getColor(context, R.color.core_red_shade),
ContextCompat.getColor(context, R.color.core_red));
} else {
int updateString = R.string.MessageRecord_outgoing_video_call;
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), Glyph.VIDEO_CAMERA);
int updateString = isVideoCall ? R.string.MessageRecord_outgoing_video_call : R.string.MessageRecord_outgoing_voice_call;
return staticUpdateDescription(context.getString(R.string.MessageRecord_call_message_with_date, context.getString(updateString), callDateString), icon);
}
} else {
boolean isVideoCall = call.getType() == CallTable.Type.VIDEO_CALL;
@@ -344,6 +344,10 @@ object AppDependencies {
val linkDeviceApi: LinkDeviceApi
get() = networkModule.linkDeviceApi
@JvmStatic
val pushServiceSocket: PushServiceSocket
get() = networkModule.pushServiceSocket
@JvmStatic
val registrationApi: RegistrationApi
get() = networkModule.registrationApi
@@ -141,6 +141,7 @@ object FcmFetchManager {
@JvmStatic
fun onForeground(context: Context) {
cancelMayHaveMessagesNotification(context)
FcmFetchForegroundService.stopServiceIfNecessary(context)
}
@JvmStatic
@@ -16,6 +16,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -54,12 +55,16 @@ fun MemberLabelPill(
maxLines: Int = 1
) {
val isDark = isSystemInDarkTheme()
val backgroundColor = tintColor.copy(alpha = if (isDark) 0.32f else 0.10f)
val backgroundColor = remember(isDark, tintColor) {
tintColor.copy(alpha = if (isDark) 0.32f else 0.10f)
}
val textColor = if (isDark) {
Color.White.copy(alpha = 0.25f).compositeOver(tintColor)
} else {
Color.Black.copy(alpha = 0.30f).compositeOver(tintColor)
val textColor = remember(isDark, tintColor) {
if (isDark) {
Color.White.copy(alpha = 0.25f).compositeOver(tintColor)
} else {
Color.Black.copy(alpha = 0.30f).compositeOver(tintColor)
}
}
MemberLabelPill(
@@ -97,27 +97,38 @@ private fun SenderNameWithLabel(
modifier: Modifier = Modifier,
labelSlot: @Composable (MemberLabel) -> Unit
) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically
) {
ProvideTextStyle(MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold)) {
Emojifier(text = senderName) { annotatedText, inlineContent ->
Text(
text = annotatedText,
inlineContent = inlineContent,
color = senderColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
if (memberLabel != null) {
if (memberLabel != null) {
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
itemVerticalAlignment = Alignment.CenterVertically
) {
SenderNameText(senderName, senderColor)
labelSlot(memberLabel)
}
} else {
SenderNameText(senderName, senderColor, modifier)
}
}
@Composable
private fun SenderNameText(
senderName: String,
senderColor: Color,
modifier: Modifier = Modifier
) {
ProvideTextStyle(MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold)) {
Emojifier(text = senderName) { annotatedText, inlineContent ->
Text(
modifier = modifier,
text = annotatedText,
inlineContent = inlineContent,
color = senderColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@@ -560,8 +560,10 @@ class GroupsV2StateProcessor private constructor(
}
if (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION) {
Log.i(TAG, "$logPrefix Inserting single update message for no local state or restore placeholder")
profileAndMessageHelper.insertUpdateMessages(timestamp, null, setOf(AppliedGroupChangeLog(updatedGroupState, null)), null)
if (!updatedGroupState.terminated) {
Log.i(TAG, "$logPrefix Inserting single update message for no local state or restore placeholder")
profileAndMessageHelper.insertUpdateMessages(timestamp, null, setOf(AppliedGroupChangeLog(updatedGroupState, null)), null)
}
} else {
profileAndMessageHelper.insertUpdateMessages(timestamp, currentLocalState, applyGroupStateDiffResult.processedLogEntries, serverGuid)
}
@@ -12,6 +12,9 @@ import org.signal.core.util.Util
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.inRoundedDays
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.net.RetryLaterException
import org.signal.libsignal.net.UploadTooLargeException
import org.signal.protos.resumableuploads.ResumableUpload
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.Attachment
@@ -171,8 +174,15 @@ class AttachmentUploadJob private constructor(
try {
val existingSpec = uploadSpec?.let { ResumableUploadSpec.from(it) }
val ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(databaseAttachment.size))
val uploadForm = if (existingSpec == null) {
SignalNetwork.attachments.getAttachmentV4UploadForm().successOrThrow()
when (val result = SignalNetwork.attachments.getAttachmentV4UploadForm(ciphertextLength)) {
is RequestResult.Success -> result.result
is RequestResult.NonSuccess -> throw result.error
is RequestResult.RetryableNetworkError -> throw RetryLaterException(result.retryAfter)
is RequestResult.ApplicationError -> throw result.cause
}
} else {
null
}
@@ -288,8 +298,16 @@ class AttachmentUploadJob private constructor(
database.setTransferProgressFailed(attachmentId, databaseAttachment.mmsId)
}
override fun getNextRunAttemptBackoff(pastAttemptCount: Int, exception: java.lang.Exception): Long {
if (exception is RetryLaterException && exception.duration != null) {
return exception.duration.toMillis()
}
return super.getNextRunAttemptBackoff(pastAttemptCount, exception)
}
override fun onShouldRetry(exception: Exception): Boolean {
return exception is IOException && exception !is NotPushRegisteredException
return exception is IOException && exception !is NotPushRegisteredException && exception !is UploadTooLargeException
}
@Throws(InvalidAttachmentException::class)
@@ -1,7 +1,6 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.requireBlob
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireLong
@@ -16,6 +15,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobs.BackfillCollapsedMessageJob.Companion.BATCH_SIZE
import org.thoughtcrime.securesms.jobs.protos.BackfillCollapsedMessageJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.DateUtils
/**
@@ -51,61 +51,70 @@ class BackfillCollapsedMessageJob private constructor(
override fun getFactoryKey(): String = KEY
override fun run(): Result {
if (SignalStore.misc.completedCollapsedEventsMigration) {
Log.i(TAG, "Already completed migration")
return Result.success()
}
val db = SignalDatabase.rawDatabase
val messages = db
.select(MessageTable.ID, MessageTable.THREAD_ID, MessageTable.DATE_RECEIVED, MessageTable.TYPE, MessageTable.READ, MessageTable.COLLAPSED_STATE, MessageTable.MESSAGE_EXTRAS)
var messageCount = 0
var lastProcessedDateReceived = lastDateReceived
// Tracks the last/previous message to compare against the current message when determining collapsed state
val lastMessageByThread = mutableMapOf<Long, LastMessage?>()
db
.select(MessageTable.ID, MessageTable.THREAD_ID, MessageTable.DATE_RECEIVED, MessageTable.TYPE, MessageTable.READ, MessageTable.MESSAGE_EXTRAS)
.from(MessageTable.TABLE_NAME)
.where("${MessageTable.DATE_RECEIVED} > ?", lastDateReceived)
.orderBy("${MessageTable.DATE_RECEIVED}, ${MessageTable.ID}")
.limit(BATCH_SIZE)
.run()
.readToList { cursor ->
PotentialCollapsibleMessage(
id = cursor.requireLong(MessageTable.ID),
threadId = cursor.requireLong(MessageTable.THREAD_ID),
type = cursor.requireLong(MessageTable.TYPE),
dateReceived = cursor.requireLong(MessageTable.DATE_RECEIVED),
collapsedState = cursor.requireLong(MessageTable.COLLAPSED_STATE),
read = cursor.requireBoolean(MessageTable.READ),
messageExtras = cursor.requireBlob(MessageTable.MESSAGE_EXTRAS)?.let { MessageExtras.ADAPTER.decode(it) }
)
}
.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.requireLong(MessageTable.ID)
val threadId = cursor.requireLong(MessageTable.THREAD_ID)
val type = cursor.requireLong(MessageTable.TYPE)
val dateReceived = cursor.requireLong(MessageTable.DATE_RECEIVED)
val read = cursor.requireBoolean(MessageTable.READ)
val messageExtras = cursor.requireBlob(MessageTable.MESSAGE_EXTRAS)?.let { MessageExtras.ADAPTER.decode(it) }
// Tracks the last/previous message to compare against the current message when determining collapsed state
val lastMessageByThread = mutableMapOf<Long, LastMessage?>()
for (message in messages) {
val collapsibleType = CollapsibleEvents.getCollapsibleType(message.type, message.messageExtras)
val collapsibleType = CollapsibleEvents.getCollapsibleType(type, messageExtras)
if (collapsibleType == null) {
lastMessageByThread[message.threadId] = null
} else {
val previous = lastMessageByThread[message.threadId]
if (collapsibleType == null) {
lastMessageByThread[threadId] = null
} else {
val previous = lastMessageByThread[threadId]
val (collapsedState, headId, size) = if ((previous?.collapsibleType == collapsibleType) && DateUtils.isSameDay(previous.dateReceived, message.dateReceived) && previous.collapsedSetSize < CollapsibleEvents.MAX_SIZE) {
val state = if (message.read) CollapsedState.COLLAPSED.id else CollapsedState.PENDING_COLLAPSED.id
Triple(state, previous.headId, previous.collapsedSetSize)
} else {
Triple(CollapsedState.HEAD_COLLAPSED.id, message.id, 0)
val (collapsedState, headId, size) = if ((previous?.collapsibleType == collapsibleType) && DateUtils.isSameDay(previous.dateReceived, dateReceived) && previous.collapsedSetSize < CollapsibleEvents.MAX_SIZE) {
val state = if (read) CollapsedState.COLLAPSED.id else CollapsedState.PENDING_COLLAPSED.id
Triple(state, previous.headId, previous.collapsedSetSize)
} else {
Triple(CollapsedState.HEAD_COLLAPSED.id, id, 0)
}
db.update(MessageTable.TABLE_NAME)
.values(
MessageTable.COLLAPSED_STATE to collapsedState,
MessageTable.COLLAPSED_HEAD_ID to headId
)
.where("${MessageTable.ID} = ?", id)
.run()
lastMessageByThread[threadId] = LastMessage(collapsibleType, headId, dateReceived, size + 1)
}
messageCount++
lastProcessedDateReceived = dateReceived
}
db.update(MessageTable.TABLE_NAME)
.values(
MessageTable.COLLAPSED_STATE to collapsedState,
MessageTable.COLLAPSED_HEAD_ID to headId
)
.where("${MessageTable.ID} = ?", message.id)
.run()
lastMessageByThread[message.threadId] = LastMessage(collapsibleType, headId, message.dateReceived, size + 1)
}
}
if (messages.isEmpty() || messages.size != BATCH_SIZE) {
if (messageCount == 0 || messageCount != BATCH_SIZE) {
Log.i(TAG, "Finished processing all messages, backfill is completed")
SignalStore.misc.completedCollapsedEventsMigration = true
} else {
val dateReceived = messages.last().dateReceived
Log.i(TAG, "Processed ${messages.size} messages, up to time $dateReceived. Re-enqueuing job")
AppDependencies.jobManager.add(BackfillCollapsedMessageJob(lastDateReceived = dateReceived))
Log.i(TAG, "Processed $messageCount messages, up to time $lastProcessedDateReceived. Re-enqueuing job")
AppDependencies.jobManager.add(BackfillCollapsedMessageJob(lastDateReceived = lastProcessedDateReceived))
}
return Result.success()
@@ -115,19 +124,6 @@ class BackfillCollapsedMessageJob private constructor(
Log.w(TAG, "Failed to backfill collapsed messages. Time of last processed message: $lastDateReceived")
}
/**
* Data required from a message to know if it collapsible
*/
private data class PotentialCollapsibleMessage(
val id: Long,
val threadId: Long,
val type: Long,
val dateReceived: Long,
val collapsedState: Long,
val read: Boolean,
val messageExtras: MessageExtras?
)
/**
* Information about the previous message, used when deciding the collapsible state of the next
*/
@@ -0,0 +1,63 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.AutoDownloadEmojiConstraint
import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.webrtc.CallingAssets
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
/**
* Job that downloads missing calling assets.
*/
class CallingAssetsDownloadJob private constructor(parameters: Parameters) : Job(parameters) {
companion object {
private val TAG = Log.tag(CallingAssetsDownloadJob::class)
const val KEY = "CallingAssetsDownloadJob"
}
constructor() : this(
Parameters.Builder()
.addConstraint(AutoDownloadEmojiConstraint.KEY)
.setLifespan(3.days.inWholeMilliseconds)
.setMaxAttempts(5)
.setMaxInstancesForFactory(1)
.build()
)
override fun serialize(): ByteArray? = null
override fun getFactoryKey(): String = KEY
override fun run(): Result {
var succeeded = true
if (SignalStore.misc.callingAssetsVersion != CallingAssets.CURRENT_VERSION) {
succeeded = CallingAssets.downloadMissingAssets()
}
CallingAssets.registerAssetsIfNeeded()
if (!succeeded) {
Log.w(TAG, "Failed to download some calling assets")
return Result.retry(BackoffUtil.exponentialBackoff(runAttempt + 1, 1.hours.inWholeMilliseconds))
}
SignalStore.misc.callingAssetsVersion = CallingAssets.CURRENT_VERSION
return Result.success()
}
override fun onFailure() = Unit
class Factory : Job.Factory<CallingAssetsDownloadJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): CallingAssetsDownloadJob {
return CallingAssetsDownloadJob(parameters)
}
}
}
@@ -86,6 +86,11 @@ class CopyAttachmentToArchiveJob private constructor(private val attachmentId: A
return Result.success()
}
if (SignalStore.backup.isNotEnoughRemoteStorageSpace) {
Log.w(TAG, "[$attachmentId] Already marked as out of remote storage space. Failing.")
return Result.failure()
}
val attachment: DatabaseAttachment? = SignalDatabase.attachments.getAttachment(attachmentId)
if (attachment == null) {
@@ -154,6 +154,7 @@ public final class JobManagerFactories {
put(BackupRestoreMediaJob.KEY, new BackupRestoreMediaJob.Factory());
put(BackupSubscriptionCheckJob.KEY, new BackupSubscriptionCheckJob.Factory());
put(BuildExpirationConfirmationJob.KEY, new BuildExpirationConfirmationJob.Factory());
put(CallingAssetsDownloadJob.KEY, new CallingAssetsDownloadJob.Factory());
put(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory());
put(CallLinkUpdateSendJob.KEY, new CallLinkUpdateSendJob.Factory());
put(CallLogEventSendJob.KEY, new CallLogEventSendJob.Factory());
@@ -261,7 +262,6 @@ public final class JobManagerFactories {
put(RemoteDeleteSendJob.KEY, new RemoteDeleteSendJob.Factory());
put(ReportSpamJob.KEY, new ReportSpamJob.Factory());
put(ResendMessageJob.KEY, new ResendMessageJob.Factory());
put(ResumableUploadSpecJob.KEY, new ResumableUploadSpecJob.Factory());
put(RequestGroupV2InfoWorkerJob.KEY, new RequestGroupV2InfoWorkerJob.Factory());
put(RequestGroupV2InfoJob.KEY, new RequestGroupV2InfoJob.Factory());
put(LocalBackupRestoreMediaJob.KEY, new LocalBackupRestoreMediaJob.Factory());
@@ -431,6 +431,7 @@ public final class JobManagerFactories {
put("BackupRestoreJob", new FailingJob.Factory());
put("BackfillDigestsMigrationJob", new PassingMigrationJob.Factory());
put("BackfillDigestJob", new FailingJob.Factory());
put("ResumableUploadSpecJob", new FailingJob.Factory());
}};
}
@@ -35,7 +35,9 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage;
@@ -288,7 +290,7 @@ public class MultiDeviceContactUpdateJob extends BaseJob {
.withStream(stream)
.withContentType("application/octet-stream")
.withLength(length)
.withResumableUploadSpec(messageSender.getResumableUploadSpec());
.withResumableUploadSpec(messageSender.getResumableUploadSpec(AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(length))));
messageSender.sendSyncMessage(SignalServiceSyncMessage.forContacts(new ContactsMessage(attachmentStream.build(), complete))
);
@@ -17,7 +17,9 @@ import org.thoughtcrime.securesms.net.NotPushRegisteredException;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage;
@@ -26,6 +28,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.DeviceContactsO
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
@@ -88,11 +91,14 @@ public class MultiDeviceProfileKeyUpdateJob extends BaseJob {
out.close();
SignalServiceMessageSender messageSender = AppDependencies.getSignalServiceMessageSender();
long dataLength = baos.toByteArray().length;
long ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(dataLength));
ResumableUploadSpec uploadSpec = messageSender.getResumableUploadSpec(ciphertextLength);
SignalServiceAttachmentStream attachmentStream = SignalServiceAttachment.newStreamBuilder()
.withStream(new ByteArrayInputStream(baos.toByteArray()))
.withContentType("application/octet-stream")
.withLength(baos.toByteArray().length)
.withResumableUploadSpec(messageSender.getResumableUploadSpec())
.withLength(dataLength)
.withResumableUploadSpec(uploadSpec)
.build();
SignalServiceSyncMessage syncMessage = SignalServiceSyncMessage.forContacts(new ContactsMessage(attachmentStream, false));
@@ -60,7 +60,9 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress;
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
@@ -70,6 +72,7 @@ import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.internal.push.BodyRange;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import java.io.IOException;
import java.io.InputStream;
@@ -171,9 +174,12 @@ public abstract class PushSendJob extends SendJob {
try {
if (attachment.getUri() == null || attachment.size == 0) throw new IOException("Assertion failed, outgoing attachment has no data!");
InputStream is = PartAuthority.getAttachmentStream(context, attachment.getUri());
InputStream inputStream = PartAuthority.getAttachmentStream(context, attachment.getUri());
long ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(attachment.size));
ResumableUploadSpec uploadSpec = AppDependencies.getSignalServiceMessageSender().getResumableUploadSpec(ciphertextLength);
return SignalServiceAttachment.newStreamBuilder()
.withStream(is)
.withStream(inputStream)
.withContentType(attachment.contentType)
.withLength(attachment.size)
.withFileName(attachment.fileName)
@@ -185,7 +191,7 @@ public abstract class PushSendJob extends SendJob {
.withHeight(attachment.height)
.withCaption(attachment.caption)
.withUuid(attachment.uuid)
.withResumableUploadSpec(AppDependencies.getSignalServiceMessageSender().getResumableUploadSpec())
.withResumableUploadSpec(uploadSpec)
.withListener(new SignalServiceAttachment.ProgressListener() {
@Override
public void onAttachmentProgress(@NonNull AttachmentTransferProgress progress) {
@@ -1,67 +0,0 @@
package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import java.io.IOException;
/**
* No longer used. Functionality has been merged into {@link AttachmentUploadJob}.
*/
@Deprecated
public class ResumableUploadSpecJob extends BaseJob {
private static final String TAG = Log.tag(ResumableUploadSpecJob.class);
static final String KEY_RESUME_SPEC = "resume_spec";
public static final String KEY = "ResumableUploadSpecJob";
private ResumableUploadSpecJob(@NonNull Parameters parameters) {
super(parameters);
}
@Override
protected void onRun() throws Exception {
ResumableUploadSpec resumableUploadSpec = AppDependencies.getSignalServiceMessageSender()
.getResumableUploadSpec();
setOutputData(new JsonJobData.Builder()
.putString(KEY_RESUME_SPEC, resumableUploadSpec.serialize())
.serialize());
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof IOException;
}
@Override
public @Nullable byte[] serialize() {
return null;
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public void onFailure() {
}
public static class Factory implements Job.Factory<ResumableUploadSpecJob> {
@Override
public @NonNull ResumableUploadSpecJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
return new ResumableUploadSpecJob(parameters);
}
}
}
@@ -47,6 +47,9 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto
private const val HAS_KEY_TRANSPARENCY_FAILURE = "misc.has_key_transparency_failure"
private const val HAS_SEEN_KEY_TRANSPARENCY_FAILURE = "misc.has_seen_key_transparency_failure"
private const val CAMERA_FACING_FRONT = "misc.camera_facing_front"
private const val COMPLETED_COLLAPSED_EVENTS_MIGRATION = "misc.completed_collapsed_events_migration"
private const val CAPTCHA_LAST_VIEWED_AT = "misc.captcha_last_viewed_at"
private const val CALLING_ASSETS_VERSION = "misc.calling_assets_version"
}
public override fun onFirstEverAppLaunch() {
@@ -315,4 +318,18 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto
* Whether or not the preferred camera direction is front-facing.
*/
var isCameraFacingFront: Boolean by booleanValue(CAMERA_FACING_FRONT, true)
var completedCollapsedEventsMigration: Boolean by booleanValue(COMPLETED_COLLAPSED_EVENTS_MIGRATION, false)
/**
* The last time the user viewed the captcha/recaptcha proof activity.
*/
var captchaLastViewedAt: Long by longValue(CAPTCHA_LAST_VIEWED_AT, 0)
/**
* The last successfully-downloaded calling assets version. Compared against
* [org.thoughtcrime.securesms.service.webrtc.CallingAssets.CURRENT_VERSION] to determine
* if new assets need to be fetched.
*/
var callingAssetsVersion: Int by integerValue(CALLING_ASSETS_VERSION, 0)
}
@@ -10,6 +10,7 @@ import org.signal.core.util.logging.logD
import org.signal.core.util.logging.logI
import org.signal.core.util.logging.logW
import org.signal.core.util.toByteArray
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil
@@ -337,11 +338,11 @@ object LinkDeviceRepository {
}
Log.d(TAG, "[createAndUploadArchive] Fetching an upload form...")
val uploadForm = when (val result = NetworkResult.withRetry { SignalNetwork.attachments.getAttachmentV4UploadForm() }) {
is NetworkResult.Success -> result.result.logD(TAG, "[createAndUploadArchive] Successfully retrieved upload form.")
is NetworkResult.ApplicationError -> throw result.throwable
is NetworkResult.NetworkError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "[createAndUploadArchive] Network error when fetching form.", result.exception)
is NetworkResult.StatusCodeError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "[createAndUploadArchive] Status code error when fetching form.", result.exception)
val uploadForm = when (val result = SignalNetwork.attachments.getAttachmentV4UploadForm(tempBackupFile.length())) {
is RequestResult.Success -> result.result.logD(TAG, "[createAndUploadArchive] Successfully retrieved upload form.")
is RequestResult.ApplicationError -> throw result.cause
is RequestResult.RetryableNetworkError -> return LinkUploadArchiveResult.NetworkError(result.networkError).logW(TAG, "[createAndUploadArchive] Network error when fetching form.", result.networkError)
is RequestResult.NonSuccess -> return LinkUploadArchiveResult.BadRequest(result.error).logW(TAG, "[createAndUploadArchive] Upload too large when fetching form.", result.error)
}
if (cancellationSignal()) {
@@ -10,14 +10,14 @@ import androidx.annotation.Nullable;
import androidx.navigation.NavGraph;
import androidx.navigation.Navigation;
import org.thoughtcrime.securesms.BaseActivity;
import org.thoughtcrime.securesms.PassphrasePromptActivity;
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.DynamicRegistrationTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
public class CreateSvrPinActivity extends BaseActivity {
public class CreateSvrPinActivity extends PassphraseRequiredActivity {
public static final int REQUEST_NEW_PIN = 27698;
@@ -55,8 +55,8 @@ public class CreateSvrPinActivity extends BaseActivity {
}
@Override
public void onCreate(Bundle bundle) {
super.onCreate(bundle);
public void onCreate(Bundle bundle, boolean ready) {
super.onCreate(bundle, ready);
if (KeyCachingService.isLocked(this)) {
startActivity(getPromptPassphraseIntent());
@@ -38,6 +38,7 @@ import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import java.util.zip.GZIPOutputStream;
import java.util.zip.ZipEntry;
@@ -114,15 +115,15 @@ public class SubmitDebugLogRepository {
this.executor = SignalExecutors.SERIAL;
}
public void getPrefixLogLines(@NonNull Callback<List<LogLine>> callback) {
executor.execute(() -> callback.onResult(getPrefixLogLinesInternal()));
public void getPrefixLogLines(@NonNull Consumer<List<LogLine>> callback) {
executor.execute(() -> callback.accept(getPrefixLogLinesInternal()));
}
public void buildAndSubmitLog(@NonNull Callback<Optional<String>> callback) {
public void buildAndSubmitLog(@NonNull Consumer<Optional<String>> callback) {
SignalExecutors.UNBOUNDED.execute(() -> {
Log.blockUntilAllWritesFinished();
LogDatabase.getInstance(context).logs().trimToSize();
callback.onResult(submitLogInternal(System.currentTimeMillis(), getPrefixLogLinesInternal(), Tracer.getInstance().serialize()));
callback.accept(submitLogInternal(System.currentTimeMillis(), getPrefixLogLinesInternal(), Tracer.getInstance().serialize()));
});
}
@@ -133,11 +134,11 @@ public class SubmitDebugLogRepository {
return submitLogInternal(untilTime, getPrefixLogLinesInternal(), Tracer.getInstance().serialize());
}
public void submitLogFromReader(DebugLogsViewer.LogReader logReader, @Nullable byte[] trace, Callback<Optional<String>> callback) {
SignalExecutors.UNBOUNDED.execute(() -> callback.onResult(submitLogFromReaderInternal(logReader, trace)));
public void submitLogFromReader(DebugLogsViewer.LogReader logReader, @Nullable byte[] trace, Consumer<Optional<String>> callback) {
SignalExecutors.UNBOUNDED.execute(() -> callback.accept(submitLogFromReaderInternal(logReader, trace)));
}
public void writeLogToDisk(@NonNull Uri uri, long untilTime, Callback<Boolean> callback) {
public void writeLogToDisk(@NonNull Uri uri, long untilTime, Consumer<Boolean> callback) {
SignalExecutors.UNBOUNDED.execute(() -> {
try (ZipOutputStream outputStream = new ZipOutputStream(context.getContentResolver().openOutputStream(uri))) {
StringBuilder prefixLines = linesToStringBuilder(getPrefixLogLinesInternal(), null);
@@ -152,7 +153,7 @@ public class SubmitDebugLogRepository {
}
} catch (IllegalStateException e) {
Log.e(TAG, "Failed to read row!", e);
callback.onResult(false);
callback.accept(false);
return;
}
@@ -162,9 +163,9 @@ public class SubmitDebugLogRepository {
outputStream.write(Tracer.getInstance().serialize());
outputStream.closeEntry();
callback.onResult(true);
callback.accept(true);
} catch (IOException e) {
callback.onResult(false);
callback.accept(false);
}
});
}
@@ -449,8 +450,4 @@ public class SubmitDebugLogRepository {
return stringBuilder;
}
public interface Callback<E> {
void onResult(E result);
}
}
@@ -12,9 +12,12 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.window.core.layout.WindowSizeClass
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.getWindowBreakpoint
import org.signal.core.ui.isSplitPane
private val MEDIUM_CONTENT_CORNERS = 18.dp
@@ -70,25 +73,27 @@ data class MainContentLayoutData(
@Composable
fun rememberContentLayoutData(mode: MainToolbarMode): MainContentLayoutData {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val resources = LocalResources.current
val breakpoint = resources.getWindowBreakpoint()
return remember(windowSizeClass, mode) {
return remember(windowSizeClass, mode, breakpoint) {
val isSplitPane = windowSizeClass.isSplitPane()
val isWidthExpanded = windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND)
val isLargeWindowSize = breakpoint == WindowBreakpoint.LARGE
MainContentLayoutData(
shape = when {
!isSplitPane -> RectangleShape
isWidthExpanded -> RoundedCornerShape(EXTENDED_CONTENT_CORNERS)
isLargeWindowSize -> RoundedCornerShape(EXTENDED_CONTENT_CORNERS)
else -> RoundedCornerShape(MEDIUM_CONTENT_CORNERS)
},
navigationBarShape = when {
!isSplitPane -> RectangleShape
isWidthExpanded -> RoundedCornerShape(0.dp, 0.dp, EXTENDED_CONTENT_CORNERS, EXTENDED_CONTENT_CORNERS)
isLargeWindowSize -> RoundedCornerShape(0.dp, 0.dp, EXTENDED_CONTENT_CORNERS, EXTENDED_CONTENT_CORNERS)
else -> RoundedCornerShape(0.dp, 0.dp, MEDIUM_CONTENT_CORNERS, MEDIUM_CONTENT_CORNERS)
},
partitionWidth = when {
!isSplitPane -> 0.dp
isWidthExpanded -> 24.dp
isLargeWindowSize -> 24.dp
else -> 13.dp
},
listPaddingStart = when {
@@ -102,7 +107,7 @@ data class MainContentLayoutData(
},
detailPaddingEnd = when {
!isSplitPane -> 0.dp
isWidthExpanded -> 24.dp
isLargeWindowSize -> 24.dp
else -> 12.dp
}
)
@@ -17,6 +17,7 @@ import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.CreationExtras
import io.reactivex.rxjava3.core.Observable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
@@ -29,14 +30,18 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.reactive.asFlow
import kotlinx.coroutines.rx3.asObservable
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.calls.log.CallLogRow
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
import org.thoughtcrime.securesms.components.snackbars.SnackbarStateConsumerRegistry
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.Megaphones
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.delegate
import org.thoughtcrime.securesms.window.AppScaffoldNavigator
@@ -44,11 +49,12 @@ import java.util.Optional
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
class MainNavigationViewModel(
private val savedStateHandle: SavedStateHandle,
savedStateHandle: SavedStateHandle,
initialListLocation: MainNavigationListLocation = MainNavigationListLocation.CHATS
) : ViewModel(), MainNavigationRouter {
companion object {
private val TAG = Log.tag(MainNavigationViewModel::class)
private const val LOCK_PANE_TO_SECONDARY = "lock_pane_to_secondary"
}
@@ -141,9 +147,11 @@ class MainNavigationViewModel(
is MainNavigationDetailLocation.Chats.Conversation -> {
internalActiveChatThreadId.update { location.conversationArgs.threadId }
}
is MainNavigationDetailLocation.Calls -> {
internalActiveCallId.update { location.controllerKey }
}
else -> Unit
}
}
@@ -221,8 +229,13 @@ class MainNavigationViewModel(
return
}
viewModelScope.launch {
internalDetailLocation.emit(location)
when (location) {
is MainNavigationDetailLocation.Chats.Conversation -> goToConversation(location.conversationArgs)
else -> {
viewModelScope.launch {
internalDetailLocation.emit(location)
}
}
}
}
@@ -239,6 +252,16 @@ class MainNavigationViewModel(
}
}
private fun goToConversation(args: ConversationArgs) = viewModelScope.launch {
withContext(Dispatchers.IO) {
val wallpaper = Recipient.resolved(args.recipientId).wallpaper
if (wallpaper?.prefetch(AppDependencies.application, 250) == false) {
Log.w(TAG, "goToConversation: Failed to prefetch wallpaper.")
}
}
internalDetailLocation.emit(MainNavigationDetailLocation.Chats.Conversation(args))
}
fun goToCameraFirstStoryCapture() {
viewModelScope.launch {
internalNavigationEvents.emit(NavigationEvent.STORY_CAMERA_FIRST)
@@ -305,22 +305,26 @@ final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter {
}
protected void updateSelectedView() {
boolean selected = isSelected();
itemView.setSelected(selected);
if (selectedIndicator != null) {
selectedIndicator.animate().cancel();
selectedIndicator.setAlpha(isSelected() ? 1f : 0f);
selectedIndicator.setAlpha(selected ? 1f : 0f);
}
}
protected void animateSelectedView() {
boolean selected = isSelected();
itemView.setSelected(selected);
if (selectedIndicator != null) {
selectedIndicator.animate()
.alpha(isSelected() ? 1f : 0f)
.alpha(selected ? 1f : 0f)
.setDuration(SELECTION_ANIMATION_DURATION);
}
}
boolean onLongClick() {
itemClickListener.onMediaLongClicked(mediaRecord);
itemClickListener.onMediaLongClicked(itemView, mediaRecord);
return true;
}
@@ -817,7 +821,7 @@ final class MediaGalleryAllAdapter extends StickyHeaderGridAdapter {
interface ItemClickListener {
void onMediaClicked(@NonNull View view, @NonNull MediaTable.MediaRecord mediaRecord);
void onMediaLongClicked(MediaTable.MediaRecord mediaRecord);
void onMediaLongClicked(@NonNull View view, MediaTable.MediaRecord mediaRecord);
}
interface AudioItemListener {
@@ -0,0 +1,108 @@
package org.thoughtcrime.securesms.mediaoverview
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.database.MediaTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.signal.core.ui.R as CoreUiR
/**
* Context menu shown when long-pressing a media item in [MediaOverviewPageFragment].
*/
class MediaOverviewContextMenu(
private val fragment: Fragment,
private val callbacks: Callbacks
) {
private val lifecycleDisposable by lazy { LifecycleDisposable().bindTo(fragment.viewLifecycleOwner) }
fun show(anchor: View, mediaRecord: MediaTable.MediaRecord) {
val recyclerView = anchor.parent as? RecyclerView
recyclerView?.suppressLayout(true)
anchor.isSelected = true
SignalContextMenu.Builder(anchor, anchor.parent as ViewGroup)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
.offsetY(4.dp)
.onDismiss {
anchor.isSelected = false
recyclerView?.suppressLayout(false)
}
.show(
listOfNotNull(
getSaveActionItem(mediaRecord),
getDeleteActionItem(mediaRecord),
getSelectActionItem(mediaRecord),
getJumpToMessageActionItem(mediaRecord)
)
)
}
private fun getSaveActionItem(mediaRecord: MediaTable.MediaRecord): ActionItem? {
if (mediaRecord.attachment == null) return null
return ActionItem(
iconRes = R.drawable.symbol_save_android_24,
title = fragment.getString(R.string.save)
) {
callbacks.onSave(mediaRecord)
}
}
private fun getDeleteActionItem(mediaRecord: MediaTable.MediaRecord): ActionItem {
return ActionItem(
iconRes = CoreUiR.drawable.symbol_trash_24,
title = fragment.getString(R.string.delete)
) {
callbacks.onDelete(mediaRecord)
}
}
private fun getSelectActionItem(mediaRecord: MediaTable.MediaRecord): ActionItem {
return ActionItem(
iconRes = CoreUiR.drawable.symbol_check_circle_24,
title = fragment.getString(R.string.CallContextMenu__select)
) {
callbacks.onSelect(mediaRecord)
}
}
private fun getJumpToMessageActionItem(mediaRecord: MediaTable.MediaRecord): ActionItem {
return ActionItem(
iconRes = R.drawable.symbol_open_24,
title = fragment.getString(R.string.MediaOverviewActivity_jump_to_message)
) {
lifecycleDisposable += Single.fromCallable<Int> {
val dateReceived = SignalDatabase.messages.getMessageRecordOrNull(mediaRecord.messageId)?.dateReceived
?: mediaRecord.date
SignalDatabase.messages.getMessagePositionInConversation(mediaRecord.threadId, dateReceived)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy { position ->
fragment.startActivity(
ConversationIntents.createBuilderSync(fragment.requireContext(), mediaRecord.threadRecipientId, mediaRecord.threadId)
.withStartingPosition(maxOf(0, position))
.build()
)
}
}
}
interface Callbacks {
fun onSave(mediaRecord: MediaTable.MediaRecord)
fun onDelete(mediaRecord: MediaTable.MediaRecord)
fun onSelect(mediaRecord: MediaTable.MediaRecord)
}
}
@@ -63,6 +63,7 @@ import org.json.JSONException;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Objects;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
@@ -392,12 +393,52 @@ public final class MediaOverviewPageFragment extends LoggingFragment
}
@Override
public void onMediaLongClicked(MediaTable.MediaRecord mediaRecord) {
if (actionMode == null) {
enterMultiSelect();
public void onMediaLongClicked(@NonNull View view, MediaTable.MediaRecord mediaRecord) {
if (actionMode != null) {
handleMediaMultiSelectClick(mediaRecord);
return;
}
handleMediaMultiSelectClick(mediaRecord);
new MediaOverviewContextMenu(this, new MediaOverviewContextMenu.Callbacks() {
@Override
public void onSave(@NonNull MediaTable.MediaRecord record) {
handleSaveSingleMedia(record);
}
@Override
public void onDelete(@NonNull MediaTable.MediaRecord record) {
handleDeleteSingleMedia(record);
}
@Override
public void onSelect(@NonNull MediaTable.MediaRecord record) {
enterMultiSelect();
handleMediaMultiSelectClick(record);
}
}).show(view, mediaRecord);
}
private void handleSaveSingleMedia(@NonNull MediaTable.MediaRecord mediaRecord) {
if (SignalStore.backup().getOptimizeStorage() && mediaRecord.getAttachment() != null && !mediaRecord.getAttachment().hasData) {
OffloadedMediaDialogUtil.showAllOffloaded(requireContext());
return;
}
lifecycleDisposable.add(
MediaActions.handleSaveMedia(this, Collections.singleton(mediaRecord))
.observeOn(AndroidSchedulers.mainThread())
.subscribe()
);
}
private void handleDeleteSingleMedia(@NonNull MediaTable.MediaRecord mediaRecord) {
if (DeleteSyncEducationDialog.shouldShow()) {
lifecycleDisposable.add(
DeleteSyncEducationDialog.show(getChildFragmentManager())
.subscribe(() -> handleDeleteSingleMedia(mediaRecord))
);
return;
}
MediaActions.handleDeleteMedia(requireContext(), Collections.singleton(mediaRecord));
}
private void handleDeleteSelectedMedia() {
@@ -90,7 +90,7 @@ class MediaPreviewV2Activity : PassphraseRequiredActivity(), VoiceNoteMediaContr
setContentView(R.layout.activity_mediapreview_v2)
transitionImageView = findViewById(R.id.transition_image_view)
val cacheDrawable = MediaPreviewCache.drawable
val cacheDrawable = MediaPreviewCache.drawable?.let { RecycledBitmapGuardDrawable(it) }
if (cacheDrawable != null && !args.skipSharedElementTransition) {
val bounds = cacheDrawable.bounds
val aspectRatio = bounds.width().toFloat() / bounds.height()
@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.mediapreview
import android.graphics.Canvas
import android.graphics.ColorFilter
import android.graphics.PixelFormat
import android.graphics.drawable.Drawable
/**
* A wrapper that skips drawing upon failure. This is to guard against situations where we may
* be using a bitmap from Glide that could be recycled at a time outside our control
*
* If you ever truly need the bitmap in this case, you should save it yourself. But there are situations
* (like transition animations) where having a bitmap isn't strictly necessary, and we'd rather
* show nothing than crash or have to manage the bitmap lifecycle ourselves.
*/
class RecycledBitmapGuardDrawable(private val inner: Drawable) : Drawable() {
init {
val b = inner.bounds
setBounds(b.left, b.top, b.right, b.bottom)
}
override fun draw(canvas: Canvas) {
val savedBounds = inner.copyBounds()
inner.setBounds(bounds.left, bounds.top, bounds.right, bounds.bottom)
try {
inner.draw(canvas)
} catch (_: RuntimeException) {
// Bitmap was recycled — nothing to draw.
} finally {
inner.setBounds(savedBounds.left, savedBounds.top, savedBounds.right, savedBounds.bottom)
}
}
override fun getIntrinsicWidth(): Int {
return inner.intrinsicWidth
}
override fun getIntrinsicHeight(): Int {
return inner.intrinsicHeight
}
override fun setAlpha(alpha: Int) {
inner.alpha = alpha
}
override fun setColorFilter(colorFilter: ColorFilter?) {
inner.colorFilter = colorFilter
}
@Deprecated("Deprecated in Java", ReplaceWith("PixelFormat.TRANSLUCENT", "android.graphics.PixelFormat"))
override fun getOpacity(): Int {
return PixelFormat.TRANSLUCENT
}
}
@@ -1,9 +1,12 @@
package org.thoughtcrime.securesms.mediasend.v2
import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet
import android.widget.TextView
import androidx.annotation.ColorInt
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.ViewCompat
import org.thoughtcrime.securesms.R
class MediaCountIndicatorButton @JvmOverloads constructor(
@@ -21,4 +24,8 @@ class MediaCountIndicatorButton @JvmOverloads constructor(
fun setCount(count: Int) {
countView.text = "$count"
}
fun setChatColor(@ColorInt color: Int) {
ViewCompat.setBackgroundTintList(countView, ColorStateList.valueOf(color))
}
}
@@ -197,6 +197,7 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
viewStateLiveData.observe(viewLifecycleOwner) { state ->
binding.mediaGalleryBottomBarGroup.visible = state.selectedMedia.isNotEmpty()
binding.mediaGalleryCountButton.setCount(state.selectedMedia.size)
state.chatColor?.let { binding.mediaGalleryCountButton.setChatColor(it) }
val stopwatch = Stopwatch("mediaSubmit")
selectedAdapter.submitList(state.selectedMedia.map { MediaGallerySelectedItem.Model(it) }) {
@@ -214,14 +215,16 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
val galleryItemsWithSelection = LiveDataUtil.combineLatest(
viewModel.state.map { it.items },
viewStateLiveData.map { it.selectedMedia }
) { galleryItems, selectedMedia ->
viewStateLiveData.map { it.selectedMedia },
viewStateLiveData.map { it.chatColor }
) { galleryItems, selectedMedia, chatColor ->
galleryItems.map {
if (it is MediaGallerySelectableItem.FileModel) {
val selectedIndex = selectedMedia.indexOfFirst { selected -> selected.uri == it.media.uri }
it.copy(
isSelected = selectedIndex >= 0,
selectionOneBasedIndex = selectedIndex + 1
selectionOneBasedIndex = selectedIndex + 1,
chatColor = chatColor
)
} else {
it
@@ -339,7 +342,8 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) {
}
data class ViewState(
val selectedMedia: List<Media> = listOf()
val selectedMedia: List<Media> = listOf(),
val chatColor: Int? = null
)
interface Callbacks {
@@ -2,10 +2,13 @@ package org.thoughtcrime.securesms.mediasend.v2.gallery
import android.animation.ValueAnimator
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.net.Uri
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.setPadding
import com.bumptech.glide.Glide
import com.bumptech.glide.load.DataSource
@@ -29,6 +32,7 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
import java.util.concurrent.TimeUnit
import org.signal.core.ui.R as CoreUiR
typealias OnMediaFolderClicked = (MediaFolder) -> Unit
typealias OnMediaClicked = (Media, Boolean) -> Unit
@@ -99,13 +103,13 @@ object MediaGallerySelectableItem {
}
}
data class FileModel(val media: Media, val isSelected: Boolean, val selectionOneBasedIndex: Int) : MappingModel<FileModel> {
data class FileModel(val media: Media, val isSelected: Boolean, val selectionOneBasedIndex: Int, val chatColor: Int? = null) : MappingModel<FileModel> {
override fun areItemsTheSame(newItem: FileModel): Boolean {
return newItem.media == media
}
override fun areContentsTheSame(newItem: FileModel): Boolean {
return newItem.media == media && isSelected == newItem.isSelected && selectionOneBasedIndex == newItem.selectionOneBasedIndex
return newItem.media == media && isSelected == newItem.isSelected && selectionOneBasedIndex == newItem.selectionOneBasedIndex && chatColor == newItem.chatColor
}
override fun getChangePayload(newItem: FileModel): Any? {
@@ -127,6 +131,12 @@ object MediaGallerySelectableItem {
override fun bind(model: FileModel) {
checkView?.visible = model.isSelected
checkView?.text = "${model.selectionOneBasedIndex}"
(checkView?.background?.mutate() as? LayerDrawable)?.getDrawable(1)
?.let { backgroundDrawable ->
val tintColor = model.chatColor ?: ContextCompat.getColor(itemView.context, CoreUiR.color.signal_light_colorPrimary)
DrawableCompat.setTint(backgroundDrawable, tintColor)
}
itemView.setOnClickListener { onMediaClicked(model.media, model.isSelected) }
itemView.setOnLongClickListener {
mediaGalleryGridItemTouchListener.startDragSelection(bindingAdapterPosition)
@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.mediasend.v2.gallery
import android.os.Bundle
import android.view.View
import androidx.activity.OnBackPressedCallback
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
@@ -16,6 +17,7 @@ import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator.Companion.requestPermissionsForCamera
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mediasend.v2.review.MediaSelectionItemTouchHelper
import org.signal.core.ui.R as CoreUiR
private const val MEDIA_GALLERY_TAG = "MEDIA_GALLERY"
@@ -49,7 +51,12 @@ class MediaSelectionGalleryFragment : Fragment(R.layout.fragment_container), Med
mediaGalleryFragment.bindSelectedMediaItemDragHelper(ItemTouchHelper(MediaSelectionItemTouchHelper(sharedViewModel)))
sharedViewModel.state.observe(viewLifecycleOwner) { state ->
mediaGalleryFragment.onViewStateUpdated(MediaGalleryFragment.ViewState(state.selectedMedia))
mediaGalleryFragment.onViewStateUpdated(
MediaGalleryFragment.ViewState(
selectedMedia = state.selectedMedia,
chatColor = state.recipient?.chatColors?.asSingleColor() ?: ContextCompat.getColor(requireContext(), CoreUiR.color.signal_light_colorPrimary)
)
)
}
lifecycleDisposable += sharedViewModel.mediaErrors
@@ -0,0 +1,40 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.fragment.compose.AndroidFragment
import org.signal.mediasend.MediaSendActivityContract
import org.signal.mediasend.MediaSendScreen
import org.thoughtcrime.securesms.PassphraseRequiredActivity
/**
* Encapsulates the media send flow for v3.
*/
class MediaSendV3Activity : PassphraseRequiredActivity() {
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
enableEdgeToEdge()
val contractArgs = MediaSendActivityContract.Args.fromIntent(intent)
setContent {
MediaSendScreen(
contractArgs = contractArgs,
sendSlot = {
AndroidFragment(
clazz = MediaSendV3ForwardFragment::class.java,
modifier = Modifier.fillMaxSize()
)
}
)
}
}
}
@@ -0,0 +1,29 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import org.signal.mediasend.MediaSendActivityContract
import org.signal.mediasend.StorySendRequirements
import org.thoughtcrime.securesms.stories.Stories
private fun contract(): ActivityResultContract<MediaSendActivityContract.Args, MediaSendActivityContract.Result?> = MediaSendActivityContract(MediaSendV3Activity::class.java)
fun Fragment.mediaSendLauncher(callback: (MediaSendActivityContract.Result?) -> Unit = {}): ActivityResultLauncher<MediaSendActivityContract.Args> = registerForActivityResult(contract(), callback)
fun AppCompatActivity.mediaSendLauncher(callback: (MediaSendActivityContract.Result?) -> Unit = {}): ActivityResultLauncher<MediaSendActivityContract.Args> = registerForActivityResult(contract(), callback)
/**
* Maps the feature-module [StorySendRequirements] to the app-layer [Stories.MediaTransform.SendRequirements].
*/
fun StorySendRequirements.toAppSendRequirements(): Stories.MediaTransform.SendRequirements = when (this) {
StorySendRequirements.CAN_SEND -> Stories.MediaTransform.SendRequirements.VALID_DURATION
StorySendRequirements.CAN_NOT_SEND -> Stories.MediaTransform.SendRequirements.CAN_NOT_SEND
StorySendRequirements.REQUIRES_CROP -> Stories.MediaTransform.SendRequirements.REQUIRES_CLIP
}
@@ -0,0 +1,115 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import org.signal.core.util.getParcelableArrayListCompat
import org.signal.core.util.logging.Log
import org.signal.mediasend.MediaRecipientId
import org.signal.mediasend.MediaSendActivityContract
import org.signal.mediasend.MediaSendState
import org.signal.mediasend.MediaSendViewModel
import org.signal.mediasend.SendResult
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.stories.Stories
import org.signal.core.ui.R as CoreUiR
/**
* View-backed wrapper around [MultiselectForwardFragment] that provides the [ViewGroup] container
* required by [MultiselectForwardFragment.Callback.getContainer] for bottom bar inflation.
*
* Implements the callback interface and uses the shared [MediaSendViewModel] to drive
* the send flow forward.
*/
class MediaSendV3ForwardFragment : Fragment(R.layout.multiselect_forward_activity), MultiselectForwardFragment.Callback {
companion object {
private val TAG = Log.tag(MediaSendV3ForwardFragment::class.java)
}
private val viewModel: MediaSendViewModel by activityViewModels {
MediaSendViewModel.Factory(args = MediaSendActivityContract.Args.fromIntent(requireActivity().intent))
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
if (savedInstanceState == null) {
val state = viewModel.state.value
val forwardFragment = MultiselectForwardFragment.create(
MultiselectForwardFragmentArgs(
title = R.string.MediaReviewFragment__send_to,
storySendRequirements = state.storySendRequirements.toAppSendRequirements(),
isSearchEnabled = !state.isStory,
isViewOnce = state.viewOnceToggleState == MediaSendState.ViewOnceToggleState.ONCE
)
)
childFragmentManager.beginTransaction()
.replace(R.id.fragment_container, forwardFragment)
.commitNow()
}
}
override fun onFinishForwardAction() = Unit
override fun exitFlow() {
requireActivity().finish()
}
override fun onSearchInputFocused() = Unit
override fun setResult(bundle: Bundle) {
val selectedRecipients: List<ContactSearchKey.RecipientSearchKey> = bundle.getParcelableArrayListCompat(MultiselectForwardFragment.RESULT_SELECTION, ContactSearchKey.RecipientSearchKey::class.java)
?: emptyList()
val recipientIds = selectedRecipients.map { MediaRecipientId(it.recipientId.toLong()) }
viewModel.setAdditionalRecipients(recipientIds)
viewLifecycleOwner.lifecycleScope.launch {
when (val result = viewModel.send()) {
is SendResult.Success -> {
Log.d(TAG, "Send completed successfully.")
requireActivity().finish()
}
is SendResult.Error -> {
Log.w(TAG, "Send failed: ${result.message}")
requireActivity().finish()
}
is SendResult.UntrustedIdentity -> {
Log.w(TAG, "Send failed due to untrusted identities.")
SafetyNumberBottomSheet
.forRecipientIdsAndDestinations(result.recipientIds.map { RecipientId.from(it) }, selectedRecipients)
.show(childFragmentManager)
}
}
}
}
override fun getContainer(): ViewGroup {
return requireView().findViewById(R.id.fragment_container_wrapper)
}
override fun getDialogBackgroundColor(): Int {
return ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorBackground)
}
override fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? {
return viewModel.getStorySendRequirements().toAppSendRequirements()
}
}
@@ -93,9 +93,6 @@ object MediaSendV3Repository : MediaSendRepository {
return@withContext SendResult.Error("No recipients provided.")
}
val singleContact = if (recipients.size == 1) recipients.first() else null
val contacts = if (recipients.size > 1) recipients else emptyList()
val legacyEditorStateMap = mapLegacyEditorState(request.editorStateMap)
val quality = SentMediaQuality.fromCode(request.quality)
@@ -106,8 +103,8 @@ object MediaSendV3Repository : MediaSendRepository {
quality = quality,
message = request.message,
isViewOnce = request.isViewOnce,
singleContact = singleContact,
contacts = contacts,
singleContact = null,
contacts = recipients,
mentions = emptyList(),
bodyRanges = null,
sendType = resolveSendType(request.sendType),
@@ -1252,7 +1252,7 @@ object DataMessageProcessor {
SignalDatabase.polls.insertVotes(
pollId = pollId,
pollOptionIds = pollVote.optionIndexes.map { index -> allOptionIds[index] },
pollOptionIds = pollVote.optionIndexes.distinct().map { index -> allOptionIds[index] },
voterId = senderRecipient.id.toLong(),
voteCount = pollVote.voteCount?.toLong() ?: 0,
messageId = messageId
@@ -1586,15 +1586,16 @@ object DataMessageProcessor {
}
warn(timestamp, "Didn't find matching message record...")
val cappedQuoteRanges = quote.bodyRanges.take(BODY_RANGE_PROCESSING_LIMIT)
return QuoteModel(
id = quote.id!!,
author = authorId,
text = quote.text ?: "",
isOriginalMissing = true,
attachment = quote.attachments.firstNotNullOfOrNull { PointerAttachment.forPointer(it).orNull() },
mentions = getMentions(quote.bodyRanges),
mentions = getMentions(cappedQuoteRanges),
type = QuoteModel.Type.fromProto(quote.type),
bodyRanges = quote.bodyRanges.filter { Util.allAreNull(it.mentionAci, it.mentionAciBinary) }.toBodyRangeList()
bodyRanges = cappedQuoteRanges.filter { Util.allAreNull(it.mentionAci, it.mentionAciBinary) }.toBodyRangeList()
)
}
@@ -356,7 +356,8 @@ open class MessageContentProcessor(private val context: Context) {
if (!processingEarlyContent && earlyCacheEntries != null) {
log(envelope.clientTimestamp!!, "Found " + earlyCacheEntries.size + " dependent item(s) that were retrieved earlier. Processing.")
for (entry in earlyCacheEntries) {
handleMessage(senderRecipient, entry.envelope, entry.content, entry.metadata, entry.serverDeliveredTimestamp, processingEarlyContent = true, localMetric = null, batchCache)
val earlyEntrySender = Recipient.externalPush(SignalServiceAddress(entry.metadata.sourceServiceId, entry.metadata.sourceE164))
handleMessage(senderRecipient = earlyEntrySender, entry.envelope, entry.content, entry.metadata, entry.serverDeliveredTimestamp, processingEarlyContent = true, localMetric = null, batchCache)
}
}
}
@@ -197,9 +197,10 @@ public class ApplicationMigrations {
static final int RELEASE_CHANNEL_RECIPIENT_FIX = 153;
static final int EMOJI_VERSION_13 = 154;
static final int COLLAPSED_EVENTS = 155;
static final int COLLAPSED_EVENTS_2 = 156;
}
public static final int CURRENT_VERSION = 155;
public static final int CURRENT_VERSION = 156;
/**
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
@@ -914,6 +915,10 @@ public class ApplicationMigrations {
jobs.put(Version.COLLAPSED_EVENTS, new BackfillCollapsedEventsMigrationJob());
}
if (lastSeenVersion < Version.COLLAPSED_EVENTS_2) {
jobs.put(Version.COLLAPSED_EVENTS_2, new BackfillCollapsedEventsMigrationJob());
}
return jobs;
}
@@ -64,6 +64,7 @@ private fun UpgradeLocalBackupCardComponent(onClick: () -> Unit) {
Text(
text = stringResource(R.string.OnDeviceBackupsSettingsScreen__update_to_a_new_recovery_key),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(end = 8.dp).weight(1f)
)
@@ -69,6 +69,7 @@ public class RecaptchaProofActivity extends PassphraseRequiredActivity {
}
});
SignalStore.misc().setCaptchaLastViewedAt(System.currentTimeMillis());
webView.loadUrl(BuildConfig.RECAPTCHA_PROOF_URL);
}
@@ -0,0 +1,658 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import org.signal.core.models.MasterKey
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.BackupMasterKeyError
import org.signal.registration.NetworkController.CheckSvrCredentialsError
import org.signal.registration.NetworkController.CheckSvrCredentialsResponse
import org.signal.registration.NetworkController.CreateSessionError
import org.signal.registration.NetworkController.GetSessionStatusError
import org.signal.registration.NetworkController.GetSvrCredentialsError
import org.signal.registration.NetworkController.PreKeyCollection
import org.signal.registration.NetworkController.ProvisioningEvent
import org.signal.registration.NetworkController.ProvisioningMessage
import org.signal.registration.NetworkController.RegisterAccountError
import org.signal.registration.NetworkController.RegisterAccountResponse
import org.signal.registration.NetworkController.RegistrationLockResponse
import org.signal.registration.NetworkController.RequestVerificationCodeError
import org.signal.registration.NetworkController.RestoreMasterKeyError
import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.NetworkController.SetAccountAttributesError
import org.signal.registration.NetworkController.SetRegistrationLockError
import org.signal.registration.NetworkController.SubmitVerificationCodeError
import org.signal.registration.NetworkController.SvrCredentials
import org.signal.registration.NetworkController.ThirdPartyServiceErrorResponse
import org.signal.registration.NetworkController.UpdateSessionError
import org.signal.registration.NetworkController.VerificationCodeTransport
import org.signal.registration.proto.RegistrationProvisionMessage
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.gcm.FcmUtil
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import java.io.IOException
import java.util.Locale
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import org.whispersystems.signalservice.api.account.AccountAttributes as ServiceAccountAttributes
import org.whispersystems.signalservice.api.account.PreKeyCollection as ServicePreKeyCollection
/**
* Implementation of [NetworkController] that bridges to the app's existing network infrastructure.
*/
class AppRegistrationNetworkController(
private val context: Context,
private val pushServiceSocket: PushServiceSocket
) : NetworkController {
companion object {
private val TAG = Log.tag(AppRegistrationNetworkController::class)
private val PUSH_REQUEST_TIMEOUT = 5.seconds.inWholeMilliseconds
}
private val json = Json { ignoreUnknownKeys = true }
override suspend fun createSession(
e164: String,
fcmToken: String?,
mcc: String?,
mnc: String?
): RequestResult<SessionMetadata, CreateSessionError> = withContext(Dispatchers.IO) {
try {
pushServiceSocket.createVerificationSessionV2(e164, fcmToken, mcc, mnc).use { response ->
when (response.code) {
200 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RequestResult.Success(session)
}
422 -> {
RequestResult.NonSuccess(CreateSessionError.InvalidRequest(response.body.string()))
}
429 -> {
RequestResult.NonSuccess(CreateSessionError.RateLimited(response.retryAfter()))
}
else -> {
RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: Exception) {
RequestResult.ApplicationError(e)
}
}
override suspend fun getSession(sessionId: String): RequestResult<SessionMetadata, GetSessionStatusError> = withContext(Dispatchers.IO) {
try {
pushServiceSocket.getSessionStatusV2(sessionId).use { response ->
when (response.code) {
200 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RequestResult.Success(session)
}
400 -> {
RequestResult.NonSuccess(GetSessionStatusError.InvalidRequest(response.body.string()))
}
404 -> {
RequestResult.NonSuccess(GetSessionStatusError.SessionNotFound(response.body.string()))
}
422 -> {
RequestResult.NonSuccess(GetSessionStatusError.InvalidSessionId(response.body.string()))
}
else -> {
RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: Exception) {
RequestResult.ApplicationError(e)
}
}
override suspend fun updateSession(
sessionId: String?,
pushChallengeToken: String?,
captchaToken: String?
): RequestResult<SessionMetadata, UpdateSessionError> = withContext(Dispatchers.IO) {
try {
pushServiceSocket.patchVerificationSessionV2(
sessionId,
null,
null,
null,
captchaToken,
pushChallengeToken
).use { response ->
when (response.code) {
200 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RequestResult.Success(session)
}
400 -> {
RequestResult.NonSuccess(UpdateSessionError.InvalidRequest(response.body.string()))
}
409 -> {
RequestResult.NonSuccess(UpdateSessionError.RejectedUpdate(response.body.string()))
}
429 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RequestResult.NonSuccess(UpdateSessionError.RateLimited(response.retryAfter(), session))
}
else -> {
RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: Exception) {
RequestResult.ApplicationError(e)
}
}
override suspend fun requestVerificationCode(
sessionId: String,
locale: Locale?,
androidSmsRetrieverSupported: Boolean,
transport: VerificationCodeTransport
): RequestResult<SessionMetadata, RequestVerificationCodeError> = withContext(Dispatchers.IO) {
try {
val socketTransport = when (transport) {
VerificationCodeTransport.SMS -> PushServiceSocket.VerificationCodeTransport.SMS
VerificationCodeTransport.VOICE -> PushServiceSocket.VerificationCodeTransport.VOICE
}
pushServiceSocket.requestVerificationCodeV2(
sessionId,
locale,
androidSmsRetrieverSupported,
socketTransport
).use { response ->
when (response.code) {
200 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RequestResult.Success(session)
}
400 -> {
RequestResult.NonSuccess(RequestVerificationCodeError.InvalidSessionId(response.body.string()))
}
404 -> {
RequestResult.NonSuccess(RequestVerificationCodeError.SessionNotFound(response.body.string()))
}
409 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RequestResult.NonSuccess(RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified(session))
}
418 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RequestResult.NonSuccess(RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport(session))
}
429 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RequestResult.NonSuccess(RequestVerificationCodeError.RateLimited(response.retryAfter(), session))
}
440 -> {
val errorBody = json.decodeFromString<ThirdPartyServiceErrorResponse>(response.body.string())
RequestResult.NonSuccess(RequestVerificationCodeError.ThirdPartyServiceError(errorBody))
}
else -> {
RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: Exception) {
RequestResult.ApplicationError(e)
}
}
override suspend fun submitVerificationCode(
sessionId: String,
verificationCode: String
): RequestResult<SessionMetadata, SubmitVerificationCodeError> = withContext(Dispatchers.IO) {
try {
pushServiceSocket.submitVerificationCodeV2(sessionId, verificationCode).use { response ->
when (response.code) {
200 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RequestResult.Success(session)
}
400 -> {
RequestResult.NonSuccess(SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode(response.body.string()))
}
404 -> {
RequestResult.NonSuccess(SubmitVerificationCodeError.SessionNotFound(response.body.string()))
}
409 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RequestResult.NonSuccess(SubmitVerificationCodeError.SessionAlreadyVerifiedOrNoCodeRequested(session))
}
429 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RequestResult.NonSuccess(SubmitVerificationCodeError.RateLimited(response.retryAfter(), session))
}
else -> {
RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: Exception) {
RequestResult.ApplicationError(e)
}
}
override suspend fun registerAccount(
e164: String,
password: String,
sessionId: String?,
recoveryPassword: String?,
attributes: AccountAttributes,
aciPreKeys: PreKeyCollection,
pniPreKeys: PreKeyCollection,
fcmToken: String?,
skipDeviceTransfer: Boolean
): RequestResult<RegisterAccountResponse, RegisterAccountError> = withContext(Dispatchers.IO) {
check(sessionId != null || recoveryPassword != null) { "Either sessionId or recoveryPassword must be provided" }
check(sessionId == null || recoveryPassword == null) { "Either sessionId or recoveryPassword must be provided, but not both" }
try {
pushServiceSocket.submitRegistrationRequestV2(
e164,
password,
sessionId,
recoveryPassword,
attributes.toServiceAccountAttributes(),
aciPreKeys.toServicePreKeyCollection(),
pniPreKeys.toServicePreKeyCollection(),
fcmToken,
skipDeviceTransfer
).use { response ->
when (response.code) {
200 -> {
val result = json.decodeFromString<RegisterAccountResponse>(response.body.string())
RequestResult.Success(result)
}
401 -> {
RequestResult.NonSuccess(RegisterAccountError.SessionNotFoundOrNotVerified(response.body.string()))
}
403 -> {
RequestResult.NonSuccess(RegisterAccountError.RegistrationRecoveryPasswordIncorrect(response.body.string()))
}
409 -> {
RequestResult.NonSuccess(RegisterAccountError.DeviceTransferPossible)
}
422 -> {
RequestResult.NonSuccess(RegisterAccountError.InvalidRequest(response.body.string()))
}
423 -> {
val lockResponse = json.decodeFromString<RegistrationLockResponse>(response.body.string())
RequestResult.NonSuccess(RegisterAccountError.RegistrationLock(lockResponse))
}
429 -> {
RequestResult.NonSuccess(RegisterAccountError.RateLimited(response.retryAfter()))
}
else -> {
RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: Exception) {
RequestResult.ApplicationError(e)
}
}
override suspend fun getFcmToken(): String? {
return try {
FcmUtil.getToken(context).orElse(null)
} catch (e: Exception) {
Log.w(TAG, "Failed to get FCM token", e)
null
}
}
override suspend fun awaitPushChallengeToken(): String? = withContext(Dispatchers.IO) {
try {
val latch = java.util.concurrent.CountDownLatch(1)
val challenge = java.util.concurrent.atomic.AtomicReference<String>()
val subscriber = object {
@org.greenrobot.eventbus.Subscribe(threadMode = org.greenrobot.eventbus.ThreadMode.POSTING)
fun onChallengeEvent(event: PushChallengeRequest.PushChallengeEvent) {
challenge.set(event.challenge)
latch.countDown()
}
}
val eventBus = org.greenrobot.eventbus.EventBus.getDefault()
eventBus.register(subscriber)
try {
latch.await(PUSH_REQUEST_TIMEOUT, java.util.concurrent.TimeUnit.MILLISECONDS)
challenge.get()
} finally {
eventBus.unregister(subscriber)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to await push challenge token", e)
null
}
}
override fun getCaptchaUrl(): String {
return BuildConfig.SIGNAL_CAPTCHA_URL
}
override suspend fun restoreMasterKeyFromSvr(
svrCredentials: SvrCredentials,
pin: String
): RequestResult<NetworkController.MasterKeyResponse, RestoreMasterKeyError> = withContext(Dispatchers.IO) {
try {
val authCredentials = AuthCredentials.create(svrCredentials.username, svrCredentials.password)
val credentialSet = SvrAuthCredentialSet(svr2Credentials = authCredentials, svr3Credentials = null)
val masterKey = SvrRepository.restoreMasterKeyPreRegistration(credentialSet, pin)
RequestResult.Success(NetworkController.MasterKeyResponse(masterKey))
} catch (e: SvrWrongPinException) {
RequestResult.NonSuccess(RestoreMasterKeyError.WrongPin(e.triesRemaining))
} catch (e: SvrNoDataException) {
RequestResult.NonSuccess(RestoreMasterKeyError.NoDataFound)
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: Exception) {
RequestResult.ApplicationError(e)
}
}
override suspend fun setPinAndMasterKeyOnSvr(
pin: String,
masterKey: MasterKey
): RequestResult<SvrCredentials?, BackupMasterKeyError> = withContext(Dispatchers.IO) {
try {
val svr2 = AppDependencies.signalServiceAccountManager.getSecureValueRecoveryV2(BuildConfig.SVR2_MRENCLAVE)
val session = svr2.setPin(pin, masterKey)
when (val response = session.execute()) {
is BackupResponse.Success -> {
RequestResult.Success(SvrCredentials(response.authorization.username(), response.authorization.password()))
}
is BackupResponse.EnclaveNotFound -> {
RequestResult.NonSuccess(BackupMasterKeyError.EnclaveNotFound)
}
is BackupResponse.ExposeFailure -> {
RequestResult.Success(null)
}
is BackupResponse.NetworkError -> {
RequestResult.RetryableNetworkError(response.exception)
}
is BackupResponse.ApplicationError -> {
RequestResult.ApplicationError(response.exception)
}
is BackupResponse.ServerRejected -> {
RequestResult.RetryableNetworkError(IOException("Server rejected backup request"))
}
is BackupResponse.RateLimited -> {
RequestResult.RetryableNetworkError(IOException("Rate limited"))
}
}
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: Exception) {
RequestResult.ApplicationError(e)
}
}
override suspend fun enqueueSvrGuessResetJob() {
AppDependencies.jobManager.add(ResetSvrGuessCountJob())
}
override suspend fun enableRegistrationLock(): RequestResult<Unit, SetRegistrationLockError> = withContext(Dispatchers.IO) {
val masterKey = SignalStore.svr.masterKey
if (masterKey == null) {
return@withContext RequestResult.NonSuccess(SetRegistrationLockError.NoPinSet)
}
when (val result = SignalNetwork.account.enableRegistrationLock(masterKey.deriveRegistrationLock())) {
is NetworkResult.Success -> RequestResult.Success(Unit)
is NetworkResult.StatusCodeError -> {
when (result.code) {
401 -> RequestResult.NonSuccess(SetRegistrationLockError.Unauthorized)
422 -> RequestResult.NonSuccess(SetRegistrationLockError.InvalidRequest(result.toString()))
else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${result.code}"))
}
}
is NetworkResult.NetworkError -> RequestResult.RetryableNetworkError(result.exception)
is NetworkResult.ApplicationError -> RequestResult.ApplicationError(result.throwable)
}
}
override suspend fun disableRegistrationLock(): RequestResult<Unit, SetRegistrationLockError> = withContext(Dispatchers.IO) {
when (val result = SignalNetwork.account.disableRegistrationLock()) {
is NetworkResult.Success -> RequestResult.Success(Unit)
is NetworkResult.StatusCodeError -> {
when (result.code) {
401 -> RequestResult.NonSuccess(SetRegistrationLockError.Unauthorized)
else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${result.code}"))
}
}
is NetworkResult.NetworkError -> RequestResult.RetryableNetworkError(result.exception)
is NetworkResult.ApplicationError -> RequestResult.ApplicationError(result.throwable)
}
}
override suspend fun getSvrCredentials(): RequestResult<SvrCredentials, GetSvrCredentialsError> = withContext(Dispatchers.IO) {
try {
val svr2 = AppDependencies.signalServiceAccountManager.getSecureValueRecoveryV2(BuildConfig.SVR2_MRENCLAVE)
val auth = svr2.authorization()
RequestResult.Success(SvrCredentials(auth.username(), auth.password()))
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: Exception) {
RequestResult.ApplicationError(e)
}
}
override suspend fun checkSvrCredentials(
e164: String,
credentials: List<SvrCredentials>
): RequestResult<CheckSvrCredentialsResponse, CheckSvrCredentialsError> = withContext(Dispatchers.IO) {
try {
val tokens = credentials.map { "${it.username}:${it.password}" }
pushServiceSocket.checkSvr2AuthCredentialsV2(e164, tokens).use { response ->
when (response.code) {
200 -> {
val result = json.decodeFromString<CheckSvrCredentialsResponse>(response.body.string())
RequestResult.Success(result)
}
400, 422 -> {
RequestResult.NonSuccess(CheckSvrCredentialsError.InvalidRequest(response.body.string()))
}
401 -> {
RequestResult.NonSuccess(CheckSvrCredentialsError.Unauthorized)
}
else -> {
RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: Exception) {
RequestResult.ApplicationError(e)
}
}
override suspend fun setAccountAttributes(
attributes: AccountAttributes
): RequestResult<Unit, SetAccountAttributesError> = withContext(Dispatchers.IO) {
when (val result = SignalNetwork.account.setAccountAttributes(attributes.toServiceAccountAttributes())) {
is NetworkResult.Success -> RequestResult.Success(Unit)
is NetworkResult.StatusCodeError -> {
when (result.code) {
401 -> RequestResult.NonSuccess(SetAccountAttributesError.Unauthorized)
422 -> RequestResult.NonSuccess(SetAccountAttributesError.InvalidRequest(result.toString()))
else -> RequestResult.ApplicationError(IllegalStateException("Unexpected response code: ${result.code}"))
}
}
is NetworkResult.NetworkError -> RequestResult.RetryableNetworkError(result.exception)
is NetworkResult.ApplicationError -> RequestResult.ApplicationError(result.throwable)
}
}
override fun startProvisioning(): Flow<ProvisioningEvent> = callbackFlow {
val socketHandles = mutableListOf<java.io.Closeable>()
val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration()
fun startSocket() {
val handle = ProvisioningSocket.start<RegistrationProvisionMessage>(
mode = ProvisioningSocket.Mode.REREG,
identityKeyPair = IdentityKeyPair.generate(),
configuration = configuration,
handler = { id, t ->
Log.w(TAG, "[startProvisioning] Socket [$id] failed", t)
trySend(ProvisioningEvent.Error(t))
}
) { socket ->
val url = socket.getProvisioningUrl()
trySend(ProvisioningEvent.QrCodeReady(url))
val result = socket.getProvisioningMessageDecryptResult()
if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) {
val msg = result.message
trySend(
ProvisioningEvent.MessageReceived(
ProvisioningMessage(
accountEntropyPool = msg.accountEntropyPool,
e164 = msg.e164,
pin = msg.pin,
aciIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.aciIdentityKeyPublic.toByteArray()), ECPrivateKey(msg.aciIdentityKeyPrivate.toByteArray())),
pniIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.pniIdentityKeyPublic.toByteArray()), ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray())),
platform = when (msg.platform) {
RegistrationProvisionMessage.Platform.ANDROID -> ProvisioningMessage.Platform.ANDROID
RegistrationProvisionMessage.Platform.IOS -> ProvisioningMessage.Platform.IOS
},
tier = when (msg.tier) {
RegistrationProvisionMessage.Tier.FREE -> ProvisioningMessage.Tier.FREE
RegistrationProvisionMessage.Tier.PAID -> ProvisioningMessage.Tier.PAID
null -> null
},
backupTimestampMs = msg.backupTimestampMs,
backupSizeBytes = msg.backupSizeBytes,
restoreMethodToken = msg.restoreMethodToken,
backupVersion = msg.backupVersion
)
)
)
channel.close()
} else {
Log.w(TAG, "[startProvisioning] Failed to decrypt provisioning message")
trySend(ProvisioningEvent.Error(IOException("Failed to decrypt provisioning message")))
}
}
synchronized(socketHandles) {
socketHandles += handle
if (socketHandles.size > 2) {
socketHandles.removeAt(0).close()
}
}
}
startSocket()
val rotationJob = launch {
var count = 0
while (count < 5 && isActive) {
kotlinx.coroutines.delay(ProvisioningSocket.LIFESPAN / 2)
if (isActive) {
startSocket()
count++
Log.d(TAG, "[startProvisioning] Rotated socket, count: $count")
}
}
}
awaitClose {
rotationJob.cancel()
synchronized(socketHandles) {
socketHandles.forEach { it.close() }
socketHandles.clear()
}
}
}
private fun AccountAttributes.toServiceAccountAttributes(): ServiceAccountAttributes {
return ServiceAccountAttributes(
signalingKey,
registrationId,
fetchesMessages,
registrationLock,
unidentifiedAccessKey,
unrestrictedUnidentifiedAccess,
capabilities?.toServiceCapabilities(),
discoverableByPhoneNumber,
name,
pniRegistrationId,
recoveryPassword
)
}
private fun AccountAttributes.Capabilities.toServiceCapabilities(): ServiceAccountAttributes.Capabilities {
return ServiceAccountAttributes.Capabilities(
storage,
versionedExpirationTimer,
attachmentBackfill,
spqr
)
}
private fun PreKeyCollection.toServicePreKeyCollection(): ServicePreKeyCollection {
return ServicePreKeyCollection(
identityKey = identityKey,
signedPreKey = signedPreKey,
lastResortKyberPreKey = lastResortKyberPreKey
)
}
private fun okhttp3.Response.retryAfter(): Duration {
return this.header("Retry-After")?.toLongOrNull()?.seconds ?: 0.seconds
}
}
@@ -0,0 +1,336 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
import org.signal.archive.LocalBackupRestoreProgress
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.util.logging.Log
import org.signal.registration.PreExistingRegistrationData
import org.signal.registration.StorageController
import org.signal.registration.proto.RegistrationData
import org.signal.registration.screens.localbackuprestore.LocalBackupInfo
import org.signal.registration.screens.restoreselection.ArchiveRestoreOption
import org.thoughtcrime.securesms.backup.FullBackupImporter
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import java.io.File
import java.io.IOException
import java.time.LocalDateTime
import kotlin.time.Duration.Companion.minutes
/**
* Implementation of [StorageController] that bridges to the app's existing storage infrastructure.
*/
class AppRegistrationStorageController(private val context: Context) : StorageController {
companion object {
private val TAG = Log.tag(AppRegistrationStorageController::class)
private const val TEMP_PROTO_FILENAME = "registration-in-progress.proto"
private val TEMP_PROTO_TIMEOUT = 15.minutes
private val MODERN_BACKUP_PATTERN = Regex("^signal-backup-(\\d{4})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})$")
private val LEGACY_BACKUP_PATTERN = Regex("^signal-(\\d{4})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})\\.backup$")
}
override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = withContext(Dispatchers.IO) {
if (!SignalStore.account.isRegistered) {
return@withContext null
}
val aci = SignalStore.account.aci ?: return@withContext null
val pni = SignalStore.account.pni ?: return@withContext null
val e164 = SignalStore.account.e164 ?: return@withContext null
val servicePassword = SignalStore.account.servicePassword ?: return@withContext null
val aep = SignalStore.account.accountEntropyPool ?: return@withContext null
val aciIdentityKeyPair = SignalStore.account.aciIdentityKey
val pniIdentityKeyPair = SignalStore.account.pniIdentityKey
PreExistingRegistrationData(
e164 = e164,
aci = aci,
pni = pni,
servicePassword = servicePassword,
aep = aep,
registrationLockEnabled = SignalStore.svr.isRegistrationLockEnabled,
aciIdentityKeyPair = aciIdentityKeyPair,
pniIdentityKeyPair = pniIdentityKeyPair
)
}
override suspend fun clearAllData() = withContext(Dispatchers.IO) {
File(context.cacheDir, TEMP_PROTO_FILENAME).takeIf { it.exists() }?.delete()
Unit
}
override suspend fun readInProgressRegistrationData(): RegistrationData = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, TEMP_PROTO_FILENAME)
if (file.exists()) {
val age = System.currentTimeMillis() - file.lastModified()
if (age > TEMP_PROTO_TIMEOUT.inWholeMilliseconds) {
Log.w(TAG, "In-progress registration data is stale (${age}ms old), discarding.")
file.delete()
return@withContext RegistrationData()
}
try {
RegistrationData.ADAPTER.decode(file.readBytes())
} catch (e: Exception) {
Log.w(TAG, "Failed to decode registration data, returning empty.", e)
RegistrationData()
}
} else {
RegistrationData()
}
}
override suspend fun updateInProgressRegistrationData(updater: RegistrationData.Builder.() -> Unit) = withContext(Dispatchers.IO) {
val current = readInProgressRegistrationData()
val updated = current.newBuilder().apply(updater).build()
writeRegistrationData(updated)
}
override suspend fun commitRegistrationData() = withContext(Dispatchers.IO) {
val data = readInProgressRegistrationData()
// Build LocalRegistrationMetadata if we have enough data for account setup
if (data.e164.isNotEmpty() && data.aci.isNotEmpty() && data.pni.isNotEmpty() && data.servicePassword.isNotEmpty()) {
val profileKey = RegistrationRepository.getProfileKey(data.e164)
val metadata = LocalRegistrationMetadata.Builder().apply {
if (data.aciIdentityKeyPair.size > 0) {
aciIdentityKeyPair = data.aciIdentityKeyPair
}
if (data.pniIdentityKeyPair.size > 0) {
pniIdentityKeyPair = data.pniIdentityKeyPair
}
if (data.aciSignedPreKey.size > 0) {
aciSignedPreKey = data.aciSignedPreKey
}
if (data.pniSignedPreKey.size > 0) {
pniSignedPreKey = data.pniSignedPreKey
}
if (data.aciLastResortKyberPreKey.size > 0) {
aciLastRestoreKyberPreKey = data.aciLastResortKyberPreKey
}
if (data.pniLastResortKyberPreKey.size > 0) {
pniLastRestoreKyberPreKey = data.pniLastResortKyberPreKey
}
aci = data.aci
pni = data.pni
e164 = data.e164
this.servicePassword = data.servicePassword
this.profileKey = profileKey.serialize().toByteString()
hasPin = data.pin.isNotEmpty()
if (data.pin.isNotEmpty()) {
pin = data.pin
}
if (data.temporaryMasterKey.size > 0) {
masterKey = data.temporaryMasterKey
}
fcmEnabled = SignalStore.account.fcmEnabled
fcmToken = SignalStore.account.fcmToken ?: ""
reglockEnabled = data.registrationLockEnabled
}.build()
// TODO [greyson] Should probably move this stuff into this file as we get closer to being done
RegistrationRepository.registerAccountLocally(context, metadata)
SignalStore.registration.localRegistrationMetadata = metadata
if (data.accountEntropyPool.isNotEmpty()) {
SignalStore.account.restoreAccountEntropyPool(AccountEntropyPool(data.accountEntropyPool))
}
}
// Handle PIN/master key
if (data.pin.isNotEmpty() && data.temporaryMasterKey.size > 0) {
val masterKey = MasterKey(data.temporaryMasterKey.toByteArray())
SvrRepository.onRegistrationComplete(
masterKey,
data.pin,
true,
data.registrationLockEnabled,
data.accountEntropyPool.isNotEmpty()
)
}
Unit
}
override suspend fun getAvailableRestoreOptions(): Set<ArchiveRestoreOption> = withContext(Dispatchers.IO) {
// TODO [greyson] Real options
val options = mutableSetOf<ArchiveRestoreOption>()
options.add(ArchiveRestoreOption.LocalBackup)
options.add(ArchiveRestoreOption.DeviceTransfer)
options
}
override fun restoreLocalBackupV1(uri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress> = flow {
// TODO [greyson] better progress
Log.d(TAG, "Starting V1 local backup restore from: $uri")
emit(LocalBackupRestoreProgress.Preparing)
try {
if (!FullBackupImporter.validatePassphrase(context, uri, passphrase)) {
emit(LocalBackupRestoreProgress.Error(IllegalArgumentException("Invalid passphrase")))
return@flow
}
val database = SignalDatabase.backupDatabase
FullBackupImporter.importFile(
context,
AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
database,
uri,
passphrase,
SignalStore.registration.localRegistrationMetadata != null
)
SignalDatabase.runPostBackupRestoreTasks(database)
emit(LocalBackupRestoreProgress.Complete)
Log.d(TAG, "V1 restore complete.")
} catch (e: FullBackupImporter.DatabaseDowngradeException) {
Log.w(TAG, "V1 restore failed: database downgrade", e)
emit(LocalBackupRestoreProgress.Error(e))
} catch (e: Exception) {
Log.w(TAG, "V1 restore failed", e)
emit(LocalBackupRestoreProgress.Error(e))
}
}.flowOn(Dispatchers.IO)
override fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow<LocalBackupRestoreProgress> = flow {
// TODO [greyson] better progress
Log.d(TAG, "Starting V2 local backup restore from backup=$backupUri, root=$rootUri")
emit(LocalBackupRestoreProgress.Preparing)
try {
val backupDir = DocumentFile.fromTreeUri(context, backupUri)
if (backupDir == null || !backupDir.canRead()) {
emit(LocalBackupRestoreProgress.Error(IllegalStateException("Could not open backup directory")))
return@flow
}
val selfAci = SignalStore.account.aci
val selfPni = SignalStore.account.pni
val selfE164 = SignalStore.account.e164
if (selfAci == null || selfPni == null || selfE164 == null) {
emit(LocalBackupRestoreProgress.Error(IllegalStateException("Account not registered, cannot restore V2 backup")))
return@flow
}
val selfData = BackupRepository.SelfData(selfAci, selfPni, selfE164, ProfileKeyUtil.getSelfProfileKey())
val messageBackupKey = aep.deriveMessageBackupKey()
val snapshotFileSystem = SnapshotFileSystem(context, backupDir)
when (val result = LocalArchiver.import(snapshotFileSystem, selfData, messageBackupKey)) {
is org.signal.core.util.Result.Success -> {
emit(LocalBackupRestoreProgress.Complete)
Log.d(TAG, "V2 restore complete.")
}
is org.signal.core.util.Result.Failure -> {
Log.w(TAG, "V2 restore failed: ${result.failure}")
emit(LocalBackupRestoreProgress.Error(IOException("V2 restore failed: ${result.failure}")))
}
}
} catch (e: Exception) {
Log.w(TAG, "V2 restore failed", e)
emit(LocalBackupRestoreProgress.Error(e))
}
}.flowOn(Dispatchers.IO)
override suspend fun scanLocalBackupFolder(folderUri: Uri): List<LocalBackupInfo> = withContext(Dispatchers.IO) {
val folder = DocumentFile.fromTreeUri(context, folderUri) ?: return@withContext emptyList()
val children = folder.listFiles()
// If the selected folder contains a SignalBackups directory, use that instead
val signalBackupsDir = children.firstOrNull { it.isDirectory && it.name == "SignalBackups" }
val effectiveChildren = if (signalBackupsDir != null) {
Log.d(TAG, "Found SignalBackups directory, using it as the effective folder")
signalBackupsDir.listFiles()
} else {
children
}
val backups = mutableListOf<LocalBackupInfo>()
// Check for modern backups: requires a 'files' directory and signal-backup-* directories
val hasFilesDir = effectiveChildren.any { it.isDirectory && it.name == "files" }
if (hasFilesDir) {
for (child in effectiveChildren) {
if (!child.isDirectory) continue
val name = child.name ?: continue
val match = MODERN_BACKUP_PATTERN.matchEntire(name) ?: continue
val (year, month, day, hour, minute, second) = match.destructured
try {
val date = LocalDateTime.of(year.toInt(), month.toInt(), day.toInt(), hour.toInt(), minute.toInt(), second.toInt())
backups.add(
LocalBackupInfo(
type = LocalBackupInfo.BackupType.V2,
date = date,
name = name,
uri = child.uri
)
)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse date from modern backup name: $name", e)
}
}
}
// Check for legacy backups: signal-yyyy-MM-dd-HH-mm-ss.backup files
for (child in effectiveChildren) {
if (!child.isFile) continue
val name = child.name ?: continue
val match = LEGACY_BACKUP_PATTERN.matchEntire(name) ?: continue
val (year, month, day, hour, minute, second) = match.destructured
try {
val date = LocalDateTime.of(year.toInt(), month.toInt(), day.toInt(), hour.toInt(), minute.toInt(), second.toInt())
backups.add(
LocalBackupInfo(
type = LocalBackupInfo.BackupType.V1,
date = date,
name = name,
uri = child.uri,
sizeBytes = child.length()
)
)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse date from legacy backup name: $name", e)
}
}
backups.sortedByDescending { it.date }
}
private suspend fun writeRegistrationData(data: RegistrationData) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, TEMP_PROTO_FILENAME)
file.writeBytes(RegistrationData.ADAPTER.encode(data))
}
}
@@ -122,6 +122,17 @@ object SafetyNumberBottomSheet {
return SheetFactory(args)
}
/**
* Create a factory to generate a sheet for the given recipient IDs and destinations.
*
* @param recipientIds The list of untrusted recipient IDs
* @param destinations The list of locations the user was trying to send content
*/
@JvmStatic
fun forRecipientIdsAndDestinations(recipientIds: List<RecipientId>, destinations: List<ContactSearchKey.RecipientSearchKey>): Factory {
return SheetFactory(SafetyNumberBottomSheetArgs(recipientIds, destinations))
}
/**
* Create a factory to generate a sheet for the given identity records and single destination.
*
@@ -43,6 +43,11 @@ class CallLinkPreJoinActionProcessor(
override fun handlePreJoinCall(currentState: WebRtcServiceState, remotePeer: RemotePeer): WebRtcServiceState {
Log.i(TAG, "handlePreJoinCall():")
if (currentState.callInfoState.groupCall != null) {
Log.w(TAG, "handlePreJoinCall(): Group call already exists, ignoring duplicate pre-join request")
return currentState
}
val groupCall = try {
val callLink = callLinks.getCallLinkByRoomId(remotePeer.recipient.requireCallLinkRoomId())
if (callLink?.credentials == null) {
@@ -0,0 +1,170 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.service.webrtc
import android.content.Context
import okio.IOException
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.s3.S3
import java.io.File
import java.net.URI
import java.security.MessageDigest
import java.util.Base64
/**
* Manages downloading and registering calling assets (e.g. DRED weights).
*/
object CallingAssets {
private val TAG = Log.tag(CallingAssets::class)
private const val BASE_DIRECTORY = "calling-assets"
/** Increment this whenever an asset is added, removed, or updated. */
const val CURRENT_VERSION = 1
private val ASSETS: List<ManifestEntry> = listOf(
ManifestEntry(
assetGroup = "opus-dred",
name = "calling-dred_weights-1_6_1-f4aed08a.bin",
digest = "sdfpdb/u3wiTfBr2s0gx1LJX6jii4tquyax/UBThTGWTEXyOCSKjYmYV+9tKQZcO+Q1B1ReoGSW3VbvzeMGKaQ==",
url = "https://updates2.signal.org/static/android/calling/deep_plc-dred_weights-1_6_1-f4aed08a.bin",
size = 1998208
)
)
private val registeredLog = HashSet<String>(ASSETS.size)
/**
* Registers any downloaded assets with the call manager that haven't been registered yet this session.
* Safe to call multiple times -- assets already registered are skipped.
*/
@JvmStatic
fun registerAssetsIfNeeded() {
ASSETS.forEach { entry ->
if (registeredLog.contains(entry.name)) {
return@forEach
}
try {
val content = getFromFile(entry.name) ?: return@forEach
if (verify(content, entry)) {
AppDependencies.signalCallManager.addAsset(entry.assetGroup, content)
registeredLog.add(entry.name)
Log.i(TAG, "Registered calling asset: ${entry.name}")
} else {
Log.w(TAG, "Invalid calling asset on disk, skipping registration: ${entry.name}")
}
} catch (e: IOException) {
Log.e(TAG, "Failed to register calling asset ${entry.name}", e)
}
}
}
/**
* Downloads any assets not yet present on disk.
* @return true if all assets are present on disk after this call.
*/
fun downloadMissingAssets(): Boolean {
var allDownloaded = true
ASSETS.forEach { entry ->
try {
val dataOnDisk = getFromFile(entry.name)
if (dataOnDisk != null) {
if (verify(dataOnDisk, entry)) {
Log.i(TAG, "Calling asset already on disk: ${entry.name}")
return@forEach
} else {
Log.w(TAG, "Invalid calling asset found on disk: ${entry.name}")
}
}
val remoteData = getFromRemote(entry.url)
if (remoteData != null) {
if (verify(remoteData, entry)) {
Log.i(TAG, "Calling asset successfully downloaded: ${entry.name}")
val dir = AppDependencies.application.getDir(BASE_DIRECTORY, Context.MODE_PRIVATE)
File(dir, entry.name).writeBytes(remoteData)
return@forEach
} else {
Log.w(TAG, "Failed to verify calling asset: ${entry.name}")
}
}
Log.w(TAG, "Unable to find or download calling asset ${entry.name}")
allDownloaded = false
} catch (e: Exception) {
Log.e(TAG, "Unexpected exception while trying to find calling asset ${entry.name}", e)
allDownloaded = false
}
}
if (allDownloaded) {
deleteStaleAssets()
}
return allDownloaded
}
private fun getFromFile(assetName: String): ByteArray? {
try {
val file = File(AppDependencies.application.getDir(BASE_DIRECTORY, Context.MODE_PRIVATE), assetName)
return if (file.exists()) file.readBytes() else null
} catch (e: Exception) {
Log.e(TAG, "Exception while checking files for calling asset $assetName", e)
return null
}
}
private fun getFromRemote(url: String): ByteArray? {
try {
val path = URI(url).path
S3.getObject(path).use { response ->
if (!response.isSuccessful) {
throw RuntimeException("Failed to download calling asset from $url: HTTP ${response.code}")
}
return response.body.bytes()
}
} catch (e: IOException) {
Log.e(TAG, "Exception while downloading calling asset from $url", e)
return null
}
}
private fun verify(content: ByteArray, entry: ManifestEntry): Boolean {
if (content.size != entry.size) {
Log.w(TAG, "Unexpected size for calling asset ${entry.name}: expected=${entry.name},actual=${content.size}")
return false
}
val hash = MessageDigest.getInstance("SHA-512").digest(content)
val encodedHash = Base64.getEncoder().encodeToString(hash)
return encodedHash == entry.digest
}
private fun deleteStaleAssets() {
try {
val expectedNames = ASSETS.map { it.name }.toSet()
val dir = AppDependencies.application.getDir(BASE_DIRECTORY, Context.MODE_PRIVATE)
dir.listFiles()?.forEach { file ->
if (file.name !in expectedNames) {
Log.i(TAG, "Deleting stale calling asset: ${file.name}")
file.delete()
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to clean up stale calling assets", e)
}
}
data class ManifestEntry(
val assetGroup: String,
val name: String,
val digest: String,
val url: String,
val size: Int
)
}
@@ -3,8 +3,10 @@ package org.thoughtcrime.securesms.service.webrtc;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.ResultReceiver;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.signal.ringrtc.CallException;
@@ -37,6 +39,14 @@ public class GroupNetworkUnavailableActionProcessor extends WebRtcActionProcesso
this.actionProcessorFactory = actionProcessorFactory;
}
@Override
protected @NonNull WebRtcServiceState handleIsInCallQuery(@NonNull WebRtcServiceState currentState, @Nullable ResultReceiver resultReceiver) {
if (resultReceiver != null) {
resultReceiver.send(1, ActiveCallData.fromCallState(currentState).toBundle());
}
return currentState;
}
@Override
protected @NonNull WebRtcServiceState handlePreJoinCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) {
Log.i(TAG, "handlePreJoinCall():");
@@ -1,6 +1,9 @@
package org.thoughtcrime.securesms.service.webrtc;
import android.os.ResultReceiver;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
@@ -43,10 +46,23 @@ public class GroupPreJoinActionProcessor extends GroupActionProcessor {
super(actionProcessorFactory, webRtcInteractor, tag);
}
@Override
protected @NonNull WebRtcServiceState handleIsInCallQuery(@NonNull WebRtcServiceState currentState, @Nullable ResultReceiver resultReceiver) {
if (resultReceiver != null) {
resultReceiver.send(1, ActiveCallData.fromCallState(currentState).toBundle());
}
return currentState;
}
@Override
protected @NonNull WebRtcServiceState handlePreJoinCall(@NonNull WebRtcServiceState currentState, @NonNull RemotePeer remotePeer) {
Log.i(tag, "handlePreJoinCall():");
if (currentState.getCallInfoState().getGroupCall() != null) {
Log.w(tag, "handlePreJoinCall(): Group call already exists, ignoring duplicate pre-join request");
return currentState;
}
byte dredDuration = (byte) RemoteConfig.dredDuration();
byte[] groupId = currentState.getCallInfoState().getCallRecipient().requireGroupId().getDecodedId();
GroupCall groupCall = webRtcInteractor.getCallManager().createGroupCall(groupId,
@@ -1364,6 +1364,13 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
return new SignalCallLinkManager(Objects.requireNonNull(callManager));
}
public void addAsset(String assetGroup, byte[] content) throws CallException {
if (callManager == null) {
throw new CallException("Unable to add asset, call manager is not initialized");
}
callManager.addAsset(assetGroup, content);
}
public void relaunchPipOnForeground() {
AppForegroundObserver.addListener(new RelaunchListener(AppForegroundObserver.isForegrounded()));
}
@@ -38,13 +38,14 @@ object DeleteDialog {
isAdmin: Boolean = false
): Single<Pair<Boolean, Boolean>> = Single.create { emitter ->
val builder = MaterialAlertDialogBuilder(context)
val isNoteToSelfDelete = isNoteToSelfDelete(messageRecords)
builder.setTitle(title)
builder.setMessage(message)
if (!isNoteToSelfDelete) {
builder.setMessage(message)
}
builder.setCancelable(true)
val isNoteToSelfDelete = isNoteToSelfDelete(messageRecords)
if (forceRemoteDelete) {
builder.setPositiveButton(R.string.ConversationFragment_delete_for_everyone) { _, _ -> deleteForEveryone(messageRecords = messageRecords, emitter = emitter) }
} else {
@@ -19,6 +19,8 @@ object Environment {
return !IS_INSTRUMENTATION && (BuildConfig.DEBUG || IS_NIGHTLY || IS_PERF || IS_STAGING)
}
const val USE_NEW_REGISTRATION: Boolean = false
object Backups {
@JvmStatic
fun supportsGooglePlayBilling(): Boolean {
@@ -38,7 +38,7 @@ object MessageConstraintsUtil {
@JvmStatic
fun isValidAdminDeleteReceive(targetMessage: MessageRecord, deleteSender: Recipient, deleteServerTimestamp: Long, groupRecord: GroupRecord): Boolean {
val isValidSender = groupRecord.isAdmin(deleteSender)
val messageTimestamp = targetMessage.dateSent
val messageTimestamp = if (targetMessage.isOutgoing) targetMessage.dateSent else targetMessage.serverTimestamp
return isValidSender && (deleteServerTimestamp - messageTimestamp < ADMIN_RECEIVE_THRESHOLD)
}
@@ -1354,7 +1354,7 @@ object RemoteConfig {
@JvmStatic
@get:JvmName("localPlaintextExport")
val localPlaintextExport: Boolean by remoteBoolean(
key = "android.localPlaintextExport.2",
key = "android.localPlaintextExport.3",
defaultValue = false,
hotSwappable = false
)
@@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
public final class SupportEmailUtil {
@@ -68,7 +69,9 @@ public final class SupportEmailUtil {
"\n" +
context.getString(R.string.SupportEmailUtil_registration_lock) + " " + getRegistrationLockEnabled() +
"\n" +
context.getString(R.string.SupportEmailUtil_locale) + " " + Locale.getDefault().toString();
context.getString(R.string.SupportEmailUtil_locale) + " " + Locale.getDefault().toString() +
"\n" +
context.getString(R.string.SupportEmailUtil_challenge_received) + " " + getChallengeReceived();
}
private static CharSequence getDeviceInfo() {
@@ -90,4 +93,11 @@ public final class SupportEmailUtil {
private static CharSequence getRegistrationLockEnabled() {
return String.valueOf(SignalStore.svr().isRegistrationLockEnabled());
}
private static String getChallengeReceived() {
long captchaLastViewedAt = SignalStore.misc().getCaptchaLastViewedAt();
boolean receivedRecently = captchaLastViewedAt > 0 && (System.currentTimeMillis() - captchaLastViewedAt) <= TimeUnit.DAYS.toMillis(3);
return receivedRecently ? "yes" : "no";
}
}
@@ -134,13 +134,19 @@ class VerifySafetyNumberViewModel(
val context: Context = AppDependencies.application
SignalExecutors.BOUNDED.execute {
val resolved = recipient.resolve()
if (resolved.aci.isEmpty) {
Log.w(TAG, "Cannot update safety number verification -- recipient has no ACI")
return@execute
}
ReentrantSessionLock.INSTANCE.acquire().use { _ ->
if (verified) {
Log.i(TAG, "Saving identity: $recipientId")
AppDependencies.protocolStore.aci().identities()
.saveIdentityWithoutSideEffects(
recipientId,
recipient.resolve().requireAci(),
resolved.requireAci(),
remoteIdentity,
IdentityTable.VerifiedStatus.VERIFIED,
false,
@@ -211,6 +211,22 @@ public abstract class AudioManagerCompat {
}
return false;
}
public boolean isHeadsetConnected() {
AudioDeviceInfo[] devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS);
for (AudioDeviceInfo device : devices) {
final int type = device.getType();
if (type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
type == AudioDeviceInfo.TYPE_USB_HEADSET ||
type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
Log.i(TAG, "Headset connected: " + type);
return true;
}
}
return false;
}
public float ringVolumeWithMinimum() {
int currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_RING);
@@ -38,12 +38,17 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import org.signal.core.ui.WindowBreakpoint
import org.signal.core.ui.compose.AllDevicePreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.getWindowBreakpoint
import org.signal.core.ui.isSplitPane
import org.signal.core.ui.isWidthExpanded
import org.thoughtcrime.securesms.main.MainFloatingActionButtonsCallback
import org.thoughtcrime.securesms.main.MainNavigationBar
import org.thoughtcrime.securesms.main.MainNavigationRail
@@ -57,14 +62,21 @@ enum class NavigationType {
companion object {
@Composable
fun rememberNavigationType(): NavigationType {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val resources = LocalResources.current
val config = LocalConfiguration.current
val windowBreakpoint = remember(config) { resources.getWindowBreakpoint() }
return remember(windowSizeClass) {
if (windowSizeClass.isSplitPane()) {
RAIL
} else {
BAR
return when (windowBreakpoint) {
WindowBreakpoint.SMALL -> BAR
WindowBreakpoint.MEDIUM -> {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
if (windowSizeClass.isWidthExpanded) {
RAIL
} else {
BAR
}
}
WindowBreakpoint.LARGE -> RAIL
}
}
}
@@ -265,7 +277,7 @@ private fun AppScaffoldPreview() {
AppScaffold(
navigator = rememberAppScaffoldNavigator(
isSplitPane = windowSizeClass.isSplitPane(),
isSplitPane = windowSizeClass.isSplitPane(false),
defaultPanePreferredWidth = 416.dp,
horizontalPartitionSpacerSize = 16.dp
),
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"
android:enterFadeDuration="100"
android:exitFadeDuration="100">
<item android:state_selected="true">
<inset
android:insetBottom="2dp"
android:insetLeft="12dp"
android:insetRight="12dp"
android:insetTop="2dp">
<shape android:shape="rectangle">
<solid android:color="@color/conversation_list_selected_color" />
<corners android:radius="18dp" />
</shape>
</inset>
</item>
<item>
<ripple android:color="@color/conversation_list_selected_color">
<item android:id="@android:id/mask">
<inset
android:insetBottom="2dp"
android:insetLeft="12dp"
android:insetRight="12dp"
android:insetTop="2dp">
<shape android:shape="rectangle">
<solid android:color="@color/transparent_black_60" />
<corners android:radius="18dp" />
</shape>
</inset>
</item>
</ripple>
</item>
</selector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android"
android:enterFadeDuration="100"
android:exitFadeDuration="100">
<item android:state_selected="true">
<color android:color="@color/transparent_black_20" />
</item>
<item>
<ripple android:color="@color/transparent_black_30">
<item android:id="@android:id/mask">
<color android:color="@android:color/white" />
</item>
</ripple>
</item>
</selector>
@@ -7,8 +7,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:paddingStart="32dp"
android:paddingEnd="32dp">
android:paddingStart="24dp"
android:paddingEnd="24dp">
<LinearLayout
android:id="@+id/conversation_update_background"
@@ -30,6 +30,7 @@
android:layout_height="wrap_content"
android:minHeight="0dp"
android:paddingVertical="6dp"
android:textSize="13sp"
android:background="@drawable/rounded_rectangle_38"
android:paddingHorizontal="16dp"
android:textColor="@color/signal_colorOnSurfaceVariant"
@@ -5,6 +5,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/media_overview_detail_item_background"
android:minHeight="@dimen/media_overview_detail_item_height">
<FrameLayout
@@ -5,6 +5,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/media_overview_detail_item_background"
android:minHeight="@dimen/media_overview_detail_item_height">
<FrameLayout
@@ -5,6 +5,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/media_overview_detail_item_background"
android:minHeight="@dimen/media_overview_detail_item_height">
<FrameLayout
@@ -5,6 +5,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/media_overview_detail_item_background"
android:minHeight="@dimen/media_overview_detail_item_height">
<FrameLayout
@@ -4,7 +4,8 @@
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:foreground="@drawable/media_overview_item_selected_foreground">
<org.thoughtcrime.securesms.components.ThumbnailView
android:id="@+id/image"
+46 -36
View File
@@ -517,7 +517,7 @@
<string name="ConversationActivity_unable_to_record_audio">Kan nie klank opneem nie!</string>
<string name="ConversationActivity_you_cant_send_messages_to_this_group">Jy kan nie boodskappe aan hierdie groep stuur nie, want jy is nie meer \'n lid nie.</string>
<!-- Removed by excludeNonTranslatables <string name="DisabledInputView__incognito_mode" translatable="false">Incognito mode (Labs)</string> -->
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">You can\'t send messages because the group has ended.</string>
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">Jy kan nie boodskappe stuur nie, want die groep is beëindig.</string>
<string name="ConversationActivity_only_s_can_send_messages">Slegs %1$s kan boodskappe stuur.</string>
<string name="ConversationActivity_admins">admins</string>
<string name="ConversationActivity_message_an_admin">Stuur n boodskap aan admin</string>
@@ -659,8 +659,8 @@
<string name="ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation">Jy kan notas vir jouself in hierdie klets byvoeg. Indien daar enige toestelle aan jou rekening gekoppel is, sal nuwe notas gesinchroniseer word.</string>
<!-- Text in banner to show how many people have the same name. -->
<plurals name="ConversationFragment__d_group_members_have_the_same_name">
<item quantity="one">%1$d group member have the same name.</item>
<item quantity="other">%1$d group members have the same name.</item>
<item quantity="one">%1$d groeplid het dieselfde naam.</item>
<item quantity="other">%1$d groeplede het dieselfde naam.</item>
</plurals>
<string name="ConversationFragment__tap_to_review">Tik om te hersien</string>
<!-- The body of a banner that can show up at the top of a chat, letting the user know that you have two contacts with the same name -->
@@ -986,7 +986,7 @@
<string name="BackupsPreferenceFragment__test_your_backup_passphrase">Toets jou wagwoordfrase en verifieer dat dit ooreenkom</string>
<string name="BackupsPreferenceFragment__turn_on">Skakel rugsteun aan</string>
<string name="BackupsPreferenceFragment__turn_off">Skakel af</string>
<string name="BackupsPreferenceFragment__to_restore_a_backup">"Om \'n rugsteun te herwin, installeer \'n nuwe kopie van Signal. Maak die toepassing oop, tik op \"Herwin vanaf rugsteun\" en spoor dan \'n rugsteunlêer op. %1$s"</string>
<string name="BackupsPreferenceFragment__to_restore_a_backup">"Om 'n rugsteun te herwin, installeer 'n nuwe kopie van Signal. Maak die toepassing oop, tik op \"Herwin vanaf rugsteun\" en spoor dan 'n rugsteunlêer op. %1$s"</string>
<string name="BackupsPreferenceFragment__learn_more">Vind meer uit</string>
<string name="BackupsPreferenceFragment__in_progress">Aan die gang…</string>
<!-- Status text shown in backup preferences when verifying a backup -->
@@ -1683,7 +1683,7 @@
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Netwerkfout teëgekom.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Hierdie groepskakel is nie aktief nie.</string>
<!-- Toast message shown when trying to join a group via link but the group has been ended -->
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Unable to join this group.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Kon nie by hierdie groep aansluit nie.</string>
<!-- Toast message shown when trying to join a group by link but the group is full -->
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">Groepbeperking bereik, kan nie by groep aansluit nie</string>
<!-- Title shown when there was an known issue getting group information from a group link -->
@@ -1832,6 +1832,7 @@
<string name="MediaOverviewActivity_sent_by_you_to_s">Deur jou aan %1$s gestuur</string>
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Hierdie media is nog nie gestuur nie.</string>
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
<!-- Megaphones -->
<string name="Megaphones_remind_me_later">Herinner my later</string>
@@ -1900,7 +1901,7 @@
<string name="MessageRecord_you_updated_group">Jy het die groep bygewerk.</string>
<string name="MessageRecord_the_group_was_updated">Die groep is bygewerk.</string>
<!-- Update message shown a group is terminated, but the person that terminated it is unknown. -->
<string name="MessageRecord_the_group_was_terminated">The group ended.</string>
<string name="MessageRecord_the_group_was_terminated">Die groep is beëindig.</string>
<!-- Update message shown when a group is terminated, placeholder is the name of the person that terminated the group. -->
<string name="MessageRecord_s_terminated_the_group">%1$s het die groep beëindig</string>
<!-- Update message shown when a group is terminated by you -->
@@ -1921,6 +1922,10 @@
<string name="MessageRecord_declined_voice_call">Stemoproep geweier</string>
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
<string name="MessageRecord_declined_video_call">Video-oproep geweier</string>
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
<string name="MessageRecord_unanswered_voice_call">Onbeantwoorde stemoproep</string>
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
<string name="MessageRecord_unanswered_video_call">Onbeantwoorde video-oproep</string>
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
<string name="MessageRecord_missed_voice_call_notification_profile">Gemiste stemoproep terwyl kennisgewingprofiel aan was</string>
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
@@ -3076,6 +3081,7 @@
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
<string name="SupportEmailUtil_registration_lock">Registrasieslot:</string>
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
<!-- ThreadRecord -->
<string name="ThreadRecord_group_updated">Groep bygewerk</string>
@@ -3581,7 +3587,7 @@
<string name="ContactSelectionListFragment_signal_requires_the_contacts_permission_in_order_to_display_your_contacts">Signal benodig Kontaktoestemming om jou kontakte te wys, maar dit is permanent geweier. Gaan asseblief na die toepassinginstelling-kieslys, kies \"Toestemmings\" en aktiveer \"Kontakte\".</string>
<string name="ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection">Fout met herwinning van kontakte, gaan jou netwerkverbinding na</string>
<string name="ContactSelectionListFragment_username_not_found">Gebruikersnaam nie gevind nie</string>
<string name="ContactSelectionListFragment_s_is_not_a_signal_user">"\"%1$s\" is nie \'n Signal-gebruiker nie. Kontroleer asb. die gebruikersnaam en probeer weer."</string>
<string name="ContactSelectionListFragment_s_is_not_a_signal_user">"\"%1$s\" is nie 'n Signal-gebruiker nie. Kontroleer asb. die gebruikersnaam en probeer weer."</string>
<string name="ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group">Jy hoef nie jouself by die groep te voeg nie</string>
<string name="ContactSelectionListFragment_maximum_group_size_reached">Maksimum groepgrootte bereik.</string>
<string name="ContactSelectionListFragment_signal_groups_can_have_a_maximum_of_s_members">Signal-groepe kan \'n maksimum van %1$s lede hê.</string>
@@ -3637,7 +3643,7 @@
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">Sluit opname van oudio-aanhegsel</string>
<string name="conversation_activity__message_could_not_be_sent">Boodskap kon nie gestuur word nie. Kontroleer jou verbinding en probeer weer.</string>
<!-- Dialog body shown when tapping a failed message in a terminated group -->
<string name="conversation_activity__send_failed_group_ended">You can no longer send and receive messages in this group because the group has ended.</string>
<string name="conversation_activity__send_failed_group_ended">Jy kan nie meer boodskappe in hierdie groep stuur en ontvang nie, want hierdie groep is beëindig.</string>
<!-- Dialog body shown when tapping a group action button (e.g. invite friends) in a terminated group -->
<string name="conversation_activity__group_action_not_allowed_group_ended">Hierdie aksie is nie beskikbaar nie, want die groep is beëindig.</string>
<!-- Dialog body when a message failed to delete and retry is possible. -->
@@ -3695,23 +3701,23 @@
<string name="ConversationUpdateItem_update">Werk by</string>
<!-- Update item button text to show how many group updates there are. -->
<plurals name="CollapsedEvent__group_update">
<item quantity="one">%1$d group update</item>
<item quantity="other">%1$d group updates</item>
<item quantity="one">%1$d groepopdatering</item>
<item quantity="other">%1$d groepopdaterings</item>
</plurals>
<!-- Update item button text to show how many group updates there are. -->
<plurals name="CollapsedEvent__chat_update">
<item quantity="one">%1$d chat update</item>
<item quantity="other">%1$d chat updates</item>
<item quantity="one">%1$d kletsopdatering</item>
<item quantity="other">%1$d kletsopdaterings</item>
</plurals>
<!-- Update item button text to show how many disappearing message timer changes are. %2$s is what the timer was ultimately set to.-->
<plurals name="CollapsedEvent__disappearing_timer">
<item quantity="one">%1$d disappearing message timer change · %2$s</item>
<item quantity="other">%1$d disappearing message timer changes · %2$s</item>
<item quantity="one">%1$d verandering in verdwynboodskap-tydhouer · %2$s</item>
<item quantity="other">%1$d veranderinge in verdwynboodskap-tydhouer · %2$s</item>
</plurals>
<!-- Update item button text to show how many call events are. -->
<plurals name="CollapsedEvent__call_event">
<item quantity="one">%1$d call event</item>
<item quantity="other">%1$d call events</item>
<item quantity="one">%1$d oproepgebeurtenis</item>
<item quantity="other">%1$d oproepgebeurtenisse</item>
</plurals>
<!-- audio_view -->
@@ -5749,35 +5755,35 @@
<!-- Row description for the plaintext chat export option -->
<string name="ChatsSettingsFragment__export_chat_history_label">Voer \'n masjienleesbare JSON-kopie van al jou kletse uit. Verdwynende boodskappe sal nie uitgevoer word nie.</string>
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Ontsluit om kletsgeskiedenis uit te voer</string>
<!-- Snackbar shown when biometric authentication fails before a chat export -->
<string name="ChatsSettingsFragment__authentication_failed">Authentication failed</string>
<string name="ChatsSettingsFragment__authentication_failed">Bevestiging het misluk</string>
<!-- ChatExportDialogs -->
<!-- Progress dialog message shown while canceling an in-progress chat export -->
<string name="ChatExportDialogs__canceling_export">Canceling export</string>
<string name="ChatExportDialogs__canceling_export">Besig om uitvoer te kanselleer</string>
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
<string name="ChatExportDialogs__export_chat_history_title">Voer kletsgeskiedenis uit?</string>
<!-- Bold warning prefix in the export confirmation dialog body -->
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
<string name="ChatExportDialogs__be_careful_warning">WEES VERSIGTIG!</string>
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
<string name="ChatExportDialogs__export_confirm_body">Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size.</string>
<string name="ChatExportDialogs__export_confirm_body">Moenie hierdie lêer met enigiemand deel nie. Jou kletsgeskiedenis sal op jou toestel gestoor word, en ander toepassings kan toegang daartoe verkry, na gelang van jou toestel se toestemmings. Uitvoer met media sal \'n groter lêergrootte tot gevolg hê.</string>
<!-- Button in the export confirmation dialog to export messages and media -->
<string name="ChatExportDialogs__export_with_media">Export with media</string>
<string name="ChatExportDialogs__export_with_media">Voer uit met media</string>
<!-- Button in the export confirmation dialog to export messages only -->
<string name="ChatExportDialogs__export_without_media">Export without media</string>
<string name="ChatExportDialogs__export_without_media">Voer uit sonder media</string>
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_title">Kies \'n vouer</string>
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
<string name="ChatExportDialogs__choose_a_folder_body">Kies \'n vouer in jou toestel se stoorruimte waar jou kletsgeskiedenis gestoor sal word.</string>
<!-- Button that opens the system folder picker -->
<string name="ChatExportDialogs__choose_folder_button">Kies vouer</string>
<!-- Title of the dialog shown when a chat export finishes successfully -->
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
<string name="ChatExportDialogs__complete_title">Kletsuitvoer voltooi</string>
<!-- Bold warning prefix in the export complete dialog body -->
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
<string name="ChatExportDialogs__be_careful">WEES VERSIGTIG</string>
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
<string name="ChatExportDialogs__complete_body">where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions.</string>
<string name="ChatExportDialogs__complete_body">waar jy jou klets-uitvoerlêer stoor, en moet dit met niemand deel nie. Ander toepassings op jou toestel kan toegang daartoe verkry, na gelang van jou foon se toestemmings.</string>
<!-- ChatFoldersEducationSheet -->
<!-- Text in a bottom sheet describing chat folders and what they can be created for -->
@@ -6124,7 +6130,7 @@
<!-- End group option in group conversation settings screen -->
<string name="ConversationSettingsFragment__end_group">Beëindig groep</string>
<!-- Banner shown at the top of group settings when the group has been ended -->
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
<string name="ConversationSettingsFragment__this_group_was_ended">Hierdie groep is beëindig.</string>
<!-- Archive chat option in group conversation settings screen -->
<string name="ConversationSettingsFragment__archive_chat">Argiveer klets</string>
<!-- Delete chat option in group conversation settings screen -->
@@ -6941,7 +6947,7 @@
<!-- Message of dialog to confirm deletion of story -->
<string name="MyStories__this_story_will_be_deleted">Hierdie storie sal geskrap word vir jou en almal wat dit ontvang het.</string>
<!-- Message of dialog to confirm deletion of story in a terminated group -->
<string name="MyStories__delete_story_terminated_group">It will only be deleted for you because the group has ended.</string>
<string name="MyStories__delete_story_terminated_group">Dit sal slegs vir jou geskrap word, want die groep is beëindig.</string>
<!-- Toast shown when story media cannot be saved -->
<string name="MyStories__unable_to_save">Kon nie stoor nie</string>
@@ -7796,6 +7802,8 @@
<string name="CallLogAdapter__incoming">Inkomend</string>
<!-- Displayed for outgoing calls -->
<string name="CallLogAdapter__outgoing">Uitgaande</string>
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
<string name="CallLogAdapter__unanswered">Unanswered</string>
<!-- Displayed for missed calls -->
<string name="CallLogAdapter__missed">Gemis</string>
<!-- Displayed for one missed call declined by notification profile -->
@@ -7880,6 +7888,8 @@
<string name="CallLogFragment__get_started_by_calling_a_friend">Begin deur \'n vriend te bel.</string>
<!-- Displayed as a message in a dialog when deleting multiple items -->
<string name="CallLogFragment__call_links_youve_created">Oproepskakels wat jy geskep het, sal nie meer werk vir mense wat dit het nie.</string>
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
<!-- New call activity -->
<!-- Activity title in title bar -->
@@ -8193,7 +8203,7 @@
<string name="UseNewOnDeviceBackups__not_now">Nie nou nie</string>
<!-- Text describing how to restore a backup displayed on the on-device backups screen -->
<string name="OnDeviceBackupsScreen__to_restore_a_backup">"Om \'n rugsteun te herwin, installeer \'n nuwe kopie van Signal. Maak die toepassing oop, tik op \"Herwin rugsteun\" en vind dan die rugsteunlêer."</string>
<string name="OnDeviceBackupsScreen__to_restore_a_backup">"Om 'n rugsteun te herwin, installeer 'n nuwe kopie van Signal. Maak die toepassing oop, tik op \"Herwin rugsteun\" en vind dan die rugsteunlêer."</string>
<!-- Title of a megaphone shown to prompt the user to verify their recovery key -->
<string name="VerifyBackupKey__title">Verifieer jou herwinsleutel</string>
@@ -8618,14 +8628,14 @@
<!-- Progress message shown while ending a group -->
<string name="EndGroupDialog__ending_group">Besig om groep te beëindig…</string>
<!-- Message shown when ending a group fails -->
<string name="EndGroupDialog__ending_the_group_failed">Couldn\'t end the group. Check your connection and try again.</string>
<string name="EndGroupDialog__ending_the_group_failed">Kon nie die groep beëindig nie. Gaan jou verbinding na en probeer weer.</string>
<!-- Retry button shown when ending a group fails -->
<string name="EndGroupDialog__try_again">Probeer weer</string>
<!-- Title of bottom sheet shown when opening a terminated group for the first time, with admin name. %1$s is the admin\'s display name -->
<string name="TerminatedGroupBottomSheet__s_ended_the_group">%1$s het die Groep beëindig</string>
<!-- Title of bottom sheet shown when opening a terminated group for the first time, without admin name -->
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">The group has ended.</string>
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">Die groep is beëindig.</string>
<!-- Body of bottom sheet shown when opening a terminated group -->
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">Jy kan nie meer boodskappe of oproepe in hierdie groep stuur en ontvang nie.</string>
@@ -8997,7 +9007,7 @@
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">Dit is dieselfde as die herwinsleutel op jou toestel.</string>
<!-- Screen body shown when enabling on-device backups while Signal Secure Backups are already enabled (part 2, bolded). -->
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">This is the same as your Signal Secure Backups recovery key.</string>
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">Dis dieselfde as jou herstelsleutel vir Signal se Veilige Rugsteun.</string>
<!-- Label shown above a list of actions that the recovery key can be used for. -->
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Gebruik hierdie sleutel om:</string>
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
@@ -9802,13 +9812,13 @@
<!-- Removed by excludeNonTranslatables <string name="AddGroupDetailsFragment__groups_with_same_members" translatable="false">Groups with same members (Labs)</string> -->
<!-- Title of the sheet shown when a local backup restore could not be completed -->
<string name="CouldNotCompleteBackupRestoreSheet__title">Can\'t restore backup</string>
<string name="CouldNotCompleteBackupRestoreSheet__title">Kan nie rugsteun herwin nie</string>
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining the likely cause -->
<string name="CouldNotCompleteBackupRestoreSheet__body_error">An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring.</string>
<string name="CouldNotCompleteBackupRestoreSheet__body_error">\'n Fout het voorgekom en jou rugsteun kan nie herwin word nie. Dit kan wees omdat die rugsteunvouer op jou toestel verskuif is terwyl herwinning van jou rugsteun aan die gang was.</string>
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining how to try again -->
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\".</string>
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">Om weer te probeer, deïnstalleer en herinstalleer Signal op hierdie toestel en kies \"Herwin of dra oor\".</string>
<!-- EOF -->
</resources>
+57 -47
View File
@@ -537,7 +537,7 @@
<string name="ConversationActivity_unable_to_record_audio">تعذَّر تسجيل الصوت!</string>
<string name="ConversationActivity_you_cant_send_messages_to_this_group">لا تستطيع إرسال الرسائل لهذه المجموعة لأنك لم تعد عضوًا فيها.</string>
<!-- Removed by excludeNonTranslatables <string name="DisabledInputView__incognito_mode" translatable="false">Incognito mode (Labs)</string> -->
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">You can\'t send messages because the group has ended.</string>
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">لا يمكنك إرسال رسائل لأن المجموعة أُغلِقت.</string>
<string name="ConversationActivity_only_s_can_send_messages">يمكن فقط لـ %1$s إرسال الرسائل.</string>
<string name="ConversationActivity_admins">المُشرِفون</string>
<string name="ConversationActivity_message_an_admin">مراسلة أحد المُشرِفين</string>
@@ -1875,7 +1875,7 @@
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">حدث خطأ فى الشبكة.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">رابط هذه المجموعة غير مُفعَّل.</string>
<!-- Toast message shown when trying to join a group via link but the group has been ended -->
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Unable to join this group.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">تعذَّر الانضمام إلى هذه المجموعة.</string>
<!-- Toast message shown when trying to join a group by link but the group is full -->
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">حد المجموعة اكتمل. تعذَّر الانضمام إلى المجموعة.</string>
<!-- Title shown when there was an known issue getting group information from a group link -->
@@ -2052,6 +2052,7 @@
<string name="MediaOverviewActivity_sent_by_you_to_s">مرسل منك إلى %1$s</string>
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">لم يتم إرسال ملف الوسائط بعد.</string>
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
<!-- Megaphones -->
<string name="Megaphones_remind_me_later">ذكِّرني لاحقًا</string>
@@ -2120,7 +2121,7 @@
<string name="MessageRecord_you_updated_group">قمتَ بتحديث المجموعة.</string>
<string name="MessageRecord_the_group_was_updated">تمَّ تحديث المجموعة.</string>
<!-- Update message shown a group is terminated, but the person that terminated it is unknown. -->
<string name="MessageRecord_the_group_was_terminated">The group ended.</string>
<string name="MessageRecord_the_group_was_terminated">أُغلِقت المجموعة.</string>
<!-- Update message shown when a group is terminated, placeholder is the name of the person that terminated the group. -->
<string name="MessageRecord_s_terminated_the_group">%1$s أنهى المجموعة</string>
<!-- Update message shown when a group is terminated by you -->
@@ -2141,6 +2142,10 @@
<string name="MessageRecord_declined_voice_call">مكالمة صوتية مرفوضة</string>
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
<string name="MessageRecord_declined_video_call">مكالمة فيديو مرفوضة</string>
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
<string name="MessageRecord_unanswered_voice_call">مكالمة صوتية لم يتم الرد عليها</string>
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
<string name="MessageRecord_unanswered_video_call">مكالمة فيديو لم يتم الرد عليها</string>
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
<string name="MessageRecord_missed_voice_call_notification_profile">مكالمة صوتية فائتة بينما كان ملف الإشعارات في وضع تشغيل</string>
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
@@ -3468,6 +3473,7 @@
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
<string name="SupportEmailUtil_registration_lock">قفل التسجيل:</string>
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
<!-- ThreadRecord -->
<string name="ThreadRecord_group_updated">تمَّ تحديث المجموعة</string>
@@ -4073,7 +4079,7 @@
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">قفل تسجيل المُرفَق الصوتي</string>
<string name="conversation_activity__message_could_not_be_sent">لم تُرسَل الرسالة. عليك التأكُّد من اتصالك بالانترنت ثم المحاولة مُجدَّدًا.</string>
<!-- Dialog body shown when tapping a failed message in a terminated group -->
<string name="conversation_activity__send_failed_group_ended">You can no longer send and receive messages in this group because the group has ended.</string>
<string name="conversation_activity__send_failed_group_ended">لا يمكنك إرسال وتلقّي الرسائل في هذه المجموعة لأن المجموعة أُغلِقت.</string>
<!-- Dialog body shown when tapping a group action button (e.g. invite friends) in a terminated group -->
<string name="conversation_activity__group_action_not_allowed_group_ended">هذا الإجراء غير متوفر لأنه تمَّ إنهاء المجموعة.</string>
<!-- Dialog body when a message failed to delete and retry is possible. -->
@@ -4131,39 +4137,39 @@
<string name="ConversationUpdateItem_update">تحديث</string>
<!-- Update item button text to show how many group updates there are. -->
<plurals name="CollapsedEvent__group_update">
<item quantity="zero">%1$d group updates</item>
<item quantity="one">%1$d group update</item>
<item quantity="two">%1$d group updates</item>
<item quantity="few">%1$d group updates</item>
<item quantity="many">%1$d group updates</item>
<item quantity="other">%1$d group updates</item>
<item quantity="zero">%1$d تحديثات للمجموعة</item>
<item quantity="one">%1$d تحديث للمجموعة</item>
<item quantity="two">%1$d تحديثان للمجموعة</item>
<item quantity="few">%1$d تحديثات للمجموعة</item>
<item quantity="many">%1$d تحديثًا للمجموعة</item>
<item quantity="other">%1$d تحديثٍ للمجموعة</item>
</plurals>
<!-- Update item button text to show how many group updates there are. -->
<plurals name="CollapsedEvent__chat_update">
<item quantity="zero">%1$d chat updates</item>
<item quantity="one">%1$d chat update</item>
<item quantity="two">%1$d chat updates</item>
<item quantity="few">%1$d chat updates</item>
<item quantity="many">%1$d chat updates</item>
<item quantity="other">%1$d chat updates</item>
<item quantity="zero">%1$d تحديثات للدردشة</item>
<item quantity="one">%1$d تحديث للدردشة</item>
<item quantity="two">%1$d تحديثان للدردشة</item>
<item quantity="few">%1$d تحديثات للدردشة</item>
<item quantity="many">%1$d تحديثًا للدردشة</item>
<item quantity="other">%1$d تحديثٍ للدردشة</item>
</plurals>
<!-- Update item button text to show how many disappearing message timer changes are. %2$s is what the timer was ultimately set to.-->
<plurals name="CollapsedEvent__disappearing_timer">
<item quantity="zero">%1$d disappearing message timer changes · %2$s</item>
<item quantity="one">%1$d disappearing message timer change · %2$s</item>
<item quantity="two">%1$d disappearing message timer changes · %2$s</item>
<item quantity="few">%1$d disappearing message timer changes · %2$s</item>
<item quantity="many">%1$d disappearing message timer changes · %2$s</item>
<item quantity="other">%1$d disappearing message timer changes · %2$s</item>
<item quantity="zero">%1$d تغييرات في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
<item quantity="one">%1$d تغيير في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
<item quantity="two">%1$d تغييران في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
<item quantity="few">%1$d تغييرات في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
<item quantity="many">%1$d تغييرًا في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
<item quantity="other">%1$d تغييرٍ في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
</plurals>
<!-- Update item button text to show how many call events are. -->
<plurals name="CollapsedEvent__call_event">
<item quantity="zero">%1$d call events</item>
<item quantity="one">%1$d call event</item>
<item quantity="two">%1$d call events</item>
<item quantity="few">%1$d call events</item>
<item quantity="many">%1$d call events</item>
<item quantity="other">%1$d call events</item>
<item quantity="zero">%1$d أحداث مكالمة</item>
<item quantity="one">%1$d حدث مكالمة</item>
<item quantity="two">%1$d حدثا مكالمة</item>
<item quantity="few">%1$d أحداث مكالمة</item>
<item quantity="many">%1$d حدث مكالمة</item>
<item quantity="other">%1$d حدث مكالمة</item>
</plurals>
<!-- audio_view -->
@@ -6313,35 +6319,35 @@
<!-- Row description for the plaintext chat export option -->
<string name="ChatsSettingsFragment__export_chat_history_label">تصدير نسخة JSON قابلة للقراءة آليًا لجميع دردشاتك. لن يتم تصدير الرسائل المؤقَّتة.</string>
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">فتح القفل لتصدير سِجل الدردشة</string>
<!-- Snackbar shown when biometric authentication fails before a chat export -->
<string name="ChatsSettingsFragment__authentication_failed">فشلت المُصادَقة.</string>
<!-- ChatExportDialogs -->
<!-- Progress dialog message shown while canceling an in-progress chat export -->
<string name="ChatExportDialogs__canceling_export">Canceling export</string>
<string name="ChatExportDialogs__canceling_export">جارٍ إلغاء التصدير</string>
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
<string name="ChatExportDialogs__export_chat_history_title">هل ترغبُ بتصدير سِجل الدردشة؟</string>
<!-- Bold warning prefix in the export confirmation dialog body -->
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
<string name="ChatExportDialogs__be_careful_warning">انتبه!</string>
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
<string name="ChatExportDialogs__export_confirm_body">Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size.</string>
<string name="ChatExportDialogs__export_confirm_body">لا تشارِك هذا الملف مع أي أحد. سيتم حفظ سِجل الدردشة الخاص بك في جهازك وقد تتمكن التطبيقات الأخرى من الوصول إليه حسب أذونات جهازك. التصدير مع الوسائط قد ينتج عنه حجم ملف أكبر.</string>
<!-- Button in the export confirmation dialog to export messages and media -->
<string name="ChatExportDialogs__export_with_media">Export with media</string>
<string name="ChatExportDialogs__export_with_media">التصدير مع الوسائط</string>
<!-- Button in the export confirmation dialog to export messages only -->
<string name="ChatExportDialogs__export_without_media">Export without media</string>
<string name="ChatExportDialogs__export_without_media">التصدير دون الوسائط</string>
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_title">اِختر مجلد</string>
<string name="ChatExportDialogs__choose_a_folder_title">اختر مجلد</string>
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
<string name="ChatExportDialogs__choose_a_folder_body">اختر مجلدًا في مساحة تخزين جهازك حيث سيتم تخرين سِجل دردشتك.</string>
<!-- Button that opens the system folder picker -->
<string name="ChatExportDialogs__choose_folder_button">اختر مجلدًا</string>
<!-- Title of the dialog shown when a chat export finishes successfully -->
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
<string name="ChatExportDialogs__complete_title">اكتملَ تصدير الدردشة</string>
<!-- Bold warning prefix in the export complete dialog body -->
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
<string name="ChatExportDialogs__be_careful">انتبه</string>
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
<string name="ChatExportDialogs__complete_body">where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions.</string>
<string name="ChatExportDialogs__complete_body">أين تُخزِّن ملف تصدير دردشتك ولا تشاركه مع أي أحد. قد تتمكّن تطبيقات أخرى في جهازك من الوصول إليها حسب الأذونات في جهازك.</string>
<!-- ChatFoldersEducationSheet -->
<!-- Text in a bottom sheet describing chat folders and what they can be created for -->
@@ -6712,7 +6718,7 @@
<!-- End group option in group conversation settings screen -->
<string name="ConversationSettingsFragment__end_group">إنهاء المجموعة</string>
<!-- Banner shown at the top of group settings when the group has been ended -->
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
<string name="ConversationSettingsFragment__this_group_was_ended">أُغلِقت هذه المجموعة.</string>
<!-- Archive chat option in group conversation settings screen -->
<string name="ConversationSettingsFragment__archive_chat">أرشفة الدردشة</string>
<!-- Delete chat option in group conversation settings screen -->
@@ -7565,7 +7571,7 @@
<!-- Message of dialog to confirm deletion of story -->
<string name="MyStories__this_story_will_be_deleted">ستُحذَف هذه القصة من عند جميع من استلمها بما فيهم أنت.</string>
<!-- Message of dialog to confirm deletion of story in a terminated group -->
<string name="MyStories__delete_story_terminated_group">It will only be deleted for you because the group has ended.</string>
<string name="MyStories__delete_story_terminated_group">ستُحذف لديك فقط لأن المجموعة أُغلِقت.</string>
<!-- Toast shown when story media cannot be saved -->
<string name="MyStories__unable_to_save">تعذَّر الحفظ</string>
@@ -8508,6 +8514,8 @@
<string name="CallLogAdapter__incoming">واردة</string>
<!-- Displayed for outgoing calls -->
<string name="CallLogAdapter__outgoing">صادرة</string>
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
<string name="CallLogAdapter__unanswered">Unanswered</string>
<!-- Displayed for missed calls -->
<string name="CallLogAdapter__missed">فائتة</string>
<!-- Displayed for one missed call declined by notification profile -->
@@ -8604,6 +8612,8 @@
<string name="CallLogFragment__get_started_by_calling_a_friend">ابدأ بالاتصال بصديق.</string>
<!-- Displayed as a message in a dialog when deleting multiple items -->
<string name="CallLogFragment__call_links_youve_created">لن تشتغل روابط المكالمات التي أنشأتها بالنسبة للأشخاص الذين حصلوا عليها.</string>
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
<!-- New call activity -->
<!-- Activity title in title bar -->
@@ -9366,14 +9376,14 @@
<!-- Progress message shown while ending a group -->
<string name="EndGroupDialog__ending_group">جارٍ إنهاء المجموعة…</string>
<!-- Message shown when ending a group fails -->
<string name="EndGroupDialog__ending_the_group_failed">Couldn\'t end the group. Check your connection and try again.</string>
<string name="EndGroupDialog__ending_the_group_failed">تعذَّر إغلاق المجموعة. تحقَّق من اتصالك بالشبكة ثم حاوِل مرّة أخرى.</string>
<!-- Retry button shown when ending a group fails -->
<string name="EndGroupDialog__try_again">حاوِل مُجدَّدًا</string>
<!-- Title of bottom sheet shown when opening a terminated group for the first time, with admin name. %1$s is the admin\'s display name -->
<string name="TerminatedGroupBottomSheet__s_ended_the_group">%1$s أنهى المجموعة</string>
<!-- Title of bottom sheet shown when opening a terminated group for the first time, without admin name -->
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">The group has ended.</string>
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">أُغلِقت هذه المجموعة.</string>
<!-- Body of bottom sheet shown when opening a terminated group -->
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">لم يبق بإمكانك إرسال الرسائل واستلامها والتواصل بالمكالمات في هذه المجموعة.</string>
@@ -9769,7 +9779,7 @@
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">هذا المفتاح مثل مفتاح الاستعادة على الجهاز الخاص بك.</string>
<!-- Screen body shown when enabling on-device backups while Signal Secure Backups are already enabled (part 2, bolded). -->
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">This is the same as your Signal Secure Backups recovery key.</string>
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">هذا المفتاح مثل مفتاح استعادة النسخ الاحتياطية الآمنة الخاصة بك.</string>
<!-- Label shown above a list of actions that the recovery key can be used for. -->
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">استخدِم هذا المفتاح من أجل:</string>
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
@@ -10594,13 +10604,13 @@
<!-- Removed by excludeNonTranslatables <string name="AddGroupDetailsFragment__groups_with_same_members" translatable="false">Groups with same members (Labs)</string> -->
<!-- Title of the sheet shown when a local backup restore could not be completed -->
<string name="CouldNotCompleteBackupRestoreSheet__title">Can\'t restore backup</string>
<string name="CouldNotCompleteBackupRestoreSheet__title">تعذَّرت استعادة النسخة الاحتياطية</string>
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining the likely cause -->
<string name="CouldNotCompleteBackupRestoreSheet__body_error">An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring.</string>
<string name="CouldNotCompleteBackupRestoreSheet__body_error">حدث خطأ وتعذَّر استعادة نسختك الاحتياطية. قد يكون هذا بسبب نقل مجلد النسخة الاحتياطية على جهازك بينما كانت النسخة الاحتياطية قيد الاستعادة.</string>
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining how to try again -->
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\".</string>
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">للمحاولة من جديد، قُم بإلغاء تثبيت وإعادة تثبيت سيجنال على هذا الجهاز، واختر \"الاستعادة أو النقل\".</string>
<!-- EOF -->
</resources>
+43 -33
View File
@@ -517,7 +517,7 @@
<string name="ConversationActivity_unable_to_record_audio">Səs yazıla bilmir!</string>
<string name="ConversationActivity_you_cant_send_messages_to_this_group">Üzv olmadığınız üçün bu qrupa mesaj göndərə bilməzsiniz.</string>
<!-- Removed by excludeNonTranslatables <string name="DisabledInputView__incognito_mode" translatable="false">Incognito mode (Labs)</string> -->
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">You can\'t send messages because the group has ended.</string>
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">Qrup sonlandırıldığı üçün mesajlar göndərə bilmirsiniz.</string>
<string name="ConversationActivity_only_s_can_send_messages">Yalnız %1$s mesaj göndərə bilər.</string>
<string name="ConversationActivity_admins">adminlər</string>
<string name="ConversationActivity_message_an_admin">Bir adminə mesaj yazın</string>
@@ -659,8 +659,8 @@
<string name="ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation">Bu çatda özünüzə qeydlər yaza bilərsiniz. Hesabınızla əlaqələndirilmiş cihazlar varsa, yeni qeydlər sinxronlaşdırılacaq.</string>
<!-- Text in banner to show how many people have the same name. -->
<plurals name="ConversationFragment__d_group_members_have_the_same_name">
<item quantity="one">%1$d group member have the same name.</item>
<item quantity="other">%1$d group members have the same name.</item>
<item quantity="one">%1$d qrup üzvünün adı eynidir.</item>
<item quantity="other">%1$d qrup üzvünün adı eynidir.</item>
</plurals>
<string name="ConversationFragment__tap_to_review">Nəzərdən keçirmək üçün toxunun</string>
<!-- The body of a banner that can show up at the top of a chat, letting the user know that you have two contacts with the same name -->
@@ -1683,7 +1683,7 @@
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Şəbəkə xətası ilə qarşılaşıldı.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Qrup bağlantısı aktiv deyil</string>
<!-- Toast message shown when trying to join a group via link but the group has been ended -->
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Unable to join this group.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Bu qrupa qoşulmaq mümkün olmadı.</string>
<!-- Toast message shown when trying to join a group by link but the group is full -->
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">Qrup limiti dolub, qrupa qoşulmaq mümkün deyil</string>
<!-- Title shown when there was an known issue getting group information from a group link -->
@@ -1832,6 +1832,7 @@
<string name="MediaOverviewActivity_sent_by_you_to_s">%1$s əlaqəsinə göndərdiniz</string>
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Bu media faylı hələ göndərilməyib.</string>
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
<!-- Megaphones -->
<string name="Megaphones_remind_me_later">Daha sonra xatırlat</string>
@@ -1900,7 +1901,7 @@
<string name="MessageRecord_you_updated_group">Qrupu yenilədiniz.</string>
<string name="MessageRecord_the_group_was_updated">Qrup yeniləndi.</string>
<!-- Update message shown a group is terminated, but the person that terminated it is unknown. -->
<string name="MessageRecord_the_group_was_terminated">The group ended.</string>
<string name="MessageRecord_the_group_was_terminated">Qrup sonlandırıldı.</string>
<!-- Update message shown when a group is terminated, placeholder is the name of the person that terminated the group. -->
<string name="MessageRecord_s_terminated_the_group">%1$s qrupu sonlandırdı</string>
<!-- Update message shown when a group is terminated by you -->
@@ -1921,6 +1922,10 @@
<string name="MessageRecord_declined_voice_call">İmtina edilmiş audio-zəng</string>
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
<string name="MessageRecord_declined_video_call">İmtina edilmiş video-zəng</string>
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
<string name="MessageRecord_unanswered_voice_call">Cavablanmamış audio zəng</string>
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
<string name="MessageRecord_unanswered_video_call">Cavablanmamış video zəng</string>
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
<string name="MessageRecord_missed_voice_call_notification_profile">Profil statusu aktiv ikən buraxılmış səsli zəng</string>
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
@@ -3076,6 +3081,7 @@
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
<string name="SupportEmailUtil_registration_lock">Qeydiyyat kilidi:</string>
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
<!-- ThreadRecord -->
<string name="ThreadRecord_group_updated">Qrup yeniləndi</string>
@@ -3637,7 +3643,7 @@
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">Səsyazmanı kilidlə</string>
<string name="conversation_activity__message_could_not_be_sent">Mesaj göndərilə bilmir. Bağlantınızı yoxlayıb yenidən sınayın.</string>
<!-- Dialog body shown when tapping a failed message in a terminated group -->
<string name="conversation_activity__send_failed_group_ended">You can no longer send and receive messages in this group because the group has ended.</string>
<string name="conversation_activity__send_failed_group_ended">Bu qrup sonlandırıldığına görə artıq burada mesajlaşa bilməyəcəksiniz.</string>
<!-- Dialog body shown when tapping a group action button (e.g. invite friends) in a terminated group -->
<string name="conversation_activity__group_action_not_allowed_group_ended">Bu addım mövcud deyil, çünki qrup sonlandırılıb.</string>
<!-- Dialog body when a message failed to delete and retry is possible. -->
@@ -3695,23 +3701,23 @@
<string name="ConversationUpdateItem_update">Yenilə</string>
<!-- Update item button text to show how many group updates there are. -->
<plurals name="CollapsedEvent__group_update">
<item quantity="one">%1$d group update</item>
<item quantity="other">%1$d group updates</item>
<item quantity="one">%1$d qrup yeniləməsi</item>
<item quantity="other">%1$d qrup yeniləməsi</item>
</plurals>
<!-- Update item button text to show how many group updates there are. -->
<plurals name="CollapsedEvent__chat_update">
<item quantity="one">%1$d chat update</item>
<item quantity="other">%1$d chat updates</item>
<item quantity="one">%1$d çat yeniləməsi</item>
<item quantity="other">%1$d çat yeniləməsi</item>
</plurals>
<!-- Update item button text to show how many disappearing message timer changes are. %2$s is what the timer was ultimately set to.-->
<plurals name="CollapsedEvent__disappearing_timer">
<item quantity="one">%1$d disappearing message timer change · %2$s</item>
<item quantity="other">%1$d disappearing message timer changes · %2$s</item>
<item quantity="one">%1$d avtomatik yox olan mesaj taymeri dəyişdi · %2$s</item>
<item quantity="other">%1$d avtomatik yox olan mesaj taymeri dəyişdi · %2$s</item>
</plurals>
<!-- Update item button text to show how many call events are. -->
<plurals name="CollapsedEvent__call_event">
<item quantity="one">%1$d call event</item>
<item quantity="other">%1$d call events</item>
<item quantity="one">%1$d zəng etmə</item>
<item quantity="other">%1$d zəng etmə</item>
</plurals>
<!-- audio_view -->
@@ -5749,35 +5755,35 @@
<!-- Row description for the plaintext chat export option -->
<string name="ChatsSettingsFragment__export_chat_history_label">Bütün çatların maşın tərəfindən oxuna bilən JSON kopiyasını ixrac et. Avtomatik yox olan mesajlar ixrac edilməyəvək.</string>
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Çat tarixçəsini ixrac etmək üçün kilidi açın</string>
<!-- Snackbar shown when biometric authentication fails before a chat export -->
<string name="ChatsSettingsFragment__authentication_failed">Authentication failed</string>
<string name="ChatsSettingsFragment__authentication_failed">Autentifikasiya baş tutmadı</string>
<!-- ChatExportDialogs -->
<!-- Progress dialog message shown while canceling an in-progress chat export -->
<string name="ChatExportDialogs__canceling_export">Canceling export</string>
<string name="ChatExportDialogs__canceling_export">İxrac ləğv olunur</string>
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
<string name="ChatExportDialogs__export_chat_history_title">Çat tarixçəsi ixrac edilsin?</string>
<!-- Bold warning prefix in the export confirmation dialog body -->
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
<string name="ChatExportDialogs__be_careful_warning">EHTİYATLI OLUN!</string>
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
<string name="ChatExportDialogs__export_confirm_body">Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size.</string>
<string name="ChatExportDialogs__export_confirm_body">Bu faylı heç kimlə PAYLAŞMAYIN. Çat tarixçəsi cihazınızda saxlanacaq və cihaz icazələrindən asılı olaraq digər tətbiqlər ona daxil ola bilər. Media ilə ixrac edildikdə fayl ölçüsü daha böyük olacaq.</string>
<!-- Button in the export confirmation dialog to export messages and media -->
<string name="ChatExportDialogs__export_with_media">Export with media</string>
<string name="ChatExportDialogs__export_with_media">Media faylı ilə ixrac et</string>
<!-- Button in the export confirmation dialog to export messages only -->
<string name="ChatExportDialogs__export_without_media">Export without media</string>
<string name="ChatExportDialogs__export_without_media">Media faylı olmadan ixrac et</string>
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_title">Bir qovluq seç</string>
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
<string name="ChatExportDialogs__choose_a_folder_body">Cihaz yaddaşında çat tarixçəsinin saxlanacağı qovluğu seçin.</string>
<!-- Button that opens the system folder picker -->
<string name="ChatExportDialogs__choose_folder_button">Qovluq seçin</string>
<!-- Title of the dialog shown when a chat export finishes successfully -->
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
<string name="ChatExportDialogs__complete_title">Çat ixracı tamamlandı</string>
<!-- Bold warning prefix in the export complete dialog body -->
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
<string name="ChatExportDialogs__be_careful">EHTİYATLI OLUN</string>
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
<string name="ChatExportDialogs__complete_body">where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions.</string>
<string name="ChatExportDialogs__complete_body">çatı ixrac etdiyiniz faylı saxladığınız yeri diqqətlə seçin və onu heç kimlə paylaşmayın. Cihazınızdakı digər tətbiqlər cihaz icazələrindən asılı olaraq ona daxil ola bilər.</string>
<!-- ChatFoldersEducationSheet -->
<!-- Text in a bottom sheet describing chat folders and what they can be created for -->
@@ -6124,7 +6130,7 @@
<!-- End group option in group conversation settings screen -->
<string name="ConversationSettingsFragment__end_group">Qrupu sonlandır</string>
<!-- Banner shown at the top of group settings when the group has been ended -->
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
<string name="ConversationSettingsFragment__this_group_was_ended">Bu qrup sonlandırılıb.</string>
<!-- Archive chat option in group conversation settings screen -->
<string name="ConversationSettingsFragment__archive_chat">Çatı arxivləşdir</string>
<!-- Delete chat option in group conversation settings screen -->
@@ -6941,7 +6947,7 @@
<!-- Message of dialog to confirm deletion of story -->
<string name="MyStories__this_story_will_be_deleted">Bu hekayə siz və bunu alan hər kəs üçün silinəcək.</string>
<!-- Message of dialog to confirm deletion of story in a terminated group -->
<string name="MyStories__delete_story_terminated_group">It will only be deleted for you because the group has ended.</string>
<string name="MyStories__delete_story_terminated_group">Qrup sonlandırıldığına görə bu yalnız sizin üçün silinəcək.</string>
<!-- Toast shown when story media cannot be saved -->
<string name="MyStories__unable_to_save">Yaddaşda saxlamaq mümkün deyil</string>
@@ -7796,6 +7802,8 @@
<string name="CallLogAdapter__incoming">Gələn zəng</string>
<!-- Displayed for outgoing calls -->
<string name="CallLogAdapter__outgoing">Gedən zəng</string>
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
<string name="CallLogAdapter__unanswered">Unanswered</string>
<!-- Displayed for missed calls -->
<string name="CallLogAdapter__missed">Cavabsız zəng</string>
<!-- Displayed for one missed call declined by notification profile -->
@@ -7880,6 +7888,8 @@
<string name="CallLogFragment__get_started_by_calling_a_friend">Bir dosta zəng edərək başlayın.</string>
<!-- Displayed as a message in a dialog when deleting multiple items -->
<string name="CallLogFragment__call_links_youve_created">Yaratdığınız zəng keçidlərini paylaşdığınız insanlar artıq bu keçidləri görə bilməyəcək.</string>
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
<!-- New call activity -->
<!-- Activity title in title bar -->
@@ -8618,14 +8628,14 @@
<!-- Progress message shown while ending a group -->
<string name="EndGroupDialog__ending_group">Qrup sonlandırılır…</string>
<!-- Message shown when ending a group fails -->
<string name="EndGroupDialog__ending_the_group_failed">Couldn\'t end the group. Check your connection and try again.</string>
<string name="EndGroupDialog__ending_the_group_failed">Qrupu sonlandırmaq mümkün olmadı. Bağlantınızı yoxlayıb yenidən cəhd edin.</string>
<!-- Retry button shown when ending a group fails -->
<string name="EndGroupDialog__try_again">Yenidən cəhd edin</string>
<!-- Title of bottom sheet shown when opening a terminated group for the first time, with admin name. %1$s is the admin\'s display name -->
<string name="TerminatedGroupBottomSheet__s_ended_the_group">%1$s Qrupu sonlandırdı</string>
<!-- Title of bottom sheet shown when opening a terminated group for the first time, without admin name -->
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">The group has ended.</string>
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">Bu qrup sonlandırılıb.</string>
<!-- Body of bottom sheet shown when opening a terminated group -->
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">Bu qrupda artıq mesajlaşa və ya zənglər edə bilməyəcəksiniz.</string>
@@ -8997,7 +9007,7 @@
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">Bu, cihazdakı bərpa şifrənizlə eynidir.</string>
<!-- Screen body shown when enabling on-device backups while Signal Secure Backups are already enabled (part 2, bolded). -->
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">This is the same as your Signal Secure Backups recovery key.</string>
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">Bu, Signal təhlükəsiz ehtiyat nüsxələrinin bərpa şifrəsi ilə eynidir.</string>
<!-- Label shown above a list of actions that the recovery key can be used for. -->
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Bu şifrədən aşağıdakılar üçün istifadə edin:</string>
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
@@ -9802,13 +9812,13 @@
<!-- Removed by excludeNonTranslatables <string name="AddGroupDetailsFragment__groups_with_same_members" translatable="false">Groups with same members (Labs)</string> -->
<!-- Title of the sheet shown when a local backup restore could not be completed -->
<string name="CouldNotCompleteBackupRestoreSheet__title">Can\'t restore backup</string>
<string name="CouldNotCompleteBackupRestoreSheet__title">Ehtiyat nüsxəni bərpa etmək mümkün deyil</string>
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining the likely cause -->
<string name="CouldNotCompleteBackupRestoreSheet__body_error">An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring.</string>
<string name="CouldNotCompleteBackupRestoreSheet__body_error">Xəta baş verdiyi üçün ehtiyat nüsxəniz bərpa oluna bilmir. Bunun səbəbi ehtiyat nüsxəniz bərpa olunarkən onun qovluğunun yerinin cihazınızda dəyişdirilməsi ilə bağlı ola bilər.</string>
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining how to try again -->
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\".</string>
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">Yenidən cəhd etmək üçün bu cihazda Signal-ın quraşdırmasını ləğv edib, təkrar quraşdırın və \"Bərpa et və ya köçür\" seçimini edin.</string>
<!-- EOF -->
</resources>
+52 -42
View File
@@ -527,7 +527,7 @@
<string name="ConversationActivity_unable_to_record_audio">Немагчыма запісаць аўдыё!</string>
<string name="ConversationActivity_you_cant_send_messages_to_this_group">Вы не можаце адпраўляць паведамленні ў гэтую групу, таму што вы больш не з\'яўляецеся яе ўдзельнікам.</string>
<!-- Removed by excludeNonTranslatables <string name="DisabledInputView__incognito_mode" translatable="false">Incognito mode (Labs)</string> -->
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">You can\'t send messages because the group has ended.</string>
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">Вы не можаце адпраўляць паведамленні, таму што гэта група спынена.</string>
<string name="ConversationActivity_only_s_can_send_messages">Толькі %1$s можа адпраўляць паведамленні.</string>
<string name="ConversationActivity_admins">адміністратары</string>
<string name="ConversationActivity_message_an_admin">Напісаць паведамленне адміністратару</string>
@@ -685,7 +685,7 @@
<string name="ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation">Вы можаце дадаваць нататкі для сябе ў гэтым чаце. Калі ваш уліковы запіс мае звязаныя прылады, новыя нататкі будуць сінхранізаваны.</string>
<!-- Text in banner to show how many people have the same name. -->
<plurals name="ConversationFragment__d_group_members_have_the_same_name">
<item quantity="one">%1$d удзельнік групы мае тое самае імя.</item>
<item quantity="one">%1$d удзельнік групы мае аднолькавае імя.</item>
<item quantity="few">%1$d удзельнікі групы маюць аднолькавыя імёны.</item>
<item quantity="many">%1$d удзельнікаў групы маюць аднолькавыя імёны.</item>
<item quantity="other">%1$d удзельнікаў групы маюць аднолькавыя імёны.</item>
@@ -1779,7 +1779,7 @@
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Адбылася памылка сеткі.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Гэта спасылка на групу неактыўная</string>
<!-- Toast message shown when trying to join a group via link but the group has been ended -->
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Unable to join this group.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Немагчыма далучыцца да гэтай групы.</string>
<!-- Toast message shown when trying to join a group by link but the group is full -->
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">Ліміт групы дасягнуты: далучыцца да яе немагчыма</string>
<!-- Title shown when there was an known issue getting group information from a group link -->
@@ -1942,6 +1942,7 @@
<string name="MediaOverviewActivity_sent_by_you_to_s">Адпраўлена вамі да %1$s</string>
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Гэты медыяфайл яшчэ не адпраўлены.</string>
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
<!-- Megaphones -->
<string name="Megaphones_remind_me_later">Нагадаць мне пазней</string>
@@ -2010,7 +2011,7 @@
<string name="MessageRecord_you_updated_group">Вы абнавілі групу.</string>
<string name="MessageRecord_the_group_was_updated">Група абноўленая.</string>
<!-- Update message shown a group is terminated, but the person that terminated it is unknown. -->
<string name="MessageRecord_the_group_was_terminated">The group ended.</string>
<string name="MessageRecord_the_group_was_terminated">Група была спынена.</string>
<!-- Update message shown when a group is terminated, placeholder is the name of the person that terminated the group. -->
<string name="MessageRecord_s_terminated_the_group">%1$s спыніў(-ла) гэту групу</string>
<!-- Update message shown when a group is terminated by you -->
@@ -2031,6 +2032,10 @@
<string name="MessageRecord_declined_voice_call">Галасавы выклік адхілены</string>
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
<string name="MessageRecord_declined_video_call">Відэавыклік адхілены</string>
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
<string name="MessageRecord_unanswered_voice_call">Неадказаны галасавы званок</string>
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
<string name="MessageRecord_unanswered_video_call">Неадказаны відэазванок</string>
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
<string name="MessageRecord_missed_voice_call_notification_profile">Прапушчаны галасавы званок, калі ўключаны профіль апавяшчэнняў</string>
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
@@ -3272,6 +3277,7 @@
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
<string name="SupportEmailUtil_registration_lock">Блакіроўка рэгістрацыі:</string>
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
<!-- ThreadRecord -->
<string name="ThreadRecord_group_updated">Група абноўленая</string>
@@ -3855,7 +3861,7 @@
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">Заблакаваць запіс аўдыядалучэння</string>
<string name="conversation_activity__message_could_not_be_sent">Не атрымалася адправіць паведамленне. Праверце падключэнне і паўтарыце спробу.</string>
<!-- Dialog body shown when tapping a failed message in a terminated group -->
<string name="conversation_activity__send_failed_group_ended">You can no longer send and receive messages in this group because the group has ended.</string>
<string name="conversation_activity__send_failed_group_ended">Вы больш не зможаце адпраўляць і атрымліваць паведамленні ў гэтай групе, таму што група была спынена.</string>
<!-- Dialog body shown when tapping a group action button (e.g. invite friends) in a terminated group -->
<string name="conversation_activity__group_action_not_allowed_group_ended">Гэта дзеянне недаступнае, бо гэта група спынена.</string>
<!-- Dialog body when a message failed to delete and retry is possible. -->
@@ -3913,31 +3919,31 @@
<string name="ConversationUpdateItem_update">Абнавіць</string>
<!-- Update item button text to show how many group updates there are. -->
<plurals name="CollapsedEvent__group_update">
<item quantity="one">%1$d group update</item>
<item quantity="few">%1$d group updates</item>
<item quantity="many">%1$d group updates</item>
<item quantity="other">%1$d group updates</item>
<item quantity="one">%1$d абнаўленне ў групе</item>
<item quantity="few">%1$d абнаўленнi ў групе</item>
<item quantity="many">%1$d абнаўленняў у групе</item>
<item quantity="other">%1$d абнаўленняў у групе</item>
</plurals>
<!-- Update item button text to show how many group updates there are. -->
<plurals name="CollapsedEvent__chat_update">
<item quantity="one">%1$d chat update</item>
<item quantity="few">%1$d chat updates</item>
<item quantity="many">%1$d chat updates</item>
<item quantity="other">%1$d chat updates</item>
<item quantity="one">%1$d абнаўленне ў чаце</item>
<item quantity="few">%1$d абнаўленнi ў чаце</item>
<item quantity="many">%1$d абнаўленняў у чаце</item>
<item quantity="other">%1$d абнаўленняў у чаце</item>
</plurals>
<!-- Update item button text to show how many disappearing message timer changes are. %2$s is what the timer was ultimately set to.-->
<plurals name="CollapsedEvent__disappearing_timer">
<item quantity="one">%1$d disappearing message timer change · %2$s</item>
<item quantity="few">%1$d disappearing message timer changes · %2$s</item>
<item quantity="many">%1$d disappearing message timer changes · %2$s</item>
<item quantity="other">%1$d disappearing message timer changes · %2$s</item>
<item quantity="one">%1$d змена таймера знікнення паведамленняў · %2$s</item>
<item quantity="few">%1$d змены таймера знікнення паведамленняў · %2$s</item>
<item quantity="many">%1$d зменаў таймера знікнення паведамленняў · %2$s</item>
<item quantity="other">%1$d зменаў таймера знікнення паведамленняў · %2$s</item>
</plurals>
<!-- Update item button text to show how many call events are. -->
<plurals name="CollapsedEvent__call_event">
<item quantity="one">%1$d call event</item>
<item quantity="few">%1$d call events</item>
<item quantity="many">%1$d call events</item>
<item quantity="other">%1$d call events</item>
<item quantity="one">%1$d падзея званка</item>
<item quantity="few">%1$d падзеi званка</item>
<item quantity="many">%1$d падзей званка</item>
<item quantity="other">%1$d падзей званка</item>
</plurals>
<!-- audio_view -->
@@ -6027,39 +6033,39 @@
</plurals>
<!-- Row title for the option to export chat history as a plaintext archive -->
<string name="ChatsSettingsFragment__export_chat_history">Export chat history</string>
<string name="ChatsSettingsFragment__export_chat_history">Экспартаваць гісторыю чата</string>
<!-- Row description for the plaintext chat export option -->
<string name="ChatsSettingsFragment__export_chat_history_label">Export a machine-readable JSON copy of all your chats. Disappearing messages will not be exported.</string>
<string name="ChatsSettingsFragment__export_chat_history_label">Экспартуйце копію ўсіх вашых чатаў у фармаце JSON, які счытвае электронныя прылады. Знікаючыя паведамлення не будуць экспартаваны.</string>
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Разблакіруйце, каб экспартаваць гісторыю чата</string>
<!-- Snackbar shown when biometric authentication fails before a chat export -->
<string name="ChatsSettingsFragment__authentication_failed">Памылка аўтэнтыфікацыі</string>
<!-- ChatExportDialogs -->
<!-- Progress dialog message shown while canceling an in-progress chat export -->
<string name="ChatExportDialogs__canceling_export">Canceling export</string>
<string name="ChatExportDialogs__canceling_export">Скасаванне экспарту</string>
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
<string name="ChatExportDialogs__export_chat_history_title">Export chat history?</string>
<string name="ChatExportDialogs__export_chat_history_title">Экспартаваць гісторыю чата?</string>
<!-- Bold warning prefix in the export confirmation dialog body -->
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
<string name="ChatExportDialogs__be_careful_warning">БУДЗЬЦЕ ПIЛЬНЫМI!</string>
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
<string name="ChatExportDialogs__export_confirm_body">Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size.</string>
<string name="ChatExportDialogs__export_confirm_body">Нікому НЕ перадавайце гэты файл. Ваша гісторыя чата будзе захавана на вашай прыладзе. Іншыя праграмы могуць мець доступ да яе ў залежнасці ад наладжаных вамі дазволаў. Калi экспартаваць разам з медыяфайламi, памер файла можа павялічыцца.</string>
<!-- Button in the export confirmation dialog to export messages and media -->
<string name="ChatExportDialogs__export_with_media">Export with media</string>
<string name="ChatExportDialogs__export_with_media">Экспартаваць з медыяфайламi</string>
<!-- Button in the export confirmation dialog to export messages only -->
<string name="ChatExportDialogs__export_without_media">Export without media</string>
<string name="ChatExportDialogs__export_without_media">Экспартаваць без медыяфайлаў</string>
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_title">Выбраць папку</string>
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
<string name="ChatExportDialogs__choose_a_folder_body">Выберыце папку ў сховiшчы прылады, дзе будзе захоўвацца ваша гісторыя чата.</string>
<!-- Button that opens the system folder picker -->
<string name="ChatExportDialogs__choose_folder_button">Выбраць папку</string>
<!-- Title of the dialog shown when a chat export finishes successfully -->
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
<string name="ChatExportDialogs__complete_title">Экспарт чата адбыўся</string>
<!-- Bold warning prefix in the export complete dialog body -->
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
<string name="ChatExportDialogs__be_careful">БУДЗЬЦЕ ПIЛЬНЫМI,</string>
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
<string name="ChatExportDialogs__complete_body">where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions.</string>
<string name="ChatExportDialogs__complete_body">дзе вы захоўваеце файл экспарту вашага чата і нікому не перадавайце яго. Іншыя праграмы на вашай прыладзе могуць мець доступ да яго ў залежнасці ад наладжаных вамі дазволаў.</string>
<!-- ChatFoldersEducationSheet -->
<!-- Text in a bottom sheet describing chat folders and what they can be created for -->
@@ -6418,7 +6424,7 @@
<!-- End group option in group conversation settings screen -->
<string name="ConversationSettingsFragment__end_group">Спыніць групу</string>
<!-- Banner shown at the top of group settings when the group has been ended -->
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
<string name="ConversationSettingsFragment__this_group_was_ended">Гэта група была спынена.</string>
<!-- Archive chat option in group conversation settings screen -->
<string name="ConversationSettingsFragment__archive_chat">Архіваваць чат</string>
<!-- Delete chat option in group conversation settings screen -->
@@ -7253,7 +7259,7 @@
<!-- Message of dialog to confirm deletion of story -->
<string name="MyStories__this_story_will_be_deleted">Гэта гісторыя будзе выдалена для вас і ўсіх, хто яе атрымаў.</string>
<!-- Message of dialog to confirm deletion of story in a terminated group -->
<string name="MyStories__delete_story_terminated_group">It will only be deleted for you because the group has ended.</string>
<string name="MyStories__delete_story_terminated_group">Яна будзе выдалена толькі для вас, таму што група была спынена.</string>
<!-- Toast shown when story media cannot be saved -->
<string name="MyStories__unable_to_save">Не атрымалася захаваць</string>
@@ -8152,6 +8158,8 @@
<string name="CallLogAdapter__incoming">Уваходны</string>
<!-- Displayed for outgoing calls -->
<string name="CallLogAdapter__outgoing">Выходны</string>
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
<string name="CallLogAdapter__unanswered">Unanswered</string>
<!-- Displayed for missed calls -->
<string name="CallLogAdapter__missed">Прапушчаныя</string>
<!-- Displayed for one missed call declined by notification profile -->
@@ -8242,6 +8250,8 @@
<string name="CallLogFragment__get_started_by_calling_a_friend">Пачніце са званка сябру.</string>
<!-- Displayed as a message in a dialog when deleting multiple items -->
<string name="CallLogFragment__call_links_youve_created">Створаныя вамі спасылкі на званкі больш не будуць дзейнічаць для тых, хто мае іх.</string>
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
<!-- New call activity -->
<!-- Activity title in title bar -->
@@ -8992,14 +9002,14 @@
<!-- Progress message shown while ending a group -->
<string name="EndGroupDialog__ending_group">Спыняем групу…</string>
<!-- Message shown when ending a group fails -->
<string name="EndGroupDialog__ending_the_group_failed">Couldn\'t end the group. Check your connection and try again.</string>
<string name="EndGroupDialog__ending_the_group_failed">Не атрымалася спыніць гэту групу. Праверце падключэнне і паўтарыце спробу.</string>
<!-- Retry button shown when ending a group fails -->
<string name="EndGroupDialog__try_again">Паўтарыць спробу</string>
<!-- Title of bottom sheet shown when opening a terminated group for the first time, with admin name. %1$s is the admin\'s display name -->
<string name="TerminatedGroupBottomSheet__s_ended_the_group">%1$s спыніў(-ла) групу</string>
<!-- Title of bottom sheet shown when opening a terminated group for the first time, without admin name -->
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">The group has ended.</string>
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">Гэта група была спынена.</string>
<!-- Body of bottom sheet shown when opening a terminated group -->
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">Вы больш не зможаце пісаць і тэлефанаваць у гэтай групе.</string>
@@ -9383,7 +9393,7 @@
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">Гэта тое ж самае, што і ваш код для аднаўлення на прыладзе.</string>
<!-- Screen body shown when enabling on-device backups while Signal Secure Backups are already enabled (part 2, bolded). -->
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">This is the same as your Signal Secure Backups recovery key.</string>
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">Гэта тое ж самае, што і ваш код для аднаўлення бяспечнага рэзервовага капіравання.</string>
<!-- Label shown above a list of actions that the recovery key can be used for. -->
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Выкарыстоўвайце гэты ключ, каб:</string>
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
@@ -10198,13 +10208,13 @@
<!-- Removed by excludeNonTranslatables <string name="AddGroupDetailsFragment__groups_with_same_members" translatable="false">Groups with same members (Labs)</string> -->
<!-- Title of the sheet shown when a local backup restore could not be completed -->
<string name="CouldNotCompleteBackupRestoreSheet__title">Can\'t restore backup</string>
<string name="CouldNotCompleteBackupRestoreSheet__title">Не атрымалася аднавіць рэзервовую копію</string>
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining the likely cause -->
<string name="CouldNotCompleteBackupRestoreSheet__body_error">An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring.</string>
<string name="CouldNotCompleteBackupRestoreSheet__body_error">Узнікла памылка, таму рэзервовую копію немагчыма аднавіць. Магчыма, гэта адбылося таму, што падчас аднаўлення рэзервовай копіі папка з рэзервовай копіяй была перамешчана на вашу прыладу.</string>
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining how to try again -->
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\".</string>
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">Каб паспрабаваць яшчэ раз, выдаліце і пераўсталюйце Signal на гэтай прыладзе і потым выберыце «Аднавіць або перанесці».</string>
<!-- EOF -->
</resources>
+45 -35
View File
@@ -517,7 +517,7 @@
<string name="ConversationActivity_unable_to_record_audio">Не може да бъде записано аудио!</string>
<string name="ConversationActivity_you_cant_send_messages_to_this_group">Не може да изпращате съобщения на тази група, защото вече не сте неин член.</string>
<!-- Removed by excludeNonTranslatables <string name="DisabledInputView__incognito_mode" translatable="false">Incognito mode (Labs)</string> -->
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">You can\'t send messages because the group has ended.</string>
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">Не можете да изпращате съобщения, защото групата е прекратена.</string>
<string name="ConversationActivity_only_s_can_send_messages">Само %1$s могат да изпращат съобщения.</string>
<string name="ConversationActivity_admins">администратори</string>
<string name="ConversationActivity_message_an_admin">Пиши на администратор</string>
@@ -659,8 +659,8 @@
<string name="ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation">В този чат можете да добавяте бележки за себе си. Ако акаунтът ви има свързани устройства, новите бележки ще бъдат синхронизирани.</string>
<!-- Text in banner to show how many people have the same name. -->
<plurals name="ConversationFragment__d_group_members_have_the_same_name">
<item quantity="one">%1$d group member have the same name.</item>
<item quantity="other">%1$d group members have the same name.</item>
<item quantity="one">%1$d член на групата е със същото име.</item>
<item quantity="other">%1$d членове на групата са със същото име.</item>
</plurals>
<string name="ConversationFragment__tap_to_review">Натиснете за преглед</string>
<!-- The body of a banner that can show up at the top of a chat, letting the user know that you have two contacts with the same name -->
@@ -1683,7 +1683,7 @@
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Възникна мрежова грешка.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Тази групова връзка не е активна</string>
<!-- Toast message shown when trying to join a group via link but the group has been ended -->
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Unable to join this group.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Неуспешно присъединяване към тази група.</string>
<!-- Toast message shown when trying to join a group by link but the group is full -->
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">Лимитът на групата е достигнат, не можете да се присъедините към групата</string>
<!-- Title shown when there was an known issue getting group information from a group link -->
@@ -1832,6 +1832,7 @@
<string name="MediaOverviewActivity_sent_by_you_to_s">Изпратено от тебе до %1$s</string>
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Тази мултимедия все още не е изпратена.</string>
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
<!-- Megaphones -->
<string name="Megaphones_remind_me_later">Напомнете ми по-късно</string>
@@ -1900,7 +1901,7 @@
<string name="MessageRecord_you_updated_group">Обновихте групата.</string>
<string name="MessageRecord_the_group_was_updated">Групата беше актуализирана.</string>
<!-- Update message shown a group is terminated, but the person that terminated it is unknown. -->
<string name="MessageRecord_the_group_was_terminated">The group ended.</string>
<string name="MessageRecord_the_group_was_terminated">Групата е прекратена.</string>
<!-- Update message shown when a group is terminated, placeholder is the name of the person that terminated the group. -->
<string name="MessageRecord_s_terminated_the_group">%1$s прекрати групата</string>
<!-- Update message shown when a group is terminated by you -->
@@ -1921,6 +1922,10 @@
<string name="MessageRecord_declined_voice_call">Отказано гласово обаждане</string>
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
<string name="MessageRecord_declined_video_call">Отказано видео обаждане</string>
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
<string name="MessageRecord_unanswered_voice_call">Гласово повикване без отговор</string>
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
<string name="MessageRecord_unanswered_video_call">Видео повикване без отговор</string>
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
<string name="MessageRecord_missed_voice_call_notification_profile">Пропуснато гласово обаждане при включен профил за известия</string>
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
@@ -3076,6 +3081,7 @@
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
<string name="SupportEmailUtil_registration_lock">Регистрационно заключване:</string>
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
<!-- ThreadRecord -->
<string name="ThreadRecord_group_updated">Групата е обновена</string>
@@ -3637,7 +3643,7 @@
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">Заключване на запис на прикачен файл с аудио</string>
<string name="conversation_activity__message_could_not_be_sent">Съобщението не можа да бъде изпратено. Проверете връзката си и опитайте отново.</string>
<!-- Dialog body shown when tapping a failed message in a terminated group -->
<string name="conversation_activity__send_failed_group_ended">You can no longer send and receive messages in this group because the group has ended.</string>
<string name="conversation_activity__send_failed_group_ended">Вече не можете да изпращате и получавате съобщения в тази група, защото групата е била прекратена.</string>
<!-- Dialog body shown when tapping a group action button (e.g. invite friends) in a terminated group -->
<string name="conversation_activity__group_action_not_allowed_group_ended">Това действие не е достъпно, защото групата е прекратена.</string>
<!-- Dialog body when a message failed to delete and retry is possible. -->
@@ -3695,23 +3701,23 @@
<string name="ConversationUpdateItem_update">Обновяване</string>
<!-- Update item button text to show how many group updates there are. -->
<plurals name="CollapsedEvent__group_update">
<item quantity="one">%1$d group update</item>
<item quantity="other">%1$d group updates</item>
<item quantity="one">%1$d актуализация в групата</item>
<item quantity="other">%1$d актуализации в групата</item>
</plurals>
<!-- Update item button text to show how many group updates there are. -->
<plurals name="CollapsedEvent__chat_update">
<item quantity="one">%1$d chat update</item>
<item quantity="other">%1$d chat updates</item>
<item quantity="one">%1$d актуализация в чата</item>
<item quantity="other">%1$d актуализации в чата</item>
</plurals>
<!-- Update item button text to show how many disappearing message timer changes are. %2$s is what the timer was ultimately set to.-->
<plurals name="CollapsedEvent__disappearing_timer">
<item quantity="one">%1$d disappearing message timer change · %2$s</item>
<item quantity="other">%1$d disappearing message timer changes · %2$s</item>
<item quantity="one">%1$d промяна в таймера за изчезващи съобщения · %2$s</item>
<item quantity="other">%1$d промени в таймера за изчезващи съобщения · %2$s</item>
</plurals>
<!-- Update item button text to show how many call events are. -->
<plurals name="CollapsedEvent__call_event">
<item quantity="one">%1$d call event</item>
<item quantity="other">%1$d call events</item>
<item quantity="one">%1$d събитие, свързано с обаждане</item>
<item quantity="other">%1$d събития, свързани с обаждания</item>
</plurals>
<!-- audio_view -->
@@ -5745,39 +5751,39 @@
</plurals>
<!-- Row title for the option to export chat history as a plaintext archive -->
<string name="ChatsSettingsFragment__export_chat_history">Експортиране на историята на чата</string>
<string name="ChatsSettingsFragment__export_chat_history">Експорт на историята на чата</string>
<!-- Row description for the plaintext chat export option -->
<string name="ChatsSettingsFragment__export_chat_history_label">Експортирайте машинно четимо JSON копие на всичките си чатове. Изчезващите съобщения няма да бъдат експортирани.</string>
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Отключете, за да експортирате историята на чата</string>
<!-- Snackbar shown when biometric authentication fails before a chat export -->
<string name="ChatsSettingsFragment__authentication_failed">Authentication failed</string>
<string name="ChatsSettingsFragment__authentication_failed">Неуспешна автентикация</string>
<!-- ChatExportDialogs -->
<!-- Progress dialog message shown while canceling an in-progress chat export -->
<string name="ChatExportDialogs__canceling_export">Canceling export</string>
<string name="ChatExportDialogs__canceling_export">Отмяна на експорт</string>
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
<string name="ChatExportDialogs__export_chat_history_title">Експортиране на историята на чата?</string>
<string name="ChatExportDialogs__export_chat_history_title">Експорт на историята на чата?</string>
<!-- Bold warning prefix in the export confirmation dialog body -->
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
<string name="ChatExportDialogs__be_careful_warning">ВНИМАВАЙТЕ!</string>
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
<string name="ChatExportDialogs__export_confirm_body">Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size.</string>
<string name="ChatExportDialogs__export_confirm_body">НЕ споделяйте този файл с никого. Вашата история на чата ще бъде запазена на вашето устройство и други приложения ще могат да осъществяват достъп до нея според разрешенията на вашето устройство. Експортирането с мултимедийните файлове ще направи размера на файла по-голям.</string>
<!-- Button in the export confirmation dialog to export messages and media -->
<string name="ChatExportDialogs__export_with_media">Export with media</string>
<string name="ChatExportDialogs__export_with_media">Експорт с мултимедийните файлове</string>
<!-- Button in the export confirmation dialog to export messages only -->
<string name="ChatExportDialogs__export_without_media">Export without media</string>
<string name="ChatExportDialogs__export_without_media">Експорт без мултимедийните файлове</string>
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_title">Изберете папка</string>
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
<string name="ChatExportDialogs__choose_a_folder_body">Изберете папка в хранилището на устройството си, в която да се съхранява вашата история на чата.</string>
<!-- Button that opens the system folder picker -->
<string name="ChatExportDialogs__choose_folder_button">Изберете папка</string>
<!-- Title of the dialog shown when a chat export finishes successfully -->
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
<string name="ChatExportDialogs__complete_title">Експортирането на чата завърши</string>
<!-- Bold warning prefix in the export complete dialog body -->
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
<string name="ChatExportDialogs__be_careful">ВНИМАВАЙТЕ</string>
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
<string name="ChatExportDialogs__complete_body">where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions.</string>
<string name="ChatExportDialogs__complete_body">къде съхранявате файла с експортираните чатове и не го споделяйте с никого. Други приложения на устройството ви може да осъществяват достъп до него в зависимост от разрешенията на вашето устройство.</string>
<!-- ChatFoldersEducationSheet -->
<!-- Text in a bottom sheet describing chat folders and what they can be created for -->
@@ -6124,7 +6130,7 @@
<!-- End group option in group conversation settings screen -->
<string name="ConversationSettingsFragment__end_group">Прекратяване на групата</string>
<!-- Banner shown at the top of group settings when the group has been ended -->
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
<string name="ConversationSettingsFragment__this_group_was_ended">Тази група е прекратена.</string>
<!-- Archive chat option in group conversation settings screen -->
<string name="ConversationSettingsFragment__archive_chat">Архивиране на чата</string>
<!-- Delete chat option in group conversation settings screen -->
@@ -6941,7 +6947,7 @@
<!-- Message of dialog to confirm deletion of story -->
<string name="MyStories__this_story_will_be_deleted">Тази история ще бъде изтрита за вас и за всички, които са я получили.</string>
<!-- Message of dialog to confirm deletion of story in a terminated group -->
<string name="MyStories__delete_story_terminated_group">It will only be deleted for you because the group has ended.</string>
<string name="MyStories__delete_story_terminated_group">Ще бъде изтрита само за вас, защото групата е била прекратена.</string>
<!-- Toast shown when story media cannot be saved -->
<string name="MyStories__unable_to_save">Неуспешно запазване</string>
@@ -7796,6 +7802,8 @@
<string name="CallLogAdapter__incoming">Входящи</string>
<!-- Displayed for outgoing calls -->
<string name="CallLogAdapter__outgoing">Изходящо</string>
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
<string name="CallLogAdapter__unanswered">Unanswered</string>
<!-- Displayed for missed calls -->
<string name="CallLogAdapter__missed">Пропуснато</string>
<!-- Displayed for one missed call declined by notification profile -->
@@ -7880,6 +7888,8 @@
<string name="CallLogFragment__get_started_by_calling_a_friend">Започнете, като се обадите на приятел.</string>
<!-- Displayed as a message in a dialog when deleting multiple items -->
<string name="CallLogFragment__call_links_youve_created">Създадените от вас линкове за повиквания повече няма да работят за хората, които ги имат.</string>
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
<!-- New call activity -->
<!-- Activity title in title bar -->
@@ -8618,14 +8628,14 @@
<!-- Progress message shown while ending a group -->
<string name="EndGroupDialog__ending_group">Прекратяване на групата…</string>
<!-- Message shown when ending a group fails -->
<string name="EndGroupDialog__ending_the_group_failed">Couldn\'t end the group. Check your connection and try again.</string>
<string name="EndGroupDialog__ending_the_group_failed">Неуспешно прекратяване на групата. Проверете връзката си и опитайте отново.</string>
<!-- Retry button shown when ending a group fails -->
<string name="EndGroupDialog__try_again">Опитайте отново</string>
<!-- Title of bottom sheet shown when opening a terminated group for the first time, with admin name. %1$s is the admin\'s display name -->
<string name="TerminatedGroupBottomSheet__s_ended_the_group">%1$s прекрати групата</string>
<!-- Title of bottom sheet shown when opening a terminated group for the first time, without admin name -->
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">The group has ended.</string>
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">Групата е прекратена.</string>
<!-- Body of bottom sheet shown when opening a terminated group -->
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">Вече не можете да изпращате и получавате съобщения или обаждания в тази група.</string>
@@ -8997,7 +9007,7 @@
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">Това е същото като ключа за възстановяване на устройството.</string>
<!-- Screen body shown when enabling on-device backups while Signal Secure Backups are already enabled (part 2, bolded). -->
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">This is the same as your Signal Secure Backups recovery key.</string>
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">Това е същото като вашия ключ за възстановяване за „Сигурни резервни копия“ на Signal.</string>
<!-- Label shown above a list of actions that the recovery key can be used for. -->
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Използвайте този ключ за:</string>
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
@@ -9802,13 +9812,13 @@
<!-- Removed by excludeNonTranslatables <string name="AddGroupDetailsFragment__groups_with_same_members" translatable="false">Groups with same members (Labs)</string> -->
<!-- Title of the sheet shown when a local backup restore could not be completed -->
<string name="CouldNotCompleteBackupRestoreSheet__title">Can\'t restore backup</string>
<string name="CouldNotCompleteBackupRestoreSheet__title">Неуспешно възстановяване на резервно копие</string>
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining the likely cause -->
<string name="CouldNotCompleteBackupRestoreSheet__body_error">An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring.</string>
<string name="CouldNotCompleteBackupRestoreSheet__body_error">Възникна грешка и вашето резервно копие не може да бъде възстановено. Възможно е папката с резервното копие на устройството ви да е била преместена, докато резервното копие се е възстановявало.</string>
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining how to try again -->
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\".</string>
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">За да опитате отново, деинсталирайте и преинсталирайте Signal на това устройство и изберете „Възстановяване или трансфер“.</string>
<!-- EOF -->
</resources>

Some files were not shown because too many files have changed in this diff Show More