mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-13 11:40:14 +01:00
Compare commits
59 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 |
@@ -24,8 +24,8 @@ plugins {
|
||||
|
||||
apply(from = "static-ips.gradle.kts")
|
||||
|
||||
val canonicalVersionCode = 1675
|
||||
val canonicalVersionName = "8.6.2"
|
||||
val canonicalVersionCode = 1678
|
||||
val canonicalVersionName = "8.7.2"
|
||||
val currentHotfixVersion = 0
|
||||
val maxHotfixVersions = 100
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ object MockProvider {
|
||||
val device = PreKeyResponseItem().apply {
|
||||
this.deviceId = deviceId
|
||||
registrationId = KeyHelper.generateRegistrationId(false)
|
||||
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id, signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
preKey = PreKeyEntity(oneTimePreKey.id, oneTimePreKey.keyPair.publicKey)
|
||||
signedPreKey = SignedPreKeyEntity(signedPreKeyRecord.id.toLong(), signedPreKeyRecord.keyPair.publicKey, signedPreKeyRecord.signature)
|
||||
preKey = PreKeyEntity(oneTimePreKey.id.toLong(), oneTimePreKey.keyPair.publicKey)
|
||||
}
|
||||
|
||||
return PreKeyResponse().apply {
|
||||
|
||||
@@ -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
-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)..."
|
||||
|
||||
+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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,8 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto
|
||||
private const val HAS_SEEN_KEY_TRANSPARENCY_FAILURE = "misc.has_seen_key_transparency_failure"
|
||||
private const val CAMERA_FACING_FRONT = "misc.camera_facing_front"
|
||||
private const val COMPLETED_COLLAPSED_EVENTS_MIGRATION = "misc.completed_collapsed_events_migration"
|
||||
private const val CAPTCHA_LAST_VIEWED_AT = "misc.captcha_last_viewed_at"
|
||||
private const val CALLING_ASSETS_VERSION = "misc.calling_assets_version"
|
||||
}
|
||||
|
||||
public override fun onFirstEverAppLaunch() {
|
||||
@@ -318,4 +320,16 @@ class MiscellaneousValues internal constructor(store: KeyValueStore) : SignalSto
|
||||
var isCameraFacingFront: Boolean by booleanValue(CAMERA_FACING_FRONT, true)
|
||||
|
||||
var completedCollapsedEventsMigration: Boolean by booleanValue(COMPLETED_COLLAPSED_EVENTS_MIGRATION, false)
|
||||
|
||||
/**
|
||||
* The last time the user viewed the captcha/recaptcha proof activity.
|
||||
*/
|
||||
var captchaLastViewedAt: Long by longValue(CAPTCHA_LAST_VIEWED_AT, 0)
|
||||
|
||||
/**
|
||||
* The last successfully-downloaded calling assets version. Compared against
|
||||
* [org.thoughtcrime.securesms.service.webrtc.CallingAssets.CURRENT_VERSION] to determine
|
||||
* if new assets need to be fetched.
|
||||
*/
|
||||
var callingAssetsVersion: Int by integerValue(CALLING_ASSETS_VERSION, 0)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.signal.core.util.logging.logD
|
||||
import org.signal.core.util.logging.logI
|
||||
import org.signal.core.util.logging.logW
|
||||
import org.signal.core.util.toByteArray
|
||||
import org.signal.libsignal.net.RequestResult
|
||||
import org.signal.libsignal.protocol.InvalidKeyException
|
||||
import org.signal.libsignal.protocol.ecc.ECPublicKey
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil
|
||||
@@ -337,11 +338,11 @@ object LinkDeviceRepository {
|
||||
}
|
||||
|
||||
Log.d(TAG, "[createAndUploadArchive] Fetching an upload form...")
|
||||
val uploadForm = when (val result = NetworkResult.withRetry { SignalNetwork.attachments.getAttachmentV4UploadForm() }) {
|
||||
is NetworkResult.Success -> result.result.logD(TAG, "[createAndUploadArchive] Successfully retrieved upload form.")
|
||||
is NetworkResult.ApplicationError -> throw result.throwable
|
||||
is NetworkResult.NetworkError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "[createAndUploadArchive] Network error when fetching form.", result.exception)
|
||||
is NetworkResult.StatusCodeError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "[createAndUploadArchive] Status code error when fetching form.", result.exception)
|
||||
val uploadForm = when (val result = SignalNetwork.attachments.getAttachmentV4UploadForm(tempBackupFile.length())) {
|
||||
is RequestResult.Success -> result.result.logD(TAG, "[createAndUploadArchive] Successfully retrieved upload form.")
|
||||
is RequestResult.ApplicationError -> throw result.cause
|
||||
is RequestResult.RetryableNetworkError -> return LinkUploadArchiveResult.NetworkError(result.networkError).logW(TAG, "[createAndUploadArchive] Network error when fetching form.", result.networkError)
|
||||
is RequestResult.NonSuccess -> return LinkUploadArchiveResult.BadRequest(result.error).logW(TAG, "[createAndUploadArchive] Upload too large when fetching form.", result.error)
|
||||
}
|
||||
|
||||
if (cancellationSignal()) {
|
||||
|
||||
@@ -10,14 +10,14 @@ import androidx.annotation.Nullable;
|
||||
import androidx.navigation.NavGraph;
|
||||
import androidx.navigation.Navigation;
|
||||
|
||||
import org.thoughtcrime.securesms.BaseActivity;
|
||||
import org.thoughtcrime.securesms.PassphrasePromptActivity;
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.util.DynamicRegistrationTheme;
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme;
|
||||
|
||||
public class CreateSvrPinActivity extends BaseActivity {
|
||||
public class CreateSvrPinActivity extends PassphraseRequiredActivity {
|
||||
|
||||
public static final int REQUEST_NEW_PIN = 27698;
|
||||
|
||||
@@ -55,8 +55,8 @@ public class CreateSvrPinActivity extends BaseActivity {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate(Bundle bundle) {
|
||||
super.onCreate(bundle);
|
||||
public void onCreate(Bundle bundle, boolean ready) {
|
||||
super.onCreate(bundle, ready);
|
||||
|
||||
if (KeyCachingService.isLocked(this)) {
|
||||
startActivity(getPromptPassphraseIntent());
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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"
|
||||
|
||||
@@ -986,7 +986,7 @@
|
||||
<string name="BackupsPreferenceFragment__test_your_backup_passphrase">Toets jou wagwoordfrase en verifieer dat dit ooreenkom</string>
|
||||
<string name="BackupsPreferenceFragment__turn_on">Skakel rugsteun aan</string>
|
||||
<string name="BackupsPreferenceFragment__turn_off">Skakel af</string>
|
||||
<string name="BackupsPreferenceFragment__to_restore_a_backup">"Om \'n rugsteun te herwin, installeer \'n nuwe kopie van Signal. Maak die toepassing oop, tik op \"Herwin vanaf rugsteun\" en spoor dan \'n rugsteunlêer op. %1$s"</string>
|
||||
<string name="BackupsPreferenceFragment__to_restore_a_backup">"Om 'n rugsteun te herwin, installeer 'n nuwe kopie van Signal. Maak die toepassing oop, tik op \"Herwin vanaf rugsteun\" en spoor dan 'n rugsteunlêer op. %1$s"</string>
|
||||
<string name="BackupsPreferenceFragment__learn_more">Vind meer uit</string>
|
||||
<string name="BackupsPreferenceFragment__in_progress">Aan die gang…</string>
|
||||
<!-- Status text shown in backup preferences when verifying a backup -->
|
||||
@@ -1832,6 +1832,7 @@
|
||||
<string name="MediaOverviewActivity_sent_by_you_to_s">Deur jou aan %1$s gestuur</string>
|
||||
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
|
||||
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Hierdie media is nog nie gestuur nie.</string>
|
||||
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
|
||||
|
||||
<!-- Megaphones -->
|
||||
<string name="Megaphones_remind_me_later">Herinner my later</string>
|
||||
@@ -1921,6 +1922,10 @@
|
||||
<string name="MessageRecord_declined_voice_call">Stemoproep geweier</string>
|
||||
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
|
||||
<string name="MessageRecord_declined_video_call">Video-oproep geweier</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_voice_call">Onbeantwoorde stemoproep</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_video_call">Onbeantwoorde video-oproep</string>
|
||||
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
|
||||
<string name="MessageRecord_missed_voice_call_notification_profile">Gemiste stemoproep terwyl kennisgewingprofiel aan was</string>
|
||||
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
|
||||
@@ -3076,6 +3081,7 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
|
||||
<string name="SupportEmailUtil_registration_lock">Registrasieslot:</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
|
||||
|
||||
<!-- ThreadRecord -->
|
||||
<string name="ThreadRecord_group_updated">Groep bygewerk</string>
|
||||
@@ -3581,7 +3587,7 @@
|
||||
<string name="ContactSelectionListFragment_signal_requires_the_contacts_permission_in_order_to_display_your_contacts">Signal benodig Kontaktoestemming om jou kontakte te wys, maar dit is permanent geweier. Gaan asseblief na die toepassinginstelling-kieslys, kies \"Toestemmings\" en aktiveer \"Kontakte\".</string>
|
||||
<string name="ContactSelectionListFragment_error_retrieving_contacts_check_your_network_connection">Fout met herwinning van kontakte, gaan jou netwerkverbinding na</string>
|
||||
<string name="ContactSelectionListFragment_username_not_found">Gebruikersnaam nie gevind nie</string>
|
||||
<string name="ContactSelectionListFragment_s_is_not_a_signal_user">"\"%1$s\" is nie \'n Signal-gebruiker nie. Kontroleer asb. die gebruikersnaam en probeer weer."</string>
|
||||
<string name="ContactSelectionListFragment_s_is_not_a_signal_user">"\"%1$s\" is nie 'n Signal-gebruiker nie. Kontroleer asb. die gebruikersnaam en probeer weer."</string>
|
||||
<string name="ContactSelectionListFragment_you_do_not_need_to_add_yourself_to_the_group">Jy hoef nie jouself by die groep te voeg nie</string>
|
||||
<string name="ContactSelectionListFragment_maximum_group_size_reached">Maksimum groepgrootte bereik.</string>
|
||||
<string name="ContactSelectionListFragment_signal_groups_can_have_a_maximum_of_s_members">Signal-groepe kan \'n maksimum van %1$s lede hê.</string>
|
||||
@@ -7796,6 +7802,8 @@
|
||||
<string name="CallLogAdapter__incoming">Inkomend</string>
|
||||
<!-- Displayed for outgoing calls -->
|
||||
<string name="CallLogAdapter__outgoing">Uitgaande</string>
|
||||
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
|
||||
<string name="CallLogAdapter__unanswered">Unanswered</string>
|
||||
<!-- Displayed for missed calls -->
|
||||
<string name="CallLogAdapter__missed">Gemis</string>
|
||||
<!-- Displayed for one missed call declined by notification profile -->
|
||||
@@ -7880,6 +7888,8 @@
|
||||
<string name="CallLogFragment__get_started_by_calling_a_friend">Begin deur \'n vriend te bel.</string>
|
||||
<!-- Displayed as a message in a dialog when deleting multiple items -->
|
||||
<string name="CallLogFragment__call_links_youve_created">Oproepskakels wat jy geskep het, sal nie meer werk vir mense wat dit het nie.</string>
|
||||
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
|
||||
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
@@ -8193,7 +8203,7 @@
|
||||
<string name="UseNewOnDeviceBackups__not_now">Nie nou nie</string>
|
||||
|
||||
<!-- Text describing how to restore a backup displayed on the on-device backups screen -->
|
||||
<string name="OnDeviceBackupsScreen__to_restore_a_backup">"Om \'n rugsteun te herwin, installeer \'n nuwe kopie van Signal. Maak die toepassing oop, tik op \"Herwin rugsteun\" en vind dan die rugsteunlêer."</string>
|
||||
<string name="OnDeviceBackupsScreen__to_restore_a_backup">"Om 'n rugsteun te herwin, installeer 'n nuwe kopie van Signal. Maak die toepassing oop, tik op \"Herwin rugsteun\" en vind dan die rugsteunlêer."</string>
|
||||
|
||||
<!-- Title of a megaphone shown to prompt the user to verify their recovery key -->
|
||||
<string name="VerifyBackupKey__title">Verifieer jou herwinsleutel</string>
|
||||
|
||||
@@ -537,7 +537,7 @@
|
||||
<string name="ConversationActivity_unable_to_record_audio">تعذَّر تسجيل الصوت!</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_to_this_group">لا تستطيع إرسال الرسائل لهذه المجموعة لأنك لم تعد عضوًا فيها.</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="DisabledInputView__incognito_mode" translatable="false">Incognito mode (Labs)</string> -->
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">You can\'t send messages because the group has ended.</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">لا يمكنك إرسال رسائل لأن المجموعة أُغلِقت.</string>
|
||||
<string name="ConversationActivity_only_s_can_send_messages">يمكن فقط لـ %1$s إرسال الرسائل.</string>
|
||||
<string name="ConversationActivity_admins">المُشرِفون</string>
|
||||
<string name="ConversationActivity_message_an_admin">مراسلة أحد المُشرِفين</string>
|
||||
@@ -1875,7 +1875,7 @@
|
||||
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">حدث خطأ فى الشبكة.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">رابط هذه المجموعة غير مُفعَّل.</string>
|
||||
<!-- Toast message shown when trying to join a group via link but the group has been ended -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Unable to join this group.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">تعذَّر الانضمام إلى هذه المجموعة.</string>
|
||||
<!-- Toast message shown when trying to join a group by link but the group is full -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">حد المجموعة اكتمل. تعذَّر الانضمام إلى المجموعة.</string>
|
||||
<!-- Title shown when there was an known issue getting group information from a group link -->
|
||||
@@ -2052,6 +2052,7 @@
|
||||
<string name="MediaOverviewActivity_sent_by_you_to_s">مرسل منك إلى %1$s</string>
|
||||
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
|
||||
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">لم يتم إرسال ملف الوسائط بعد.</string>
|
||||
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
|
||||
|
||||
<!-- Megaphones -->
|
||||
<string name="Megaphones_remind_me_later">ذكِّرني لاحقًا</string>
|
||||
@@ -2120,7 +2121,7 @@
|
||||
<string name="MessageRecord_you_updated_group">قمتَ بتحديث المجموعة.</string>
|
||||
<string name="MessageRecord_the_group_was_updated">تمَّ تحديث المجموعة.</string>
|
||||
<!-- Update message shown a group is terminated, but the person that terminated it is unknown. -->
|
||||
<string name="MessageRecord_the_group_was_terminated">The group ended.</string>
|
||||
<string name="MessageRecord_the_group_was_terminated">أُغلِقت المجموعة.</string>
|
||||
<!-- Update message shown when a group is terminated, placeholder is the name of the person that terminated the group. -->
|
||||
<string name="MessageRecord_s_terminated_the_group">%1$s أنهى المجموعة</string>
|
||||
<!-- Update message shown when a group is terminated by you -->
|
||||
@@ -2141,6 +2142,10 @@
|
||||
<string name="MessageRecord_declined_voice_call">مكالمة صوتية مرفوضة</string>
|
||||
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
|
||||
<string name="MessageRecord_declined_video_call">مكالمة فيديو مرفوضة</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_voice_call">مكالمة صوتية لم يتم الرد عليها</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_video_call">مكالمة فيديو لم يتم الرد عليها</string>
|
||||
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
|
||||
<string name="MessageRecord_missed_voice_call_notification_profile">مكالمة صوتية فائتة بينما كان ملف الإشعارات في وضع تشغيل</string>
|
||||
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
|
||||
@@ -3468,6 +3473,7 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
|
||||
<string name="SupportEmailUtil_registration_lock">قفل التسجيل:</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
|
||||
|
||||
<!-- ThreadRecord -->
|
||||
<string name="ThreadRecord_group_updated">تمَّ تحديث المجموعة</string>
|
||||
@@ -4073,7 +4079,7 @@
|
||||
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">قفل تسجيل المُرفَق الصوتي</string>
|
||||
<string name="conversation_activity__message_could_not_be_sent">لم تُرسَل الرسالة. عليك التأكُّد من اتصالك بالانترنت ثم المحاولة مُجدَّدًا.</string>
|
||||
<!-- Dialog body shown when tapping a failed message in a terminated group -->
|
||||
<string name="conversation_activity__send_failed_group_ended">You can no longer send and receive messages in this group because the group has ended.</string>
|
||||
<string name="conversation_activity__send_failed_group_ended">لا يمكنك إرسال وتلقّي الرسائل في هذه المجموعة لأن المجموعة أُغلِقت.</string>
|
||||
<!-- Dialog body shown when tapping a group action button (e.g. invite friends) in a terminated group -->
|
||||
<string name="conversation_activity__group_action_not_allowed_group_ended">هذا الإجراء غير متوفر لأنه تمَّ إنهاء المجموعة.</string>
|
||||
<!-- Dialog body when a message failed to delete and retry is possible. -->
|
||||
@@ -4131,39 +4137,39 @@
|
||||
<string name="ConversationUpdateItem_update">تحديث</string>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__group_update">
|
||||
<item quantity="zero">%1$d group updates</item>
|
||||
<item quantity="one">%1$d group update</item>
|
||||
<item quantity="two">%1$d group updates</item>
|
||||
<item quantity="few">%1$d group updates</item>
|
||||
<item quantity="many">%1$d group updates</item>
|
||||
<item quantity="other">%1$d group updates</item>
|
||||
<item quantity="zero">%1$d تحديثات للمجموعة</item>
|
||||
<item quantity="one">%1$d تحديث للمجموعة</item>
|
||||
<item quantity="two">%1$d تحديثان للمجموعة</item>
|
||||
<item quantity="few">%1$d تحديثات للمجموعة</item>
|
||||
<item quantity="many">%1$d تحديثًا للمجموعة</item>
|
||||
<item quantity="other">%1$d تحديثٍ للمجموعة</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__chat_update">
|
||||
<item quantity="zero">%1$d chat updates</item>
|
||||
<item quantity="one">%1$d chat update</item>
|
||||
<item quantity="two">%1$d chat updates</item>
|
||||
<item quantity="few">%1$d chat updates</item>
|
||||
<item quantity="many">%1$d chat updates</item>
|
||||
<item quantity="other">%1$d chat updates</item>
|
||||
<item quantity="zero">%1$d تحديثات للدردشة</item>
|
||||
<item quantity="one">%1$d تحديث للدردشة</item>
|
||||
<item quantity="two">%1$d تحديثان للدردشة</item>
|
||||
<item quantity="few">%1$d تحديثات للدردشة</item>
|
||||
<item quantity="many">%1$d تحديثًا للدردشة</item>
|
||||
<item quantity="other">%1$d تحديثٍ للدردشة</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many disappearing message timer changes are. %2$s is what the timer was ultimately set to.-->
|
||||
<plurals name="CollapsedEvent__disappearing_timer">
|
||||
<item quantity="zero">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="one">%1$d disappearing message timer change · %2$s</item>
|
||||
<item quantity="two">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="few">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="many">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="other">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="zero">%1$d تغييرات في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
|
||||
<item quantity="one">%1$d تغيير في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
|
||||
<item quantity="two">%1$d تغييران في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
|
||||
<item quantity="few">%1$d تغييرات في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
|
||||
<item quantity="many">%1$d تغييرًا في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
|
||||
<item quantity="other">%1$d تغييرٍ في مُؤقِّت الرسائل المؤقَّتة· %2$s</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many call events are. -->
|
||||
<plurals name="CollapsedEvent__call_event">
|
||||
<item quantity="zero">%1$d call events</item>
|
||||
<item quantity="one">%1$d call event</item>
|
||||
<item quantity="two">%1$d call events</item>
|
||||
<item quantity="few">%1$d call events</item>
|
||||
<item quantity="many">%1$d call events</item>
|
||||
<item quantity="other">%1$d call events</item>
|
||||
<item quantity="zero">%1$d أحداث مكالمة</item>
|
||||
<item quantity="one">%1$d حدث مكالمة</item>
|
||||
<item quantity="two">%1$d حدثا مكالمة</item>
|
||||
<item quantity="few">%1$d أحداث مكالمة</item>
|
||||
<item quantity="many">%1$d حدث مكالمة</item>
|
||||
<item quantity="other">%1$d حدث مكالمة</item>
|
||||
</plurals>
|
||||
|
||||
<!-- audio_view -->
|
||||
@@ -6313,35 +6319,35 @@
|
||||
<!-- Row description for the plaintext chat export option -->
|
||||
<string name="ChatsSettingsFragment__export_chat_history_label">تصدير نسخة JSON قابلة للقراءة آليًا لجميع دردشاتك. لن يتم تصدير الرسائل المؤقَّتة.</string>
|
||||
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">فتح القفل لتصدير سِجل الدردشة</string>
|
||||
<!-- Snackbar shown when biometric authentication fails before a chat export -->
|
||||
<string name="ChatsSettingsFragment__authentication_failed">فشلت المُصادَقة.</string>
|
||||
|
||||
<!-- ChatExportDialogs -->
|
||||
<!-- Progress dialog message shown while canceling an in-progress chat export -->
|
||||
<string name="ChatExportDialogs__canceling_export">Canceling export…</string>
|
||||
<string name="ChatExportDialogs__canceling_export">جارٍ إلغاء التصدير…</string>
|
||||
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
|
||||
<string name="ChatExportDialogs__export_chat_history_title">هل ترغبُ بتصدير سِجل الدردشة؟</string>
|
||||
<!-- Bold warning prefix in the export confirmation dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
|
||||
<string name="ChatExportDialogs__be_careful_warning">انتبه!</string>
|
||||
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
|
||||
<string name="ChatExportDialogs__export_confirm_body">Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size.</string>
|
||||
<string name="ChatExportDialogs__export_confirm_body">لا تشارِك هذا الملف مع أي أحد. سيتم حفظ سِجل الدردشة الخاص بك في جهازك وقد تتمكن التطبيقات الأخرى من الوصول إليه حسب أذونات جهازك. التصدير مع الوسائط قد ينتج عنه حجم ملف أكبر.</string>
|
||||
<!-- Button in the export confirmation dialog to export messages and media -->
|
||||
<string name="ChatExportDialogs__export_with_media">Export with media</string>
|
||||
<string name="ChatExportDialogs__export_with_media">التصدير مع الوسائط</string>
|
||||
<!-- Button in the export confirmation dialog to export messages only -->
|
||||
<string name="ChatExportDialogs__export_without_media">Export without media</string>
|
||||
<string name="ChatExportDialogs__export_without_media">التصدير دون الوسائط</string>
|
||||
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_title">اِختر مجلد</string>
|
||||
<string name="ChatExportDialogs__choose_a_folder_title">اختر مجلد</string>
|
||||
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">اختر مجلدًا في مساحة تخزين جهازك حيث سيتم تخرين سِجل دردشتك.</string>
|
||||
<!-- Button that opens the system folder picker -->
|
||||
<string name="ChatExportDialogs__choose_folder_button">اختر مجلدًا</string>
|
||||
<!-- Title of the dialog shown when a chat export finishes successfully -->
|
||||
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
|
||||
<string name="ChatExportDialogs__complete_title">اكتملَ تصدير الدردشة</string>
|
||||
<!-- Bold warning prefix in the export complete dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
|
||||
<string name="ChatExportDialogs__be_careful">انتبه</string>
|
||||
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
|
||||
<string name="ChatExportDialogs__complete_body">where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions.</string>
|
||||
<string name="ChatExportDialogs__complete_body">أين تُخزِّن ملف تصدير دردشتك ولا تشاركه مع أي أحد. قد تتمكّن تطبيقات أخرى في جهازك من الوصول إليها حسب الأذونات في جهازك.</string>
|
||||
|
||||
<!-- ChatFoldersEducationSheet -->
|
||||
<!-- Text in a bottom sheet describing chat folders and what they can be created for -->
|
||||
@@ -6712,7 +6718,7 @@
|
||||
<!-- End group option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__end_group">إنهاء المجموعة</string>
|
||||
<!-- Banner shown at the top of group settings when the group has been ended -->
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">أُغلِقت هذه المجموعة.</string>
|
||||
<!-- Archive chat option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__archive_chat">أرشفة الدردشة</string>
|
||||
<!-- Delete chat option in group conversation settings screen -->
|
||||
@@ -7565,7 +7571,7 @@
|
||||
<!-- Message of dialog to confirm deletion of story -->
|
||||
<string name="MyStories__this_story_will_be_deleted">ستُحذَف هذه القصة من عند جميع من استلمها بما فيهم أنت.</string>
|
||||
<!-- Message of dialog to confirm deletion of story in a terminated group -->
|
||||
<string name="MyStories__delete_story_terminated_group">It will only be deleted for you because the group has ended.</string>
|
||||
<string name="MyStories__delete_story_terminated_group">ستُحذف لديك فقط لأن المجموعة أُغلِقت.</string>
|
||||
<!-- Toast shown when story media cannot be saved -->
|
||||
<string name="MyStories__unable_to_save">تعذَّر الحفظ</string>
|
||||
|
||||
@@ -8508,6 +8514,8 @@
|
||||
<string name="CallLogAdapter__incoming">واردة</string>
|
||||
<!-- Displayed for outgoing calls -->
|
||||
<string name="CallLogAdapter__outgoing">صادرة</string>
|
||||
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
|
||||
<string name="CallLogAdapter__unanswered">Unanswered</string>
|
||||
<!-- Displayed for missed calls -->
|
||||
<string name="CallLogAdapter__missed">فائتة</string>
|
||||
<!-- Displayed for one missed call declined by notification profile -->
|
||||
@@ -8604,6 +8612,8 @@
|
||||
<string name="CallLogFragment__get_started_by_calling_a_friend">ابدأ بالاتصال بصديق.</string>
|
||||
<!-- Displayed as a message in a dialog when deleting multiple items -->
|
||||
<string name="CallLogFragment__call_links_youve_created">لن تشتغل روابط المكالمات التي أنشأتها بالنسبة للأشخاص الذين حصلوا عليها.</string>
|
||||
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
|
||||
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
@@ -9366,14 +9376,14 @@
|
||||
<!-- Progress message shown while ending a group -->
|
||||
<string name="EndGroupDialog__ending_group">جارٍ إنهاء المجموعة…</string>
|
||||
<!-- Message shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Couldn\'t end the group. Check your connection and try again.</string>
|
||||
<string name="EndGroupDialog__ending_the_group_failed">تعذَّر إغلاق المجموعة. تحقَّق من اتصالك بالشبكة ثم حاوِل مرّة أخرى.</string>
|
||||
<!-- Retry button shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__try_again">حاوِل مُجدَّدًا</string>
|
||||
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, with admin name. %1$s is the admin\'s display name -->
|
||||
<string name="TerminatedGroupBottomSheet__s_ended_the_group">%1$s أنهى المجموعة</string>
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, without admin name -->
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">The group has ended.</string>
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">أُغلِقت هذه المجموعة.</string>
|
||||
<!-- Body of bottom sheet shown when opening a terminated group -->
|
||||
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">لم يبق بإمكانك إرسال الرسائل واستلامها والتواصل بالمكالمات في هذه المجموعة.</string>
|
||||
|
||||
@@ -9769,7 +9779,7 @@
|
||||
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">هذا المفتاح مثل مفتاح الاستعادة على الجهاز الخاص بك.</string>
|
||||
<!-- Screen body shown when enabling on-device backups while Signal Secure Backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">This is the same as your Signal Secure Backups recovery key.</string>
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">هذا المفتاح مثل مفتاح استعادة النسخ الاحتياطية الآمنة الخاصة بك.</string>
|
||||
<!-- Label shown above a list of actions that the recovery key can be used for. -->
|
||||
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">استخدِم هذا المفتاح من أجل:</string>
|
||||
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
|
||||
@@ -10597,10 +10607,10 @@
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__title">تعذَّرت استعادة النسخة الاحتياطية</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining the likely cause -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring.</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">حدث خطأ وتعذَّر استعادة نسختك الاحتياطية. قد يكون هذا بسبب نقل مجلد النسخة الاحتياطية على جهازك بينما كانت النسخة الاحتياطية قيد الاستعادة.</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining how to try again -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\".</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">للمحاولة من جديد، قُم بإلغاء تثبيت وإعادة تثبيت سيجنال على هذا الجهاز، واختر \"الاستعادة أو النقل\".</string>
|
||||
|
||||
<!-- EOF -->
|
||||
</resources>
|
||||
|
||||
@@ -1832,6 +1832,7 @@
|
||||
<string name="MediaOverviewActivity_sent_by_you_to_s">%1$s əlaqəsinə göndərdiniz</string>
|
||||
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
|
||||
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Bu media faylı hələ göndərilməyib.</string>
|
||||
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
|
||||
|
||||
<!-- Megaphones -->
|
||||
<string name="Megaphones_remind_me_later">Daha sonra xatırlat</string>
|
||||
@@ -1921,6 +1922,10 @@
|
||||
<string name="MessageRecord_declined_voice_call">İmtina edilmiş audio-zəng</string>
|
||||
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
|
||||
<string name="MessageRecord_declined_video_call">İmtina edilmiş video-zəng</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_voice_call">Cavablanmamış audio zəng</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_video_call">Cavablanmamış video zəng</string>
|
||||
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
|
||||
<string name="MessageRecord_missed_voice_call_notification_profile">Profil statusu aktiv ikən buraxılmış səsli zəng</string>
|
||||
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
|
||||
@@ -3076,6 +3081,7 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
|
||||
<string name="SupportEmailUtil_registration_lock">Qeydiyyat kilidi:</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
|
||||
|
||||
<!-- ThreadRecord -->
|
||||
<string name="ThreadRecord_group_updated">Qrup yeniləndi</string>
|
||||
@@ -7796,6 +7802,8 @@
|
||||
<string name="CallLogAdapter__incoming">Gələn zəng</string>
|
||||
<!-- Displayed for outgoing calls -->
|
||||
<string name="CallLogAdapter__outgoing">Gedən zəng</string>
|
||||
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
|
||||
<string name="CallLogAdapter__unanswered">Unanswered</string>
|
||||
<!-- Displayed for missed calls -->
|
||||
<string name="CallLogAdapter__missed">Cavabsız zəng</string>
|
||||
<!-- Displayed for one missed call declined by notification profile -->
|
||||
@@ -7880,6 +7888,8 @@
|
||||
<string name="CallLogFragment__get_started_by_calling_a_friend">Bir dosta zəng edərək başlayın.</string>
|
||||
<!-- Displayed as a message in a dialog when deleting multiple items -->
|
||||
<string name="CallLogFragment__call_links_youve_created">Yaratdığınız zəng keçidlərini paylaşdığınız insanlar artıq bu keçidləri görə bilməyəcək.</string>
|
||||
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
|
||||
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
|
||||
@@ -1942,6 +1942,7 @@
|
||||
<string name="MediaOverviewActivity_sent_by_you_to_s">Адпраўлена вамі да %1$s</string>
|
||||
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
|
||||
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Гэты медыяфайл яшчэ не адпраўлены.</string>
|
||||
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
|
||||
|
||||
<!-- Megaphones -->
|
||||
<string name="Megaphones_remind_me_later">Нагадаць мне пазней</string>
|
||||
@@ -2031,6 +2032,10 @@
|
||||
<string name="MessageRecord_declined_voice_call">Галасавы выклік адхілены</string>
|
||||
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
|
||||
<string name="MessageRecord_declined_video_call">Відэавыклік адхілены</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_voice_call">Неадказаны галасавы званок</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_video_call">Неадказаны відэазванок</string>
|
||||
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
|
||||
<string name="MessageRecord_missed_voice_call_notification_profile">Прапушчаны галасавы званок, калі ўключаны профіль апавяшчэнняў</string>
|
||||
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
|
||||
@@ -3272,6 +3277,7 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
|
||||
<string name="SupportEmailUtil_registration_lock">Блакіроўка рэгістрацыі:</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
|
||||
|
||||
<!-- ThreadRecord -->
|
||||
<string name="ThreadRecord_group_updated">Група абноўленая</string>
|
||||
@@ -8152,6 +8158,8 @@
|
||||
<string name="CallLogAdapter__incoming">Уваходны</string>
|
||||
<!-- Displayed for outgoing calls -->
|
||||
<string name="CallLogAdapter__outgoing">Выходны</string>
|
||||
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
|
||||
<string name="CallLogAdapter__unanswered">Unanswered</string>
|
||||
<!-- Displayed for missed calls -->
|
||||
<string name="CallLogAdapter__missed">Прапушчаныя</string>
|
||||
<!-- Displayed for one missed call declined by notification profile -->
|
||||
@@ -8242,6 +8250,8 @@
|
||||
<string name="CallLogFragment__get_started_by_calling_a_friend">Пачніце са званка сябру.</string>
|
||||
<!-- Displayed as a message in a dialog when deleting multiple items -->
|
||||
<string name="CallLogFragment__call_links_youve_created">Створаныя вамі спасылкі на званкі больш не будуць дзейнічаць для тых, хто мае іх.</string>
|
||||
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
|
||||
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
|
||||
@@ -1832,6 +1832,7 @@
|
||||
<string name="MediaOverviewActivity_sent_by_you_to_s">Изпратено от тебе до %1$s</string>
|
||||
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
|
||||
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Тази мултимедия все още не е изпратена.</string>
|
||||
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
|
||||
|
||||
<!-- Megaphones -->
|
||||
<string name="Megaphones_remind_me_later">Напомнете ми по-късно</string>
|
||||
@@ -1921,6 +1922,10 @@
|
||||
<string name="MessageRecord_declined_voice_call">Отказано гласово обаждане</string>
|
||||
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
|
||||
<string name="MessageRecord_declined_video_call">Отказано видео обаждане</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_voice_call">Гласово повикване без отговор</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_video_call">Видео повикване без отговор</string>
|
||||
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
|
||||
<string name="MessageRecord_missed_voice_call_notification_profile">Пропуснато гласово обаждане при включен профил за известия</string>
|
||||
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
|
||||
@@ -3076,6 +3081,7 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
|
||||
<string name="SupportEmailUtil_registration_lock">Регистрационно заключване:</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
|
||||
|
||||
<!-- ThreadRecord -->
|
||||
<string name="ThreadRecord_group_updated">Групата е обновена</string>
|
||||
@@ -7796,6 +7802,8 @@
|
||||
<string name="CallLogAdapter__incoming">Входящи</string>
|
||||
<!-- Displayed for outgoing calls -->
|
||||
<string name="CallLogAdapter__outgoing">Изходящо</string>
|
||||
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
|
||||
<string name="CallLogAdapter__unanswered">Unanswered</string>
|
||||
<!-- Displayed for missed calls -->
|
||||
<string name="CallLogAdapter__missed">Пропуснато</string>
|
||||
<!-- Displayed for one missed call declined by notification profile -->
|
||||
@@ -7880,6 +7888,8 @@
|
||||
<string name="CallLogFragment__get_started_by_calling_a_friend">Започнете, като се обадите на приятел.</string>
|
||||
<!-- Displayed as a message in a dialog when deleting multiple items -->
|
||||
<string name="CallLogFragment__call_links_youve_created">Създадените от вас линкове за повиквания повече няма да работят за хората, които ги имат.</string>
|
||||
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
|
||||
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
|
||||
@@ -1832,6 +1832,7 @@
|
||||
<string name="MediaOverviewActivity_sent_by_you_to_s">%1$sকে আপনার প্রেরিত</string>
|
||||
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
|
||||
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">এই মিডিয়াটি এখনো পাঠানো হয়নি।</string>
|
||||
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
|
||||
|
||||
<!-- Megaphones -->
|
||||
<string name="Megaphones_remind_me_later">আমাকে পরে মনে করিয়ে দিবেন</string>
|
||||
@@ -1921,6 +1922,10 @@
|
||||
<string name="MessageRecord_declined_voice_call">প্রত্যাখ্যান করা হয়েছে এমন ভয়েস কল</string>
|
||||
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
|
||||
<string name="MessageRecord_declined_video_call">প্রত্যাখ্যান করা হয়েছে এমন ভিডিও কল</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_voice_call">উত্তর না দেওয়া ভয়েস কল</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_video_call">উত্তর না দেওয়া ভিডিও কল</string>
|
||||
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
|
||||
<string name="MessageRecord_missed_voice_call_notification_profile">নোটিফিকেশন প্রোফাইল চালু থাকা অবস্থায় মিসড ভয়েস কল</string>
|
||||
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
|
||||
@@ -3076,6 +3081,7 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
|
||||
<string name="SupportEmailUtil_registration_lock">রেজিস্ট্রেশন লক</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
|
||||
|
||||
<!-- ThreadRecord -->
|
||||
<string name="ThreadRecord_group_updated">গ্রুপ অাপডেট হয়েছে</string>
|
||||
@@ -7796,6 +7802,8 @@
|
||||
<string name="CallLogAdapter__incoming">কল আসছে</string>
|
||||
<!-- Displayed for outgoing calls -->
|
||||
<string name="CallLogAdapter__outgoing">আউটগোয়িং কল</string>
|
||||
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
|
||||
<string name="CallLogAdapter__unanswered">Unanswered</string>
|
||||
<!-- Displayed for missed calls -->
|
||||
<string name="CallLogAdapter__missed">মিসড কল</string>
|
||||
<!-- Displayed for one missed call declined by notification profile -->
|
||||
@@ -7880,6 +7888,8 @@
|
||||
<string name="CallLogFragment__get_started_by_calling_a_friend">একজন বন্ধুকে কল দিয়ে শুরু করুন।</string>
|
||||
<!-- Displayed as a message in a dialog when deleting multiple items -->
|
||||
<string name="CallLogFragment__call_links_youve_created">আপনার তৈরি করা কলের লিংকগুলো যাদের কাছে আছে তাদের জন্য আর কাজ করবে না।</string>
|
||||
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
|
||||
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
|
||||
@@ -527,7 +527,7 @@
|
||||
<string name="ConversationActivity_unable_to_record_audio">Nije moguće snimiti zvuk!</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_to_this_group">Ne možete slati poruke ovoj grupi jer više niste njen član.</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="DisabledInputView__incognito_mode" translatable="false">Incognito mode (Labs)</string> -->
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">You can\'t send messages because the group has ended.</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">Ne možete slati poruke jer je ova grupa okončana.</string>
|
||||
<string name="ConversationActivity_only_s_can_send_messages">Samo %1$s može slati poruke.</string>
|
||||
<string name="ConversationActivity_admins">administratori</string>
|
||||
<string name="ConversationActivity_message_an_admin">Pošalji poruku administratoru</string>
|
||||
@@ -685,10 +685,10 @@
|
||||
<string name="ConversationFragment__you_can_add_notes_for_yourself_in_this_conversation">U ovom chatu možete dodati bilješke za sebe. Ako Signal koristite na više uređaja, ove će bilješke biti sinhronizovane na njima.</string>
|
||||
<!-- Text in banner to show how many people have the same name. -->
|
||||
<plurals name="ConversationFragment__d_group_members_have_the_same_name">
|
||||
<item quantity="one">%1$d group member have the same name.</item>
|
||||
<item quantity="few">%1$d group members have the same name.</item>
|
||||
<item quantity="many">%1$d group members have the same name.</item>
|
||||
<item quantity="other">%1$d group members have the same name.</item>
|
||||
<item quantity="one">%1$d član grupe ima isto ime.</item>
|
||||
<item quantity="few">%1$d člana grupe imaju isto ime.</item>
|
||||
<item quantity="many">%1$d članova grupe imaju isto ime.</item>
|
||||
<item quantity="other">%1$d članova grupe ima isto ime.</item>
|
||||
</plurals>
|
||||
<string name="ConversationFragment__tap_to_review">Dotaknite da provjerite</string>
|
||||
<!-- The body of a banner that can show up at the top of a chat, letting the user know that you have two contacts with the same name -->
|
||||
@@ -1779,7 +1779,7 @@
|
||||
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Došlo je do greške u mreži.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Ovaj link za grupu nije aktivan</string>
|
||||
<!-- Toast message shown when trying to join a group via link but the group has been ended -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Unable to join this group.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Pridruživanje ovoj grupi nije uspjelo.</string>
|
||||
<!-- Toast message shown when trying to join a group by link but the group is full -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">Dostignuto je ograničenje grupe, nije moguće pridružiti se grupi</string>
|
||||
<!-- Title shown when there was an known issue getting group information from a group link -->
|
||||
@@ -1942,6 +1942,7 @@
|
||||
<string name="MediaOverviewActivity_sent_by_you_to_s">Vi ste poslali za %1$s</string>
|
||||
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
|
||||
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Ovaj medijski sadržaj još nije poslan.</string>
|
||||
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
|
||||
|
||||
<!-- Megaphones -->
|
||||
<string name="Megaphones_remind_me_later">Podsjeti me kasnije</string>
|
||||
@@ -2010,7 +2011,7 @@
|
||||
<string name="MessageRecord_you_updated_group">Ažurirali ste grupu.</string>
|
||||
<string name="MessageRecord_the_group_was_updated">Grupa je ažurirana.</string>
|
||||
<!-- Update message shown a group is terminated, but the person that terminated it is unknown. -->
|
||||
<string name="MessageRecord_the_group_was_terminated">The group ended.</string>
|
||||
<string name="MessageRecord_the_group_was_terminated">Grupa je okončana.</string>
|
||||
<!-- Update message shown when a group is terminated, placeholder is the name of the person that terminated the group. -->
|
||||
<string name="MessageRecord_s_terminated_the_group">%1$s je okončao/la grupu</string>
|
||||
<!-- Update message shown when a group is terminated by you -->
|
||||
@@ -2031,6 +2032,10 @@
|
||||
<string name="MessageRecord_declined_voice_call">Odbijen poziv</string>
|
||||
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
|
||||
<string name="MessageRecord_declined_video_call">Odbijen video poziv</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_voice_call">Propušteni glasovni poziv</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_video_call">Propušteni video poziv</string>
|
||||
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
|
||||
<string name="MessageRecord_missed_voice_call_notification_profile">Propušteni glasovni poziv dok je profil obavijesti uključen</string>
|
||||
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
|
||||
@@ -3272,6 +3277,7 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
|
||||
<string name="SupportEmailUtil_registration_lock">Zaključavanje registracije:</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
|
||||
|
||||
<!-- ThreadRecord -->
|
||||
<string name="ThreadRecord_group_updated">Grupa ažurirana</string>
|
||||
@@ -3855,7 +3861,7 @@
|
||||
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">Zaključaj snimanje zvučnog priloga</string>
|
||||
<string name="conversation_activity__message_could_not_be_sent">Nije bilo moguće poslati poruku. Provjerite svoju konekciju i pokušajte ponovo.</string>
|
||||
<!-- Dialog body shown when tapping a failed message in a terminated group -->
|
||||
<string name="conversation_activity__send_failed_group_ended">You can no longer send and receive messages in this group because the group has ended.</string>
|
||||
<string name="conversation_activity__send_failed_group_ended">Više ne možete slati niti primati poruke u ovoj grupi jer je grupa okončana.</string>
|
||||
<!-- Dialog body shown when tapping a group action button (e.g. invite friends) in a terminated group -->
|
||||
<string name="conversation_activity__group_action_not_allowed_group_ended">Ova radnja nije dostupna jer je grupa okončana.</string>
|
||||
<!-- Dialog body when a message failed to delete and retry is possible. -->
|
||||
@@ -3913,31 +3919,31 @@
|
||||
<string name="ConversationUpdateItem_update">Ažuriraj</string>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__group_update">
|
||||
<item quantity="one">%1$d group update</item>
|
||||
<item quantity="few">%1$d group updates</item>
|
||||
<item quantity="many">%1$d group updates</item>
|
||||
<item quantity="other">%1$d group updates</item>
|
||||
<item quantity="one">%1$d ažuriranje u grupi</item>
|
||||
<item quantity="few">%1$d ažuriranja u grupi</item>
|
||||
<item quantity="many">%1$d ažuriranja u grupi</item>
|
||||
<item quantity="other">%1$d ažuriranja u grupi</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__chat_update">
|
||||
<item quantity="one">%1$d chat update</item>
|
||||
<item quantity="few">%1$d chat updates</item>
|
||||
<item quantity="many">%1$d chat updates</item>
|
||||
<item quantity="other">%1$d chat updates</item>
|
||||
<item quantity="one">%1$d ažuriranje u chatu</item>
|
||||
<item quantity="few">%1$d ažuriranja u chatu</item>
|
||||
<item quantity="many">%1$d ažuriranja u chatu</item>
|
||||
<item quantity="other">%1$d ažuriranja u chatu</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many disappearing message timer changes are. %2$s is what the timer was ultimately set to.-->
|
||||
<plurals name="CollapsedEvent__disappearing_timer">
|
||||
<item quantity="one">%1$d disappearing message timer change · %2$s</item>
|
||||
<item quantity="few">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="many">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="other">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="one">%1$d promjena tajmera za poruke koje nestaju · %2$s</item>
|
||||
<item quantity="few">%1$d promjene tajmera za poruke koje nestaju · %2$s</item>
|
||||
<item quantity="many">%1$d promjena tajmera za poruke koje nestaju · %2$s</item>
|
||||
<item quantity="other">%1$d promjene tajmera za poruke koje nestaju · %2$s</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many call events are. -->
|
||||
<plurals name="CollapsedEvent__call_event">
|
||||
<item quantity="one">%1$d call event</item>
|
||||
<item quantity="few">%1$d call events</item>
|
||||
<item quantity="many">%1$d call events</item>
|
||||
<item quantity="other">%1$d call events</item>
|
||||
<item quantity="one">%1$d događaj vezan za pozive</item>
|
||||
<item quantity="few">%1$d događaja vezana za pozive</item>
|
||||
<item quantity="many">%1$d događaja vezanih za pozive</item>
|
||||
<item quantity="other">%1$d događaja vezana za pozive</item>
|
||||
</plurals>
|
||||
|
||||
<!-- audio_view -->
|
||||
@@ -6031,35 +6037,35 @@
|
||||
<!-- Row description for the plaintext chat export option -->
|
||||
<string name="ChatsSettingsFragment__export_chat_history_label">Izvezite mašinski čitljivu JSON kopiju svih vaših chatova. Poruke koje nestaju neće biti izvezene.</string>
|
||||
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Otključajte da izvezete historiju chata</string>
|
||||
<!-- Snackbar shown when biometric authentication fails before a chat export -->
|
||||
<string name="ChatsSettingsFragment__authentication_failed">Authentication failed</string>
|
||||
<string name="ChatsSettingsFragment__authentication_failed">Autentifikacija nije uspjela</string>
|
||||
|
||||
<!-- ChatExportDialogs -->
|
||||
<!-- Progress dialog message shown while canceling an in-progress chat export -->
|
||||
<string name="ChatExportDialogs__canceling_export">Canceling export…</string>
|
||||
<string name="ChatExportDialogs__canceling_export">Otkazivanje izvoza…</string>
|
||||
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
|
||||
<string name="ChatExportDialogs__export_chat_history_title">Izvesti historiju chata?</string>
|
||||
<!-- Bold warning prefix in the export confirmation dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
|
||||
<string name="ChatExportDialogs__be_careful_warning">BUDITE OPREZNI!</string>
|
||||
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
|
||||
<string name="ChatExportDialogs__export_confirm_body">Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size.</string>
|
||||
<string name="ChatExportDialogs__export_confirm_body">NE dijelite ovu datoteku ni sa kim. Vaša historija razgovora će se sačuvati na vašem uređaju, a druge aplikacije joj mogu pristupiti u zavisnosti od dozvola na vašem uređaju. Izvoz s medijskim sadržajem će za rezultat imati veću datoteku.</string>
|
||||
<!-- Button in the export confirmation dialog to export messages and media -->
|
||||
<string name="ChatExportDialogs__export_with_media">Export with media</string>
|
||||
<string name="ChatExportDialogs__export_with_media">Izvezi s medijskim sadržajem</string>
|
||||
<!-- Button in the export confirmation dialog to export messages only -->
|
||||
<string name="ChatExportDialogs__export_without_media">Export without media</string>
|
||||
<string name="ChatExportDialogs__export_without_media">Izvezi bez medijskog sadržaja</string>
|
||||
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_title">Odaberite folder</string>
|
||||
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Odaberite folder u pohrani uređaja gdje će se pohranjivati vaša historija chata.</string>
|
||||
<!-- Button that opens the system folder picker -->
|
||||
<string name="ChatExportDialogs__choose_folder_button">Odaberi direktorij</string>
|
||||
<!-- Title of the dialog shown when a chat export finishes successfully -->
|
||||
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
|
||||
<string name="ChatExportDialogs__complete_title">Izvoz chata je završen</string>
|
||||
<!-- Bold warning prefix in the export complete dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
|
||||
<string name="ChatExportDialogs__be_careful">BUDITE OPREZNI</string>
|
||||
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
|
||||
<string name="ChatExportDialogs__complete_body">where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions.</string>
|
||||
<string name="ChatExportDialogs__complete_body">gdje pohranjujete svoju izvezenu datoteku za chat i ne dijelite je ni sa kim. Druge aplikacije na vašem uređaju mogu pristupiti ovisno o dopuštenjima vašeg uređaja.</string>
|
||||
|
||||
<!-- ChatFoldersEducationSheet -->
|
||||
<!-- Text in a bottom sheet describing chat folders and what they can be created for -->
|
||||
@@ -6418,7 +6424,7 @@
|
||||
<!-- End group option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__end_group">Okončaj grupu</string>
|
||||
<!-- Banner shown at the top of group settings when the group has been ended -->
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">Ova grupa je okončana.</string>
|
||||
<!-- Archive chat option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__archive_chat">Arhiviraj chat</string>
|
||||
<!-- Delete chat option in group conversation settings screen -->
|
||||
@@ -7253,7 +7259,7 @@
|
||||
<!-- Message of dialog to confirm deletion of story -->
|
||||
<string name="MyStories__this_story_will_be_deleted">Ova priča će biti izbrisana i za vas i za sve one koji su je primili.</string>
|
||||
<!-- Message of dialog to confirm deletion of story in a terminated group -->
|
||||
<string name="MyStories__delete_story_terminated_group">It will only be deleted for you because the group has ended.</string>
|
||||
<string name="MyStories__delete_story_terminated_group">Izbrisat će se za vas samo zato što je grupa okončana.</string>
|
||||
<!-- Toast shown when story media cannot be saved -->
|
||||
<string name="MyStories__unable_to_save">Nije moguće spremiti</string>
|
||||
|
||||
@@ -8152,6 +8158,8 @@
|
||||
<string name="CallLogAdapter__incoming">Dolazni</string>
|
||||
<!-- Displayed for outgoing calls -->
|
||||
<string name="CallLogAdapter__outgoing">Odlazni</string>
|
||||
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
|
||||
<string name="CallLogAdapter__unanswered">Unanswered</string>
|
||||
<!-- Displayed for missed calls -->
|
||||
<string name="CallLogAdapter__missed">Propušteni</string>
|
||||
<!-- Displayed for one missed call declined by notification profile -->
|
||||
@@ -8242,6 +8250,8 @@
|
||||
<string name="CallLogFragment__get_started_by_calling_a_friend">Započnite pozivom prijatelja.</string>
|
||||
<!-- Displayed as a message in a dialog when deleting multiple items -->
|
||||
<string name="CallLogFragment__call_links_youve_created">Poveznice za pozive koje ste kreirali više neće raditi za osobe koje ih imaju.</string>
|
||||
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
|
||||
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
@@ -8992,14 +9002,14 @@
|
||||
<!-- Progress message shown while ending a group -->
|
||||
<string name="EndGroupDialog__ending_group">Okončavanje grupe…</string>
|
||||
<!-- Message shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Couldn\'t end the group. Check your connection and try again.</string>
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Nije bilo moguće okončati grupu. Provjerite vezu i pokušajte ponovno.</string>
|
||||
<!-- Retry button shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__try_again">Pokušajte ponovo</string>
|
||||
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, with admin name. %1$s is the admin\'s display name -->
|
||||
<string name="TerminatedGroupBottomSheet__s_ended_the_group">%1$s je završio/la grupu</string>
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, without admin name -->
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">The group has ended.</string>
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">Grupa je okončana.</string>
|
||||
<!-- Body of bottom sheet shown when opening a terminated group -->
|
||||
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">Više nećete moći slati niti primati poruke ili pozive u ovoj grupi.</string>
|
||||
|
||||
@@ -9383,7 +9393,7 @@
|
||||
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">Ovo je isto što i vaš ključ za oporavak na uređaju.</string>
|
||||
<!-- Screen body shown when enabling on-device backups while Signal Secure Backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">This is the same as your Signal Secure Backups recovery key.</string>
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">Ovo je isto kao ključ za oporavak zaštićene kopije podataka.</string>
|
||||
<!-- Label shown above a list of actions that the recovery key can be used for. -->
|
||||
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Koristite ovaj ključ za:</string>
|
||||
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
|
||||
@@ -10201,10 +10211,10 @@
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__title">Nije moguće vratiti sigurnosnu kopiju</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining the likely cause -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring.</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">Došlo je do greške i vaša sigurnosna kopija nije vraćena. To može biti zato što je folder sigurnosne kopije premješten na vašem uređaju tokom vraćanja.</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining how to try again -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\".</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">Pokušajte ponovo tako što ćete deinstalirati i ponovo instalirati Signal na ovom uređaju, a zatim odabrati \"Vrati ili prenesi\".</string>
|
||||
|
||||
<!-- EOF -->
|
||||
</resources>
|
||||
|
||||
@@ -517,7 +517,7 @@
|
||||
<string name="ConversationActivity_unable_to_record_audio">No s\'ha pogut enregistrar l\'àudio.</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_to_this_group">No podeu enviar missatges a aquest grup perquè ja no en sou membre.</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="DisabledInputView__incognito_mode" translatable="false">Incognito mode (Labs)</string> -->
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">You can\'t send messages because the group has ended.</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">No pots enviar missatges perquè aquest grup s\'ha tancat.</string>
|
||||
<string name="ConversationActivity_only_s_can_send_messages">Només els %1$s poden enviar missatges.</string>
|
||||
<string name="ConversationActivity_admins">administradors</string>
|
||||
<string name="ConversationActivity_message_an_admin">Envia un missatge a un administrador</string>
|
||||
@@ -1683,7 +1683,7 @@
|
||||
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Hi ha hagut un error de xarxa.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Aquest enllaç de grup no està actiu.</string>
|
||||
<!-- Toast message shown when trying to join a group via link but the group has been ended -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Unable to join this group.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">No pots unir-te a aquest grup.</string>
|
||||
<!-- Toast message shown when trying to join a group by link but the group is full -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">S\'ha arribat al límit del grup, no pots unir-te al grup</string>
|
||||
<!-- Title shown when there was an known issue getting group information from a group link -->
|
||||
@@ -1832,6 +1832,7 @@
|
||||
<string name="MediaOverviewActivity_sent_by_you_to_s">Ho heu enviat a %1$s</string>
|
||||
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
|
||||
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Aquest arxiu encara no ha estat enviat.</string>
|
||||
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
|
||||
|
||||
<!-- Megaphones -->
|
||||
<string name="Megaphones_remind_me_later">Recorda-m\'ho més tard.</string>
|
||||
@@ -1900,7 +1901,7 @@
|
||||
<string name="MessageRecord_you_updated_group">Heu actualitzat el grup.</string>
|
||||
<string name="MessageRecord_the_group_was_updated">S\'ha actualitzat el grup.</string>
|
||||
<!-- Update message shown a group is terminated, but the person that terminated it is unknown. -->
|
||||
<string name="MessageRecord_the_group_was_terminated">The group ended.</string>
|
||||
<string name="MessageRecord_the_group_was_terminated">S\'ha tancat el grup.</string>
|
||||
<!-- Update message shown when a group is terminated, placeholder is the name of the person that terminated the group. -->
|
||||
<string name="MessageRecord_s_terminated_the_group">%1$s ha tancat el grup</string>
|
||||
<!-- Update message shown when a group is terminated by you -->
|
||||
@@ -1921,6 +1922,10 @@
|
||||
<string name="MessageRecord_declined_voice_call">Trucada de veu rebutjada</string>
|
||||
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
|
||||
<string name="MessageRecord_declined_video_call">Videotrucada rebutjada</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_voice_call">Trucada sense contestar</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_video_call">Videotrucada sense contestar</string>
|
||||
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
|
||||
<string name="MessageRecord_missed_voice_call_notification_profile">Trucada de veu perduda mentre els ajustos de notificació estaven activats</string>
|
||||
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
|
||||
@@ -3076,6 +3081,7 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
|
||||
<string name="SupportEmailUtil_registration_lock">Bloqueig de registre:</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
|
||||
|
||||
<!-- ThreadRecord -->
|
||||
<string name="ThreadRecord_group_updated">S\'ha actualitzat el grup</string>
|
||||
@@ -3637,7 +3643,7 @@
|
||||
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">Bloqueja la gravació d\'un adjunt d\'àudio</string>
|
||||
<string name="conversation_activity__message_could_not_be_sent">No s\'ha pogut enviar el missatge. Comproveu la connexió i torneu-ho a provar.</string>
|
||||
<!-- Dialog body shown when tapping a failed message in a terminated group -->
|
||||
<string name="conversation_activity__send_failed_group_ended">You can no longer send and receive messages in this group because the group has ended.</string>
|
||||
<string name="conversation_activity__send_failed_group_ended">Ja no pots enviar ni rebre missatges perquè aquest grup s\'ha tancat.</string>
|
||||
<!-- Dialog body shown when tapping a group action button (e.g. invite friends) in a terminated group -->
|
||||
<string name="conversation_activity__group_action_not_allowed_group_ended">Aquesta acció no està disponible perquè s\'ha tancat el grup.</string>
|
||||
<!-- Dialog body when a message failed to delete and retry is possible. -->
|
||||
@@ -3695,23 +3701,23 @@
|
||||
<string name="ConversationUpdateItem_update">Actualitza-la</string>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__group_update">
|
||||
<item quantity="one">%1$d group update</item>
|
||||
<item quantity="other">%1$d group updates</item>
|
||||
<item quantity="one">%1$d actualització de grup</item>
|
||||
<item quantity="other">%1$d actualitzacions de grup</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__chat_update">
|
||||
<item quantity="one">%1$d chat update</item>
|
||||
<item quantity="other">%1$d chat updates</item>
|
||||
<item quantity="one">%1$d actualització de xat</item>
|
||||
<item quantity="other">%1$d actualitzacions de xat</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many disappearing message timer changes are. %2$s is what the timer was ultimately set to.-->
|
||||
<plurals name="CollapsedEvent__disappearing_timer">
|
||||
<item quantity="one">%1$d disappearing message timer change · %2$s</item>
|
||||
<item quantity="other">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="one">S\'ha canviat el temporitzador de %1$d missatge a desaparèixer · %2$s</item>
|
||||
<item quantity="other">S\'ha canviat el temporitzador de %1$d missatges a desaparèixer · %2$s</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many call events are. -->
|
||||
<plurals name="CollapsedEvent__call_event">
|
||||
<item quantity="one">%1$d call event</item>
|
||||
<item quantity="other">%1$d call events</item>
|
||||
<item quantity="one">%1$d trucada</item>
|
||||
<item quantity="other">%1$d trucades</item>
|
||||
</plurals>
|
||||
|
||||
<!-- audio_view -->
|
||||
@@ -5749,35 +5755,35 @@
|
||||
<!-- Row description for the plaintext chat export option -->
|
||||
<string name="ChatsSettingsFragment__export_chat_history_label">Exportar una còpia JSON llegible per màquina de tots els teus xats. Els missatges a desaparèixer no s\'exportaran.</string>
|
||||
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Desbloqueja per exportar l\'historial del xat</string>
|
||||
<!-- Snackbar shown when biometric authentication fails before a chat export -->
|
||||
<string name="ChatsSettingsFragment__authentication_failed">Ha fallat l\'autenticació.</string>
|
||||
|
||||
<!-- ChatExportDialogs -->
|
||||
<!-- Progress dialog message shown while canceling an in-progress chat export -->
|
||||
<string name="ChatExportDialogs__canceling_export">Canceling export…</string>
|
||||
<string name="ChatExportDialogs__canceling_export">Cancel·lant exportació…</string>
|
||||
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
|
||||
<string name="ChatExportDialogs__export_chat_history_title">Vols exportar l\'historial del xat?</string>
|
||||
<!-- Bold warning prefix in the export confirmation dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
|
||||
<string name="ChatExportDialogs__be_careful_warning">VIGILA!</string>
|
||||
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
|
||||
<string name="ChatExportDialogs__export_confirm_body">Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size.</string>
|
||||
<string name="ChatExportDialogs__export_confirm_body">NO comparteixis aquest arxiu amb ningú. El teu historial del xat es desarà al dispositiu i, d’acord amb els permisos que concedeixis, podrà ser accessible per altres aplicacions. Si l\'exportes amb els arxius multimèdia, la mida de l\'arxiu serà més gran.</string>
|
||||
<!-- Button in the export confirmation dialog to export messages and media -->
|
||||
<string name="ChatExportDialogs__export_with_media">Export with media</string>
|
||||
<string name="ChatExportDialogs__export_with_media">Exportar amb arxius multimèdia</string>
|
||||
<!-- Button in the export confirmation dialog to export messages only -->
|
||||
<string name="ChatExportDialogs__export_without_media">Export without media</string>
|
||||
<string name="ChatExportDialogs__export_without_media">Exportar sense arxius multimèdia</string>
|
||||
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_title">Tria una carpeta</string>
|
||||
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Selecciona una carpeta al teu dispositiu on vulguis emmagatzemar l\'historial del xat.</string>
|
||||
<!-- Button that opens the system folder picker -->
|
||||
<string name="ChatExportDialogs__choose_folder_button">Trieu una carpeta</string>
|
||||
<!-- Title of the dialog shown when a chat export finishes successfully -->
|
||||
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
|
||||
<string name="ChatExportDialogs__complete_title">Exportació del xat completada</string>
|
||||
<!-- Bold warning prefix in the export complete dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
|
||||
<string name="ChatExportDialogs__be_careful">VIGILA</string>
|
||||
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
|
||||
<string name="ChatExportDialogs__complete_body">where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions.</string>
|
||||
<string name="ChatExportDialogs__complete_body">a on guardes el fitxer d\'exportació del xat i no el comparteixis amb ningú. Depenent dels permisos del teu dispositiu, altres aplicacions també podrien accedir-hi.</string>
|
||||
|
||||
<!-- ChatFoldersEducationSheet -->
|
||||
<!-- Text in a bottom sheet describing chat folders and what they can be created for -->
|
||||
@@ -6124,7 +6130,7 @@
|
||||
<!-- End group option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__end_group">Tancar grup</string>
|
||||
<!-- Banner shown at the top of group settings when the group has been ended -->
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">S\'ha tancat el grup.</string>
|
||||
<!-- Archive chat option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__archive_chat">Arxivar xat</string>
|
||||
<!-- Delete chat option in group conversation settings screen -->
|
||||
@@ -6941,7 +6947,7 @@
|
||||
<!-- Message of dialog to confirm deletion of story -->
|
||||
<string name="MyStories__this_story_will_be_deleted">Aquesta història se suprimirà per a tu i per a tots els que l\'han rebut.</string>
|
||||
<!-- Message of dialog to confirm deletion of story in a terminated group -->
|
||||
<string name="MyStories__delete_story_terminated_group">It will only be deleted for you because the group has ended.</string>
|
||||
<string name="MyStories__delete_story_terminated_group">Només s\'eliminarà per a tu perquè aquest grup s\'ha tancat.</string>
|
||||
<!-- Toast shown when story media cannot be saved -->
|
||||
<string name="MyStories__unable_to_save">No s\'ha pogut guardar</string>
|
||||
|
||||
@@ -7796,6 +7802,8 @@
|
||||
<string name="CallLogAdapter__incoming">Entrant</string>
|
||||
<!-- Displayed for outgoing calls -->
|
||||
<string name="CallLogAdapter__outgoing">Sortint</string>
|
||||
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
|
||||
<string name="CallLogAdapter__unanswered">Unanswered</string>
|
||||
<!-- Displayed for missed calls -->
|
||||
<string name="CallLogAdapter__missed">Perduda</string>
|
||||
<!-- Displayed for one missed call declined by notification profile -->
|
||||
@@ -7880,6 +7888,8 @@
|
||||
<string name="CallLogFragment__get_started_by_calling_a_friend">Comença trucant a algú.</string>
|
||||
<!-- Displayed as a message in a dialog when deleting multiple items -->
|
||||
<string name="CallLogFragment__call_links_youve_created">Els enllaços de trucada que hagis creat ja no funcionaran per a les persones que els tinguin.</string>
|
||||
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
|
||||
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
@@ -8618,14 +8628,14 @@
|
||||
<!-- Progress message shown while ending a group -->
|
||||
<string name="EndGroupDialog__ending_group">Tancant grup…</string>
|
||||
<!-- Message shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Couldn\'t end the group. Check your connection and try again.</string>
|
||||
<string name="EndGroupDialog__ending_the_group_failed">No s\'ha pogut tancar el grup. Comprova la connexió i torna-ho a provar.</string>
|
||||
<!-- Retry button shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__try_again">Torna a provar-ho</string>
|
||||
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, with admin name. %1$s is the admin\'s display name -->
|
||||
<string name="TerminatedGroupBottomSheet__s_ended_the_group">%1$s ha tancat el grup</string>
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, without admin name -->
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">The group has ended.</string>
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">S\'ha tancat el grup.</string>
|
||||
<!-- Body of bottom sheet shown when opening a terminated group -->
|
||||
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">Ja no pots enviar ni rebre missatges o trucades en aquest grup.</string>
|
||||
|
||||
@@ -8997,7 +9007,7 @@
|
||||
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">És la mateixa que la teva clau de còpia de seguretat local.</string>
|
||||
<!-- Screen body shown when enabling on-device backups while Signal Secure Backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">This is the same as your Signal Secure Backups recovery key.</string>
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">És la mateixa que la teva clau de recuperació de Còpies de seguretat segures de Signal.</string>
|
||||
<!-- Label shown above a list of actions that the recovery key can be used for. -->
|
||||
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Fes servir aquesta clau per a:</string>
|
||||
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
|
||||
@@ -9805,10 +9815,10 @@
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__title">No es pot restaurar la còpia de seguretat</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining the likely cause -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring.</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">S\'ha produït un error i no s\'ha pogut restaurar la teva còpia de seguretat. És possible que la carpeta on s\'emmagatzemen les còpies de seguretat al teu dispositiu s\'hagi mogut mentre la restauració estava en curs.</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining how to try again -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\".</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">Per tornar-ho a provar, desinstal·la i torna a instal·lar Signal en aquest dispositiu, i selecciona \"Restaurar o transferir\".</string>
|
||||
|
||||
<!-- EOF -->
|
||||
</resources>
|
||||
|
||||
@@ -527,7 +527,7 @@
|
||||
<string name="ConversationActivity_unable_to_record_audio">Nemohu nahrávat audio!</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_to_this_group">Do této skupiny nemůžete zasílat zprávy, jelikož už nejste jejím členem.</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="DisabledInputView__incognito_mode" translatable="false">Incognito mode (Labs)</string> -->
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">You can\'t send messages because the group has ended.</string>
|
||||
<string name="ConversationActivity_you_cant_send_messages_because_group_ended">Zprávy nelze odesílat, protože skupina byla ukončena.</string>
|
||||
<string name="ConversationActivity_only_s_can_send_messages">Pouze %1$s může posílat zprávy.</string>
|
||||
<string name="ConversationActivity_admins">správci</string>
|
||||
<string name="ConversationActivity_message_an_admin">Poslat zprávu správci</string>
|
||||
@@ -1779,7 +1779,7 @@
|
||||
<string name="GroupJoinBottomSheetDialogFragment_encountered_a_network_error">Došlo k chybě v síti.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_link_is_not_active">Tento odkaz skupiny není aktivní</string>
|
||||
<!-- Toast message shown when trying to join a group via link but the group has been ended -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">Unable to join this group.</string>
|
||||
<string name="GroupJoinBottomSheetDialogFragment_this_group_has_been_ended">K této skupině se nelze připojit.</string>
|
||||
<!-- Toast message shown when trying to join a group by link but the group is full -->
|
||||
<string name="GroupJoinBottomSheetDialogFragment_group_limit_reached">Limit skupiny dosažen, ke skupině se nelze připojit</string>
|
||||
<!-- Title shown when there was an known issue getting group information from a group link -->
|
||||
@@ -1942,6 +1942,7 @@
|
||||
<string name="MediaOverviewActivity_sent_by_you_to_s">Odesláno vámi pro %1$s</string>
|
||||
<!-- Error message shown when the user is trying to open a media that is not sent yet. -->
|
||||
<string name="MediaOverviewActivity_this_media_is_not_sent_yet">Toto médium se ještě neodeslalo.</string>
|
||||
<string name="MediaOverviewActivity_jump_to_message">Jump to message</string>
|
||||
|
||||
<!-- Megaphones -->
|
||||
<string name="Megaphones_remind_me_later">Připomenout později</string>
|
||||
@@ -2010,7 +2011,7 @@
|
||||
<string name="MessageRecord_you_updated_group">Upravili jste skupinu.</string>
|
||||
<string name="MessageRecord_the_group_was_updated">Skupina byla aktualizována.</string>
|
||||
<!-- Update message shown a group is terminated, but the person that terminated it is unknown. -->
|
||||
<string name="MessageRecord_the_group_was_terminated">The group ended.</string>
|
||||
<string name="MessageRecord_the_group_was_terminated">Skupina byla ukončena.</string>
|
||||
<!-- Update message shown when a group is terminated, placeholder is the name of the person that terminated the group. -->
|
||||
<string name="MessageRecord_s_terminated_the_group">%1$s ukončil(a) skupinu</string>
|
||||
<!-- Update message shown when a group is terminated by you -->
|
||||
@@ -2031,6 +2032,10 @@
|
||||
<string name="MessageRecord_declined_voice_call">Odmítnutý hlasový hovor</string>
|
||||
<!-- Update message shown when receiving an incoming 1:1 video call and not answered -->
|
||||
<string name="MessageRecord_declined_video_call">Odmítnutý videohovor</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 voice/audio call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_voice_call">Nepřijatý hlasový hovor</string>
|
||||
<!-- Update message shown when placing an outgoing 1:1 video call and the remote party did not answer -->
|
||||
<string name="MessageRecord_unanswered_video_call">Nepřijatý videohovor</string>
|
||||
<!-- Update message shown when receiving an incoming voice call and declined due to notification profile -->
|
||||
<string name="MessageRecord_missed_voice_call_notification_profile">Zmeškaný hlasový hovor při zapnutém profilu oznámení</string>
|
||||
<!-- Update message shown when receiving an incoming video call and declined due to notification profile -->
|
||||
@@ -3272,6 +3277,7 @@
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_signal_package" translatable="false">Signal package:</string> -->
|
||||
<string name="SupportEmailUtil_registration_lock">Zámek registrace:</string>
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_locale" translatable="false">Locale:</string> -->
|
||||
<!-- Removed by excludeNonTranslatables <string name="SupportEmailUtil_challenge_received" translatable="false">Challenge Received:</string> -->
|
||||
|
||||
<!-- ThreadRecord -->
|
||||
<string name="ThreadRecord_group_updated">Skupina upravena</string>
|
||||
@@ -3855,7 +3861,7 @@
|
||||
<string name="conversation_activity__quick_attachment_drawer_lock_record_description">Zamknout nahrávání zvukové přílohy</string>
|
||||
<string name="conversation_activity__message_could_not_be_sent">Zprávu nebylo možné odeslat. Zkontrolujte své připojení a zkuste to znovu.</string>
|
||||
<!-- Dialog body shown when tapping a failed message in a terminated group -->
|
||||
<string name="conversation_activity__send_failed_group_ended">You can no longer send and receive messages in this group because the group has ended.</string>
|
||||
<string name="conversation_activity__send_failed_group_ended">V této skupině již nelze posílat a přijímat zprávy, protože byla ukončena.</string>
|
||||
<!-- Dialog body shown when tapping a group action button (e.g. invite friends) in a terminated group -->
|
||||
<string name="conversation_activity__group_action_not_allowed_group_ended">Tuto akci nelze provést, protože skupina byla ukončena.</string>
|
||||
<!-- Dialog body when a message failed to delete and retry is possible. -->
|
||||
@@ -3913,31 +3919,31 @@
|
||||
<string name="ConversationUpdateItem_update">Aktualizovat</string>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__group_update">
|
||||
<item quantity="one">%1$d group update</item>
|
||||
<item quantity="few">%1$d group updates</item>
|
||||
<item quantity="many">%1$d group updates</item>
|
||||
<item quantity="other">%1$d group updates</item>
|
||||
<item quantity="one">%1$d úprava ve skupině</item>
|
||||
<item quantity="few">%1$d úpravy ve skupině</item>
|
||||
<item quantity="many">%1$d úprav ve skupině</item>
|
||||
<item quantity="other">%1$d úprav ve skupině</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many group updates there are. -->
|
||||
<plurals name="CollapsedEvent__chat_update">
|
||||
<item quantity="one">%1$d chat update</item>
|
||||
<item quantity="few">%1$d chat updates</item>
|
||||
<item quantity="many">%1$d chat updates</item>
|
||||
<item quantity="other">%1$d chat updates</item>
|
||||
<item quantity="one">%1$d úprava chatu</item>
|
||||
<item quantity="few">%1$d úpravy chatu</item>
|
||||
<item quantity="many">%1$d úprav chatu</item>
|
||||
<item quantity="other">%1$d úprav chatu</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many disappearing message timer changes are. %2$s is what the timer was ultimately set to.-->
|
||||
<plurals name="CollapsedEvent__disappearing_timer">
|
||||
<item quantity="one">%1$d disappearing message timer change · %2$s</item>
|
||||
<item quantity="few">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="many">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="other">%1$d disappearing message timer changes · %2$s</item>
|
||||
<item quantity="one">%1$d změna časovače mizejících zpráv · %2$s</item>
|
||||
<item quantity="few">%1$d změny časovače mizejících zpráv · %2$s</item>
|
||||
<item quantity="many">%1$d změn časovače mizejících zpráv · %2$s</item>
|
||||
<item quantity="other">%1$d změn časovače mizejících zpráv · %2$s</item>
|
||||
</plurals>
|
||||
<!-- Update item button text to show how many call events are. -->
|
||||
<plurals name="CollapsedEvent__call_event">
|
||||
<item quantity="one">%1$d call event</item>
|
||||
<item quantity="few">%1$d call events</item>
|
||||
<item quantity="many">%1$d call events</item>
|
||||
<item quantity="other">%1$d call events</item>
|
||||
<item quantity="one">%1$d volání</item>
|
||||
<item quantity="few">%1$d volání</item>
|
||||
<item quantity="many">%1$d volání</item>
|
||||
<item quantity="other">%1$d volání</item>
|
||||
</plurals>
|
||||
|
||||
<!-- audio_view -->
|
||||
@@ -6029,37 +6035,37 @@
|
||||
<!-- Row title for the option to export chat history as a plaintext archive -->
|
||||
<string name="ChatsSettingsFragment__export_chat_history">Exportovat historii chatů</string>
|
||||
<!-- Row description for the plaintext chat export option -->
|
||||
<string name="ChatsSettingsFragment__export_chat_history_label">Umožňuje xportovat strojově čitelnou kopii všech vašich chatů ve formátu JSON. Mizející zprávy nebudou exportovány.</string>
|
||||
<string name="ChatsSettingsFragment__export_chat_history_label">Exportovat strojově čitelnou kopii všech chatů ve formátu JSON. Mizející zprávy nebudou exportovány.</string>
|
||||
<!-- Biometrics prompt title shown before allowing the user to export chat history -->
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Unlock to export chat history</string>
|
||||
<string name="ChatsSettingsFragment__unlock_to_export_chat_history">Odemknout pro export historie chatů</string>
|
||||
<!-- Snackbar shown when biometric authentication fails before a chat export -->
|
||||
<string name="ChatsSettingsFragment__authentication_failed">Autentifikace neúspěšná</string>
|
||||
<string name="ChatsSettingsFragment__authentication_failed">Ověření se nezdařilo</string>
|
||||
|
||||
<!-- ChatExportDialogs -->
|
||||
<!-- Progress dialog message shown while canceling an in-progress chat export -->
|
||||
<string name="ChatExportDialogs__canceling_export">Canceling export…</string>
|
||||
<string name="ChatExportDialogs__canceling_export">Rušení exportu…</string>
|
||||
<!-- Title of the dialog asking the user to confirm they want to export chat history -->
|
||||
<string name="ChatExportDialogs__export_chat_history_title">Exportovat historii chatů?</string>
|
||||
<!-- Bold warning prefix in the export confirmation dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful_warning">BE CAREFUL!</string>
|
||||
<string name="ChatExportDialogs__be_careful_warning">BUĎTE OPATRNÍ!</string>
|
||||
<!-- Body of the export confirmation dialog, displayed after the bold "BE CAREFUL!" prefix -->
|
||||
<string name="ChatExportDialogs__export_confirm_body">Do NOT share this file with anyone. Your chat history will be saved to your device and other apps can access it depending on your device\'s permissions. Exporting with media will result in a larger file size.</string>
|
||||
<string name="ChatExportDialogs__export_confirm_body">Tento soubor s nikým NESDÍLEJTE. Historie chatů se uloží do vašeho zařízení a ostatní aplikace k ní mohou mít přístup podle vašich oprávnění. Při exportu včetně médií bude výsledný soubor mnohem větší.</string>
|
||||
<!-- Button in the export confirmation dialog to export messages and media -->
|
||||
<string name="ChatExportDialogs__export_with_media">Export with media</string>
|
||||
<string name="ChatExportDialogs__export_with_media">Exportovat včetně médií</string>
|
||||
<!-- Button in the export confirmation dialog to export messages only -->
|
||||
<string name="ChatExportDialogs__export_without_media">Export without media</string>
|
||||
<string name="ChatExportDialogs__export_without_media">Exportovat bez médií</string>
|
||||
<!-- Title of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_title">Vybrat složku</string>
|
||||
<!-- Body of the dialog prompting the user to pick a destination folder for the export -->
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Choose a folder in your device\'s storage where your chat history will be stored.</string>
|
||||
<string name="ChatExportDialogs__choose_a_folder_body">Vyberte složku v úložišti vašeho zařízení, do které se uloží historie vašich chatů.</string>
|
||||
<!-- Button that opens the system folder picker -->
|
||||
<string name="ChatExportDialogs__choose_folder_button">Vybrat složku</string>
|
||||
<!-- Title of the dialog shown when a chat export finishes successfully -->
|
||||
<string name="ChatExportDialogs__complete_title">Chat export complete</string>
|
||||
<string name="ChatExportDialogs__complete_title">Export chatů dokončen</string>
|
||||
<!-- Bold warning prefix in the export complete dialog body -->
|
||||
<string name="ChatExportDialogs__be_careful">BE CAREFUL</string>
|
||||
<string name="ChatExportDialogs__be_careful">BUĎTE OPATRNÍ,</string>
|
||||
<!-- Body of the export complete dialog, displayed after the bold "BE CAREFUL" prefix -->
|
||||
<string name="ChatExportDialogs__complete_body">where you store your chat export file and do not share it with anyone. Other apps on your device can access it depending on your device\'s permissions.</string>
|
||||
<string name="ChatExportDialogs__complete_body">kam soubor s exportem chatů uložíte, a s nikým jej nesdílejte. Ostatní aplikace ve vašem zařízení k němu mohou mít přístup podle vašich oprávnění.</string>
|
||||
|
||||
<!-- ChatFoldersEducationSheet -->
|
||||
<!-- Text in a bottom sheet describing chat folders and what they can be created for -->
|
||||
@@ -6418,7 +6424,7 @@
|
||||
<!-- End group option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__end_group">Ukončit skupinu</string>
|
||||
<!-- Banner shown at the top of group settings when the group has been ended -->
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">This group has ended.</string>
|
||||
<string name="ConversationSettingsFragment__this_group_was_ended">Tato skupina byla ukončena.</string>
|
||||
<!-- Archive chat option in group conversation settings screen -->
|
||||
<string name="ConversationSettingsFragment__archive_chat">Archivovat chat</string>
|
||||
<!-- Delete chat option in group conversation settings screen -->
|
||||
@@ -7253,7 +7259,7 @@
|
||||
<!-- Message of dialog to confirm deletion of story -->
|
||||
<string name="MyStories__this_story_will_be_deleted">Příběh bude odstraněn pro vás a pro všechny, kdo ho obdrželi.</string>
|
||||
<!-- Message of dialog to confirm deletion of story in a terminated group -->
|
||||
<string name="MyStories__delete_story_terminated_group">It will only be deleted for you because the group has ended.</string>
|
||||
<string name="MyStories__delete_story_terminated_group">Odstraní se pouze u vás, protože skupina byla ukončena.</string>
|
||||
<!-- Toast shown when story media cannot be saved -->
|
||||
<string name="MyStories__unable_to_save">Nepodařilo se uložit</string>
|
||||
|
||||
@@ -8152,6 +8158,8 @@
|
||||
<string name="CallLogAdapter__incoming">Příchozí</string>
|
||||
<!-- Displayed for outgoing calls -->
|
||||
<string name="CallLogAdapter__outgoing">Odchozí</string>
|
||||
<!-- Displayed for outgoing calls that were not accepted by the remote party -->
|
||||
<string name="CallLogAdapter__unanswered">Unanswered</string>
|
||||
<!-- Displayed for missed calls -->
|
||||
<string name="CallLogAdapter__missed">Zmeškané</string>
|
||||
<!-- Displayed for one missed call declined by notification profile -->
|
||||
@@ -8242,6 +8250,8 @@
|
||||
<string name="CallLogFragment__get_started_by_calling_a_friend">Začněte tím, že zavoláte příteli.</string>
|
||||
<!-- Displayed as a message in a dialog when deleting multiple items -->
|
||||
<string name="CallLogFragment__call_links_youve_created">Vámi vytvořené odkazy na hovory již nebudou pro lidi, kteří je mají, fungovat.</string>
|
||||
<!-- Dialog message shown when trying to start a group call but the user is no longer a member -->
|
||||
<string name="CallLogFragment__cant_start_call_no_longer_a_member">You\'re no longer a member of this group.</string>
|
||||
|
||||
<!-- New call activity -->
|
||||
<!-- Activity title in title bar -->
|
||||
@@ -8992,14 +9002,14 @@
|
||||
<!-- Progress message shown while ending a group -->
|
||||
<string name="EndGroupDialog__ending_group">Ukončování skupiny…</string>
|
||||
<!-- Message shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Couldn\'t end the group. Check your connection and try again.</string>
|
||||
<string name="EndGroupDialog__ending_the_group_failed">Skupinu se nepodařilo ukončit. Zkontrolujte připojení a zkuste to znovu.</string>
|
||||
<!-- Retry button shown when ending a group fails -->
|
||||
<string name="EndGroupDialog__try_again">Zkusit znovu</string>
|
||||
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, with admin name. %1$s is the admin\'s display name -->
|
||||
<string name="TerminatedGroupBottomSheet__s_ended_the_group">%1$s ukončil(a) skupinu</string>
|
||||
<!-- Title of bottom sheet shown when opening a terminated group for the first time, without admin name -->
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">The group has ended.</string>
|
||||
<string name="TerminatedGroupBottomSheet__the_group_has_been_ended">Skupina byla ukončena.</string>
|
||||
<!-- Body of bottom sheet shown when opening a terminated group -->
|
||||
<string name="TerminatedGroupBottomSheet__you_can_no_longer_send_and_receive">V této skupině již nelze posílat a přijímat zprávy ani hovory.</string>
|
||||
|
||||
@@ -9383,7 +9393,7 @@
|
||||
<!-- Screen body shown when enabling Signal Secure Backups while on-device backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__remote_backup_with_local_enabled_description_bold">Jedná se o stejný klíč, jako je váš klíč pro obnovení v zařízení.</string>
|
||||
<!-- Screen body shown when enabling on-device backups while Signal Secure Backups are already enabled (part 2, bolded). -->
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">This is the same as your Signal Secure Backups recovery key.</string>
|
||||
<string name="MessageBackupsKeyEducationScreen__local_backup_with_remote_enabled_description_bold">Jedná se o stejný klíč, jako je váš klíč pro obnovení zabezpečené zálohy Signal.</string>
|
||||
<!-- Label shown above a list of actions that the recovery key can be used for. -->
|
||||
<string name="MessageBackupsKeyEducationScreen__use_this_key_to">Tento klíč lze použít k:</string>
|
||||
<!-- Row label indicating that the recovery key can be used to restore an on-device backup. -->
|
||||
@@ -10201,10 +10211,10 @@
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__title">Zálohu nelze obnovit</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining the likely cause -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">An error occurred and your backup can\'t be restored. This may be because the backup folder was moved on your device while your backup was restoring.</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_error">Došlo k chybě a zálohu nelze obnovit. Mohlo k tomu dojít proto, že se během obnovování zálohy změnilo umístění složky se zálohou ve vašem zařízení.</string>
|
||||
|
||||
<!-- Body of the sheet shown when a local backup restore could not be completed, explaining how to try again -->
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">To try again, uninstall and re-install Signal on this device, and choose \"Restore or transfer\".</string>
|
||||
<string name="CouldNotCompleteBackupRestoreSheet__body_retry">Můžete to zkusit znovu tak, že odinstalujete a znovu nainstalujete Signal v tomto zařízení a zvolíte Obnovit nebo přenést.</string>
|
||||
|
||||
<!-- EOF -->
|
||||
</resources>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user