mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-11 18:50:15 +01:00
Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dfd2f7baf9 | |||
| 5de17a971d | |||
| 001896d244 | |||
| 1844b128e1 | |||
| 08623cc0c4 | |||
| f93a948169 | |||
| 76476191be | |||
| d00bb28ee4 | |||
| 453e5bede7 | |||
| c7c108bd77 | |||
| fb81574d35 | |||
| e6d3de091c | |||
| 99b8a6020d | |||
| 88b21b6113 | |||
| 256ee9b1aa | |||
| e2feaaf74c | |||
| 17def87c17 | |||
| d90e9919ae | |||
| 38baf17938 | |||
| 3f7707985f | |||
| a61072b249 | |||
| 80ff64ddd3 | |||
| 95c0467bda | |||
| ff88d259fd | |||
| 6e747019d4 | |||
| 9e7a40a63d | |||
| 38eed43046 | |||
| 4c76cb682e | |||
| c47adb7482 | |||
| 3c2ccef9a8 | |||
| fb0c4757f2 | |||
| b8b9a632b5 | |||
| 9b4a13a491 | |||
| 1cdd49721d | |||
| 8b895738c0 | |||
| 6ab3cd3390 | |||
| 11c8a726ec | |||
| 264447a6d9 | |||
| a7bb2831f8 | |||
| e05586a1c9 | |||
| 0e8dedf4d0 | |||
| 0e11a1fe3e | |||
| f1ebd2dc81 | |||
| 8ea90c8a43 | |||
| 6456dcf657 | |||
| bb151c91e9 | |||
| ce6f39ae68 | |||
| 58e8ea08c2 | |||
| 4dd74d9ab4 | |||
| 3ef3a516b3 | |||
| 518a81c7fa | |||
| f81325e7ca | |||
| cc847cb229 | |||
| 7320a0ef46 | |||
| 7c45686440 | |||
| 8b5b83e974 | |||
| a4a3861398 | |||
| 01bdaaea84 | |||
| 1f02fba696 | |||
| aeb9054a63 | |||
| bb33945a93 | |||
| 3d2ceef47f | |||
| 892e6bd853 |
@@ -24,8 +24,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1674
|
||||
val canonicalVersionName = "8.6.1"
|
||||
val canonicalVersionCode = 1678
|
||||
val canonicalVersionName = "8.7.2"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ object MockProvider {
|
||||
val device = PreKeyResponseItem().apply {
|
||||
this.deviceId = deviceId
|
||||
registrationId = KeyHelper.generateRegistrationId(false)
|
||||
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
preKey = PreKeyEntity(oneTimePreKey.id, oneTimePreKey.keyPair.publicKey)
|
||||
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
preKey = PreKeyEntity(oneTimePreKey.id.toLong(), oneTimePreKey.keyPair.publicKey)
|
||||
}
|
||||
|
||||
return PreKeyResponse().apply {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+4
-4
@@ -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
|
||||
)
|
||||
|
||||
+1
@@ -235,6 +235,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
|
||||
title = DSLSettingsText.from("Collapse chat updates"),
|
||||
summary = DSLSettingsText.from("Collapses certain consecutive chat updates - cannot be undone."),
|
||||
onClick = {
|
||||
SignalStore.misc.completedCollapsedEventsMigration = false
|
||||
AppDependencies.jobManager.add(BackfillCollapsedMessageJob())
|
||||
}
|
||||
)
|
||||
|
||||
+1
-1
@@ -664,7 +664,7 @@ object InAppPaymentsRepository {
|
||||
timestamp = insertedAt.inWholeMilliseconds,
|
||||
error = null,
|
||||
pendingVerification = true,
|
||||
checkedVerification = data.waitForAuth!!.checkedVerification
|
||||
checkedVerification = data.waitForAuth?.checkedVerification ?: false
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+2
@@ -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))
|
||||
|
||||
+21
-11
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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) } ?: ""
|
||||
|
||||
+17
-3
@@ -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());
|
||||
}
|
||||
|
||||
+70
-43
@@ -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)
|
||||
|
||||
+23
-5
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-6
@@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -102,6 +103,7 @@ import org.thoughtcrime.securesms.util.hasGiftBadge
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
@@ -172,6 +174,8 @@ class ConversationViewModel(
|
||||
val isPushAvailable: Boolean
|
||||
get() = recipientSnapshot?.isRegistered == true && Recipient.self().isRegistered
|
||||
|
||||
val wallpaper: Flow<ChatWallpaper?> = recipient.asFlow().map { it.wallpaper }.distinctUntilChanged()
|
||||
|
||||
val wallpaperSnapshot: ChatWallpaper?
|
||||
get() = recipientSnapshot?.wallpaper
|
||||
|
||||
@@ -216,7 +220,7 @@ class ConversationViewModel(
|
||||
private val _plaintextExportState = MutableStateFlow<PlaintextExportState>(PlaintextExportState.None)
|
||||
val plaintextExportState: StateFlow<PlaintextExportState> = _plaintextExportState
|
||||
|
||||
private val plaintextExportCancelled = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
private val plaintextExportCancelled = AtomicBoolean(false)
|
||||
|
||||
init {
|
||||
disposables += recipient
|
||||
@@ -759,7 +763,7 @@ class ConversationViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun startPlaintextExport(context: Context, directoryUri: Uri) {
|
||||
fun startPlaintextExport(context: Context, directoryUri: Uri, withMedia: Boolean) {
|
||||
val recipient = recipientSnapshot ?: return
|
||||
val chatName = if (recipient.isSelf) context.getString(R.string.note_to_self) else recipient.getDisplayName(context)
|
||||
|
||||
@@ -772,12 +776,17 @@ class ConversationViewModel(
|
||||
threadId = threadId,
|
||||
directoryUri = directoryUri,
|
||||
chatName = chatName,
|
||||
includeMedia = withMedia,
|
||||
progressListener = { messagesProcessed, messageCount, attachmentsProcessed, attachmentCount ->
|
||||
val messagePercent = if (messageCount > 0) (messagesProcessed * 25) / messageCount else 25
|
||||
val attachmentPercent = if (attachmentCount > 0) (attachmentsProcessed * 75) / attachmentCount else 75
|
||||
val percent = messagePercent + attachmentPercent
|
||||
val percent = if (withMedia) {
|
||||
val messagePercent = if (messageCount > 0) (messagesProcessed * 25) / messageCount else 25
|
||||
val attachmentPercent = if (attachmentCount > 0) (attachmentsProcessed * 75) / attachmentCount else 75
|
||||
messagePercent + attachmentPercent
|
||||
} else {
|
||||
if (messageCount > 0) (messagesProcessed * 100) / messageCount else 100
|
||||
}
|
||||
|
||||
val status = if (attachmentsProcessed > 0 || messagesProcessed >= messageCount) {
|
||||
val status = if (withMedia && (attachmentsProcessed > 0 || messagesProcessed >= messageCount)) {
|
||||
"Exporting media ($attachmentsProcessed/$attachmentCount)..."
|
||||
} else {
|
||||
"Exporting messages ($messagesProcessed/$messageCount)..."
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.google.android.material.datepicker.CalendarConstraints.DateValidator
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.util.LRUCache
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import java.time.Instant
|
||||
@@ -36,7 +37,7 @@ private typealias MessageDateLookup = (Collection<Long>) -> Map<Long, Boolean>
|
||||
class JumpToDateValidator private constructor(
|
||||
private val threadId: Long,
|
||||
@IgnoredOnParcel private val messageExistanceLookup: MessageDateLookup = createDefaultLookup(threadId),
|
||||
@IgnoredOnParcel private val executor: Executor = SignalExecutors.BOUNDED,
|
||||
@IgnoredOnParcel private val executor: Executor,
|
||||
private val zoneId: ZoneId = ZoneId.systemDefault()
|
||||
) : DateValidator {
|
||||
|
||||
@@ -51,7 +52,7 @@ class JumpToDateValidator private constructor(
|
||||
return JumpToDateValidator(
|
||||
threadId = threadId,
|
||||
messageExistanceLookup = createDefaultLookup(threadId),
|
||||
executor = SignalExecutors.BOUNDED,
|
||||
executor = SignalExecutors.newCachedSingleThreadExecutor("jump-to-date-validator", ThreadUtil.PRIORITY_BACKGROUND_THREAD),
|
||||
zoneId = ZoneId.systemDefault()
|
||||
).also {
|
||||
it.performInitialPrefetch()
|
||||
|
||||
+2
-19
@@ -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(
|
||||
|
||||
+30
-19
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -560,8 +560,10 @@ class GroupsV2StateProcessor private constructor(
|
||||
}
|
||||
|
||||
if (currentLocalState == null || currentLocalState.revision == RESTORE_PLACEHOLDER_REVISION) {
|
||||
Log.i(TAG, "$logPrefix Inserting single update message for no local state or restore placeholder")
|
||||
profileAndMessageHelper.insertUpdateMessages(timestamp, null, setOf(AppliedGroupChangeLog(updatedGroupState, null)), null)
|
||||
if (!updatedGroupState.terminated) {
|
||||
Log.i(TAG, "$logPrefix Inserting single update message for no local state or restore placeholder")
|
||||
profileAndMessageHelper.insertUpdateMessages(timestamp, null, setOf(AppliedGroupChangeLog(updatedGroupState, null)), null)
|
||||
}
|
||||
} else {
|
||||
profileAndMessageHelper.insertUpdateMessages(timestamp, currentLocalState, applyGroupStateDiffResult.processedLogEntries, serverGuid)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import org.signal.core.util.Util
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.inRoundedDays
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.net.RetryLaterException
|
||||
import org.signal.libsignal.net.UploadTooLargeException
|
||||
import org.signal.protos.resumableuploads.ResumableUpload
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.Attachment
|
||||
@@ -171,8 +174,15 @@ class AttachmentUploadJob private constructor(
|
||||
try {
|
||||
val existingSpec = uploadSpec?.let { ResumableUploadSpec.from(it) }
|
||||
|
||||
val ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(databaseAttachment.size))
|
||||
|
||||
val uploadForm = if (existingSpec == null) {
|
||||
SignalNetwork.attachments.getAttachmentV4UploadForm().successOrThrow()
|
||||
when (val result = SignalNetwork.attachments.getAttachmentV4UploadForm(ciphertextLength)) {
|
||||
is RequestResult.Success -> result.result
|
||||
is RequestResult.NonSuccess -> throw result.error
|
||||
is RequestResult.RetryableNetworkError -> throw RetryLaterException(result.retryAfter)
|
||||
is RequestResult.ApplicationError -> throw result.cause
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -288,8 +298,16 @@ class AttachmentUploadJob private constructor(
|
||||
database.setTransferProgressFailed(attachmentId, databaseAttachment.mmsId)
|
||||
}
|
||||
|
||||
override fun getNextRunAttemptBackoff(pastAttemptCount: Int, exception: java.lang.Exception): Long {
|
||||
if (exception is RetryLaterException && exception.duration != null) {
|
||||
return exception.duration.toMillis()
|
||||
}
|
||||
|
||||
return super.getNextRunAttemptBackoff(pastAttemptCount, exception)
|
||||
}
|
||||
|
||||
override fun onShouldRetry(exception: Exception): Boolean {
|
||||
return exception is IOException && exception !is NotPushRegisteredException
|
||||
return exception is IOException && exception !is NotPushRegisteredException && exception !is UploadTooLargeException
|
||||
}
|
||||
|
||||
@Throws(InvalidAttachmentException::class)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.requireBlob
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireLong
|
||||
@@ -16,6 +15,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobs.BackfillCollapsedMessageJob.Companion.BATCH_SIZE
|
||||
import org.thoughtcrime.securesms.jobs.protos.BackfillCollapsedMessageJobData
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
|
||||
/**
|
||||
@@ -51,61 +51,70 @@ class BackfillCollapsedMessageJob private constructor(
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun run(): Result {
|
||||
if (SignalStore.misc.completedCollapsedEventsMigration) {
|
||||
Log.i(TAG, "Already completed migration")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val db = SignalDatabase.rawDatabase
|
||||
|
||||
val messages = db
|
||||
.select(MessageTable.ID, MessageTable.THREAD_ID, MessageTable.DATE_RECEIVED, MessageTable.TYPE, MessageTable.READ, MessageTable.COLLAPSED_STATE, MessageTable.MESSAGE_EXTRAS)
|
||||
var messageCount = 0
|
||||
var lastProcessedDateReceived = lastDateReceived
|
||||
|
||||
// Tracks the last/previous message to compare against the current message when determining collapsed state
|
||||
val lastMessageByThread = mutableMapOf<Long, LastMessage?>()
|
||||
|
||||
db
|
||||
.select(MessageTable.ID, MessageTable.THREAD_ID, MessageTable.DATE_RECEIVED, MessageTable.TYPE, MessageTable.READ, MessageTable.MESSAGE_EXTRAS)
|
||||
.from(MessageTable.TABLE_NAME)
|
||||
.where("${MessageTable.DATE_RECEIVED} > ?", lastDateReceived)
|
||||
.orderBy("${MessageTable.DATE_RECEIVED}, ${MessageTable.ID}")
|
||||
.limit(BATCH_SIZE)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
PotentialCollapsibleMessage(
|
||||
id = cursor.requireLong(MessageTable.ID),
|
||||
threadId = cursor.requireLong(MessageTable.THREAD_ID),
|
||||
type = cursor.requireLong(MessageTable.TYPE),
|
||||
dateReceived = cursor.requireLong(MessageTable.DATE_RECEIVED),
|
||||
collapsedState = cursor.requireLong(MessageTable.COLLAPSED_STATE),
|
||||
read = cursor.requireBoolean(MessageTable.READ),
|
||||
messageExtras = cursor.requireBlob(MessageTable.MESSAGE_EXTRAS)?.let { MessageExtras.ADAPTER.decode(it) }
|
||||
)
|
||||
}
|
||||
.use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
val id = cursor.requireLong(MessageTable.ID)
|
||||
val threadId = cursor.requireLong(MessageTable.THREAD_ID)
|
||||
val type = cursor.requireLong(MessageTable.TYPE)
|
||||
val dateReceived = cursor.requireLong(MessageTable.DATE_RECEIVED)
|
||||
val read = cursor.requireBoolean(MessageTable.READ)
|
||||
val messageExtras = cursor.requireBlob(MessageTable.MESSAGE_EXTRAS)?.let { MessageExtras.ADAPTER.decode(it) }
|
||||
|
||||
// Tracks the last/previous message to compare against the current message when determining collapsed state
|
||||
val lastMessageByThread = mutableMapOf<Long, LastMessage?>()
|
||||
for (message in messages) {
|
||||
val collapsibleType = CollapsibleEvents.getCollapsibleType(message.type, message.messageExtras)
|
||||
val collapsibleType = CollapsibleEvents.getCollapsibleType(type, messageExtras)
|
||||
|
||||
if (collapsibleType == null) {
|
||||
lastMessageByThread[message.threadId] = null
|
||||
} else {
|
||||
val previous = lastMessageByThread[message.threadId]
|
||||
if (collapsibleType == null) {
|
||||
lastMessageByThread[threadId] = null
|
||||
} else {
|
||||
val previous = lastMessageByThread[threadId]
|
||||
|
||||
val (collapsedState, headId, size) = if ((previous?.collapsibleType == collapsibleType) && DateUtils.isSameDay(previous.dateReceived, message.dateReceived) && previous.collapsedSetSize < CollapsibleEvents.MAX_SIZE) {
|
||||
val state = if (message.read) CollapsedState.COLLAPSED.id else CollapsedState.PENDING_COLLAPSED.id
|
||||
Triple(state, previous.headId, previous.collapsedSetSize)
|
||||
} else {
|
||||
Triple(CollapsedState.HEAD_COLLAPSED.id, message.id, 0)
|
||||
val (collapsedState, headId, size) = if ((previous?.collapsibleType == collapsibleType) && DateUtils.isSameDay(previous.dateReceived, dateReceived) && previous.collapsedSetSize < CollapsibleEvents.MAX_SIZE) {
|
||||
val state = if (read) CollapsedState.COLLAPSED.id else CollapsedState.PENDING_COLLAPSED.id
|
||||
Triple(state, previous.headId, previous.collapsedSetSize)
|
||||
} else {
|
||||
Triple(CollapsedState.HEAD_COLLAPSED.id, id, 0)
|
||||
}
|
||||
|
||||
db.update(MessageTable.TABLE_NAME)
|
||||
.values(
|
||||
MessageTable.COLLAPSED_STATE to collapsedState,
|
||||
MessageTable.COLLAPSED_HEAD_ID to headId
|
||||
)
|
||||
.where("${MessageTable.ID} = ?", id)
|
||||
.run()
|
||||
lastMessageByThread[threadId] = LastMessage(collapsibleType, headId, dateReceived, size + 1)
|
||||
}
|
||||
|
||||
messageCount++
|
||||
lastProcessedDateReceived = dateReceived
|
||||
}
|
||||
|
||||
db.update(MessageTable.TABLE_NAME)
|
||||
.values(
|
||||
MessageTable.COLLAPSED_STATE to collapsedState,
|
||||
MessageTable.COLLAPSED_HEAD_ID to headId
|
||||
)
|
||||
.where("${MessageTable.ID} = ?", message.id)
|
||||
.run()
|
||||
lastMessageByThread[message.threadId] = LastMessage(collapsibleType, headId, message.dateReceived, size + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.isEmpty() || messages.size != BATCH_SIZE) {
|
||||
if (messageCount == 0 || messageCount != BATCH_SIZE) {
|
||||
Log.i(TAG, "Finished processing all messages, backfill is completed")
|
||||
SignalStore.misc.completedCollapsedEventsMigration = true
|
||||
} else {
|
||||
val dateReceived = messages.last().dateReceived
|
||||
Log.i(TAG, "Processed ${messages.size} messages, up to time $dateReceived. Re-enqueuing job")
|
||||
AppDependencies.jobManager.add(BackfillCollapsedMessageJob(lastDateReceived = dateReceived))
|
||||
Log.i(TAG, "Processed $messageCount messages, up to time $lastProcessedDateReceived. Re-enqueuing job")
|
||||
AppDependencies.jobManager.add(BackfillCollapsedMessageJob(lastDateReceived = lastProcessedDateReceived))
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
@@ -115,19 +124,6 @@ class BackfillCollapsedMessageJob private constructor(
|
||||
Log.w(TAG, "Failed to backfill collapsed messages. Time of last processed message: $lastDateReceived")
|
||||
}
|
||||
|
||||
/**
|
||||
* Data required from a message to know if it collapsible
|
||||
*/
|
||||
private data class PotentialCollapsibleMessage(
|
||||
val id: Long,
|
||||
val threadId: Long,
|
||||
val type: Long,
|
||||
val dateReceived: Long,
|
||||
val collapsedState: Long,
|
||||
val read: Boolean,
|
||||
val messageExtras: MessageExtras?
|
||||
)
|
||||
|
||||
/**
|
||||
* Information about the previous message, used when deciding the collapsible state of the next
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.jobs
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.jobmanager.Job
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.AutoDownloadEmojiConstraint
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.service.webrtc.CallingAssets
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
/**
|
||||
* Job that downloads missing calling assets.
|
||||
*/
|
||||
class CallingAssetsDownloadJob private constructor(parameters: Parameters) : Job(parameters) {
|
||||
companion object {
|
||||
private val TAG = Log.tag(CallingAssetsDownloadJob::class)
|
||||
|
||||
const val KEY = "CallingAssetsDownloadJob"
|
||||
}
|
||||
|
||||
constructor() : this(
|
||||
Parameters.Builder()
|
||||
.addConstraint(AutoDownloadEmojiConstraint.KEY)
|
||||
.setLifespan(3.days.inWholeMilliseconds)
|
||||
.setMaxAttempts(5)
|
||||
.setMaxInstancesForFactory(1)
|
||||
.build()
|
||||
)
|
||||
|
||||
override fun serialize(): ByteArray? = null
|
||||
|
||||
override fun getFactoryKey(): String = KEY
|
||||
|
||||
override fun run(): Result {
|
||||
var succeeded = true
|
||||
if (SignalStore.misc.callingAssetsVersion != CallingAssets.CURRENT_VERSION) {
|
||||
succeeded = CallingAssets.downloadMissingAssets()
|
||||
}
|
||||
|
||||
CallingAssets.registerAssetsIfNeeded()
|
||||
|
||||
if (!succeeded) {
|
||||
Log.w(TAG, "Failed to download some calling assets")
|
||||
return Result.retry(BackoffUtil.exponentialBackoff(runAttempt + 1, 1.hours.inWholeMilliseconds))
|
||||
}
|
||||
SignalStore.misc.callingAssetsVersion = CallingAssets.CURRENT_VERSION
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
override fun onFailure() = Unit
|
||||
|
||||
class Factory : Job.Factory<CallingAssetsDownloadJob> {
|
||||
override fun create(parameters: Parameters, serializedData: ByteArray?): CallingAssetsDownloadJob {
|
||||
return CallingAssetsDownloadJob(parameters)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,11 @@ class CopyAttachmentToArchiveJob private constructor(private val attachmentId: A
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
if (SignalStore.backup.isNotEnoughRemoteStorageSpace) {
|
||||
Log.w(TAG, "[$attachmentId] Already marked as out of remote storage space. Failing.")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val attachment: DatabaseAttachment? = SignalDatabase.attachments.getAttachment(attachmentId)
|
||||
|
||||
if (attachment == null) {
|
||||
|
||||
@@ -154,6 +154,7 @@ public final class JobManagerFactories {
|
||||
put(BackupRestoreMediaJob.KEY, new BackupRestoreMediaJob.Factory());
|
||||
put(BackupSubscriptionCheckJob.KEY, new BackupSubscriptionCheckJob.Factory());
|
||||
put(BuildExpirationConfirmationJob.KEY, new BuildExpirationConfirmationJob.Factory());
|
||||
put(CallingAssetsDownloadJob.KEY, new CallingAssetsDownloadJob.Factory());
|
||||
put(CallLinkPeekJob.KEY, new CallLinkPeekJob.Factory());
|
||||
put(CallLinkUpdateSendJob.KEY, new CallLinkUpdateSendJob.Factory());
|
||||
put(CallLogEventSendJob.KEY, new CallLogEventSendJob.Factory());
|
||||
@@ -261,7 +262,6 @@ public final class JobManagerFactories {
|
||||
put(RemoteDeleteSendJob.KEY, new RemoteDeleteSendJob.Factory());
|
||||
put(ReportSpamJob.KEY, new ReportSpamJob.Factory());
|
||||
put(ResendMessageJob.KEY, new ResendMessageJob.Factory());
|
||||
put(ResumableUploadSpecJob.KEY, new ResumableUploadSpecJob.Factory());
|
||||
put(RequestGroupV2InfoWorkerJob.KEY, new RequestGroupV2InfoWorkerJob.Factory());
|
||||
put(RequestGroupV2InfoJob.KEY, new RequestGroupV2InfoJob.Factory());
|
||||
put(LocalBackupRestoreMediaJob.KEY, new LocalBackupRestoreMediaJob.Factory());
|
||||
@@ -431,6 +431,7 @@ public final class JobManagerFactories {
|
||||
put("BackupRestoreJob", new FailingJob.Factory());
|
||||
put("BackfillDigestsMigrationJob", new PassingMigrationJob.Factory());
|
||||
put("BackfillDigestJob", new FailingJob.Factory());
|
||||
put("ResumableUploadSpecJob", new FailingJob.Factory());
|
||||
}};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
|
||||
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage;
|
||||
@@ -288,7 +290,7 @@ public class MultiDeviceContactUpdateJob extends BaseJob {
|
||||
.withStream(stream)
|
||||
.withContentType("application/octet-stream")
|
||||
.withLength(length)
|
||||
.withResumableUploadSpec(messageSender.getResumableUploadSpec());
|
||||
.withResumableUploadSpec(messageSender.getResumableUploadSpec(AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(length))));
|
||||
|
||||
messageSender.sendSyncMessage(SignalServiceSyncMessage.forContacts(new ContactsMessage(attachmentStream.build(), complete))
|
||||
);
|
||||
|
||||
+8
-2
@@ -17,7 +17,9 @@ import org.thoughtcrime.securesms.net.NotPushRegisteredException;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
|
||||
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage;
|
||||
@@ -26,6 +28,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.DeviceContactsO
|
||||
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
@@ -88,11 +91,14 @@ public class MultiDeviceProfileKeyUpdateJob extends BaseJob {
|
||||
out.close();
|
||||
|
||||
SignalServiceMessageSender messageSender = AppDependencies.getSignalServiceMessageSender();
|
||||
long dataLength = baos.toByteArray().length;
|
||||
long ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(dataLength));
|
||||
ResumableUploadSpec uploadSpec = messageSender.getResumableUploadSpec(ciphertextLength);
|
||||
SignalServiceAttachmentStream attachmentStream = SignalServiceAttachment.newStreamBuilder()
|
||||
.withStream(new ByteArrayInputStream(baos.toByteArray()))
|
||||
.withContentType("application/octet-stream")
|
||||
.withLength(baos.toByteArray().length)
|
||||
.withResumableUploadSpec(messageSender.getResumableUploadSpec())
|
||||
.withLength(dataLength)
|
||||
.withResumableUploadSpec(uploadSpec)
|
||||
.build();
|
||||
|
||||
SignalServiceSyncMessage syncMessage = SignalServiceSyncMessage.forContacts(new ContactsMessage(attachmentStream, false));
|
||||
|
||||
@@ -60,7 +60,9 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
|
||||
import org.thoughtcrime.securesms.transport.RetryLaterException;
|
||||
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
|
||||
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress;
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
|
||||
@@ -70,6 +72,7 @@ import org.whispersystems.signalservice.api.messages.shared.SharedContact;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
|
||||
import org.whispersystems.signalservice.internal.push.BodyRange;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
@@ -171,9 +174,12 @@ public abstract class PushSendJob extends SendJob {
|
||||
|
||||
try {
|
||||
if (attachment.getUri() == null || attachment.size == 0) throw new IOException("Assertion failed, outgoing attachment has no data!");
|
||||
InputStream is = PartAuthority.getAttachmentStream(context, attachment.getUri());
|
||||
InputStream inputStream = PartAuthority.getAttachmentStream(context, attachment.getUri());
|
||||
long ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(attachment.size));
|
||||
ResumableUploadSpec uploadSpec = AppDependencies.getSignalServiceMessageSender().getResumableUploadSpec(ciphertextLength);
|
||||
|
||||
return SignalServiceAttachment.newStreamBuilder()
|
||||
.withStream(is)
|
||||
.withStream(inputStream)
|
||||
.withContentType(attachment.contentType)
|
||||
.withLength(attachment.size)
|
||||
.withFileName(attachment.fileName)
|
||||
@@ -185,7 +191,7 @@ public abstract class PushSendJob extends SendJob {
|
||||
.withHeight(attachment.height)
|
||||
.withCaption(attachment.caption)
|
||||
.withUuid(attachment.uuid)
|
||||
.withResumableUploadSpec(AppDependencies.getSignalServiceMessageSender().getResumableUploadSpec())
|
||||
.withResumableUploadSpec(uploadSpec)
|
||||
.withListener(new SignalServiceAttachment.ProgressListener() {
|
||||
@Override
|
||||
public void onAttachmentProgress(@NonNull AttachmentTransferProgress progress) {
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* No longer used. Functionality has been merged into {@link AttachmentUploadJob}.
|
||||
*/
|
||||
@Deprecated
|
||||
public class ResumableUploadSpecJob extends BaseJob {
|
||||
|
||||
private static final String TAG = Log.tag(ResumableUploadSpecJob.class);
|
||||
|
||||
static final String KEY_RESUME_SPEC = "resume_spec";
|
||||
|
||||
public static final String KEY = "ResumableUploadSpecJob";
|
||||
|
||||
private ResumableUploadSpecJob(@NonNull Parameters parameters) {
|
||||
super(parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws Exception {
|
||||
ResumableUploadSpec resumableUploadSpec = AppDependencies.getSignalServiceMessageSender()
|
||||
.getResumableUploadSpec();
|
||||
|
||||
setOutputData(new JsonJobData.Builder()
|
||||
.putString(KEY_RESUME_SPEC, resumableUploadSpec.serialize())
|
||||
.serialize());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
return e instanceof IOException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable byte[] serialize() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull String getFactoryKey() {
|
||||
return KEY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure() {
|
||||
|
||||
}
|
||||
|
||||
public static class Factory implements Job.Factory<ResumableUploadSpecJob> {
|
||||
|
||||
@Override
|
||||
public @NonNull ResumableUploadSpecJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
|
||||
return new ResumableUploadSpecJob(parameters);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,9 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto
|
||||
private const val HAS_KEY_TRANSPARENCY_FAILURE = "misc.has_key_transparency_failure"
|
||||
private const val HAS_SEEN_KEY_TRANSPARENCY_FAILURE = "misc.has_seen_key_transparency_failure"
|
||||
private const val CAMERA_FACING_FRONT = "misc.camera_facing_front"
|
||||
private const val COMPLETED_COLLAPSED_EVENTS_MIGRATION = "misc.completed_collapsed_events_migration"
|
||||
private const val CAPTCHA_LAST_VIEWED_AT = "misc.captcha_last_viewed_at"
|
||||
private const val CALLING_ASSETS_VERSION = "misc.calling_assets_version"
|
||||
}
|
||||
|
||||
public override fun onFirstEverAppLaunch() {
|
||||
@@ -315,4 +318,18 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto
|
||||
* Whether or not the preferred camera direction is front-facing.
|
||||
*/
|
||||
var isCameraFacingFront: Boolean by booleanValue(CAMERA_FACING_FRONT, true)
|
||||
|
||||
var completedCollapsedEventsMigration: Boolean by booleanValue(COMPLETED_COLLAPSED_EVENTS_MIGRATION, false)
|
||||
|
||||
/**
|
||||
* The last time the user viewed the captcha/recaptcha proof activity.
|
||||
*/
|
||||
var captchaLastViewedAt: Long by longValue(CAPTCHA_LAST_VIEWED_AT, 0)
|
||||
|
||||
/**
|
||||
* The last successfully-downloaded calling assets version. Compared against
|
||||
* [org.thoughtcrime.securesms.service.webrtc.CallingAssets.CURRENT_VERSION] to determine
|
||||
* if new assets need to be fetched.
|
||||
*/
|
||||
var callingAssetsVersion: Int by integerValue(CALLING_ASSETS_VERSION, 0)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.signal.core.util.logging.logD
|
||||
import org.signal.core.util.logging.logI
|
||||
import org.signal.core.util.logging.logW
|
||||
import org.signal.core.util.toByteArray
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.protocol.InvalidKeyException
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil
|
||||
@@ -337,11 +338,11 @@ object LinkDeviceRepository {
|
||||
}
|
||||
|
||||
Log.d(TAG, "[createAndUploadArchive] Fetching an upload form...")
|
||||
val uploadForm = when (val result = NetworkResult.withRetry { SignalNetwork.attachments.getAttachmentV4UploadForm() }) {
|
||||
is NetworkResult.Success -> result.result.logD(TAG, "[createAndUploadArchive] Successfully retrieved upload form.")
|
||||
is NetworkResult.ApplicationError -> throw result.throwable
|
||||
is NetworkResult.NetworkError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "[createAndUploadArchive] Network error when fetching form.", result.exception)
|
||||
is NetworkResult.StatusCodeError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "[createAndUploadArchive] Status code error when fetching form.", result.exception)
|
||||
val uploadForm = when (val result = SignalNetwork.attachments.getAttachmentV4UploadForm(tempBackupFile.length())) {
|
||||
is RequestResult.Success -> result.result.logD(TAG, "[createAndUploadArchive] Successfully retrieved upload form.")
|
||||
is RequestResult.ApplicationError -> throw result.cause
|
||||
is RequestResult.RetryableNetworkError -> return LinkUploadArchiveResult.NetworkError(result.networkError).logW(TAG, "[createAndUploadArchive] Network error when fetching form.", result.networkError)
|
||||
is RequestResult.NonSuccess -> return LinkUploadArchiveResult.BadRequest(result.error).logW(TAG, "[createAndUploadArchive] Upload too large when fetching form.", result.error)
|
||||
}
|
||||
|
||||
if (cancellationSignal()) {
|
||||
|
||||
@@ -10,14 +10,14 @@ import androidx.annotation.Nullable;
|
||||
import androidx.navigation.NavGraph;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.thoughtcrime.securesms.BaseActivity;
|
||||
import org.thoughtcrime.securesms.PassphrasePromptActivity;
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.util.DynamicRegistrationTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
|
||||
public class CreateSvrPinActivity extends BaseActivity {
|
||||
public class CreateSvrPinActivity extends PassphraseRequiredActivity {
|
||||
|
||||
public static final int REQUEST_NEW_PIN = 27698;
|
||||
|
||||
@@ -55,8 +55,8 @@ public class CreateSvrPinActivity extends BaseActivity {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle bundle) {
|
||||
super.onCreate(bundle);
|
||||
public void onCreate(Bundle bundle, boolean ready) {
|
||||
super.onCreate(bundle, ready);
|
||||
|
||||
if (KeyCachingService.isLocked(this)) {
|
||||
startActivity(getPromptPassphraseIntent());
|
||||
|
||||
+11
-14
@@ -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)
|
||||
|
||||
+8
-4
@@ -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 {
|
||||
|
||||
+108
@@ -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)
|
||||
}
|
||||
}
|
||||
+45
-4
@@ -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()
|
||||
|
||||
+55
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -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 {
|
||||
|
||||
+12
-2
@@ -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)
|
||||
|
||||
+8
-1
@@ -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
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.mediasend.v3
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.getParcelableArrayListCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.mediasend.MediaRecipientId
|
||||
import org.signal.mediasend.MediaSendActivityContract
|
||||
import org.signal.mediasend.MediaSendState
|
||||
import org.signal.mediasend.MediaSendViewModel
|
||||
import org.signal.mediasend.SendResult
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.signal.core.ui.R as CoreUiR
|
||||
|
||||
/**
|
||||
* View-backed wrapper around [MultiselectForwardFragment] that provides the [ViewGroup] container
|
||||
* required by [MultiselectForwardFragment.Callback.getContainer] for bottom bar inflation.
|
||||
*
|
||||
* Implements the callback interface and uses the shared [MediaSendViewModel] to drive
|
||||
* the send flow forward.
|
||||
*/
|
||||
class MediaSendV3ForwardFragment : Fragment(R.layout.multiselect_forward_activity), MultiselectForwardFragment.Callback {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(MediaSendV3ForwardFragment::class.java)
|
||||
}
|
||||
|
||||
private val viewModel: MediaSendViewModel by activityViewModels {
|
||||
MediaSendViewModel.Factory(args = MediaSendActivityContract.Args.fromIntent(requireActivity().intent))
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
if (savedInstanceState == null) {
|
||||
val state = viewModel.state.value
|
||||
val forwardFragment = MultiselectForwardFragment.create(
|
||||
MultiselectForwardFragmentArgs(
|
||||
title = R.string.MediaReviewFragment__send_to,
|
||||
storySendRequirements = state.storySendRequirements.toAppSendRequirements(),
|
||||
isSearchEnabled = !state.isStory,
|
||||
isViewOnce = state.viewOnceToggleState == MediaSendState.ViewOnceToggleState.ONCE
|
||||
)
|
||||
)
|
||||
|
||||
childFragmentManager.beginTransaction()
|
||||
.replace(R.id.fragment_container, forwardFragment)
|
||||
.commitNow()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFinishForwardAction() = Unit
|
||||
|
||||
override fun exitFlow() {
|
||||
requireActivity().finish()
|
||||
}
|
||||
|
||||
override fun onSearchInputFocused() = Unit
|
||||
|
||||
override fun setResult(bundle: Bundle) {
|
||||
val selectedRecipients: List<ContactSearchKey.RecipientSearchKey> = bundle.getParcelableArrayListCompat(MultiselectForwardFragment.RESULT_SELECTION, ContactSearchKey.RecipientSearchKey::class.java)
|
||||
?: emptyList()
|
||||
|
||||
val recipientIds = selectedRecipients.map { MediaRecipientId(it.recipientId.toLong()) }
|
||||
viewModel.setAdditionalRecipients(recipientIds)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
when (val result = viewModel.send()) {
|
||||
is SendResult.Success -> {
|
||||
Log.d(TAG, "Send completed successfully.")
|
||||
requireActivity().finish()
|
||||
}
|
||||
is SendResult.Error -> {
|
||||
Log.w(TAG, "Send failed: ${result.message}")
|
||||
requireActivity().finish()
|
||||
}
|
||||
is SendResult.UntrustedIdentity -> {
|
||||
Log.w(TAG, "Send failed due to untrusted identities.")
|
||||
SafetyNumberBottomSheet
|
||||
.forRecipientIdsAndDestinations(result.recipientIds.map { RecipientId.from(it) }, selectedRecipients)
|
||||
.show(childFragmentManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getContainer(): ViewGroup {
|
||||
return requireView().findViewById(R.id.fragment_container_wrapper)
|
||||
}
|
||||
|
||||
override fun getDialogBackgroundColor(): Int {
|
||||
return ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorBackground)
|
||||
}
|
||||
|
||||
override fun getStorySendRequirements(): Stories.MediaTransform.SendRequirements? {
|
||||
return viewModel.getStorySendRequirements().toAppSendRequirements()
|
||||
}
|
||||
}
|
||||
@@ -93,9 +93,6 @@ object MediaSendV3Repository : MediaSendRepository {
|
||||
return@withContext SendResult.Error("No recipients provided.")
|
||||
}
|
||||
|
||||
val singleContact = if (recipients.size == 1) recipients.first() else null
|
||||
val contacts = if (recipients.size > 1) recipients else emptyList()
|
||||
|
||||
val legacyEditorStateMap = mapLegacyEditorState(request.editorStateMap)
|
||||
val quality = SentMediaQuality.fromCode(request.quality)
|
||||
|
||||
@@ -106,8 +103,8 @@ object MediaSendV3Repository : MediaSendRepository {
|
||||
quality = quality,
|
||||
message = request.message,
|
||||
isViewOnce = request.isViewOnce,
|
||||
singleContact = singleContact,
|
||||
contacts = contacts,
|
||||
singleContact = null,
|
||||
contacts = recipients,
|
||||
mentions = emptyList(),
|
||||
bodyRanges = null,
|
||||
sendType = resolveSendType(request.sendType),
|
||||
|
||||
@@ -1252,7 +1252,7 @@ object DataMessageProcessor {
|
||||
|
||||
SignalDatabase.polls.insertVotes(
|
||||
pollId = pollId,
|
||||
pollOptionIds = pollVote.optionIndexes.map { index -> allOptionIds[index] },
|
||||
pollOptionIds = pollVote.optionIndexes.distinct().map { index -> allOptionIds[index] },
|
||||
voterId = senderRecipient.id.toLong(),
|
||||
voteCount = pollVote.voteCount?.toLong() ?: 0,
|
||||
messageId = messageId
|
||||
@@ -1586,15 +1586,16 @@ object DataMessageProcessor {
|
||||
}
|
||||
|
||||
warn(timestamp, "Didn't find matching message record...")
|
||||
val cappedQuoteRanges = quote.bodyRanges.take(BODY_RANGE_PROCESSING_LIMIT)
|
||||
return QuoteModel(
|
||||
id = quote.id!!,
|
||||
author = authorId,
|
||||
text = quote.text ?: "",
|
||||
isOriginalMissing = true,
|
||||
attachment = quote.attachments.firstNotNullOfOrNull { PointerAttachment.forPointer(it).orNull() },
|
||||
mentions = getMentions(quote.bodyRanges),
|
||||
mentions = getMentions(cappedQuoteRanges),
|
||||
type = QuoteModel.Type.fromProto(quote.type),
|
||||
bodyRanges = quote.bodyRanges.filter { Util.allAreNull(it.mentionAci, it.mentionAciBinary) }.toBodyRangeList()
|
||||
bodyRanges = cappedQuoteRanges.filter { Util.allAreNull(it.mentionAci, it.mentionAciBinary) }.toBodyRangeList()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -356,7 +356,8 @@ open class MessageContentProcessor(private val context: Context) {
|
||||
if (!processingEarlyContent && earlyCacheEntries != null) {
|
||||
log(envelope.clientTimestamp!!, "Found " + earlyCacheEntries.size + " dependent item(s) that were retrieved earlier. Processing.")
|
||||
for (entry in earlyCacheEntries) {
|
||||
handleMessage(senderRecipient, entry.envelope, entry.content, entry.metadata, entry.serverDeliveredTimestamp, processingEarlyContent = true, localMetric = null, batchCache)
|
||||
val earlyEntrySender = Recipient.externalPush(SignalServiceAddress(entry.metadata.sourceServiceId, entry.metadata.sourceE164))
|
||||
handleMessage(senderRecipient = earlyEntrySender, entry.envelope, entry.content, entry.metadata, entry.serverDeliveredTimestamp, processingEarlyContent = true, localMetric = null, batchCache)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,9 +197,10 @@ public class ApplicationMigrations {
|
||||
static final int RELEASE_CHANNEL_RECIPIENT_FIX = 153;
|
||||
static final int EMOJI_VERSION_13 = 154;
|
||||
static final int COLLAPSED_EVENTS = 155;
|
||||
static final int COLLAPSED_EVENTS_2 = 156;
|
||||
}
|
||||
|
||||
public static final int CURRENT_VERSION = 155;
|
||||
public static final int CURRENT_VERSION = 156;
|
||||
|
||||
/**
|
||||
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
|
||||
@@ -914,6 +915,10 @@ public class ApplicationMigrations {
|
||||
jobs.put(Version.COLLAPSED_EVENTS, new BackfillCollapsedEventsMigrationJob());
|
||||
}
|
||||
|
||||
if (lastSeenVersion < Version.COLLAPSED_EVENTS_2) {
|
||||
jobs.put(Version.COLLAPSED_EVENTS_2, new BackfillCollapsedEventsMigrationJob());
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+658
@@ -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
|
||||
}
|
||||
}
|
||||
+336
@@ -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.
|
||||
*
|
||||
|
||||
+5
@@ -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
|
||||
)
|
||||
}
|
||||
+10
@@ -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():");
|
||||
|
||||
+16
@@ -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"
|
||||
|
||||
@@ -517,7 +517,7 @@
|
||||
<string name="ConversationActivity_unable_to_record_audio">Kan nie klank opneem nie!</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_to_this_group">Jy kan nie boodskappe aan hierdie groep stuur nie, want jy is nie meer \'n lid nie.</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="DisabledInputView__incognito_mode" translatable="false">Incognito mode (Labs)</string> -->
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">You can\'t send messages because the group has ended.</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">Jy kan nie boodskappe stuur nie, want die groep is beëindig.</string>
|
||||
<string name="ConversationActivity_only_s_can_send_messages">Slegs %1$s kan boodskappe stuur.</string>
|
||||
<string name="ConversationActivity_admins">admins</string>
|
||||
<string name="ConversationActivity_message_an_admin">Stuur ’n boodskap aan admin</string>
|
||||
@@ -659,8 +659,8 @@
|
||||
<string name="ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation">Jy kan notas vir jouself in hierdie klets byvoeg. Indien daar enige toestelle aan jou rekening gekoppel is, sal nuwe notas gesinchroniseer word.</string>
|
||||
<!-- Text in banner to show how many people have the same name. -->
|
||||
<plurals name="ConversationFragment__d_group_members_have_the_same_name">
|
||||
<item quantity="one">%1$d group member have the same name.</item>
|
||||
<item quantity="other">%1$d group members have the same name.</item>
|
||||
<item quantity="one">%1$d groeplid het dieselfde naam.</item>
|
||||
<item quantity="other">%1$d groeplede het dieselfde naam.</item>
|
||||
</plurals>
|
||||
<string name="ConversationFragment__tap_to_review">Tik om te hersien</string>
|
||||
<!-- The body of a banner that can show up at the top of a chat, letting the user know that you have two contacts with the same name -->
|
||||
@@ -986,7 +986,7 @@
|
||||
<string name="BackupsPreferenceFragment__test_your_backup_passphrase">Toets jou wagwoordfrase en verifieer dat dit ooreenkom</string>
|
||||
<string name="BackupsPreferenceFragment__turn_on">Skakel rugsteun aan</string>
|
||||
<string name="BackupsPreferenceFragment__turn_off">Skakel af</string>
|
||||
<string name="BackupsPreferenceFragment__to_restore_a_backup">"Om \'n rugsteun te herwin, installeer \'n nuwe kopie van Signal. Maak die toepassing oop, tik op \"Herwin vanaf rugsteun\" en spoor dan \'n rugsteunlêer op. %1$s"</string>
|
||||
<string name="BackupsPreferenceFragment__to_restore_a_backup">"Om 'n rugsteun te herwin, installeer 'n nuwe kopie van Signal. Maak die toepassing oop, tik op \"Herwin vanaf rugsteun\" en spoor dan 'n rugsteunlêer op. %1$s"</string>
|
||||
<string name="BackupsPreferenceFragment__learn_more">Vind meer uit</string>
|
||||
<string name="BackupsPreferenceFragment__in_progress">Aan die gang…</string>
|
||||
<!-- Status text shown in backup preferences when verifying a backup -->
|
||||
@@ -1683,7 +1683,7 @@
|
||||
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Netwerkfout teëgekom.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Hierdie groepskakel is nie aktief nie.</string>
|
||||
<!-- Toast message shown when trying to join a group via link but the group has been ended -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Unable to join this group.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Kon nie by hierdie groep aansluit nie.</string>
|
||||
<!-- Toast message shown when trying to join a group by link but the group is full -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">Groepbeperking bereik, kan nie by groep aansluit nie</string>
|
||||
<!-- Title shown when there was an known issue getting group information from a group link -->
|
||||
@@ -1832,6 +1832,7 @@
|
||||
<string name="MediaOverviewActivity_sent_by_you_to_s">Deur jou aan %1$s gestuur</string>
|
||||
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
|
||||
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Hierdie media is nog nie gestuur nie.</string>
|
||||
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
|
||||
|
||||
<!-- Megaphones -->
|
||||
<string name="Megaphones_remind_me_later">Herinner my later</string>
|
||||
@@ -1900,7 +1901,7 @@
|
||||
<string name="MessageRecord_you_updated_group">Jy het die groep bygewerk.</string>
|
||||
<string name="MessageRecord_the_group_was_updated">Die groep is bygewerk.</string>
|
||||
<!-- Update message shown a group is terminated, but the person that terminated it is unknown. -->
|
||||
<string name="MessageRecord_the_group_was_terminated">The group ended.</string>
|
||||
<string name="MessageRecord_the_group_was_terminated">Die groep is beëindig.</string>
|
||||
<!-- Update message shown when a group is terminated, placeholder is the name of the person that terminated the group. -->
|
||||
<string name="MessageRecord_s_terminated_the_group">%1$s het die groep beëindig</string>
|
||||
<!-- Update message shown when a group is terminated by you -->
|
||||
@@ -1921,6 +1922,10 @@
|
||||
<string name="MessageRecord_declined_voice_call">Stemoproep geweier</string>
|
||||
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
|
||||
<string name="MessageRecord_declined_video_call">Video-oproep geweier</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_voice_call">Onbeantwoorde stemoproep</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_video_call">Onbeantwoorde video-oproep</string>
|
||||
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
|
||||
<string name="MessageRecord_missed_voice_call_notification_profile">Gemiste stemoproep terwyl kennisgewingprofiel aan was</string>
|
||||
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
|
||||
@@ -3076,6 +3081,7 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
|
||||
<string name="SupportEmailUtil_registration_lock">Registrasieslot:</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
|
||||
|
||||
<!-- ThreadRecord -->
|
||||
<string name="ThreadRecord_group_updated">Groep bygewerk</string>
|
||||
@@ -3581,7 +3587,7 @@
|
||||
<string name="ContactSelectionListFragment_signal_requires_the_contacts_permission_in_order_to_display_your_contacts">Signal benodig Kontaktoestemming om jou kontakte te wys, maar dit is permanent geweier. Gaan asseblief na die toepassinginstelling-kieslys, kies \"Toestemmings\" en aktiveer \"Kontakte\".</string>
|
||||
<string name="ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection">Fout met herwinning van kontakte, gaan jou netwerkverbinding na</string>
|
||||
<string name="ContactSelectionListFragment_username_not_found">Gebruikersnaam nie gevind nie</string>
|
||||
<string name="ContactSelectionListFragment_s_is_not_a_signal_user">"\"%1$s\" is nie \'n Signal-gebruiker nie. Kontroleer asb. die gebruikersnaam en probeer weer."</string>
|
||||
<string name="ContactSelectionListFragment_s_is_not_a_signal_user">"\"%1$s\" is nie 'n Signal-gebruiker nie. Kontroleer asb. die gebruikersnaam en probeer weer."</string>
|
||||
<string name="ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group">Jy hoef nie jouself by die groep te voeg nie</string>
|
||||
<string name="ContactSelectionListFragment_maximum_group_size_reached">Maksimum groepgrootte bereik.</string>
|
||||
<string name="ContactSelectionListFragment_signal_groups_can_have_a_maximum_of_s_members">Signal-groepe kan \'n maksimum van %1$s lede hê.</string>
|
||||
@@ -3637,7 +3643,7 @@
|
||||
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">Sluit opname van oudio-aanhegsel</string>
|
||||
<string name="conversation_activity__message_could_not_be_sent">Boodskap kon nie gestuur word nie. Kontroleer jou verbinding en probeer weer.</string>
|
||||
<!-- Dialog body shown when tapping a failed message in a terminated group -->
|
||||
<string name="conversation_activity__send_failed_group_ended">You can no longer send and receive messages in this group because the group has ended.</string>
|
||||
<string name="conversation_activity__send_failed_group_ended">Jy kan nie meer boodskappe in hierdie groep stuur en ontvang nie, want hierdie groep is beëindig.</string>
|
||||
<!-- Dialog body shown when tapping a group action button (e.g. invite friends) in a terminated group -->
|
||||
<string name="conversation_activity__group_action_not_allowed_group_ended">Hierdie aksie is nie beskikbaar nie, want die groep is beëindig.</string>
|
||||
<!-- Dialog body when a message failed to delete and retry is possible. -->
|
||||
@@ -3695,23 +3701,23 @@
|
||||
<string name="ConversationUpdateItem_update">Werk by</string>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__group_update">
|
||||
<item quantity="one">%1$d group update</item>
|
||||
<item quantity="other">%1$d group updates</item>
|
||||
<item quantity="one">%1$d groepopdatering</item>
|
||||
<item quantity="other">%1$d groepopdaterings</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__chat_update">
|
||||
<item quantity="one">%1$d chat update</item>
|
||||
<item quantity="other">%1$d chat updates</item>
|
||||
<item quantity="one">%1$d kletsopdatering</item>
|
||||
<item quantity="other">%1$d kletsopdaterings</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many disappearing message timer changes are. %2$s is what the timer was ultimately set to.-->
|
||||
<plurals name="CollapsedEvent__disappearing_timer">
|
||||
<item quantity="one">%1$d disappearing message timer change · %2$s</item>
|
||||
<item quantity="other">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="one">%1$d verandering in verdwynboodskap-tydhouer · %2$s</item>
|
||||
<item quantity="other">%1$d veranderinge in verdwynboodskap-tydhouer · %2$s</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many call events are. -->
|
||||
<plurals name="CollapsedEvent__call_event">
|
||||
<item quantity="one">%1$d call event</item>
|
||||
<item quantity="other">%1$d call events</item>
|
||||
<item quantity="one">%1$d oproepgebeurtenis</item>
|
||||
<item quantity="other">%1$d oproepgebeurtenisse</item>
|
||||
</plurals>
|
||||
|
||||
<!-- audio_view -->
|
||||
@@ -5749,35 +5755,35 @@
|
||||
<!-- Row description for the plaintext chat export option -->
|
||||
<string name="ChatsSettingsFragment__export_chat_history_label">Voer \'n masjienleesbare JSON-kopie van al jou kletse uit. Verdwynende boodskappe sal nie uitgevoer word nie.</string>
|
||||
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Ontsluit om kletsgeskiedenis uit te voer</string>
|
||||
<!-- Snackbar shown when biometric authentication fails before a chat export -->
|
||||
<string name="ChatsSettingsFragment__authentication_failed">Authentication failed</string>
|
||||
<string name="ChatsSettingsFragment__authentication_failed">Bevestiging het misluk</string>
|
||||
|
||||
<!-- ChatExportDialogs -->
|
||||
<!-- Progress dialog message shown while canceling an in-progress chat export -->
|
||||
<string name="ChatExportDialogs__canceling_export">Canceling export…</string>
|
||||
<string name="ChatExportDialogs__canceling_export">Besig om uitvoer te kanselleer…</string>
|
||||
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
|
||||
<string name="ChatExportDialogs__export_chat_history_title">Voer kletsgeskiedenis uit?</string>
|
||||
<!-- Bold warning prefix in the export confirmation dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
|
||||
<string name="ChatExportDialogs__be_careful_warning">WEES VERSIGTIG!</string>
|
||||
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
|
||||
<string name="ChatExportDialogs__export_confirm_body">Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size.</string>
|
||||
<string name="ChatExportDialogs__export_confirm_body">Moenie hierdie lêer met enigiemand deel nie. Jou kletsgeskiedenis sal op jou toestel gestoor word, en ander toepassings kan toegang daartoe verkry, na gelang van jou toestel se toestemmings. Uitvoer met media sal \'n groter lêergrootte tot gevolg hê.</string>
|
||||
<!-- Button in the export confirmation dialog to export messages and media -->
|
||||
<string name="ChatExportDialogs__export_with_media">Export with media</string>
|
||||
<string name="ChatExportDialogs__export_with_media">Voer uit met media</string>
|
||||
<!-- Button in the export confirmation dialog to export messages only -->
|
||||
<string name="ChatExportDialogs__export_without_media">Export without media</string>
|
||||
<string name="ChatExportDialogs__export_without_media">Voer uit sonder media</string>
|
||||
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_title">Kies \'n vouer</string>
|
||||
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Kies \'n vouer in jou toestel se stoorruimte waar jou kletsgeskiedenis gestoor sal word.</string>
|
||||
<!-- Button that opens the system folder picker -->
|
||||
<string name="ChatExportDialogs__choose_folder_button">Kies vouer</string>
|
||||
<!-- Title of the dialog shown when a chat export finishes successfully -->
|
||||
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
|
||||
<string name="ChatExportDialogs__complete_title">Kletsuitvoer voltooi</string>
|
||||
<!-- Bold warning prefix in the export complete dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
|
||||
<string name="ChatExportDialogs__be_careful">WEES VERSIGTIG</string>
|
||||
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
|
||||
<string name="ChatExportDialogs__complete_body">where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions.</string>
|
||||
<string name="ChatExportDialogs__complete_body">waar jy jou klets-uitvoerlêer stoor, en moet dit met niemand deel nie. Ander toepassings op jou toestel kan toegang daartoe verkry, na gelang van jou foon se toestemmings.</string>
|
||||
|
||||
<!-- ChatFoldersEducationSheet -->
|
||||
<!-- Text in a bottom sheet describing chat folders and what they can be created for -->
|
||||
@@ -6124,7 +6130,7 @@
|
||||
<!-- End group option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__end_group">Beëindig groep</string>
|
||||
<!-- Banner shown at the top of group settings when the group has been ended -->
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">Hierdie groep is beëindig.</string>
|
||||
<!-- Archive chat option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__archive_chat">Argiveer klets</string>
|
||||
<!-- Delete chat option in group conversation settings screen -->
|
||||
@@ -6941,7 +6947,7 @@
|
||||
<!-- Message of dialog to confirm deletion of story -->
|
||||
<string name="MyStories__this_story_will_be_deleted">Hierdie storie sal geskrap word vir jou en almal wat dit ontvang het.</string>
|
||||
<!-- Message of dialog to confirm deletion of story in a terminated group -->
|
||||
<string name="MyStories__delete_story_terminated_group">It will only be deleted for you because the group has ended.</string>
|
||||
<string name="MyStories__delete_story_terminated_group">Dit sal slegs vir jou geskrap word, want die groep is beëindig.</string>
|
||||
<!-- Toast shown when story media cannot be saved -->
|
||||
<string name="MyStories__unable_to_save">Kon nie stoor nie</string>
|
||||
|
||||
@@ -7796,6 +7802,8 @@
|
||||
<string name="CallLogAdapter__incoming">Inkomend</string>
|
||||
<!-- Displayed for outgoing calls -->
|
||||
<string name="CallLogAdapter__outgoing">Uitgaande</string>
|
||||
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
|
||||
<string name="CallLogAdapter__unanswered">Unanswered</string>
|
||||
<!-- Displayed for missed calls -->
|
||||
<string name="CallLogAdapter__missed">Gemis</string>
|
||||
<!-- Displayed for one missed call declined by notification profile -->
|
||||
@@ -7880,6 +7888,8 @@
|
||||
<string name="CallLogFragment__get_started_by_calling_a_friend">Begin deur \'n vriend te bel.</string>
|
||||
<!-- Displayed as a message in a dialog when deleting multiple items -->
|
||||
<string name="CallLogFragment__call_links_youve_created">Oproepskakels wat jy geskep het, sal nie meer werk vir mense wat dit het nie.</string>
|
||||
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
|
||||
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
@@ -8193,7 +8203,7 @@
|
||||
<string name="UseNewOnDeviceBackups__not_now">Nie nou nie</string>
|
||||
|
||||
<!-- Text describing how to restore a backup displayed on the on-device backups screen -->
|
||||
<string name="OnDeviceBackupsScreen__to_restore_a_backup">"Om \'n rugsteun te herwin, installeer \'n nuwe kopie van Signal. Maak die toepassing oop, tik op \"Herwin rugsteun\" en vind dan die rugsteunlêer."</string>
|
||||
<string name="OnDeviceBackupsScreen__to_restore_a_backup">"Om 'n rugsteun te herwin, installeer 'n nuwe kopie van Signal. Maak die toepassing oop, tik op \"Herwin rugsteun\" en vind dan die rugsteunlêer."</string>
|
||||
|
||||
<!-- Title of a megaphone shown to prompt the user to verify their recovery key -->
|
||||
<string name="VerifyBackupKey__title">Verifieer jou herwinsleutel</string>
|
||||
@@ -8618,14 +8628,14 @@
|
||||
<!-- Progress message shown while ending a group -->
|
||||
<string name="EndGroupDialog__ending_group">Besig om groep te beëindig…</string>
|
||||
<!-- Message shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Couldn\'t end the group. Check your connection and try again.</string>
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Kon nie die groep beëindig nie. Gaan jou verbinding na en probeer weer.</string>
|
||||
<!-- Retry button shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__try_again">Probeer weer</string>
|
||||
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, with admin name. %1$s is the admin\'s display name -->
|
||||
<string name="TerminatedGroupBottomSheet__s_ended_the_group">%1$s het die Groep beëindig</string>
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, without admin name -->
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">The group has ended.</string>
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">Die groep is beëindig.</string>
|
||||
<!-- Body of bottom sheet shown when opening a terminated group -->
|
||||
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">Jy kan nie meer boodskappe of oproepe in hierdie groep stuur en ontvang nie.</string>
|
||||
|
||||
@@ -8997,7 +9007,7 @@
|
||||
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">Dit is dieselfde as die herwinsleutel op jou toestel.</string>
|
||||
<!-- Screen body shown when enabling on-device backups while Signal Secure Backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">This is the same as your Signal Secure Backups recovery key.</string>
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">Dis dieselfde as jou herstelsleutel vir Signal se Veilige Rugsteun.</string>
|
||||
<!-- Label shown above a list of actions that the recovery key can be used for. -->
|
||||
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Gebruik hierdie sleutel om:</string>
|
||||
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
|
||||
@@ -9802,13 +9812,13 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="AddGroupDetailsFragment__groups_with_same_members" translatable="false">Groups with same members (Labs)</string> -->
|
||||
|
||||
<!-- Title of the sheet shown when a local backup restore could not be completed -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__title">Can\'t restore backup</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__title">Kan nie rugsteun herwin nie</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining the likely cause -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring.</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">\'n Fout het voorgekom en jou rugsteun kan nie herwin word nie. Dit kan wees omdat die rugsteunvouer op jou toestel verskuif is terwyl herwinning van jou rugsteun aan die gang was.</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining how to try again -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\".</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">Om weer te probeer, deïnstalleer en herinstalleer Signal op hierdie toestel en kies \"Herwin of dra oor\".</string>
|
||||
|
||||
<!-- EOF -->
|
||||
</resources>
|
||||
|
||||
@@ -537,7 +537,7 @@
|
||||
<string name="ConversationActivity_unable_to_record_audio">تعذَّر تسجيل الصوت!</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_to_this_group">لا تستطيع إرسال الرسائل لهذه المجموعة لأنك لم تعد عضوًا فيها.</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="DisabledInputView__incognito_mode" translatable="false">Incognito mode (Labs)</string> -->
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">You can\'t send messages because the group has ended.</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">لا يمكنك إرسال رسائل لأن المجموعة أُغلِقت.</string>
|
||||
<string name="ConversationActivity_only_s_can_send_messages">يمكن فقط لـ %1$s إرسال الرسائل.</string>
|
||||
<string name="ConversationActivity_admins">المُشرِفون</string>
|
||||
<string name="ConversationActivity_message_an_admin">مراسلة أحد المُشرِفين</string>
|
||||
@@ -1875,7 +1875,7 @@
|
||||
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">حدث خطأ فى الشبكة.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">رابط هذه المجموعة غير مُفعَّل.</string>
|
||||
<!-- Toast message shown when trying to join a group via link but the group has been ended -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Unable to join this group.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">تعذَّر الانضمام إلى هذه المجموعة.</string>
|
||||
<!-- Toast message shown when trying to join a group by link but the group is full -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">حد المجموعة اكتمل. تعذَّر الانضمام إلى المجموعة.</string>
|
||||
<!-- Title shown when there was an known issue getting group information from a group link -->
|
||||
@@ -2052,6 +2052,7 @@
|
||||
<string name="MediaOverviewActivity_sent_by_you_to_s">مرسل منك إلى %1$s</string>
|
||||
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
|
||||
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">لم يتم إرسال ملف الوسائط بعد.</string>
|
||||
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
|
||||
|
||||
<!-- Megaphones -->
|
||||
<string name="Megaphones_remind_me_later">ذكِّرني لاحقًا</string>
|
||||
@@ -2120,7 +2121,7 @@
|
||||
<string name="MessageRecord_you_updated_group">قمتَ بتحديث المجموعة.</string>
|
||||
<string name="MessageRecord_the_group_was_updated">تمَّ تحديث المجموعة.</string>
|
||||
<!-- Update message shown a group is terminated, but the person that terminated it is unknown. -->
|
||||
<string name="MessageRecord_the_group_was_terminated">The group ended.</string>
|
||||
<string name="MessageRecord_the_group_was_terminated">أُغلِقت المجموعة.</string>
|
||||
<!-- Update message shown when a group is terminated, placeholder is the name of the person that terminated the group. -->
|
||||
<string name="MessageRecord_s_terminated_the_group">%1$s أنهى المجموعة</string>
|
||||
<!-- Update message shown when a group is terminated by you -->
|
||||
@@ -2141,6 +2142,10 @@
|
||||
<string name="MessageRecord_declined_voice_call">مكالمة صوتية مرفوضة</string>
|
||||
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
|
||||
<string name="MessageRecord_declined_video_call">مكالمة فيديو مرفوضة</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_voice_call">مكالمة صوتية لم يتم الرد عليها</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_video_call">مكالمة فيديو لم يتم الرد عليها</string>
|
||||
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
|
||||
<string name="MessageRecord_missed_voice_call_notification_profile">مكالمة صوتية فائتة بينما كان ملف الإشعارات في وضع تشغيل</string>
|
||||
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
|
||||
@@ -3468,6 +3473,7 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
|
||||
<string name="SupportEmailUtil_registration_lock">قفل التسجيل:</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
|
||||
|
||||
<!-- ThreadRecord -->
|
||||
<string name="ThreadRecord_group_updated">تمَّ تحديث المجموعة</string>
|
||||
@@ -4073,7 +4079,7 @@
|
||||
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">قفل تسجيل المُرفَق الصوتي</string>
|
||||
<string name="conversation_activity__message_could_not_be_sent">لم تُرسَل الرسالة. عليك التأكُّد من اتصالك بالانترنت ثم المحاولة مُجدَّدًا.</string>
|
||||
<!-- Dialog body shown when tapping a failed message in a terminated group -->
|
||||
<string name="conversation_activity__send_failed_group_ended">You can no longer send and receive messages in this group because the group has ended.</string>
|
||||
<string name="conversation_activity__send_failed_group_ended">لا يمكنك إرسال وتلقّي الرسائل في هذه المجموعة لأن المجموعة أُغلِقت.</string>
|
||||
<!-- Dialog body shown when tapping a group action button (e.g. invite friends) in a terminated group -->
|
||||
<string name="conversation_activity__group_action_not_allowed_group_ended">هذا الإجراء غير متوفر لأنه تمَّ إنهاء المجموعة.</string>
|
||||
<!-- Dialog body when a message failed to delete and retry is possible. -->
|
||||
@@ -4131,39 +4137,39 @@
|
||||
<string name="ConversationUpdateItem_update">تحديث</string>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__group_update">
|
||||
<item quantity="zero">%1$d group updates</item>
|
||||
<item quantity="one">%1$d group update</item>
|
||||
<item quantity="two">%1$d group updates</item>
|
||||
<item quantity="few">%1$d group updates</item>
|
||||
<item quantity="many">%1$d group updates</item>
|
||||
<item quantity="other">%1$d group updates</item>
|
||||
<item quantity="zero">%1$d تحديثات للمجموعة</item>
|
||||
<item quantity="one">%1$d تحديث للمجموعة</item>
|
||||
<item quantity="two">%1$d تحديثان للمجموعة</item>
|
||||
<item quantity="few">%1$d تحديثات للمجموعة</item>
|
||||
<item quantity="many">%1$d تحديثًا للمجموعة</item>
|
||||
<item quantity="other">%1$d تحديثٍ للمجموعة</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__chat_update">
|
||||
<item quantity="zero">%1$d chat updates</item>
|
||||
<item quantity="one">%1$d chat update</item>
|
||||
<item quantity="two">%1$d chat updates</item>
|
||||
<item quantity="few">%1$d chat updates</item>
|
||||
<item quantity="many">%1$d chat updates</item>
|
||||
<item quantity="other">%1$d chat updates</item>
|
||||
<item quantity="zero">%1$d تحديثات للدردشة</item>
|
||||
<item quantity="one">%1$d تحديث للدردشة</item>
|
||||
<item quantity="two">%1$d تحديثان للدردشة</item>
|
||||
<item quantity="few">%1$d تحديثات للدردشة</item>
|
||||
<item quantity="many">%1$d تحديثًا للدردشة</item>
|
||||
<item quantity="other">%1$d تحديثٍ للدردشة</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many disappearing message timer changes are. %2$s is what the timer was ultimately set to.-->
|
||||
<plurals name="CollapsedEvent__disappearing_timer">
|
||||
<item quantity="zero">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="one">%1$d disappearing message timer change · %2$s</item>
|
||||
<item quantity="two">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="few">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="many">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="other">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="zero">%1$d تغييرات في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
|
||||
<item quantity="one">%1$d تغيير في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
|
||||
<item quantity="two">%1$d تغييران في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
|
||||
<item quantity="few">%1$d تغييرات في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
|
||||
<item quantity="many">%1$d تغييرًا في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
|
||||
<item quantity="other">%1$d تغييرٍ في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many call events are. -->
|
||||
<plurals name="CollapsedEvent__call_event">
|
||||
<item quantity="zero">%1$d call events</item>
|
||||
<item quantity="one">%1$d call event</item>
|
||||
<item quantity="two">%1$d call events</item>
|
||||
<item quantity="few">%1$d call events</item>
|
||||
<item quantity="many">%1$d call events</item>
|
||||
<item quantity="other">%1$d call events</item>
|
||||
<item quantity="zero">%1$d أحداث مكالمة</item>
|
||||
<item quantity="one">%1$d حدث مكالمة</item>
|
||||
<item quantity="two">%1$d حدثا مكالمة</item>
|
||||
<item quantity="few">%1$d أحداث مكالمة</item>
|
||||
<item quantity="many">%1$d حدث مكالمة</item>
|
||||
<item quantity="other">%1$d حدث مكالمة</item>
|
||||
</plurals>
|
||||
|
||||
<!-- audio_view -->
|
||||
@@ -6313,35 +6319,35 @@
|
||||
<!-- Row description for the plaintext chat export option -->
|
||||
<string name="ChatsSettingsFragment__export_chat_history_label">تصدير نسخة JSON قابلة للقراءة آليًا لجميع دردشاتك. لن يتم تصدير الرسائل المؤقَّتة.</string>
|
||||
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">فتح القفل لتصدير سِجل الدردشة</string>
|
||||
<!-- Snackbar shown when biometric authentication fails before a chat export -->
|
||||
<string name="ChatsSettingsFragment__authentication_failed">فشلت المُصادَقة.</string>
|
||||
|
||||
<!-- ChatExportDialogs -->
|
||||
<!-- Progress dialog message shown while canceling an in-progress chat export -->
|
||||
<string name="ChatExportDialogs__canceling_export">Canceling export…</string>
|
||||
<string name="ChatExportDialogs__canceling_export">جارٍ إلغاء التصدير…</string>
|
||||
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
|
||||
<string name="ChatExportDialogs__export_chat_history_title">هل ترغبُ بتصدير سِجل الدردشة؟</string>
|
||||
<!-- Bold warning prefix in the export confirmation dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
|
||||
<string name="ChatExportDialogs__be_careful_warning">انتبه!</string>
|
||||
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
|
||||
<string name="ChatExportDialogs__export_confirm_body">Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size.</string>
|
||||
<string name="ChatExportDialogs__export_confirm_body">لا تشارِك هذا الملف مع أي أحد. سيتم حفظ سِجل الدردشة الخاص بك في جهازك وقد تتمكن التطبيقات الأخرى من الوصول إليه حسب أذونات جهازك. التصدير مع الوسائط قد ينتج عنه حجم ملف أكبر.</string>
|
||||
<!-- Button in the export confirmation dialog to export messages and media -->
|
||||
<string name="ChatExportDialogs__export_with_media">Export with media</string>
|
||||
<string name="ChatExportDialogs__export_with_media">التصدير مع الوسائط</string>
|
||||
<!-- Button in the export confirmation dialog to export messages only -->
|
||||
<string name="ChatExportDialogs__export_without_media">Export without media</string>
|
||||
<string name="ChatExportDialogs__export_without_media">التصدير دون الوسائط</string>
|
||||
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_title">اِختر مجلد</string>
|
||||
<string name="ChatExportDialogs__choose_a_folder_title">اختر مجلد</string>
|
||||
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">اختر مجلدًا في مساحة تخزين جهازك حيث سيتم تخرين سِجل دردشتك.</string>
|
||||
<!-- Button that opens the system folder picker -->
|
||||
<string name="ChatExportDialogs__choose_folder_button">اختر مجلدًا</string>
|
||||
<!-- Title of the dialog shown when a chat export finishes successfully -->
|
||||
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
|
||||
<string name="ChatExportDialogs__complete_title">اكتملَ تصدير الدردشة</string>
|
||||
<!-- Bold warning prefix in the export complete dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
|
||||
<string name="ChatExportDialogs__be_careful">انتبه</string>
|
||||
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
|
||||
<string name="ChatExportDialogs__complete_body">where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions.</string>
|
||||
<string name="ChatExportDialogs__complete_body">أين تُخزِّن ملف تصدير دردشتك ولا تشاركه مع أي أحد. قد تتمكّن تطبيقات أخرى في جهازك من الوصول إليها حسب الأذونات في جهازك.</string>
|
||||
|
||||
<!-- ChatFoldersEducationSheet -->
|
||||
<!-- Text in a bottom sheet describing chat folders and what they can be created for -->
|
||||
@@ -6712,7 +6718,7 @@
|
||||
<!-- End group option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__end_group">إنهاء المجموعة</string>
|
||||
<!-- Banner shown at the top of group settings when the group has been ended -->
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">أُغلِقت هذه المجموعة.</string>
|
||||
<!-- Archive chat option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__archive_chat">أرشفة الدردشة</string>
|
||||
<!-- Delete chat option in group conversation settings screen -->
|
||||
@@ -7565,7 +7571,7 @@
|
||||
<!-- Message of dialog to confirm deletion of story -->
|
||||
<string name="MyStories__this_story_will_be_deleted">ستُحذَف هذه القصة من عند جميع من استلمها بما فيهم أنت.</string>
|
||||
<!-- Message of dialog to confirm deletion of story in a terminated group -->
|
||||
<string name="MyStories__delete_story_terminated_group">It will only be deleted for you because the group has ended.</string>
|
||||
<string name="MyStories__delete_story_terminated_group">ستُحذف لديك فقط لأن المجموعة أُغلِقت.</string>
|
||||
<!-- Toast shown when story media cannot be saved -->
|
||||
<string name="MyStories__unable_to_save">تعذَّر الحفظ</string>
|
||||
|
||||
@@ -8508,6 +8514,8 @@
|
||||
<string name="CallLogAdapter__incoming">واردة</string>
|
||||
<!-- Displayed for outgoing calls -->
|
||||
<string name="CallLogAdapter__outgoing">صادرة</string>
|
||||
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
|
||||
<string name="CallLogAdapter__unanswered">Unanswered</string>
|
||||
<!-- Displayed for missed calls -->
|
||||
<string name="CallLogAdapter__missed">فائتة</string>
|
||||
<!-- Displayed for one missed call declined by notification profile -->
|
||||
@@ -8604,6 +8612,8 @@
|
||||
<string name="CallLogFragment__get_started_by_calling_a_friend">ابدأ بالاتصال بصديق.</string>
|
||||
<!-- Displayed as a message in a dialog when deleting multiple items -->
|
||||
<string name="CallLogFragment__call_links_youve_created">لن تشتغل روابط المكالمات التي أنشأتها بالنسبة للأشخاص الذين حصلوا عليها.</string>
|
||||
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
|
||||
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
@@ -9366,14 +9376,14 @@
|
||||
<!-- Progress message shown while ending a group -->
|
||||
<string name="EndGroupDialog__ending_group">جارٍ إنهاء المجموعة…</string>
|
||||
<!-- Message shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Couldn\'t end the group. Check your connection and try again.</string>
|
||||
<string name="EndGroupDialog__ending_the_group_failed">تعذَّر إغلاق المجموعة. تحقَّق من اتصالك بالشبكة ثم حاوِل مرّة أخرى.</string>
|
||||
<!-- Retry button shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__try_again">حاوِل مُجدَّدًا</string>
|
||||
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, with admin name. %1$s is the admin\'s display name -->
|
||||
<string name="TerminatedGroupBottomSheet__s_ended_the_group">%1$s أنهى المجموعة</string>
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, without admin name -->
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">The group has ended.</string>
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">أُغلِقت هذه المجموعة.</string>
|
||||
<!-- Body of bottom sheet shown when opening a terminated group -->
|
||||
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">لم يبق بإمكانك إرسال الرسائل واستلامها والتواصل بالمكالمات في هذه المجموعة.</string>
|
||||
|
||||
@@ -9769,7 +9779,7 @@
|
||||
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">هذا المفتاح مثل مفتاح الاستعادة على الجهاز الخاص بك.</string>
|
||||
<!-- Screen body shown when enabling on-device backups while Signal Secure Backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">This is the same as your Signal Secure Backups recovery key.</string>
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">هذا المفتاح مثل مفتاح استعادة النسخ الاحتياطية الآمنة الخاصة بك.</string>
|
||||
<!-- Label shown above a list of actions that the recovery key can be used for. -->
|
||||
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">استخدِم هذا المفتاح من أجل:</string>
|
||||
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
|
||||
@@ -10594,13 +10604,13 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="AddGroupDetailsFragment__groups_with_same_members" translatable="false">Groups with same members (Labs)</string> -->
|
||||
|
||||
<!-- Title of the sheet shown when a local backup restore could not be completed -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__title">Can\'t restore backup</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__title">تعذَّرت استعادة النسخة الاحتياطية</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining the likely cause -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring.</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">حدث خطأ وتعذَّر استعادة نسختك الاحتياطية. قد يكون هذا بسبب نقل مجلد النسخة الاحتياطية على جهازك بينما كانت النسخة الاحتياطية قيد الاستعادة.</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining how to try again -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\".</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">للمحاولة من جديد، قُم بإلغاء تثبيت وإعادة تثبيت سيجنال على هذا الجهاز، واختر \"الاستعادة أو النقل\".</string>
|
||||
|
||||
<!-- EOF -->
|
||||
</resources>
|
||||
|
||||
@@ -517,7 +517,7 @@
|
||||
<string name="ConversationActivity_unable_to_record_audio">Səs yazıla bilmir!</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_to_this_group">Üzv olmadığınız üçün bu qrupa mesaj göndərə bilməzsiniz.</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="DisabledInputView__incognito_mode" translatable="false">Incognito mode (Labs)</string> -->
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">You can\'t send messages because the group has ended.</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">Qrup sonlandırıldığı üçün mesajlar göndərə bilmirsiniz.</string>
|
||||
<string name="ConversationActivity_only_s_can_send_messages">Yalnız %1$s mesaj göndərə bilər.</string>
|
||||
<string name="ConversationActivity_admins">adminlər</string>
|
||||
<string name="ConversationActivity_message_an_admin">Bir adminə mesaj yazın</string>
|
||||
@@ -659,8 +659,8 @@
|
||||
<string name="ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation">Bu çatda özünüzə qeydlər yaza bilərsiniz. Hesabınızla əlaqələndirilmiş cihazlar varsa, yeni qeydlər sinxronlaşdırılacaq.</string>
|
||||
<!-- Text in banner to show how many people have the same name. -->
|
||||
<plurals name="ConversationFragment__d_group_members_have_the_same_name">
|
||||
<item quantity="one">%1$d group member have the same name.</item>
|
||||
<item quantity="other">%1$d group members have the same name.</item>
|
||||
<item quantity="one">%1$d qrup üzvünün adı eynidir.</item>
|
||||
<item quantity="other">%1$d qrup üzvünün adı eynidir.</item>
|
||||
</plurals>
|
||||
<string name="ConversationFragment__tap_to_review">Nəzərdən keçirmək üçün toxunun</string>
|
||||
<!-- The body of a banner that can show up at the top of a chat, letting the user know that you have two contacts with the same name -->
|
||||
@@ -1683,7 +1683,7 @@
|
||||
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Şəbəkə xətası ilə qarşılaşıldı.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Qrup bağlantısı aktiv deyil</string>
|
||||
<!-- Toast message shown when trying to join a group via link but the group has been ended -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Unable to join this group.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Bu qrupa qoşulmaq mümkün olmadı.</string>
|
||||
<!-- Toast message shown when trying to join a group by link but the group is full -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">Qrup limiti dolub, qrupa qoşulmaq mümkün deyil</string>
|
||||
<!-- Title shown when there was an known issue getting group information from a group link -->
|
||||
@@ -1832,6 +1832,7 @@
|
||||
<string name="MediaOverviewActivity_sent_by_you_to_s">%1$s əlaqəsinə göndərdiniz</string>
|
||||
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
|
||||
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Bu media faylı hələ göndərilməyib.</string>
|
||||
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
|
||||
|
||||
<!-- Megaphones -->
|
||||
<string name="Megaphones_remind_me_later">Daha sonra xatırlat</string>
|
||||
@@ -1900,7 +1901,7 @@
|
||||
<string name="MessageRecord_you_updated_group">Qrupu yenilədiniz.</string>
|
||||
<string name="MessageRecord_the_group_was_updated">Qrup yeniləndi.</string>
|
||||
<!-- Update message shown a group is terminated, but the person that terminated it is unknown. -->
|
||||
<string name="MessageRecord_the_group_was_terminated">The group ended.</string>
|
||||
<string name="MessageRecord_the_group_was_terminated">Qrup sonlandırıldı.</string>
|
||||
<!-- Update message shown when a group is terminated, placeholder is the name of the person that terminated the group. -->
|
||||
<string name="MessageRecord_s_terminated_the_group">%1$s qrupu sonlandırdı</string>
|
||||
<!-- Update message shown when a group is terminated by you -->
|
||||
@@ -1921,6 +1922,10 @@
|
||||
<string name="MessageRecord_declined_voice_call">İmtina edilmiş audio-zəng</string>
|
||||
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
|
||||
<string name="MessageRecord_declined_video_call">İmtina edilmiş video-zəng</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_voice_call">Cavablanmamış audio zəng</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_video_call">Cavablanmamış video zəng</string>
|
||||
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
|
||||
<string name="MessageRecord_missed_voice_call_notification_profile">Profil statusu aktiv ikən buraxılmış səsli zəng</string>
|
||||
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
|
||||
@@ -3076,6 +3081,7 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
|
||||
<string name="SupportEmailUtil_registration_lock">Qeydiyyat kilidi:</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
|
||||
|
||||
<!-- ThreadRecord -->
|
||||
<string name="ThreadRecord_group_updated">Qrup yeniləndi</string>
|
||||
@@ -3637,7 +3643,7 @@
|
||||
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">Səsyazmanı kilidlə</string>
|
||||
<string name="conversation_activity__message_could_not_be_sent">Mesaj göndərilə bilmir. Bağlantınızı yoxlayıb yenidən sınayın.</string>
|
||||
<!-- Dialog body shown when tapping a failed message in a terminated group -->
|
||||
<string name="conversation_activity__send_failed_group_ended">You can no longer send and receive messages in this group because the group has ended.</string>
|
||||
<string name="conversation_activity__send_failed_group_ended">Bu qrup sonlandırıldığına görə artıq burada mesajlaşa bilməyəcəksiniz.</string>
|
||||
<!-- Dialog body shown when tapping a group action button (e.g. invite friends) in a terminated group -->
|
||||
<string name="conversation_activity__group_action_not_allowed_group_ended">Bu addım mövcud deyil, çünki qrup sonlandırılıb.</string>
|
||||
<!-- Dialog body when a message failed to delete and retry is possible. -->
|
||||
@@ -3695,23 +3701,23 @@
|
||||
<string name="ConversationUpdateItem_update">Yenilə</string>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__group_update">
|
||||
<item quantity="one">%1$d group update</item>
|
||||
<item quantity="other">%1$d group updates</item>
|
||||
<item quantity="one">%1$d qrup yeniləməsi</item>
|
||||
<item quantity="other">%1$d qrup yeniləməsi</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__chat_update">
|
||||
<item quantity="one">%1$d chat update</item>
|
||||
<item quantity="other">%1$d chat updates</item>
|
||||
<item quantity="one">%1$d çat yeniləməsi</item>
|
||||
<item quantity="other">%1$d çat yeniləməsi</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many disappearing message timer changes are. %2$s is what the timer was ultimately set to.-->
|
||||
<plurals name="CollapsedEvent__disappearing_timer">
|
||||
<item quantity="one">%1$d disappearing message timer change · %2$s</item>
|
||||
<item quantity="other">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="one">%1$d avtomatik yox olan mesaj taymeri dəyişdi · %2$s</item>
|
||||
<item quantity="other">%1$d avtomatik yox olan mesaj taymeri dəyişdi · %2$s</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many call events are. -->
|
||||
<plurals name="CollapsedEvent__call_event">
|
||||
<item quantity="one">%1$d call event</item>
|
||||
<item quantity="other">%1$d call events</item>
|
||||
<item quantity="one">%1$d zəng etmə</item>
|
||||
<item quantity="other">%1$d zəng etmə</item>
|
||||
</plurals>
|
||||
|
||||
<!-- audio_view -->
|
||||
@@ -5749,35 +5755,35 @@
|
||||
<!-- Row description for the plaintext chat export option -->
|
||||
<string name="ChatsSettingsFragment__export_chat_history_label">Bütün çatların maşın tərəfindən oxuna bilən JSON kopiyasını ixrac et. Avtomatik yox olan mesajlar ixrac edilməyəvək.</string>
|
||||
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Çat tarixçəsini ixrac etmək üçün kilidi açın</string>
|
||||
<!-- Snackbar shown when biometric authentication fails before a chat export -->
|
||||
<string name="ChatsSettingsFragment__authentication_failed">Authentication failed</string>
|
||||
<string name="ChatsSettingsFragment__authentication_failed">Autentifikasiya baş tutmadı</string>
|
||||
|
||||
<!-- ChatExportDialogs -->
|
||||
<!-- Progress dialog message shown while canceling an in-progress chat export -->
|
||||
<string name="ChatExportDialogs__canceling_export">Canceling export…</string>
|
||||
<string name="ChatExportDialogs__canceling_export">İxrac ləğv olunur…</string>
|
||||
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
|
||||
<string name="ChatExportDialogs__export_chat_history_title">Çat tarixçəsi ixrac edilsin?</string>
|
||||
<!-- Bold warning prefix in the export confirmation dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
|
||||
<string name="ChatExportDialogs__be_careful_warning">EHTİYATLI OLUN!</string>
|
||||
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
|
||||
<string name="ChatExportDialogs__export_confirm_body">Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size.</string>
|
||||
<string name="ChatExportDialogs__export_confirm_body">Bu faylı heç kimlə PAYLAŞMAYIN. Çat tarixçəsi cihazınızda saxlanacaq və cihaz icazələrindən asılı olaraq digər tətbiqlər ona daxil ola bilər. Media ilə ixrac edildikdə fayl ölçüsü daha böyük olacaq.</string>
|
||||
<!-- Button in the export confirmation dialog to export messages and media -->
|
||||
<string name="ChatExportDialogs__export_with_media">Export with media</string>
|
||||
<string name="ChatExportDialogs__export_with_media">Media faylı ilə ixrac et</string>
|
||||
<!-- Button in the export confirmation dialog to export messages only -->
|
||||
<string name="ChatExportDialogs__export_without_media">Export without media</string>
|
||||
<string name="ChatExportDialogs__export_without_media">Media faylı olmadan ixrac et</string>
|
||||
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_title">Bir qovluq seç</string>
|
||||
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Cihaz yaddaşında çat tarixçəsinin saxlanacağı qovluğu seçin.</string>
|
||||
<!-- Button that opens the system folder picker -->
|
||||
<string name="ChatExportDialogs__choose_folder_button">Qovluq seçin</string>
|
||||
<!-- Title of the dialog shown when a chat export finishes successfully -->
|
||||
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
|
||||
<string name="ChatExportDialogs__complete_title">Çat ixracı tamamlandı</string>
|
||||
<!-- Bold warning prefix in the export complete dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
|
||||
<string name="ChatExportDialogs__be_careful">EHTİYATLI OLUN</string>
|
||||
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
|
||||
<string name="ChatExportDialogs__complete_body">where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions.</string>
|
||||
<string name="ChatExportDialogs__complete_body">çatı ixrac etdiyiniz faylı saxladığınız yeri diqqətlə seçin və onu heç kimlə paylaşmayın. Cihazınızdakı digər tətbiqlər cihaz icazələrindən asılı olaraq ona daxil ola bilər.</string>
|
||||
|
||||
<!-- ChatFoldersEducationSheet -->
|
||||
<!-- Text in a bottom sheet describing chat folders and what they can be created for -->
|
||||
@@ -6124,7 +6130,7 @@
|
||||
<!-- End group option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__end_group">Qrupu sonlandır</string>
|
||||
<!-- Banner shown at the top of group settings when the group has been ended -->
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">Bu qrup sonlandırılıb.</string>
|
||||
<!-- Archive chat option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__archive_chat">Çatı arxivləşdir</string>
|
||||
<!-- Delete chat option in group conversation settings screen -->
|
||||
@@ -6941,7 +6947,7 @@
|
||||
<!-- Message of dialog to confirm deletion of story -->
|
||||
<string name="MyStories__this_story_will_be_deleted">Bu hekayə siz və bunu alan hər kəs üçün silinəcək.</string>
|
||||
<!-- Message of dialog to confirm deletion of story in a terminated group -->
|
||||
<string name="MyStories__delete_story_terminated_group">It will only be deleted for you because the group has ended.</string>
|
||||
<string name="MyStories__delete_story_terminated_group">Qrup sonlandırıldığına görə bu yalnız sizin üçün silinəcək.</string>
|
||||
<!-- Toast shown when story media cannot be saved -->
|
||||
<string name="MyStories__unable_to_save">Yaddaşda saxlamaq mümkün deyil</string>
|
||||
|
||||
@@ -7796,6 +7802,8 @@
|
||||
<string name="CallLogAdapter__incoming">Gələn zəng</string>
|
||||
<!-- Displayed for outgoing calls -->
|
||||
<string name="CallLogAdapter__outgoing">Gedən zəng</string>
|
||||
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
|
||||
<string name="CallLogAdapter__unanswered">Unanswered</string>
|
||||
<!-- Displayed for missed calls -->
|
||||
<string name="CallLogAdapter__missed">Cavabsız zəng</string>
|
||||
<!-- Displayed for one missed call declined by notification profile -->
|
||||
@@ -7880,6 +7888,8 @@
|
||||
<string name="CallLogFragment__get_started_by_calling_a_friend">Bir dosta zəng edərək başlayın.</string>
|
||||
<!-- Displayed as a message in a dialog when deleting multiple items -->
|
||||
<string name="CallLogFragment__call_links_youve_created">Yaratdığınız zəng keçidlərini paylaşdığınız insanlar artıq bu keçidləri görə bilməyəcək.</string>
|
||||
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
|
||||
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
@@ -8618,14 +8628,14 @@
|
||||
<!-- Progress message shown while ending a group -->
|
||||
<string name="EndGroupDialog__ending_group">Qrup sonlandırılır…</string>
|
||||
<!-- Message shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Couldn\'t end the group. Check your connection and try again.</string>
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Qrupu sonlandırmaq mümkün olmadı. Bağlantınızı yoxlayıb yenidən cəhd edin.</string>
|
||||
<!-- Retry button shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__try_again">Yenidən cəhd edin</string>
|
||||
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, with admin name. %1$s is the admin\'s display name -->
|
||||
<string name="TerminatedGroupBottomSheet__s_ended_the_group">%1$s Qrupu sonlandırdı</string>
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, without admin name -->
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">The group has ended.</string>
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">Bu qrup sonlandırılıb.</string>
|
||||
<!-- Body of bottom sheet shown when opening a terminated group -->
|
||||
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">Bu qrupda artıq mesajlaşa və ya zənglər edə bilməyəcəksiniz.</string>
|
||||
|
||||
@@ -8997,7 +9007,7 @@
|
||||
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">Bu, cihazdakı bərpa şifrənizlə eynidir.</string>
|
||||
<!-- Screen body shown when enabling on-device backups while Signal Secure Backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">This is the same as your Signal Secure Backups recovery key.</string>
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">Bu, Signal təhlükəsiz ehtiyat nüsxələrinin bərpa şifrəsi ilə eynidir.</string>
|
||||
<!-- Label shown above a list of actions that the recovery key can be used for. -->
|
||||
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Bu şifrədən aşağıdakılar üçün istifadə edin:</string>
|
||||
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
|
||||
@@ -9802,13 +9812,13 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="AddGroupDetailsFragment__groups_with_same_members" translatable="false">Groups with same members (Labs)</string> -->
|
||||
|
||||
<!-- Title of the sheet shown when a local backup restore could not be completed -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__title">Can\'t restore backup</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__title">Ehtiyat nüsxəni bərpa etmək mümkün deyil</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining the likely cause -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring.</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">Xəta baş verdiyi üçün ehtiyat nüsxəniz bərpa oluna bilmir. Bunun səbəbi ehtiyat nüsxəniz bərpa olunarkən onun qovluğunun yerinin cihazınızda dəyişdirilməsi ilə bağlı ola bilər.</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining how to try again -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\".</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">Yenidən cəhd etmək üçün bu cihazda Signal-ın quraşdırmasını ləğv edib, təkrar quraşdırın və \"Bərpa et və ya köçür\" seçimini edin.</string>
|
||||
|
||||
<!-- EOF -->
|
||||
</resources>
|
||||
|
||||
@@ -527,7 +527,7 @@
|
||||
<string name="ConversationActivity_unable_to_record_audio">Немагчыма запісаць аўдыё!</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_to_this_group">Вы не можаце адпраўляць паведамленні ў гэтую групу, таму што вы больш не з\'яўляецеся яе ўдзельнікам.</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="DisabledInputView__incognito_mode" translatable="false">Incognito mode (Labs)</string> -->
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">You can\'t send messages because the group has ended.</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">Вы не можаце адпраўляць паведамленні, таму што гэта група спынена.</string>
|
||||
<string name="ConversationActivity_only_s_can_send_messages">Толькі %1$s можа адпраўляць паведамленні.</string>
|
||||
<string name="ConversationActivity_admins">адміністратары</string>
|
||||
<string name="ConversationActivity_message_an_admin">Напісаць паведамленне адміністратару</string>
|
||||
@@ -685,7 +685,7 @@
|
||||
<string name="ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation">Вы можаце дадаваць нататкі для сябе ў гэтым чаце. Калі ваш уліковы запіс мае звязаныя прылады, новыя нататкі будуць сінхранізаваны.</string>
|
||||
<!-- Text in banner to show how many people have the same name. -->
|
||||
<plurals name="ConversationFragment__d_group_members_have_the_same_name">
|
||||
<item quantity="one">%1$d удзельнік групы мае тое самае імя.</item>
|
||||
<item quantity="one">%1$d удзельнік групы мае аднолькавае імя.</item>
|
||||
<item quantity="few">%1$d удзельнікі групы маюць аднолькавыя імёны.</item>
|
||||
<item quantity="many">%1$d удзельнікаў групы маюць аднолькавыя імёны.</item>
|
||||
<item quantity="other">%1$d удзельнікаў групы маюць аднолькавыя імёны.</item>
|
||||
@@ -1779,7 +1779,7 @@
|
||||
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Адбылася памылка сеткі.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Гэта спасылка на групу неактыўная</string>
|
||||
<!-- Toast message shown when trying to join a group via link but the group has been ended -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Unable to join this group.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Немагчыма далучыцца да гэтай групы.</string>
|
||||
<!-- Toast message shown when trying to join a group by link but the group is full -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">Ліміт групы дасягнуты: далучыцца да яе немагчыма</string>
|
||||
<!-- Title shown when there was an known issue getting group information from a group link -->
|
||||
@@ -1942,6 +1942,7 @@
|
||||
<string name="MediaOverviewActivity_sent_by_you_to_s">Адпраўлена вамі да %1$s</string>
|
||||
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
|
||||
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Гэты медыяфайл яшчэ не адпраўлены.</string>
|
||||
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
|
||||
|
||||
<!-- Megaphones -->
|
||||
<string name="Megaphones_remind_me_later">Нагадаць мне пазней</string>
|
||||
@@ -2010,7 +2011,7 @@
|
||||
<string name="MessageRecord_you_updated_group">Вы абнавілі групу.</string>
|
||||
<string name="MessageRecord_the_group_was_updated">Група абноўленая.</string>
|
||||
<!-- Update message shown a group is terminated, but the person that terminated it is unknown. -->
|
||||
<string name="MessageRecord_the_group_was_terminated">The group ended.</string>
|
||||
<string name="MessageRecord_the_group_was_terminated">Група была спынена.</string>
|
||||
<!-- Update message shown when a group is terminated, placeholder is the name of the person that terminated the group. -->
|
||||
<string name="MessageRecord_s_terminated_the_group">%1$s спыніў(-ла) гэту групу</string>
|
||||
<!-- Update message shown when a group is terminated by you -->
|
||||
@@ -2031,6 +2032,10 @@
|
||||
<string name="MessageRecord_declined_voice_call">Галасавы выклік адхілены</string>
|
||||
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
|
||||
<string name="MessageRecord_declined_video_call">Відэавыклік адхілены</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_voice_call">Неадказаны галасавы званок</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_video_call">Неадказаны відэазванок</string>
|
||||
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
|
||||
<string name="MessageRecord_missed_voice_call_notification_profile">Прапушчаны галасавы званок, калі ўключаны профіль апавяшчэнняў</string>
|
||||
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
|
||||
@@ -3272,6 +3277,7 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
|
||||
<string name="SupportEmailUtil_registration_lock">Блакіроўка рэгістрацыі:</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
|
||||
|
||||
<!-- ThreadRecord -->
|
||||
<string name="ThreadRecord_group_updated">Група абноўленая</string>
|
||||
@@ -3855,7 +3861,7 @@
|
||||
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">Заблакаваць запіс аўдыядалучэння</string>
|
||||
<string name="conversation_activity__message_could_not_be_sent">Не атрымалася адправіць паведамленне. Праверце падключэнне і паўтарыце спробу.</string>
|
||||
<!-- Dialog body shown when tapping a failed message in a terminated group -->
|
||||
<string name="conversation_activity__send_failed_group_ended">You can no longer send and receive messages in this group because the group has ended.</string>
|
||||
<string name="conversation_activity__send_failed_group_ended">Вы больш не зможаце адпраўляць і атрымліваць паведамленні ў гэтай групе, таму што група была спынена.</string>
|
||||
<!-- Dialog body shown when tapping a group action button (e.g. invite friends) in a terminated group -->
|
||||
<string name="conversation_activity__group_action_not_allowed_group_ended">Гэта дзеянне недаступнае, бо гэта група спынена.</string>
|
||||
<!-- Dialog body when a message failed to delete and retry is possible. -->
|
||||
@@ -3913,31 +3919,31 @@
|
||||
<string name="ConversationUpdateItem_update">Абнавіць</string>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__group_update">
|
||||
<item quantity="one">%1$d group update</item>
|
||||
<item quantity="few">%1$d group updates</item>
|
||||
<item quantity="many">%1$d group updates</item>
|
||||
<item quantity="other">%1$d group updates</item>
|
||||
<item quantity="one">%1$d абнаўленне ў групе</item>
|
||||
<item quantity="few">%1$d абнаўленнi ў групе</item>
|
||||
<item quantity="many">%1$d абнаўленняў у групе</item>
|
||||
<item quantity="other">%1$d абнаўленняў у групе</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__chat_update">
|
||||
<item quantity="one">%1$d chat update</item>
|
||||
<item quantity="few">%1$d chat updates</item>
|
||||
<item quantity="many">%1$d chat updates</item>
|
||||
<item quantity="other">%1$d chat updates</item>
|
||||
<item quantity="one">%1$d абнаўленне ў чаце</item>
|
||||
<item quantity="few">%1$d абнаўленнi ў чаце</item>
|
||||
<item quantity="many">%1$d абнаўленняў у чаце</item>
|
||||
<item quantity="other">%1$d абнаўленняў у чаце</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many disappearing message timer changes are. %2$s is what the timer was ultimately set to.-->
|
||||
<plurals name="CollapsedEvent__disappearing_timer">
|
||||
<item quantity="one">%1$d disappearing message timer change · %2$s</item>
|
||||
<item quantity="few">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="many">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="other">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="one">%1$d змена таймера знікнення паведамленняў · %2$s</item>
|
||||
<item quantity="few">%1$d змены таймера знікнення паведамленняў · %2$s</item>
|
||||
<item quantity="many">%1$d зменаў таймера знікнення паведамленняў · %2$s</item>
|
||||
<item quantity="other">%1$d зменаў таймера знікнення паведамленняў · %2$s</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many call events are. -->
|
||||
<plurals name="CollapsedEvent__call_event">
|
||||
<item quantity="one">%1$d call event</item>
|
||||
<item quantity="few">%1$d call events</item>
|
||||
<item quantity="many">%1$d call events</item>
|
||||
<item quantity="other">%1$d call events</item>
|
||||
<item quantity="one">%1$d падзея званка</item>
|
||||
<item quantity="few">%1$d падзеi званка</item>
|
||||
<item quantity="many">%1$d падзей званка</item>
|
||||
<item quantity="other">%1$d падзей званка</item>
|
||||
</plurals>
|
||||
|
||||
<!-- audio_view -->
|
||||
@@ -6027,39 +6033,39 @@
|
||||
</plurals>
|
||||
|
||||
<!-- Row title for the option to export chat history as a plaintext archive -->
|
||||
<string name="ChatsSettingsFragment__export_chat_history">Export chat history</string>
|
||||
<string name="ChatsSettingsFragment__export_chat_history">Экспартаваць гісторыю чата</string>
|
||||
<!-- Row description for the plaintext chat export option -->
|
||||
<string name="ChatsSettingsFragment__export_chat_history_label">Export a machine-readable JSON copy of all your chats. Disappearing messages will not be exported.</string>
|
||||
<string name="ChatsSettingsFragment__export_chat_history_label">Экспартуйце копію ўсіх вашых чатаў у фармаце JSON, які счытвае электронныя прылады. Знікаючыя паведамлення не будуць экспартаваны.</string>
|
||||
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Разблакіруйце, каб экспартаваць гісторыю чата</string>
|
||||
<!-- Snackbar shown when biometric authentication fails before a chat export -->
|
||||
<string name="ChatsSettingsFragment__authentication_failed">Памылка аўтэнтыфікацыі</string>
|
||||
|
||||
<!-- ChatExportDialogs -->
|
||||
<!-- Progress dialog message shown while canceling an in-progress chat export -->
|
||||
<string name="ChatExportDialogs__canceling_export">Canceling export…</string>
|
||||
<string name="ChatExportDialogs__canceling_export">Скасаванне экспарту…</string>
|
||||
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
|
||||
<string name="ChatExportDialogs__export_chat_history_title">Export chat history?</string>
|
||||
<string name="ChatExportDialogs__export_chat_history_title">Экспартаваць гісторыю чата?</string>
|
||||
<!-- Bold warning prefix in the export confirmation dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
|
||||
<string name="ChatExportDialogs__be_careful_warning">БУДЗЬЦЕ ПIЛЬНЫМI!</string>
|
||||
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
|
||||
<string name="ChatExportDialogs__export_confirm_body">Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size.</string>
|
||||
<string name="ChatExportDialogs__export_confirm_body">Нікому НЕ перадавайце гэты файл. Ваша гісторыя чата будзе захавана на вашай прыладзе. Іншыя праграмы могуць мець доступ да яе ў залежнасці ад наладжаных вамі дазволаў. Калi экспартаваць разам з медыяфайламi, памер файла можа павялічыцца.</string>
|
||||
<!-- Button in the export confirmation dialog to export messages and media -->
|
||||
<string name="ChatExportDialogs__export_with_media">Export with media</string>
|
||||
<string name="ChatExportDialogs__export_with_media">Экспартаваць з медыяфайламi</string>
|
||||
<!-- Button in the export confirmation dialog to export messages only -->
|
||||
<string name="ChatExportDialogs__export_without_media">Export without media</string>
|
||||
<string name="ChatExportDialogs__export_without_media">Экспартаваць без медыяфайлаў</string>
|
||||
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_title">Выбраць папку</string>
|
||||
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Выберыце папку ў сховiшчы прылады, дзе будзе захоўвацца ваша гісторыя чата.</string>
|
||||
<!-- Button that opens the system folder picker -->
|
||||
<string name="ChatExportDialogs__choose_folder_button">Выбраць папку</string>
|
||||
<!-- Title of the dialog shown when a chat export finishes successfully -->
|
||||
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
|
||||
<string name="ChatExportDialogs__complete_title">Экспарт чата адбыўся</string>
|
||||
<!-- Bold warning prefix in the export complete dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
|
||||
<string name="ChatExportDialogs__be_careful">БУДЗЬЦЕ ПIЛЬНЫМI,</string>
|
||||
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
|
||||
<string name="ChatExportDialogs__complete_body">where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions.</string>
|
||||
<string name="ChatExportDialogs__complete_body">дзе вы захоўваеце файл экспарту вашага чата і нікому не перадавайце яго. Іншыя праграмы на вашай прыладзе могуць мець доступ да яго ў залежнасці ад наладжаных вамі дазволаў.</string>
|
||||
|
||||
<!-- ChatFoldersEducationSheet -->
|
||||
<!-- Text in a bottom sheet describing chat folders and what they can be created for -->
|
||||
@@ -6418,7 +6424,7 @@
|
||||
<!-- End group option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__end_group">Спыніць групу</string>
|
||||
<!-- Banner shown at the top of group settings when the group has been ended -->
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">Гэта група была спынена.</string>
|
||||
<!-- Archive chat option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__archive_chat">Архіваваць чат</string>
|
||||
<!-- Delete chat option in group conversation settings screen -->
|
||||
@@ -7253,7 +7259,7 @@
|
||||
<!-- Message of dialog to confirm deletion of story -->
|
||||
<string name="MyStories__this_story_will_be_deleted">Гэта гісторыя будзе выдалена для вас і ўсіх, хто яе атрымаў.</string>
|
||||
<!-- Message of dialog to confirm deletion of story in a terminated group -->
|
||||
<string name="MyStories__delete_story_terminated_group">It will only be deleted for you because the group has ended.</string>
|
||||
<string name="MyStories__delete_story_terminated_group">Яна будзе выдалена толькі для вас, таму што група была спынена.</string>
|
||||
<!-- Toast shown when story media cannot be saved -->
|
||||
<string name="MyStories__unable_to_save">Не атрымалася захаваць</string>
|
||||
|
||||
@@ -8152,6 +8158,8 @@
|
||||
<string name="CallLogAdapter__incoming">Уваходны</string>
|
||||
<!-- Displayed for outgoing calls -->
|
||||
<string name="CallLogAdapter__outgoing">Выходны</string>
|
||||
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
|
||||
<string name="CallLogAdapter__unanswered">Unanswered</string>
|
||||
<!-- Displayed for missed calls -->
|
||||
<string name="CallLogAdapter__missed">Прапушчаныя</string>
|
||||
<!-- Displayed for one missed call declined by notification profile -->
|
||||
@@ -8242,6 +8250,8 @@
|
||||
<string name="CallLogFragment__get_started_by_calling_a_friend">Пачніце са званка сябру.</string>
|
||||
<!-- Displayed as a message in a dialog when deleting multiple items -->
|
||||
<string name="CallLogFragment__call_links_youve_created">Створаныя вамі спасылкі на званкі больш не будуць дзейнічаць для тых, хто мае іх.</string>
|
||||
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
|
||||
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
@@ -8992,14 +9002,14 @@
|
||||
<!-- Progress message shown while ending a group -->
|
||||
<string name="EndGroupDialog__ending_group">Спыняем групу…</string>
|
||||
<!-- Message shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Couldn\'t end the group. Check your connection and try again.</string>
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Не атрымалася спыніць гэту групу. Праверце падключэнне і паўтарыце спробу.</string>
|
||||
<!-- Retry button shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__try_again">Паўтарыць спробу</string>
|
||||
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, with admin name. %1$s is the admin\'s display name -->
|
||||
<string name="TerminatedGroupBottomSheet__s_ended_the_group">%1$s спыніў(-ла) групу</string>
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, without admin name -->
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">The group has ended.</string>
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">Гэта група была спынена.</string>
|
||||
<!-- Body of bottom sheet shown when opening a terminated group -->
|
||||
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">Вы больш не зможаце пісаць і тэлефанаваць у гэтай групе.</string>
|
||||
|
||||
@@ -9383,7 +9393,7 @@
|
||||
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">Гэта тое ж самае, што і ваш код для аднаўлення на прыладзе.</string>
|
||||
<!-- Screen body shown when enabling on-device backups while Signal Secure Backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">This is the same as your Signal Secure Backups recovery key.</string>
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">Гэта тое ж самае, што і ваш код для аднаўлення бяспечнага рэзервовага капіравання.</string>
|
||||
<!-- Label shown above a list of actions that the recovery key can be used for. -->
|
||||
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Выкарыстоўвайце гэты ключ, каб:</string>
|
||||
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
|
||||
@@ -10198,13 +10208,13 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="AddGroupDetailsFragment__groups_with_same_members" translatable="false">Groups with same members (Labs)</string> -->
|
||||
|
||||
<!-- Title of the sheet shown when a local backup restore could not be completed -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__title">Can\'t restore backup</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__title">Не атрымалася аднавіць рэзервовую копію</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining the likely cause -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring.</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">Узнікла памылка, таму рэзервовую копію немагчыма аднавіць. Магчыма, гэта адбылося таму, што падчас аднаўлення рэзервовай копіі папка з рэзервовай копіяй была перамешчана на вашу прыладу.</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining how to try again -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\".</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">Каб паспрабаваць яшчэ раз, выдаліце і пераўсталюйце Signal на гэтай прыладзе і потым выберыце «Аднавіць або перанесці».</string>
|
||||
|
||||
<!-- EOF -->
|
||||
</resources>
|
||||
|
||||
@@ -517,7 +517,7 @@
|
||||
<string name="ConversationActivity_unable_to_record_audio">Не може да бъде записано аудио!</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_to_this_group">Не може да изпращате съобщения на тази група, защото вече не сте неин член.</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="DisabledInputView__incognito_mode" translatable="false">Incognito mode (Labs)</string> -->
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">You can\'t send messages because the group has ended.</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">Не можете да изпращате съобщения, защото групата е прекратена.</string>
|
||||
<string name="ConversationActivity_only_s_can_send_messages">Само %1$s могат да изпращат съобщения.</string>
|
||||
<string name="ConversationActivity_admins">администратори</string>
|
||||
<string name="ConversationActivity_message_an_admin">Пиши на администратор</string>
|
||||
@@ -659,8 +659,8 @@
|
||||
<string name="ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation">В този чат можете да добавяте бележки за себе си. Ако акаунтът ви има свързани устройства, новите бележки ще бъдат синхронизирани.</string>
|
||||
<!-- Text in banner to show how many people have the same name. -->
|
||||
<plurals name="ConversationFragment__d_group_members_have_the_same_name">
|
||||
<item quantity="one">%1$d group member have the same name.</item>
|
||||
<item quantity="other">%1$d group members have the same name.</item>
|
||||
<item quantity="one">%1$d член на групата е със същото име.</item>
|
||||
<item quantity="other">%1$d членове на групата са със същото име.</item>
|
||||
</plurals>
|
||||
<string name="ConversationFragment__tap_to_review">Натиснете за преглед</string>
|
||||
<!-- The body of a banner that can show up at the top of a chat, letting the user know that you have two contacts with the same name -->
|
||||
@@ -1683,7 +1683,7 @@
|
||||
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Възникна мрежова грешка.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Тази групова връзка не е активна</string>
|
||||
<!-- Toast message shown when trying to join a group via link but the group has been ended -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Unable to join this group.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Неуспешно присъединяване към тази група.</string>
|
||||
<!-- Toast message shown when trying to join a group by link but the group is full -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">Лимитът на групата е достигнат, не можете да се присъедините към групата</string>
|
||||
<!-- Title shown when there was an known issue getting group information from a group link -->
|
||||
@@ -1832,6 +1832,7 @@
|
||||
<string name="MediaOverviewActivity_sent_by_you_to_s">Изпратено от тебе до %1$s</string>
|
||||
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
|
||||
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Тази мултимедия все още не е изпратена.</string>
|
||||
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
|
||||
|
||||
<!-- Megaphones -->
|
||||
<string name="Megaphones_remind_me_later">Напомнете ми по-късно</string>
|
||||
@@ -1900,7 +1901,7 @@
|
||||
<string name="MessageRecord_you_updated_group">Обновихте групата.</string>
|
||||
<string name="MessageRecord_the_group_was_updated">Групата беше актуализирана.</string>
|
||||
<!-- Update message shown a group is terminated, but the person that terminated it is unknown. -->
|
||||
<string name="MessageRecord_the_group_was_terminated">The group ended.</string>
|
||||
<string name="MessageRecord_the_group_was_terminated">Групата е прекратена.</string>
|
||||
<!-- Update message shown when a group is terminated, placeholder is the name of the person that terminated the group. -->
|
||||
<string name="MessageRecord_s_terminated_the_group">%1$s прекрати групата</string>
|
||||
<!-- Update message shown when a group is terminated by you -->
|
||||
@@ -1921,6 +1922,10 @@
|
||||
<string name="MessageRecord_declined_voice_call">Отказано гласово обаждане</string>
|
||||
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
|
||||
<string name="MessageRecord_declined_video_call">Отказано видео обаждане</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_voice_call">Гласово повикване без отговор</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_video_call">Видео повикване без отговор</string>
|
||||
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
|
||||
<string name="MessageRecord_missed_voice_call_notification_profile">Пропуснато гласово обаждане при включен профил за известия</string>
|
||||
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
|
||||
@@ -3076,6 +3081,7 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
|
||||
<string name="SupportEmailUtil_registration_lock">Регистрационно заключване:</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
|
||||
|
||||
<!-- ThreadRecord -->
|
||||
<string name="ThreadRecord_group_updated">Групата е обновена</string>
|
||||
@@ -3637,7 +3643,7 @@
|
||||
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">Заключване на запис на прикачен файл с аудио</string>
|
||||
<string name="conversation_activity__message_could_not_be_sent">Съобщението не можа да бъде изпратено. Проверете връзката си и опитайте отново.</string>
|
||||
<!-- Dialog body shown when tapping a failed message in a terminated group -->
|
||||
<string name="conversation_activity__send_failed_group_ended">You can no longer send and receive messages in this group because the group has ended.</string>
|
||||
<string name="conversation_activity__send_failed_group_ended">Вече не можете да изпращате и получавате съобщения в тази група, защото групата е била прекратена.</string>
|
||||
<!-- Dialog body shown when tapping a group action button (e.g. invite friends) in a terminated group -->
|
||||
<string name="conversation_activity__group_action_not_allowed_group_ended">Това действие не е достъпно, защото групата е прекратена.</string>
|
||||
<!-- Dialog body when a message failed to delete and retry is possible. -->
|
||||
@@ -3695,23 +3701,23 @@
|
||||
<string name="ConversationUpdateItem_update">Обновяване</string>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__group_update">
|
||||
<item quantity="one">%1$d group update</item>
|
||||
<item quantity="other">%1$d group updates</item>
|
||||
<item quantity="one">%1$d актуализация в групата</item>
|
||||
<item quantity="other">%1$d актуализации в групата</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__chat_update">
|
||||
<item quantity="one">%1$d chat update</item>
|
||||
<item quantity="other">%1$d chat updates</item>
|
||||
<item quantity="one">%1$d актуализация в чата</item>
|
||||
<item quantity="other">%1$d актуализации в чата</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many disappearing message timer changes are. %2$s is what the timer was ultimately set to.-->
|
||||
<plurals name="CollapsedEvent__disappearing_timer">
|
||||
<item quantity="one">%1$d disappearing message timer change · %2$s</item>
|
||||
<item quantity="other">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="one">%1$d промяна в таймера за изчезващи съобщения · %2$s</item>
|
||||
<item quantity="other">%1$d промени в таймера за изчезващи съобщения · %2$s</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many call events are. -->
|
||||
<plurals name="CollapsedEvent__call_event">
|
||||
<item quantity="one">%1$d call event</item>
|
||||
<item quantity="other">%1$d call events</item>
|
||||
<item quantity="one">%1$d събитие, свързано с обаждане</item>
|
||||
<item quantity="other">%1$d събития, свързани с обаждания</item>
|
||||
</plurals>
|
||||
|
||||
<!-- audio_view -->
|
||||
@@ -5745,39 +5751,39 @@
|
||||
</plurals>
|
||||
|
||||
<!-- Row title for the option to export chat history as a plaintext archive -->
|
||||
<string name="ChatsSettingsFragment__export_chat_history">Експортиране на историята на чата</string>
|
||||
<string name="ChatsSettingsFragment__export_chat_history">Експорт на историята на чата</string>
|
||||
<!-- Row description for the plaintext chat export option -->
|
||||
<string name="ChatsSettingsFragment__export_chat_history_label">Експортирайте машинно четимо JSON копие на всичките си чатове. Изчезващите съобщения няма да бъдат експортирани.</string>
|
||||
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Отключете, за да експортирате историята на чата</string>
|
||||
<!-- Snackbar shown when biometric authentication fails before a chat export -->
|
||||
<string name="ChatsSettingsFragment__authentication_failed">Authentication failed</string>
|
||||
<string name="ChatsSettingsFragment__authentication_failed">Неуспешна автентикация</string>
|
||||
|
||||
<!-- ChatExportDialogs -->
|
||||
<!-- Progress dialog message shown while canceling an in-progress chat export -->
|
||||
<string name="ChatExportDialogs__canceling_export">Canceling export…</string>
|
||||
<string name="ChatExportDialogs__canceling_export">Отмяна на експорт…</string>
|
||||
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
|
||||
<string name="ChatExportDialogs__export_chat_history_title">Експортиране на историята на чата?</string>
|
||||
<string name="ChatExportDialogs__export_chat_history_title">Експорт на историята на чата?</string>
|
||||
<!-- Bold warning prefix in the export confirmation dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
|
||||
<string name="ChatExportDialogs__be_careful_warning">ВНИМАВАЙТЕ!</string>
|
||||
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
|
||||
<string name="ChatExportDialogs__export_confirm_body">Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size.</string>
|
||||
<string name="ChatExportDialogs__export_confirm_body">НЕ споделяйте този файл с никого. Вашата история на чата ще бъде запазена на вашето устройство и други приложения ще могат да осъществяват достъп до нея според разрешенията на вашето устройство. Експортирането с мултимедийните файлове ще направи размера на файла по-голям.</string>
|
||||
<!-- Button in the export confirmation dialog to export messages and media -->
|
||||
<string name="ChatExportDialogs__export_with_media">Export with media</string>
|
||||
<string name="ChatExportDialogs__export_with_media">Експорт с мултимедийните файлове</string>
|
||||
<!-- Button in the export confirmation dialog to export messages only -->
|
||||
<string name="ChatExportDialogs__export_without_media">Export without media</string>
|
||||
<string name="ChatExportDialogs__export_without_media">Експорт без мултимедийните файлове</string>
|
||||
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_title">Изберете папка</string>
|
||||
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Изберете папка в хранилището на устройството си, в която да се съхранява вашата история на чата.</string>
|
||||
<!-- Button that opens the system folder picker -->
|
||||
<string name="ChatExportDialogs__choose_folder_button">Изберете папка</string>
|
||||
<!-- Title of the dialog shown when a chat export finishes successfully -->
|
||||
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
|
||||
<string name="ChatExportDialogs__complete_title">Експортирането на чата завърши</string>
|
||||
<!-- Bold warning prefix in the export complete dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
|
||||
<string name="ChatExportDialogs__be_careful">ВНИМАВАЙТЕ</string>
|
||||
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
|
||||
<string name="ChatExportDialogs__complete_body">where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions.</string>
|
||||
<string name="ChatExportDialogs__complete_body">къде съхранявате файла с експортираните чатове и не го споделяйте с никого. Други приложения на устройството ви може да осъществяват достъп до него в зависимост от разрешенията на вашето устройство.</string>
|
||||
|
||||
<!-- ChatFoldersEducationSheet -->
|
||||
<!-- Text in a bottom sheet describing chat folders and what they can be created for -->
|
||||
@@ -6124,7 +6130,7 @@
|
||||
<!-- End group option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__end_group">Прекратяване на групата</string>
|
||||
<!-- Banner shown at the top of group settings when the group has been ended -->
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">Тази група е прекратена.</string>
|
||||
<!-- Archive chat option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__archive_chat">Архивиране на чата</string>
|
||||
<!-- Delete chat option in group conversation settings screen -->
|
||||
@@ -6941,7 +6947,7 @@
|
||||
<!-- Message of dialog to confirm deletion of story -->
|
||||
<string name="MyStories__this_story_will_be_deleted">Тази история ще бъде изтрита за вас и за всички, които са я получили.</string>
|
||||
<!-- Message of dialog to confirm deletion of story in a terminated group -->
|
||||
<string name="MyStories__delete_story_terminated_group">It will only be deleted for you because the group has ended.</string>
|
||||
<string name="MyStories__delete_story_terminated_group">Ще бъде изтрита само за вас, защото групата е била прекратена.</string>
|
||||
<!-- Toast shown when story media cannot be saved -->
|
||||
<string name="MyStories__unable_to_save">Неуспешно запазване</string>
|
||||
|
||||
@@ -7796,6 +7802,8 @@
|
||||
<string name="CallLogAdapter__incoming">Входящи</string>
|
||||
<!-- Displayed for outgoing calls -->
|
||||
<string name="CallLogAdapter__outgoing">Изходящо</string>
|
||||
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
|
||||
<string name="CallLogAdapter__unanswered">Unanswered</string>
|
||||
<!-- Displayed for missed calls -->
|
||||
<string name="CallLogAdapter__missed">Пропуснато</string>
|
||||
<!-- Displayed for one missed call declined by notification profile -->
|
||||
@@ -7880,6 +7888,8 @@
|
||||
<string name="CallLogFragment__get_started_by_calling_a_friend">Започнете, като се обадите на приятел.</string>
|
||||
<!-- Displayed as a message in a dialog when deleting multiple items -->
|
||||
<string name="CallLogFragment__call_links_youve_created">Създадените от вас линкове за повиквания повече няма да работят за хората, които ги имат.</string>
|
||||
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
|
||||
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
@@ -8618,14 +8628,14 @@
|
||||
<!-- Progress message shown while ending a group -->
|
||||
<string name="EndGroupDialog__ending_group">Прекратяване на групата…</string>
|
||||
<!-- Message shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Couldn\'t end the group. Check your connection and try again.</string>
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Неуспешно прекратяване на групата. Проверете връзката си и опитайте отново.</string>
|
||||
<!-- Retry button shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__try_again">Опитайте отново</string>
|
||||
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, with admin name. %1$s is the admin\'s display name -->
|
||||
<string name="TerminatedGroupBottomSheet__s_ended_the_group">%1$s прекрати групата</string>
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, without admin name -->
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">The group has ended.</string>
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">Групата е прекратена.</string>
|
||||
<!-- Body of bottom sheet shown when opening a terminated group -->
|
||||
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">Вече не можете да изпращате и получавате съобщения или обаждания в тази група.</string>
|
||||
|
||||
@@ -8997,7 +9007,7 @@
|
||||
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">Това е същото като ключа за възстановяване на устройството.</string>
|
||||
<!-- Screen body shown when enabling on-device backups while Signal Secure Backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">This is the same as your Signal Secure Backups recovery key.</string>
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">Това е същото като вашия ключ за възстановяване за „Сигурни резервни копия“ на Signal.</string>
|
||||
<!-- Label shown above a list of actions that the recovery key can be used for. -->
|
||||
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Използвайте този ключ за:</string>
|
||||
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
|
||||
@@ -9802,13 +9812,13 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="AddGroupDetailsFragment__groups_with_same_members" translatable="false">Groups with same members (Labs)</string> -->
|
||||
|
||||
<!-- Title of the sheet shown when a local backup restore could not be completed -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__title">Can\'t restore backup</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__title">Неуспешно възстановяване на резервно копие</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining the likely cause -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring.</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">Възникна грешка и вашето резервно копие не може да бъде възстановено. Възможно е папката с резервното копие на устройството ви да е била преместена, докато резервното копие се е възстановявало.</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining how to try again -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\".</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">За да опитате отново, деинсталирайте и преинсталирайте Signal на това устройство и изберете „Възстановяване или трансфер“.</string>
|
||||
|
||||
<!-- EOF -->
|
||||
</resources>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user