Compare commits

..

59 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
318 changed files with 14230 additions and 2717 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ plugins {
apply(from = "static-ips.gradle.kts")
val canonicalVersionCode = 1675
val canonicalVersionName = "8.6.2"
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
)
@@ -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)..."
@@ -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)
@@ -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);
}
}
}
@@ -48,6 +48,8 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto
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() {
@@ -318,4 +320,16 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto
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)
}
}
}
@@ -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"
+13 -3
View File
@@ -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 -->
@@ -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>
@@ -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>
@@ -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>
+56 -46
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. -->
@@ -10597,10 +10607,10 @@
<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>
+10
View File
@@ -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>
@@ -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>
@@ -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 -->
+10
View File
@@ -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>
@@ -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>
@@ -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 -->
+10
View File
@@ -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>
@@ -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>
@@ -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 -->
+10
View File
@@ -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>
@@ -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>
@@ -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 -->
+52 -42
View File
@@ -527,7 +527,7 @@
<string name="ConversationActivity_unable_to_record_audio">Nije moguće snimiti zvuk!</string>
<string name="ConversationActivity_you_cant_send_messages_to_this_group">Ne možete slati poruke ovoj grupi jer više niste njen član.</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">Ne možete slati poruke jer je ova grupa okončana.</string>
<string name="ConversationActivity_only_s_can_send_messages">Samo %1$s može slati poruke.</string>
<string name="ConversationActivity_admins">administratori</string>
<string name="ConversationActivity_message_an_admin">Pošalji poruku administratoru</string>
@@ -685,10 +685,10 @@
<string name="ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation">U ovom chatu možete dodati bilješke za sebe. Ako Signal koristite na više uređaja, ove će bilješke biti sinhronizovane na njima.</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="few">%1$d group members have the same name.</item>
<item quantity="many">%1$d group members have the same name.</item>
<item quantity="other">%1$d group members have the same name.</item>
<item quantity="one">%1$d član grupe ima isto ime.</item>
<item quantity="few">%1$d člana grupe imaju isto ime.</item>
<item quantity="many">%1$d članova grupe imaju isto ime.</item>
<item quantity="other">%1$d članova grupe ima isto ime.</item>
</plurals>
<string name="ConversationFragment__tap_to_review">Dotaknite da provjerite</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 -->
@@ -1779,7 +1779,7 @@
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Došlo je do greške u mreži.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Ovaj link za grupu nije aktivan</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">Pridruživanje ovoj grupi nije uspjelo.</string>
<!-- Toast message shown when trying to join a group by link but the group is full -->
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">Dostignuto je ograničenje grupe, nije moguće pridružiti se grupi</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">Vi ste poslali za %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">Ovaj medijski sadržaj još nije poslan.</string>
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
<!-- Megaphones -->
<string name="Megaphones_remind_me_later">Podsjeti me kasnije</string>
@@ -2010,7 +2011,7 @@
<string name="MessageRecord_you_updated_group">Ažurirali ste grupu.</string>
<string name="MessageRecord_the_group_was_updated">Grupa je ažurirana.</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">Grupa je okončana.</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 je okončao/la grupu</string>
<!-- Update message shown when a group is terminated by you -->
@@ -2031,6 +2032,10 @@
<string name="MessageRecord_declined_voice_call">Odbijen poziv</string>
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
<string name="MessageRecord_declined_video_call">Odbijen video poziv</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">Propušteni glasovni poziv</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">Propušteni video poziv</string>
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
<string name="MessageRecord_missed_voice_call_notification_profile">Propušteni glasovni poziv dok je profil obavijesti uključen</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">Zaključavanje registracije:</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">Grupa ažurirana</string>
@@ -3855,7 +3861,7 @@
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">Zaključaj snimanje zvučnog priloga</string>
<string name="conversation_activity__message_could_not_be_sent">Nije bilo moguće poslati poruku. Provjerite svoju konekciju i pokušajte ponovo.</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">Više ne možete slati niti primati poruke u ovoj grupi jer je grupa okončana.</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">Ova radnja nije dostupna jer je grupa okončana.</string>
<!-- Dialog body when a message failed to delete and retry is possible. -->
@@ -3913,31 +3919,31 @@
<string name="ConversationUpdateItem_update">Ažuriraj</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 ažuriranje u grupi</item>
<item quantity="few">%1$d ažuriranja u grupi</item>
<item quantity="many">%1$d ažuriranja u grupi</item>
<item quantity="other">%1$d ažuriranja u grupi</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 ažuriranje u chatu</item>
<item quantity="few">%1$d ažuriranja u chatu</item>
<item quantity="many">%1$d ažuriranja u chatu</item>
<item quantity="other">%1$d ažuriranja u chatu</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 promjena tajmera za poruke koje nestaju · %2$s</item>
<item quantity="few">%1$d promjene tajmera za poruke koje nestaju · %2$s</item>
<item quantity="many">%1$d promjena tajmera za poruke koje nestaju · %2$s</item>
<item quantity="other">%1$d promjene tajmera za poruke koje nestaju · %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 događaj vezan za pozive</item>
<item quantity="few">%1$d događaja vezana za pozive</item>
<item quantity="many">%1$d događaja vezanih za pozive</item>
<item quantity="other">%1$d događaja vezana za pozive</item>
</plurals>
<!-- audio_view -->
@@ -6031,35 +6037,35 @@
<!-- Row description for the plaintext chat export option -->
<string name="ChatsSettingsFragment__export_chat_history_label">Izvezite mašinski čitljivu JSON kopiju svih vaših chatova. Poruke koje nestaju neće biti izvezene.</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">Otključajte da izvezete historiju chata</string>
<!-- Snackbar shown when biometric authentication fails before a chat export -->
<string name="ChatsSettingsFragment__authentication_failed">Authentication failed</string>
<string name="ChatsSettingsFragment__authentication_failed">Autentifikacija nije uspjela</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">Otkazivanje izvoza</string>
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
<string name="ChatExportDialogs__export_chat_history_title">Izvesti historiju chata?</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">BUDITE OPREZNI!</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">NE dijelite ovu datoteku ni sa kim. Vaša historija razgovora će se sačuvati na vašem uređaju, a druge aplikacije joj mogu pristupiti u zavisnosti od dozvola na vašem uređaju. Izvoz s medijskim sadržajem će za rezultat imati veću datoteku.</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">Izvezi s medijskim sadržajem</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">Izvezi bez medijskog sadržaja</string>
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_title">Odaberite folder</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">Odaberite folder u pohrani uređaja gdje će se pohranjivati vaša historija chata.</string>
<!-- Button that opens the system folder picker -->
<string name="ChatExportDialogs__choose_folder_button">Odaberi direktorij</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">Izvoz chata je završen</string>
<!-- Bold warning prefix in the export complete dialog body -->
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
<string name="ChatExportDialogs__be_careful">BUDITE OPREZNI</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">gdje pohranjujete svoju izvezenu datoteku za chat i ne dijelite je ni sa kim. Druge aplikacije na vašem uređaju mogu pristupiti ovisno o dopuštenjima vašeg uređaja.</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">Okončaj grupu</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">Ova grupa je okončana.</string>
<!-- Archive chat option in group conversation settings screen -->
<string name="ConversationSettingsFragment__archive_chat">Arhiviraj 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">Ova priča će biti izbrisana i za vas i za sve one koji su je primili.</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">Izbrisat će se za vas samo zato što je grupa okončana.</string>
<!-- Toast shown when story media cannot be saved -->
<string name="MyStories__unable_to_save">Nije moguće spremiti</string>
@@ -8152,6 +8158,8 @@
<string name="CallLogAdapter__incoming">Dolazni</string>
<!-- Displayed for outgoing calls -->
<string name="CallLogAdapter__outgoing">Odlazni</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">Propušteni</string>
<!-- Displayed for one missed call declined by notification profile -->
@@ -8242,6 +8250,8 @@
<string name="CallLogFragment__get_started_by_calling_a_friend">Započnite pozivom prijatelja.</string>
<!-- Displayed as a message in a dialog when deleting multiple items -->
<string name="CallLogFragment__call_links_youve_created">Poveznice za pozive koje ste kreirali više neće raditi za osobe koje ih imaju.</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">Okončavanje grupe…</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">Nije bilo moguće okončati grupu. Provjerite vezu i pokušajte ponovno.</string>
<!-- Retry button shown when ending a group fails -->
<string name="EndGroupDialog__try_again">Pokušajte ponovo</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 je završio/la grupu</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">Grupa je okončana.</string>
<!-- Body of bottom sheet shown when opening a terminated group -->
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">Više nećete moći slati niti primati poruke ili pozive u ovoj grupi.</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">Ovo je isto što i vaš ključ za oporavak na uređaju.</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">Ovo je isto kao ključ za oporavak zaštićene kopije podataka.</string>
<!-- Label shown above a list of actions that the recovery key can be used for. -->
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Koristite ovaj ključ za:</string>
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
@@ -10201,10 +10211,10 @@
<string name="CouldNotCompleteBackupRestoreSheet__title">Nije moguće vratiti sigurnosnu kopiju</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">Došlo je do greške i vaša sigurnosna kopija nije vraćena. To može biti zato što je folder sigurnosne kopije premješten na vašem uređaju tokom vraćanja.</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">Pokušajte ponovo tako što ćete deinstalirati i ponovo instalirati Signal na ovom uređaju, a zatim odabrati \"Vrati ili prenesi\".</string>
<!-- EOF -->
</resources>
+39 -29
View File
@@ -517,7 +517,7 @@
<string name="ConversationActivity_unable_to_record_audio">No s\'ha pogut enregistrar l\'àudio.</string>
<string name="ConversationActivity_you_cant_send_messages_to_this_group">No podeu enviar missatges a aquest grup perquè ja no en sou membre.</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">No pots enviar missatges perquè aquest grup s\'ha tancat.</string>
<string name="ConversationActivity_only_s_can_send_messages">Només els %1$s poden enviar missatges.</string>
<string name="ConversationActivity_admins">administradors</string>
<string name="ConversationActivity_message_an_admin">Envia un missatge a un administrador</string>
@@ -1683,7 +1683,7 @@
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Hi ha hagut un error de xarxa.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Aquest enllaç de grup no està actiu.</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">No pots unir-te a aquest grup.</string>
<!-- Toast message shown when trying to join a group by link but the group is full -->
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">S\'ha arribat al límit del grup, no pots unir-te al grup</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">Ho heu enviat a %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">Aquest arxiu encara no ha estat enviat.</string>
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
<!-- Megaphones -->
<string name="Megaphones_remind_me_later">Recorda-m\'ho més tard.</string>
@@ -1900,7 +1901,7 @@
<string name="MessageRecord_you_updated_group">Heu actualitzat el grup.</string>
<string name="MessageRecord_the_group_was_updated">S\'ha actualitzat el grup.</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">S\'ha tancat el grup.</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 ha tancat el grup</string>
<!-- Update message shown when a group is terminated by you -->
@@ -1921,6 +1922,10 @@
<string name="MessageRecord_declined_voice_call">Trucada de veu rebutjada</string>
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
<string name="MessageRecord_declined_video_call">Videotrucada rebutjada</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">Trucada sense contestar</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">Videotrucada sense contestar</string>
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
<string name="MessageRecord_missed_voice_call_notification_profile">Trucada de veu perduda mentre els ajustos de notificació estaven activats</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">Bloqueig de registre:</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">S\'ha actualitzat el grup</string>
@@ -3637,7 +3643,7 @@
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">Bloqueja la gravació d\'un adjunt d\'àudio</string>
<string name="conversation_activity__message_could_not_be_sent">No s\'ha pogut enviar el missatge. Comproveu la connexió i torneu-ho a provar.</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">Ja no pots enviar ni rebre missatges perquè aquest grup s\'ha tancat.</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">Aquesta acció no està disponible perquè s\'ha tancat el grup.</string>
<!-- Dialog body when a message failed to delete and retry is possible. -->
@@ -3695,23 +3701,23 @@
<string name="ConversationUpdateItem_update">Actualitza-la</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 actualització de grup</item>
<item quantity="other">%1$d actualitzacions de grup</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 actualització de xat</item>
<item quantity="other">%1$d actualitzacions de xat</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">S\'ha canviat el temporitzador de %1$d missatge a desaparèixer · %2$s</item>
<item quantity="other">S\'ha canviat el temporitzador de %1$d missatges a desaparèixer · %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 trucada</item>
<item quantity="other">%1$d trucades</item>
</plurals>
<!-- audio_view -->
@@ -5749,35 +5755,35 @@
<!-- Row description for the plaintext chat export option -->
<string name="ChatsSettingsFragment__export_chat_history_label">Exportar una còpia JSON llegible per màquina de tots els teus xats. Els missatges a desaparèixer no s\'exportaran.</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">Desbloqueja per exportar l\'historial del xat</string>
<!-- Snackbar shown when biometric authentication fails before a chat export -->
<string name="ChatsSettingsFragment__authentication_failed">Ha fallat l\'autenticació.</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">Cancel·lant exportació</string>
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
<string name="ChatExportDialogs__export_chat_history_title">Vols exportar l\'historial del xat?</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">VIGILA!</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">NO comparteixis aquest arxiu amb ningú. El teu historial del xat es desarà al dispositiu i, dacord amb els permisos que concedeixis, podrà ser accessible per altres aplicacions. Si l\'exportes amb els arxius multimèdia, la mida de l\'arxiu serà més gran.</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">Exportar amb arxius multimèdia</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">Exportar sense arxius multimèdia</string>
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_title">Tria una carpeta</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">Selecciona una carpeta al teu dispositiu on vulguis emmagatzemar l\'historial del xat.</string>
<!-- Button that opens the system folder picker -->
<string name="ChatExportDialogs__choose_folder_button">Trieu una carpeta</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">Exportació del xat completada</string>
<!-- Bold warning prefix in the export complete dialog body -->
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
<string name="ChatExportDialogs__be_careful">VIGILA</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">a on guardes el fitxer d\'exportació del xat i no el comparteixis amb ningú. Depenent dels permisos del teu dispositiu, altres aplicacions també podrien accedir-hi.</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">Tancar grup</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">S\'ha tancat el grup.</string>
<!-- Archive chat option in group conversation settings screen -->
<string name="ConversationSettingsFragment__archive_chat">Arxivar xat</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">Aquesta història se suprimirà per a tu i per a tots els que l\'han rebut.</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">Només s\'eliminarà per a tu perquè aquest grup s\'ha tancat.</string>
<!-- Toast shown when story media cannot be saved -->
<string name="MyStories__unable_to_save">No s\'ha pogut guardar</string>
@@ -7796,6 +7802,8 @@
<string name="CallLogAdapter__incoming">Entrant</string>
<!-- Displayed for outgoing calls -->
<string name="CallLogAdapter__outgoing">Sortint</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">Perduda</string>
<!-- Displayed for one missed call declined by notification profile -->
@@ -7880,6 +7888,8 @@
<string name="CallLogFragment__get_started_by_calling_a_friend">Comença trucant a algú.</string>
<!-- Displayed as a message in a dialog when deleting multiple items -->
<string name="CallLogFragment__call_links_youve_created">Els enllaços de trucada que hagis creat ja no funcionaran per a les persones que els tinguin.</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">Tancant grup…</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">No s\'ha pogut tancar el grup. Comprova la connexió i torna-ho a provar.</string>
<!-- Retry button shown when ending a group fails -->
<string name="EndGroupDialog__try_again">Torna a provar-ho</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 ha tancat el grup</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">S\'ha tancat el grup.</string>
<!-- Body of bottom sheet shown when opening a terminated group -->
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">Ja no pots enviar ni rebre missatges o trucades en aquest grup.</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">És la mateixa que la teva clau de còpia de seguretat local.</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">És la mateixa que la teva clau de recuperació de Còpies de seguretat segures de Signal.</string>
<!-- Label shown above a list of actions that the recovery key can be used for. -->
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Fes servir aquesta clau per a:</string>
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
@@ -9805,10 +9815,10 @@
<string name="CouldNotCompleteBackupRestoreSheet__title">No es pot restaurar la còpia de seguretat</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">S\'ha produït un error i no s\'ha pogut restaurar la teva còpia de seguretat. És possible que la carpeta on s\'emmagatzemen les còpies de seguretat al teu dispositiu s\'hagi mogut mentre la restauració estava en curs.</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">Per tornar-ho a provar, desinstal·la i torna a instal·lar Signal en aquest dispositiu, i selecciona \"Restaurar o transferir\".</string>
<!-- EOF -->
</resources>
+49 -39
View File
@@ -527,7 +527,7 @@
<string name="ConversationActivity_unable_to_record_audio">Nemohu nahrávat audio!</string>
<string name="ConversationActivity_you_cant_send_messages_to_this_group">Do této skupiny nemůžete zasílat zprávy, jelikož už nejste jejím členem.</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">Zprávy nelze odesílat, protože skupina byla ukončena.</string>
<string name="ConversationActivity_only_s_can_send_messages">Pouze %1$s může posílat zprávy.</string>
<string name="ConversationActivity_admins">správci</string>
<string name="ConversationActivity_message_an_admin">Poslat zprávu správci</string>
@@ -1779,7 +1779,7 @@
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Došlo k chybě v síti.</string>
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Tento odkaz skupiny není aktivní</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">K této skupině se nelze připojit.</string>
<!-- Toast message shown when trying to join a group by link but the group is full -->
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">Limit skupiny dosažen, ke skupině se nelze připojit</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">Odesláno vámi pro %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">Toto médium se ještě neodeslalo.</string>
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
<!-- Megaphones -->
<string name="Megaphones_remind_me_later">Připomenout později</string>
@@ -2010,7 +2011,7 @@
<string name="MessageRecord_you_updated_group">Upravili jste skupinu.</string>
<string name="MessageRecord_the_group_was_updated">Skupina byla aktualizována.</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">Skupina byla ukončena.</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 ukončil(a) skupinu</string>
<!-- Update message shown when a group is terminated by you -->
@@ -2031,6 +2032,10 @@
<string name="MessageRecord_declined_voice_call">Odmítnutý hlasový hovor</string>
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
<string name="MessageRecord_declined_video_call">Odmítnutý videohovor</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">Nepřijatý hlasový hovor</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">Nepřijatý videohovor</string>
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
<string name="MessageRecord_missed_voice_call_notification_profile">Zmeškaný hlasový hovor při zapnutém profilu oznámení</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">Zámek registrace:</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">Skupina upravena</string>
@@ -3855,7 +3861,7 @@
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">Zamknout nahrávání zvukové přílohy</string>
<string name="conversation_activity__message_could_not_be_sent">Zprávu nebylo možné odeslat. Zkontrolujte své připojení a zkuste to znovu.</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">V této skupině již nelze posílat a přijímat zprávy, protože byla ukončena.</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">Tuto akci nelze provést, protože skupina byla ukončena.</string>
<!-- Dialog body when a message failed to delete and retry is possible. -->
@@ -3913,31 +3919,31 @@
<string name="ConversationUpdateItem_update">Aktualizovat</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 úprava ve skupině</item>
<item quantity="few">%1$d úpravy ve skupině</item>
<item quantity="many">%1$d úprav ve skupině</item>
<item quantity="other">%1$d úprav ve skupině</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 úprava chatu</item>
<item quantity="few">%1$d úpravy chatu</item>
<item quantity="many">%1$d úprav chatu</item>
<item quantity="other">%1$d úprav chatu</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 změna časovače mizejících zpráv · %2$s</item>
<item quantity="few">%1$d změny časovače mizejících zpráv · %2$s</item>
<item quantity="many">%1$d změn časovače mizejících zpráv · %2$s</item>
<item quantity="other">%1$d změn časovače mizejících zpráv · %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 volání</item>
<item quantity="few">%1$d volání</item>
<item quantity="many">%1$d volání</item>
<item quantity="other">%1$d volání</item>
</plurals>
<!-- audio_view -->
@@ -6029,37 +6035,37 @@
<!-- Row title for the option to export chat history as a plaintext archive -->
<string name="ChatsSettingsFragment__export_chat_history">Exportovat historii chatů</string>
<!-- Row description for the plaintext chat export option -->
<string name="ChatsSettingsFragment__export_chat_history_label">Umožňuje xportovat strojově čitelnou kopii všech vašich chatů ve formátu JSON. Mizející zprávy nebudou exportovány.</string>
<string name="ChatsSettingsFragment__export_chat_history_label">Exportovat strojově čitelnou kopii všech chatů ve formátu JSON. Mizející zprávy nebudou exportovány.</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">Odemknout pro export historie chatů</string>
<!-- Snackbar shown when biometric authentication fails before a chat export -->
<string name="ChatsSettingsFragment__authentication_failed">Autentifikace neúspěšná</string>
<string name="ChatsSettingsFragment__authentication_failed">Ověření se nezdařilo</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">Rušení exportu</string>
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
<string name="ChatExportDialogs__export_chat_history_title">Exportovat historii chatů?</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">BUĎTE OPATRNÍ!</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">Tento soubor s nikým NESDÍLEJTE. Historie chatů se uloží do vašeho zařízení a ostatní aplikace k ní mohou mít přístup podle vašich oprávnění. Při exportu včetně médií bude výsledný soubor mnohem větší.</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">Exportovat včetně médií</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">Exportovat bez médií</string>
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
<string name="ChatExportDialogs__choose_a_folder_title">Vybrat složku</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">Vyberte složku v úložišti vašeho zařízení, do které se uloží historie vašich chatů.</string>
<!-- Button that opens the system folder picker -->
<string name="ChatExportDialogs__choose_folder_button">Vybrat složku</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">Export chatů dokončen</string>
<!-- Bold warning prefix in the export complete dialog body -->
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
<string name="ChatExportDialogs__be_careful">BUĎTE OPATRNÍ,</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">kam soubor s exportem chatů uložíte, a s nikým jej nesdílejte. Ostatní aplikace ve vašem zařízení k němu mohou mít přístup podle vašich oprávnění.</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">Ukončit skupinu</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">Tato skupina byla ukončena.</string>
<!-- Archive chat option in group conversation settings screen -->
<string name="ConversationSettingsFragment__archive_chat">Archivovat 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">Příběh bude odstraněn pro vás a  pro všechny, kdo ho obdrželi.</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">Odstraní se pouze u vás, protože skupina byla ukončena.</string>
<!-- Toast shown when story media cannot be saved -->
<string name="MyStories__unable_to_save">Nepodařilo se uložit</string>
@@ -8152,6 +8158,8 @@
<string name="CallLogAdapter__incoming">Příchozí</string>
<!-- Displayed for outgoing calls -->
<string name="CallLogAdapter__outgoing">Odchozí</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">Zmeškané</string>
<!-- Displayed for one missed call declined by notification profile -->
@@ -8242,6 +8250,8 @@
<string name="CallLogFragment__get_started_by_calling_a_friend">Začněte tím, že zavoláte příteli.</string>
<!-- Displayed as a message in a dialog when deleting multiple items -->
<string name="CallLogFragment__call_links_youve_created">Vámi vytvořené odkazy na hovory již nebudou pro lidi, kteří je mají, fungovat.</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">Ukončování skupiny…</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">Skupinu se nepodařilo ukončit. Zkontrolujte připojení a zkuste to znovu.</string>
<!-- Retry button shown when ending a group fails -->
<string name="EndGroupDialog__try_again">Zkusit znovu</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 ukončil(a) skupinu</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">Skupina byla ukončena.</string>
<!-- Body of bottom sheet shown when opening a terminated group -->
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">V této skupině již nelze posílat a přijímat zprávy ani hovory.</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">Jedná se o stejný klíč, jako je váš klíč pro obnovení v zařízení.</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">Jedná se o stejný klíč, jako je váš klíč pro obnovení zabezpečené zálohy Signal.</string>
<!-- Label shown above a list of actions that the recovery key can be used for. -->
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Tento klíč lze použít k:</string>
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
@@ -10201,10 +10211,10 @@
<string name="CouldNotCompleteBackupRestoreSheet__title">Zálohu nelze obnovit</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">Došlo k chybě a zálohu nelze obnovit. Mohlo k tomu dojít proto, že se během obnovování zálohy změnilo umístění složky se zálohou ve vašem zařízení.</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">Můžete to zkusit znovu tak, že odinstalujete a znovu nainstalujete Signal v tomto zařízení a zvolíte Obnovit nebo přenést.</string>
<!-- EOF -->
</resources>

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