diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 26d2e3fea2..702acd19bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,11 +18,11 @@ apply { from("fix-profm.gradle") } -val canonicalVersionCode = 1484 -val canonicalVersionName = "7.24.2" +val canonicalVersionCode = 1487 +val canonicalVersionName = "7.25.2" val currentHotfixVersion = 0 val maxHotfixVersions = 100 -val mollyRevision = 2 +val mollyRevision = 1 val sourceVersionNameWithRevision = "${canonicalVersionName}-${mollyRevision}" @@ -83,7 +83,6 @@ android { ndkVersion = signalNdkVersion flavorDimensions += listOf("environment", "license", "distribution") - useLibrary("org.apache.http.legacy") testBuildType = "instrumentation" android.bundle.language.enableSplit = false @@ -491,7 +490,6 @@ dependencies { implementation(libs.molly.ringrtc) implementation(libs.leolin.shortcutbadger) implementation(libs.emilsjolander.stickylistheaders) - implementation(libs.apache.httpclient.android) implementation(libs.glide.glide) implementation(libs.roundedimageview) implementation(libs.materialish.progress) diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientTableTest_applyStorageSyncContactUpdate.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientTableTest_applyStorageSyncContactUpdate.kt index dfd59bf614..b21be1a9ca 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientTableTest_applyStorageSyncContactUpdate.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientTableTest_applyStorageSyncContactUpdate.kt @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.testing.SignalActivityRule import org.thoughtcrime.securesms.testing.assertIs import org.thoughtcrime.securesms.util.MessageTableTestUtils import org.whispersystems.signalservice.api.storage.SignalContactRecord +import org.whispersystems.signalservice.api.storage.toSignalContactRecord import org.whispersystems.signalservice.internal.storage.protos.ContactRecord @Suppress("ClassName") @@ -34,10 +35,10 @@ class RecipientTableTest_applyStorageSyncContactUpdate { MmsHelper.insert(recipient = other) identities.setVerified(other.id, harness.othersKeys[0].publicKey, IdentityTable.VerifiedStatus.VERIFIED) - val oldRecord: SignalContactRecord = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(harness.others[0])!!).contact.get() + val oldRecord: SignalContactRecord = StorageSyncModels.localToRemoteRecord(SignalDatabase.recipients.getRecordForSync(harness.others[0])!!).let { it.proto.contact!!.toSignalContactRecord(it.id) } val newProto = oldRecord - .toProto() + .proto .newBuilder() .identityState(ContactRecord.IdentityState.DEFAULT) .build() diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalActivityRule.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalActivityRule.kt index 3de19a4a0d..3134c90acf 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalActivityRule.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalActivityRule.kt @@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.profiles.ProfileName import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil import org.thoughtcrime.securesms.registration.data.RegistrationData import org.thoughtcrime.securesms.registration.data.RegistrationRepository @@ -101,7 +102,7 @@ class SignalActivityRule(private val othersCount: Int = 4, private val createGro pniRegistrationId = RegistrationRepository.getPniRegistrationId(), recoveryPassword = "asdfasdfasdfasdf" ) - val remoteResult = RegistrationRepository.AccountRegistrationResult( + val remoteResult = AccountRegistrationResult( uuid = UUID.randomUUID().toString(), pni = UUID.randomUUID().toString(), storageCapable = false, diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1284ea9d3e..70955770f8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -642,7 +642,7 @@ android:exported="false"/> @@ -819,6 +819,11 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" android:exported="false"/> + + + + - @@ -1040,13 +1052,13 @@ - - diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java index 9c127c9f9a..bbd0ea6966 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java @@ -26,6 +26,7 @@ import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController; import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner; import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment; +import org.thoughtcrime.securesms.conversationlist.RestoreCompleteBottomSheetDialog; import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor; @@ -187,7 +188,16 @@ protected void onPreCreate() { protected void onResume() { super.onResume(); dynamicTheme.onResume(this); - if (SignalStore.misc().isOldDeviceTransferLocked()) { + + if (SignalStore.misc().getShouldShowLinkedDevicesReminder()) { + SignalStore.misc().setShouldShowLinkedDevicesReminder(false); + RelinkDevicesReminderBottomSheetFragment.show(getSupportFragmentManager()); + } + + if (SignalStore.registration().isRestoringOnNewDevice()) { + SignalStore.registration().setRestoringOnNewDevice(false); + RestoreCompleteBottomSheetDialog.show(getSupportFragmentManager()); + } else if (SignalStore.misc().isOldDeviceTransferLocked()) { new MaterialAlertDialogBuilder(this) .setTitle(R.string.OldDeviceTransferLockedDialog__complete_registration_on_your_new_device) .setMessage(R.string.OldDeviceTransferLockedDialog__your_signal_account_has_been_transferred_to_your_new_device) @@ -200,11 +210,6 @@ protected void onResume() { .show(); } - if (SignalStore.misc().getShouldShowLinkedDevicesReminder()) { - SignalStore.misc().setShouldShowLinkedDevicesReminder(false); - RelinkDevicesReminderBottomSheetFragment.show(getSupportFragmentManager()); - } - updateTabVisibility(); vitalsViewModel.checkSlowNotificationHeuristics(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java index bfa0dc2d36..8ce7218a1c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java @@ -127,7 +127,7 @@ private int getApplicationState(boolean locked) { return STATE_UI_BLOCKING_UPGRADE; } else if (!TextSecurePreferences.hasPromptedPushRegistration(this)) { return STATE_WELCOME_PUSH_SCREEN; - } else if (SignalStore.storageService().needsAccountRestore()) { + } else if (SignalStore.storageService().getNeedsAccountRestore()) { return STATE_ENTER_SIGNAL_PIN; } else if (userCanTransferOrRestore()) { return STATE_TRANSFER_OR_RESTORE; @@ -151,11 +151,10 @@ private boolean userCanTransferOrRestore() { } private boolean userMustCreateSignalPin() { - return !SignalStore.registration().isRegistrationComplete() && !SignalStore.svr().hasPin() && !SignalStore.svr().lastPinCreateFailed() && !SignalStore.svr().hasOptedOut(); - } - - private boolean userHasSkippedOrForgottenPin() { - return !SignalStore.registration().isRegistrationComplete() && !SignalStore.svr().hasPin() && !SignalStore.svr().hasOptedOut() && SignalStore.svr().isPinForgottenOrSkipped(); + return !SignalStore.registration().isRegistrationComplete() && + !SignalStore.svr().hasOptedInWithAccess() && + !SignalStore.svr().lastPinCreateFailed() && + !SignalStore.svr().hasOptedOut(); } private boolean userMustSetProfileName() { @@ -195,7 +194,7 @@ private Intent getCreateSignalPinIntent() { } private Intent getTransferOrRestoreIntent() { - Intent intent = RestoreActivity.getIntentForTransferOrRestore(this); + Intent intent = RestoreActivity.getRestoreIntent(this); return getRoutedIntent(intent, MainActivity.clearTop(this)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 8fd61e16d3..a95b88c2f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -5,14 +5,18 @@ package org.thoughtcrime.securesms.backup.v2 +import android.os.Environment +import android.os.StatFs import androidx.annotation.WorkerThread import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okio.ByteString.Companion.toByteString import org.greenrobot.eventbus.EventBus import org.signal.core.util.Base64 +import org.signal.core.util.ByteSize import org.signal.core.util.EventTimer import org.signal.core.util.Stopwatch +import org.signal.core.util.bytes import org.signal.core.util.concurrent.LimitedWorker import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.forceForeignKeyConstraintsEnabled @@ -126,9 +130,16 @@ object BackupRepository { } } + fun getFreeStorageSpace(): ByteSize { + val statFs = StatFs(Environment.getDataDirectory().absolutePath) + val free = (statFs.availableBlocksLong) * statFs.blockSizeLong + + return free.bytes + } + @JvmStatic fun skipMediaRestore() { - // TODO [backups] -- Clear the error as necessary + // TODO [backups] -- Clear the error as necessary, cancel anything remaining in the restore } /** @@ -644,7 +655,7 @@ object BackupRepository { else -> Log.w(TAG, "Unrecognized frame") } - EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_RESTORE, frameReader.getBytesRead(), totalLength)) + EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_RESTORE, frameReader.getBytesRead().bytes, totalLength.bytes)) } if (chatItemInserter.flush()) { @@ -1176,7 +1187,7 @@ object BackupRepository { return if (SignalStore.backup.backupsInitialized) { getArchiveServiceAccessPair().runOnStatusCodeError(resetInitializedStateErrorAction) } else if (isPreRestoreDuringRegistration()) { - Log.w(TAG, "Requesting/using auth credentials in pre-restore state") + Log.w(TAG, "Requesting/using auth credentials in pre-restore state", Throwable()) getArchiveServiceAccessPair() } else { val messageBackupKey = SignalStore.backup.messageBackupKey diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/RestoreV2Event.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/RestoreV2Event.kt index 8183c5173e..3d7fdf006a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/RestoreV2Event.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/RestoreV2Event.kt @@ -5,18 +5,19 @@ package org.thoughtcrime.securesms.backup.v2 -class RestoreV2Event(val type: Type, val count: Long, val estimatedTotalCount: Long) { +import org.signal.core.util.ByteSize + +class RestoreV2Event(val type: Type, val count: ByteSize, val estimatedTotalCount: ByteSize) { enum class Type { PROGRESS_DOWNLOAD, PROGRESS_RESTORE, - PROGRESS_MEDIA_RESTORE, FINISHED } fun getProgress(): Float { - if (estimatedTotalCount == 0L) { + if (estimatedTotalCount.inWholeBytes == 0L) { return 0f } - return count.toFloat() / estimatedTotalCount.toFloat() + return count.inWholeBytes.toFloat() / estimatedTotalCount.inWholeBytes.toFloat() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt index ffa96440c7..f64515919f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertBottomSheet.kt @@ -49,19 +49,22 @@ import org.signal.core.ui.Previews import org.signal.core.ui.SignalPreview import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsType import org.thoughtcrime.securesms.billing.launchManageBackupsSubscription +import org.thoughtcrime.securesms.billing.upgrade.UpgradeToPaidTierBottomSheet import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity -import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobs.BackupMessagesJob import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob import org.thoughtcrime.securesms.payments.FiatMoneyUtil +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds import org.signal.core.ui.R as CoreUiR /** * Notifies the user of an issue with their backup. */ -class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() { +class BackupAlertBottomSheet : UpgradeToPaidTierBottomSheet() { companion object { private const val ARG_ALERT = "alert" @@ -79,24 +82,33 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() { } @Composable - override fun SheetContent() { + override fun UpgradeSheetContent( + paidBackupType: MessageBackupsType.Paid, + freeBackupType: MessageBackupsType.Free, + isSubscribeEnabled: Boolean, + onSubscribeClick: () -> Unit + ) { var pricePerMonth by remember { mutableStateOf("-") } val resources = LocalContext.current.resources - LaunchedEffect(Unit) { - val price = AppDependencies.billingApi.queryProduct()?.price ?: return@LaunchedEffect - pricePerMonth = FiatMoneyUtil.format(resources, price, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) + LaunchedEffect(paidBackupType.pricePerMonth) { + pricePerMonth = FiatMoneyUtil.format(resources, paidBackupType.pricePerMonth, FiatMoneyUtil.formatOptions().trimZerosAfterDecimal()) + } + + val performPrimaryAction = remember(onSubscribeClick) { + createPrimaryAction(onSubscribeClick) } BackupAlertSheetContent( backupAlert = backupAlert, - onPrimaryActionClick = this::performPrimaryAction, + isSubscribeEnabled = isSubscribeEnabled, + onPrimaryActionClick = performPrimaryAction, onSecondaryActionClick = this::performSecondaryAction ) } @Stable - private fun performPrimaryAction() { + private fun createPrimaryAction(onSubscribeClick: () -> Unit): () -> Unit = { when (backupAlert) { is BackupAlert.CouldNotCompleteBackup -> { BackupMessagesJob.enqueue() @@ -104,7 +116,10 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() { } BackupAlert.FailedToRenew -> launchManageBackupsSubscription() - BackupAlert.MediaBackupsAreOff, BackupAlert.MediaWillBeDeletedToday -> { + is BackupAlert.MediaBackupsAreOff -> { + onSubscribeClick() + } + BackupAlert.MediaWillBeDeletedToday -> { performFullMediaDownload() } @@ -119,10 +134,7 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() { when (backupAlert) { is BackupAlert.CouldNotCompleteBackup -> Unit BackupAlert.FailedToRenew -> Unit - BackupAlert.MediaBackupsAreOff -> { - // TODO [backups] - Silence and remind on last day - } - + is BackupAlert.MediaBackupsAreOff -> Unit BackupAlert.MediaWillBeDeletedToday -> { displayLastChanceDialog() } @@ -182,6 +194,7 @@ class BackupAlertBottomSheet : ComposeBottomSheetDialogFragment() { private fun BackupAlertSheetContent( backupAlert: BackupAlert, pricePerMonth: String = "", + isSubscribeEnabled: Boolean = true, onPrimaryActionClick: () -> Unit = {}, onSecondaryActionClick: () -> Unit = {} ) { @@ -196,7 +209,7 @@ private fun BackupAlertSheetContent( Spacer(modifier = Modifier.size(26.dp)) when (backupAlert) { - BackupAlert.FailedToRenew, BackupAlert.MediaBackupsAreOff -> { + BackupAlert.FailedToRenew, is BackupAlert.MediaBackupsAreOff -> { Box { Image( painter = painterResource(id = R.drawable.image_signal_backups), @@ -241,7 +254,7 @@ private fun BackupAlertSheetContent( ) BackupAlert.FailedToRenew -> PaymentProcessingBody() - BackupAlert.MediaBackupsAreOff -> MediaBackupsAreOffBody(30) // TODO [backups] -- Get this value from backend + is BackupAlert.MediaBackupsAreOff -> MediaBackupsAreOffBody(backupAlert.endOfPeriodSeconds) BackupAlert.MediaWillBeDeletedToday -> MediaWillBeDeletedTodayBody() is BackupAlert.DiskFull -> DiskFullBody(requiredSpace = backupAlert.requiredSpace) } @@ -250,6 +263,7 @@ private fun BackupAlertSheetContent( val padBottom = if (secondaryActionResource > 0) 16.dp else 56.dp Buttons.LargeTonal( + enabled = isSubscribeEnabled, onClick = onPrimaryActionClick, modifier = Modifier .defaultMinSize(minWidth = 220.dp) @@ -259,7 +273,11 @@ private fun BackupAlertSheetContent( } if (secondaryActionResource > 0) { - TextButton(onClick = onSecondaryActionClick, modifier = Modifier.padding(bottom = 32.dp)) { + TextButton( + enabled = isSubscribeEnabled, + onClick = onSecondaryActionClick, + modifier = Modifier.padding(bottom = 32.dp) + ) { Text(text = stringResource(id = secondaryActionResource)) } } @@ -290,10 +308,13 @@ private fun PaymentProcessingBody() { @Composable private fun MediaBackupsAreOffBody( - daysUntilDeletion: Long + endOfPeriodSeconds: Long ) { + // TODO [backups] Get value from config to calculate days until deletion. + val daysUntilDeletion = remember { endOfPeriodSeconds.days + 60.days }.inWholeDays.toInt() + Text( - text = pluralStringResource(id = R.plurals.BackupAlertBottomSheet__your_backup_plan_has_expired, daysUntilDeletion.toInt(), daysUntilDeletion), + text = pluralStringResource(id = R.plurals.BackupAlertBottomSheet__your_backup_plan_has_expired, daysUntilDeletion, daysUntilDeletion), textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 24.dp) @@ -345,7 +366,7 @@ private fun DiskFullBody(requiredSpace: String) { private fun rememberBackupsIconColors(backupAlert: BackupAlert): BackupsIconColors { return remember(backupAlert) { when (backupAlert) { - BackupAlert.FailedToRenew, BackupAlert.MediaBackupsAreOff -> error("Not icon-based options.") + BackupAlert.FailedToRenew, is BackupAlert.MediaBackupsAreOff -> error("Not icon-based options.") is BackupAlert.CouldNotCompleteBackup, is BackupAlert.DiskFull -> BackupsIconColors.Warning BackupAlert.MediaWillBeDeletedToday -> BackupsIconColors.Error } @@ -357,7 +378,7 @@ private fun titleString(backupAlert: BackupAlert): String { return when (backupAlert) { is BackupAlert.CouldNotCompleteBackup -> stringResource(R.string.BackupAlertBottomSheet__couldnt_complete_backup) BackupAlert.FailedToRenew -> stringResource(R.string.BackupAlertBottomSheet__your_backups_subscription_failed_to_renew) - BackupAlert.MediaBackupsAreOff -> stringResource(R.string.BackupAlertBottomSheet__your_backups_subscription_expired) + is BackupAlert.MediaBackupsAreOff -> stringResource(R.string.BackupAlertBottomSheet__your_backups_subscription_expired) BackupAlert.MediaWillBeDeletedToday -> stringResource(R.string.BackupAlertBottomSheet__your_media_will_be_deleted_today) is BackupAlert.DiskFull -> stringResource(R.string.BackupAlertBottomSheet__free_up_s_on_this_device, backupAlert.requiredSpace) } @@ -371,7 +392,7 @@ private fun primaryActionString( return when (backupAlert) { is BackupAlert.CouldNotCompleteBackup -> stringResource(R.string.BackupAlertBottomSheet__back_up_now) BackupAlert.FailedToRenew -> stringResource(R.string.BackupAlertBottomSheet__manage_subscription) - BackupAlert.MediaBackupsAreOff -> stringResource(R.string.BackupAlertBottomSheet__subscribe_for_s_month, pricePerMonth) + is BackupAlert.MediaBackupsAreOff -> stringResource(R.string.BackupAlertBottomSheet__subscribe_for_s_month, pricePerMonth) BackupAlert.MediaWillBeDeletedToday -> stringResource(R.string.BackupAlertBottomSheet__download_media_now) is BackupAlert.DiskFull -> stringResource(R.string.BackupAlertBottomSheet__got_it) } @@ -383,7 +404,7 @@ private fun rememberSecondaryActionResource(backupAlert: BackupAlert): Int { when (backupAlert) { is BackupAlert.CouldNotCompleteBackup -> R.string.BackupAlertBottomSheet__try_later BackupAlert.FailedToRenew -> R.string.BackupAlertBottomSheet__not_now - BackupAlert.MediaBackupsAreOff -> R.string.BackupAlertBottomSheet__not_now + is BackupAlert.MediaBackupsAreOff -> R.string.BackupAlertBottomSheet__not_now BackupAlert.MediaWillBeDeletedToday -> R.string.BackupAlertBottomSheet__dont_download_media is BackupAlert.DiskFull -> R.string.BackupAlertBottomSheet__skip_restore } @@ -415,7 +436,7 @@ private fun BackupAlertSheetContentPreviewPayment() { private fun BackupAlertSheetContentPreviewMedia() { Previews.BottomSheetPreview { BackupAlertSheetContent( - backupAlert = BackupAlert.MediaBackupsAreOff, + backupAlert = BackupAlert.MediaBackupsAreOff(endOfPeriodSeconds = System.currentTimeMillis().milliseconds.inWholeSeconds), pricePerMonth = "$2.99" ) } @@ -447,14 +468,30 @@ private fun BackupAlertSheetContentPreviewDiskFull() { @Parcelize sealed class BackupAlert : Parcelable { + /** + * This value is driven by a watermarking system and will be dismissed and snoozed whenever the sheet is closed. + * This value is driven by failure to complete a backup within a timeout based on the user's chosen backup frequency. + */ data class CouldNotCompleteBackup( val daysSinceLastBackup: Int ) : BackupAlert() + /** + * This value is driven by InAppPayment state, and will be automatically cleared when the sheet is displayed. + */ data object FailedToRenew : BackupAlert() - data object MediaBackupsAreOff : BackupAlert() + /** + * This value is driven by InAppPayment state, and will be automatically cleared when the sheet is displayed. + * This value is displayed if we hit an 'unexpected cancellation' of a user's backup. + */ + data class MediaBackupsAreOff( + val endOfPeriodSeconds: Long + ) : BackupAlert() + /** + * TODO [backups] - This value is driven as "60D after the last time the user pinged their backup" + */ data object MediaWillBeDeletedToday : BackupAlert() /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertDelegate.kt index 7076f16386..1ddbc8a08e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/BackupAlertDelegate.kt @@ -25,6 +25,8 @@ object BackupAlertDelegate { BackupAlertBottomSheet.create(BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = SignalStore.backup.daysSinceLastBackup)).show(fragmentManager, null) } + // TODO [backups] Check if media will be deleted within 24hrs and display warning sheet. + // TODO [backups] // Get unnotified backup download failures & display sheet } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/RemoteRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/RemoteRestoreViewModel.kt deleted file mode 100644 index 660e07b68a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/ui/subscription/RemoteRestoreViewModel.kt +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.backup.v2.ui.subscription - -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.ViewModel -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.kotlin.plusAssign -import io.reactivex.rxjava3.kotlin.subscribeBy -import io.reactivex.rxjava3.schedulers.Schedulers -import org.signal.libsignal.zkgroup.profiles.ProfileKey -import org.thoughtcrime.securesms.backup.v2.BackupRepository -import org.thoughtcrime.securesms.backup.v2.MessageBackupTier -import org.thoughtcrime.securesms.backup.v2.RestoreV2Event -import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.jobs.BackupRestoreJob -import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob -import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob -import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.registration.util.RegistrationUtil -import java.io.InputStream -import kotlin.time.Duration.Companion.seconds - -class RemoteRestoreViewModel : ViewModel() { - val disposables = CompositeDisposable() - - private val _state: MutableState = mutableStateOf( - ScreenState( - backupTier = SignalStore.backup.backupTier, - backupTime = SignalStore.backup.lastBackupTime, - importState = ImportState.NONE, - restoreProgress = null - ) - ) - - val state: State = _state - - fun import(length: Long, inputStreamFactory: () -> InputStream) { - _state.value = _state.value.copy(importState = ImportState.IN_PROGRESS) - - val self = Recipient.self() - val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey)) - - disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy { - _state.value = _state.value.copy(importState = ImportState.NONE) - } - } - - fun restore() { - _state.value = _state.value.copy(importState = ImportState.IN_PROGRESS) - disposables += Single.fromCallable { - AppDependencies - .jobManager - .startChain(BackupRestoreJob()) - .then(SyncArchivedMediaJob()) - .then(BackupRestoreMediaJob()) - .enqueueAndBlockUntilCompletion(120.seconds.inWholeMilliseconds) - RegistrationUtil.maybeMarkRegistrationComplete() - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy { - _state.value = _state.value.copy(importState = ImportState.RESTORED) - } - } - - fun updateRestoreProgress(restoreEvent: RestoreV2Event) { - _state.value = _state.value.copy(restoreProgress = restoreEvent) - } - - override fun onCleared() { - disposables.clear() - } - - data class ScreenState( - val backupTier: MessageBackupTier?, - val backupTime: Long, - val importState: ImportState, - val restoreProgress: RestoreV2Event? - ) - - enum class ImportState(val inProgress: Boolean = false) { - NONE, - IN_PROGRESS(true), - RESTORED - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt index a6d47b987b..778a6f9b78 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogViewModel.kt @@ -42,7 +42,7 @@ class CallLogViewModel( val isEmpty: Boolean get() = _isEmpty.value ?: false val totalCount: Flowable = Flowable.combineLatest(distinctQueryFilterPairs, data) { a, _ -> a } - .map { (query, filter) -> callLogRepository.getCallsCount(query, filter) } + .map { (query, filter) -> callLogRepository.getCallsCount(query, filter) + callLogRepository.getCallLinksCount(query, filter) } .doOnNext { _isEmpty.onNext(it <= 0) } val selectionStateSnapshot: CallLogSelectionState diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt index d94c2288b2..1eb238a35a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsFragment.kt @@ -517,7 +517,7 @@ private fun BioRow( ) { annotatedString, inlineTextContentMap -> Text( text = annotatedString, - color = MaterialTheme.colorScheme.outline, + color = MaterialTheme.colorScheme.onSurfaceVariant, inlineContent = inlineTextContentMap, modifier = Modifier.padding(top = 8.dp) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt index 94f97a56e2..fcffb2f39b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt @@ -57,10 +57,10 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag } else { @Suppress("DEPRECATION") clickPref( - title = DSLSettingsText.from(if (state.hasPin) R.string.preferences_app_protection__change_your_pin else R.string.preferences_app_protection__create_a_pin), + title = DSLSettingsText.from(if (state.hasOptedInWithAccess) R.string.preferences_app_protection__change_your_pin else R.string.preferences_app_protection__create_a_pin), isEnabled = state.isDeprecatedOrUnregistered(), onClick = { - if (state.hasPin) { + if (state.hasOptedInWithAccess) { startActivityForResult(CreateSvrPinActivity.getIntentForPinChangeFromSettings(requireContext()), CreateSvrPinActivity.REQUEST_NEW_PIN) } else { startActivityForResult(CreateSvrPinActivity.getIntentForPinCreate(requireContext()), CreateSvrPinActivity.REQUEST_NEW_PIN) @@ -82,7 +82,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag title = DSLSettingsText.from(R.string.preferences_app_protection__registration_lock), summary = DSLSettingsText.from(R.string.AccountSettingsFragment__require_your_signal_pin), isChecked = state.registrationLockEnabled, - isEnabled = state.hasPin && state.isDeprecatedOrUnregistered(), + isEnabled = (state.hasOptedInWithAccess) && state.isDeprecatedOrUnregistered(), onClick = { setRegistrationLockEnabled(!state.registrationLockEnabled) } @@ -114,7 +114,7 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag clickPref( title = DSLSettingsText.from(R.string.preferences_chats__transfer_account), summary = DSLSettingsText.from(R.string.preferences_chats__transfer_account_to_a_new_android_device), - isEnabled = state.isDeprecatedOrUnregistered() && !state.isLinkedDevice, + isEnabled = !state.isLinkedDevice, onClick = { Navigation.findNavController(requireView()).safeNavigate(R.id.action_accountSettingsFragment_to_oldDeviceTransferActivity) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsState.kt index be87a777fc..121e15d4d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsState.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.account data class AccountSettingsState( val isLinkedDevice: Boolean, val hasPin: Boolean, + val hasOptedInWithAccess: Boolean, val pinRemindersEnabled: Boolean, val registrationLockEnabled: Boolean, val userUnregistered: Boolean, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt index 046d2e82ca..1bff1fa42e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsViewModel.kt @@ -20,7 +20,8 @@ class AccountSettingsViewModel : ViewModel() { return AccountSettingsState( isLinkedDevice = SignalStore.account.isLinkedDevice, hasPin = SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut(), - pinRemindersEnabled = SignalStore.pin.arePinRemindersEnabled(), + hasOptedInWithAccess = SignalStore.svr.hasOptedInWithAccess(), + pinRemindersEnabled = SignalStore.pin.arePinRemindersEnabled() && SignalStore.svr.hasPin(), registrationLockEnabled = SignalStore.svr.isRegistrationLockEnabled, userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.application), clientDeprecated = SignalStore.misc.isClientDeprecated diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberViewModel.kt index 985c4c5af3..6539cf9d8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberViewModel.kt @@ -392,7 +392,7 @@ class ChangeNumberViewModel : ViewModel() { private suspend fun changeNumberWithRecoveryPassword(): Boolean { Log.v(TAG, "changeNumberWithRecoveryPassword()") SignalStore.svr.recoveryPassword?.let { recoveryPassword -> - if (SignalStore.svr.hasPin()) { + if (SignalStore.svr.hasOptedInWithAccess()) { val result = repository.changeNumberWithRecoveryPassword(recoveryPassword = recoveryPassword, newE164 = number.e164Number) if (result is ChangeNumberResult.Success) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index 65d41e1e51..d377871fd2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -715,8 +715,8 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter AdvancedPrivacySettingsRepository.DisablePushMessagesResult.SUCCESS -> { SignalStore.account.setRegistered(false) SignalStore.registration.clearRegistrationComplete() - SignalStore.registration.clearHasUploadedProfile() - SignalStore.registration.clearSkippedTransferOrRestore() + SignalStore.registration.hasUploadedProfile = false + SignalStore.registration.debugClearSkippedTransferOrRestore() Toast.makeText(context, "Unregistered!", Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt index dc94d64bc2..ec1b9a0933 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/donor/InternalDonorErrorConfigurationViewModel.kt @@ -20,6 +20,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.rx.RxStore import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import java.util.Locale +import kotlin.concurrent.withLock class InternalDonorErrorConfigurationViewModel : ViewModel() { @@ -101,7 +102,7 @@ class InternalDonorErrorConfigurationViewModel : ViewModel() { fun save(): Completable { val snapshot = store.state val saveState = Completable.fromAction { - synchronized(InAppPaymentSubscriberRecord.Type.DONATION) { + InAppPaymentSubscriberRecord.Type.DONATION.lock.withLock { when { snapshot.selectedBadge?.isGift() == true -> {} snapshot.selectedBadge?.isBoost() == true -> handleBoostExpiration(snapshot) @@ -116,7 +117,7 @@ class InternalDonorErrorConfigurationViewModel : ViewModel() { fun clearErrorState(): Completable { return Completable.fromAction { - synchronized(InAppPaymentSubscriberRecord.Type.DONATION) { + InAppPaymentSubscriberRecord.Type.DONATION.lock.withLock { SignalStore.inAppPayments.setExpiredBadge(null) SignalStore.inAppPayments.unexpectedSubscriptionCancelationReason = null SignalStore.inAppPayments.unexpectedSubscriptionCancelationTimestamp = 0L diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt index 3003bf4cd8..18f668ea55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/InAppPaymentsRepository.kt @@ -55,6 +55,7 @@ import org.whispersystems.signalservice.internal.push.exceptions.InAppPaymentPro import java.security.SecureRandom import java.util.Currency import java.util.Optional +import java.util.concurrent.locks.Lock import kotlin.jvm.optionals.getOrNull import kotlin.time.Duration import kotlin.time.Duration.Companion.days @@ -230,10 +231,11 @@ object InAppPaymentsRepository { /** * Returns the object to utilize as a mutex for recurring subscriptions. */ - fun resolveMutex(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Any { + @WorkerThread + fun resolveLock(inAppPaymentId: InAppPaymentTable.InAppPaymentId): Lock { val payment = SignalDatabase.inAppPayments.getById(inAppPaymentId) ?: error("Not found") - return payment.type.requireSubscriberType() + return payment.type.requireSubscriberType().lock } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt index af08accd45..df3e57325a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/RecurringInAppPaymentRepository.kt @@ -214,7 +214,7 @@ object RecurringInAppPaymentRepository { subscriptionLevel, subscriber.currency.currencyCode, levelUpdateOperation.idempotencyKey.serialize(), - subscriberType + subscriberType.lock ) } .flatMapCompletable { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/InAppPaymentsBottomSheetDelegate.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/InAppPaymentsBottomSheetDelegate.kt index 25be2682f3..f81c086248 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/InAppPaymentsBottomSheetDelegate.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/completed/InAppPaymentsBottomSheetDelegate.kt @@ -134,9 +134,9 @@ class InAppPaymentsBottomSheetDelegate( }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribeBy { inAppPayments -> for (payment in inAppPayments) { if (isPaymentProcessingError(payment.state, payment.data)) { - BackupAlertBottomSheet.create(BackupAlert.CouldNotCompleteBackup(daysSinceLastBackup = SignalStore.backup.daysSinceLastBackup)).show(fragmentManager, null) + BackupAlertBottomSheet.create(BackupAlert.FailedToRenew).show(fragmentManager, null) } else if (isUnexpectedCancellation(payment.state, payment.data)) { - BackupAlertBottomSheet.create(BackupAlert.MediaBackupsAreOff).show(fragmentManager, null) + BackupAlertBottomSheet.create(BackupAlert.MediaBackupsAreOff(payment.endOfPeriodSeconds)).show(fragmentManager, null) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt index f47808257f..98a0b58010 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCode.kt @@ -63,13 +63,17 @@ fun DrawScope.drawQr( val deadzonePaddingPercent = 0.045f // We want an even number of dots on either side of the deadzone - val deadzoneRadius: Int = (data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight -> - if ((data.height - candidateDeadzoneHeight) % 2 == 0) { - candidateDeadzoneHeight - } else { - candidateDeadzoneHeight + 1 - } - } / 2 + val deadzoneRadius: Int = if (data.canSupportIconOverlay) { + (data.height * (deadzonePercent + deadzonePaddingPercent)).toInt().let { candidateDeadzoneHeight -> + if ((data.height - candidateDeadzoneHeight) % 2 == 0) { + candidateDeadzoneHeight + } else { + candidateDeadzoneHeight + 1 + } + } / 2 + } else { + 0 + } val cellWidthPx: Float = size.width / data.width val cornerRadius = CornerRadius(7f, 7f) @@ -108,25 +112,27 @@ fun DrawScope.drawQr( } } - // Logo border - val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2 - drawCircle( - color = foregroundColor, - radius = logoBorderRadiusPx, - style = Stroke(width = cellWidthPx * 0.75f), - center = this.center - ) - - // Logo - val logoWidthPx = (((deadzonePercent - deadzonePaddingPercent) * 0.6f) * size.width).toInt() - val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt() - if (logo != null) { - drawImage( - image = logo, - dstOffset = IntOffset(logoOffsetPx, logoOffsetPx), - dstSize = IntSize(logoWidthPx, logoWidthPx), - colorFilter = ColorFilter.tint(foregroundColor) + if (data.canSupportIconOverlay) { + // Logo border + val logoBorderRadiusPx = ((deadzonePercent - deadzonePaddingPercent) * size.width) / 2 + drawCircle( + color = foregroundColor, + radius = logoBorderRadiusPx, + style = Stroke(width = cellWidthPx * 0.75f), + center = this.center ) + + // Logo + val logoWidthPx = (((deadzonePercent - deadzonePaddingPercent) * 0.6f) * size.width).toInt() + val logoOffsetPx = ((size.width - logoWidthPx) / 2).toInt() + if (logo != null) { + drawImage( + image = logo, + dstOffset = IntOffset(logoOffsetPx, logoOffsetPx), + dstSize = IntSize(logoWidthPx, logoWidthPx), + colorFilter = ColorFilter.tint(foregroundColor) + ) + } } } @@ -135,7 +141,7 @@ fun DrawScope.drawQr( private fun Preview() { Surface { QrCode( - data = QrCodeData.forData("https://signal.org", 64), + data = QrCodeData.forData("https://signal.org"), modifier = Modifier.size(350.dp) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt index 96269226cc..43870ad119 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeBadge.kt @@ -24,7 +24,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -170,13 +169,13 @@ private fun PreviewWithCodeShort() { Surface { Column { QrCodeBadge( - data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)), + data = QrCodeState.Present(QrCodeData.forData("https://signal.org")), colorScheme = UsernameQrCodeColorScheme.Blue, username = "parker.42", usernameCopyable = false ) QrCodeBadge( - data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)), + data = QrCodeState.Present(QrCodeData.forData("https://signal.org")), colorScheme = UsernameQrCodeColorScheme.Blue, username = "parker.42", usernameCopyable = true @@ -193,14 +192,14 @@ private fun PreviewWithCodeLong() { Surface { Column { QrCodeBadge( - data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)), + data = QrCodeState.Present(QrCodeData.forData("https://signal.org")), colorScheme = UsernameQrCodeColorScheme.Blue, username = "TheAmazingSpiderMan.42", usernameCopyable = false ) Spacer(modifier = Modifier.height(8.dp)) QrCodeBadge( - data = QrCodeState.Present(QrCodeData.forData("https://signal.org", 64)), + data = QrCodeState.Present(QrCodeData.forData("https://signal.org")), colorScheme = UsernameQrCodeColorScheme.Blue, username = "TheAmazingSpiderMan.42", usernameCopyable = true @@ -249,7 +248,7 @@ private fun PreviewAllColorsP2() { @Composable private fun SampleCode(colorScheme: UsernameQrCodeColorScheme) { QrCodeBadge( - data = QrCodeState.Present(QrCodeData.forData("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdf", 64)), + data = QrCodeState.Present(QrCodeData.forData("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdf")), colorScheme = colorScheme, username = "parker.42" ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeData.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeData.kt index 797bb184f6..941183b83b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeData.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/QrCodeData.kt @@ -15,6 +15,7 @@ import java.util.BitSet class QrCodeData( val width: Int, val height: Int, + val canSupportIconOverlay: Boolean, private val bits: BitSet ) { @@ -34,13 +35,17 @@ class QrCodeData( /** * Converts the provided string data into a QR representation. + * + * @param supportIconOverlay indicates data can be rendered with the icon overlay. Rendering with an icon relies on more error correction + * data in the QR which requires a denser rendering which is sometimes not easily scanned by our scanner. Set to false if data is expected to be + * long to prevent scanning issues. */ @WorkerThread - fun forData(data: String, size: Int): QrCodeData { + fun forData(data: String, supportIconOverlay: Boolean = true): QrCodeData { val qrCodeWriter = QRCodeWriter() - val hints = mapOf(EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.Q.toString()) + val hints = mapOf(EncodeHintType.ERROR_CORRECTION to if (supportIconOverlay) ErrorCorrectionLevel.Q.toString() else ErrorCorrectionLevel.L.toString()) - val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, size, size, hints) + val padded = qrCodeWriter.encode(data, BarcodeFormat.QR_CODE, 64, 64, hints) val dimens = padded.enclosingRectangle val xStart = dimens[0] val yStart = dimens[1] @@ -58,7 +63,7 @@ class QrCodeData( } } - return QrCodeData(width, height, bitSet) + return QrCodeData(width, height, supportIconOverlay, bitSet) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerViewModel.kt index 3cb801c22a..b26b38c69a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/colorpicker/UsernameLinkQrColorPickerViewModel.kt @@ -39,7 +39,7 @@ class UsernameLinkQrColorPickerViewModel : ViewModel() { if (usernameLink != null) { disposable += Single - .fromCallable { QrCodeData.forData(usernameLink.toLink(), 64) } + .fromCallable { QrCodeData.forData(usernameLink.toLink()) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe { qrData -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt index faff325671..9edd91a960 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsFragment.kt @@ -371,7 +371,7 @@ private fun MainScreenPreview() { activeTab = ActiveTab.Code, username = "PeterParker.42", usernameLinkState = UsernameLinkState.Present("https://signal.org"), - qrCodeState = QrCodeState.Present(QrCodeData.forData("PeterParker.42", 64)), + qrCodeState = QrCodeState.Present(QrCodeData.forData("PeterParker.42")), qrCodeColorScheme = UsernameQrCodeColorScheme.Orange ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt index 9eea657560..49aa1dfa91 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkSettingsViewModel.kt @@ -203,7 +203,7 @@ class UsernameLinkSettingsViewModel : ViewModel() { private fun generateQrCodeData(url: Optional): Single> { return Single.fromCallable { - url.map { QrCodeData.forData(it, 64) } + url.map { QrCodeData.forData(it) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt index 8122d57235..a988d1bb32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt @@ -334,7 +334,7 @@ private fun previewState(): UsernameLinkSettingsState { activeTab = ActiveTab.Code, username = "parker.42", usernameLinkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"), - qrCodeState = QrCodeState.Present(QrCodeData.forData(link, 64)), + qrCodeState = QrCodeState.Present(QrCodeData.forData(link)), qrCodeColorScheme = UsernameQrCodeColorScheme.Blue ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java index 8800d11d15..fb98b57c15 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java @@ -320,7 +320,7 @@ public void updateFromWebRtcViewModel(@NonNull WebRtcViewModel webRtcViewModel, webRtcViewModel.isRemoteVideoEnabled(), webRtcViewModel.isRemoteVideoOffer(), localParticipant.isMoreThanOneCameraAvailable(), - Util.hasItems(webRtcViewModel.getRemoteParticipants()), + webRtcViewModel.getRemoteDevicesCount().orElse(0L) > 0, webRtcViewModel.getActiveDevice(), webRtcViewModel.getAvailableDevices(), webRtcViewModel.getRemoteDevicesCount().orElse(0), diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt index 60f133c5f9..e7909d9e2d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Intent +import android.content.res.Configuration import android.os.Bundle import android.view.MotionEvent import android.view.Window @@ -12,6 +13,7 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.util.ConfigurationUtil import org.thoughtcrime.securesms.util.Debouncer import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme import java.util.concurrent.TimeUnit @@ -88,6 +90,13 @@ open class ConversationActivity : PassphraseRequiredActivity(), VoiceNoteMediaCo startActivity(intent) } + override fun onConfigurationChanged(newConfiguration: Configuration) { + super.onConfigurationChanged(newConfiguration) + if (ConfigurationUtil.isUiModeChanged(resources.configuration, newConfiguration)) { + recreate() + } + } + private fun replaceFragment() { val fragment = ConversationFragment().apply { arguments = if (ConversationIntents.isBubbleIntentUri(intent.data)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/RestoreCompleteBottomSheetDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/RestoreCompleteBottomSheetDialog.kt new file mode 100644 index 0000000000..ff39f24d60 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/RestoreCompleteBottomSheetDialog.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversationlist + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.Buttons +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.signal.core.ui.horizontalGutters +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.BottomSheetUtil + +/** + * Bottom sheet dialog shown on an old device after the user has decided to transfer/restore to a new device. + */ +class RestoreCompleteBottomSheetDialog : ComposeBottomSheetDialogFragment() { + + companion object { + @JvmStatic + fun show(fragmentManager: FragmentManager) { + RestoreCompleteBottomSheetDialog().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } + + override val peekHeightPercentage: Float = 1f + + @Composable + override fun SheetContent() { + RestoreCompleteContent( + isAfterDeviceTransfer = SignalStore.misc.isOldDeviceTransferLocked, + onOkayClick = this::dismissAllowingStateLoss + ) + } +} + +@Composable +private fun RestoreCompleteContent( + isAfterDeviceTransfer: Boolean = false, + onOkayClick: () -> Unit = {} +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .horizontalGutters() + .padding(bottom = 54.dp) + ) { + BottomSheets.Handle() + + Spacer(modifier = Modifier.height(20.dp)) + + Icon( + painter = painterResource(R.drawable.symbol_check_circle_40), + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + modifier = Modifier + .size(64.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val title = if (isAfterDeviceTransfer) R.string.RestoreCompleteBottomSheet_transfer_complete else R.string.RestoreCompleteBottomSheet_restore_complete + Text( + text = stringResource(id = title), + style = MaterialTheme.typography.titleLarge + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val message = if (isAfterDeviceTransfer) R.string.RestoreCompleteBottomSheet_transfer_complete_message else R.string.RestoreCompleteBottomSheet_restore_complete_message + Text( + text = stringResource(id = message), + style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + ) + + Spacer(modifier = Modifier.height(54.dp)) + + Buttons.LargeTonal( + onClick = onOkayClick, + modifier = Modifier.widthIn(min = 220.dp) + ) { + Text(text = stringResource(R.string.RestoreCompleteBottomSheet_button)) + } + } +} + +@SignalPreview +@Composable +private fun RestoreCompleteContentPreview() { + Previews.Preview { + RestoreCompleteContent(isAfterDeviceTransfer = false) + } +} + +@SignalPreview +@Composable +private fun RestoreCompleteContentAfterDeviceTransferPreview() { + Previews.Preview { + RestoreCompleteContent(isAfterDeviceTransfer = true) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt index 01de3e2cf2..ec4f5473be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -2229,8 +2229,6 @@ class AttachmentTable( put(CDN_NUMBER, attachment.cdn.serialize()) put(REMOTE_LOCATION, attachment.remoteLocation) put(REMOTE_DIGEST, attachment.remoteDigest) - put(REMOTE_INCREMENTAL_DIGEST, attachment.incrementalDigest) - put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, attachment.incrementalMacChunkSize) put(REMOTE_KEY, attachment.remoteKey) put(FILE_NAME, StorageUtil.getCleanFileName(attachment.fileName)) put(DATA_SIZE, attachment.size) @@ -2252,6 +2250,13 @@ class AttachmentTable( put(STICKER_ID, sticker.stickerId) put(STICKER_EMOJI, sticker.emoji) } + + if (attachment.incrementalDigest?.isNotEmpty() == true && attachment.incrementalMacChunkSize != 0) { + put(REMOTE_INCREMENTAL_DIGEST, attachment.incrementalDigest) + put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, attachment.incrementalMacChunkSize) + } else { + putNull(REMOTE_INCREMENTAL_DIGEST) + } } val rowId = db.insert(TABLE_NAME, null, contentValues) @@ -2281,8 +2286,6 @@ class AttachmentTable( put(CDN_NUMBER, attachment.cdn.serialize()) put(REMOTE_LOCATION, attachment.remoteLocation) put(REMOTE_DIGEST, attachment.remoteDigest) - put(REMOTE_INCREMENTAL_DIGEST, attachment.incrementalDigest) - put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, attachment.incrementalMacChunkSize) put(REMOTE_KEY, attachment.remoteKey) put(FILE_NAME, StorageUtil.getCleanFileName(attachment.fileName)) put(DATA_SIZE, attachment.size) @@ -2310,6 +2313,13 @@ class AttachmentTable( put(STICKER_ID, sticker.stickerId) put(STICKER_EMOJI, sticker.emoji) } + + if (attachment.incrementalDigest?.isNotEmpty() == true && attachment.incrementalMacChunkSize != 0) { + put(REMOTE_INCREMENTAL_DIGEST, attachment.incrementalDigest) + put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, attachment.incrementalMacChunkSize) + } else { + putNull(REMOTE_INCREMENTAL_DIGEST) + } } val rowId = db.insert(TABLE_NAME, null, contentValues) @@ -2430,8 +2440,6 @@ class AttachmentTable( contentValues.put(CDN_NUMBER, uploadTemplate?.cdn?.serialize() ?: Cdn.CDN_0.serialize()) contentValues.put(REMOTE_LOCATION, uploadTemplate?.remoteLocation) contentValues.put(REMOTE_DIGEST, uploadTemplate?.remoteDigest) - contentValues.put(REMOTE_INCREMENTAL_DIGEST, uploadTemplate?.incrementalDigest) - contentValues.put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, uploadTemplate?.incrementalMacChunkSize ?: 0) contentValues.put(REMOTE_KEY, uploadTemplate?.remoteKey) contentValues.put(REMOTE_IV, uploadTemplate?.remoteIv) contentValues.put(FILE_NAME, StorageUtil.getCleanFileName(attachment.fileName)) @@ -2447,6 +2455,13 @@ class AttachmentTable( contentValues.put(TRANSFORM_PROPERTIES, transformProperties.serialize()) contentValues.put(ATTACHMENT_UUID, attachment.uuid?.toString()) + if (uploadTemplate?.incrementalDigest?.isNotEmpty() == true && uploadTemplate.incrementalMacChunkSize != 0) { + contentValues.put(REMOTE_INCREMENTAL_DIGEST, uploadTemplate.incrementalDigest) + contentValues.put(REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE, uploadTemplate.incrementalMacChunkSize) + } else { + contentValues.putNull(REMOTE_INCREMENTAL_DIGEST) + } + if (attachment.transformProperties?.videoTrimStartTimeUs != 0L) { contentValues.putNull(BLUR_HASH) } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt index 2b6a11139c..e247228103 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListTables.kt @@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.storage.StorageRecordUpdate import org.thoughtcrime.securesms.storage.StorageSyncHelper import org.whispersystems.signalservice.api.push.DistributionId import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord +import org.whispersystems.signalservice.api.storage.recipientServiceAddresses import org.whispersystems.signalservice.api.util.UuidUtil import java.util.UUID @@ -552,7 +553,7 @@ class DistributionListTables constructor(context: Context?, databaseHelper: Sign } fun getRecipientIdForSyncRecord(record: SignalStoryDistributionListRecord): RecipientId? { - val uuid: UUID = requireNotNull(UuidUtil.parseOrNull(record.identifier)) { "Incoming record did not have a valid identifier." } + val uuid: UUID = requireNotNull(UuidUtil.parseOrNull(record.proto.identifier)) { "Incoming record did not have a valid identifier." } val distributionId = DistributionId.from(uuid) return readableDatabase.query( @@ -591,30 +592,30 @@ class DistributionListTables constructor(context: Context?, databaseHelper: Sign } fun applyStorageSyncStoryDistributionListInsert(insert: SignalStoryDistributionListRecord) { - val distributionId = DistributionId.from(UuidUtil.parseOrThrow(insert.identifier)) + val distributionId = DistributionId.from(UuidUtil.parseOrThrow(insert.proto.identifier)) if (distributionId == DistributionId.MY_STORY) { throw AssertionError("Should never try to insert My Story") } val privacyMode: DistributionListPrivacyMode = when { - insert.isBlockList && insert.recipients.isEmpty() -> DistributionListPrivacyMode.ALL - insert.isBlockList -> DistributionListPrivacyMode.ALL_EXCEPT + insert.proto.isBlockList && insert.proto.recipientServiceIds.isEmpty() -> DistributionListPrivacyMode.ALL + insert.proto.isBlockList -> DistributionListPrivacyMode.ALL_EXCEPT else -> DistributionListPrivacyMode.ONLY_WITH } createList( - name = insert.name, - members = insert.recipients.map(RecipientId::from), + name = insert.proto.name, + members = insert.proto.recipientServiceAddresses.map(RecipientId::from), distributionId = distributionId, - allowsReplies = insert.allowsReplies(), - deletionTimestamp = insert.deletedAtTimestamp, + allowsReplies = insert.proto.allowsReplies, + deletionTimestamp = insert.proto.deletedAtTimestamp, privacyMode = privacyMode, storageId = insert.id.raw ) } fun applyStorageSyncStoryDistributionListUpdate(update: StorageRecordUpdate) { - val distributionId = DistributionId.from(UuidUtil.parseOrThrow(update.new.identifier)) + val distributionId = DistributionId.from(UuidUtil.parseOrThrow(update.new.proto.identifier)) val distributionListId: DistributionListId? = readableDatabase.query(ListTable.TABLE_NAME, arrayOf(ListTable.ID), "${ListTable.DISTRIBUTION_ID} = ?", SqlUtil.buildArgs(distributionId.toString()), null, null, null).use { cursor -> if (cursor == null || !cursor.moveToFirst()) { @@ -632,26 +633,26 @@ class DistributionListTables constructor(context: Context?, databaseHelper: Sign val recipientId = getRecipientId(distributionListId)!! SignalDatabase.recipients.updateStorageId(recipientId, update.new.id.raw) - if (update.new.deletedAtTimestamp > 0L) { + if (update.new.proto.deletedAtTimestamp > 0L) { if (distributionId == DistributionId.MY_STORY) { Log.w(TAG, "Refusing to delete My Story.") return } - deleteList(distributionListId, update.new.deletedAtTimestamp) + deleteList(distributionListId, update.new.proto.deletedAtTimestamp) return } val privacyMode: DistributionListPrivacyMode = when { - update.new.isBlockList && update.new.recipients.isEmpty() -> DistributionListPrivacyMode.ALL - update.new.isBlockList -> DistributionListPrivacyMode.ALL_EXCEPT + update.new.proto.isBlockList && update.new.proto.recipientServiceIds.isEmpty() -> DistributionListPrivacyMode.ALL + update.new.proto.isBlockList -> DistributionListPrivacyMode.ALL_EXCEPT else -> DistributionListPrivacyMode.ONLY_WITH } writableDatabase.withinTransaction { val listTableValues = contentValuesOf( - ListTable.ALLOWS_REPLIES to update.new.allowsReplies(), - ListTable.NAME to update.new.name, + ListTable.ALLOWS_REPLIES to update.new.proto.allowsReplies, + ListTable.NAME to update.new.proto.name, ListTable.IS_UNKNOWN to false, ListTable.PRIVACY_MODE to privacyMode.serialize() ) @@ -664,7 +665,7 @@ class DistributionListTables constructor(context: Context?, databaseHelper: Sign ) val currentlyInDistributionList = getRawMembers(distributionListId, privacyMode).toSet() - val shouldBeInDistributionList = update.new.recipients.map(RecipientId::from).toSet() + val shouldBeInDistributionList = update.new.proto.recipientServiceAddresses.map(RecipientId::from).toSet() val toRemove = currentlyInDistributionList - shouldBeInDistributionList val toAdd = shouldBeInDistributionList - currentlyInDistributionList diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt index c4be8a5cf4..2684f6cad4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/RecipientTable.kt @@ -17,11 +17,13 @@ import org.signal.core.util.SqlUtil import org.signal.core.util.delete import org.signal.core.util.exists import org.signal.core.util.forEach +import org.signal.core.util.hasUnknownFields +import org.signal.core.util.isNotEmpty import org.signal.core.util.logging.Log import org.signal.core.util.nullIfBlank +import org.signal.core.util.nullIfEmpty import org.signal.core.util.optionalString import org.signal.core.util.or -import org.signal.core.util.orNull import org.signal.core.util.readToList import org.signal.core.util.readToSet import org.signal.core.util.readToSingleBoolean @@ -40,6 +42,7 @@ import org.signal.core.util.updateAll import org.signal.core.util.withinTransaction import org.signal.libsignal.protocol.IdentityKey import org.signal.libsignal.protocol.InvalidKeyException +import org.signal.libsignal.zkgroup.groups.GroupMasterKey import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.signal.storageservice.protos.groups.local.DecryptedGroup @@ -108,12 +111,13 @@ import org.whispersystems.signalservice.api.storage.SignalContactRecord import org.whispersystems.signalservice.api.storage.SignalGroupV1Record import org.whispersystems.signalservice.api.storage.SignalGroupV2Record import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.api.storage.signalAci +import org.whispersystems.signalservice.api.storage.signalPni import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record import java.io.Closeable import java.io.IOException import java.util.Collections import java.util.LinkedList -import java.util.Objects import java.util.Optional import java.util.concurrent.TimeUnit import kotlin.jvm.optionals.getOrNull @@ -856,7 +860,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da val recipientId: RecipientId if (id < 0) { Log.w(TAG, "[applyStorageSyncContactInsert] Failed to insert. Possibly merging.") - recipientId = getAndPossiblyMerge(aci = insert.aci.orNull(), pni = insert.pni.orNull(), e164 = insert.number.orNull(), pniVerified = insert.isPniSignatureVerified) + recipientId = getAndPossiblyMerge(aci = ACI.parseOrNull(insert.proto.aci), pni = PNI.parseOrNull(insert.proto.pni), e164 = insert.proto.e164.nullIfBlank(), pniVerified = insert.proto.pniSignatureVerified) resolvePotentialUsernameConflicts(values.getAsString(USERNAME), recipientId) db.update(TABLE_NAME, values, ID_WHERE, SqlUtil.buildArgs(recipientId)) @@ -864,18 +868,18 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da recipientId = RecipientId.from(id) } - if (insert.identityKey.isPresent && (insert.aci.isPresent || insert.pni.isPresent)) { + if (insert.proto.identityKey.isNotEmpty() && (insert.proto.signalAci != null || insert.proto.signalPni != null)) { try { - val serviceId: ServiceId = insert.aci.orNull() ?: insert.pni.get() - val identityKey = IdentityKey(insert.identityKey.get(), 0) - identities.updateIdentityAfterSync(serviceId.toString(), recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(insert.identityState)) + val serviceId: ServiceId = insert.proto.signalAci ?: insert.proto.signalPni!! + val identityKey = IdentityKey(insert.proto.identityKey.toByteArray(), 0) + identities.updateIdentityAfterSync(serviceId.toString(), recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(insert.proto.identityState)) } catch (e: InvalidKeyException) { Log.w(TAG, "Failed to process identity key during insert! Skipping.", e) } } updateExtras(recipientId) { - it.hideStory(insert.shouldHideStory()) + it.hideStory(insert.proto.hideStory) } threadDatabase.applyStorageSyncUpdate(recipientId, insert) @@ -896,7 +900,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da var recipientId = getByColumn(STORAGE_SERVICE_ID, Base64.encodeWithPadding(update.old.id.raw)).get() Log.w(TAG, "[applyStorageSyncContactUpdate] Found user $recipientId. Possibly merging.") - recipientId = getAndPossiblyMerge(aci = update.new.aci.orElse(null), pni = update.new.pni.orElse(null), e164 = update.new.number.orElse(null), pniVerified = update.new.isPniSignatureVerified) + recipientId = getAndPossiblyMerge(aci = ACI.parseOrNull(update.new.proto.aci), pni = PNI.parseOrNull(update.new.proto.pni), e164 = update.new.proto.e164.nullIfBlank(), pniVerified = update.new.proto.pniSignatureVerified) Log.w(TAG, "[applyStorageSyncContactUpdate] Merged into $recipientId") resolvePotentialUsernameConflicts(values.getAsString(USERNAME), recipientId) @@ -914,9 +918,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da try { val oldIdentityRecord = identityStore.getIdentityRecord(recipientId) - if (update.new.identityKey.isPresent && update.new.aci.isPresent) { - val identityKey = IdentityKey(update.new.identityKey.get(), 0) - identities.updateIdentityAfterSync(update.new.aci.get().toString(), recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(update.new.identityState)) + if (update.new.proto.identityKey.isNotEmpty() && update.new.proto.signalAci != null) { + val identityKey = IdentityKey(update.new.proto.identityKey.toByteArray(), 0) + identities.updateIdentityAfterSync(update.new.proto.aci, recipientId, identityKey, StorageSyncModels.remoteToLocalIdentityStatus(update.new.proto.identityState)) } val newIdentityRecord = identityStore.getIdentityRecord(recipientId) @@ -930,7 +934,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da } updateExtras(recipientId) { - it.hideStory(update.new.shouldHideStory()) + it.hideStory(update.new.proto.hideStory) } threads.applyStorageSyncUpdate(recipientId, update.new) @@ -963,13 +967,13 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da throw AssertionError("Had an update, but it didn't match any rows!") } - val recipient = Recipient.externalGroupExact(GroupId.v1orThrow(update.old.groupId)) + val recipient = Recipient.externalGroupExact(GroupId.v1orThrow(update.old.proto.id.toByteArray())) threads.applyStorageSyncUpdate(recipient.id, update.new) recipient.live().refresh() } fun applyStorageSyncGroupV2Insert(insert: SignalGroupV2Record) { - val masterKey = insert.masterKeyOrThrow + val masterKey = GroupMasterKey(insert.proto.masterKey.toByteArray()) val groupId = GroupId.v2(masterKey) val values = getValuesForStorageGroupV2(insert, true) @@ -986,12 +990,12 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da Log.w(TAG, "Unable to create restore placeholder for $groupId, group already exists") } - groups.setShowAsStoryState(groupId, insert.storySendMode.toShowAsStoryState()) + groups.setShowAsStoryState(groupId, insert.proto.storySendMode.toShowAsStoryState()) val recipient = Recipient.externalGroupExact(groupId) updateExtras(recipient.id) { - it.hideStory(insert.shouldHideStory()) + it.hideStory(insert.proto.hideStory) } Log.i(TAG, "Scheduling request for latest group info for $groupId") @@ -1008,26 +1012,27 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da throw AssertionError("Had an update, but it didn't match any rows!") } - val masterKey = update.old.masterKeyOrThrow + val masterKey = GroupMasterKey(update.old.proto.masterKey.toByteArray()) val groupId = GroupId.v2(masterKey) val recipient = Recipient.externalGroupExact(groupId) updateExtras(recipient.id) { - it.hideStory(update.new.shouldHideStory()) + it.hideStory(update.new.proto.hideStory) } - groups.setShowAsStoryState(groupId, update.new.storySendMode.toShowAsStoryState()) + groups.setShowAsStoryState(groupId, update.new.proto.storySendMode.toShowAsStoryState()) threads.applyStorageSyncUpdate(recipient.id, update.new) recipient.live().refresh() } fun applyStorageSyncAccountUpdate(update: StorageRecordUpdate) { - val profileName = ProfileName.fromParts(update.new.givenName.orElse(null), update.new.familyName.orElse(null)) - val localKey = ProfileKeyUtil.profileKeyOptional(update.old.profileKey.orElse(null)) - val remoteKey = ProfileKeyUtil.profileKeyOptional(update.new.profileKey.orElse(null)) - val profileKey: String? = remoteKey.or(localKey).map { obj: ProfileKey -> obj.serialize() }.map { source: ByteArray? -> Base64.encodeWithPadding(source!!) }.orElse(null) - if (!remoteKey.isPresent) { - Log.w(TAG, "Got an empty profile key while applying an account record update! The parsed local key is ${if (localKey.isPresent) "present" else "not present"}. The raw local key is ${if (update.old.profileKey.isPresent) "present" else "not present"}. The resulting key is ${if (profileKey != null) "present" else "not present"}.") + val profileName = ProfileName.fromParts(update.new.proto.givenName, update.new.proto.familyName) + val localKey = update.old.proto.profileKey.nullIfEmpty()?.toByteArray()?.let { ProfileKeyUtil.profileKeyOrNull(it) } + val remoteKey = update.new.proto.profileKey.nullIfEmpty()?.toByteArray()?.let { ProfileKeyUtil.profileKeyOrNull(it) } + val profileKey: String? = (remoteKey ?: localKey)?.let { Base64.encodeWithPadding(it.serialize()) } + + if (remoteKey == null) { + Log.w(TAG, "Got an empty profile key while applying an account record update! The parsed local key is ${if (localKey != null) "present" else "not present"}. The raw local key is ${if (update.old.proto.profileKey.isNotEmpty()) "present" else "not present"}. The resulting key is ${if (profileKey != null) "present" else "not present"}.") } val values = ContentValues().apply { @@ -1041,21 +1046,21 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da Log.w(TAG, "Avoided attempt to apply null profile key in account record update!") } - put(USERNAME, update.new.username) + put(USERNAME, update.new.proto.username.nullIfBlank()) put(STORAGE_SERVICE_ID, Base64.encodeWithPadding(update.new.id.raw)) - if (update.new.hasUnknownFields()) { - put(STORAGE_SERVICE_PROTO, Base64.encodeWithPadding(Objects.requireNonNull(update.new.serializeUnknownFields()))) + if (update.new.proto.hasUnknownFields()) { + put(STORAGE_SERVICE_PROTO, Base64.encodeWithPadding(update.new.serializedUnknowns!!)) } else { putNull(STORAGE_SERVICE_PROTO) } } - if (update.new.username != null) { + if (update.new.proto.username.nullIfBlank() != null) { writableDatabase .update(TABLE_NAME) .values(USERNAME to null) - .where("$USERNAME = ?", update.new.username!!) + .where("$USERNAME = ?", update.new.proto.username) .run() } @@ -4098,68 +4103,68 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da private fun getValuesForStorageContact(contact: SignalContactRecord, isInsert: Boolean): ContentValues { return ContentValues().apply { - val profileName = ProfileName.fromParts(contact.profileGivenName.orElse(null), contact.profileFamilyName.orElse(null)) - val systemName = ProfileName.fromParts(contact.systemGivenName.orElse(null), contact.systemFamilyName.orElse(null)) - val username = contact.username.orElse(null) - val nickname = ProfileName.fromParts(contact.nicknameGivenName.orNull(), contact.nicknameFamilyName.orNull()) - - put(ACI_COLUMN, contact.aci.orElse(null)?.toString()) - put(PNI_COLUMN, contact.pni.orElse(null)?.toString()) - put(E164, contact.number.orElse(null)) + val profileName = ProfileName.fromParts(contact.proto.givenName.nullIfBlank(), contact.proto.familyName.nullIfBlank()) + val systemName = ProfileName.fromParts(contact.proto.systemGivenName.nullIfBlank(), contact.proto.systemFamilyName.nullIfBlank()) + val username = contact.proto.username.nullIfBlank() + val nickname = ProfileName.fromParts(contact.proto.nickname?.given, contact.proto.nickname?.family) + + put(ACI_COLUMN, contact.proto.signalAci?.toString()) + put(PNI_COLUMN, contact.proto.signalPni?.toString()) + put(E164, contact.proto.e164.nullIfBlank()) put(PROFILE_GIVEN_NAME, profileName.givenName) put(PROFILE_FAMILY_NAME, profileName.familyName) put(PROFILE_JOINED_NAME, profileName.toString()) put(SYSTEM_GIVEN_NAME, systemName.givenName) put(SYSTEM_FAMILY_NAME, systemName.familyName) put(SYSTEM_JOINED_NAME, systemName.toString()) - put(SYSTEM_NICKNAME, contact.systemNickname.orElse(null)) - put(PROFILE_KEY, contact.profileKey.map { source -> Base64.encodeWithPadding(source) }.orElse(null)) + put(SYSTEM_NICKNAME, contact.proto.systemNickname.nullIfBlank()) + put(PROFILE_KEY, contact.proto.profileKey.takeIf { it.isNotEmpty() }?.let { source -> Base64.encodeWithPadding(source.toByteArray()) }) put(USERNAME, if (TextUtils.isEmpty(username)) null else username) - put(PROFILE_SHARING, if (contact.isProfileSharingEnabled) "1" else "0") - put(BLOCKED, if (contact.isBlocked) "1" else "0") - put(MUTE_UNTIL, contact.muteUntil) + put(PROFILE_SHARING, contact.proto.whitelisted.toInt()) + put(BLOCKED, contact.proto.blocked.toInt()) + put(MUTE_UNTIL, contact.proto.mutedUntilTimestamp) put(STORAGE_SERVICE_ID, Base64.encodeWithPadding(contact.id.raw)) - put(HIDDEN, contact.isHidden) - put(PNI_SIGNATURE_VERIFIED, contact.isPniSignatureVerified.toInt()) + put(HIDDEN, contact.proto.hidden) + put(PNI_SIGNATURE_VERIFIED, contact.proto.pniSignatureVerified.toInt()) put(NICKNAME_GIVEN_NAME, nickname.givenName.nullIfBlank()) put(NICKNAME_FAMILY_NAME, nickname.familyName.nullIfBlank()) put(NICKNAME_JOINED_NAME, nickname.toString().nullIfBlank()) - put(NOTE, contact.note.orNull().nullIfBlank()) + put(NOTE, contact.proto.note.nullIfBlank()) - if (contact.hasUnknownFields()) { - put(STORAGE_SERVICE_PROTO, Base64.encodeWithPadding(Objects.requireNonNull(contact.serializeUnknownFields()))) + if (contact.proto.hasUnknownFields()) { + put(STORAGE_SERVICE_PROTO, Base64.encodeWithPadding(contact.serializedUnknowns!!)) } else { putNull(STORAGE_SERVICE_PROTO) } - put(UNREGISTERED_TIMESTAMP, contact.unregisteredTimestamp) - if (contact.unregisteredTimestamp > 0L) { + put(UNREGISTERED_TIMESTAMP, contact.proto.unregisteredAtTimestamp) + if (contact.proto.unregisteredAtTimestamp > 0L) { put(REGISTERED, RegisteredState.NOT_REGISTERED.id) - } else if (contact.aci.isPresent) { + } else if (contact.proto.signalAci != null) { put(REGISTERED, RegisteredState.REGISTERED.id) } else { - Log.w(TAG, "Contact is marked as registered, but has no serviceId! Can't locally mark registered. (Phone: ${contact.number.orElse("null")}, Username: ${username?.isNotEmpty()})") + Log.w(TAG, "Contact is marked as registered, but has no serviceId! Can't locally mark registered. (Phone: ${contact.proto.e164.nullIfBlank()}, Username: ${username?.isNotEmpty()})") } if (isInsert) { - put(AVATAR_COLOR, AvatarColorHash.forAddress(contact.aci.map { it.toString() }.or(contact.pni.map { it.toString() }).orNull(), contact.number.orNull()).serialize()) + put(AVATAR_COLOR, AvatarColorHash.forAddress(contact.proto.signalAci?.toString() ?: contact.proto.signalPni?.toString(), contact.proto.e164).serialize()) } } } private fun getValuesForStorageGroupV1(groupV1: SignalGroupV1Record, isInsert: Boolean): ContentValues { return ContentValues().apply { - val groupId = GroupId.v1orThrow(groupV1.groupId) + val groupId = GroupId.v1orThrow(groupV1.proto.id.toByteArray()) put(GROUP_ID, groupId.toString()) put(TYPE, RecipientType.GV1.id) - put(PROFILE_SHARING, if (groupV1.isProfileSharingEnabled) "1" else "0") - put(BLOCKED, if (groupV1.isBlocked) "1" else "0") - put(MUTE_UNTIL, groupV1.muteUntil) + put(PROFILE_SHARING, if (groupV1.proto.whitelisted) "1" else "0") + put(BLOCKED, if (groupV1.proto.blocked) "1" else "0") + put(MUTE_UNTIL, groupV1.proto.mutedUntilTimestamp) put(STORAGE_SERVICE_ID, Base64.encodeWithPadding(groupV1.id.raw)) - if (groupV1.hasUnknownFields()) { - put(STORAGE_SERVICE_PROTO, Base64.encodeWithPadding(groupV1.serializeUnknownFields())) + if (groupV1.proto.hasUnknownFields()) { + put(STORAGE_SERVICE_PROTO, Base64.encodeWithPadding(groupV1.serializedUnknowns!!)) } else { putNull(STORAGE_SERVICE_PROTO) } @@ -4172,18 +4177,18 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da private fun getValuesForStorageGroupV2(groupV2: SignalGroupV2Record, isInsert: Boolean): ContentValues { return ContentValues().apply { - val groupId = GroupId.v2(groupV2.masterKeyOrThrow) + val groupId = GroupId.v2(GroupMasterKey(groupV2.proto.masterKey.toByteArray())) put(GROUP_ID, groupId.toString()) put(TYPE, RecipientType.GV2.id) - put(PROFILE_SHARING, if (groupV2.isProfileSharingEnabled) "1" else "0") - put(BLOCKED, if (groupV2.isBlocked) "1" else "0") - put(MUTE_UNTIL, groupV2.muteUntil) + put(PROFILE_SHARING, if (groupV2.proto.whitelisted) "1" else "0") + put(BLOCKED, if (groupV2.proto.blocked) "1" else "0") + put(MUTE_UNTIL, groupV2.proto.mutedUntilTimestamp) put(STORAGE_SERVICE_ID, Base64.encodeWithPadding(groupV2.id.raw)) - put(MENTION_SETTING, if (groupV2.notifyForMentionsWhenMuted()) MentionSetting.ALWAYS_NOTIFY.id else MentionSetting.DO_NOT_NOTIFY.id) + put(MENTION_SETTING, if (groupV2.proto.dontNotifyForMentionsIfMuted) MentionSetting.DO_NOT_NOTIFY.id else MentionSetting.ALWAYS_NOTIFY.id) - if (groupV2.hasUnknownFields()) { - put(STORAGE_SERVICE_PROTO, Base64.encodeWithPadding(groupV2.serializeUnknownFields())) + if (groupV2.proto.hasUnknownFields()) { + put(STORAGE_SERVICE_PROTO, Base64.encodeWithPadding(groupV2.serializedUnknowns!!)) } else { putNull(STORAGE_SERVICE_PROTO) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt index b3e1b7a004..b9cb885fde 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadTable.kt @@ -71,10 +71,11 @@ import org.thoughtcrime.securesms.util.LRUCache import org.thoughtcrime.securesms.util.TextSecurePreferences import org.thoughtcrime.securesms.util.isScheduled import org.whispersystems.signalservice.api.storage.SignalAccountRecord -import org.whispersystems.signalservice.api.storage.SignalAccountRecord.PinnedConversation import org.whispersystems.signalservice.api.storage.SignalContactRecord import org.whispersystems.signalservice.api.storage.SignalGroupV1Record import org.whispersystems.signalservice.api.storage.SignalGroupV2Record +import org.whispersystems.signalservice.api.storage.toSignalServiceAddress +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord import java.io.Closeable import java.io.IOException import java.util.Collections @@ -1509,20 +1510,20 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa } fun applyStorageSyncUpdate(recipientId: RecipientId, record: SignalContactRecord) { - applyStorageSyncUpdate(recipientId, record.isArchived, record.isForcedUnread) + applyStorageSyncUpdate(recipientId, record.proto.archived, record.proto.markedUnread) } fun applyStorageSyncUpdate(recipientId: RecipientId, record: SignalGroupV1Record) { - applyStorageSyncUpdate(recipientId, record.isArchived, record.isForcedUnread) + applyStorageSyncUpdate(recipientId, record.proto.archived, record.proto.markedUnread) } fun applyStorageSyncUpdate(recipientId: RecipientId, record: SignalGroupV2Record) { - applyStorageSyncUpdate(recipientId, record.isArchived, record.isForcedUnread) + applyStorageSyncUpdate(recipientId, record.proto.archived, record.proto.markedUnread) } fun applyStorageSyncUpdate(recipientId: RecipientId, record: SignalAccountRecord) { writableDatabase.withinTransaction { db -> - applyStorageSyncUpdate(recipientId, record.isNoteToSelfArchived, record.isNoteToSelfForcedUnread) + applyStorageSyncUpdate(recipientId, record.proto.noteToSelfArchived, record.proto.noteToSelfMarkedUnread) db.updateAll(TABLE_NAME) .values(PINNED to 0) @@ -1530,19 +1531,19 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa var pinnedPosition = 1 - for (pinned: PinnedConversation in record.pinnedConversations) { - val pinnedRecipient: Recipient? = if (pinned.contact.isPresent) { - Recipient.externalPush(pinned.contact.get()) - } else if (pinned.groupV1Id.isPresent) { + for (pinned: AccountRecord.PinnedConversation in record.proto.pinnedConversations) { + val pinnedRecipient: Recipient? = if (pinned.contact != null) { + Recipient.externalPush(pinned.contact!!.toSignalServiceAddress()) + } else if (pinned.legacyGroupId != null) { try { - Recipient.externalGroupExact(GroupId.v1(pinned.groupV1Id.get())) + Recipient.externalGroupExact(GroupId.v1(pinned.legacyGroupId!!.toByteArray())) } catch (e: BadGroupIdException) { Log.w(TAG, "Failed to parse pinned groupV1 ID!", e) null } - } else if (pinned.groupV2MasterKey.isPresent) { + } else if (pinned.groupMasterKey != null) { try { - Recipient.externalGroupExact(GroupId.v2(GroupMasterKey(pinned.groupV2MasterKey.get()))) + Recipient.externalGroupExact(GroupId.v2(GroupMasterKey(pinned.groupMasterKey!!.toByteArray()))) } catch (e: InvalidInputException) { Log.w(TAG, "Failed to parse pinned groupV2 master key!", e) null diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/UnknownStorageIdTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/UnknownStorageIdTable.java index 5cc50a28a4..4059b85d82 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/UnknownStorageIdTable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/UnknownStorageIdTable.java @@ -116,7 +116,7 @@ public void insert(@NonNull Collection inserts) { for (SignalStorageRecord insert : inserts) { ContentValues values = new ContentValues(); - values.put(TYPE, insert.getType()); + values.put(TYPE, insert.getId().getType()); values.put(STORAGE_ID, Base64.encodeWithPadding(insert.getId().getRaw())); db.insert(TABLE_NAME, null, values); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 0b81ce17ee..01c0c12d35 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -112,6 +112,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V252_AttachmentOffl import org.thoughtcrime.securesms.database.helpers.migration.V253_CreateChatFolderTables import org.thoughtcrime.securesms.database.helpers.migration.V254_AddChatFolderConstraint import org.thoughtcrime.securesms.database.helpers.migration.V255_AddCallTableLogIndex +import org.thoughtcrime.securesms.database.helpers.migration.V256_FixIncrementalDigestColumns /** * Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness. @@ -228,10 +229,11 @@ object SignalDatabaseMigrations { 252 to V252_AttachmentOffloadRestoredAtColumn, 253 to V253_CreateChatFolderTables, 254 to V254_AddChatFolderConstraint, - 255 to V255_AddCallTableLogIndex + 255 to V255_AddCallTableLogIndex, + 256 to V256_FixIncrementalDigestColumns ) - const val DATABASE_VERSION = 255 + const val DATABASE_VERSION = 256 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V256_FixIncrementalDigestColumns.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V256_FixIncrementalDigestColumns.kt new file mode 100644 index 0000000000..f4e16779c6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V256_FixIncrementalDigestColumns.kt @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import net.zetetic.database.sqlcipher.SQLiteDatabase + +/** + * Fixes a bug where sometimes incremental chunk size would be set when the attachment was not actually incremental + * Clears out any cases where only one of the incremental_digest / incremental_size fields were previously set + */ +@Suppress("ClassName") +object V256_FixIncrementalDigestColumns : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL( + """ + UPDATE attachment + SET + remote_incremental_digest = NULL, + remote_incremental_digest_chunk_size = 0 + WHERE + remote_incremental_digest IS NULL OR + LENGTH(remote_incremental_digest) = 0 OR + remote_incremental_digest_chunk_size = 0 + """ + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/InAppPaymentSubscriberRecord.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/InAppPaymentSubscriberRecord.kt index 522ee8f552..d312683597 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/InAppPaymentSubscriberRecord.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/InAppPaymentSubscriberRecord.kt @@ -9,6 +9,8 @@ import org.signal.donations.InAppPaymentType import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData import org.whispersystems.signalservice.api.subscriptions.SubscriberId import java.util.Currency +import java.util.concurrent.locks.Lock +import java.util.concurrent.locks.ReentrantLock /** * Represents a SubscriberId and metadata that can be used for a recurring @@ -24,7 +26,7 @@ data class InAppPaymentSubscriberRecord( /** * Serves as the mutex by which to perform mutations to subscriptions. */ - enum class Type(val code: Int, val jobQueue: String, val inAppPaymentType: InAppPaymentType) { + enum class Type(val code: Int, val jobQueue: String, val inAppPaymentType: InAppPaymentType, val lock: Lock = ReentrantLock()) { /** * A recurring donation */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index c97d4787eb..af701e635a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -49,6 +49,7 @@ import org.whispersystems.signalservice.api.attachment.AttachmentApi import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations import org.whispersystems.signalservice.api.keys.KeysApi import org.whispersystems.signalservice.api.link.LinkDeviceApi +import org.whispersystems.signalservice.api.registration.RegistrationApi import org.whispersystems.signalservice.api.services.CallLinksService import org.whispersystems.signalservice.api.services.DonationsService import org.whispersystems.signalservice.api.services.ProfileService @@ -300,6 +301,10 @@ object AppDependencies { val linkDeviceApi: LinkDeviceApi get() = networkModule.linkDeviceApi + @JvmStatic + val registrationApi: RegistrationApi + get() = networkModule.registrationApi + @JvmStatic val okHttpClient: OkHttpClient get() = networkModule.okHttpClient @@ -366,5 +371,6 @@ object AppDependencies { fun provideKeysApi(pushServiceSocket: PushServiceSocket): KeysApi fun provideAttachmentApi(signalWebSocket: SignalWebSocket, pushServiceSocket: PushServiceSocket): AttachmentApi fun provideLinkDeviceApi(pushServiceSocket: PushServiceSocket): LinkDeviceApi + fun provideRegistrationApi(pushServiceSocket: PushServiceSocket): RegistrationApi } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index ef75fd31ee..5b35e5fd0f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -70,6 +70,7 @@ import org.thoughtcrime.securesms.util.AlarmSleepTimer; import org.thoughtcrime.securesms.util.ByteUnit; import org.thoughtcrime.securesms.util.EarlyMessageCache; +import org.thoughtcrime.securesms.util.Environment; import org.thoughtcrime.securesms.util.FrameRateTracker; import org.thoughtcrime.securesms.util.RemoteConfig; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -89,6 +90,7 @@ import org.whispersystems.signalservice.api.link.LinkDeviceApi; import org.whispersystems.signalservice.api.push.ServiceId.ACI; import org.whispersystems.signalservice.api.push.ServiceId.PNI; +import org.whispersystems.signalservice.api.registration.RegistrationApi; import org.whispersystems.signalservice.api.services.CallLinksService; import org.whispersystems.signalservice.api.services.DonationsService; import org.whispersystems.signalservice.api.services.ProfileService; @@ -449,7 +451,7 @@ public WebSocketConnection createUnidentifiedWebSocket() { @Override public @NonNull BillingApi provideBillingApi() { - return BillingFactory.create(GooglePlayBillingDependencies.INSTANCE, RemoteConfig.messageBackups()); + return BillingFactory.create(GooglePlayBillingDependencies.INSTANCE, RemoteConfig.messageBackups() && !Environment.IS_STAGING); } @Override @@ -472,6 +474,11 @@ public WebSocketConnection createUnidentifiedWebSocket() { return new LinkDeviceApi(pushServiceSocket); } + @Override + public @NonNull RegistrationApi provideRegistrationApi(@NonNull PushServiceSocket pushServiceSocket) { + return new RegistrationApi(pushServiceSocket); + } + @VisibleForTesting static class DynamicCredentialsProvider implements CredentialsProvider { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt index e19e9775aa..4c7a4a930f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt @@ -34,6 +34,7 @@ import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations import org.whispersystems.signalservice.api.keys.KeysApi import org.whispersystems.signalservice.api.link.LinkDeviceApi import org.whispersystems.signalservice.api.push.TrustStore +import org.whispersystems.signalservice.api.registration.RegistrationApi import org.whispersystems.signalservice.api.services.CallLinksService import org.whispersystems.signalservice.api.services.DonationsService import org.whispersystems.signalservice.api.services.ProfileService @@ -144,6 +145,10 @@ class NetworkDependenciesModule( provider.provideLinkDeviceApi(pushServiceSocket) } + val registrationApi: RegistrationApi by lazy { + provider.provideRegistrationApi(pushServiceSocket) + } + val okHttpClient: OkHttpClient by lazy { OkHttpClient.Builder() .socketFactory(Networking.socketFactory) diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferFragment.java deleted file mode 100644 index e165e36fb0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferFragment.java +++ /dev/null @@ -1,170 +0,0 @@ -package org.thoughtcrime.securesms.devicetransfer; - -import android.os.Bundle; -import android.view.View; -import android.widget.Button; -import android.widget.TextView; - -import androidx.activity.OnBackPressedCallback; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; - -import com.google.android.material.dialog.MaterialAlertDialogBuilder; - -import org.greenrobot.eventbus.EventBus; -import org.greenrobot.eventbus.Subscribe; -import org.greenrobot.eventbus.ThreadMode; -import org.signal.devicetransfer.DeviceToDeviceTransferService; -import org.signal.devicetransfer.TransferStatus; -import org.thoughtcrime.securesms.LoggingFragment; -import org.thoughtcrime.securesms.R; - -/** - * Drives the UI for the actual device transfer progress. Shown after setup is complete - * and the two devices are transferring. - *

- * Handles show progress and error state. - */ -public abstract class DeviceTransferFragment extends LoggingFragment { - - private static final String TRANSFER_FINISHED_KEY = "transfer_finished"; - - private final OnBackPressed onBackPressed = new OnBackPressed(); - private final TransferModeListener transferModeListener = new TransferModeListener(); - - protected TextView title; - protected View tryAgain; - protected Button cancel; - protected View progress; - protected View alert; - protected TextView status; - protected boolean transferFinished; - - public DeviceTransferFragment() { - super(R.layout.fragment_device_transfer); - } - - @Override - public void onCreate(@Nullable Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - if (savedInstanceState != null) { - transferFinished = savedInstanceState.getBoolean(TRANSFER_FINISHED_KEY); - } - } - - @Override - public void onStart() { - super.onStart(); - if (transferFinished) { - navigateToTransferComplete(); - } - } - - @Override - public void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - outState.putBoolean(TRANSFER_FINISHED_KEY, transferFinished); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - title = view.findViewById(R.id.device_transfer_fragment_title); - tryAgain = view.findViewById(R.id.device_transfer_fragment_try_again); - cancel = view.findViewById(R.id.device_transfer_fragment_cancel); - progress = view.findViewById(R.id.device_transfer_fragment_progress); - alert = view.findViewById(R.id.device_transfer_fragment_alert); - status = view.findViewById(R.id.device_transfer_fragment_status); - - cancel.setOnClickListener(v -> cancelActiveTransfer()); - tryAgain.setOnClickListener(v -> { - EventBus.getDefault().unregister(transferModeListener); - EventBus.getDefault().removeStickyEvent(TransferStatus.class); - navigateToRestartTransfer(); - }); - - EventBus.getDefault().register(transferModeListener); - } - - @Override - public void onActivityCreated(@Nullable Bundle savedInstanceState) { - super.onActivityCreated(savedInstanceState); - requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), onBackPressed); - } - - @Override - public void onDestroyView() { - EventBus.getDefault().unregister(transferModeListener); - super.onDestroyView(); - } - - private void cancelActiveTransfer() { - new MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.DeviceTransfer__stop_transfer) - .setMessage(R.string.DeviceTransfer__all_transfer_progress_will_be_lost) - .setPositiveButton(R.string.DeviceTransfer__stop_transfer, (d, w) -> { - EventBus.getDefault().unregister(transferModeListener); - DeviceToDeviceTransferService.stop(requireContext()); - EventBus.getDefault().removeStickyEvent(TransferStatus.class); - navigateAwayFromTransfer(); - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); - } - - protected void ignoreTransferStatusEvents() { - EventBus.getDefault().unregister(transferModeListener); - } - - protected abstract void navigateToRestartTransfer(); - - protected abstract void navigateAwayFromTransfer(); - - protected abstract void navigateToTransferComplete(); - - private class TransferModeListener { - @Subscribe(sticky = true, threadMode = ThreadMode.MAIN) - public void onEventMainThread(@NonNull TransferStatus event) { - if (event.getTransferMode() != TransferStatus.TransferMode.SERVICE_CONNECTED) { - abort(); - } - } - } - - protected void abort() { - abort(R.string.DeviceTransfer__transfer_failed); - } - - protected void abort(@StringRes int errorMessage) { - EventBus.getDefault().unregister(transferModeListener); - DeviceToDeviceTransferService.stop(requireContext()); - - progress.setVisibility(View.GONE); - alert.setVisibility(View.VISIBLE); - tryAgain.setVisibility(View.VISIBLE); - - title.setText(R.string.DeviceTransfer__unable_to_transfer); - status.setText(errorMessage); - cancel.setText(R.string.DeviceTransfer__cancel); - cancel.setOnClickListener(v -> navigateAwayFromTransfer()); - - onBackPressed.isActiveTransfer = false; - } - - protected class OnBackPressed extends OnBackPressedCallback { - - private boolean isActiveTransfer = true; - - public OnBackPressed() { - super(true); - } - - @Override - public void handleOnBackPressed() { - if (isActiveTransfer) { - cancelActiveTransfer(); - } else { - navigateAwayFromTransfer(); - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsMode.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsMode.kt deleted file mode 100644 index 4619dd41b7..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsMode.kt +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.devicetransfer.moreoptions - -/** - * Allows component opening sheet to specify mode - */ -enum class MoreTransferOrRestoreOptionsMode { - /** - * Only display the option to log in without transferring. Selection - * will be disabled. - */ - SKIP_ONLY, - - /** - * Display transfer/restore local/skip as well as a next and cancel button - */ - SELECTION -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsSheet.kt deleted file mode 100644 index db26bd0644..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsSheet.kt +++ /dev/null @@ -1,339 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.devicetransfer.moreoptions - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.navigation.fragment.findNavController -import androidx.navigation.fragment.navArgs -import org.signal.core.ui.BottomSheets -import org.signal.core.ui.Buttons -import org.signal.core.ui.Previews -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment -import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType -import org.signal.core.ui.R as CoreUiR - -/** - * Lists a set of options the user can choose from for restoring backup or skipping restoration - */ -class MoreTransferOrRestoreOptionsSheet : ComposeBottomSheetDialogFragment() { - - private val args by navArgs() - - @Composable - override fun SheetContent() { - var selectedOption by remember { - mutableStateOf(null) - } - - MoreOptionsSheetContent( - mode = args.mode, - selectedOption = selectedOption, - onOptionSelected = { selectedOption = it }, - onCancelClick = { findNavController().popBackStack() }, - onNextClick = { - this.onNextClicked(selectedOption ?: BackupRestorationType.NONE) - } - ) - } - - private fun onNextClicked(selectedOption: BackupRestorationType) { - // TODO [message-requests] -- Launch next screen based off user choice - } -} - -@Preview -@Composable -private fun MoreOptionsSheetContentPreview() { - Previews.BottomSheetPreview { - MoreOptionsSheetContent( - mode = MoreTransferOrRestoreOptionsMode.SKIP_ONLY, - selectedOption = null, - onOptionSelected = {}, - onCancelClick = {}, - onNextClick = {} - ) - } -} - -@Preview -@Composable -private fun MoreOptionsSheetSelectableContentPreview() { - Previews.BottomSheetPreview { - MoreOptionsSheetContent( - mode = MoreTransferOrRestoreOptionsMode.SELECTION, - selectedOption = null, - onOptionSelected = {}, - onCancelClick = {}, - onNextClick = {} - ) - } -} - -@Composable -private fun MoreOptionsSheetContent( - mode: MoreTransferOrRestoreOptionsMode, - selectedOption: BackupRestorationType?, - onOptionSelected: (BackupRestorationType) -> Unit, - onCancelClick: () -> Unit, - onNextClick: () -> Unit -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter)) - ) { - BottomSheets.Handle() - - Spacer(modifier = Modifier.size(42.dp)) - - if (mode == MoreTransferOrRestoreOptionsMode.SELECTION) { - TransferFromAndroidDeviceOption( - selectedOption = selectedOption, - onOptionSelected = onOptionSelected - ) - Spacer(modifier = Modifier.size(16.dp)) - RestoreLocalBackupOption( - selectedOption = selectedOption, - onOptionSelected = onOptionSelected - ) - Spacer(modifier = Modifier.size(16.dp)) - } - - LogInWithoutTransferringOption( - selectedOption = selectedOption, - onOptionSelected = when (mode) { - MoreTransferOrRestoreOptionsMode.SKIP_ONLY -> { _ -> onNextClick() } - MoreTransferOrRestoreOptionsMode.SELECTION -> onOptionSelected - } - ) - - if (mode == MoreTransferOrRestoreOptionsMode.SELECTION) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 30.dp, bottom = 24.dp) - ) { - TextButton( - onClick = onCancelClick - ) { - Text(text = stringResource(id = android.R.string.cancel)) - } - - Spacer(modifier = Modifier.weight(1f)) - - Buttons.LargeTonal( - enabled = selectedOption != null, - onClick = onNextClick - ) { - Text(text = stringResource(id = R.string.RegistrationActivity_next)) - } - } - } else { - Spacer(modifier = Modifier.size(45.dp)) - } - } -} - -@Preview -@Composable -private fun LogInWithoutTransferringOptionPreview() { - Previews.BottomSheetPreview { - LogInWithoutTransferringOption( - selectedOption = null, - onOptionSelected = {} - ) - } -} - -@Composable -private fun LogInWithoutTransferringOption( - selectedOption: BackupRestorationType?, - onOptionSelected: (BackupRestorationType) -> Unit -) { - Option( - icon = { - Box( - modifier = Modifier.padding(horizontal = 18.dp) - ) { - Icon( - painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [backups] Finalized asset. - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(36.dp) - ) - } - }, - isSelected = selectedOption == BackupRestorationType.NONE, - title = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__log_in_without_transferring), - subtitle = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__continue_without_transferring), - onClick = { onOptionSelected(BackupRestorationType.NONE) } - ) -} - -@Preview -@Composable -private fun TransferFromAndroidDeviceOptionPreview() { - Previews.BottomSheetPreview { - TransferFromAndroidDeviceOption( - selectedOption = null, - onOptionSelected = {} - ) - } -} - -@Composable -private fun TransferFromAndroidDeviceOption( - selectedOption: BackupRestorationType?, - onOptionSelected: (BackupRestorationType) -> Unit -) { - Option( - icon = { - Box( - modifier = Modifier.padding(horizontal = 18.dp) - ) { - Icon( - painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [backups] Finalized asset. - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(36.dp) - ) - } - }, - isSelected = selectedOption == BackupRestorationType.DEVICE_TRANSFER, - title = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__transfer_from_android_device), - subtitle = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__transfer_your_account_and_messages), - onClick = { onOptionSelected(BackupRestorationType.DEVICE_TRANSFER) } - ) -} - -@Preview -@Composable -private fun RestoreLocalBackupOptionPreview() { - Previews.BottomSheetPreview { - RestoreLocalBackupOption( - selectedOption = null, - onOptionSelected = {} - ) - } -} - -@Composable -private fun RestoreLocalBackupOption( - selectedOption: BackupRestorationType?, - onOptionSelected: (BackupRestorationType) -> Unit -) { - Option( - icon = { - Box( - modifier = Modifier.padding(horizontal = 18.dp) - ) { - Icon( - painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [backups] Finalized asset. - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(36.dp) - ) - } - }, - isSelected = selectedOption == BackupRestorationType.LOCAL_BACKUP, - title = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__restore_local_backup), - subtitle = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__restore_your_messages), - onClick = { onOptionSelected(BackupRestorationType.LOCAL_BACKUP) } - ) -} - -@Preview -@Composable -private fun OptionPreview() { - Previews.BottomSheetPreview { - Option( - icon = { - Box( - modifier = Modifier.padding(horizontal = 18.dp) - ) { - Icon( - painter = painterResource(id = R.drawable.symbol_backup_light), - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(36.dp) - ) - } - }, - isSelected = false, - title = "Option Preview Title", - subtitle = "Option Preview Subtitle", - onClick = {} - ) - } -} - -@Composable -private fun Option( - icon: @Composable () -> Unit, - isSelected: Boolean, - title: String, - subtitle: String, - onClick: () -> Unit -) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .background( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(12.dp) - ) - .border( - width = if (isSelected) 2.dp else 0.dp, - color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent - ) - .clip(RoundedCornerShape(12.dp)) - .clickable { onClick() } - .padding(vertical = 21.dp) - ) { - icon() - Column { - Text( - text = title, - style = MaterialTheme.typography.bodyLarge - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java index 80d8f9dcae..e1affd13c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java @@ -73,6 +73,8 @@ public void run(@NonNull Context context, @NonNull InputStream inputStream) { long end = System.currentTimeMillis(); Log.i(TAG, "Receive took: " + (end - start)); + + EventBus.getDefault().post(new Status(0, Status.State.RESTORE_COMPLETE)); } @Subscribe(threadMode = ThreadMode.POSTING) @@ -80,7 +82,7 @@ public void onEvent(BackupEvent event) { if (event.getType() == BackupEvent.Type.PROGRESS) { EventBus.getDefault().post(new Status(event.getCount(), Status.State.IN_PROGRESS)); } else if (event.getType() == BackupEvent.Type.FINISHED) { - EventBus.getDefault().post(new Status(event.getCount(), Status.State.SUCCESS)); + EventBus.getDefault().post(new Status(event.getCount(), Status.State.TRANSFER_COMPLETE)); } } @@ -103,7 +105,8 @@ public long getMessageCount() { public enum State { IN_PROGRESS, - SUCCESS, + TRANSFER_COMPLETE, + RESTORE_COMPLETE, FAILURE_VERSION_DOWNGRADE, FAILURE_FOREIGN_KEY, FAILURE_UNKNOWN diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferCompleteFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferCompleteFragment.java index 013cd93461..c6f0d093b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferCompleteFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferCompleteFragment.java @@ -6,11 +6,10 @@ import androidx.activity.OnBackPressedCallback; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.navigation.fragment.NavHostFragment; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.util.navigation.SafeNavigation; +import org.thoughtcrime.securesms.restore.RestoreActivity; /** * Shown after the new device successfully completes receiving a backup from the old device. @@ -23,8 +22,7 @@ public NewDeviceTransferCompleteFragment() { @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { view.findViewById(R.id.new_device_transfer_complete_fragment_continue_registration) - .setOnClickListener(v -> SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), - R.id.action_newDeviceTransferComplete_to_enterPhoneNumberFragment)); + .setOnClickListener(v -> ((RestoreActivity) requireActivity()).onBackupCompletedSuccessfully()); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.java deleted file mode 100644 index 6feb785390..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.java +++ /dev/null @@ -1,80 +0,0 @@ -package org.thoughtcrime.securesms.devicetransfer.newdevice; - -import android.os.Bundle; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.navigation.fragment.NavHostFragment; - -import org.greenrobot.eventbus.EventBus; -import org.greenrobot.eventbus.Subscribe; -import org.greenrobot.eventbus.ThreadMode; -import org.signal.devicetransfer.DeviceToDeviceTransferService; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.devicetransfer.DeviceTransferFragment; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.util.navigation.SafeNavigation; - -/** - * Shows transfer progress on the new device. Most logic is in {@link DeviceTransferFragment} - * and it delegates to this class for strings, navigation, and updating progress. - */ -public final class NewDeviceTransferFragment extends DeviceTransferFragment { - - private final ServerTaskListener serverTaskListener = new ServerTaskListener(); - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - EventBus.getDefault().register(serverTaskListener); - } - - @Override - public void onDestroyView() { - EventBus.getDefault().unregister(serverTaskListener); - super.onDestroyView(); - } - - @Override - protected void navigateToRestartTransfer() { - SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferInstructions()); - } - - @Override - protected void navigateAwayFromTransfer() { - EventBus.getDefault().unregister(serverTaskListener); - requireActivity().finish(); - } - - @Override - protected void navigateToTransferComplete() { - SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferComplete()); - } - - private class ServerTaskListener { - @Subscribe(threadMode = ThreadMode.MAIN) - public void onEventMainThread(@NonNull NewDeviceServerTask.Status event) { - status.setText(getString(R.string.DeviceTransfer__d_messages_so_far, event.getMessageCount())); - switch (event.getState()) { - case IN_PROGRESS: - break; - case SUCCESS: - transferFinished = true; - DeviceToDeviceTransferService.stop(requireContext()); - SignalStore.registration().markRestoreCompleted(); - navigateToTransferComplete(); - break; - case FAILURE_VERSION_DOWNGRADE: - abort(R.string.NewDeviceTransfer__cannot_transfer_from_a_newer_version_of_signal); - break; - case FAILURE_FOREIGN_KEY: - abort(R.string.NewDeviceTransfer__failure_foreign_key); - break; - case FAILURE_UNKNOWN: - abort(); - break; - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.kt new file mode 100644 index 0000000000..95a8ee4ff3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.kt @@ -0,0 +1,79 @@ +package org.thoughtcrime.securesms.devicetransfer.newdevice + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.signal.devicetransfer.DeviceToDeviceTransferService +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.restore.RestoreActivity +import org.thoughtcrime.securesms.restore.devicetransfer.DeviceTransferFragment +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +/** + * Shows transfer progress on the new device. Most logic is in [DeviceTransferFragment] + * and it delegates to this class for strings, navigation, and updating progress. + */ +class NewDeviceTransferFragment : DeviceTransferFragment() { + + private val viewModel: NewDeviceTransferViewModel by viewModels() + private val serverTaskListener = ServerTaskListener() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + EventBus.getDefault().register(serverTaskListener) + } + + override fun onDestroyView() { + EventBus.getDefault().unregister(serverTaskListener) + super.onDestroyView() + } + + override fun navigateToRestartTransfer() { + findNavController().safeNavigate(NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferInstructions()) + } + + override fun navigateAwayFromTransfer() { + EventBus.getDefault().unregister(serverTaskListener) + requireActivity().finish() + } + + override fun navigateToTransferComplete() { + if (SignalStore.account.isRegistered) { + (requireActivity() as RestoreActivity).onBackupCompletedSuccessfully() + } else { + findNavController().safeNavigate(NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferComplete()) + } + } + + private fun onRestoreComplete() { + ignoreTransferStatusEvents() + DeviceToDeviceTransferService.stop(requireContext()) + + viewModel.onRestoreComplete(requireContext()) { + transferFinished = true + navigateToTransferComplete() + } + } + + private inner class ServerTaskListener { + @Subscribe(threadMode = ThreadMode.MAIN) + fun onEventMainThread(event: NewDeviceServerTask.Status) { + status.text = getString(R.string.DeviceTransfer__d_messages_so_far, event.messageCount) + + when (event.state) { + NewDeviceServerTask.Status.State.IN_PROGRESS, + NewDeviceServerTask.Status.State.TRANSFER_COMPLETE -> Unit + + NewDeviceServerTask.Status.State.RESTORE_COMPLETE -> onRestoreComplete() + NewDeviceServerTask.Status.State.FAILURE_VERSION_DOWNGRADE -> abort(R.string.NewDeviceTransfer__cannot_transfer_from_a_newer_version_of_signal) + NewDeviceServerTask.Status.State.FAILURE_FOREIGN_KEY -> abort(R.string.NewDeviceTransfer__failure_foreign_key) + NewDeviceServerTask.Status.State.FAILURE_UNKNOWN -> abort() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.java deleted file mode 100644 index c4c5098599..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.thoughtcrime.securesms.devicetransfer.newdevice; - -import android.os.Bundle; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.navigation.Navigation; - -import org.greenrobot.eventbus.EventBus; -import org.signal.devicetransfer.TransferStatus; -import org.thoughtcrime.securesms.LoggingFragment; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.util.navigation.SafeNavigation; - -/** - * Shows instructions for new device to being transfer. - */ -public final class NewDeviceTransferInstructionsFragment extends LoggingFragment { - public NewDeviceTransferInstructionsFragment() { - super(R.layout.new_device_transfer_instructions_fragment); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - view.findViewById(R.id.new_device_transfer_instructions_fragment_continue) - .setOnClickListener(v -> SafeNavigation.safeNavigate(Navigation.findNavController(v), R.id.action_device_transfer_setup)); - } - - @Override - public void onResume() { - super.onResume(); - EventBus.getDefault().removeStickyEvent(TransferStatus.class); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.kt new file mode 100644 index 0000000000..9ec2cdca6c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.kt @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.devicetransfer.newdevice + +import android.os.Bundle +import android.view.View +import androidx.navigation.fragment.findNavController +import org.greenrobot.eventbus.EventBus +import org.signal.devicetransfer.TransferStatus +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +/** + * Shows instructions for new device to being transfer. + */ +class NewDeviceTransferInstructionsFragment : LoggingFragment(R.layout.new_device_transfer_instructions_fragment) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view + .findViewById(R.id.new_device_transfer_instructions_fragment_continue) + .setOnClickListener { findNavController().safeNavigate(R.id.action_device_transfer_setup) } + } + + override fun onResume() { + super.onResume() + EventBus.getDefault().removeStickyEvent(TransferStatus::class.java) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferSetupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferSetupFragment.java index 963c9b76e8..51b3e746a4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferSetupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferSetupFragment.java @@ -27,7 +27,7 @@ public final class NewDeviceTransferSetupFragment extends DeviceTransferSetupFra @Override protected void navigateAwayFromTransfer() { - SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), R.id.action_deviceTransferSetup_to_transferOrRestore); + requireActivity().onNavigateUp(); } @Override @@ -78,7 +78,7 @@ protected void navigateToTransferConnected() { @Override protected void navigateWhenWifiDirectUnavailable() { - SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), R.id.action_deviceTransferSetup_to_transferOrRestore); + requireActivity().onNavigateUp(); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferViewModel.kt new file mode 100644 index 0000000000..fab0b6961b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferViewModel.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.devicetransfer.newdevice + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.registration.util.RegistrationUtil +import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository + +class NewDeviceTransferViewModel : ViewModel() { + fun onRestoreComplete(context: Context, onComplete: () -> Unit) { + viewModelScope.launch { + SignalStore.registration.localRegistrationMetadata?.let { metadata -> + RegistrationRepository.registerAccountLocally(context, metadata) + SignalStore.registration.localRegistrationMetadata = null + RegistrationUtil.maybeMarkRegistrationComplete() + } + + SignalStore.registration.markRestoreCompleted() + + withContext(Dispatchers.Main) { + onComplete() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreFragment.java deleted file mode 100644 index 686cb9cc8b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreFragment.java +++ /dev/null @@ -1,71 +0,0 @@ -package org.thoughtcrime.securesms.devicetransfer.newdevice; - -import android.os.Bundle; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.lifecycle.ViewModelProvider; -import androidx.navigation.Navigation; - -import org.signal.core.util.concurrent.LifecycleDisposable; -import org.thoughtcrime.securesms.LoggingFragment; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.databinding.FragmentTransferRestoreBinding; -import org.thoughtcrime.securesms.util.RemoteConfig; -import org.thoughtcrime.securesms.util.SpanUtil; -import org.thoughtcrime.securesms.util.navigation.SafeNavigation; - -/** - * Simple jumping off menu to starts a device-to-device transfer or restore a backup. - */ -public final class TransferOrRestoreFragment extends LoggingFragment { - - private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable(); - - private FragmentTransferRestoreBinding binding; - - public TransferOrRestoreFragment() { - super(R.layout.fragment_transfer_restore); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - binding = FragmentTransferRestoreBinding.bind(view); - - TransferOrRestoreViewModel viewModel = new ViewModelProvider(this).get(TransferOrRestoreViewModel.class); - - binding.transferOrRestoreFragmentTransfer.setOnClickListener(v -> viewModel.onTransferFromAndroidDeviceSelected()); - binding.transferOrRestoreFragmentRestore.setOnClickListener(v -> viewModel.onRestoreFromLocalBackupSelected()); - binding.transferOrRestoreFragmentRestoreRemote.setOnClickListener(v -> viewModel.onRestoreFromRemoteBackupSelected()); - binding.transferOrRestoreFragmentNext.setOnClickListener(v -> launchSelection(viewModel.getStateSnapshot())); - binding.transferOrRestoreFragmentMoreOptions.setOnClickListener(v -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_transferOrRestore_to_moreOptions)); - - int visibility = RemoteConfig.messageBackups() ? View.VISIBLE : View.GONE; - binding.transferOrRestoreFragmentRestoreRemoteCard.setVisibility(visibility); - binding.transferOrRestoreFragmentMoreOptions.setVisibility(visibility); - - String description = getString(R.string.TransferOrRestoreFragment__transfer_your_account_and_messages_from_your_old_android_device); - String toBold = getString(R.string.TransferOrRestoreFragment__you_need_access_to_your_old_device); - - binding.transferOrRestoreFragmentTransferDescription.setText(SpanUtil.boldSubstring(description, toBold)); - - lifecycleDisposable.bindTo(getViewLifecycleOwner()); - lifecycleDisposable.add(viewModel.getState().subscribe(this::updateSelection)); - } - - private void updateSelection(BackupRestorationType restorationType) { - binding.transferOrRestoreFragmentTransferCard.setSelected(restorationType == BackupRestorationType.DEVICE_TRANSFER); - binding.transferOrRestoreFragmentRestoreCard.setSelected(restorationType == BackupRestorationType.LOCAL_BACKUP); - binding.transferOrRestoreFragmentRestoreRemoteCard.setSelected(restorationType == BackupRestorationType.REMOTE_BACKUP); - } - - private void launchSelection(BackupRestorationType restorationType) { - switch (restorationType) { - case DEVICE_TRANSFER -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_new_device_transfer_instructions); - case LOCAL_BACKUP -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_transfer_or_restore_to_local_restore); - case REMOTE_BACKUP -> {} - default -> throw new IllegalArgumentException(); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreViewModel.kt deleted file mode 100644 index 7848ff2304..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreViewModel.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.devicetransfer.newdevice - -import androidx.lifecycle.ViewModel -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.processors.BehaviorProcessor - -/** - * Maintains state of the TransferOrRestoreFragment - */ -class TransferOrRestoreViewModel : ViewModel() { - - private val internalState = BehaviorProcessor.createDefault(BackupRestorationType.DEVICE_TRANSFER) - - val state: Flowable = internalState.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread()) - val stateSnapshot: BackupRestorationType get() = internalState.value!! - - fun onTransferFromAndroidDeviceSelected() { - internalState.onNext(BackupRestorationType.DEVICE_TRANSFER) - } - - fun onRestoreFromLocalBackupSelected() { - internalState.onNext(BackupRestorationType.LOCAL_BACKUP) - } - - fun onRestoreFromRemoteBackupSelected() { - internalState.onNext(BackupRestorationType.REMOTE_BACKUP) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferFragment.java index e51a5a6a6e..06d5e65560 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferFragment.java @@ -13,7 +13,7 @@ import org.signal.devicetransfer.DeviceToDeviceTransferService; import org.signal.devicetransfer.TransferStatus; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.devicetransfer.DeviceTransferFragment; +import org.thoughtcrime.securesms.restore.devicetransfer.DeviceTransferFragment; import org.thoughtcrime.securesms.util.navigation.SafeNavigation; import java.text.NumberFormat; @@ -66,16 +66,16 @@ public ClientTaskListener() { @Subscribe(threadMode = ThreadMode.MAIN) public void onEventMainThread(@NonNull OldDeviceClientTask.Status event) { if (event.isDone()) { - transferFinished = true; + setTransferFinished(true); ignoreTransferStatusEvents(); EventBus.getDefault().removeStickyEvent(TransferStatus.class); DeviceToDeviceTransferService.stop(requireContext()); SafeNavigation.safeNavigate(NavHostFragment.findNavController(OldDeviceTransferFragment.this), R.id.action_oldDeviceTransfer_to_oldDeviceTransferComplete); } else { if (event.getEstimatedMessageCount() == 0) { - status.setText(getString(R.string.DeviceTransfer__d_messages_so_far, event.getMessageCount())); + getStatus().setText(getString(R.string.DeviceTransfer__d_messages_so_far, event.getMessageCount())); } else { - status.setText(getString(R.string.DeviceTransfer__s_of_messages_so_far, formatter.format(event.getCompletionPercentage()))); + getStatus().setText(getString(R.string.DeviceTransfer__s_of_messages_so_far, formatter.format(event.getCompletionPercentage()))); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt index fd2246bd5d..9ba4dcee3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt @@ -25,7 +25,8 @@ object SignalSymbols { enum class Glyph(val unicode: Char) { CHECKMARK('\u2713'), CHEVRON_RIGHT('\uE025'), - PERSON_CIRCLE('\uE05E') + PERSON_CIRCLE('\uE05E'), + LOCK('\uE041') } enum class Weight { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AccountConsistencyWorkerJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/AccountConsistencyWorkerJob.kt index 3f1b9a2ad1..c18d7a5e3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AccountConsistencyWorkerJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AccountConsistencyWorkerJob.kt @@ -64,7 +64,7 @@ class AccountConsistencyWorkerJob private constructor(parameters: Parameters) : SignalStore.account.setRegistered(false) SignalStore.registration.clearRegistrationComplete() - SignalStore.registration.clearHasUploadedProfile() + SignalStore.registration.hasUploadedProfile = false SignalStore.misc.lastConsistencyCheckTime = System.currentTimeMillis() return @@ -78,7 +78,7 @@ class AccountConsistencyWorkerJob private constructor(parameters: Parameters) : SignalStore.account.setRegistered(false) SignalStore.registration.clearRegistrationComplete() - SignalStore.registration.clearHasUploadedProfile() + SignalStore.registration.hasUploadedProfile = false return } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreJob.kt index 00cf7e2027..0c552bf3ae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreJob.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.jobs import org.greenrobot.eventbus.EventBus +import org.signal.core.util.bytes import org.signal.core.util.logging.Log import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.thoughtcrime.securesms.R @@ -75,7 +76,7 @@ class BackupRestoreJob private constructor(parameters: Parameters) : BaseJob(par progress = progress.toFloat() / total.toFloat(), indeterminate = false ) - EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_DOWNLOAD, progress, total)) + EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_DOWNLOAD, progress.bytes, total.bytes)) } override fun shouldCancel() = isCanceled diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt index 46aa4f8eb3..9b58baac76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupSubscriptionCheckJob.kt @@ -22,6 +22,7 @@ import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.util.RemoteConfig +import kotlin.concurrent.withLock /** * Checks and rectifies state pertaining to backups subscriptions. @@ -85,7 +86,7 @@ class BackupSubscriptionCheckJob private constructor(parameters: Parameters) : C val purchase: BillingPurchaseResult = AppDependencies.billingApi.queryPurchases() val hasActivePurchase = purchase is BillingPurchaseResult.Success && purchase.isAcknowledged && purchase.isWithinTheLastMonth() - synchronized(InAppPaymentSubscriberRecord.Type.BACKUP) { + InAppPaymentSubscriberRecord.Type.BACKUP.lock.withLock { val inAppPayment = SignalDatabase.inAppPayments.getLatestInAppPaymentByType(InAppPaymentType.RECURRING_BACKUP) if (inAppPayment?.state == InAppPaymentTable.State.PENDING) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt index 108f002f07..ca84584e0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentAuthCheckJob.kt @@ -32,6 +32,7 @@ import org.thoughtcrime.securesms.subscription.LevelUpdate import org.thoughtcrime.securesms.subscription.LevelUpdateOperation import org.thoughtcrime.securesms.util.Environment import org.whispersystems.signalservice.internal.ServiceResponse +import kotlin.concurrent.withLock import kotlin.time.Duration.Companion.days /** @@ -77,7 +78,7 @@ class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : Bas var hasRetry = false for (payment in unauthorizedInAppPayments) { val verificationStatus: CheckResult = if (payment.type.recurring) { - synchronized(payment.type.requireSubscriberType().inAppPaymentType) { + payment.type.requireSubscriberType().lock.withLock { checkRecurringPayment(payment) } } else { @@ -244,7 +245,7 @@ class InAppPaymentAuthCheckJob private constructor(parameters: Parameters) : Bas level, subscriber.currency.currencyCode, updateOperation.idempotencyKey.serialize(), - subscriber.type + subscriber.type.lock ) val updateLevelResult = checkResult(updateLevelResponse) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt index 1c0bc48ccf..fea499de53 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentKeepAliveJob.kt @@ -26,6 +26,7 @@ import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription import org.whispersystems.signalservice.internal.EmptyResponse import org.whispersystems.signalservice.internal.ServiceResponse import java.util.Locale +import kotlin.concurrent.withLock import kotlin.jvm.optionals.getOrNull import kotlin.time.Duration import kotlin.time.Duration.Companion.days @@ -89,7 +90,7 @@ class InAppPaymentKeepAliveJob private constructor( } override fun onRun() { - synchronized(type) { + type.lock.withLock { doRun() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt index fa6b36345f..65b879228f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentPurchaseTokenJob.kt @@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.JobManager.Chain import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import kotlin.concurrent.withLock /** * Submits a purchase token to the server to link it with a subscriber id. @@ -76,7 +77,7 @@ class InAppPaymentPurchaseTokenJob private constructor( } override fun onRun() { - synchronized(InAppPaymentsRepository.resolveMutex(inAppPaymentId)) { + InAppPaymentsRepository.resolveLock(inAppPaymentId).withLock { doRun() } } @@ -87,7 +88,7 @@ class InAppPaymentPurchaseTokenJob private constructor( val response = AppDependencies.donationsService.linkGooglePlayBillingPurchaseTokenToSubscriberId( inAppPayment.subscriberId!!, inAppPayment.data.redemption!!.googlePlayBillingPurchaseToken!!, - InAppPaymentSubscriberRecord.Type.BACKUP + InAppPaymentSubscriberRecord.Type.BACKUP.lock ) if (response.applicationError.isPresent) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt index 1cbb4dfdcc..3081292233 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRecurringContextJob.kt @@ -32,6 +32,7 @@ import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription.Sub import org.whispersystems.signalservice.internal.ServiceResponse import java.io.IOException import java.util.Currency +import kotlin.concurrent.withLock import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -128,7 +129,7 @@ class InAppPaymentRecurringContextJob private constructor( } override fun onRun() { - synchronized(InAppPaymentsRepository.resolveMutex(inAppPaymentId)) { + InAppPaymentsRepository.resolveLock(inAppPaymentId).withLock { doRun() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt index de05a96061..b8b16566fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/InAppPaymentRedemptionJob.kt @@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.util.hasGiftBadge import org.thoughtcrime.securesms.util.requireGiftBadge import org.whispersystems.signalservice.internal.ServiceResponse import java.io.IOException +import kotlin.concurrent.withLock /** * Takes a ReceiptCredentialResponse and submits it to the server for redemption. @@ -181,7 +182,7 @@ class InAppPaymentRedemptionJob private constructor( } if (inAppPayment.type.recurring) { - synchronized(inAppPayment.type.requireSubscriberType()) { + inAppPayment.type.requireSubscriberType().lock.withLock { performInAppPaymentRedemption(inAppPayment) } } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceKeysUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceKeysUpdateJob.java deleted file mode 100644 index ffc49e2719..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceKeysUpdateJob.java +++ /dev/null @@ -1,94 +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.Job; -import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.net.NotPushRegisteredException; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.whispersystems.signalservice.api.SignalServiceMessageSender; -import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException; -import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage; -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.api.storage.StorageKey; - -import java.io.IOException; -import java.util.Optional; - -public class MultiDeviceKeysUpdateJob extends BaseJob { - - public static final String KEY = "MultiDeviceKeysUpdateJob"; - - private static final String TAG = Log.tag(MultiDeviceKeysUpdateJob.class); - - public MultiDeviceKeysUpdateJob() { - this(new Parameters.Builder() - .setQueue("MultiDeviceKeysUpdateJob") - .setMaxInstancesForFactory(2) - .addConstraint(NetworkConstraint.KEY) - .setMaxAttempts(10) - .build()); - - } - - private MultiDeviceKeysUpdateJob(@NonNull Parameters parameters) { - super(parameters); - } - - @Override - public @Nullable byte[] serialize() { - return null; - } - - @Override - public @NonNull String getFactoryKey() { - return KEY; - } - - @Override - public void onRun() throws IOException, UntrustedIdentityException { - if (!Recipient.self().isRegistered()) { - throw new NotPushRegisteredException(); - } - - if (!SignalStore.account().hasLinkedDevices()) { - Log.i(TAG, "Not multi device, aborting..."); - return; - } - - if (SignalStore.account().isLinkedDevice()) { - Log.i(TAG, "Not primary device, aborting..."); - return; - } - - SignalServiceMessageSender messageSender = AppDependencies.getSignalServiceMessageSender(); - StorageKey storageServiceKey = SignalStore.storageService().getOrCreateStorageKey(); - - messageSender.sendSyncMessage(SignalServiceSyncMessage.forKeys(new KeysMessage(Optional.ofNullable(storageServiceKey), Optional.of(SignalStore.svr().getMasterKey()))) - ); - } - - @Override - public boolean onShouldRetry(@NonNull Exception e) { - if (e instanceof ServerRejectedException) return false; - return e instanceof PushNetworkException; - } - - @Override - public void onFailure() { - } - - public static final class Factory implements Job.Factory { - @Override - public @NonNull MultiDeviceKeysUpdateJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) { - return new MultiDeviceKeysUpdateJob(parameters); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceKeysUpdateJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceKeysUpdateJob.kt new file mode 100644 index 0000000000..952dd00dce --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceKeysUpdateJob.kt @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.net.NotPushRegisteredException +import org.thoughtcrime.securesms.recipients.Recipient +import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException +import org.whispersystems.signalservice.api.messages.multidevice.KeysMessage +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 java.io.IOException +import java.util.Optional + +class MultiDeviceKeysUpdateJob private constructor(parameters: Parameters) : BaseJob(parameters) { + + companion object { + const val KEY: String = "MultiDeviceKeysUpdateJob" + + private val TAG = Log.tag(MultiDeviceKeysUpdateJob::class.java) + } + + constructor() : this( + Parameters.Builder() + .setQueue("MultiDeviceKeysUpdateJob") + .setMaxInstancesForFactory(2) + .addConstraint(NetworkConstraint.KEY) + .setMaxAttempts(10) + .build() + ) + + override fun serialize(): ByteArray? = null + + override fun getFactoryKey(): String = KEY + + @Throws(IOException::class, UntrustedIdentityException::class) + public override fun onRun() { + if (!Recipient.self().isRegistered) { + throw NotPushRegisteredException() + } + + if (!SignalStore.account.hasLinkedDevices) { + Log.i(TAG, "Not multi device, aborting...") + return + } + + if (SignalStore.account.isLinkedDevice) { + Log.i(TAG, "Not primary device, aborting...") + return + } + + val syncMessage = SignalServiceSyncMessage.forKeys( + KeysMessage( + Optional.of(SignalStore.storageService.storageKey), + Optional.of(SignalStore.svr.masterKey) + ) + ) + + AppDependencies.signalServiceMessageSender.sendSyncMessage(syncMessage) + } + + public override fun onShouldRetry(e: Exception): Boolean { + if (e is ServerRejectedException) return false + return e is PushNetworkException + } + + override fun onFailure() { + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): MultiDeviceKeysUpdateJob { + return MultiDeviceKeysUpdateJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java index da9bb041d5..5fdc42bafe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java @@ -103,8 +103,8 @@ public void onRun() throws IOException { String deviceName = SignalStore.account().getDeviceName(); byte[] encryptedDeviceName = (deviceName == null) ? null : DeviceNameCipher.encryptDeviceName(deviceName.getBytes(StandardCharsets.UTF_8), SignalStore.account().getAciIdentityKey()); - AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities((svrValues.hasPin() && !svrValues.hasOptedOut()) || SignalStore.storageService().hasStorageKeyFromPrimary()); - Log.i(TAG, "Calling setAccountAttributes() reglockV2? " + !TextUtils.isEmpty(registrationLockV2) + ", pin? " + svrValues.hasPin() + + AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities((svrValues.hasOptedInWithAccess() && !svrValues.hasOptedOut()) || SignalStore.storageService().hasStorageKeyFromPrimary()); + Log.i(TAG, "Calling setAccountAttributes() reglockV2? " + !TextUtils.isEmpty(registrationLockV2) + ", pin? " + svrValues.hasPin() + ", access? " + svrValues.hasOptedInWithAccess() + ", fetchesMessages? " + fetchesMessages + "\n Recovery password? " + !TextUtils.isEmpty(recoveryPassword) + "\n Phone number discoverable : " + phoneNumberDiscoverable + diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java index 81ca9668ad..752d25bd17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java @@ -82,7 +82,7 @@ protected void onRun() throws Exception { return; } - if (SignalStore.svr().hasPin() && !SignalStore.svr().hasOptedOut() && SignalStore.storageService().getLastSyncTime() == 0) { + if (SignalStore.svr().hasOptedInWithAccess() && !SignalStore.svr().hasOptedOut() && SignalStore.storageService().getLastSyncTime() == 0) { Log.i(TAG, "Registered with PIN but haven't completed storage sync yet."); return; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshSvrCredentialsJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshSvrCredentialsJob.kt index 40175201a7..5f8454a6fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshSvrCredentialsJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshSvrCredentialsJob.kt @@ -24,7 +24,7 @@ class RefreshSvrCredentialsJob private constructor(parameters: Parameters) : Bas @JvmStatic fun enqueueIfNecessary() { - if (SignalStore.svr.hasPin() && SignalStore.account.isRegistered) { + if (SignalStore.svr.hasOptedInWithAccess() && SignalStore.account.isRegistered) { val lastTimestamp = SignalStore.svr.lastRefreshAuthTimestamp if (lastTimestamp + FREQUENCY.inWholeMilliseconds < System.currentTimeMillis() || lastTimestamp > System.currentTimeMillis()) { AppDependencies.jobManager.add(RefreshSvrCredentialsJob()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java deleted file mode 100644 index 99e77bea83..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.java +++ /dev/null @@ -1,166 +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.database.SignalDatabase; -import org.thoughtcrime.securesms.dependencies.AppDependencies; -import org.thoughtcrime.securesms.jobmanager.Job; -import org.thoughtcrime.securesms.jobmanager.JobManager; -import org.thoughtcrime.securesms.jobmanager.JobTracker; -import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.profiles.manage.UsernameRepository; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.storage.StorageSyncHelper; -import org.whispersystems.signalservice.api.SignalServiceAccountManager; -import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException; -import org.whispersystems.signalservice.api.storage.SignalAccountRecord; -import org.whispersystems.signalservice.api.storage.SignalStorageManifest; -import org.whispersystems.signalservice.api.storage.SignalStorageRecord; -import org.whispersystems.signalservice.api.storage.StorageId; -import org.whispersystems.signalservice.api.storage.StorageKey; - -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.TimeUnit; - -/** - * Restored the AccountRecord present in the storage service, if any. This will overwrite any local - * data that is stored in AccountRecord, so this should only be done immediately after registration. - */ -public class StorageAccountRestoreJob extends BaseJob { - - public static String KEY = "StorageAccountRestoreJob"; - - public static long LIFESPAN = TimeUnit.SECONDS.toMillis(20); - - private static final String TAG = Log.tag(StorageAccountRestoreJob.class); - - public StorageAccountRestoreJob() { - this(new Parameters.Builder() - .setQueue(StorageSyncJob.QUEUE_KEY) - .addConstraint(NetworkConstraint.KEY) - .setMaxInstancesForFactory(1) - .setMaxAttempts(1) - .setLifespan(LIFESPAN) - .build()); - } - - private StorageAccountRestoreJob(@NonNull Parameters parameters) { - super(parameters); - } - - @Override - public @Nullable byte[] serialize() { - return null; - } - - @Override - public @NonNull String getFactoryKey() { - return KEY; - } - - @Override - protected void onRun() throws Exception { - SignalServiceAccountManager accountManager = AppDependencies.getSignalServiceAccountManager(); - StorageKey storageServiceKey = SignalStore.storageService().getOrCreateStorageKey(); - - Log.i(TAG, "Retrieving manifest..."); - Optional manifest = accountManager.getStorageManifest(storageServiceKey); - - if (!manifest.isPresent()) { - Log.w(TAG, "Manifest did not exist or was undecryptable (bad key). Not restoring. Force-pushing."); - AppDependencies.getJobManager().add(new StorageForcePushJob()); - return; - } - - Log.i(TAG, "Resetting the local manifest to an empty state so that it will sync later."); - SignalStore.storageService().setManifest(SignalStorageManifest.EMPTY); - - Optional accountId = manifest.get().getAccountStorageId(); - - if (!accountId.isPresent()) { - Log.w(TAG, "Manifest had no account record! Not restoring."); - return; - } - - Log.i(TAG, "Retrieving account record..."); - List records = accountManager.readStorageRecords(storageServiceKey, Collections.singletonList(accountId.get())); - SignalStorageRecord record = records.size() > 0 ? records.get(0) : null; - - if (record == null) { - Log.w(TAG, "Could not find account record, even though we had an ID! Not restoring."); - return; - } - - SignalAccountRecord accountRecord = record.getAccount().orElse(null); - if (accountRecord == null) { - Log.w(TAG, "The storage record didn't actually have an account on it! Not restoring."); - return; - } - - - Log.i(TAG, "Applying changes locally..."); - SignalDatabase.getRawDatabase().beginTransaction(); - try { - StorageSyncHelper.applyAccountStorageSyncUpdates(context, Recipient.self().fresh(), accountRecord, false); - SignalDatabase.getRawDatabase().setTransactionSuccessful(); - } finally { - SignalDatabase.getRawDatabase().endTransaction(); - } - - // We will try to reclaim the username here, as early as possible, but the registration flow also enqueues a username restore job, - // so failing here isn't a huge deal - if (SignalStore.account().getUsername() != null) { - Log.i(TAG, "Attempting to reclaim username..."); - UsernameRepository.UsernameReclaimResult result = UsernameRepository.reclaimUsernameIfNecessary(); - Log.i(TAG, "Username reclaim result: " + result.name()); - } else { - Log.i(TAG, "No username to reclaim."); - } - - JobManager jobManager = AppDependencies.getJobManager(); - - if (accountRecord.getAvatarUrlPath().isPresent()) { - Log.i(TAG, "Fetching avatar..."); - Optional state = jobManager.runSynchronously(new RetrieveProfileAvatarJob(Recipient.self(), accountRecord.getAvatarUrlPath().get()), LIFESPAN/2); - - if (state.isPresent()) { - Log.i(TAG, "Avatar retrieved successfully. " + state.get()); - } else { - Log.w(TAG, "Avatar retrieval did not complete in time (or otherwise failed)."); - } - } else { - Log.i(TAG, "No avatar present. Not fetching."); - } - - Log.i(TAG, "Refreshing attributes..."); - Optional state = jobManager.runSynchronously(new RefreshAttributesJob(), LIFESPAN/2); - - if (state.isPresent()) { - Log.i(TAG, "Attributes refreshed successfully. " + state.get()); - } else { - Log.w(TAG, "Attribute refresh did not complete in time (or otherwise failed)."); - } - } - - @Override - protected boolean onShouldRetry(@NonNull Exception e) { - return e instanceof PushNetworkException; - } - - @Override - public void onFailure() { - } - - public static class Factory implements Job.Factory { - @Override - public @NonNull - StorageAccountRestoreJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) { - return new StorageAccountRestoreJob(parameters); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.kt new file mode 100644 index 0000000000..7940ea3a3a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageAccountRestoreJob.kt @@ -0,0 +1,138 @@ +package org.thoughtcrime.securesms.jobs + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.profiles.manage.UsernameRepository.reclaimUsernameIfNecessary +import org.thoughtcrime.securesms.recipients.Recipient.Companion.self +import org.thoughtcrime.securesms.storage.StorageSyncHelper.applyAccountStorageSyncUpdates +import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException +import org.whispersystems.signalservice.api.storage.SignalAccountRecord +import org.whispersystems.signalservice.api.storage.SignalStorageManifest +import java.util.concurrent.TimeUnit + +/** + * Restored the AccountRecord present in the storage service, if any. This will overwrite any local + * data that is stored in AccountRecord, so this should only be done immediately after registration. + */ +class StorageAccountRestoreJob private constructor(parameters: Parameters) : BaseJob(parameters) { + companion object { + const val KEY: String = "StorageAccountRestoreJob" + + val LIFESPAN: Long = TimeUnit.SECONDS.toMillis(20) + + private val TAG = Log.tag(StorageAccountRestoreJob::class.java) + } + + constructor() : this( + Parameters.Builder() + .setQueue(StorageSyncJob.QUEUE_KEY) + .addConstraint(NetworkConstraint.KEY) + .setMaxInstancesForFactory(1) + .setMaxAttempts(1) + .setLifespan(LIFESPAN) + .build() + ) + + override fun serialize(): ByteArray? = null + + override fun getFactoryKey(): String = KEY + + @Throws(Exception::class) + override fun onRun() { + val accountManager = AppDependencies.signalServiceAccountManager + val storageServiceKey = SignalStore.storageService.storageKey + + Log.i(TAG, "Retrieving manifest...") + val manifest = accountManager.getStorageManifest(storageServiceKey) + + if (!manifest.isPresent) { + Log.w(TAG, "Manifest did not exist or was undecryptable (bad key). Not restoring. Force-pushing.") + AppDependencies.jobManager.add(StorageForcePushJob()) + return + } + + Log.i(TAG, "Resetting the local manifest to an empty state so that it will sync later.") + SignalStore.storageService.manifest = SignalStorageManifest.EMPTY + + val accountId = manifest.get().accountStorageId + + if (!accountId.isPresent) { + Log.w(TAG, "Manifest had no account record! Not restoring.") + return + } + + Log.i(TAG, "Retrieving account record...") + val records = accountManager.readStorageRecords(storageServiceKey, listOf(accountId.get())) + val record = if (records.size > 0) records[0] else null + + if (record == null) { + Log.w(TAG, "Could not find account record, even though we had an ID! Not restoring.") + return + } + + if (record.proto.account == null) { + Log.w(TAG, "The storage record didn't actually have an account on it! Not restoring.") + return + } + + val accountRecord = SignalAccountRecord(record.id, record.proto.account!!) + + Log.i(TAG, "Applying changes locally...") + SignalDatabase.rawDatabase.beginTransaction() + try { + applyAccountStorageSyncUpdates(context, self().fresh(), accountRecord, false) + SignalDatabase.rawDatabase.setTransactionSuccessful() + } finally { + SignalDatabase.rawDatabase.endTransaction() + } + + // We will try to reclaim the username here, as early as possible, but the registration flow also enqueues a username restore job, + // so failing here isn't a huge deal + if (SignalStore.account.username != null) { + Log.i(TAG, "Attempting to reclaim username...") + val result = reclaimUsernameIfNecessary() + Log.i(TAG, "Username reclaim result: " + result.name) + } else { + Log.i(TAG, "No username to reclaim.") + } + + if (accountRecord.proto.avatarUrlPath.isNotEmpty()) { + Log.i(TAG, "Fetching avatar...") + val state = AppDependencies.jobManager.runSynchronously(RetrieveProfileAvatarJob(self(), accountRecord.proto.avatarUrlPath), LIFESPAN / 2) + + if (state.isPresent) { + Log.i(TAG, "Avatar retrieved successfully. ${state.get()}") + } else { + Log.w(TAG, "Avatar retrieval did not complete in time (or otherwise failed).") + } + } else { + Log.i(TAG, "No avatar present. Not fetching.") + } + + Log.i(TAG, "Refreshing attributes...") + val state = AppDependencies.jobManager.runSynchronously(RefreshAttributesJob(), LIFESPAN / 2) + + if (state.isPresent) { + Log.i(TAG, "Attributes refreshed successfully. ${state.get()}") + } else { + Log.w(TAG, "Attribute refresh did not complete in time (or otherwise failed).") + } + } + + override fun onShouldRetry(e: Exception): Boolean { + return e is PushNetworkException + } + + override fun onFailure() { + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): StorageAccountRestoreJob { + return StorageAccountRestoreJob(parameters) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.kt index 2b405293cf..4bcd89c6c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageForcePushJob.kt @@ -61,7 +61,7 @@ class StorageForcePushJob private constructor(parameters: Parameters) : BaseJob( return } - val storageServiceKey = SignalStore.storageService.getOrCreateStorageKey() + val storageServiceKey = SignalStore.storageService.storageKey val accountManager = AppDependencies.signalServiceAccountManager val currentVersion = accountManager.storageManifestVersion diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt index 9f31667338..cfcb810e60 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt @@ -37,6 +37,12 @@ import org.whispersystems.signalservice.api.storage.SignalStorageManifest import org.whispersystems.signalservice.api.storage.SignalStorageRecord import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.api.storage.toSignalAccountRecord +import org.whispersystems.signalservice.api.storage.toSignalCallLinkRecord +import org.whispersystems.signalservice.api.storage.toSignalContactRecord +import org.whispersystems.signalservice.api.storage.toSignalGroupV1Record +import org.whispersystems.signalservice.api.storage.toSignalGroupV2Record +import org.whispersystems.signalservice.api.storage.toSignalStoryDistributionListRecord import org.whispersystems.signalservice.internal.push.SyncMessage import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord import java.io.IOException @@ -100,13 +106,11 @@ import java.util.stream.Collectors * - Update the respective model (i.e. [SignalContactRecord]) * - Add getters * - Update the builder - * - Update [SignalRecord.describeDiff]. - * - Update the respective record processor (i.e [ContactRecordProcessor]). You need to make - * sure that you're: - * - Merging the attributes, likely preferring remote - * - Adding to doParamsMatch() - * - Adding the parameter to the builder chain when creating a merged model - * - Update builder usage in StorageSyncModels + * - Update the respective record processor (i.e [ContactRecordProcessor]). You need to make sure that you're: + * - Merging the attributes, likely preferring remote + * - Adding to doParamsMatch() + * - Adding the parameter to the builder chain when creating a merged model + * - Update builder usage in StorageSyncModels * - Handle the new data when writing to the local storage * (i.e. [RecipientTable.applyStorageSyncContactUpdate]). * - Make sure that whenever you change the field in the UI, we rotate the storageId for that row @@ -139,7 +143,7 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param @Throws(IOException::class, RetryLaterException::class, UntrustedIdentityException::class) override fun onRun() { - if (!SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut() && !SignalStore.storageService.hasStorageKeyFromPrimary()) { + if (!SignalStore.svr.hasOptedInWithAccess() && !SignalStore.svr.hasOptedOut() && !SignalStore.storageService.hasStorageKeyFromPrimary()) { Log.i(TAG, "Doesn't have a PIN. Skipping.") return } @@ -166,7 +170,7 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param AppDependencies.jobManager.add(MultiDeviceStorageSyncRequestJob()) } - SignalStore.storageService.onSyncCompleted() + SignalStore.storageService.lastSyncTime = System.currentTimeMillis() } catch (e: InvalidKeyException) { if (SignalStore.account.isPrimaryDevice) { Log.w(TAG, "Failed to decrypt remote storage! Force-pushing and syncing the storage key to linked devices.", e) @@ -196,7 +200,7 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param val stopwatch = Stopwatch("StorageSync") val db = SignalDatabase.rawDatabase val accountManager = AppDependencies.signalServiceAccountManager - val storageServiceKey = SignalStore.storageService.getOrCreateStorageKey() + val storageServiceKey = SignalStore.storageService.storageKey val localManifest = SignalStore.storageService.manifest val remoteManifest = accountManager.getStorageManifestIfDifferentVersion(storageServiceKey, localManifest.version).orElse(localManifest) @@ -221,14 +225,14 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param var localStorageIdsBeforeMerge = getAllLocalStorageIds(self) var idDifference = StorageSyncHelper.findIdDifference(remoteManifest.storageIds, localStorageIdsBeforeMerge) - if (idDifference.hasTypeMismatches() && SignalStore.account.isPrimaryDevice) { + if (idDifference.hasTypeMismatches && SignalStore.account.isPrimaryDevice) { Log.w(TAG, "[Remote Sync] Found type mismatches in the ID sets! Scheduling a force push after this sync completes.") needsForcePush = true } Log.i(TAG, "[Remote Sync] Pre-Merge ID Difference :: $idDifference") - if (idDifference.localOnlyIds.size > 0) { + if (idDifference.localOnlyIds.isNotEmpty()) { val updated = SignalDatabase.recipients.removeStorageIdsFromLocalOnlyUnregisteredRecipients(idDifference.localOnlyIds) if (updated > 0) { @@ -368,14 +372,14 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param @Throws(IOException::class) private fun processKnownRecords(context: Context, records: StorageRecordCollection) { ContactRecordProcessor().process(records.contacts, StorageSyncHelper.KEY_GENERATOR) - GroupV1RecordProcessor(context).process(records.gv1, StorageSyncHelper.KEY_GENERATOR) - GroupV2RecordProcessor(context).process(records.gv2, StorageSyncHelper.KEY_GENERATOR) + GroupV1RecordProcessor().process(records.gv1, StorageSyncHelper.KEY_GENERATOR) + GroupV2RecordProcessor().process(records.gv2, StorageSyncHelper.KEY_GENERATOR) AccountRecordProcessor(context, freshSelf()).process(records.account, StorageSyncHelper.KEY_GENERATOR) StoryDistributionListRecordProcessor().process(records.storyDistributionLists, StorageSyncHelper.KEY_GENERATOR) CallLinkRecordProcessor().process(records.callLinkRecords, StorageSyncHelper.KEY_GENERATOR) } - private fun getAllLocalStorageIds(self: Recipient): List { + private fun getAllLocalStorageIds(self: Recipient): List { return SignalDatabase.recipients.getContactStorageSyncIds() + listOf(StorageId.forAccount(self.storageId)) + SignalDatabase.unknownStorageIds.allUnknownIds @@ -477,18 +481,18 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param init { for (record in records) { - if (record.contact.isPresent) { - contacts += record.contact.get() - } else if (record.groupV1.isPresent) { - gv1 += record.groupV1.get() - } else if (record.groupV2.isPresent) { - gv2 += record.groupV2.get() - } else if (record.account.isPresent) { - account += record.account.get() - } else if (record.storyDistributionList.isPresent) { - storyDistributionLists += record.storyDistributionList.get() - } else if (record.callLink.isPresent) { - callLinkRecords += record.callLink.get() + if (record.proto.contact != null) { + contacts += record.proto.contact!!.toSignalContactRecord(record.id) + } else if (record.proto.groupV1 != null) { + gv1 += record.proto.groupV1!!.toSignalGroupV1Record(record.id) + } else if (record.proto.groupV2 != null) { + gv2 += record.proto.groupV2!!.toSignalGroupV2Record(record.id) + } else if (record.proto.account != null) { + account += record.proto.account!!.toSignalAccountRecord(record.id) + } else if (record.proto.storyDistributionList != null) { + storyDistributionLists += record.proto.storyDistributionList!!.toSignalStoryDistributionListRecord(record.id) + } else if (record.proto.callLink != null) { + callLinkRecords += record.proto.callLink!!.toSignalCallLinkRecord(record.id) } else if (record.id.isUnknown) { unknown += record } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/SyncSystemContactLinksJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/SyncSystemContactLinksJob.kt index ef5a7dffc6..d548dbb88a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/SyncSystemContactLinksJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/SyncSystemContactLinksJob.kt @@ -83,6 +83,8 @@ class SyncSystemContactLinksJob private constructor(parameters: Parameters) : Ba Log.w(TAG, "[addSystemContactLinks] Failed to add links to contacts.", e) } catch (e: OperationApplicationException) { Log.w(TAG, "[addSystemContactLinks] Failed to add links to contacts.", e) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "[addSystemContactLinks] Failed to add links to contacts.", e) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt index a9fe3a28b5..207eeae2c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.keyvalue import android.content.Context import org.signal.core.util.Base64 import org.signal.core.util.logging.Log +import org.signal.core.util.nullIfBlank import org.signal.libsignal.protocol.IdentityKey import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.libsignal.protocol.ecc.Curve @@ -404,10 +405,10 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) var username: String? get() { val value = getString(KEY_USERNAME, null) - return if (value.isNullOrBlank()) null else value + return value.nullIfBlank() } set(value) { - putString(KEY_USERNAME, value) + putString(KEY_USERNAME, value.nullIfBlank()) } /** The local user's username link components, if set. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt index b21ccfd312..c42edcb9d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt @@ -154,7 +154,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) { } /** - * When uploading a backup, we store the progress state here so that I can remain across app restarts. + * When uploading a backup, we store the progress state here so that it can remain across app restarts. */ var archiveUploadState: ArchiveUploadProgressState? by protoValue(KEY_ARCHIVE_UPLOAD_STATE, ArchiveUploadProgressState.ADAPTER) diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InAppPaymentValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InAppPaymentValues.kt index 6361dcf0b5..e25a738def 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InAppPaymentValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InAppPaymentValues.kt @@ -33,6 +33,7 @@ import java.util.Currency import java.util.Locale import java.util.Optional import java.util.concurrent.TimeUnit +import kotlin.concurrent.withLock /** * Key-Value store for in app payment related values. Note that most of this file will be deprecated after the release of @@ -451,7 +452,7 @@ class InAppPaymentValues internal constructor(store: KeyValueStore) : SignalStor */ @WorkerThread fun updateLocalStateForManualCancellation(subscriberType: InAppPaymentSubscriberRecord.Type) { - synchronized(subscriberType) { + subscriberType.lock.withLock { Log.d(TAG, "[updateLocalStateForManualCancellation] Clearing donation values.") clearLevelOperations() @@ -495,7 +496,7 @@ class InAppPaymentValues internal constructor(store: KeyValueStore) : SignalStor */ @WorkerThread fun updateLocalStateForLocalSubscribe(subscriberType: InAppPaymentSubscriberRecord.Type) { - synchronized(subscriberType) { + subscriberType.lock.withLock { clearLevelOperations() if (subscriberType == InAppPaymentSubscriberRecord.Type.DONATION) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LocalRegistrationMetadataSerializer.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LocalRegistrationMetadataSerializer.kt deleted file mode 100644 index 9106bc20cc..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/LocalRegistrationMetadataSerializer.kt +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.keyvalue - -import org.signal.core.util.ByteSerializer -import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata - -/** - * Serialize [LocalRegistrationMetadata] - */ -object LocalRegistrationMetadataSerializer : ByteSerializer { - override fun serialize(data: LocalRegistrationMetadata): ByteArray = data.encode() - override fun deserialize(data: ByteArray): LocalRegistrationMetadata = LocalRegistrationMetadata.ADAPTER.decode(data) -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt index 7be13f4574..4921cef998 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt @@ -217,7 +217,8 @@ class PaymentsValues internal constructor(store: KeyValueStore) : SignalStoreVal fun showUpdatePinInfoCard(): Boolean { return if (userHasLargeBalance() && SignalStore.svr.hasPin() && - !SignalStore.svr.hasOptedOut() && SignalStore.pin.keyboardType == PinKeyboardType.NUMERIC + !SignalStore.svr.hasOptedOut() && + SignalStore.pin.keyboardType == PinKeyboardType.NUMERIC ) { store.getBoolean(SHOW_CASHING_OUT_INFO_CARD, true) } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java deleted file mode 100644 index 7f64139092..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java +++ /dev/null @@ -1,124 +0,0 @@ -package org.thoughtcrime.securesms.keyvalue; - -import androidx.annotation.CheckResult; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata; - -import java.util.Collections; -import java.util.List; - -public final class RegistrationValues extends SignalStoreValues { - - private static final String REGISTRATION_COMPLETE = "registration.complete"; - private static final String PIN_REQUIRED = "registration.pin_required"; - private static final String HAS_UPLOADED_PROFILE = "registration.has_uploaded_profile"; - private static final String SESSION_E164 = "registration.session_e164"; - private static final String SESSION_ID = "registration.session_id"; - private static final String SKIPPED_TRANSFER_OR_RESTORE = "registration.has_skipped_transfer_or_restore"; - private static final String LOCAL_REGISTRATION_DATA = "registration.local_registration_data"; - private static final String RESTORE_COMPLETED = "registration.backup_restore_completed"; - - RegistrationValues(@NonNull KeyValueStore store) { - super(store); - } - - public synchronized void onFirstEverAppLaunch() { - getStore().beginWrite() - .putBoolean(HAS_UPLOADED_PROFILE, false) - .putBoolean(REGISTRATION_COMPLETE, false) - .putBoolean(PIN_REQUIRED, true) - .putBoolean(SKIPPED_TRANSFER_OR_RESTORE, false) - .commit(); - } - - @Override - @NonNull List getKeysToIncludeInBackup() { - return Collections.emptyList(); - } - - public synchronized void clearRegistrationComplete() { - onFirstEverAppLaunch(); - } - - public synchronized void setRegistrationComplete() { - getStore().beginWrite() - .putBoolean(REGISTRATION_COMPLETE, true) - .commit(); - } - - @CheckResult - public synchronized boolean pinWasRequiredAtRegistration() { - return getStore().getBoolean(PIN_REQUIRED, false); - } - - @CheckResult - public synchronized boolean isRegistrationComplete() { - return getStore().getBoolean(REGISTRATION_COMPLETE, true); - } - - - public void setLocalRegistrationMetadata(LocalRegistrationMetadata data) { - putObject(LOCAL_REGISTRATION_DATA, data, LocalRegistrationMetadataSerializer.INSTANCE); - } - - @Nullable - public LocalRegistrationMetadata getLocalRegistrationMetadata() { - return getObject(LOCAL_REGISTRATION_DATA, null, LocalRegistrationMetadataSerializer.INSTANCE); - } - - public void clearLocalRegistrationMetadata() { - remove(LOCAL_REGISTRATION_DATA); - } - - public boolean hasUploadedProfile() { - return getBoolean(HAS_UPLOADED_PROFILE, true); - } - - public void markHasUploadedProfile() { - putBoolean(HAS_UPLOADED_PROFILE, true); - } - - public void clearHasUploadedProfile() { - putBoolean(HAS_UPLOADED_PROFILE, false); - } - - public void setSessionId(String sessionId) { - putString(SESSION_ID, sessionId); - } - - public boolean hasSkippedTransferOrRestore() { - return getBoolean(SKIPPED_TRANSFER_OR_RESTORE, false); - } - - public void markSkippedTransferOrRestore() { - putBoolean(SKIPPED_TRANSFER_OR_RESTORE, true); - } - - public void clearSkippedTransferOrRestore() { - putBoolean(SKIPPED_TRANSFER_OR_RESTORE, false); - } - - @Nullable - public String getSessionId() { - return getString(SESSION_ID, null); - } - - public void setSessionE164(String sessionE164) { - putString(SESSION_E164, sessionE164); - } - - @Nullable - public String getSessionE164() { - return getString(SESSION_E164, null); - } - - public boolean hasCompletedRestore() { - return getBoolean(RESTORE_COMPLETED, false); - } - - public void markRestoreCompleted() { - putBoolean(RESTORE_COMPLETED, true); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt new file mode 100644 index 0000000000..f33ea9a642 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt @@ -0,0 +1,87 @@ +package org.thoughtcrime.securesms.keyvalue + +import androidx.annotation.CheckResult +import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata + +class RegistrationValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) { + + companion object { + private const val REGISTRATION_COMPLETE = "registration.complete" + private const val PIN_REQUIRED = "registration.pin_required" + private const val HAS_UPLOADED_PROFILE = "registration.has_uploaded_profile" + private const val SESSION_E164 = "registration.session_e164" + private const val SESSION_ID = "registration.session_id" + private const val SKIPPED_TRANSFER_OR_RESTORE = "registration.has_skipped_transfer_or_restore" + private const val LOCAL_REGISTRATION_DATA = "registration.local_registration_data" + private const val RESTORE_COMPLETED = "registration.backup_restore_completed" + private const val RESTORE_METHOD_TOKEN = "registration.restore_method_token" + private const val RESTORING_ON_NEW_DEVICE = "registration.restoring_on_new_device" + } + + @Synchronized + public override fun onFirstEverAppLaunch() { + store + .beginWrite() + .putBoolean(HAS_UPLOADED_PROFILE, false) + .putBoolean(REGISTRATION_COMPLETE, false) + .putBoolean(PIN_REQUIRED, true) + .putBoolean(SKIPPED_TRANSFER_OR_RESTORE, false) + .commit() + } + + public override fun getKeysToIncludeInBackup(): List = emptyList() + + @Synchronized + fun clearRegistrationComplete() { + onFirstEverAppLaunch() + } + + @Synchronized + fun markRegistrationComplete() { + store + .beginWrite() + .putBoolean(REGISTRATION_COMPLETE, true) + .commit() + } + + @CheckResult + @Synchronized + fun pinWasRequiredAtRegistration(): Boolean { + return store.getBoolean(PIN_REQUIRED, false) + } + + @get:Synchronized + @get:CheckResult + val isRegistrationComplete: Boolean by booleanValue(REGISTRATION_COMPLETE, true) + + var localRegistrationMetadata: LocalRegistrationMetadata? by protoValue(LOCAL_REGISTRATION_DATA, LocalRegistrationMetadata.ADAPTER) + + @get:JvmName("hasUploadedProfile") + var hasUploadedProfile: Boolean by booleanValue(HAS_UPLOADED_PROFILE, true) + var sessionId: String? by stringValue(SESSION_ID, null) + var sessionE164: String? by stringValue(SESSION_E164, null) + var restoreMethodToken: String? by stringValue(RESTORE_METHOD_TOKEN, null) + + @get:JvmName("isRestoringOnNewDevice") + var restoringOnNewDevice: Boolean by booleanValue(RESTORING_ON_NEW_DEVICE, false) + + fun hasSkippedTransferOrRestore(): Boolean { + return getBoolean(SKIPPED_TRANSFER_OR_RESTORE, false) + } + + fun markSkippedTransferOrRestore() { + putBoolean(SKIPPED_TRANSFER_OR_RESTORE, true) + } + + fun debugClearSkippedTransferOrRestore() { + putBoolean(SKIPPED_TRANSFER_OR_RESTORE, false) + } + + fun hasCompletedRestore(): Boolean { + return getBoolean(RESTORE_COMPLETED, false) + } + + fun markRestoreCompleted() { + putBoolean(RESTORE_COMPLETED, true) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java deleted file mode 100644 index c7f72a9e44..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.java +++ /dev/null @@ -1,82 +0,0 @@ -package org.thoughtcrime.securesms.keyvalue; - -import androidx.annotation.NonNull; - -import org.whispersystems.signalservice.api.storage.SignalStorageManifest; -import org.whispersystems.signalservice.api.storage.StorageKey; -import org.whispersystems.signalservice.api.util.Preconditions; - -import java.util.Collections; -import java.util.List; - -public class StorageServiceValues extends SignalStoreValues { - - private static final String LAST_SYNC_TIME = "storage.last_sync_time"; - private static final String NEEDS_ACCOUNT_RESTORE = "storage.needs_account_restore"; - private static final String MANIFEST = "storage.manifest"; - private static final String SYNC_STORAGE_KEY = "storage.syncStorageKey"; - - StorageServiceValues(@NonNull KeyValueStore store) { - super(store); - } - - @Override - void onFirstEverAppLaunch() { - } - - @Override - @NonNull List getKeysToIncludeInBackup() { - return Collections.emptyList(); - } - - public synchronized StorageKey getOrCreateStorageKey() { - if (getStore().containsKey(SYNC_STORAGE_KEY)) { - return new StorageKey(getBlob(SYNC_STORAGE_KEY, null)); - } - return SignalStore.svr().getMasterKey().deriveStorageServiceKey(); - } - - public long getLastSyncTime() { - return getLong(LAST_SYNC_TIME, 0); - } - - public void onSyncCompleted() { - putLong(LAST_SYNC_TIME, System.currentTimeMillis()); - } - - public boolean needsAccountRestore() { - return getBoolean(NEEDS_ACCOUNT_RESTORE, false); - } - - public void setNeedsAccountRestore(boolean value) { - putBoolean(NEEDS_ACCOUNT_RESTORE, value); - } - - public void setManifest(@NonNull SignalStorageManifest manifest) { - putBlob(MANIFEST, manifest.serialize()); - } - - public @NonNull SignalStorageManifest getManifest() { - byte[] data = getBlob(MANIFEST, null); - - if (data != null) { - return SignalStorageManifest.deserialize(data); - } else { - return SignalStorageManifest.EMPTY; - } - } - - public synchronized boolean hasStorageKeyFromPrimary() { - return SignalStore.account().isLinkedDevice() && getStore().containsKey(SYNC_STORAGE_KEY); - } - - public synchronized void setStorageKeyFromPrimary(@NonNull StorageKey storageKey) { - Preconditions.checkState(SignalStore.account().isLinkedDevice(), "Can only set storage key directly on linked devices"); - putBlob(SYNC_STORAGE_KEY, storageKey.serialize()); - } - - public void clearStorageKeyFromPrimary() { - Preconditions.checkState(SignalStore.account().isLinkedDevice(), "Can only clear storage key directly on linked devices"); - remove(SYNC_STORAGE_KEY); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.kt new file mode 100644 index 0000000000..7cc0de06b8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/StorageServiceValues.kt @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.keyvalue + +import org.whispersystems.signalservice.api.storage.SignalStorageManifest +import org.whispersystems.signalservice.api.storage.StorageKey +import org.whispersystems.signalservice.api.util.Preconditions + +class StorageServiceValues internal constructor(store: KeyValueStore) : SignalStoreValues(store) { + companion object { + private const val LAST_SYNC_TIME = "storage.last_sync_time" + private const val NEEDS_ACCOUNT_RESTORE = "storage.needs_account_restore" + private const val MANIFEST = "storage.manifest" + private const val SYNC_STORAGE_KEY = "storage.syncStorageKey" + } + + public override fun onFirstEverAppLaunch() = Unit + + public override fun getKeysToIncludeInBackup(): List = emptyList() + + @get:Synchronized + val storageKey: StorageKey + get() { + if (store.containsKey(SYNC_STORAGE_KEY)) { + return StorageKey(getBlob(SYNC_STORAGE_KEY, null)) + } + return SignalStore.svr.masterKey.deriveStorageServiceKey() + } + + @Synchronized + fun setStorageKeyFromPrimary(storageKey: StorageKey) { + Preconditions.checkState(SignalStore.account.isLinkedDevice, "Can only set storage key directly on linked devices") + putBlob(SYNC_STORAGE_KEY, storageKey.serialize()) + } + + @Synchronized + fun clearStorageKeyFromPrimary() { + Preconditions.checkState(SignalStore.account.isLinkedDevice, "Can only clear storage key directly on linked devices") + remove(SYNC_STORAGE_KEY) + } + + @Synchronized + fun hasStorageKeyFromPrimary(): Boolean { + return SignalStore.account.isLinkedDevice && store.containsKey(SYNC_STORAGE_KEY) + } + + var lastSyncTime: Long by longValue(LAST_SYNC_TIME, 0) + + var needsAccountRestore: Boolean by booleanValue(NEEDS_ACCOUNT_RESTORE, false) + + var manifest: SignalStorageManifest + get() { + val data = getBlob(MANIFEST, null) + + return if (data != null) { + SignalStorageManifest.deserialize(data) + } else { + SignalStorageManifest.EMPTY + } + } + set(manifest) { + putBlob(MANIFEST, manifest.serialize()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SvrValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SvrValues.kt index 53dd94191b..788a26dd8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SvrValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SvrValues.kt @@ -25,6 +25,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s private const val SVR2_AUTH_TOKENS = "kbs.kbs_auth_tokens" private const val SVR_LAST_AUTH_REFRESH_TIMESTAMP = "kbs.kbs_auth_tokens.last_refresh_timestamp" private const val SVR3_AUTH_TOKENS = "kbs.svr3_auth_tokens" + private const val RESTORED_VIA_ACCOUNT_ENTROPY_KEY = "kbs.restore_via_account_entropy_pool" } public override fun onFirstEverAppLaunch() = Unit @@ -52,14 +53,22 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s } @Synchronized - fun setMasterKey(masterKey: MasterKey, pin: String) { - store.beginWrite() - .putBlob(MASTER_KEY, masterKey.serialize()) - .putString(LOCK_LOCAL_PIN_HASH, localPinHash(pin)) - .putString(PIN, pin) - .putLong(LAST_CREATE_FAILED_TIMESTAMP, -1) - .putBoolean(OPTED_OUT, false) - .commit() + fun setMasterKey(masterKey: MasterKey, pin: String?) { + store.beginWrite().apply { + putBlob(MASTER_KEY, masterKey.serialize()) + putLong(LAST_CREATE_FAILED_TIMESTAMP, -1) + putBoolean(OPTED_OUT, false) + + if (pin != null) { + putString(LOCK_LOCAL_PIN_HASH, localPinHash(pin)) + putString(PIN, pin) + remove(RESTORED_VIA_ACCOUNT_ENTROPY_KEY) + } else { + putBoolean(RESTORED_VIA_ACCOUNT_ENTROPY_KEY, true) + remove(LOCK_LOCAL_PIN_HASH) + remove(PIN) + } + }.commit() } @Synchronized @@ -85,9 +94,9 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s return getLong(LAST_CREATE_FAILED_TIMESTAMP, -1) > 0 } + /** Returns the Master Key, lazily creating one if needed. */ @get:Synchronized val masterKey: MasterKey - /** Returns the Master Key, lazily creating one if needed. */ get() { val blob = store.getBlob(MASTER_KEY, null) if (blob != null) { @@ -123,7 +132,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s val recoveryPassword: String? get() { val masterKey = rawMasterKey - return if (masterKey != null && hasPin()) { + return if (masterKey != null && hasOptedInWithAccess()) { masterKey.deriveRegistrationRecoveryPassword() } else { null @@ -136,11 +145,19 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s @get:Synchronized val localPinHash: String? by stringValue(LOCK_LOCAL_PIN_HASH, null) + @Synchronized + fun hasOptedInWithAccess(): Boolean { + return hasPin() || restoredViaAccountEntropyPool + } + @Synchronized fun hasPin(): Boolean { return localPinHash != null } + @get:Synchronized + val restoredViaAccountEntropyPool by booleanValue(RESTORED_VIA_ACCOUNT_ENTROPY_KEY, false) + @get:Synchronized @set:Synchronized var isPinForgottenOrSkipped: Boolean by booleanValue(PIN_FORGOTTEN_OR_SKIPPED, false) @@ -229,6 +246,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s .putBlob(MASTER_KEY, MasterKey.createNew(SecureRandom()).serialize()) .remove(LOCK_LOCAL_PIN_HASH) .remove(PIN) + .remove(RESTORED_VIA_ACCOUNT_ENTROPY_KEY) .putLong(LAST_CREATE_FAILED_TIMESTAMP, -1) .commit() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/BaseSvrPinFragment.java b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/BaseSvrPinFragment.java index 158df96247..67a09d01a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/BaseSvrPinFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/BaseSvrPinFragment.java @@ -101,7 +101,7 @@ public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflat @Override public void onPrepareOptionsMenu(@NonNull Menu menu) { if (SignalStore.svr().isRegistrationLockEnabled() || - SignalStore.svr().hasPin() || + SignalStore.svr().hasOptedInWithAccess() || SignalStore.svr().hasOptedOut()) { menu.clear(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/SvrSplashFragment.java b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/SvrSplashFragment.java index 87a3038518..88b0317c3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/SvrSplashFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/SvrSplashFragment.java @@ -115,7 +115,7 @@ private void setUpRegLockDisabled() { private void onCreatePin() { SvrSplashFragmentDirections.ActionCreateKbsPin action = SvrSplashFragmentDirections.actionCreateKbsPin(); - action.setIsPinChange(SignalStore.svr().hasPin()); + action.setIsPinChange(SignalStore.svr().hasOptedInWithAccess()); SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), action); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionPin.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionPin.java index 2949ee3fb3..10e500ce4a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionPin.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionPin.java @@ -19,9 +19,10 @@ public class LogSectionPin implements LogSection { .append("Next Reminder Interval: ").append(SignalStore.pin().getCurrentInterval()).append("\n") .append("Reglock: ").append(SignalStore.svr().isRegistrationLockEnabled()).append("\n") .append("Signal PIN: ").append(SignalStore.svr().hasPin()).append("\n") + .append("Restored via AEP: ").append(SignalStore.svr().getRestoredViaAccountEntropyPool()).append("\n") .append("Opted Out: ").append(SignalStore.svr().hasOptedOut()).append("\n") .append("Last Creation Failed: ").append(SignalStore.svr().lastPinCreateFailed()).append("\n") - .append("Needs Account Restore: ").append(SignalStore.storageService().needsAccountRestore()).append("\n") + .append("Needs Account Restore: ").append(SignalStore.storageService().getNeedsAccountRestore()).append("\n") .append("PIN Required at Registration: ").append(SignalStore.registration().pinWasRequiredAtRegistration()).append("\n") .append("Registration Complete: ").append(SignalStore.registration().isRegistrationComplete()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureEvent.kt index 13a93919ef..3fd272b8b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureEvent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureEvent.kt @@ -3,9 +3,10 @@ package org.thoughtcrime.securesms.mediasend.v2.capture import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.recipients.Recipient -sealed class MediaCaptureEvent { - data class MediaCaptureRendered(val media: Media) : MediaCaptureEvent() - data class UsernameScannedFromQrCode(val recipient: Recipient, val username: String) : MediaCaptureEvent() - object DeviceLinkScannedFromQrCode : MediaCaptureEvent() - object MediaCaptureRenderFailed : MediaCaptureEvent() +sealed interface MediaCaptureEvent { + data class MediaCaptureRendered(val media: Media) : MediaCaptureEvent + data class UsernameScannedFromQrCode(val recipient: Recipient, val username: String) : MediaCaptureEvent + data object DeviceLinkScannedFromQrCode : MediaCaptureEvent + data object MediaCaptureRenderFailed : MediaCaptureEvent + data class ReregistrationScannedFromQrCode(val data: String) : MediaCaptureEvent } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt index 1d5fec556f..ec9690a67d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt @@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.registrationv3.olddevice.TransferAccountActivity import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.navigation.safeNavigate @@ -67,6 +68,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme Log.w(TAG, "Failed to render captured media.") Toast.makeText(requireContext(), R.string.MediaSendActivity_camera_unavailable, Toast.LENGTH_SHORT).show() } + is MediaCaptureEvent.MediaCaptureRendered -> { if (isFirst()) { sharedViewModel.addCameraFirstCapture(event.media) @@ -76,6 +78,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme navigator.goToReview(findNavController()) } + is MediaCaptureEvent.UsernameScannedFromQrCode -> { MaterialAlertDialogBuilder(requireContext()) .setTitle(getString(R.string.MediaCaptureFragment_username_dialog_title, event.username)) @@ -87,6 +90,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme .setNegativeButton(android.R.string.cancel, null) .show() } + is MediaCaptureEvent.DeviceLinkScannedFromQrCode -> { MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.MediaCaptureFragment_device_link_dialog_title) @@ -98,6 +102,11 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme .setNegativeButton(android.R.string.cancel, null) .show() } + + is MediaCaptureEvent.ReregistrationScannedFromQrCode -> { + startActivity(TransferAccountActivity.intent(requireContext(), event.data)) + requireActivity().finish() + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt index 3db6595108..bba66f93e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt @@ -11,10 +11,11 @@ import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.subjects.PublishSubject import io.reactivex.rxjava3.subjects.Subject import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.QrScanResult +import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.profiles.manage.UsernameRepository import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository import org.thoughtcrime.securesms.util.rx.RxStore import java.io.FileDescriptor import java.util.Optional @@ -71,6 +72,15 @@ class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : Vi .subscribe { data -> internalEvents.onNext(MediaCaptureEvent.DeviceLinkScannedFromQrCode) } + + if (SignalStore.account.isRegistered) { + disposables += qrData + .throttleFirst(5, TimeUnit.SECONDS) + .filter { it.startsWith("sgnl://rereg") && QuickRegistrationRepository.isValidReRegistrationQr(it) } + .subscribe { data -> + internalEvents.onNext(MediaCaptureEvent.ReregistrationScannedFromQrCode(data)) + } + } } override fun onCleared() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt index f5380db64d..ea6e954939 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt @@ -191,6 +191,8 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul if (keys.isNotEmpty()) { Log.d(TAG, "Performing send from multi-select activity result.") performSend(keys) + } else { + readyToSend = true } } @@ -198,6 +200,8 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), Schedul if (keys.isNotEmpty()) { Log.d(TAG, "Performing send from stories activity result.") performSend(keys) + } else { + readyToSend = true } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/PinsForAllSchedule.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PinsForAllSchedule.java index edd1123509..29c3e96c1a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/PinsForAllSchedule.java +++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PinsForAllSchedule.java @@ -45,7 +45,7 @@ private static boolean isEnabled() { return false; } - if (SignalStore.svr().hasPin() || SignalStore.account().isLinkedDevice()) { + if (SignalStore.svr().hasOptedInWithAccess() || SignalStore.account().isLinkedDevice()) { return false; } @@ -62,6 +62,6 @@ private static boolean isEnabled() { private static boolean pinCreationFailedDuringRegistration() { return SignalStore.registration().pinWasRequiredAtRegistration() && - !SignalStore.svr().hasPin(); + !SignalStore.svr().hasOptedInWithAccess(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/PinOptOutMigration.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/PinOptOutMigration.java index 4506ed0939..ed3f9b3abc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/PinOptOutMigration.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/PinOptOutMigration.java @@ -37,7 +37,7 @@ boolean isUiBlocking() { @Override void performMigration() { - if (SignalStore.svr().hasOptedOut() && SignalStore.svr().hasPin()) { + if (SignalStore.svr().hasOptedOut() && SignalStore.svr().hasOptedInWithAccess()) { Log.w(TAG, "Discovered a legacy opt-out user! Resetting the state."); SignalStore.svr().optOut(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageFixLocalUnknownMigrationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageFixLocalUnknownMigrationJob.kt index 61ea618ad1..65b41d1783 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageFixLocalUnknownMigrationJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/StorageFixLocalUnknownMigrationJob.kt @@ -27,7 +27,7 @@ internal class StorageFixLocalUnknownMigrationJob( @Suppress("UsePropertyAccessSyntax") override fun performMigration() { - val localStorageIds = SignalStore.storageService.getManifest().storageIds.toSet() + val localStorageIds = SignalStore.storageService.manifest.storageIds.toSet() val unknownLocalIds = SignalDatabase.unknownStorageIds.getAllUnknownIds().toSet() val danglingLocalUnknownIds = unknownLocalIds - localStorageIds diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java index 3f561a6ba6..e15293c6d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java @@ -240,7 +240,7 @@ private void handleSuccess() { Activity activity = requireActivity(); if (RemoteConfig.messageBackups() && !SignalStore.registration().hasCompletedRestore()) { - final Intent transferOrRestore = RestoreActivity.getIntentForTransferOrRestore(activity); + final Intent transferOrRestore = RestoreActivity.getRestoreIntent(activity); transferOrRestore.putExtra(PassphraseRequiredActivity.NEXT_INTENT_EXTRA, MainActivity.clearTop(requireContext())); startActivity(transferOrRestore); } else if (Recipient.self().getProfileName().isEmpty() || !AvatarHelper.hasAvatar(activity, Recipient.self().getId())) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt index 6ed33b2e97..f936490261 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt @@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.pin import android.app.backup.BackupManager import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread +import okio.ByteString.Companion.toByteString import org.signal.core.util.Stopwatch import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.BuildConfig @@ -163,12 +164,15 @@ object SvrRepository { Log.i(TAG, "[restoreMasterKeyPostRegistration] Successfully restored master key. $implementation", true) stopwatch.split("restore") + SignalStore.registration.localRegistrationMetadata?.let { metadata -> + SignalStore.registration.localRegistrationMetadata = metadata.copy(masterKey = response.masterKey.serialize().toByteString(), pin = userPin) + } + SignalStore.svr.setMasterKey(response.masterKey, userPin) SignalStore.svr.isRegistrationLockEnabled = false SignalStore.pin.resetPinReminders() - SignalStore.svr.isPinForgottenOrSkipped = false SignalStore.pin.keyboardType = pinKeyboardType - SignalStore.storageService.setNeedsAccountRestore(false) + SignalStore.storageService.needsAccountRestore = false when (implementation.svrVersion) { SvrVersion.SVR2 -> SignalStore.svr.appendSvr2AuthTokenToList(response.authorization.asBasic()) @@ -264,7 +268,6 @@ object SvrRepository { Log.i(TAG, "[setPin] Success!", true) SignalStore.svr.setMasterKey(masterKey, userPin) - SignalStore.svr.isPinForgottenOrSkipped = false responses .filterIsInstance() .forEach { @@ -301,8 +304,7 @@ object SvrRepository { masterKey: MasterKey?, userPin: String?, hasPinToRestore: Boolean, - setRegistrationLockEnabled: Boolean, - isLinkedDevice: Boolean, + setRegistrationLockEnabled: Boolean ) { Log.i(TAG, "[onRegistrationComplete] Starting", true) operationLock.withLock { @@ -322,14 +324,13 @@ object SvrRepository { SignalStore.pin.resetPinReminders() AppDependencies.jobManager.add(ResetSvrGuessCountJob()) - } else if (isLinkedDevice && masterKey != null) { - Log.i(TAG, "[onRegistrationComplete] Registration as linked device.", true) - SignalStore.storageService.setStorageKeyFromPrimary(masterKey.deriveStorageServiceKey()) - SignalStore.svr.clearRegistrationLockAndPin() + } else if (masterKey != null) { + Log.i(TAG, "[onRegistrationComplete] ReRegistered with key without pin") + SignalStore.svr.setMasterKey(masterKey, null) } else if (hasPinToRestore) { Log.i(TAG, "[onRegistrationComplete] Has a PIN to restore.", true) SignalStore.svr.clearRegistrationLockAndPin() - SignalStore.storageService.setNeedsAccountRestore(true) + SignalStore.storageService.needsAccountRestore = true } else { Log.i(TAG, "[onRegistrationComplete] No registration lock or PIN at all.", true) SignalStore.svr.clearRegistrationLockAndPin() @@ -346,8 +347,7 @@ object SvrRepository { fun onPinRestoreForgottenOrSkipped() { operationLock.withLock { SignalStore.svr.clearRegistrationLockAndPin() - SignalStore.storageService.setNeedsAccountRestore(false) - SignalStore.svr.isPinForgottenOrSkipped = true + SignalStore.storageService.needsAccountRestore = false } } @@ -369,7 +369,7 @@ object SvrRepository { @Throws(IOException::class) fun enableRegistrationLockForUserWithPin() { operationLock.withLock { - check(SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to set a registration lock!" } + check(SignalStore.svr.hasOptedInWithAccess() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to set a registration lock!" } Log.i(TAG, "[enableRegistrationLockForUserWithPin] Enabling registration lock.", true) AppDependencies.signalServiceAccountManager.enableRegistrationLock(SignalStore.svr.masterKey) @@ -383,7 +383,7 @@ object SvrRepository { @Throws(IOException::class) fun disableRegistrationLockForUserWithPin() { operationLock.withLock { - check(SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to disable registration lock!" } + check(SignalStore.svr.hasOptedInWithAccess() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to disable registration lock!" } Log.i(TAG, "[disableRegistrationLockForUserWithPin] Disabling registration lock.", true) AppDependencies.signalServiceAccountManager.disableRegistrationLock() @@ -413,7 +413,7 @@ object SvrRepository { false } - if (newToken && SignalStore.svr.hasPin()) { + if (newToken && SignalStore.svr.hasOptedInWithAccess()) { BackupManager(AppDependencies.application).dataChanged() } } catch (e: Throwable) { @@ -474,7 +474,7 @@ object SvrRepository { private val hasNoRegistrationLock: Boolean get() { return !SignalStore.svr.isRegistrationLockEnabled && - !SignalStore.svr.hasPin() && + !SignalStore.svr.hasOptedInWithAccess() && !SignalStore.svr.hasOptedOut() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/AccountRegistrationResult.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/AccountRegistrationResult.kt new file mode 100644 index 0000000000..6d2f1d15fc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/AccountRegistrationResult.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registration.data + +import org.whispersystems.signalservice.api.account.PreKeyCollection +import org.whispersystems.signalservice.api.kbs.MasterKey + +data class AccountRegistrationResult( + val uuid: String, + val pni: String, + val storageCapable: Boolean, + val number: String, + val masterKey: MasterKey?, + val pin: String?, + val aciPreKeyCollection: PreKeyCollection, + val pniPreKeyCollection: PreKeyCollection +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/LinkDeviceRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/LinkDeviceRepository.kt index cad8fb4f87..cfdf7e18c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/LinkDeviceRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/LinkDeviceRepository.kt @@ -11,7 +11,6 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.push.AccountManagerFactory -import org.thoughtcrime.securesms.registration.data.RegistrationRepository.AccountRegistrationResult import org.thoughtcrime.securesms.registration.data.network.DeviceUuidRequestResult import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher @@ -107,7 +106,7 @@ class LinkDeviceRepository(password: String) { SignalStore.account.setDeviceName(deviceName) SignalStore.account.setAciIdentityKeysFromPrimaryDevice(registration.aciIdentity) SignalStore.account.setPniIdentityKeyAfterChangeNumber(registration.pniIdentity) - SignalStore.registration.markHasUploadedProfile() + SignalStore.registration.hasUploadedProfile = true AccountRegistrationResult( uuid = registration.aci.toString(), diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/LocalRegistrationMetadataUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/LocalRegistrationMetadataUtil.kt index b005bf239a..e3c665316f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/LocalRegistrationMetadataUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/LocalRegistrationMetadataUtil.kt @@ -17,7 +17,7 @@ import org.whispersystems.signalservice.api.account.PreKeyCollection * and combines them into a proto-backed class [LocalRegistrationMetadata] so they can be serialized & stored. */ object LocalRegistrationMetadataUtil { - fun createLocalRegistrationMetadata(localAciIdentityKeyPair: IdentityKeyPair, localPniIdentityKeyPair: IdentityKeyPair, registrationData: RegistrationData, remoteResult: RegistrationRepository.AccountRegistrationResult, reglockEnabled: Boolean): LocalRegistrationMetadata { + fun createLocalRegistrationMetadata(localAciIdentityKeyPair: IdentityKeyPair, localPniIdentityKeyPair: IdentityKeyPair, registrationData: RegistrationData, remoteResult: AccountRegistrationResult, reglockEnabled: Boolean): LocalRegistrationMetadata { return LocalRegistrationMetadata.Builder().apply { aciIdentityKeyPair = localAciIdentityKeyPair.serialize().toByteString() aciSignedPreKey = remoteResult.aciPreKeyCollection.signedPreKey.serialize().toByteString() diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt index cff79bb946..5359fe60d9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt @@ -220,7 +220,7 @@ object RegistrationRepository { NotificationManagerCompat.from(context).cancel(NotificationIds.UNREGISTERED_NOTIFICATION_ID) val masterKey = if (data.masterKey != null) MasterKey(data.masterKey.toByteArray()) else null - SvrRepository.onRegistrationComplete(masterKey, data.pin, hasPin, data.reglockEnabled, isLinkedDevice = SignalStore.account.isLinkedDevice) + SvrRepository.onRegistrationComplete(masterKey, data.pin, hasPin, data.reglockEnabled) AppDependencies.resetNetwork(restartMessageObserver = true) PreKeysSyncJob.enqueue() @@ -620,15 +620,4 @@ object RegistrationRepository { latch.countDown() } } - - data class AccountRegistrationResult( - val uuid: String, - val pni: String, - val storageCapable: Boolean, - val number: String, - val masterKey: MasterKey?, - val pin: String?, - val aciPreKeyCollection: PreKeyCollection, - val pniPreKeyCollection: PreKeyCollection - ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/network/DeviceUuidRequestResult.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/network/DeviceUuidRequestResult.kt index 08a3de5e6f..235fab1927 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/network/DeviceUuidRequestResult.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/network/DeviceUuidRequestResult.kt @@ -15,6 +15,6 @@ sealed class DeviceUuidRequestResult(cause: Throwable?) : RegistrationResult(cau } } - class Success(val uuid: String) : DeviceUuidRequestResult(null) + class Success(val uuid: String?) : DeviceUuidRequestResult(null) class UnknownError(cause: Throwable) : DeviceUuidRequestResult(cause) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/network/RegisterAccountResult.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/network/RegisterAccountResult.kt index 858be7c09d..af8b7bc3d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/network/RegisterAccountResult.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/network/RegisterAccountResult.kt @@ -6,7 +6,7 @@ package org.thoughtcrime.securesms.registration.data.network import org.thoughtcrime.securesms.pin.SvrWrongPinException -import org.thoughtcrime.securesms.registration.data.RegistrationRepository +import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.SvrNoDataException import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException @@ -23,7 +23,7 @@ import org.whispersystems.signalservice.internal.push.VerifyAccountResponse */ sealed class RegisterAccountResult(cause: Throwable?) : RegistrationResult(cause) { companion object { - fun from(networkResult: NetworkResult): RegisterAccountResult { + fun from(networkResult: NetworkResult): RegisterAccountResult { return when (networkResult) { is NetworkResult.Success -> Success(networkResult.result) is NetworkResult.ApplicationError -> UnknownError(networkResult.throwable) @@ -55,7 +55,7 @@ sealed class RegisterAccountResult(cause: Throwable?) : RegistrationResult(cause } } } - class Success(val accountRegistrationResult: RegistrationRepository.AccountRegistrationResult) : RegisterAccountResult(null) + class Success(val accountRegistrationResult: AccountRegistrationResult) : RegisterAccountResult(null) class IncorrectRecoveryPassword(cause: Throwable) : RegisterAccountResult(cause) class AuthorizationFailed(cause: Throwable) : RegisterAccountResult(cause) class MalformedRequest(cause: Throwable) : RegisterAccountResult(cause) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/SignalStrengthPhoneStateListener.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/SignalStrengthPhoneStateListener.java index daf6077202..a21eb99994 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/SignalStrengthPhoneStateListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/SignalStrengthPhoneStateListener.java @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.registration.fragments; import android.content.Context; +import android.os.Build; import android.telephony.PhoneStateListener; import android.telephony.SignalStrength; import android.telephony.TelephonyManager; @@ -49,7 +50,12 @@ public void onSignalStrengthsChanged(SignalStrength signalStrength) { } private boolean isLowLevel(@NonNull SignalStrength signalStrength) { - return signalStrength.getLevel() == 0; + if (Build.VERSION.SDK_INT >= 23) { + return signalStrength.getLevel() == 0; + } else { + //noinspection deprecation: False lint warning, deprecated by 29, but this else block is for < 23 + return signalStrength.getGsmSignalStrength() == 0; + } } public interface Callback { diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt index 5b625611b4..82ed2890b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt @@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.registration.sms.SmsRetrieverReceiver -import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity +import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme import org.thoughtcrime.securesms.util.RemoteConfig @@ -68,7 +68,7 @@ class RegistrationActivity : PassphraseRequiredActivity() { SignalStore.misc.shouldShowLinkedDevicesReminder = sharedViewModel.isReregister } - if (SignalStore.storageService.needsAccountRestore()) { + if (SignalStore.storageService.needsAccountRestore) { Log.i(TAG, "Performing pin restore.") startActivity(Intent(this, PinRestoreActivity::class.java)) finish() @@ -120,7 +120,7 @@ class RegistrationActivity : PassphraseRequiredActivity() { @JvmStatic fun newIntentForNewRegistration(context: Context, originalIntent: Intent): Intent { - return Intent(context, RegistrationActivity::class.java).apply { + return Intent(context, getRegistrationClass()).apply { putExtra(RE_REGISTRATION_EXTRA, false) setData(originalIntent.data) } @@ -128,9 +128,13 @@ class RegistrationActivity : PassphraseRequiredActivity() { @JvmStatic fun newIntentForReRegistration(context: Context): Intent { - return Intent(context, RegistrationActivity::class.java).apply { + return Intent(context, getRegistrationClass()).apply { putExtra(RE_REGISTRATION_EXTRA, true) } } + + private fun getRegistrationClass(): Class<*> { + return if (RemoteConfig.restoreAfterRegistration) org.thoughtcrime.securesms.registrationv3.ui.RegistrationActivity::class.java else RegistrationActivity::class.java + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt index b8db544c54..1b359de9cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt @@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.pin.SvrRepository import org.thoughtcrime.securesms.pin.SvrWrongPinException +import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult import org.thoughtcrime.securesms.registration.data.LinkDeviceRepository import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil import org.thoughtcrime.securesms.registration.data.RegistrationData @@ -832,7 +833,7 @@ class RegistrationViewModel : ViewModel() { handleRegistrationResult(context, registrationData, registrationResponse, false) } - private suspend fun onSuccessfulRegistration(context: Context, registrationData: RegistrationData, remoteResult: RegistrationRepository.AccountRegistrationResult, reglockEnabled: Boolean) { + private suspend fun onSuccessfulRegistration(context: Context, registrationData: RegistrationData, remoteResult: AccountRegistrationResult, reglockEnabled: Boolean) { Log.v(TAG, "onSuccessfulRegistration()") val metadata = LocalRegistrationMetadataUtil.createLocalRegistrationMetadata(SignalStore.account.aciIdentityKey, SignalStore.account.pniIdentityKey, registrationData, remoteResult, reglockEnabled) RegistrationRepository.registerAccountLocally(context, metadata) @@ -876,12 +877,12 @@ class RegistrationViewModel : ViewModel() { viewModelScope.launch(context = coroutineExceptionHandler) { val linkDeviceRepository = LinkDeviceRepository(password) - val deviceUuid = when (val result = linkDeviceRepository.requestDeviceLinkUuid()) { - is DeviceUuidRequestResult.Success -> result.uuid - is DeviceUuidRequestResult.UnknownError -> { - registrationErrorHandler(RegisterAccountResult.UnknownError(result.getCause())) - return@launch + val deviceUuid = linkDeviceRepository.requestDeviceLinkUuid().let { result -> + if (result !is DeviceUuidRequestResult.Success || result.uuid == null) { + registrationErrorHandler(RegisterAccountResult.UnknownError(result.getCause())) + return@launch } + result.uuid } val deviceKeyPair: IdentityKeyPair = IdentityKeyUtil.generateIdentityKeyPair() diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/grantpermissions/GrantPermissionsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/grantpermissions/GrantPermissionsFragment.kt index e2b3dc61a6..91a3e84e78 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/grantpermissions/GrantPermissionsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/grantpermissions/GrantPermissionsFragment.kt @@ -104,7 +104,7 @@ class GrantPermissionsFragment : ComposeFragment() { when (welcomeAction) { WelcomeAction.CONTINUE -> findNavController().safeNavigate(GrantPermissionsFragmentDirections.actionEnterPhoneNumber()) WelcomeAction.RESTORE_BACKUP -> { - val restoreIntent = RestoreActivity.getIntentForTransferOrRestore(requireActivity()) + val restoreIntent = RestoreActivity.getRestoreIntent(requireActivity()) launchRestoreActivity.launch(restoreIntent) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RemoteRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RemoteRestoreActivity.kt deleted file mode 100644 index cb23cd83a2..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RemoteRestoreActivity.kt +++ /dev/null @@ -1,382 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.registration.ui.restore - -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.activity.compose.setContent -import androidx.activity.viewModels -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import org.greenrobot.eventbus.EventBus -import org.greenrobot.eventbus.Subscribe -import org.greenrobot.eventbus.ThreadMode -import org.signal.core.ui.Buttons -import org.signal.core.ui.Previews -import org.signal.core.ui.theme.SignalTheme -import org.thoughtcrime.securesms.BaseActivity -import org.thoughtcrime.securesms.MainActivity -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.backup.v2.MessageBackupTier -import org.thoughtcrime.securesms.backup.v2.RestoreV2Event -import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature -import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow -import org.thoughtcrime.securesms.backup.v2.ui.subscription.RemoteRestoreViewModel -import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle -import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.jobs.ProfileUploadJob -import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.profiles.AvatarHelper -import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity -import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.registration.util.RegistrationUtil -import org.thoughtcrime.securesms.restore.transferorrestore.TransferOrRestoreMoreOptionsDialog -import org.thoughtcrime.securesms.util.DateUtils -import org.thoughtcrime.securesms.util.Util -import java.util.Locale -import org.signal.core.ui.R as CoreUiR - -@SuppressLint("BaseActivitySubclass") -class RemoteRestoreActivity : BaseActivity() { - companion object { - fun getIntent(context: Context): Intent { - return Intent(context, RemoteRestoreActivity::class.java) - } - } - - private val viewModel: RemoteRestoreViewModel by viewModels() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - val state by viewModel.state - SignalTheme { - Surface { - RestoreFromBackupContent( - features = getFeatureList(state.backupTier), - onRestoreBackupClick = { - viewModel.restore() - }, - onCancelClick = { - finish() - }, - onMoreOptionsClick = { - TransferOrRestoreMoreOptionsDialog.show(fragmentManager = supportFragmentManager, skipOnly = false) - }, - state.backupTier, - state.backupTime, - state.backupTier != MessageBackupTier.PAID - ) - if (state.importState == RemoteRestoreViewModel.ImportState.RESTORED) { - SideEffect { - SignalStore.registration.markRestoreCompleted() - RegistrationUtil.maybeMarkRegistrationComplete() - AppDependencies.jobManager.add(ProfileUploadJob()) - startActivity(MainActivity.clearTop(this)) - } - } else if (state.importState == RemoteRestoreViewModel.ImportState.IN_PROGRESS) { - ProgressDialog(state.restoreProgress) - } - } - } - } - EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this) - } - - @Subscribe(threadMode = ThreadMode.MAIN) - fun onEvent(restoreEvent: RestoreV2Event) { - viewModel.updateRestoreProgress(restoreEvent) - } - - @Composable - private fun getFeatureList(tier: MessageBackupTier?): ImmutableList { - return when (tier) { - null -> persistentListOf() - MessageBackupTier.PAID -> { - persistentListOf( - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_thread_compact_bold_16, - label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_media) - ), - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_recent_compact_bold_16, - label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages) - ) - ) - } - MessageBackupTier.FREE -> { - persistentListOf( - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_thread_compact_bold_16, - label = stringResource(id = R.string.RemoteRestoreActivity__your_last_d_days_of_media, 30) - ), - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_recent_compact_bold_16, - label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages) - ) - ) - } - } - } - - /** - * A dialog that *just* shows a spinner. Useful for short actions where you need to - * let the user know that some action is completing. - */ - @Composable - fun ProgressDialog(restoreProgress: RestoreV2Event?) { - androidx.compose.material3.AlertDialog( - onDismissRequest = {}, - confirmButton = {}, - dismissButton = {}, - text = { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth() - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.wrapContentSize() - ) { - if (restoreProgress == null) { - CircularProgressIndicator( - modifier = Modifier - .padding(top = 55.dp, bottom = 16.dp) - .width(48.dp) - .height(48.dp) - ) - } else { - CircularProgressIndicator( - progress = restoreProgress.getProgress(), - modifier = Modifier - .padding(top = 55.dp, bottom = 16.dp) - .width(48.dp) - .height(48.dp) - ) - } - - val progressText = when (restoreProgress?.type) { - RestoreV2Event.Type.PROGRESS_DOWNLOAD -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup) - RestoreV2Event.Type.PROGRESS_RESTORE -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup) - else -> stringResource(id = R.string.RemoteRestoreActivity__restoring) - } - - Text( - text = progressText, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(bottom = 12.dp) - ) - - if (restoreProgress != null) { - val progressBytes = Util.getPrettyFileSize(restoreProgress.count) - val totalBytes = Util.getPrettyFileSize(restoreProgress.estimatedTotalCount) - Text( - text = stringResource(id = R.string.RemoteRestoreActivity__s_of_s_s, progressBytes, totalBytes, "%.2f%%".format(restoreProgress.getProgress())), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(bottom = 12.dp) - ) - } - } - } - }, - modifier = Modifier.width(212.dp) - ) - } - - @Preview - @Composable - private fun ProgressDialogPreview() { - Previews.Preview { - ProgressDialog(RestoreV2Event(RestoreV2Event.Type.PROGRESS_RESTORE, 10, 1000)) - } - } - - @Preview - @Composable - private fun RestoreFromBackupContentPreview() { - Previews.Preview { - RestoreFromBackupContent( - features = persistentListOf( - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_thread_compact_bold_16, - label = "Your last 30 days of media" - ), - MessageBackupsTypeFeature( - iconResourceId = R.drawable.symbol_recent_compact_bold_16, - label = "All of your text messages" - ) - ), - onRestoreBackupClick = {}, - onCancelClick = {}, - onMoreOptionsClick = {}, - MessageBackupTier.PAID, - System.currentTimeMillis(), - true - ) - } - } - - @Composable - private fun RestoreFromBackupContent( - features: ImmutableList, - onRestoreBackupClick: () -> Unit, - onCancelClick: () -> Unit, - onMoreOptionsClick: () -> Unit, - tier: MessageBackupTier?, - lastBackupTime: Long, - cancelable: Boolean - ) { - Column( - modifier = Modifier - .padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter)) - .padding(top = 40.dp, bottom = 24.dp) - ) { - Text( - text = stringResource(id = R.string.RemoteRestoreActivity__restore_from_backup), - style = MaterialTheme.typography.headlineMedium, - modifier = Modifier.padding(bottom = 12.dp) - ) - - val yourLastBackupText = buildAnnotatedString { - append( - stringResource( - id = R.string.RemoteRestoreActivity__backup_created_at, - DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), lastBackupTime), - DateUtils.getOnlyTimeString(LocalContext.current, lastBackupTime) - ) - - ) - append(" ") - if (tier != MessageBackupTier.PAID) { - withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { - append(stringResource(id = R.string.RemoteRestoreActivity__only_media_sent_or_received)) - } - } - } - - Text( - text = yourLastBackupText, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 28.dp) - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp)) - .padding(horizontal = 20.dp) - .padding(top = 20.dp, bottom = 18.dp) - ) { - Text( - text = stringResource(id = R.string.RemoteRestoreActivity__your_backup_includes), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 6.dp) - ) - - features.forEach { - MessageBackupsTypeFeatureRow( - messageBackupsTypeFeature = it, - iconTint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(start = 16.dp, top = 6.dp) - ) - } - } - - Spacer(modifier = Modifier.weight(1f)) - - Buttons.LargeTonal( - onClick = onRestoreBackupClick, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(id = R.string.RemoteRestoreActivity__restore_backup) - ) - } - - if (cancelable) { - TextButton( - onClick = onCancelClick, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(id = android.R.string.cancel) - ) - } - } else { - TextButton( - onClick = onMoreOptionsClick, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = stringResource(id = R.string.TransferOrRestoreFragment__more_options) - ) - } - } - } - } - - private fun restoreFromServer() { - viewModel.restore() - } - - private fun continueRegistration() { - if (Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(this, Recipient.self().id)) { - val main = MainActivity.clearTop(this) - val profile = CreateProfileActivity.getIntentForUserProfile(this) - profile.putExtra("next_intent", main) - startActivity(profile) - } else { - RegistrationUtil.maybeMarkRegistrationComplete() - AppDependencies.jobManager.add(ProfileUploadJob()) - startActivity(MainActivity.clearTop(this)) - } - finish() - } - - @Composable - private fun StateLabel(text: String) { - Text( - text = text, - style = MaterialTheme.typography.labelSmall, - textAlign = TextAlign.Center - ) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/welcome/WelcomeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/welcome/WelcomeFragment.kt index b56855cfbc..555f8ee5cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/welcome/WelcomeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/welcome/WelcomeFragment.kt @@ -83,7 +83,7 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome) } else { sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED) - val restoreIntent = RestoreActivity.getIntentForTransferOrRestore(requireActivity()) + val restoreIntent = RestoreActivity.getRestoreIntent(requireActivity()) launchRestoreActivity.launch(restoreIntent) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java index 8dba027a46..72336e2076 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.RemoteConfig; public final class RegistrationUtil { @@ -29,11 +30,13 @@ public static void maybeMarkRegistrationComplete() { if (!SignalStore.registration().isRegistrationComplete() && SignalStore.account().isRegistered() && ((!Recipient.self().getProfileName().isEmpty() && - (SignalStore.svr().hasPin() || SignalStore.svr().hasOptedOut())) || SignalStore.account().isLinkedDevice())) + (SignalStore.svr().hasOptedInWithAccess() || SignalStore.svr().hasOptedOut()) && + (!RemoteConfig.restoreAfterRegistration() || (SignalStore.registration().hasSkippedTransferOrRestore() || SignalStore.registration().hasCompletedRestore()))) || SignalStore.account().isLinkedDevice())) { Log.i(TAG, "Marking registration completed.", new Throwable()); - SignalStore.registration().setRegistrationComplete(); - SignalStore.registration().clearLocalRegistrationMetadata(); + SignalStore.registration().markRegistrationComplete(); + SignalStore.registration().setLocalRegistrationMetadata(null); + SignalStore.registration().setRestoreMethodToken(null); if (SignalStore.phoneNumberPrivacy().getPhoneNumberDiscoverabilityMode() == PhoneNumberDiscoverabilityMode.UNDECIDED) { Log.w(TAG, "Phone number discoverability mode is still UNDECIDED. Setting to DISCOVERABLE."); diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/QuickRegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/QuickRegistrationRepository.kt new file mode 100644 index 0000000000..b7a966bf94 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/QuickRegistrationRepository.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.data + +import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import org.signal.core.util.Base64.decode +import org.signal.core.util.Hex +import org.signal.core.util.isNotNullOrBlank +import org.signal.core.util.logging.Log +import org.signal.libsignal.protocol.InvalidKeyException +import org.signal.libsignal.protocol.ecc.Curve +import org.signal.registration.proto.RegistrationProvisionMessage +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.whispersystems.signalservice.api.NetworkResult +import org.whispersystems.signalservice.api.registration.RestoreMethod +import java.io.IOException +import kotlin.coroutines.coroutineContext +import kotlin.time.Duration.Companion.seconds + +/** + * Helpers for quickly re-registering on a new device with the old device. + */ +object QuickRegistrationRepository { + private val TAG = Log.tag(QuickRegistrationRepository::class) + + private const val REREG_URI_HOST = "rereg" + + fun isValidReRegistrationQr(data: String): Boolean { + val uri = Uri.parse(data) + + if (!uri.isHierarchical) { + return false + } + + val ephemeralId: String? = uri.getQueryParameter("uuid") + val publicKeyEncoded: String? = uri.getQueryParameter("pub_key") + return uri.host == REREG_URI_HOST && ephemeralId.isNotNullOrBlank() && publicKeyEncoded.isNotNullOrBlank() + } + + /** + * Send registration provisioning message to new device. + */ + fun transferAccount(reRegisterUri: String, restoreMethodToken: String): TransferAccountResult { + if (!isValidReRegistrationQr(reRegisterUri)) { + Log.w(TAG, "Invalid quick re-register qr data") + return TransferAccountResult.FAILED + } + + val uri = Uri.parse(reRegisterUri) + + try { + val ephemeralId: String? = uri.getQueryParameter("uuid") + val publicKeyEncoded: String? = uri.getQueryParameter("pub_key") + val publicKey = Curve.decodePoint(publicKeyEncoded?.let { decode(it) }, 0) + + if (ephemeralId == null || publicKeyEncoded == null) { + Log.w(TAG, "Invalid link data hasId: ${ephemeralId != null} hasKey: ${publicKeyEncoded != null}") + return TransferAccountResult.FAILED + } + + val pin = SignalStore.svr.pin ?: run { + Log.w(TAG, "No pin") + return TransferAccountResult.FAILED + } + + AppDependencies + .signalServiceAccountManager + .registrationApi + .sendReRegisterDeviceProvisioningMessage( + ephemeralId, + publicKey, + RegistrationProvisionMessage( + e164 = SignalStore.account.requireE164(), + aci = SignalStore.account.requireAci().toByteString(), + accountEntropyPool = Hex.toStringCondensed(SignalStore.svr.masterKey.serialize()), + pin = pin, + platform = RegistrationProvisionMessage.Platform.ANDROID, + backupTimestampMs = SignalStore.backup.lastBackupTime.coerceAtLeast(0L), + tier = when (SignalStore.backup.backupTier) { + MessageBackupTier.PAID -> RegistrationProvisionMessage.Tier.PAID + MessageBackupTier.FREE, + null -> RegistrationProvisionMessage.Tier.FREE + }, + restoreMethodToken = restoreMethodToken + ) + ) + .successOrThrow() + + Log.i(TAG, "Re-registration provisioning message sent") + } catch (e: IOException) { + Log.w(TAG, "Exception re-registering new device", e) + return TransferAccountResult.FAILED + } catch (e: InvalidKeyException) { + Log.w(TAG, "Exception re-registering new device", e) + return TransferAccountResult.FAILED + } + + return TransferAccountResult.SUCCESS + } + + /** + * Sets the restore method enum for the old device to retrieve and update their UI with. + */ + suspend fun setRestoreMethodForOldDevice(restoreMethod: RestoreMethod) { + val restoreMethodToken = SignalStore.registration.restoreMethodToken + + if (restoreMethodToken != null) { + withContext(Dispatchers.IO) { + Log.d(TAG, "Setting restore method ***${restoreMethodToken.takeLast(4)}: $restoreMethod") + var retries = 3 + var result: NetworkResult? = null + while (retries-- > 0 && result !is NetworkResult.Success) { + Log.d(TAG, "Setting method, retries remaining: $retries") + result = AppDependencies.registrationApi.setRestoreMethod(restoreMethodToken, restoreMethod) + + if (result !is NetworkResult.Success) { + delay(1.seconds) + } + } + + if (result is NetworkResult.Success) { + Log.i(TAG, "Restore method set successfully") + SignalStore.registration.restoreMethodToken = null + } else { + Log.w(TAG, "Restore method set failed", result?.getCause()) + } + } + } + } + + /** + * Gets the restore method used by the new device to update UI with. This is a long polling operation. + */ + suspend fun waitForRestoreMethodSelectionOnNewDevice(restoreMethodToken: String): RestoreMethod { + var retries = 5 + var result: NetworkResult? = null + + Log.d(TAG, "Waiting for restore method with token: ***${restoreMethodToken.takeLast(4)}") + while (retries-- > 0 && result !is NetworkResult.Success && coroutineContext.isActive) { + Log.d(TAG, "Remaining tries $retries...") + val api = AppDependencies.registrationApi + result = api.waitForRestoreMethod(restoreMethodToken) + Log.d(TAG, "Result: $result") + } + + if (result is NetworkResult.Success) { + Log.i(TAG, "Restore method selected on new device ${result.result}") + return result.result + } else { + Log.w(TAG, "Failed to determine restore method, using default") + return RestoreMethod.DECLINE + } + } + + enum class TransferAccountResult { + SUCCESS, + FAILED + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt new file mode 100644 index 0000000000..045d5865a9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt @@ -0,0 +1,622 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.data + +import android.app.backup.BackupManager +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.core.app.NotificationManagerCompat +import com.google.android.gms.auth.api.phone.SmsRetriever +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.signal.core.util.Base64 +import org.signal.core.util.logging.Log +import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.util.KeyHelper +import org.signal.libsignal.zkgroup.profiles.ProfileKey +import org.thoughtcrime.securesms.AppCapabilities +import org.thoughtcrime.securesms.crypto.PreKeyUtil +import org.thoughtcrime.securesms.crypto.ProfileKeyUtil +import org.thoughtcrime.securesms.crypto.SenderKeyUtil +import org.thoughtcrime.securesms.crypto.storage.PreKeyMetadataStore +import org.thoughtcrime.securesms.crypto.storage.SignalServiceAccountDataStoreImpl +import org.thoughtcrime.securesms.database.IdentityTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.gcm.FcmUtil +import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob +import org.thoughtcrime.securesms.jobs.PreKeysSyncJob +import org.thoughtcrime.securesms.jobs.RotateCertificateJob +import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.notifications.NotificationIds +import org.thoughtcrime.securesms.pin.Svr3Migration +import org.thoughtcrime.securesms.pin.SvrRepository +import org.thoughtcrime.securesms.pin.SvrWrongPinException +import org.thoughtcrime.securesms.profiles.AvatarHelper +import org.thoughtcrime.securesms.push.AccountManagerFactory +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult +import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getAciIdentityKeyPair +import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getAciPreKeyCollection +import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getPniIdentityKeyPair +import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getPniPreKeyCollection +import org.thoughtcrime.securesms.registration.data.RegistrationData +import org.thoughtcrime.securesms.registration.data.network.BackupAuthCheckResult +import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult +import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult +import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult +import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult +import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest +import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet +import org.thoughtcrime.securesms.service.DirectoryRefreshListener +import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.whispersystems.signalservice.api.NetworkResult +import org.whispersystems.signalservice.api.SvrNoDataException +import org.whispersystems.signalservice.api.account.AccountAttributes +import org.whispersystems.signalservice.api.account.PreKeyCollection +import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess +import org.whispersystems.signalservice.api.kbs.MasterKey +import org.whispersystems.signalservice.api.kbs.PinHashUtil +import org.whispersystems.signalservice.api.push.ServiceId +import org.whispersystems.signalservice.api.push.ServiceId.ACI +import org.whispersystems.signalservice.api.push.ServiceId.PNI +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import org.whispersystems.signalservice.api.registration.RegistrationApi +import org.whispersystems.signalservice.api.svr.Svr3Credentials +import org.whispersystems.signalservice.internal.push.AuthCredentials +import org.whispersystems.signalservice.internal.push.PushServiceSocket +import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataHeaders +import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse +import org.whispersystems.signalservice.internal.push.VerifyAccountResponse +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.util.Locale +import java.util.Optional +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.time.Duration.Companion.seconds + +/** + * A repository that deals with disk I/O during account registration. + */ +object RegistrationRepository { + + private val TAG = Log.tag(RegistrationRepository::class.java) + + private val PUSH_REQUEST_TIMEOUT = 5.seconds.inWholeMilliseconds + + /** + * Retrieve the FCM token from the Firebase service. + */ + suspend fun getFcmToken(context: Context): String? = + withContext(Dispatchers.Default) { + FcmUtil.getToken(context).orElse(null) + } + + /** + * Queries, and creates if needed, the local registration ID. + */ + @JvmStatic + fun getRegistrationId(): Int { + // TODO [regv2]: make creation more explicit instead of hiding it in this getter + var registrationId = SignalStore.account.registrationId + if (registrationId == 0) { + registrationId = KeyHelper.generateRegistrationId(false) + SignalStore.account.registrationId = registrationId + } + return registrationId + } + + /** + * Queries, and creates if needed, the local PNI registration ID. + */ + @JvmStatic + fun getPniRegistrationId(): Int { + // TODO [regv2]: make creation more explicit instead of hiding it in this getter + var pniRegistrationId = SignalStore.account.pniRegistrationId + if (pniRegistrationId == 0) { + pniRegistrationId = KeyHelper.generateRegistrationId(false) + SignalStore.account.pniRegistrationId = pniRegistrationId + } + return pniRegistrationId + } + + /** + * Queries, and creates if needed, the local profile key. + */ + @JvmStatic + suspend fun getProfileKey(e164: String): ProfileKey = + withContext(Dispatchers.IO) { + // TODO [regv2]: make creation more explicit instead of hiding it in this getter + val recipientTable = SignalDatabase.recipients + val recipient = recipientTable.getByE164(e164) + var profileKey = if (recipient.isPresent) { + ProfileKeyUtil.profileKeyOrNull(Recipient.resolved(recipient.get()).profileKey) + } else { + null + } + if (profileKey == null) { + profileKey = ProfileKeyUtil.createNew() + Log.i(TAG, "No profile key found, created a new one") + } + profileKey + } + + /** + * Takes a server response from a successful registration and persists the relevant data. + */ + @JvmStatic + suspend fun registerAccountLocally(context: Context, data: LocalRegistrationMetadata) = + withContext(Dispatchers.IO) { + Log.v(TAG, "registerAccountLocally()") + val aciIdentityKeyPair = data.getAciIdentityKeyPair() + val pniIdentityKeyPair = data.getPniIdentityKeyPair() + SignalStore.account.restoreAciIdentityKeyFromBackup(aciIdentityKeyPair.publicKey.serialize(), aciIdentityKeyPair.privateKey.serialize()) + SignalStore.account.restorePniIdentityKeyFromBackup(pniIdentityKeyPair.publicKey.serialize(), pniIdentityKeyPair.privateKey.serialize()) + + val aciPreKeyCollection = data.getAciPreKeyCollection() + val pniPreKeyCollection = data.getPniPreKeyCollection() + val aci: ACI = ACI.parseOrThrow(data.aci) + val pni: PNI = PNI.parseOrThrow(data.pni) + val hasPin: Boolean = data.hasPin + + SignalStore.account.setAci(aci) + SignalStore.account.setPni(pni) + + AppDependencies.resetProtocolStores() + + AppDependencies.protocolStore.aci().sessions().archiveAllSessions() + AppDependencies.protocolStore.pni().sessions().archiveAllSessions() + SenderKeyUtil.clearAllState() + + val aciProtocolStore = AppDependencies.protocolStore.aci() + val aciMetadataStore = SignalStore.account.aciPreKeys + + val pniProtocolStore = AppDependencies.protocolStore.pni() + val pniMetadataStore = SignalStore.account.pniPreKeys + + storeSignedAndLastResortPreKeys(aciProtocolStore, aciMetadataStore, aciPreKeyCollection) + storeSignedAndLastResortPreKeys(pniProtocolStore, pniMetadataStore, pniPreKeyCollection) + + val recipientTable = SignalDatabase.recipients + val selfId = Recipient.trustedPush(aci, pni, data.e164).id + + recipientTable.setProfileSharing(selfId, true) + recipientTable.markRegisteredOrThrow(selfId, aci) + recipientTable.linkIdsForSelf(aci, pni, data.e164) + recipientTable.setProfileKey(selfId, ProfileKey(data.profileKey.toByteArray())) + + AppDependencies.recipientCache.clearSelf() + + SignalStore.account.setE164(data.e164) + SignalStore.account.fcmToken = data.fcmToken + SignalStore.account.fcmEnabled = data.fcmEnabled + + val now = System.currentTimeMillis() + saveOwnIdentityKey(selfId, aci, aciProtocolStore, now) + saveOwnIdentityKey(selfId, pni, pniProtocolStore, now) + + SignalStore.account.setServicePassword(data.servicePassword) + SignalStore.account.setRegistered(true) + TextSecurePreferences.setPromptedPushRegistration(context, true) + TextSecurePreferences.setUnauthorizedReceived(context, false) + NotificationManagerCompat.from(context).cancel(NotificationIds.UNREGISTERED_NOTIFICATION_ID) + + val masterKey = if (data.masterKey != null) MasterKey(data.masterKey.toByteArray()) else null + SvrRepository.onRegistrationComplete(masterKey, data.pin, hasPin, data.reglockEnabled) + + AppDependencies.resetNetwork(restartMessageObserver = true) + PreKeysSyncJob.enqueue() + + val jobManager = AppDependencies.jobManager + jobManager.add(DirectoryRefreshJob(false)) + jobManager.add(RotateCertificateJob()) + + DirectoryRefreshListener.schedule(context) + RotateSignedPreKeyListener.schedule(context) + } + + @JvmStatic + private fun saveOwnIdentityKey(selfId: RecipientId, serviceId: ServiceId, protocolStore: SignalServiceAccountDataStoreImpl, now: Long) { + protocolStore.identities().saveIdentityWithoutSideEffects( + selfId, + serviceId, + protocolStore.identityKeyPair.publicKey, + IdentityTable.VerifiedStatus.VERIFIED, + true, + now, + true + ) + } + + @JvmStatic + private fun storeSignedAndLastResortPreKeys(protocolStore: SignalServiceAccountDataStoreImpl, metadataStore: PreKeyMetadataStore, preKeyCollection: PreKeyCollection) { + PreKeyUtil.storeSignedPreKey(protocolStore, metadataStore, preKeyCollection.signedPreKey) + metadataStore.isSignedPreKeyRegistered = true + metadataStore.activeSignedPreKeyId = preKeyCollection.signedPreKey.id + metadataStore.lastSignedPreKeyRotationTime = System.currentTimeMillis() + + PreKeyUtil.storeLastResortKyberPreKey(protocolStore, metadataStore, preKeyCollection.lastResortKyberPreKey) + metadataStore.lastResortKyberPreKeyId = preKeyCollection.lastResortKyberPreKey.id + metadataStore.lastResortKyberPreKeyRotationTime = System.currentTimeMillis() + } + + fun canUseLocalRecoveryPassword(): Boolean { + val recoveryPassword = SignalStore.svr.recoveryPassword + val pinHash = SignalStore.svr.localPinHash + return recoveryPassword != null && pinHash != null + } + + fun doesPinMatchLocalHash(pin: String): Boolean { + val pinHash = SignalStore.svr.localPinHash ?: throw IllegalStateException("Local PIN hash is not present!") + return PinHashUtil.verifyLocalPinHash(pinHash, pin) + } + + suspend fun fetchMasterKeyFromSvrRemote(pin: String, svr2Credentials: AuthCredentials?, svr3Credentials: Svr3Credentials?): MasterKey = + withContext(Dispatchers.IO) { + val credentialSet = SvrAuthCredentialSet(svr2Credentials = svr2Credentials, svr3Credentials = svr3Credentials) + val masterKey = SvrRepository.restoreMasterKeyPreRegistration(credentialSet, pin) + return@withContext masterKey + } + + /** + * Validates a session ID. + */ + private suspend fun validateSession(context: Context, sessionId: String, e164: String, password: String): RegistrationSessionCheckResult = + withContext(Dispatchers.IO) { + val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi + Log.d(TAG, "Validating registration session with service.") + val registrationSessionResult = api.getRegistrationSessionStatus(sessionId) + return@withContext RegistrationSessionCheckResult.from(registrationSessionResult) + } + + /** + * Initiates a new registration session on the service. + */ + suspend fun createSession(context: Context, e164: String, password: String, mcc: String?, mnc: String?): RegistrationSessionCreationResult = + withContext(Dispatchers.IO) { + Log.d(TAG, "About to create a registration session…") + val fcmToken: String? = FcmUtil.getToken(context).orElse(null) + val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi + + val registrationSessionResult = if (fcmToken == null) { + Log.d(TAG, "Creating registration session without FCM token.") + api.createRegistrationSession(null, mcc, mnc) + } else { + Log.d(TAG, "Creating registration session with FCM token.") + createSessionAndBlockForPushChallenge(api, fcmToken, mcc, mnc) + } + val result = RegistrationSessionCreationResult.from(registrationSessionResult) + if (result is RegistrationSessionCreationResult.Success) { + Log.d(TAG, "Updating registration session and E164 in value store.") + SignalStore.registration.sessionId = result.getMetadata().body.id + SignalStore.registration.sessionE164 = e164 + } + + return@withContext result + } + + /** + * Validates an existing session, if its ID is provided. If the session is expired/invalid, or none is provided, it will attempt to initiate a new session. + */ + suspend fun createOrValidateSession(context: Context, sessionId: String?, e164: String, password: String, mcc: String?, mnc: String?): RegistrationSessionResult { + val savedSessionId = if (sessionId == null && e164 == SignalStore.registration.sessionE164) { + SignalStore.registration.sessionId + } else { + sessionId + } + + if (savedSessionId != null) { + Log.d(TAG, "Validating existing registration session.") + val sessionValidationResult = validateSession(context, savedSessionId, e164, password) + when (sessionValidationResult) { + is RegistrationSessionCheckResult.Success -> { + Log.d(TAG, "Existing registration session is valid.") + return sessionValidationResult + } + + is RegistrationSessionCheckResult.UnknownError -> { + Log.w(TAG, "Encountered error when validating existing session.", sessionValidationResult.getCause()) + return sessionValidationResult + } + + is RegistrationSessionCheckResult.SessionNotFound -> { + Log.i(TAG, "Current session is invalid or has expired. Must create new one.") + // fall through to creation + } + } + } + return createSession(context, e164, password, mcc, mnc) + } + + /** + * Asks the service to send a verification code through one of our supported channels (SMS, phone call). + */ + suspend fun requestSmsCode(context: Context, sessionId: String, e164: String, password: String, mode: E164VerificationMode): VerificationCodeRequestResult = + withContext(Dispatchers.IO) { + val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi + + val codeRequestResult = api.requestSmsVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported, mode.transport) + + return@withContext VerificationCodeRequestResult.from(codeRequestResult) + } + + /** + * Submits the user-entered verification code to the service. + */ + suspend fun submitVerificationCode(context: Context, sessionId: String, registrationData: RegistrationData): VerificationCodeRequestResult = + withContext(Dispatchers.IO) { + val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, registrationData.e164, SignalServiceAddress.DEFAULT_DEVICE_ID, registrationData.password).registrationApi + val result = api.verifyAccount(sessionId = sessionId, verificationCode = registrationData.code) + return@withContext VerificationCodeRequestResult.from(result) + } + + /** + * Submits the solved captcha token to the service. + */ + suspend fun submitCaptchaToken(context: Context, e164: String, password: String, sessionId: String, captchaToken: String): VerificationCodeRequestResult = + withContext(Dispatchers.IO) { + val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi + val captchaSubmissionResult = api.submitCaptchaToken(sessionId = sessionId, captchaToken = captchaToken) + return@withContext VerificationCodeRequestResult.from(captchaSubmissionResult) + } + + suspend fun requestAndVerifyPushToken(context: Context, sessionId: String, e164: String, password: String) = + withContext(Dispatchers.IO) { + val fcmToken = getFcmToken(context) + val accountManager = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password) + val pushChallenge = PushChallengeRequest.getPushChallengeBlocking(accountManager, sessionId, Optional.ofNullable(fcmToken), PUSH_REQUEST_TIMEOUT).orElse(null) + val pushSubmissionResult = accountManager.registrationApi.submitPushChallengeToken(sessionId = sessionId, pushChallengeToken = pushChallenge) + return@withContext VerificationCodeRequestResult.from(pushSubmissionResult) + } + + /** + * Submit the necessary assets as a verified account so that the user can actually use the service. + */ + suspend fun registerAccount(context: Context, sessionId: String?, registrationData: RegistrationData, pin: String? = null, masterKeyProducer: MasterKeyProducer? = null): RegisterAccountResult = + withContext(Dispatchers.IO) { + Log.v(TAG, "registerAccount()") + val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, registrationData.e164, SignalServiceAddress.DEFAULT_DEVICE_ID, registrationData.password).registrationApi + + val universalUnidentifiedAccess: Boolean = TextSecurePreferences.isUniversalUnidentifiedAccess(context) + val unidentifiedAccessKey: ByteArray = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey) + + val masterKey: MasterKey? + try { + masterKey = masterKeyProducer?.produceMasterKey() + } catch (e: SvrNoDataException) { + return@withContext RegisterAccountResult.SvrNoData(e) + } catch (e: SvrWrongPinException) { + return@withContext RegisterAccountResult.SvrWrongPin(e) + } catch (e: IOException) { + return@withContext RegisterAccountResult.UnknownError(e) + } + + val registrationLock: String? = masterKey?.deriveRegistrationLock() + + val accountAttributes = AccountAttributes( + signalingKey = null, + registrationId = registrationData.registrationId, + fetchesMessages = registrationData.isNotFcm, + registrationLock = registrationLock, + unidentifiedAccessKey = unidentifiedAccessKey, + unrestrictedUnidentifiedAccess = universalUnidentifiedAccess, + capabilities = AppCapabilities.getCapabilities(true), + discoverableByPhoneNumber = SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode == PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE, + name = null, + pniRegistrationId = registrationData.pniRegistrationId, + recoveryPassword = registrationData.recoveryPassword + ) + + SignalStore.account.generateAciIdentityKeyIfNecessary() + val aciIdentity: IdentityKeyPair = SignalStore.account.aciIdentityKey + + SignalStore.account.generatePniIdentityKeyIfNecessary() + val pniIdentity: IdentityKeyPair = SignalStore.account.pniIdentityKey + + val aciPreKeyCollection = generateSignedAndLastResortPreKeys(aciIdentity, SignalStore.account.aciPreKeys) + val pniPreKeyCollection = generateSignedAndLastResortPreKeys(pniIdentity, SignalStore.account.pniPreKeys) + + val result: NetworkResult = api.registerAccount(sessionId, registrationData.recoveryPassword, accountAttributes, aciPreKeyCollection, pniPreKeyCollection, registrationData.fcmToken, true) + .map { accountRegistrationResponse: VerifyAccountResponse -> + AccountRegistrationResult( + uuid = accountRegistrationResponse.uuid, + pni = accountRegistrationResponse.pni, + storageCapable = accountRegistrationResponse.storageCapable, + number = accountRegistrationResponse.number, + masterKey = masterKey, + pin = pin, + aciPreKeyCollection = aciPreKeyCollection, + pniPreKeyCollection = pniPreKeyCollection + ) + } + + return@withContext RegisterAccountResult.from(result) + } + + private suspend fun createSessionAndBlockForPushChallenge(accountManager: RegistrationApi, fcmToken: String, mcc: String?, mnc: String?): NetworkResult = + withContext(Dispatchers.IO) { + // TODO [regv2]: do not use event bus nor latch + val subscriber = PushTokenChallengeSubscriber() + val eventBus = EventBus.getDefault() + eventBus.register(subscriber) + + try { + Log.d(TAG, "Requesting a registration session with FCM token…") + val sessionCreationResponse = accountManager.createRegistrationSession(fcmToken, mcc, mnc) + if (sessionCreationResponse !is NetworkResult.Success) { + return@withContext sessionCreationResponse + } + + val receivedPush = subscriber.latch.await(PUSH_REQUEST_TIMEOUT, TimeUnit.MILLISECONDS) + eventBus.unregister(subscriber) + + if (receivedPush) { + val challenge = subscriber.challenge + if (challenge != null) { + Log.w(TAG, "Push challenge token received.") + return@withContext accountManager.submitPushChallengeToken(sessionCreationResponse.result.body.id, challenge) + } else { + Log.w(TAG, "Push received but challenge token was null.") + } + } else { + Log.i(TAG, "Push challenge timed out.") + } + Log.i(TAG, "Push challenge unsuccessful. Continuing with session created without one.") + return@withContext sessionCreationResponse + } catch (ex: Exception) { + Log.w(TAG, "Exception caught, but the earlier try block should have caught it?", ex) + return@withContext NetworkResult.ApplicationError(ex) + } + } + + @JvmStatic + fun deriveTimestamp(headers: RegistrationSessionMetadataHeaders, deltaSeconds: Int?): Long { + if (deltaSeconds == null) { + return 0L + } + + val timestamp: Long = headers.timestamp + return timestamp + deltaSeconds.seconds.inWholeMilliseconds + } + + suspend fun hasValidSvrAuthCredentials(context: Context, e164: String, password: String): BackupAuthCheckResult = + withContext(Dispatchers.IO) { + val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi + + val svr3Result = SignalStore.svr.svr3AuthTokens + ?.takeIf { Svr3Migration.shouldReadFromSvr3 } + ?.takeIf { it.isNotEmpty() } + ?.toSvrCredentials() + ?.let { authTokens -> + api + .validateSvr3AuthCredential(e164, authTokens) + .runIfSuccessful { + val removedInvalidTokens = SignalStore.svr.removeSvr3AuthTokens(it.invalid) + if (removedInvalidTokens) { + BackupManager(context).dataChanged() + } + } + .let { BackupAuthCheckResult.fromV3(it) } + } + + if (svr3Result is BackupAuthCheckResult.SuccessWithCredentials) { + Log.d(TAG, "Found valid SVR3 credentials.") + return@withContext svr3Result + } + + Log.d(TAG, "No valid SVR3 credentials, looking for SVR2.") + + return@withContext SignalStore.svr.svr2AuthTokens + ?.takeIf { it.isNotEmpty() } + ?.toSvrCredentials() + ?.let { authTokens -> + api + .validateSvr2AuthCredential(e164, authTokens) + .runIfSuccessful { + val removedInvalidTokens = SignalStore.svr.removeSvr2AuthTokens(it.invalid) + if (removedInvalidTokens) { + BackupManager(context).dataChanged() + } + } + .let { BackupAuthCheckResult.fromV2(it) } + } ?: BackupAuthCheckResult.SuccessWithoutCredentials() + } + + /** Converts the basic-auth creds we have locally into username:password pairs that are suitable for handing off to the service. */ + private fun List.toSvrCredentials(): List { + return this + .asSequence() + .filterNotNull() + .take(10) + .map { it.replace("Basic ", "").trim() } + .mapNotNull { + try { + Base64.decode(it) + } catch (e: IOException) { + Log.w(TAG, "Encountered error trying to decode a token!", e) + null + } + } + .map { String(it, StandardCharsets.ISO_8859_1) } + .toList() + } + + /** + * Starts an SMS listener to auto-enter a verification code. + * + * The listener [lives for 5 minutes](https://developers.google.com/android/reference/com/google/android/gms/auth/api/phone/SmsRetrieverApi). + * + * @return whether or not the Play Services SMS Listener was successfully registered. + */ + suspend fun registerSmsListener(context: Context): Boolean { + Log.d(TAG, "Attempting to start verification code SMS retriever.") + val started = withTimeoutOrNull(5.seconds.inWholeMilliseconds) { + try { + SmsRetriever.getClient(context).startSmsRetriever().await() + Log.d(TAG, "Successfully started verification code SMS retriever.") + return@withTimeoutOrNull true + } catch (ex: Exception) { + Log.w(TAG, "Could not start verification code SMS retriever due to exception.", ex) + return@withTimeoutOrNull false + } + } + + if (started == null) { + Log.w(TAG, "Could not start verification code SMS retriever due to timeout.") + } + + return started == true + } + + @VisibleForTesting + fun generateSignedAndLastResortPreKeys(identity: IdentityKeyPair, metadataStore: PreKeyMetadataStore): PreKeyCollection { + val signedPreKey = PreKeyUtil.generateSignedPreKey(metadataStore.nextSignedPreKeyId, identity.privateKey) + val lastResortKyberPreKey = PreKeyUtil.generateLastResortKyberPreKey(metadataStore.nextKyberPreKeyId, identity.privateKey) + + return PreKeyCollection( + identity.publicKey, + signedPreKey, + lastResortKyberPreKey + ) + } + + fun isMissingProfileData(): Boolean { + return Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(AppDependencies.application, Recipient.self().id) + } + + fun interface MasterKeyProducer { + @Throws(IOException::class, SvrWrongPinException::class, SvrNoDataException::class) + fun produceMasterKey(): MasterKey + } + + enum class E164VerificationMode(val isSmsRetrieverSupported: Boolean, val transport: PushServiceSocket.VerificationCodeTransport) { + SMS_WITH_LISTENER(true, PushServiceSocket.VerificationCodeTransport.SMS), + SMS_WITHOUT_LISTENER(false, PushServiceSocket.VerificationCodeTransport.SMS), + PHONE_CALL(false, PushServiceSocket.VerificationCodeTransport.VOICE) + } + + private class PushTokenChallengeSubscriber { + var challenge: String? = null + val latch = CountDownLatch(1) + + @Subscribe + fun onChallengeEvent(pushChallengeEvent: PushChallengeRequest.PushChallengeEvent) { + Log.d(TAG, "Push challenge received!") + challenge = pushChallengeEvent.challenge + latch.countDown() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountActivity.kt new file mode 100644 index 0000000000..eb7a2c3362 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountActivity.kt @@ -0,0 +1,379 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.olddevice + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.Buttons +import org.signal.core.ui.Dialogs +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.signal.core.ui.Texts +import org.signal.core.ui.horizontalGutters +import org.signal.core.ui.theme.SignalTheme +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.BiometricDeviceAuthentication +import org.thoughtcrime.securesms.BiometricDeviceLockContract +import org.thoughtcrime.securesms.MainActivity +import org.thoughtcrime.securesms.PassphraseRequiredActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity +import org.thoughtcrime.securesms.fonts.SignalSymbols +import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme +import org.thoughtcrime.securesms.util.DynamicTheme +import org.thoughtcrime.securesms.util.SpanUtil +import org.thoughtcrime.securesms.util.viewModel +import org.whispersystems.signalservice.api.registration.RestoreMethod + +/** + * Launched after scanning QR code from new device to start the transfer/reregistration process from + * old phone to new phone. + */ +class TransferAccountActivity : PassphraseRequiredActivity() { + + companion object { + private val TAG = Log.tag(TransferAccountActivity::class) + + private const val KEY_URI = "URI" + + // TODO [backups] Put actual learn more url + const val LEARN_MORE_URL = "https://signal.org#" + + fun intent(context: Context, uri: String): Intent { + return Intent(context, TransferAccountActivity::class.java).apply { + putExtra(KEY_URI, uri) + } + } + } + + private val theme: DynamicTheme = DynamicNoActionBarTheme() + + private val viewModel: TransferAccountViewModel by viewModel { + TransferAccountViewModel(intent.getStringExtra(KEY_URI)!!) + } + + private lateinit var biometricAuth: BiometricDeviceAuthentication + private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher + + override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { + super.onCreate(savedInstanceState, ready) + theme.onCreate(this) + + if (!SignalStore.account.isRegistered) { + finish() + } + + biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int -> + if (result == BiometricDeviceAuthentication.AUTHENTICATED) { + Log.i(TAG, "Device authentication succeeded via contract") + transferAccount() + } + } + + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS) + .setTitle(getString(R.string.TransferAccount_unlock_to_transfer)) + .setConfirmationRequired(true) + .build() + + biometricAuth = BiometricDeviceAuthentication( + BiometricManager.from(this), + BiometricPrompt(this, BiometricAuthenticationListener()), + promptInfo + ) + + lifecycleScope.launch { + val restoreMethodSelected = viewModel + .state + .mapNotNull { it.restoreMethodSelected } + .firstOrNull() + + when (restoreMethodSelected) { + RestoreMethod.DEVICE_TRANSFER -> { + startActivities( + arrayOf( + MainActivity.clearTop(this@TransferAccountActivity), + Intent(this@TransferAccountActivity, OldDeviceTransferActivity::class.java) + ) + ) + } + + RestoreMethod.REMOTE_BACKUP, + RestoreMethod.LOCAL_BACKUP, + RestoreMethod.DECLINE, + null -> startActivity(MainActivity.clearTop(this@TransferAccountActivity)) + } + } + + setContent { + val state by viewModel.state.collectAsState() + + SignalTheme { + TransferToNewDevice( + state = state, + onTransferAccount = this::authenticate, + onContinueOnOtherDeviceDismiss = { + finish() + viewModel.clearReRegisterResult() + }, + onErrorDismiss = viewModel::clearReRegisterResult, + onBackClicked = { finish() } + ) + } + } + } + + override fun onPause() { + super.onPause() + biometricAuth.cancelAuthentication() + } + + override fun onResume() { + super.onResume() + theme.onResume(this) + } + + private fun authenticate() { + val canAuthenticate = biometricAuth.authenticate(this, true) { + biometricDeviceLockLauncher.launch(getString(R.string.TransferAccount_unlock_to_transfer)) + } + + if (!canAuthenticate) { + Log.w(TAG, "Device authentication not available") + transferAccount() + } + } + + private fun transferAccount() { + Log.d(TAG, "transferAccount()") + + viewModel.transferAccount() + } + + private inner class BiometricAuthenticationListener : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) { + Log.w(TAG, "Device authentication error: $errorCode") + onAuthenticationFailed() + } + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + Log.i(TAG, "Device authentication succeeded") + transferAccount() + } + + override fun onAuthenticationFailed() { + Log.w(TAG, "Device authentication failed") + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TransferToNewDevice( + state: TransferAccountViewModel.TransferAccountState, + onTransferAccount: () -> Unit = {}, + onContinueOnOtherDeviceDismiss: () -> Unit = {}, + onErrorDismiss: () -> Unit = {}, + onBackClicked: () -> Unit = {} +) { + Scaffold( + topBar = { TopAppBarContent(onBackClicked = onBackClicked) } + ) { contentPadding -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(contentPadding) + .horizontalGutters() + ) { + Image( + painter = painterResource(R.drawable.image_transfer_phones), + contentDescription = null, + modifier = Modifier.padding(top = 20.dp, bottom = 28.dp) + ) + + val context = LocalContext.current + val learnMore = stringResource(id = R.string.TransferAccount_learn_more) + val fullString = stringResource(id = R.string.TransferAccount_body, learnMore) + val spanned = SpanUtil.urlSubsequence(fullString, learnMore, TransferAccountActivity.LEARN_MORE_URL) + Texts.LinkifiedText( + textWithUrlSpans = spanned, + onUrlClick = { CommunicationActions.openBrowserLink(context, it) }, + style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center) + ) + + Spacer(modifier = Modifier.height(28.dp)) + + AnimatedContent( + targetState = state.inProgress, + contentAlignment = Alignment.Center + ) { inProgress -> + if (inProgress) { + CircularProgressIndicator() + } else { + Buttons.LargeTonal( + onClick = onTransferAccount, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = R.string.TransferAccount_button)) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = buildAnnotatedString { + SignalSymbol(SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.LOCK) + append(" ") + append(stringResource(id = R.string.TransferAccount_messages_e2e)) + }, + style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + ) + } + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + when (state.reRegisterResult) { + QuickRegistrationRepository.TransferAccountResult.SUCCESS -> { + ModalBottomSheet( + dragHandle = null, + onDismissRequest = onContinueOnOtherDeviceDismiss, + sheetState = sheetState + ) { + ContinueOnOtherDevice() + } + } + + QuickRegistrationRepository.TransferAccountResult.FAILED -> { + Dialogs.SimpleMessageDialog( + message = stringResource(R.string.RegistrationActivity_unable_to_connect_to_service), + dismiss = stringResource(android.R.string.ok), + onDismiss = onErrorDismiss + ) + } + + null -> Unit + } + } +} + +@SignalPreview +@Composable +private fun TransferToNewDevicePreview() { + Previews.Preview { + TransferToNewDevice(state = TransferAccountViewModel.TransferAccountState("sgnl://rereg")) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TopAppBarContent(onBackClicked: () -> Unit) { + TopAppBar( + title = { + Text(text = stringResource(R.string.TransferAccount_title)) + }, + navigationIcon = { + IconButton(onClick = onBackClicked) { + Icon( + painter = painterResource(R.drawable.symbol_x_24), + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = null + ) + } + } + ) +} + +/** + * Shown after successfully sending provisioning message to new device. + */ +@Composable +fun ContinueOnOtherDevice() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .horizontalGutters() + .padding(bottom = 54.dp) + ) { + BottomSheets.Handle() + + Spacer(modifier = Modifier.height(26.dp)) + + Image( + painter = painterResource(R.drawable.image_other_device), + contentDescription = null, + modifier = Modifier.padding(bottom = 20.dp) + ) + + Text( + text = stringResource(id = R.string.TransferAccount_continue_on_your_other_device), + style = MaterialTheme.typography.titleLarge + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(id = R.string.TransferAccount_continue_on_your_other_device_details), + style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + ) + + Spacer(modifier = Modifier.height(36.dp)) + + CircularProgressIndicator(modifier = Modifier.size(44.dp)) + } +} + +@SignalPreview +@Composable +private fun ContinueOnOtherDevicePreview() { + Previews.Preview { + ContinueOnOtherDevice() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountViewModel.kt new file mode 100644 index 0000000000..ae3273b595 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountViewModel.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.olddevice + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository +import org.whispersystems.signalservice.api.registration.RestoreMethod +import java.util.UUID + +class TransferAccountViewModel(reRegisterUri: String) : ViewModel() { + + private val store: MutableStateFlow = MutableStateFlow(TransferAccountState(reRegisterUri)) + + val state: StateFlow = store + + fun transferAccount() { + viewModelScope.launch(Dispatchers.IO) { + val restoreMethodToken = UUID.randomUUID().toString() + store.update { it.copy(inProgress = true) } + val result = QuickRegistrationRepository.transferAccount(store.value.reRegisterUri, restoreMethodToken) + store.update { it.copy(reRegisterResult = result, inProgress = false) } + + val restoreMethod = QuickRegistrationRepository.waitForRestoreMethodSelectionOnNewDevice(restoreMethodToken) + + if (restoreMethod != RestoreMethod.DECLINE) { + SignalStore.registration.restoringOnNewDevice = true + } + + store.update { it.copy(restoreMethodSelected = restoreMethod) } + } + } + + fun clearReRegisterResult() { + store.update { it.copy(reRegisterResult = null) } + } + + data class TransferAccountState( + val reRegisterUri: String, + val inProgress: Boolean = false, + val reRegisterResult: QuickRegistrationRepository.TransferAccountResult? = null, + val restoreMethodSelected: RestoreMethod? = null + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationActivity.kt new file mode 100644 index 0000000000..2c8bce2ff6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationActivity.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.navigation.ActivityNavigator +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.BaseActivity +import org.thoughtcrime.securesms.MainActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity +import org.thoughtcrime.securesms.pin.PinRestoreActivity +import org.thoughtcrime.securesms.profiles.AvatarHelper +import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.registration.sms.SmsRetrieverReceiver +import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme +import org.thoughtcrime.securesms.util.RemoteConfig + +/** + * Activity to hold the entire registration process. + */ +class RegistrationActivity : BaseActivity() { + + private val TAG = Log.tag(RegistrationActivity::class.java) + + private val dynamicTheme = DynamicNoActionBarTheme() + val sharedViewModel: RegistrationViewModel by viewModels() + + private var smsRetrieverReceiver: SmsRetrieverReceiver? = null + + init { + lifecycle.addObserver(SmsRetrieverObserver()) + } + + override fun onCreate(savedInstanceState: Bundle?) { + dynamicTheme.onCreate(this) + + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_registration_navigation_v3) + + sharedViewModel.isReregister = intent.getBooleanExtra(RE_REGISTRATION_EXTRA, false) + + sharedViewModel.checkpoint.observe(this) { + if (it >= RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE) { + handleSuccessfulVerify() + } + } + } + + override fun onResume() { + super.onResume() + dynamicTheme.onResume(this) + } + + private fun handleSuccessfulVerify() { + if (SignalStore.misc.hasLinkedDevices) { + SignalStore.misc.shouldShowLinkedDevicesReminder = sharedViewModel.isReregister + } + + if (SignalStore.storageService.needsAccountRestore) { + Log.i(TAG, "Performing pin restore.") + startActivity(Intent(this, PinRestoreActivity::class.java)) + finish() + } else { + val isProfileNameEmpty = Recipient.self().profileName.isEmpty + val isAvatarEmpty = !AvatarHelper.hasAvatar(this, Recipient.self().id) + val needsProfile = isProfileNameEmpty || isAvatarEmpty + val needsPin = !SignalStore.svr.hasOptedInWithAccess() + + Log.i(TAG, "Pin restore flow not required. Profile name empty: $isProfileNameEmpty | Profile avatar empty: $isAvatarEmpty | Needs PIN: $needsPin") + + if (!needsProfile && !needsPin) { + sharedViewModel.completeRegistration() + } + sharedViewModel.setInProgress(false) + + val startIntent = MainActivity.clearTop(this) + + val nextIntent: Intent? = when { + needsPin -> CreateSvrPinActivity.getIntentForPinCreate(this@RegistrationActivity) + !SignalStore.registration.hasSkippedTransferOrRestore() && RemoteConfig.messageBackups -> RemoteRestoreActivity.getIntent(this@RegistrationActivity) + needsProfile -> CreateProfileActivity.getIntentForUserProfile(this@RegistrationActivity) + else -> null + } + + if (nextIntent != null) { + startIntent.putExtra("next_intent", nextIntent) + } + + Log.d(TAG, "Launching ${startIntent.component} with next_intent: ${nextIntent?.component}") + startActivity(startIntent) + finish() + ActivityNavigator.applyPopAnimationsToPendingTransition(this) + } + } + + private inner class SmsRetrieverObserver : DefaultLifecycleObserver { + override fun onCreate(owner: LifecycleOwner) { + smsRetrieverReceiver = SmsRetrieverReceiver(application) + smsRetrieverReceiver?.registerReceiver() + } + + override fun onDestroy(owner: LifecycleOwner) { + smsRetrieverReceiver?.unregisterReceiver() + smsRetrieverReceiver = null + } + } + + companion object { + const val RE_REGISTRATION_EXTRA: String = "re_registration" + + @JvmStatic + fun newIntentForNewRegistration(context: Context, originalIntent: Intent): Intent { + return Intent(context, RegistrationActivity::class.java).apply { + putExtra(RE_REGISTRATION_EXTRA, false) + setData(originalIntent.data) + } + } + + @JvmStatic + fun newIntentForReRegistration(context: Context): Intent { + return Intent(context, RegistrationActivity::class.java).apply { + putExtra(RE_REGISTRATION_EXTRA, true) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationCheckpoint.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationCheckpoint.kt new file mode 100644 index 0000000000..82e04d1eeb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationCheckpoint.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui + +/** + * An ordered list of checkpoints of the registration process. + * This is used for screens to know when to advance, as well as restoring state after process death. + */ +enum class RegistrationCheckpoint { + INITIALIZATION, + PERMISSIONS_GRANTED, + BACKUP_RESTORED_OR_SKIPPED, + PUSH_NETWORK_AUDITED, + PHONE_NUMBER_CONFIRMED, + PIN_CONFIRMED, + CHALLENGE_RECEIVED, + CHALLENGE_COMPLETED, + VERIFICATION_CODE_REQUESTED, + VERIFICATION_CODE_ENTERED, + PIN_ENTERED, + VERIFICATION_CODE_VALIDATED, + SERVICE_REGISTRATION_COMPLETED, + LOCAL_REGISTRATION_COMPLETE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationState.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationState.kt new file mode 100644 index 0000000000..4bcffe037d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationState.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui + +import com.google.i18n.phonenumbers.NumberParseException +import com.google.i18n.phonenumbers.PhoneNumberUtil +import com.google.i18n.phonenumbers.Phonenumber +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.registration.data.network.Challenge +import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult +import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult +import org.whispersystems.signalservice.api.svr.Svr3Credentials +import org.whispersystems.signalservice.internal.push.AuthCredentials + +/** + * State holder shared across all of registration. + */ +data class RegistrationState( + val sessionId: String? = null, + val enteredCode: String = "", + val phoneNumber: Phonenumber.PhoneNumber? = fetchExistingE164FromValues(), + val inProgress: Boolean = false, + val isReRegister: Boolean = false, + val recoveryPassword: String? = null, + val canSkipSms: Boolean = false, + val svr2AuthCredentials: AuthCredentials? = null, + val svr3AuthCredentials: Svr3Credentials? = null, + val svrTriesRemaining: Int = 10, + val incorrectCodeAttempts: Int = 0, + val isRegistrationLockEnabled: Boolean = false, + val lockedTimeRemaining: Long = 0L, + val userSkippedReregistration: Boolean = false, + val isFcmSupported: Boolean = false, + val isAllowedToRequestCode: Boolean = false, + val fcmToken: String? = null, + val challengesRequested: List = emptyList(), + val challengesPresented: Set = emptySet(), + val captchaToken: String? = null, + val allowedToRequestCode: Boolean = false, + val nextSmsTimestamp: Long = 0L, + val nextCallTimestamp: Long = 0L, + val nextVerificationAttempt: Long = 0L, + val verified: Boolean = false, + val smsListenerTimeout: Long = 0L, + val registrationCheckpoint: RegistrationCheckpoint = RegistrationCheckpoint.INITIALIZATION, + val networkError: Throwable? = null, + val sessionCreationError: RegistrationSessionResult? = null, + val sessionStateError: VerificationCodeRequestResult? = null, + val registerAccountError: RegisterAccountResult? = null +) { + val challengesRemaining: List = challengesRequested.filterNot { it in challengesPresented } + + companion object { + private val TAG = Log.tag(RegistrationState::class) + + private fun fetchExistingE164FromValues(): Phonenumber.PhoneNumber? { + val existingE164 = SignalStore.registration.sessionE164 + if (existingE164 != null) { + try { + return PhoneNumberUtil.getInstance().parse(existingE164, null) + } catch (ex: NumberParseException) { + Log.w(TAG, "Could not parse stored E164.", ex) + return null + } + } else { + return null + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt new file mode 100644 index 0000000000..ff8c911de7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt @@ -0,0 +1,996 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui + +import android.Manifest +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import com.google.i18n.phonenumbers.PhoneNumberUtil +import com.google.i18n.phonenumbers.Phonenumber +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.util.Hex +import org.signal.core.util.Stopwatch +import org.signal.core.util.isNotNullOrBlank +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob +import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob +import org.thoughtcrime.securesms.jobs.ProfileUploadJob +import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob +import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob +import org.thoughtcrime.securesms.jobs.StorageSyncJob +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.pin.SvrRepository +import org.thoughtcrime.securesms.pin.SvrWrongPinException +import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult +import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil +import org.thoughtcrime.securesms.registration.data.RegistrationData +import org.thoughtcrime.securesms.registration.data.network.BackupAuthCheckResult +import org.thoughtcrime.securesms.registration.data.network.Challenge +import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult +import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult +import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult +import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.AlreadyVerified +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.AttemptsExhausted +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ChallengeRequired +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ExternalServiceFailure +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ImpossibleNumber +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.InvalidTransportModeFailure +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.MalformedRequest +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.MustRetry +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.NoSuchSession +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.NonNormalizedNumber +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.RateLimited +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.RegistrationLocked +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.Success +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.TokenNotAccepted +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.UnknownError +import org.thoughtcrime.securesms.registration.ui.toE164 +import org.thoughtcrime.securesms.registration.util.RegistrationUtil +import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet +import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository +import org.thoughtcrime.securesms.util.RemoteConfig +import org.thoughtcrime.securesms.util.Util +import org.thoughtcrime.securesms.util.dualsim.MccMncProducer +import org.whispersystems.signalservice.api.SvrNoDataException +import org.whispersystems.signalservice.api.kbs.MasterKey +import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlin.jvm.optionals.getOrNull +import kotlin.time.Duration.Companion.minutes + +/** + * ViewModel shared across all of registration. + */ +class RegistrationViewModel : ViewModel() { + + private val store = MutableStateFlow(RegistrationState()) + private val password = Util.getSecret(18) + + private val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception -> + Log.w(TAG, "CoroutineExceptionHandler invoked.", exception) + store.update { + it.copy( + networkError = exception, + inProgress = false + ) + } + } + + val state: StateFlow = store + + val uiState = store.asLiveData() + + val checkpoint = store.map { it.registrationCheckpoint }.asLiveData() + + val lockedTimeRemaining = store.map { it.lockedTimeRemaining }.asLiveData() + + val incorrectCodeAttempts = store.map { it.incorrectCodeAttempts }.asLiveData() + + val svrTriesRemaining: Int + get() = store.value.svrTriesRemaining + + var isReregister: Boolean + get() = store.value.isReRegister + set(value) { + store.update { + it.copy(isReRegister = value) + } + } + + val phoneNumber: Phonenumber.PhoneNumber? + get() = store.value.phoneNumber + + fun maybePrefillE164(context: Context) { + Log.v(TAG, "maybePrefillE164()") + if (Permissions.hasAll(context, Manifest.permission.READ_PHONE_STATE, Manifest.permission.READ_PHONE_NUMBERS)) { + val localNumber = Util.getDeviceNumber(context).getOrNull() + + if (localNumber != null) { + Log.v(TAG, "Phone number detected.") + setPhoneNumber(localNumber) + } else { + Log.i(TAG, "Could not read phone number.") + } + } else { + Log.i(TAG, "No phone permission.") + } + } + + fun setInProgress(inProgress: Boolean) { + store.update { + it.copy(inProgress = inProgress) + } + } + + fun setRegistrationCheckpoint(checkpoint: RegistrationCheckpoint) { + store.update { + it.copy(registrationCheckpoint = checkpoint) + } + } + + fun setPhoneNumber(phoneNumber: Phonenumber.PhoneNumber?) { + store.update { + it.copy( + phoneNumber = phoneNumber, + sessionId = null + ) + } + } + + fun setCaptchaResponse(token: String) { + store.update { + it.copy( + registrationCheckpoint = RegistrationCheckpoint.CHALLENGE_COMPLETED, + captchaToken = token + ) + } + } + + fun sessionCreationErrorShown() { + store.update { + it.copy(sessionCreationError = null) + } + } + + fun sessionStateErrorShown() { + store.update { + it.copy(sessionStateError = null) + } + } + + fun registerAccountErrorShown() { + store.update { + it.copy(registerAccountError = null) + } + } + + fun incrementIncorrectCodeAttempts() { + store.update { + it.copy(incorrectCodeAttempts = it.incorrectCodeAttempts + 1) + } + } + + fun addPresentedChallenge(challenge: Challenge) { + store.update { + it.copy(challengesPresented = it.challengesPresented.plus(challenge)) + } + } + + fun removePresentedChallenge(challenge: Challenge) { + store.update { + it.copy(challengesPresented = it.challengesPresented.minus(challenge)) + } + } + + fun fetchFcmToken(context: Context) { + viewModelScope.launch(context = coroutineExceptionHandler) { + val fcmToken = RegistrationRepository.getFcmToken(context) + store.update { + it.copy(registrationCheckpoint = RegistrationCheckpoint.PUSH_NETWORK_AUDITED, isFcmSupported = true, fcmToken = fcmToken) + } + } + } + + private suspend fun updateFcmToken(context: Context): String? { + Log.d(TAG, "Fetching FCM token…") + val fcmToken = RegistrationRepository.getFcmToken(context) + store.update { + it.copy(fcmToken = fcmToken) + } + Log.d(TAG, "FCM token fetched.") + return fcmToken + } + + fun onBackupSuccessfullyRestored() { + val recoveryPassword = SignalStore.svr.recoveryPassword + store.update { + it.copy(registrationCheckpoint = RegistrationCheckpoint.BACKUP_RESTORED_OR_SKIPPED, recoveryPassword = SignalStore.svr.recoveryPassword, canSkipSms = recoveryPassword != null, isReRegister = true) + } + } + + fun onUserConfirmedPhoneNumber(context: Context) { + setRegistrationCheckpoint(RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED) + val state = store.value + + val e164 = state.phoneNumber?.toE164() ?: return bail { Log.i(TAG, "Phone number was null after confirmation.") } + + if (!state.userSkippedReregistration) { + if (hasRecoveryPassword() && matchesSavedE164(e164)) { + // Re-registration when the local database is intact. + Log.d(TAG, "Has recovery password, and therefore can skip SMS verification.") + store.update { + it.copy( + canSkipSms = true, + isReRegister = true, + inProgress = false + ) + } + return + } + } + + viewModelScope.launch { + if (!state.userSkippedReregistration) { + val svrCredentialsResult: BackupAuthCheckResult = RegistrationRepository.hasValidSvrAuthCredentials(context, e164, password) + + when (svrCredentialsResult) { + is BackupAuthCheckResult.UnknownError -> { + handleGenericError(svrCredentialsResult.getCause()) + return@launch + } + + is BackupAuthCheckResult.SuccessWithCredentials -> { + Log.d(TAG, "Found local valid SVR auth credentials.") + store.update { + it.copy( + isReRegister = true, + canSkipSms = true, + svr2AuthCredentials = svrCredentialsResult.svr2Credentials, + svr3AuthCredentials = svrCredentialsResult.svr3Credentials, + inProgress = false + ) + } + return@launch + } + + is BackupAuthCheckResult.SuccessWithoutCredentials -> { + Log.d(TAG, "No local SVR auth credentials could be found and/or validated.") + } + } + } + + val validSession = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for confirming the entered E164.") } + + if (validSession.body.verified) { + Log.i(TAG, "Session is already verified, registering account.") + registerVerifiedSession(context, validSession.body.id) + return@launch + } + + if (!validSession.body.allowedToRequestCode) { + if (System.currentTimeMillis() > (validSession.body.nextVerificationAttempt ?: Int.MAX_VALUE)) { + store.update { + it.copy(registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED) + } + } else { + val challenges = validSession.body.requestedInformation + Log.i(TAG, "Not allowed to request code! Remaining challenges: ${challenges.joinToString()}") + handleSessionStateResult(context, ChallengeRequired(Challenge.parse(validSession.body.requestedInformation))) + } + return@launch + } + + requestSmsCodeInternal(context, validSession.body.id, e164) + } + } + + fun requestSmsCode(context: Context) { + val e164 = getCurrentE164() ?: return bail { Log.i(TAG, "Phone number was null after confirmation.") } + + viewModelScope.launch { + val validSession = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for requesting an SMS code.") } + requestSmsCodeInternal(context, validSession.body.id, e164) + } + } + + fun requestVerificationCall(context: Context) { + val e164 = getCurrentE164() + + if (e164 == null) { + Log.w(TAG, "Phone number was null after confirmation.") + onErrorOccurred() + return + } + + viewModelScope.launch { + val validSession = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for requesting a verification call.") } + Log.d(TAG, "Requesting voice call code…") + val codeRequestResponse = RegistrationRepository.requestSmsCode( + context = context, + sessionId = validSession.body.id, + e164 = e164, + password = password, + mode = RegistrationRepository.E164VerificationMode.PHONE_CALL + ) + Log.d(TAG, "Voice code request network call completed.") + + handleSessionStateResult(context, codeRequestResponse) + if (codeRequestResponse is Success) { + Log.d(TAG, "Voice code request was successful.") + } + } + } + + private suspend fun requestSmsCodeInternal(context: Context, sessionId: String, e164: String) { + var smsListenerReady = false + Log.d(TAG, "Initializing SMS listener.") + if (store.value.smsListenerTimeout < System.currentTimeMillis()) { + smsListenerReady = store.value.isFcmSupported && RegistrationRepository.registerSmsListener(context) + + if (smsListenerReady) { + val smsRetrieverTimeout = System.currentTimeMillis() + 5.minutes.inWholeMilliseconds + Log.d(TAG, "Successfully started verification code SMS retriever, which will last until $smsRetrieverTimeout.") + store.update { it.copy(smsListenerTimeout = smsRetrieverTimeout) } + } else { + Log.d(TAG, "Could not start verification code SMS retriever.") + } + } + + Log.d(TAG, "Requesting SMS code…") + val transportMode = if (smsListenerReady) RegistrationRepository.E164VerificationMode.SMS_WITH_LISTENER else RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER + val codeRequestResponse = RegistrationRepository.requestSmsCode( + context = context, + sessionId = sessionId, + e164 = e164, + password = password, + mode = transportMode + ) + Log.d(TAG, "SMS code request network call completed.") + + if (codeRequestResponse is AlreadyVerified) { + Log.d(TAG, "Got session was already verified when requesting SMS code.") + registerVerifiedSession(context, sessionId) + return + } + + handleSessionStateResult(context, codeRequestResponse) + + if (codeRequestResponse is Success) { + Log.d(TAG, "SMS code request was successful.") + store.update { + it.copy( + registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED + ) + } + } + } + + private suspend fun getOrCreateValidSession(context: Context): RegistrationSessionMetadataResponse? { + Log.v(TAG, "getOrCreateValidSession()") + val e164 = getCurrentE164() ?: throw IllegalStateException("E164 required to create session!") + val mccMncProducer = MccMncProducer(context) + + val existingSessionId = store.value.sessionId + return getOrCreateValidSession( + context = context, + existingSessionId = existingSessionId, + e164 = e164, + password = password, + mcc = mccMncProducer.mcc, + mnc = mccMncProducer.mnc, + successListener = { networkResult -> + store.update { + it.copy( + sessionId = networkResult.body.id, + nextSmsTimestamp = RegistrationRepository.deriveTimestamp(networkResult.headers, networkResult.body.nextSms), + nextCallTimestamp = RegistrationRepository.deriveTimestamp(networkResult.headers, networkResult.body.nextCall), + nextVerificationAttempt = RegistrationRepository.deriveTimestamp(networkResult.headers, networkResult.body.nextVerificationAttempt), + allowedToRequestCode = networkResult.body.allowedToRequestCode, + challengesRequested = Challenge.parse(networkResult.body.requestedInformation), + verified = networkResult.body.verified, + inProgress = false + ) + } + }, + errorHandler = { error -> + Log.d(TAG, "Setting ${error::class.simpleName} as session creation error.") + store.update { + it.copy( + sessionCreationError = error, + inProgress = false + ) + } + } + ) + } + + fun submitCaptchaToken(context: Context) { + val e164 = getCurrentE164() ?: throw IllegalStateException("Can't submit captcha token if no phone number is set!") + val captchaToken = store.value.captchaToken ?: throw IllegalStateException("Can't submit captcha token if no captcha token is set!") + + store.update { + it.copy(captchaToken = null) + } + + viewModelScope.launch { + val session = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for submitting a captcha token.") } + Log.d(TAG, "Submitting captcha token…") + val captchaSubmissionResult = RegistrationRepository.submitCaptchaToken(context, e164, password, session.body.id, captchaToken) + Log.d(TAG, "Captcha token submitted.") + + handleSessionStateResult(context, captchaSubmissionResult) + } + } + + fun requestAndSubmitPushToken(context: Context) { + Log.v(TAG, "validatePushToken()") + + addPresentedChallenge(Challenge.PUSH) + + val e164 = getCurrentE164() ?: throw IllegalStateException("Can't submit captcha token if no phone number is set!") + + viewModelScope.launch { + Log.d(TAG, "Getting session in order to perform push token verification…") + val session = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for submitting a push challenge token.") } + + if (!Challenge.parse(session.body.requestedInformation).contains(Challenge.PUSH)) { + Log.d(TAG, "Push submission no longer necessary, bailing.") + store.update { + it.copy( + inProgress = false + ) + } + return@launch bail { Log.i(TAG, "Push challenge token no longer needed, bailing.") } + } + + Log.d(TAG, "Requesting push challenge token…") + val pushSubmissionResult = RegistrationRepository.requestAndVerifyPushToken(context, session.body.id, e164, password) + Log.d(TAG, "Push challenge token submitted.") + handleSessionStateResult(context, pushSubmissionResult) + } + } + + /** + * @return whether the request was successful and execution should continue + */ + private suspend fun handleSessionStateResult(context: Context, sessionResult: VerificationCodeRequestResult): Boolean { + Log.v(TAG, "handleSessionStateResult()") + when (sessionResult) { + is UnknownError -> { + handleGenericError(sessionResult.getCause()) + } + + is Success -> { + Log.d(TAG, "New registration session status received.") + updateFcmToken(context) + store.update { + it.copy( + sessionId = sessionResult.sessionId, + nextSmsTimestamp = sessionResult.nextSmsTimestamp, + nextCallTimestamp = sessionResult.nextCallTimestamp, + isAllowedToRequestCode = sessionResult.allowedToRequestCode, + challengesRequested = emptyList(), + inProgress = false + ) + } + return true + } + + is ChallengeRequired -> { + Log.d(TAG, "[${sessionResult.challenges.joinToString()}] registration challenges received.") + store.update { + it.copy( + registrationCheckpoint = RegistrationCheckpoint.CHALLENGE_RECEIVED, + challengesRequested = sessionResult.challenges, + inProgress = false + ) + } + return false + } + + is AttemptsExhausted -> Log.i(TAG, "Received AttemptsExhausted.", sessionResult.getCause()) + + is ImpossibleNumber -> Log.i(TAG, "Received ImpossibleNumber.", sessionResult.getCause()) + + is NonNormalizedNumber -> Log.i(TAG, "Received NonNormalizedNumber.", sessionResult.getCause()) + + is RateLimited -> Log.i(TAG, "Received RateLimited.", sessionResult.getCause()) + + is ExternalServiceFailure -> Log.i(TAG, "Received ExternalServiceFailure.", sessionResult.getCause()) + + is InvalidTransportModeFailure -> Log.i(TAG, "Received InvalidTransportModeFailure.", sessionResult.getCause()) + + is MalformedRequest -> Log.i(TAG, "Received MalformedRequest.", sessionResult.getCause()) + + is MustRetry -> Log.i(TAG, "Received MustRetry.", sessionResult.getCause()) + + is TokenNotAccepted -> Log.i(TAG, "Received TokenNotAccepted.", sessionResult.getCause()) + + is RegistrationLocked -> { + store.update { + it.copy(lockedTimeRemaining = sessionResult.timeRemaining) + } + Log.i(TAG, "Received RegistrationLocked.", sessionResult.getCause()) + } + + is NoSuchSession -> Log.i(TAG, "Received NoSuchSession.", sessionResult.getCause()) + + is AlreadyVerified -> Log.i(TAG, "Received AlreadyVerified", sessionResult.getCause()) + } + setInProgress(false) + store.update { + it.copy( + sessionStateError = sessionResult + ) + } + return false + } + + /** + * @return whether the request was successful and execution should continue + */ + private suspend fun handleRegistrationResult(context: Context, registrationData: RegistrationData, registrationResult: RegisterAccountResult, reglockEnabled: Boolean): Boolean { + Log.v(TAG, "handleRegistrationResult()") + when (registrationResult) { + is RegisterAccountResult.Success -> { + Log.i(TAG, "Register account result: Success! Registration lock: $reglockEnabled") + store.update { + it.copy( + registrationCheckpoint = RegistrationCheckpoint.SERVICE_REGISTRATION_COMPLETED + ) + } + onSuccessfulRegistration(context, registrationData, registrationResult.accountRegistrationResult, reglockEnabled) + return true + } + + is RegisterAccountResult.IncorrectRecoveryPassword -> { + Log.i(TAG, "Registration recovery password was incorrect, falling back to SMS verification.", registrationResult.getCause()) + setUserSkippedReRegisterFlow(true) + } + + is RegisterAccountResult.RegistrationLocked -> { + Log.i(TAG, "Account is registration locked!", registrationResult.getCause()) + } + + is RegisterAccountResult.SvrWrongPin -> { + Log.i(TAG, "Received wrong SVR PIN response! ${registrationResult.triesRemaining} tries remaining.") + updateSvrTriesRemaining(registrationResult.triesRemaining) + } + + is RegisterAccountResult.SvrNoData, + is RegisterAccountResult.AttemptsExhausted, + is RegisterAccountResult.RateLimited, + is RegisterAccountResult.AuthorizationFailed, + is RegisterAccountResult.MalformedRequest, + is RegisterAccountResult.ValidationError, + is RegisterAccountResult.UnknownError -> Log.i(TAG, "Received error when trying to register!", registrationResult.getCause()) + } + setInProgress(false) + store.update { + it.copy( + registerAccountError = registrationResult + ) + } + return false + } + + private fun handleGenericError(cause: Throwable) { + Log.w(TAG, "Encountered unknown error!", cause) + store.update { + it.copy(inProgress = false, networkError = cause) + } + } + + private fun setRecoveryPassword(recoveryPassword: String?) { + store.update { + it.copy(recoveryPassword = recoveryPassword) + } + } + + private fun updateSvrTriesRemaining(remainingTries: Int) { + store.update { + it.copy(svrTriesRemaining = remainingTries) + } + } + + fun setUserSkippedReRegisterFlow(value: Boolean) { + store.update { + it.copy(userSkippedReregistration = value, canSkipSms = !value) + } + } + + fun verifyReRegisterWithPin(context: Context, pin: String, wrongPinHandler: () -> Unit) { + setInProgress(true) + + // Local recovery password + if (RegistrationRepository.canUseLocalRecoveryPassword()) { + if (RegistrationRepository.doesPinMatchLocalHash(pin)) { + Log.d(TAG, "Found recovery password, attempting to re-register.") + viewModelScope.launch(context = coroutineExceptionHandler) { + val masterKey = SignalStore.svr.masterKey + setRecoveryPassword(masterKey.deriveRegistrationRecoveryPassword()) + verifyReRegisterInternal(context, pin, masterKey) + setInProgress(false) + } + } else { + Log.d(TAG, "Entered PIN did not match local PIN hash.") + wrongPinHandler() + setInProgress(false) + } + return + } + + // remote recovery password + val svr2Credentials = store.value.svr2AuthCredentials + val svr3Credentials = store.value.svr3AuthCredentials + + if (svr2Credentials != null || svr3Credentials != null) { + Log.d(TAG, "Found SVR auth credentials, fetching recovery password from SVR (svr2: ${svr2Credentials != null}, svr3: ${svr3Credentials != null}).") + viewModelScope.launch(context = coroutineExceptionHandler) { + try { + val masterKey = RegistrationRepository.fetchMasterKeyFromSvrRemote(pin, svr2Credentials, svr3Credentials) + setRecoveryPassword(masterKey.deriveRegistrationRecoveryPassword()) + updateSvrTriesRemaining(10) + verifyReRegisterInternal(context, pin, masterKey) + } catch (rejectedPin: SvrWrongPinException) { + Log.w(TAG, "Submitted PIN was rejected by SVR.", rejectedPin) + updateSvrTriesRemaining(rejectedPin.triesRemaining) + wrongPinHandler() + } catch (noData: SvrNoDataException) { + Log.w(TAG, "SVR has no data for these credentials. Aborting skip SMS flow.", noData) + updateSvrTriesRemaining(0) + setUserSkippedReRegisterFlow(true) + } + setInProgress(false) + } + return + } + + Log.w(TAG, "Could not get credentials to skip SMS registration, aborting!") + store.update { + it.copy(canSkipSms = false, inProgress = false) + } + } + + private suspend fun verifyReRegisterInternal(context: Context, pin: String?, masterKey: MasterKey) { + Log.v(TAG, "verifyReRegisterInternal(hasPin=${pin != null})") + updateFcmToken(context) + + val registrationData = getRegistrationData() + + val resultAndRegLockStatus = registerAccountInternal(context, null, registrationData, pin, masterKey) + val result = resultAndRegLockStatus.first + val reglockEnabled = resultAndRegLockStatus.second + + handleRegistrationResult(context, registrationData, result, reglockEnabled) + } + + /** + * @return a [Pair] containing the server response and a boolean signifying whether the current account is registration locked. + */ + private suspend fun registerAccountInternal(context: Context, sessionId: String?, registrationData: RegistrationData, pin: String?, masterKey: MasterKey): Pair { + Log.v(TAG, "registerAccountInternal()") + var registrationResult: RegisterAccountResult = RegistrationRepository.registerAccount(context = context, sessionId = sessionId, registrationData = registrationData, pin = pin) + + // Check if reg lock is enabled + if (registrationResult !is RegisterAccountResult.RegistrationLocked) { + if (registrationResult is RegisterAccountResult.Success) { + registrationResult = RegisterAccountResult.Success(registrationResult.accountRegistrationResult.copy(masterKey = masterKey)) + } + + Log.i(TAG, "Received a non-registration lock response to registration. Assuming registration lock as DISABLED") + return Pair(registrationResult, false) + } + + Log.i(TAG, "Received a registration lock response when trying to register an account. Retrying with master key.") + store.update { + it.copy( + svr2AuthCredentials = registrationResult.svr2Credentials, + svr3AuthCredentials = registrationResult.svr3Credentials + ) + } + + return Pair(RegistrationRepository.registerAccount(context = context, sessionId = sessionId, registrationData = registrationData, pin = pin) { masterKey }, true) + } + + fun verifyCodeWithoutRegistrationLock(context: Context, code: String) { + Log.v(TAG, "verifyCodeWithoutRegistrationLock()") + store.update { + it.copy( + inProgress = true, + enteredCode = code, + registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_ENTERED + ) + } + + viewModelScope.launch(context = coroutineExceptionHandler) { + verifyCodeInternal( + context = context, + registrationLocked = false, + pin = null + ) + } + } + + fun verifyCodeAndRegisterAccountWithRegistrationLock(context: Context, pin: String) { + Log.v(TAG, "verifyCodeAndRegisterAccountWithRegistrationLock()") + store.update { + it.copy( + inProgress = true, + registrationCheckpoint = RegistrationCheckpoint.PIN_ENTERED + ) + } + viewModelScope.launch { + verifyCodeInternal( + context = context, + registrationLocked = true, + pin = pin + ) + } + } + + private suspend fun verifyCodeInternal(context: Context, registrationLocked: Boolean, pin: String?) { + Log.d(TAG, "Getting valid session in order to submit verification code.") + + if (registrationLocked && pin.isNullOrBlank()) { + throw IllegalStateException("Must have PIN to register with registration lock!") + } + + var reglock = registrationLocked + + val sessionId = getOrCreateValidSession(context)?.body?.id ?: return + val registrationData = getRegistrationData() + + Log.d(TAG, "Submitting verification code…") + + val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData) + + val submissionSuccessful = verificationResponse is Success + val alreadyVerified = verificationResponse is AlreadyVerified + + Log.d(TAG, "Verification code submission network call completed. Submission successful? $submissionSuccessful Account already verified? $alreadyVerified") + + if (!submissionSuccessful && !alreadyVerified) { + handleSessionStateResult(context, verificationResponse) + return + } + + Log.d(TAG, "Submitting registration…") + + var result: RegisterAccountResult? = null + var state = store.value + + if (!reglock) { + Log.d(TAG, "Registration lock not enabled, attempting to register account without master key producer.") + result = RegistrationRepository.registerAccount(context, sessionId, registrationData, pin) + } + + if (result is RegisterAccountResult.RegistrationLocked) { + Log.d(TAG, "Registration lock response received.") + val timeRemaining = result.timeRemaining + store.update { + it.copy(lockedTimeRemaining = timeRemaining) + } + reglock = true + if (pin == null && SignalStore.svr.registrationLockToken != null) { + Log.d(TAG, "Retrying registration with stored credentials.") + result = RegistrationRepository.registerAccount(context, sessionId, registrationData, SignalStore.svr.pin) { SignalStore.svr.masterKey } + } else if (result.svr2Credentials != null || result.svr3Credentials != null) { + Log.d(TAG, "Retrying registration with received credentials (svr2: ${result.svr2Credentials != null}, svr3: ${result.svr3Credentials != null}).") + val svr2Credentials = result.svr2Credentials + val svr3Credentials = result.svr3Credentials + state = store.updateAndGet { + it.copy(svr2AuthCredentials = svr2Credentials, svr3AuthCredentials = svr3Credentials) + } + } + } + + if (reglock && pin.isNotNullOrBlank()) { + Log.d(TAG, "Registration lock enabled, attempting to register account restore master key from SVR (svr2: ${state.svr2AuthCredentials != null}, svr3: ${state.svr3AuthCredentials != null})") + result = RegistrationRepository.registerAccount(context, sessionId, registrationData, pin) { + SvrRepository.restoreMasterKeyPreRegistration( + credentials = SvrAuthCredentialSet( + svr2Credentials = state.svr2AuthCredentials, + svr3Credentials = state.svr3AuthCredentials + ), + userPin = pin + ) + } + } + + if (result != null) { + handleRegistrationResult(context, registrationData, result, reglock) + } else { + Log.w(TAG, "No registration response received!") + } + } + + private suspend fun registerVerifiedSession(context: Context, sessionId: String) { + Log.v(TAG, "registerVerifiedSession()") + val registrationData = getRegistrationData() + val registrationResponse: RegisterAccountResult = RegistrationRepository.registerAccount(context, sessionId, registrationData) + handleRegistrationResult(context, registrationData, registrationResponse, false) + } + + private suspend fun onSuccessfulRegistration(context: Context, registrationData: RegistrationData, remoteResult: AccountRegistrationResult, reglockEnabled: Boolean) = withContext(Dispatchers.IO) { + Log.v(TAG, "onSuccessfulRegistration()") + val metadata = LocalRegistrationMetadataUtil.createLocalRegistrationMetadata(SignalStore.account.aciIdentityKey, SignalStore.account.pniIdentityKey, registrationData, remoteResult, reglockEnabled) + SignalStore.registration.localRegistrationMetadata = metadata + RegistrationRepository.registerAccountLocally(context, metadata) + + if (!remoteResult.storageCapable && !SignalStore.registration.hasCompletedRestore()) { + // Not being storage capable is a high signal that account is new and there's no data to restore + SignalStore.registration.markSkippedTransferOrRestore() + } + + if (reglockEnabled || SignalStore.svr.hasOptedInWithAccess()) { + SignalStore.onboarding.clearAll() + } + + if (reglockEnabled || SignalStore.svr.hasOptedInWithAccess()) { + val stopwatch = Stopwatch("post-reg-storage-service") + + AppDependencies.jobManager.runSynchronously(StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN) + stopwatch.split("account-restore") + + AppDependencies.jobManager + .startChain(StorageSyncJob()) + .then(ReclaimUsernameAndLinkJob()) + .enqueueAndBlockUntilCompletion(TimeUnit.SECONDS.toMillis(10)) + stopwatch.split("storage-sync") + + BackupRepository.restoreBackupTier(SignalStore.account.requireAci()) + stopwatch.split("backup-tier") + + stopwatch.stop(TAG) + } + + refreshRemoteConfig() + + store.update { + it.copy( + registrationCheckpoint = RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE, + inProgress = false + ) + } + } + + fun completeRegistration() { + AppDependencies.jobManager.startChain(ProfileUploadJob()).then(listOf(MultiDeviceProfileKeyUpdateJob(), MultiDeviceProfileContentUpdateJob())).enqueue() + RegistrationUtil.maybeMarkRegistrationComplete() + } + + fun networkErrorShown() { + store.update { + it.copy(networkError = null) + } + } + + private fun matchesSavedE164(e164: String?): Boolean { + return if (e164 == null) { + false + } else { + e164 == SignalStore.account.e164 + } + } + + private fun hasRecoveryPassword(): Boolean { + return store.value.recoveryPassword != null + } + + private fun getCurrentE164(): String? { + return store.value.phoneNumber?.toE164() + } + + private suspend fun getRegistrationData(): RegistrationData { + val currentState = store.value + val code = currentState.enteredCode + val e164: String = currentState.phoneNumber?.toE164() ?: throw IllegalStateException("Can't construct registration data without E164!") + val recoveryPassword = if (currentState.sessionId == null && hasRecoveryPassword()) store.value.recoveryPassword!! else null + return RegistrationData(code, e164, password, RegistrationRepository.getRegistrationId(), RegistrationRepository.getProfileKey(e164), currentState.fcmToken, RegistrationRepository.getPniRegistrationId(), recoveryPassword) + } + + /** + * This is a generic error UI handler that re-enables the UI so that the user can recover from errors. + * Do not forget to log any errors when calling this method! + */ + private fun onErrorOccurred() { + setInProgress(false) + } + + /** + * Used for early returns in order to end the in-progress visual state, as well as print a log message explaining what happened. + * + * @param logMessage Logging code is wrapped in lambda so that our automated tools detect the various [Log] calls with their accompanying messages. + */ + private fun bail(logMessage: () -> Unit) { + logMessage() + setInProgress(false) + } + + fun registerWithBackupKey(context: Context, backupKey: String, e164: String?, pin: String?) { + setInProgress(true) + + viewModelScope.launch(context = coroutineExceptionHandler) { + if (e164 != null) { + setPhoneNumber(PhoneNumberUtil.getInstance().parse(e164, null)) + } + + // TODO [backups] use new data and not master key + val masterKey = MasterKey(Hex.fromStringCondensed(backupKey)) + SignalStore.svr.setMasterKey(masterKey, pin) + setRecoveryPassword(masterKey.deriveRegistrationRecoveryPassword()) + verifyReRegisterInternal(context = context, pin = pin, masterKey = masterKey) + + setInProgress(false) + } + } + + companion object { + private val TAG = Log.tag(RegistrationViewModel::class.java) + + private suspend fun refreshRemoteConfig() = withContext(Dispatchers.IO) { + val startTime = System.currentTimeMillis() + try { + RemoteConfig.refreshSync() + Log.i(TAG, "Took " + (System.currentTimeMillis() - startTime) + " ms to get feature flags.") + } catch (e: IOException) { + Log.w(TAG, "Failed to refresh flags after " + (System.currentTimeMillis() - startTime) + " ms.", e) + } + } + + suspend fun getOrCreateValidSession( + context: Context, + existingSessionId: String?, + e164: String, + password: String, + mcc: String?, + mnc: String?, + successListener: (RegistrationSessionMetadataResponse) -> Unit, + errorHandler: (RegistrationSessionResult) -> Unit + ): RegistrationSessionMetadataResponse? { + Log.d(TAG, "Validating/creating a registration session.") + val sessionResult: RegistrationSessionResult = RegistrationRepository.createOrValidateSession(context, existingSessionId, e164, password, mcc, mnc) + when (sessionResult) { + is RegistrationSessionCheckResult.Success -> { + val metadata = sessionResult.getMetadata() + successListener(metadata) + Log.d(TAG, "Registration session validated.") + return metadata + } + + is RegistrationSessionCreationResult.Success -> { + val metadata = sessionResult.getMetadata() + successListener(metadata) + Log.d(TAG, "Registration session created.") + return metadata + } + + else -> { + Log.d(TAG, "Handling error during session creation.") + errorHandler(sessionResult) + } + } + return null + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/accountlocked/AccountLockedFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/accountlocked/AccountLockedFragment.kt new file mode 100644 index 0000000000..c2099c3a75 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/accountlocked/AccountLockedFragment.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.accountlocked + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.TextView +import androidx.activity.OnBackPressedCallback +import androidx.fragment.app.activityViewModels +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel +import kotlin.time.Duration.Companion.milliseconds + +/** + * Screen educating the user that they need to wait some number of days to register. + */ +class AccountLockedFragment : LoggingFragment(R.layout.account_locked_fragment) { + private val viewModel by activityViewModels() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setDebugLogSubmitMultiTapView(view.findViewById(R.id.account_locked_title)) + + val description = view.findViewById(R.id.account_locked_description) + + viewModel.lockedTimeRemaining.observe( + viewLifecycleOwner + ) { t: Long? -> description.text = getString(R.string.AccountLockedFragment__your_account_has_been_locked_to_protect_your_privacy, durationToDays(t!!)) } + + view.findViewById(R.id.account_locked_next).setOnClickListener { v: View? -> onNext() } + view.findViewById(R.id.account_locked_learn_more).setOnClickListener { v: View? -> learnMore() } + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + onNext() + } + } + ) + } + + private fun learnMore() { + val intent = Intent(Intent.ACTION_VIEW) + intent.setData(Uri.parse(getString(R.string.AccountLockedFragment__learn_more_url))) + startActivity(intent) + } + + fun onNext() { + requireActivity().finish() + } + + private fun durationToDays(duration: Long): Long { + return if (duration != 0L) getLockoutDays(duration).toLong() else 7 + } + + private fun getLockoutDays(timeRemainingMs: Long): Int { + return timeRemainingMs.milliseconds.inWholeDays.toInt() + 1 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/captcha/CaptchaFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/captcha/CaptchaFragment.kt new file mode 100644 index 0000000000..034e6761cc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/captcha/CaptchaFragment.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.captcha + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.View +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.OnBackPressedCallback +import androidx.activity.addCallback +import androidx.navigation.fragment.findNavController +import org.thoughtcrime.securesms.BuildConfig +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.databinding.FragmentRegistrationCaptchaBinding +import org.thoughtcrime.securesms.registration.fragments.RegistrationConstants + +abstract class CaptchaFragment : LoggingFragment(R.layout.fragment_registration_captcha) { + + private val binding: FragmentRegistrationCaptchaBinding by ViewBinderDelegate(FragmentRegistrationCaptchaBinding::bind) + + private val backListener = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + handleUserExit() + } + } + + @SuppressLint("SetJavaScriptEnabled") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.registrationCaptchaWebView.settings.javaScriptEnabled = true + binding.registrationCaptchaWebView.clearCache(true) + + binding.registrationCaptchaWebView.webViewClient = object : WebViewClient() { + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean { + if (url.startsWith(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME)) { + val token = url.substring(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME.length) + handleCaptchaToken(token) + backListener.isEnabled = false + findNavController().navigateUp() + return true + } + return false + } + } + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + handleUserExit() + } + binding.registrationCaptchaWebView.loadUrl(BuildConfig.SIGNAL_CAPTCHA_URL) + } + + abstract fun handleCaptchaToken(token: String) + + abstract fun handleUserExit() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/captcha/RegistrationCaptchaFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/captcha/RegistrationCaptchaFragment.kt new file mode 100644 index 0000000000..da9fc97d6b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/captcha/RegistrationCaptchaFragment.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.captcha + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.activityViewModels +import org.thoughtcrime.securesms.registration.data.network.Challenge +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel + +/** + * Screen that displays a captcha as part of the registration flow. + * This subclass plugs in [RegistrationViewModel] to the shared super class. + * + * @see CaptchaFragment + */ +class RegistrationCaptchaFragment : CaptchaFragment() { + private val sharedViewModel by activityViewModels() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + sharedViewModel.addPresentedChallenge(Challenge.CAPTCHA) + } + + override fun handleCaptchaToken(token: String) { + sharedViewModel.setCaptchaResponse(token) + } + + override fun handleUserExit() { + sharedViewModel.removePresentedChallenge(Challenge.CAPTCHA) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeFragment.kt new file mode 100644 index 0000000000..c2d1075feb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeFragment.kt @@ -0,0 +1,300 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.entercode + +import android.content.DialogInterface +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.activity.OnBackPressedCallback +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.i18n.phonenumbers.PhoneNumberUtil +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle +import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterCodeBinding +import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult +import org.thoughtcrime.securesms.registration.data.network.RegistrationResult +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult +import org.thoughtcrime.securesms.registration.fragments.ContactSupportBottomSheetFragment +import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView +import org.thoughtcrime.securesms.registration.fragments.SignalStrengthPhoneStateListener +import org.thoughtcrime.securesms.registration.sms.ReceivedSmsEvent +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel +import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener +import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.thoughtcrime.securesms.util.visible + +/** + * The final screen of account registration, where the user enters their verification code. + */ +class EnterCodeFragment : LoggingFragment(R.layout.fragment_registration_enter_code) { + + companion object { + private const val BOTTOM_SHEET_TAG = "support_bottom_sheet" + } + + private val TAG = Log.tag(EnterCodeFragment::class.java) + + private val sharedViewModel by activityViewModels() + private val fragmentViewModel by viewModels() + private val bottomSheet = ContactSupportBottomSheetFragment() + private val binding: FragmentRegistrationEnterCodeBinding by ViewBinderDelegate(FragmentRegistrationEnterCodeBinding::bind) + + private lateinit var phoneStateListener: SignalStrengthPhoneStateListener + + private var autopilotCodeEntryActive = false + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setDebugLogSubmitMultiTapView(binding.verifyHeader) + + phoneStateListener = SignalStrengthPhoneStateListener(this, PhoneStateCallback()) + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + popBackStack() + } + } + ) + + binding.wrongNumber.setOnClickListener { + popBackStack() + } + + binding.code.setOnCompleteListener { + sharedViewModel.verifyCodeWithoutRegistrationLock(requireContext(), it) + } + + binding.havingTroubleButton.setOnClickListener { + bottomSheet.showSafely(childFragmentManager, BOTTOM_SHEET_TAG) + } + + binding.callMeCountDown.apply { + setTextResources(R.string.RegistrationActivity_call, R.string.RegistrationActivity_call_me_instead_available_in) + setOnClickListener { + sharedViewModel.requestVerificationCall(requireContext()) + } + } + + binding.resendSmsCountDown.apply { + setTextResources(R.string.RegistrationActivity_resend_code, R.string.RegistrationActivity_resend_sms_available_in) + setOnClickListener { + sharedViewModel.requestSmsCode(requireContext()) + } + } + + binding.keyboard.setOnKeyPressListener { key -> + if (!autopilotCodeEntryActive) { + if (key >= 0) { + binding.code.append(key) + } else { + binding.code.delete() + } + } + } + + sharedViewModel.incorrectCodeAttempts.observe(viewLifecycleOwner) { attempts: Int -> + if (attempts >= 3) { + binding.havingTroubleButton.visible = true + } + } + + sharedViewModel.uiState.observe(viewLifecycleOwner) { + it.sessionStateError?.let { error -> + handleSessionErrorResponse(error) + sharedViewModel.sessionStateErrorShown() + } + + it.registerAccountError?.let { error -> + handleRegistrationErrorResponse(error) + sharedViewModel.registerAccountErrorShown() + } + + binding.resendSmsCountDown.startCountDownTo(it.nextSmsTimestamp) + binding.callMeCountDown.startCountDownTo(it.nextCallTimestamp) + if (it.inProgress) { + binding.keyboard.displayProgress() + } else { + binding.keyboard.displayKeyboard() + } + } + + fragmentViewModel.uiState.observe(viewLifecycleOwner) { + if (it.resetRequiredAfterFailure) { + binding.callMeCountDown.visibility = View.VISIBLE + binding.resendSmsCountDown.visibility = View.VISIBLE + binding.wrongNumber.visibility = View.VISIBLE + binding.code.clear() + binding.keyboard.displayKeyboard() + fragmentViewModel.allViewsResetCompleted() + } else if (it.showKeyboard) { + binding.keyboard.displayKeyboard() + fragmentViewModel.keyboardShown() + } + } + + EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = viewLifecycleOwner) + } + + override fun onResume() { + super.onResume() + sharedViewModel.phoneNumber?.let { + val formatted = PhoneNumberUtil.getInstance().format(it, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL) + binding.verificationSubheader.text = requireContext().getString(R.string.RegistrationActivity_enter_the_code_we_sent_to_s, formatted) + } + } + + private fun handleSessionErrorResponse(result: VerificationCodeRequestResult) { + when (result) { + is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!") + is VerificationCodeRequestResult.RateLimited -> presentRateLimitedDialog() + is VerificationCodeRequestResult.AttemptsExhausted -> presentAccountLocked() + is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining) + else -> presentGenericError(result) + } + } + + private fun handleRegistrationErrorResponse(result: RegisterAccountResult) { + when (result) { + is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!") + is RegisterAccountResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining) + is RegisterAccountResult.AuthorizationFailed -> presentIncorrectCodeDialog() + is RegisterAccountResult.AttemptsExhausted -> presentAccountLocked() + is RegisterAccountResult.RateLimited -> presentRateLimitedDialog() + + else -> presentGenericError(result) + } + } + + private fun presentAccountLocked() { + binding.keyboard.displayLocked().addListener( + object : AssertedSuccessListener() { + override fun onSuccess(result: Boolean?) { + findNavController().safeNavigate(EnterCodeFragmentDirections.actionAccountLocked()) + } + } + ) + } + + private fun presentRegistrationLocked(timeRemaining: Long) { + binding.keyboard.displayLocked().addListener( + object : AssertedSuccessListener() { + override fun onSuccess(result: Boolean?) { + findNavController().safeNavigate(EnterCodeFragmentDirections.actionRequireKbsLockPin(timeRemaining)) + } + } + ) + } + + private fun presentRateLimitedDialog() { + binding.keyboard.displayFailure().addListener( + object : AssertedSuccessListener() { + override fun onSuccess(result: Boolean?) { + MaterialAlertDialogBuilder(requireContext()).apply { + setTitle(R.string.RegistrationActivity_too_many_attempts) + setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later) + setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> + fragmentViewModel.resetAllViews() + } + show() + } + } + } + ) + } + + private fun presentIncorrectCodeDialog() { + sharedViewModel.incrementIncorrectCodeAttempts() + + Toast.makeText(requireContext(), R.string.RegistrationActivity_incorrect_code, Toast.LENGTH_LONG).show() + + binding.keyboard.displayFailure().addListener(object : AssertedSuccessListener() { + override fun onSuccess(result: Boolean?) { + fragmentViewModel.resetAllViews() + } + }) + } + + private fun presentGenericError(requestResult: RegistrationResult) { + binding.keyboard.displayFailure().addListener( + object : AssertedSuccessListener() { + override fun onSuccess(result: Boolean?) { + Log.w(TAG, "Encountered unexpected error!", requestResult.getCause()) + MaterialAlertDialogBuilder(requireContext()).apply { + null?.let { + setTitle(it) + } + setMessage(getString(R.string.RegistrationActivity_error_connecting_to_service)) + setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.showKeyboard() } + show() + } + } + } + ) + } + + private fun popBackStack() { + sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PUSH_NETWORK_AUDITED) + NavHostFragment.findNavController(this).popBackStack() + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onVerificationCodeReceived(event: ReceivedSmsEvent) { + Log.i(TAG, "Received verification code via EventBus.") + binding.code.clear() + + if (event.code.isBlank() || event.code.length != ReceivedSmsEvent.CODE_LENGTH) { + Log.i(TAG, "Received invalid code of length ${event.code.length}. Ignoring.") + return + } + + val finalIndex = ReceivedSmsEvent.CODE_LENGTH - 1 + autopilotCodeEntryActive = true + try { + event.code + .map { it.digitToInt() } + .forEachIndexed { i, digit -> + binding.code.postDelayed({ + binding.code.append(digit) + if (i == finalIndex) { + autopilotCodeEntryActive = false + } + }, i * 200L) + } + Log.i(TAG, "Finished auto-filling code.") + } catch (notADigit: IllegalArgumentException) { + Log.w(TAG, "Failed to convert code into digits.", notADigit) + autopilotCodeEntryActive = false + } + } + + private inner class PhoneStateCallback : SignalStrengthPhoneStateListener.Callback { + override fun onNoCellSignalPresent() { + if (isAdded) { + bottomSheet.showSafely(childFragmentManager, BOTTOM_SHEET_TAG) + } + } + + override fun onCellSignalPresent() { + if (bottomSheet.isResumed) { + bottomSheet.dismiss() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeState.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeState.kt new file mode 100644 index 0000000000..f1b19819d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeState.kt @@ -0,0 +1,8 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.entercode + +data class EnterCodeState(val resetRequiredAfterFailure: Boolean = false, val showKeyboard: Boolean = false) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeViewModel.kt new file mode 100644 index 0000000000..9074a4f534 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeViewModel.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.entercode + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +class EnterCodeViewModel : ViewModel() { + private val store = MutableStateFlow(EnterCodeState()) + val uiState = store.asLiveData() + + fun resetAllViews() { + store.update { it.copy(resetRequiredAfterFailure = true) } + } + + fun allViewsResetCompleted() { + store.update { + it.copy( + resetRequiredAfterFailure = false, + showKeyboard = false + ) + } + } + + fun showKeyboard() { + store.update { it.copy(showKeyboard = true) } + } + + fun keyboardShown() { + store.update { it.copy(showKeyboard = false) } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/permissions/GrantPermissionsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/permissions/GrantPermissionsFragment.kt new file mode 100644 index 0000000000..416792d633 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/permissions/GrantPermissionsFragment.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.permissions + +import android.content.pm.PackageManager +import android.os.Build +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.core.os.bundleOf +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.setFragmentResult +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.registration.fragments.WelcomePermissions +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel +import org.thoughtcrime.securesms.registrationv3.ui.welcome.WelcomeUserSelection +import org.thoughtcrime.securesms.util.BackupUtil + +/** + * Screen in account registration that provides rationales for the suggested runtime permissions. + */ +@RequiresApi(23) +class GrantPermissionsFragment : ComposeFragment() { + + companion object { + private val TAG = Log.tag(GrantPermissionsFragment::class.java) + + const val REQUEST_KEY = "GrantPermissionsFragment" + } + + private val sharedViewModel by activityViewModels() + private val args by navArgs() + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ::onPermissionsGranted + ) + + private val welcomeUserSelection: WelcomeUserSelection by lazy { args.welcomeUserSelection } + + @Composable + override fun FragmentContent() { + GrantPermissionsScreen( + deviceBuildVersion = Build.VERSION.SDK_INT, + isBackupSelectionRequired = BackupUtil.isUserSelectionRequired(LocalContext.current), + onNextClicked = this::launchPermissionRequests, + onNotNowClicked = this::proceedToNextScreen + ) + } + + private fun launchPermissionRequests() { + val isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext()) + + val neededPermissions = WelcomePermissions.getWelcomePermissions(isUserSelectionRequired).filterNot { + ContextCompat.checkSelfPermission(requireContext(), it) == PackageManager.PERMISSION_GRANTED + } + + if (neededPermissions.isEmpty()) { + proceedToNextScreen() + } else { + requestPermissionLauncher.launch(neededPermissions.toTypedArray()) + } + } + + private fun onPermissionsGranted(permissions: Map) { + permissions.forEach { + Log.d(TAG, "${it.key} = ${it.value}") + } + sharedViewModel.maybePrefillE164(requireContext()) + sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED) + proceedToNextScreen() + } + + private fun proceedToNextScreen() { + setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to welcomeUserSelection)) + findNavController().popBackStack() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/permissions/GrantPermissionsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/permissions/GrantPermissionsScreen.kt new file mode 100644 index 0000000000..a8c19bc34c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/permissions/GrantPermissionsScreen.kt @@ -0,0 +1,147 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.permissions + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.Buttons +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen + +/** + * Layout that explains permissions rationale to the user. + */ +@Composable +fun GrantPermissionsScreen( + deviceBuildVersion: Int, + isBackupSelectionRequired: Boolean, + onNextClicked: () -> Unit = {}, + onNotNowClicked: () -> Unit = {} +) { + RegistrationScreen( + title = stringResource(id = R.string.GrantPermissionsFragment__allow_permissions), + subtitle = stringResource(id = R.string.GrantPermissionsFragment__to_help_you_message_people_you_know), + bottomContent = { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + TextButton(onClick = onNotNowClicked) { + Text( + text = stringResource(id = R.string.GrantPermissionsFragment__not_now) + ) + } + + Buttons.LargeTonal( + onClick = onNextClicked + ) { + Text( + text = stringResource(id = R.string.GrantPermissionsFragment__next) + ) + } + } + } + ) { + if (deviceBuildVersion >= 33) { + PermissionRow( + imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification), + title = stringResource(id = R.string.GrantPermissionsFragment__notifications), + subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when) + ) + } + + PermissionRow( + imageVector = ImageVector.vectorResource(id = R.drawable.permission_contact), + title = stringResource(id = R.string.GrantPermissionsFragment__contacts), + subtitle = stringResource(id = R.string.GrantPermissionsFragment__find_people_you_know) + ) + + if (deviceBuildVersion < 29 || !isBackupSelectionRequired) { + PermissionRow( + imageVector = ImageVector.vectorResource(id = R.drawable.permission_file), + title = stringResource(id = R.string.GrantPermissionsFragment__storage), + subtitle = stringResource(id = R.string.GrantPermissionsFragment__send_photos_videos_and_files) + ) + } + + PermissionRow( + imageVector = ImageVector.vectorResource(id = R.drawable.permission_phone), + title = stringResource(id = R.string.GrantPermissionsFragment__phone_calls), + subtitle = stringResource(id = R.string.GrantPermissionsFragment__make_registering_easier) + ) + } +} + +@SignalPreview +@Composable +fun GrantPermissionsScreenPreview() { + Previews.Preview { + GrantPermissionsScreen( + deviceBuildVersion = 33, + isBackupSelectionRequired = true + ) + } +} + +@Composable +fun PermissionRow( + imageVector: ImageVector, + title: String, + subtitle: String +) { + Row(modifier = Modifier.padding(bottom = 32.dp)) { + Image( + imageVector = imageVector, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + + Spacer(modifier = Modifier.size(16.dp)) + + Column { + Text( + text = title, + style = MaterialTheme.typography.titleSmall + ) + + Text( + text = subtitle, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.size(32.dp)) + } +} + +@SignalPreview +@Composable +fun PermissionRowPreview() { + Previews.Preview { + PermissionRow( + imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification), + title = stringResource(id = R.string.GrantPermissionsFragment__notifications), + subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt new file mode 100644 index 0000000000..d584df6d32 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt @@ -0,0 +1,652 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.phonenumber + +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.text.Editable +import android.text.SpannableStringBuilder +import android.text.TextWatcher +import android.view.KeyEvent +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.ArrayAdapter +import android.widget.TextView +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.MenuProvider +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.textfield.MaterialAutoCompleteTextView +import com.google.android.material.textfield.TextInputEditText +import com.google.i18n.phonenumbers.NumberParseException +import com.google.i18n.phonenumbers.PhoneNumberUtil +import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber +import org.signal.core.util.isNotNullOrBlank +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterPhoneNumberBinding +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter +import org.thoughtcrime.securesms.registration.data.network.Challenge +import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult +import org.thoughtcrime.securesms.registration.data.network.RegistrationResult +import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult +import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult +import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult +import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView +import org.thoughtcrime.securesms.registration.ui.toE164 +import org.thoughtcrime.securesms.registration.util.CountryPrefix +import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.Dialogs +import org.thoughtcrime.securesms.util.PlayServicesUtil +import org.thoughtcrime.securesms.util.SpanUtil +import org.thoughtcrime.securesms.util.SupportEmailUtil +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.livedata.LiveDataObserverCallback +import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.thoughtcrime.securesms.util.visible +import kotlin.time.Duration.Companion.milliseconds + +/** + * Screen in registration where the user enters their phone number. + */ +class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_enter_phone_number) { + + private val TAG = Log.tag(EnterPhoneNumberFragment::class.java) + private val sharedViewModel by activityViewModels() + private val fragmentViewModel by viewModels() + private val args by navArgs() + private val binding: FragmentRegistrationEnterPhoneNumberBinding by ViewBinderDelegate(FragmentRegistrationEnterPhoneNumberBinding::bind) + + private val enterPhoneNumberMode: EnterPhoneNumberMode by lazy { args.enterPhoneNumberMode } + private var processedResumeMode: Boolean = false + + private val skipToNextScreen: DialogInterface.OnClickListener = DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> moveToVerificationEntryScreen() } + + private lateinit var spinnerAdapter: ArrayAdapter + private lateinit var phoneNumberInputLayout: TextInputEditText + private lateinit var spinnerView: MaterialAutoCompleteTextView + + private var currentPhoneNumberFormatter: TextWatcher? = null + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setDebugLogSubmitMultiTapView(binding.verifyHeader) + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + popBackStack() + } + } + ) + phoneNumberInputLayout = binding.number.editText as TextInputEditText + spinnerView = binding.countryCode.editText as MaterialAutoCompleteTextView + spinnerAdapter = ArrayAdapter( + requireContext(), + R.layout.registration_country_code_dropdown_item, + fragmentViewModel.supportedCountryPrefixes + ) + binding.registerButton.setOnClickListener { onRegistrationButtonClicked() } + + binding.toolbar.title = "" + val activity = requireActivity() as AppCompatActivity + activity.setSupportActionBar(binding.toolbar) + + requireActivity().addMenuProvider(UseProxyMenuProvider(), viewLifecycleOwner) + + sharedViewModel.uiState.observe(viewLifecycleOwner) { sharedState -> + presentRegisterButton(sharedState) + presentProgressBar(sharedState.inProgress, sharedState.isReRegister) + + sharedState.networkError?.let { + presentNetworkError(it) + sharedViewModel.networkErrorShown() + } + + sharedState.sessionCreationError?.let { + handleSessionCreationError(it) + sharedViewModel.sessionCreationErrorShown() + } + + sharedState.sessionStateError?.let { + handleSessionStateError(it) + sharedViewModel.sessionStateErrorShown() + } + + sharedState.registerAccountError?.let { + handleRegistrationErrorResponse(it) + sharedViewModel.registerAccountErrorShown() + } + + if (sharedState.challengesRequested.contains(Challenge.CAPTCHA) && sharedState.captchaToken.isNotNullOrBlank()) { + sharedViewModel.submitCaptchaToken(requireContext()) + } else if (sharedState.challengesRemaining.isNotEmpty()) { + handleChallenges(sharedState.challengesRemaining) + } else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED && sharedState.canSkipSms) { + moveToEnterPinScreen() + } else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED) { + moveToVerificationEntryScreen() + } + } + + fragmentViewModel.uiState.observe(viewLifecycleOwner) { fragmentState -> + + fragmentState.phoneNumberFormatter?.let { + bindPhoneNumberFormatter(it) + phoneNumberInputLayout.requestFocus() + } + + if (fragmentViewModel.isEnteredNumberPossible(fragmentState)) { + sharedViewModel.setPhoneNumber(fragmentViewModel.parsePhoneNumber(fragmentState)) + } else { + sharedViewModel.setPhoneNumber(null) + } + + if (fragmentState.error != EnterPhoneNumberState.Error.NONE) { + presentLocalError(fragmentState) + } + } + + initializeInputFields() + + val existingPhoneNumber = sharedViewModel.phoneNumber + if (existingPhoneNumber != null) { + fragmentViewModel.restoreState(existingPhoneNumber) + spinnerView.setText(existingPhoneNumber.countryCode.toString()) + fragmentViewModel.formatter?.let { + bindPhoneNumberFormatter(it) + } + phoneNumberInputLayout.setText(existingPhoneNumber.nationalNumber.toString()) + } else { + spinnerView.setText(fragmentViewModel.countryPrefix().toString()) + } + + if (enterPhoneNumberMode == EnterPhoneNumberMode.RESTART_AFTER_COLLECTION && (savedInstanceState == null && !processedResumeMode)) { + processedResumeMode = true + startNormalRegistration() + } else { + ViewUtil.focusAndShowKeyboard(phoneNumberInputLayout) + } + } + + private fun bindPhoneNumberFormatter(formatter: TextWatcher) { + if (formatter != currentPhoneNumberFormatter) { + currentPhoneNumberFormatter?.let { oldWatcher -> + Log.d(TAG, "Removing current phone number formatter in fragment") + phoneNumberInputLayout.removeTextChangedListener(oldWatcher) + } + phoneNumberInputLayout.addTextChangedListener(formatter) + currentPhoneNumberFormatter = formatter + Log.d(TAG, "Updated phone number formatter in fragment") + } + } + + private fun handleChallenges(remainingChallenges: List) { + when (remainingChallenges.first()) { + Challenge.CAPTCHA -> moveToCaptcha() + Challenge.PUSH -> performPushChallenge() + } + } + + private fun performPushChallenge() { + sharedViewModel.requestAndSubmitPushToken(requireContext()) + } + + private fun initializeInputFields() { + binding.countryCode.editText?.addTextChangedListener { s -> + val sanitized = s.toString().filter { c -> c.isDigit() } + if (sanitized.isNotNullOrBlank()) { + val countryCode: Int = sanitized.toInt() + fragmentViewModel.setCountry(countryCode) + } + } + + phoneNumberInputLayout.addTextChangedListener { + fragmentViewModel.setPhoneNumber(it?.toString()) + } + + val scrollView = binding.scrollView + val registerButton = binding.registerButton + phoneNumberInputLayout.onFocusChangeListener = View.OnFocusChangeListener { _: View?, hasFocus: Boolean -> + if (hasFocus) { + scrollView.postDelayed({ + scrollView.smoothScrollTo(0, registerButton.bottom) + }, 250) + } + } + + phoneNumberInputLayout.imeOptions = EditorInfo.IME_ACTION_DONE + phoneNumberInputLayout.setOnEditorActionListener { v: TextView?, actionId: Int, _: KeyEvent? -> + if (actionId == EditorInfo.IME_ACTION_DONE && v != null) { + onRegistrationButtonClicked() + return@setOnEditorActionListener true + } + false + } + + spinnerView.threshold = 100 + spinnerView.setAdapter(spinnerAdapter) + spinnerView.addTextChangedListener(afterTextChanged = ::onCountryDropDownChanged) + } + + private fun onCountryDropDownChanged(s: Editable?) { + if (s.isNullOrEmpty()) { + return + } + + if (s[0] != '+') { + s.insert(0, "+") + } + + fragmentViewModel.supportedCountryPrefixes.firstOrNull { it.toString() == s.toString() }?.let { + fragmentViewModel.setCountry(it.digits) + val numberLength: Int = phoneNumberInputLayout.text?.length ?: 0 + phoneNumberInputLayout.setSelection(numberLength, numberLength) + } + } + + private fun presentRegisterButton(sharedState: RegistrationState) { + binding.registerButton.isEnabled = sharedState.phoneNumber != null && PhoneNumberUtil.getInstance().isPossibleNumber(sharedState.phoneNumber) + if (sharedState.inProgress) { + binding.registerButton.setSpinning() + } else { + binding.registerButton.cancelSpinning() + } + } + + private fun presentLocalError(state: EnterPhoneNumberState) { + when (state.error) { + EnterPhoneNumberState.Error.NONE -> Unit + + EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER -> { + MaterialAlertDialogBuilder(requireContext()).apply { + setTitle(R.string.RegistrationActivity_invalid_number) + setMessage( + String.format( + getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid), + state.phoneNumber + ) + ) + setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.clearError() } + setOnCancelListener { fragmentViewModel.clearError() } + setOnDismissListener { fragmentViewModel.clearError() } + show() + } + } + + EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING -> { + handlePromptForNoPlayServices() + } + + EnterPhoneNumberState.Error.PLAY_SERVICES_NEEDS_UPDATE -> { + GoogleApiAvailability.getInstance().getErrorDialog(requireActivity(), ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED, 0)?.show() + } + + EnterPhoneNumberState.Error.PLAY_SERVICES_TRANSIENT -> { + MaterialAlertDialogBuilder(requireContext()).apply { + setTitle(R.string.RegistrationActivity_play_services_error) + setMessage(R.string.RegistrationActivity_google_play_services_is_updating_or_unavailable) + setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.clearError() } + setOnCancelListener { fragmentViewModel.clearError() } + setOnDismissListener { fragmentViewModel.clearError() } + show() + } + } + } + } + + private fun presentNetworkError(networkError: Throwable) { + Log.i(TAG, "Unknown error during verification code request", networkError) + MaterialAlertDialogBuilder(requireContext()).apply { + setMessage(R.string.RegistrationActivity_unable_to_connect_to_service) + setPositiveButton(android.R.string.ok, null) + show() + } + } + + private fun handleSessionCreationError(result: RegistrationSessionResult) { + if (!result.isSuccess()) { + Log.i(TAG, "Handling error response of ${result.javaClass.name}", result.getCause()) + } + when (result) { + is RegistrationSessionCheckResult.Success, + is RegistrationSessionCreationResult.Success -> throw IllegalStateException("Session error handler called on successful response!") + + is RegistrationSessionCreationResult.AttemptsExhausted -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_service)) + is RegistrationSessionCreationResult.MalformedRequest -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen) + + is RegistrationSessionCreationResult.RateLimited -> { + Log.i(TAG, "Session creation rate limited! Next attempt: ${result.timeRemaining.milliseconds}") + presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_try_again, result.timeRemaining.milliseconds.toString())) + } + + is RegistrationSessionCreationResult.ServerUnableToParse -> presentGenericError(result) + is RegistrationSessionCheckResult.SessionNotFound -> presentGenericError(result) + is RegistrationSessionCheckResult.UnknownError, + is RegistrationSessionCreationResult.UnknownError -> presentGenericError(result) + } + } + + private fun handleSessionStateError(result: VerificationCodeRequestResult) { + if (!result.isSuccess()) { + Log.i(TAG, "Handling error response.", result.getCause()) + } + when (result) { + is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!") + is VerificationCodeRequestResult.AttemptsExhausted -> presentRateLimitedDialog() + is VerificationCodeRequestResult.ChallengeRequired -> handleChallenges(result.challenges) + is VerificationCodeRequestResult.ExternalServiceFailure -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen) + is VerificationCodeRequestResult.ImpossibleNumber -> { + MaterialAlertDialogBuilder(requireContext()).apply { + setMessage(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid, fragmentViewModel.phoneNumber?.toE164())) + setPositiveButton(android.R.string.ok, null) + show() + } + } + + is VerificationCodeRequestResult.InvalidTransportModeFailure -> { + MaterialAlertDialogBuilder(requireContext()).apply { + setMessage(R.string.RegistrationActivity_we_couldnt_send_you_a_verification_code) + setPositiveButton(R.string.RegistrationActivity_voice_call) { _, _ -> + sharedViewModel.requestVerificationCall(requireContext()) + } + setNegativeButton(R.string.RegistrationActivity_cancel, null) + show() + } + } + + is VerificationCodeRequestResult.MalformedRequest -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen) + is VerificationCodeRequestResult.MustRetry -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen) + is VerificationCodeRequestResult.NonNormalizedNumber -> handleNonNormalizedNumberError(result.originalNumber, result.normalizedNumber, fragmentViewModel.e164VerificationMode) + is VerificationCodeRequestResult.RateLimited -> { + Log.i(TAG, "Code request rate limited! Next attempt: ${result.timeRemaining.milliseconds}") + presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_try_again, result.timeRemaining.milliseconds.toString())) + } + + is VerificationCodeRequestResult.TokenNotAccepted -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_we_need_to_verify_that_youre_human)) { _, _ -> moveToCaptcha() } + is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining) + is VerificationCodeRequestResult.AlreadyVerified -> presentGenericError(result) + is VerificationCodeRequestResult.NoSuchSession -> presentGenericError(result) + is VerificationCodeRequestResult.UnknownError -> presentGenericError(result) + } + } + + private fun presentGenericError(result: RegistrationResult) { + Log.i(TAG, "Received unhandled response: ${result.javaClass.name}", result.getCause()) + presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service)) + } + + private fun handleRegistrationErrorResponse(result: RegisterAccountResult) { + when (result) { + is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!") + is RegisterAccountResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining) + is RegisterAccountResult.AttemptsExhausted -> presentAccountLocked() + is RegisterAccountResult.RateLimited -> presentRateLimitedDialog() + is RegisterAccountResult.SvrNoData -> presentAccountLocked() + else -> presentGenericError(result) + } + } + + private fun presentRegistrationLocked(timeRemaining: Long) { + findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionPhoneNumberRegistrationLock(timeRemaining)) + } + + private fun presentRateLimitedDialog() { + presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_service)) + } + + private fun presentAccountLocked() { + findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionPhoneNumberAccountLocked()) + } + + private fun moveToCaptcha() { + findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionRequestCaptcha()) + } + + private fun presentRemoteErrorDialog(message: String, positiveButtonListener: DialogInterface.OnClickListener? = null) { + MaterialAlertDialogBuilder(requireContext()).apply { + setMessage(message) + setPositiveButton(android.R.string.ok, positiveButtonListener) + show() + } + } + + private fun handleNonNormalizedNumberError(originalNumber: String, normalizedNumber: String, mode: RegistrationRepository.E164VerificationMode) { + try { + val phoneNumber = PhoneNumberUtil.getInstance().parse(normalizedNumber, null) + + MaterialAlertDialogBuilder(requireContext()).apply { + setTitle(R.string.RegistrationActivity_non_standard_number_format) + setMessage(getString(R.string.RegistrationActivity_the_number_you_entered_appears_to_be_a_non_standard, originalNumber, normalizedNumber)) + setNegativeButton(android.R.string.no) { d: DialogInterface, i: Int -> d.dismiss() } + setNeutralButton(R.string.RegistrationActivity_contact_signal_support) { dialogInterface, _ -> + val subject = getString(R.string.RegistrationActivity_signal_android_phone_number_format) + val body = SupportEmailUtil.generateSupportEmailBody(requireContext(), R.string.RegistrationActivity_signal_android_phone_number_format, null, null) + + CommunicationActions.openEmail(requireContext(), SupportEmailUtil.getSupportEmailAddress(requireContext()), subject, body) + dialogInterface.dismiss() + } + setPositiveButton(R.string.yes) { dialogInterface, _ -> + spinnerView.setText(phoneNumber.countryCode.toString()) + phoneNumberInputLayout.setText(phoneNumber.nationalNumber.toString()) + when (mode) { + RegistrationRepository.E164VerificationMode.SMS_WITH_LISTENER, + RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER -> sharedViewModel.requestSmsCode(requireContext()) + + RegistrationRepository.E164VerificationMode.PHONE_CALL -> sharedViewModel.requestVerificationCall(requireContext()) + } + dialogInterface.dismiss() + } + show() + } + } catch (e: NumberParseException) { + Log.w(TAG, "Failed to parse number!", e) + + Dialogs.showAlertDialog( + requireContext(), + getString(R.string.RegistrationActivity_invalid_number), + getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid, fragmentViewModel.phoneNumber?.toE164()) + ) + } + } + + private fun onRegistrationButtonClicked() { + when (enterPhoneNumberMode) { + EnterPhoneNumberMode.NORMAL, + EnterPhoneNumberMode.RESTART_AFTER_COLLECTION -> startNormalRegistration() + + EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToEnterBackupKey()) + } + } + + private fun startNormalRegistration() { + ViewUtil.hideKeyboard(requireContext(), phoneNumberInputLayout) + sharedViewModel.setInProgress(true) + val hasFcm = validateFcmStatus(requireContext()) + if (hasFcm) { + sharedViewModel.uiState.observe(viewLifecycleOwner, FcmTokenRetrievedObserver()) + sharedViewModel.fetchFcmToken(requireContext()) + } else { + sharedViewModel.uiState.value?.let { value -> + val now = System.currentTimeMillis() + if (value.phoneNumber == null) { + fragmentViewModel.setError(EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER) + sharedViewModel.setInProgress(false) + } else if (now < value.nextSmsTimestamp) { + moveToVerificationEntryScreen() + } else { + presentConfirmNumberDialog(value.phoneNumber, value.isReRegister, value.canSkipSms, missingFcmConsentRequired = true) + } + } + } + } + + private fun onFcmTokenRetrieved(value: RegistrationState) { + if (value.phoneNumber == null) { + fragmentViewModel.setError(EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER) + sharedViewModel.setInProgress(false) + } else { + presentConfirmNumberDialog(value.phoneNumber, value.isReRegister, value.canSkipSms, missingFcmConsentRequired = false) + } + } + + private fun presentProgressBar(showProgress: Boolean, isReRegister: Boolean) { + if (showProgress) { + binding.registerButton.setSpinning() + } else { + binding.registerButton.cancelSpinning() + } + binding.countryCode.isEnabled = !showProgress + binding.number.isEnabled = !showProgress + binding.cancelButton.visible = !showProgress && isReRegister + } + + private fun validateFcmStatus(context: Context): Boolean { + val fcmStatus = PlayServicesUtil.getPlayServicesStatus(context) + Log.d(TAG, "Got $fcmStatus for Play Services status.") + when (fcmStatus) { + PlayServicesUtil.PlayServicesStatus.SUCCESS -> { + return true + } + + PlayServicesUtil.PlayServicesStatus.DISABLED -> { + return false + } + + PlayServicesUtil.PlayServicesStatus.MISSING -> { + fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING) + return false + } + + PlayServicesUtil.PlayServicesStatus.NEEDS_UPDATE -> { + fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_NEEDS_UPDATE) + return false + } + + PlayServicesUtil.PlayServicesStatus.TRANSIENT_ERROR -> { + fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_TRANSIENT) + return false + } + } + } + + private fun handleConfirmNumberDialogCanceled() { + Log.d(TAG, "User canceled confirm number, returning to edit number.") + sharedViewModel.setInProgress(false) + ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(phoneNumberInputLayout) + } + + private fun presentConfirmNumberDialog(phoneNumber: PhoneNumber, isReRegister: Boolean, canSkipSms: Boolean, missingFcmConsentRequired: Boolean) { + val title = if (isReRegister) { + R.string.RegistrationActivity_additional_verification_required + } else { + R.string.RegistrationActivity_phone_number_verification_dialog_title + } + + val message: CharSequence = SpannableStringBuilder().apply { + append(SpanUtil.bold(PhoneNumberFormatter.prettyPrint(phoneNumber.toE164()))) + if (!canSkipSms) { + append("\n\n") + append(getString(R.string.RegistrationActivity_a_verification_code_will_be_sent_to_this_number)) + } + } + + MaterialAlertDialogBuilder(requireContext()).apply { + setTitle(title) + setMessage(message) + setPositiveButton(android.R.string.ok) { _, _ -> + Log.d(TAG, "User confirmed number.") + if (missingFcmConsentRequired) { + handlePromptForNoPlayServices() + } else { + sharedViewModel.onUserConfirmedPhoneNumber(requireContext()) + } + } + setNegativeButton(R.string.RegistrationActivity_edit_number) { _, _ -> handleConfirmNumberDialogCanceled() } + setOnCancelListener { _ -> handleConfirmNumberDialogCanceled() } + }.show() + } + + private fun handlePromptForNoPlayServices() { + val context = activity + + if (context != null) { + Log.d(TAG, "Device does not have Play Services, showing consent dialog.") + MaterialAlertDialogBuilder(context).apply { + setTitle(R.string.RegistrationActivity_missing_google_play_services) + setMessage(R.string.RegistrationActivity_this_device_is_missing_google_play_services) + setPositiveButton(R.string.RegistrationActivity_i_understand) { _, _ -> + Log.d(TAG, "User confirmed number.") + sharedViewModel.onUserConfirmedPhoneNumber(AppDependencies.application) + } + setNegativeButton(android.R.string.cancel, null) + setOnCancelListener { fragmentViewModel.clearError() } + setOnDismissListener { fragmentViewModel.clearError() } + show() + } + } + } + + private fun moveToEnterPinScreen() { + findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionReRegisterWithPinFragment()) + sharedViewModel.setInProgress(false) + } + + private fun moveToVerificationEntryScreen() { + findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionEnterVerificationCode()) + sharedViewModel.setInProgress(false) + } + + private fun popBackStack() { + sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.INITIALIZATION) + findNavController().popBackStack() + } + + private inner class FcmTokenRetrievedObserver : LiveDataObserverCallback(sharedViewModel.uiState) { + override fun onValue(value: RegistrationState): Boolean { + val fcmRetrieved = value.isFcmSupported + if (fcmRetrieved) { + onFcmTokenRetrieved(value) + } + return fcmRetrieved + } + } + + private inner class UseProxyMenuProvider : MenuProvider { + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.enter_phone_number, menu) + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return if (menuItem.itemId == R.id.phone_menu_use_proxy) { + NavHostFragment.findNavController(this@EnterPhoneNumberFragment).safeNavigate(EnterPhoneNumberFragmentDirections.actionEditProxy()) + true + } else { + false + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberMode.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberMode.kt new file mode 100644 index 0000000000..d6f3bf4e75 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberMode.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.phonenumber + +/** + * Enter phone number mode to determine if verification is needed or just e164 input is necessary. + */ +enum class EnterPhoneNumberMode { + /** Normal registration start, collect number to verify */ + NORMAL, + + /** User pre-selected restore/transfer flow, collect number to re-register and restore with */ + COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE, + + /** User reversed decision on restore and needs to resume normal re-register but automatically start verify */ + RESTART_AFTER_COLLECTION +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberState.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberState.kt new file mode 100644 index 0000000000..eb4f0f0ca6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberState.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.phonenumber + +import android.text.TextWatcher +import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository + +/** + * State holder for the phone number entry screen, including phone number and Play Services errors. + */ +data class EnterPhoneNumberState( + val countryPrefixIndex: Int = 0, + val phoneNumber: String = "", + val phoneNumberFormatter: TextWatcher? = null, + val mode: RegistrationRepository.E164VerificationMode = RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER, + val error: Error = Error.NONE +) { + enum class Error { + NONE, INVALID_PHONE_NUMBER, PLAY_SERVICES_MISSING, PLAY_SERVICES_NEEDS_UPDATE, PLAY_SERVICES_TRANSIENT + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberViewModel.kt new file mode 100644 index 0000000000..0d10f620fc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberViewModel.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.phonenumber + +import android.telephony.PhoneNumberFormattingTextWatcher +import android.text.TextWatcher +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope +import com.google.i18n.phonenumbers.NumberParseException +import com.google.i18n.phonenumbers.PhoneNumberUtil +import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.registration.util.CountryPrefix +import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository + +/** + * ViewModel for the phone number entry screen. + */ +class EnterPhoneNumberViewModel : ViewModel() { + + private val TAG = Log.tag(EnterPhoneNumberViewModel::class.java) + + private val store = MutableStateFlow(EnterPhoneNumberState()) + val uiState = store.asLiveData() + + val formatter: TextWatcher? + get() = store.value.phoneNumberFormatter + + val phoneNumber: PhoneNumber? + get() = try { + parsePhoneNumber(store.value) + } catch (ex: NumberParseException) { + Log.w(TAG, "Could not parse phone number in current state.", ex) + null + } + + val supportedCountryPrefixes: List = PhoneNumberUtil.getInstance().supportedCallingCodes + .map { CountryPrefix(it, PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(it)) } + .sortedBy { it.digits } + + var e164VerificationMode: RegistrationRepository.E164VerificationMode + get() = store.value.mode + set(value) = store.update { + it.copy(mode = value) + } + + fun countryPrefix(): CountryPrefix { + return supportedCountryPrefixes[store.value.countryPrefixIndex] + } + + fun setPhoneNumber(phoneNumber: String?) { + store.update { it.copy(phoneNumber = phoneNumber ?: "") } + } + + fun setCountry(digits: Int) { + val matchingIndex = countryCodeToAdapterIndex(digits) + if (matchingIndex == -1) { + Log.d(TAG, "Invalid country code specified $digits") + return + } + + store.update { + it.copy(countryPrefixIndex = matchingIndex) + } + + viewModelScope.launch { + withContext(Dispatchers.Default) { + val regionCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(digits) + val textWatcher = PhoneNumberFormattingTextWatcher(regionCode) + + store.update { + Log.d(TAG, "Updating phone number formatter in state") + it.copy(phoneNumberFormatter = textWatcher) + } + } + } + } + + fun parsePhoneNumber(state: EnterPhoneNumberState): PhoneNumber { + return PhoneNumberUtil.getInstance().parse(state.phoneNumber, supportedCountryPrefixes[state.countryPrefixIndex].regionCode) + } + + fun isEnteredNumberPossible(state: EnterPhoneNumberState): Boolean { + return try { + PhoneNumberUtil.getInstance().isPossibleNumber(parsePhoneNumber(state)) + } catch (ex: NumberParseException) { + false + } + } + + fun restoreState(value: PhoneNumber) { + val prefixIndex = countryCodeToAdapterIndex(value.countryCode) + if (prefixIndex != -1) { + store.update { + it.copy( + countryPrefixIndex = prefixIndex, + phoneNumber = value.nationalNumber.toString() + ) + } + } + } + + private fun countryCodeToAdapterIndex(countryCode: Int): Int { + return supportedCountryPrefixes.indexOfFirst { prefix -> prefix.digits == countryCode } + } + + fun clearError() { + setError(EnterPhoneNumberState.Error.NONE) + } + + fun setError(error: EnterPhoneNumberState.Error) { + store.update { + it.copy(error = error) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/registrationlock/RegistrationLockFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/registrationlock/RegistrationLockFragment.kt new file mode 100644 index 0000000000..9abccff461 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/registrationlock/RegistrationLockFragment.kt @@ -0,0 +1,304 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.registrationlock + +import android.os.Bundle +import android.text.InputType +import android.view.KeyEvent +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.TextView +import android.widget.Toast +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.databinding.FragmentRegistrationLockBinding +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.lock.v2.PinKeyboardType +import org.thoughtcrime.securesms.lock.v2.SvrConstants +import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult +import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult +import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.SupportEmailUtil +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.navigation.safeNavigate +import java.util.concurrent.TimeUnit + +class RegistrationLockFragment : LoggingFragment(R.layout.fragment_registration_lock) { + companion object { + private val TAG = Log.tag(RegistrationLockFragment::class.java) + } + + private val binding: FragmentRegistrationLockBinding by ViewBinderDelegate(FragmentRegistrationLockBinding::bind) + + private val viewModel by activityViewModels() + + private var timeRemaining: Long = 0 + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title)) + + val args: RegistrationLockFragmentArgs = RegistrationLockFragmentArgs.fromBundle(requireArguments()) + + timeRemaining = args.getTimeRemaining() + + binding.kbsLockForgotPin.visibility = View.GONE + binding.kbsLockForgotPin.setOnClickListener { handleForgottenPin(timeRemaining) } + + binding.kbsLockPinInput.setImeOptions(EditorInfo.IME_ACTION_DONE) + binding.kbsLockPinInput.setOnEditorActionListener { v: TextView?, actionId: Int, _: KeyEvent? -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + ViewUtil.hideKeyboard(requireContext(), v!!) + handlePinEntry() + return@setOnEditorActionListener true + } + false + } + + enableAndFocusPinEntry() + + binding.kbsLockPinConfirm.setOnClickListener { + ViewUtil.hideKeyboard(requireContext(), binding.kbsLockPinInput) + handlePinEntry() + } + + binding.kbsLockKeyboardToggle.setOnClickListener { + val keyboardType: PinKeyboardType = getPinEntryKeyboardType() + updateKeyboard(keyboardType.other) + binding.kbsLockKeyboardToggle.setIconResource(keyboardType.iconResource) + } + + val keyboardType: PinKeyboardType = getPinEntryKeyboardType().getOther() + binding.kbsLockKeyboardToggle.setIconResource(keyboardType.iconResource) + + viewModel.lockedTimeRemaining.observe(viewLifecycleOwner) { t: Long -> timeRemaining = t } + + val triesRemaining: Int = viewModel.svrTriesRemaining + + if (triesRemaining <= 3) { + val daysRemaining = getLockoutDays(timeRemaining) + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.RegistrationLockFragment__not_many_tries_left) + .setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining)) + .setPositiveButton(android.R.string.ok, null) + .setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ -> sendEmailToSupport() } + .show() + } + + if (triesRemaining < 5) { + binding.kbsLockPinInputLabel.text = requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__d_attempts_remaining, triesRemaining, triesRemaining) + } + + viewModel.uiState.observe(viewLifecycleOwner) { + if (it.inProgress) { + binding.kbsLockPinConfirm.setSpinning() + } else { + binding.kbsLockPinConfirm.cancelSpinning() + } + + it.sessionStateError?.let { error -> + handleSessionErrorResponse(error) + viewModel.sessionStateErrorShown() + } + + it.registerAccountError?.let { error -> + handleRegistrationErrorResponse(error) + viewModel.registerAccountErrorShown() + } + } + } + + private fun handlePinEntry() { + binding.kbsLockPinInput.setEnabled(false) + + val pin: String = binding.kbsLockPinInput.getText().toString() + + val trimmedLength = pin.replace(" ", "").length + if (trimmedLength == 0) { + Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show() + enableAndFocusPinEntry() + return + } + + if (trimmedLength < SvrConstants.MINIMUM_PIN_LENGTH) { + Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, SvrConstants.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show() + enableAndFocusPinEntry() + return + } + + SignalStore.pin.keyboardType = getPinEntryKeyboardType() + + binding.kbsLockPinConfirm.setSpinning() + + viewModel.verifyCodeAndRegisterAccountWithRegistrationLock(requireContext(), pin) + } + + private fun handleSessionErrorResponse(requestResult: VerificationCodeRequestResult) { + when (requestResult) { + is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!") + is VerificationCodeRequestResult.RateLimited -> onRateLimited() + is VerificationCodeRequestResult.AttemptsExhausted -> { + findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked()) + } + + is VerificationCodeRequestResult.RegistrationLocked -> { + Log.i(TAG, "Registration locked response to verify account!") + binding.kbsLockPinConfirm.cancelSpinning() + enableAndFocusPinEntry() + Toast.makeText(requireContext(), "Reg lock!", Toast.LENGTH_LONG).show() + } + + else -> { + Log.w(TAG, "Unable to verify code with registration lock", requestResult.getCause()) + onError() + } + } + } + + private fun handleRegistrationErrorResponse(result: RegisterAccountResult) { + when (result) { + is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!") + is RegisterAccountResult.RateLimited -> onRateLimited() + is RegisterAccountResult.AttemptsExhausted -> { + findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked()) + } + + is RegisterAccountResult.RegistrationLocked -> { + Log.i(TAG, "Registration locked response to register account!") + binding.kbsLockPinConfirm.cancelSpinning() + enableAndFocusPinEntry() + Toast.makeText(requireContext(), "Reg lock!", Toast.LENGTH_LONG).show() + } + + is RegisterAccountResult.SvrWrongPin -> onIncorrectKbsRegistrationLockPin(result.triesRemaining) + is RegisterAccountResult.SvrNoData -> { + findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked()) + } + + else -> { + Log.w(TAG, "Unable to register account with registration lock", result.getCause()) + onError() + } + } + } + + private fun onIncorrectKbsRegistrationLockPin(svrTriesRemaining: Int) { + binding.kbsLockPinConfirm.cancelSpinning() + binding.kbsLockPinInput.getText().clear() + enableAndFocusPinEntry() + + if (svrTriesRemaining == 0) { + Log.w(TAG, "Account locked. User out of attempts on KBS.") + findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked()) + return + } + + if (svrTriesRemaining == 3) { + val daysRemaining = getLockoutDays(timeRemaining) + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.RegistrationLockFragment__incorrect_pin) + .setMessage(getTriesRemainingDialogMessage(svrTriesRemaining, daysRemaining)) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + if (svrTriesRemaining > 5) { + binding.kbsLockPinInputLabel.setText(R.string.RegistrationLockFragment__incorrect_pin_try_again) + } else { + binding.kbsLockPinInputLabel.text = requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, svrTriesRemaining, svrTriesRemaining) + binding.kbsLockForgotPin.visibility = View.VISIBLE + } + } + + private fun onRateLimited() { + binding.kbsLockPinConfirm.cancelSpinning() + enableAndFocusPinEntry() + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.RegistrationActivity_too_many_attempts) + .setMessage(R.string.RegistrationActivity_you_have_made_too_many_incorrect_registration_lock_pin_attempts_please_try_again_in_a_day) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + fun onError() { + binding.kbsLockPinConfirm.cancelSpinning() + enableAndFocusPinEntry() + + Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show() + } + + private fun handleForgottenPin(timeRemainingMs: Long) { + val lockoutDays = getLockoutDays(timeRemainingMs) + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.RegistrationLockFragment__forgot_your_pin) + .setMessage(requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__for_your_privacy_and_security_there_is_no_way_to_recover, lockoutDays, lockoutDays)) + .setPositiveButton(android.R.string.ok, null) + .setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ -> sendEmailToSupport() } + .show() + } + + private fun getLockoutDays(timeRemainingMs: Long): Int { + return TimeUnit.MILLISECONDS.toDays(timeRemainingMs).toInt() + 1 + } + + private fun getTriesRemainingDialogMessage(triesRemaining: Int, daysRemaining: Int): String { + val resources = requireContext().resources + val tries = resources.getQuantityString(R.plurals.RegistrationLockFragment__you_have_d_attempts_remaining, triesRemaining, triesRemaining) + val days = resources.getQuantityString(R.plurals.RegistrationLockFragment__if_you_run_out_of_attempts_your_account_will_be_locked_for_d_days, daysRemaining, daysRemaining) + + return "$tries $days" + } + + private fun enableAndFocusPinEntry() { + binding.kbsLockPinInput.setEnabled(true) + binding.kbsLockPinInput.setFocusable(true) + ViewUtil.focusAndShowKeyboard(binding.kbsLockPinInput) + } + + private fun getPinEntryKeyboardType(): PinKeyboardType { + val isNumeric = (binding.kbsLockPinInput.inputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER + + return if (isNumeric) PinKeyboardType.NUMERIC else PinKeyboardType.ALPHA_NUMERIC + } + + private fun updateKeyboard(keyboard: PinKeyboardType) { + val isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC + + binding.kbsLockPinInput.setInputType( + if (isAlphaNumeric) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD + else InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD + ) + + binding.kbsLockPinInput.getText().clear() + } + + private fun sendEmailToSupport() { + val subject = R.string.RegistrationLockFragment__signal_registration_need_help_with_pin_for_android_v2_pin + + val body = SupportEmailUtil.generateSupportEmailBody( + requireContext(), + subject, + null, + null + ) + CommunicationActions.openEmail( + requireContext(), + SupportEmailUtil.getSupportEmailAddress(requireContext()), + getString(subject), + body + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinFragment.kt new file mode 100644 index 0000000000..11290207ca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinFragment.kt @@ -0,0 +1,287 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.reregisterwithpin + +import android.os.Bundle +import android.text.InputType +import android.view.View +import android.view.inputmethod.EditorInfo +import android.widget.Toast +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.databinding.FragmentRegistrationPinRestoreEntryV2Binding +import org.thoughtcrime.securesms.lock.v2.PinKeyboardType +import org.thoughtcrime.securesms.lock.v2.SvrConstants +import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult +import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel +import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.SupportEmailUtil +import org.thoughtcrime.securesms.util.ViewUtil +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +class ReRegisterWithPinFragment : LoggingFragment(R.layout.fragment_registration_pin_restore_entry_v2) { + companion object { + private val TAG = Log.tag(ReRegisterWithPinFragment::class.java) + } + + private val registrationViewModel by activityViewModels() + private val reRegisterViewModel by viewModels() + + private val binding: FragmentRegistrationPinRestoreEntryV2Binding by ViewBinderDelegate(FragmentRegistrationPinRestoreEntryV2Binding::bind) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + RegistrationViewDelegate.setDebugLogSubmitMultiTapView(binding.pinRestorePinTitle) + binding.pinRestorePinDescription.setText(R.string.RegistrationLockFragment__enter_the_pin_you_created_for_your_account) + + binding.pinRestoreForgotPin.visibility = View.GONE + binding.pinRestoreForgotPin.setOnClickListener { onNeedHelpClicked() } + + binding.pinRestoreSkipButton.setOnClickListener { onSkipClicked() } + + binding.pinRestorePinInput.imeOptions = EditorInfo.IME_ACTION_DONE + binding.pinRestorePinInput.setOnEditorActionListener { v, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_DONE) { + ViewUtil.hideKeyboard(requireContext(), v!!) + handlePinEntry() + return@setOnEditorActionListener true + } + false + } + + enableAndFocusPinEntry() + + binding.pinRestorePinContinue.setOnClickListener { + handlePinEntry() + } + + binding.pinRestoreKeyboardToggle.setOnClickListener { + val currentKeyboardType: PinKeyboardType = getPinEntryKeyboardType() + updateKeyboard(currentKeyboardType.other) + binding.pinRestoreKeyboardToggle.setIconResource(currentKeyboardType.iconResource) + } + + binding.pinRestoreKeyboardToggle.setIconResource(getPinEntryKeyboardType().other.iconResource) + + registrationViewModel.uiState.observe(viewLifecycleOwner, ::updateViewState) + } + + private fun updateViewState(state: RegistrationState) { + if (state.networkError != null) { + genericErrorDialog() + registrationViewModel.networkErrorShown() + } else if (!state.canSkipSms) { + findNavController().safeNavigate(ReRegisterWithPinFragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberFragment(EnterPhoneNumberMode.NORMAL)) + } else if (state.isRegistrationLockEnabled && state.svrTriesRemaining == 0) { + Log.w(TAG, "Unable to continue skip flow, KBS is locked") + onAccountLocked() + } else { + presentProgress(state.inProgress) + presentTriesRemaining(state.svrTriesRemaining) + } + + state.registerAccountError?.let { error -> + registrationErrorHandler(error) + registrationViewModel.registerAccountErrorShown() + } + } + + private fun presentProgress(inProgress: Boolean) { + if (inProgress) { + ViewUtil.hideKeyboard(requireContext(), binding.pinRestorePinInput) + binding.pinRestorePinInput.isEnabled = false + binding.pinRestorePinContinue.setSpinning() + } else { + binding.pinRestorePinInput.isEnabled = true + binding.pinRestorePinContinue.cancelSpinning() + } + } + + private fun handlePinEntry() { + val pin: String? = binding.pinRestorePinInput.text?.toString() + + if (pin.isNullOrBlank()) { + Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show() + enableAndFocusPinEntry() + return + } + + if (pin.trim().length < SvrConstants.MINIMUM_PIN_LENGTH) { + Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, SvrConstants.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show() + enableAndFocusPinEntry() + return + } + + registrationViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PIN_CONFIRMED) + + registrationViewModel.verifyReRegisterWithPin( + context = requireContext(), + pin = pin, + wrongPinHandler = { + reRegisterViewModel.markIncorrectGuess() + } + ) + } + + private fun presentTriesRemaining(triesRemaining: Int) { + if (reRegisterViewModel.hasIncorrectGuess) { + if (triesRemaining == 1 && !reRegisterViewModel.isLocalVerification) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.PinRestoreEntryFragment_incorrect_pin) + .setMessage(resources.getQuantityString(R.plurals.PinRestoreEntryFragment_you_have_d_attempt_remaining, triesRemaining, triesRemaining)) + .setPositiveButton(android.R.string.ok, null) + .show() + } + + if (triesRemaining > 5) { + binding.pinRestorePinInputLabel.setText(R.string.PinRestoreEntryFragment_incorrect_pin) + } else { + binding.pinRestorePinInputLabel.text = resources.getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, triesRemaining, triesRemaining) + } + binding.pinRestoreForgotPin.visibility = View.VISIBLE + } else { + if (triesRemaining == 1) { + binding.pinRestoreForgotPin.visibility = View.VISIBLE + if (!reRegisterViewModel.isLocalVerification) { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(resources.getQuantityString(R.plurals.PinRestoreEntryFragment_you_have_d_attempt_remaining, triesRemaining, triesRemaining)) + .setPositiveButton(android.R.string.ok, null) + .show() + } + } + } + + if (triesRemaining == 0) { + Log.w(TAG, "Account locked. User out of attempts on KBS.") + onAccountLocked() + } + } + + private fun onAccountLocked() { + Log.d(TAG, "Showing Incorrect PIN dialog. Is local verification: ${reRegisterViewModel.isLocalVerification}") + val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_out_of_guesses_local else R.string.PinRestoreLockedFragment_youve_run_out_of_pin_guesses + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.PinRestoreEntryFragment_incorrect_pin) + .setMessage(message) + .setCancelable(false) + .setPositiveButton(R.string.ReRegisterWithPinFragment_send_sms_code) { _, _ -> onSkipPinEntry() } + .setNegativeButton(R.string.AccountLockedFragment__learn_more) { _, _ -> CommunicationActions.openBrowserLink(requireContext(), getString(R.string.PinRestoreLockedFragment_learn_more_url)) } + .show() + } + + private fun enableAndFocusPinEntry() { + binding.pinRestorePinInput.isEnabled = true + binding.pinRestorePinInput.isFocusable = true + ViewUtil.focusAndShowKeyboard(binding.pinRestorePinInput) + } + + private fun getPinEntryKeyboardType(): PinKeyboardType { + val isNumeric = binding.pinRestorePinInput.inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_CLASS_NUMBER + return if (isNumeric) PinKeyboardType.NUMERIC else PinKeyboardType.ALPHA_NUMERIC + } + + private fun updateKeyboard(keyboard: PinKeyboardType) { + val isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC + binding.pinRestorePinInput.inputType = if (isAlphaNumeric) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD else InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD + binding.pinRestorePinInput.text?.clear() + } + + private fun onNeedHelpClicked() { + Log.i(TAG, "User clicked need help dialog.") + val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_need_help_local else R.string.PinRestoreEntryFragment_your_pin_is_a_d_digit_code + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.PinRestoreEntryFragment_need_help) + .setMessage(getString(message, SvrConstants.MINIMUM_PIN_LENGTH)) + .setPositiveButton(R.string.PinRestoreEntryFragment_skip) { _, _ -> onSkipPinEntry() } + .setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ -> + val body = SupportEmailUtil.generateSupportEmailBody(requireContext(), R.string.ReRegisterWithPinFragment_support_email_subject, null, null) + + CommunicationActions.openEmail( + requireContext(), + SupportEmailUtil.getSupportEmailAddress(requireContext()), + getString(R.string.ReRegisterWithPinFragment_support_email_subject), + body + ) + } + .setNegativeButton(R.string.PinRestoreEntryFragment_cancel, null) + .show() + } + + private fun onSkipClicked() { + Log.i(TAG, "User clicked the skip PIN button.") + val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_skip_local else R.string.PinRestoreEntryFragment_if_you_cant_remember_your_pin + + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.PinRestoreEntryFragment_skip_pin_entry) + .setMessage(message) + .setPositiveButton(R.string.PinRestoreEntryFragment_skip) { _, _ -> onSkipPinEntry() } + .setNegativeButton(R.string.PinRestoreEntryFragment_cancel, null) + .show() + } + + private fun onSkipPinEntry() { + Log.d(TAG, "User skipping PIN entry.") + registrationViewModel.setUserSkippedReRegisterFlow(true) + } + + private fun presentRateLimitedDialog() { + MaterialAlertDialogBuilder(requireContext()).apply { + setTitle(R.string.RegistrationActivity_too_many_attempts) + setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later) + setPositiveButton(android.R.string.ok, null) + show() + } + } + + private fun genericErrorDialog() { + MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.RegistrationActivity_error_connecting_to_service) + .setPositiveButton(android.R.string.ok, null) + .create() + .show() + } + + private fun registrationErrorHandler(result: RegisterAccountResult) { + when (result) { + is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!") + is RegisterAccountResult.AuthorizationFailed, + is RegisterAccountResult.MalformedRequest, + is RegisterAccountResult.UnknownError, + is RegisterAccountResult.ValidationError, + is RegisterAccountResult.RegistrationLocked -> { + Log.i(TAG, "Registration failed.", result.getCause()) + genericErrorDialog() + } + + is RegisterAccountResult.IncorrectRecoveryPassword -> { + registrationViewModel.setUserSkippedReRegisterFlow(true) + findNavController().safeNavigate(ReRegisterWithPinFragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberFragment(EnterPhoneNumberMode.NORMAL)) + } + + is RegisterAccountResult.AttemptsExhausted, + is RegisterAccountResult.RateLimited -> presentRateLimitedDialog() + + is RegisterAccountResult.SvrNoData -> onAccountLocked() + is RegisterAccountResult.SvrWrongPin -> { + reRegisterViewModel.markIncorrectGuess() + reRegisterViewModel.markAsRemoteVerification() + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinState.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinState.kt new file mode 100644 index 0000000000..2557fc3273 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinState.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.reregisterwithpin + +data class ReRegisterWithPinState( + val isLocalVerification: Boolean = false, + val hasIncorrectGuess: Boolean = false, + val localPinMatches: Boolean = false +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinViewModel.kt new file mode 100644 index 0000000000..89bb555a1f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinViewModel.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.reregisterwithpin + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.signal.core.util.logging.Log + +class ReRegisterWithPinViewModel : ViewModel() { + companion object { + private val TAG = Log.tag(ReRegisterWithPinViewModel::class.java) + } + + private val store = MutableStateFlow(ReRegisterWithPinState()) + + val isLocalVerification: Boolean + get() = store.value.isLocalVerification + val hasIncorrectGuess: Boolean + get() = store.value.hasIncorrectGuess + + fun markAsRemoteVerification() { + store.update { + it.copy(isLocalVerification = false) + } + } + + fun markIncorrectGuess() { + store.update { + it.copy(hasIncorrectGuess = true) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/BackupKeyVisualTransformation.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/BackupKeyVisualTransformation.kt new file mode 100644 index 0000000000..4f1f225310 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/BackupKeyVisualTransformation.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.restore + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation + +/** + * Visual formatter for backup keys. + * + * @param length max length of key + * @param chunkSize character count per group + */ +class BackupKeyVisualTransformation(private val length: Int, private val chunkSize: Int) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + var output = "" + for (i in text.take(length).indices) { + output += text[i] + if (i % chunkSize == chunkSize - 1) { + output += " " + } + } + + return TransformedText( + text = AnnotatedString(output), + offsetMapping = BackupKeyVisualTransformation(chunkSize) + ) + } + + private class BackupKeyVisualTransformation(private val chunkSize: Int) : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + return offset + (offset / chunkSize) + } + + override fun transformedToOriginal(offset: Int): Int { + return offset - (offset / chunkSize) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyFragment.kt new file mode 100644 index 0000000000..b0aa6de96a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyFragment.kt @@ -0,0 +1,299 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.restore + +import android.graphics.Typeface +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.launch +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.Buttons +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.signal.core.ui.horizontalGutters +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel +import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode +import org.thoughtcrime.securesms.registrationv3.ui.restore.EnterBackupKeyViewModel.EnterBackupKeyState +import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +/** + * Enter backup key screen for manual Signal Backups restore flow. + */ +class EnterBackupKeyFragment : ComposeFragment() { + + companion object { + private const val LEARN_MORE_URL = "https://signal.org" // TODO [backups] but really + } + + private val sharedViewModel by activityViewModels() + private val viewModel by viewModels() + + @Composable + override fun FragmentContent() { + val state by viewModel.state + val sharedState by sharedViewModel.state.collectAsState() + + EnterBackupKeyScreen( + state = state, + sharedState = sharedState, + onBackupKeyChanged = viewModel::updateBackupKey, + onNextClicked = { + sharedViewModel.registerWithBackupKey( + context = requireContext(), + backupKey = state.backupKey, + e164 = null, + pin = null + ) + }, + onLearnMore = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) }, + onSkip = { findNavController().safeNavigate(EnterBackupKeyFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION)) } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EnterBackupKeyScreen( + state: EnterBackupKeyState, + sharedState: RegistrationState, + onBackupKeyChanged: (String) -> Unit = {}, + onNextClicked: () -> Unit = {}, + onLearnMore: () -> Unit = {}, + onSkip: () -> Unit = {} +) { + val coroutineScope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + RegistrationScreen( + title = stringResource(R.string.EnterBackupKey_title), + subtitle = stringResource(R.string.EnterBackupKey_subtitle), + bottomContent = { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + TextButton( + enabled = !sharedState.inProgress, + onClick = { + coroutineScope.launch { + sheetState.show() + } + } + ) { + Text( + text = stringResource(id = R.string.EnterBackupKey_no_backup_key) + ) + } + + Buttons.LargeTonal( + enabled = state.backupKeyValid && !sharedState.inProgress, + onClick = onNextClicked + ) { + Text( + text = stringResource(id = R.string.RegistrationActivity_next) + ) + } + } + } + ) { + val focusRequester = remember { FocusRequester() } + val visualTransform = remember(state.length, state.chunkLength) { BackupKeyVisualTransformation(length = state.length, chunkSize = state.chunkLength) } + + TextField( + value = state.backupKey, + label = { + Text(text = stringResource(id = R.string.EnterBackupKey_backup_key)) + }, + onValueChange = onBackupKeyChanged, + textStyle = LocalTextStyle.current.copy( + fontFamily = FontFamily(typeface = Typeface.MONOSPACE), + lineHeight = 36.sp + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Next, + autoCorrectEnabled = false + ), + keyboardActions = KeyboardActions( + onNext = { if (state.backupKeyValid) onNextClicked() } + ), + minLines = 4, + visualTransformation = visualTransform, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester) + ) + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + if (sheetState.isVisible) { + ModalBottomSheet( + dragHandle = null, + onDismissRequest = { + coroutineScope.launch { + sheetState.hide() + } + } + ) { + NoBackupKeyBottomSheet( + onLearnMore = { + coroutineScope.launch { + sheetState.hide() + } + onLearnMore() + }, + onSkip = onSkip + ) + } + } + } +} + +@SignalPreview +@Composable +private fun EnterBackupKeyScreenPreview() { + Previews.Preview { + EnterBackupKeyScreen( + state = EnterBackupKeyState(backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", length = 64, chunkLength = 4), + sharedState = RegistrationState(phoneNumber = null, recoveryPassword = null) + ) + } +} + +@Composable +private fun NoBackupKeyBottomSheet( + onLearnMore: () -> Unit = {}, + onSkip: () -> Unit = {} +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .horizontalGutters() + ) { + BottomSheets.Handle() + + Icon( + painter = painterResource(id = R.drawable.symbol_key_24), + tint = BackupsIconColors.Success.foreground, + contentDescription = null, + modifier = Modifier + .padding(top = 18.dp, bottom = 16.dp) + .size(88.dp) + .background( + color = BackupsIconColors.Success.background, + shape = CircleShape + ) + .padding(20.dp) + ) + + Text( + text = stringResource(R.string.EnterBackupKey_no_backup_key), + style = MaterialTheme.typography.titleLarge + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1), + style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1), + style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(36.dp)) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp) + ) { + TextButton( + onClick = onLearnMore + ) { + Text( + text = stringResource(id = R.string.EnterBackupKey_learn_more) + ) + } + + TextButton( + onClick = onSkip + ) { + Text( + text = stringResource(id = R.string.EnterBackupKey_skip_and_dont_restore) + ) + } + } + } +} + +@SignalPreview +@Composable +private fun NoBackupKeyBottomSheetPreview() { + Previews.BottomSheetPreview { + NoBackupKeyBottomSheet() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyViewModel.kt new file mode 100644 index 0000000000..155d0001c9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyViewModel.kt @@ -0,0 +1,68 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.restore + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import org.signal.core.util.Hex +import java.io.IOException + +class EnterBackupKeyViewModel : ViewModel() { + + companion object { + // TODO [backups] Set actual valid characters for key input + private val VALID_CHARACTERS = setOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f') + } + + private val _state = mutableStateOf( + EnterBackupKeyState( + backupKey = "", + length = 64, + chunkLength = 4 + ) + ) + + val state: State = _state + + fun updateBackupKey(key: String) { + _state.update { + val newKey = key.removeIllegalCharacters().take(length) + copy(backupKey = newKey, backupKeyValid = validate(length, newKey)) + } + } + + private fun validate(length: Int, backupKey: String): Boolean { + if (backupKey.length != length) { + return false + } + + try { + // TODO [backups] Actually validate key with requirements instead of just hex + Hex.fromStringCondensed(backupKey) + } catch (e: IOException) { + return false + } + + return true + } + + private fun String.removeIllegalCharacters(): String { + return filter { VALID_CHARACTERS.contains(it) } + } + + private inline fun MutableState.update(update: T.() -> T) { + this.value = this.value.update() + } + + data class EnterBackupKeyState( + val backupKey: String = "", + val backupKeyValid: Boolean = false, + val length: Int, + val chunkLength: Int + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreActivity.kt new file mode 100644 index 0000000000..ccbc11376a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreActivity.kt @@ -0,0 +1,382 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.restore + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.signal.core.ui.Buttons +import org.signal.core.ui.Dialogs +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.signal.core.ui.theme.SignalTheme +import org.signal.core.util.bytes +import org.thoughtcrime.securesms.BaseActivity +import org.thoughtcrime.securesms.MainActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier +import org.thoughtcrime.securesms.backup.v2.RestoreV2Event +import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature +import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow +import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle +import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity +import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen +import org.thoughtcrime.securesms.util.DateUtils +import java.util.Locale + +/** + * Restore backup from remote source. + */ +class RemoteRestoreActivity : BaseActivity() { + companion object { + fun getIntent(context: Context): Intent { + return Intent(context, RemoteRestoreActivity::class.java) + } + } + + private val viewModel: RemoteRestoreViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + lifecycleScope.launch { + val restored = viewModel + .state + .map { it.importState } + .filterIsInstance() + .firstOrNull() + + if (restored != null) { + continueRegistration(restored.missingProfileData) + } + } + + setContent { + val state: RemoteRestoreViewModel.ScreenState by viewModel.state.collectAsStateWithLifecycle() + + SignalTheme { + Surface { + RestoreFromBackupContent( + state = state, + onRestoreBackupClick = { viewModel.restore() }, + onCancelClick = { finish() }, + onErrorDialogDismiss = { viewModel.clearError() } + ) + } + } + } + + EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this) + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onEvent(restoreEvent: RestoreV2Event) { + viewModel.updateRestoreProgress(restoreEvent) + } + + private fun continueRegistration(missingProfileData: Boolean) { + val main = MainActivity.clearTop(this) + + if (missingProfileData) { + val profile = CreateProfileActivity.getIntentForUserProfile(this) + profile.putExtra("next_intent", main) + startActivity(profile) + } else { + startActivity(main) + } + + finish() + } +} + +@Composable +private fun RestoreFromBackupContent( + state: RemoteRestoreViewModel.ScreenState, + onRestoreBackupClick: () -> Unit = {}, + onCancelClick: () -> Unit = {}, + onErrorDialogDismiss: () -> Unit = {} +) { + val subtitle = buildAnnotatedString { + append( + stringResource( + id = R.string.RemoteRestoreActivity__backup_created_at, + DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), state.backupTime), + DateUtils.getOnlyTimeString(LocalContext.current, state.backupTime) + ) + ) + append(" ") + if (state.backupTier != MessageBackupTier.PAID) { + withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { + append(stringResource(id = R.string.RemoteRestoreActivity__only_media_sent_or_received)) + } + } + } + + RegistrationScreen( + title = stringResource(id = R.string.RemoteRestoreActivity__restore_from_backup), + subtitle = if (state.isLoaded()) subtitle else null, + bottomContent = { + Column { + if (state.isLoaded()) { + Buttons.LargeTonal( + onClick = onRestoreBackupClick, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = R.string.RemoteRestoreActivity__restore_backup)) + } + } + + TextButton( + onClick = onCancelClick, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(id = android.R.string.cancel)) + } + } + } + ) { + when (state.loadState) { + RemoteRestoreViewModel.ScreenState.LoadState.LOADING -> { + Dialogs.IndeterminateProgressDialog( + message = stringResource(R.string.RemoteRestoreActivity__fetching_backup_details) + ) + } + + RemoteRestoreViewModel.ScreenState.LoadState.LOADED -> { + Column( + modifier = Modifier + .fillMaxWidth() + .background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp)) + .padding(horizontal = 20.dp) + .padding(top = 20.dp, bottom = 18.dp) + ) { + Text( + text = stringResource(id = R.string.RemoteRestoreActivity__your_backup_includes), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 6.dp) + ) + + getFeatures(state.backupTier).forEach { + MessageBackupsTypeFeatureRow( + messageBackupsTypeFeature = it, + iconTint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, top = 6.dp) + ) + } + } + } + + RemoteRestoreViewModel.ScreenState.LoadState.FAILURE -> { + RestoreFailedDialog(onDismiss = onCancelClick) + } + } + + when (state.importState) { + RemoteRestoreViewModel.ImportState.None -> Unit + RemoteRestoreViewModel.ImportState.InProgress -> RestoreProgressDialog(state.restoreProgress) + is RemoteRestoreViewModel.ImportState.Restored -> Unit + RemoteRestoreViewModel.ImportState.Failed -> RestoreFailedDialog(onDismiss = onErrorDialogDismiss) + } + } +} + +@SignalPreview +@Composable +private fun RestoreFromBackupContentPreview() { + Previews.Preview { + RestoreFromBackupContent( + state = RemoteRestoreViewModel.ScreenState( + backupTier = MessageBackupTier.PAID, + backupTime = System.currentTimeMillis(), + importState = RemoteRestoreViewModel.ImportState.None, + restoreProgress = null + ) + ) + } +} + +@SignalPreview +@Composable +private fun RestoreFromBackupContentLoadingPreview() { + Previews.Preview { + RestoreFromBackupContent( + state = RemoteRestoreViewModel.ScreenState( + importState = RemoteRestoreViewModel.ImportState.None, + restoreProgress = null + ) + ) + } +} + +@Composable +private fun getFeatures(tier: MessageBackupTier?): ImmutableList { + return when (tier) { + null -> persistentListOf() + MessageBackupTier.PAID -> { + persistentListOf( + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_thread_compact_bold_16, + label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_media) + ), + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_recent_compact_bold_16, + label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages) + ) + ) + } + + MessageBackupTier.FREE -> { + persistentListOf( + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_thread_compact_bold_16, + label = stringResource(id = R.string.RemoteRestoreActivity__your_last_d_days_of_media, 30) + ), + MessageBackupsTypeFeature( + iconResourceId = R.drawable.symbol_recent_compact_bold_16, + label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages) + ) + ) + } + } +} + +/** + * A dialog that *just* shows a spinner. Useful for short actions where you need to + * let the user know that some action is completing. + */ +@Composable +private fun RestoreProgressDialog(restoreProgress: RestoreV2Event?) { + androidx.compose.material3.AlertDialog( + onDismissRequest = {}, + confirmButton = {}, + dismissButton = {}, + text = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.wrapContentSize() + ) { + if (restoreProgress == null) { + CircularProgressIndicator( + modifier = Modifier + .padding(top = 55.dp, bottom = 16.dp) + .width(48.dp) + .height(48.dp) + ) + } else { + CircularProgressIndicator( + progress = { restoreProgress.getProgress() }, + modifier = Modifier + .padding(top = 55.dp, bottom = 16.dp) + .width(48.dp) + .height(48.dp) + ) + } + + val progressText = when (restoreProgress?.type) { + RestoreV2Event.Type.PROGRESS_DOWNLOAD -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup) + RestoreV2Event.Type.PROGRESS_RESTORE -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup) + else -> stringResource(id = R.string.RemoteRestoreActivity__restoring) + } + + Text( + text = progressText, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 12.dp) + ) + + if (restoreProgress != null) { + val progressBytes = restoreProgress.count.toUnitString(maxPlaces = 2) + val totalBytes = restoreProgress.estimatedTotalCount.toUnitString(maxPlaces = 2) + Text( + text = stringResource(id = R.string.RemoteRestoreActivity__s_of_s_s, progressBytes, totalBytes, "%.2f%%".format(restoreProgress.getProgress())), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 12.dp) + ) + } + } + } + }, + modifier = Modifier.width(212.dp) + ) +} + +@SignalPreview +@Composable +private fun ProgressDialogPreview() { + Previews.Preview { + RestoreProgressDialog( + RestoreV2Event( + type = RestoreV2Event.Type.PROGRESS_RESTORE, + count = 1234.bytes, + estimatedTotalCount = 10240.bytes + ) + ) + } +} + +@Composable +fun RestoreFailedDialog( + onDismiss: () -> Unit = {} +) { + Dialogs.SimpleAlertDialog( + title = "Restore Failed", // TODO [backups] Remote restore error placeholder copy + body = "Unable to restore from backup. Please try again.", // TODO [backups] Placeholder copy + confirm = stringResource(android.R.string.ok), + onConfirm = onDismiss, + onDismiss = onDismiss + ) +} + +@SignalPreview +@Composable +private fun RestoreFailedDialogPreview() { + Previews.Preview { + RestoreFailedDialog() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt new file mode 100644 index 0000000000..3271834407 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.restore + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.backup.v2.MessageBackupTier +import org.thoughtcrime.securesms.backup.v2.RestoreV2Event +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobmanager.JobTracker +import org.thoughtcrime.securesms.jobs.BackupRestoreJob +import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob +import org.thoughtcrime.securesms.jobs.ProfileUploadJob +import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.registration.util.RegistrationUtil +import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository + +class RemoteRestoreViewModel : ViewModel() { + + companion object { + private val TAG = Log.tag(RemoteRestoreViewModel::class) + } + + private val store: MutableStateFlow = MutableStateFlow( + ScreenState( + backupTier = SignalStore.backup.backupTier, + backupTime = SignalStore.backup.lastBackupTime + ) + ) + + val state: StateFlow = store.asStateFlow() + + init { + viewModelScope.launch(Dispatchers.IO) { + val restored = BackupRepository.restoreBackupTier(SignalStore.account.requireAci()) != null + store.update { + if (restored) { + it.copy( + loadState = ScreenState.LoadState.LOADED, + backupTier = SignalStore.backup.backupTier, + backupTime = SignalStore.backup.lastBackupTime + ) + } else { + it.copy( + loadState = ScreenState.LoadState.FAILURE + ) + } + } + } + } + + fun restore() { + viewModelScope.launch { + store.update { it.copy(importState = ImportState.InProgress) } + + withContext(Dispatchers.IO) { + val jobStateFlow = callbackFlow { + val listener = JobTracker.JobListener { _, jobState -> + trySend(jobState) + } + + AppDependencies + .jobManager + .startChain(BackupRestoreJob()) + .then(SyncArchivedMediaJob()) + .then(BackupRestoreMediaJob()) + .enqueue(listener) + + awaitClose { + AppDependencies.jobManager.removeListener(listener) + } + } + + jobStateFlow.collect { state -> + when (state) { + JobTracker.JobState.SUCCESS -> { + Log.i(TAG, "Restore successful") + SignalStore.registration.markRestoreCompleted() + + if (!RegistrationRepository.isMissingProfileData()) { + RegistrationUtil.maybeMarkRegistrationComplete() + AppDependencies.jobManager.add(ProfileUploadJob()) + } + + store.update { it.copy(importState = ImportState.Restored(RegistrationRepository.isMissingProfileData())) } + } + + JobTracker.JobState.PENDING, + JobTracker.JobState.RUNNING -> { + Log.i(TAG, "Restore job states updated: $state") + } + + JobTracker.JobState.FAILURE, + JobTracker.JobState.IGNORED -> { + Log.w(TAG, "Restore failed with $state") + + store.update { it.copy(importState = ImportState.Failed) } + } + } + } + } + } + } + + fun updateRestoreProgress(restoreEvent: RestoreV2Event) { + store.update { it.copy(restoreProgress = restoreEvent) } + } + + fun cancel() { + SignalStore.registration.markSkippedTransferOrRestore() + } + + fun clearError() { + store.update { it.copy(importState = ImportState.None, restoreProgress = null) } + } + + data class ScreenState( + val backupTier: MessageBackupTier? = null, + val backupTime: Long = -1, + val importState: ImportState = ImportState.None, + val restoreProgress: RestoreV2Event? = null, + val loadState: LoadState = if (backupTier != null) LoadState.LOADED else LoadState.LOADING + ) { + + fun isLoaded(): Boolean { + return loadState == LoadState.LOADED + } + + fun isLoading(): Boolean { + return loadState == LoadState.LOADING + } + + enum class LoadState { + LOADING, LOADED, FAILURE + } + } + + sealed interface ImportState { + data object None : ImportState + data object InProgress : ImportState + data class Restored(val missingProfileData: Boolean) : ImportState + data object Failed : ImportState + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreMethod.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreMethod.kt new file mode 100644 index 0000000000..787ff8c4ab --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreMethod.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.restore + +import org.thoughtcrime.securesms.R + +/** + * Restore methods for various spots in restore flow. + */ +enum class RestoreMethod(val iconRes: Int, val titleRes: Int, val subtitleRes: Int) { + FROM_SIGNAL_BACKUPS( + iconRes = R.drawable.symbol_signal_backups_24, + titleRes = R.string.SelectRestoreMethodFragment__from_signal_backups, + subtitleRes = R.string.SelectRestoreMethodFragment__your_free_or_paid_signal_backup_plan + ), + FROM_LOCAL_BACKUP_V1( + iconRes = R.drawable.symbol_file_24, + titleRes = R.string.SelectRestoreMethodFragment__from_a_backup_file, + subtitleRes = R.string.SelectRestoreMethodFragment__choose_a_backup_youve_saved + ), + FROM_LOCAL_BACKUP_V2( + iconRes = R.drawable.symbol_folder_24, + titleRes = R.string.SelectRestoreMethodFragment__from_a_backup_folder, + subtitleRes = R.string.SelectRestoreMethodFragment__choose_a_backup_youve_saved + ), + FROM_OLD_DEVICE( + iconRes = R.drawable.symbol_transfer_24, + titleRes = R.string.SelectRestoreMethodFragment__from_your_old_phone, + subtitleRes = R.string.SelectRestoreMethodFragment__transfer_directly_from_old + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreRow.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreRow.kt new file mode 100644 index 0000000000..3c6e982df9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreRow.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.restore + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R + +/** + * Renders row-ux used commonly through the restore flows. + */ +@Composable +fun RestoreRow( + icon: Painter, + title: String, + subtitle: String, + onRowClick: () -> Unit = {} +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(bottom = 16.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(SignalTheme.colors.colorSurface2) + .clickable(enabled = true, onClick = onRowClick) + .padding(horizontal = 20.dp, vertical = 22.dp) + ) { + Icon( + painter = icon, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + + Column( + modifier = Modifier.padding(start = 16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@SignalPreview +@Composable +private fun RestoreMethodRowPreview() { + Previews.Preview { + RestoreRow( + icon = painterResource(R.drawable.symbol_backup_24), + title = stringResource(R.string.SelectRestoreMethodFragment__from_signal_backups), + subtitle = stringResource(R.string.SelectRestoreMethodFragment__your_free_or_paid_signal_backup_plan) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrFragment.kt new file mode 100644 index 0000000000..59b64bcf00 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrFragment.kt @@ -0,0 +1,350 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.restore + +import android.os.Bundle +import android.view.View +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import org.signal.core.ui.Buttons +import org.signal.core.ui.Dialogs +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.signal.core.ui.horizontalGutters +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCode +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel +import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen + +/** + * Show QR code on new device to allow registration and restore via old device. + */ +class RestoreViaQrFragment : ComposeFragment() { + + private val sharedViewModel by activityViewModels() + private val viewModel: RestoreViaQrViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { + viewModel + .state + .mapNotNull { it.provisioningMessage } + .distinctUntilChanged() + .collect { message -> + sharedViewModel.registerWithBackupKey(requireContext(), message.accountEntropyPool, message.e164, message.pin) + } + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { + sharedViewModel + .state + .map { it.registerAccountError } + .filterNotNull() + .collect { + sharedViewModel.registerAccountErrorShown() + viewModel.handleRegistrationFailure() + } + } + } + } + + @Composable + override fun FragmentContent() { + val state by viewModel.state.collectAsState() + + RestoreViaQrScreen( + state = state, + onRetryQrCode = viewModel::restart, + onRegistrationErrorDismiss = viewModel::clearRegistrationError, + onCancel = { findNavController().popBackStack() } + ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun RestoreViaQrScreen( + state: RestoreViaQrViewModel.RestoreViaQrState, + onRetryQrCode: () -> Unit = {}, + onRegistrationErrorDismiss: () -> Unit = {}, + onCancel: () -> Unit = {} +) { + RegistrationScreen( + title = stringResource(R.string.RestoreViaQr_title), + subtitle = null, + bottomContent = { + TextButton( + onClick = onCancel, + modifier = Modifier.align(Alignment.Center) + ) { + Text(text = stringResource(android.R.string.cancel)) + } + } + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(space = 48.dp, alignment = Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(space = 48.dp), + modifier = Modifier + .fillMaxWidth() + .horizontalGutters() + ) { + Box( + modifier = Modifier + .widthIn(160.dp, 320.dp) + .aspectRatio(1f) + .clip(RoundedCornerShape(24.dp)) + .background(SignalTheme.colors.colorSurface5) + .padding(40.dp) + ) { + SignalTheme(isDarkMode = false) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surface) + .fillMaxWidth() + .fillMaxHeight() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + AnimatedContent( + targetState = state.qrState, + contentAlignment = Alignment.Center, + label = "qr-code-progress" + ) { qrState -> + when (qrState) { + is RestoreViaQrViewModel.QrState.Loaded -> { + QrCode( + data = qrState.qrData, + foregroundColor = Color(0xFF2449C0), + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) + } + + RestoreViaQrViewModel.QrState.Loading -> { + CircularProgressIndicator(modifier = Modifier.size(48.dp)) + } + + is RestoreViaQrViewModel.QrState.Scanned, + RestoreViaQrViewModel.QrState.Failed -> { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + val text = if (state.qrState is RestoreViaQrViewModel.QrState.Scanned) { + stringResource(R.string.RestoreViaQr_qr_code_scanned) + } else { + stringResource(R.string.RestoreViaQr_qr_code_error) + } + + Text( + text = text, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Buttons.Small( + onClick = onRetryQrCode + ) { + Text(text = stringResource(R.string.RestoreViaQr_retry)) + } + } + } + } + } + } + } + } + + Column( + modifier = Modifier + .align(alignment = Alignment.CenterVertically) + .widthIn(160.dp, 320.dp) + ) { + InstructionRow( + icon = painterResource(R.drawable.symbol_phone_24), + instruction = stringResource(R.string.RestoreViaQr_instruction_1) + ) + + InstructionRow( + icon = painterResource(R.drawable.symbol_camera_24), + instruction = stringResource(R.string.RestoreViaQr_instruction_2) + ) + + InstructionRow( + icon = painterResource(R.drawable.symbol_qrcode_24), + instruction = stringResource(R.string.RestoreViaQr_instruction_3) + ) + } + } + + if (state.isRegistering) { + Dialogs.IndeterminateProgressDialog() + } else if (state.showRegistrationError) { + Dialogs.SimpleMessageDialog( + message = stringResource(R.string.RegistrationActivity_error_connecting_to_service), + onDismiss = onRegistrationErrorDismiss, + dismiss = stringResource(android.R.string.ok) + ) + } + } +} + +@SignalPreview +@Composable +private fun RestoreViaQrScreenPreview() { + Previews.Preview { + RestoreViaQrScreen( + state = RestoreViaQrViewModel.RestoreViaQrState( + qrState = RestoreViaQrViewModel.QrState.Loaded( + QrCodeData.forData("sgnl://rereg?uuid=asdfasdfasdfasdfasdfasdf&pub_key=asdfasdfasdfSDFSsdfsdfSDFSDffd", false) + ) + ) + ) + } +} + +@SignalPreview +@Composable +private fun RestoreViaQrScreenLoadingPreview() { + Previews.Preview { + RestoreViaQrScreen( + state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Loading) + ) + } +} + +@SignalPreview +@Composable +private fun RestoreViaQrScreenFailurePreview() { + Previews.Preview { + RestoreViaQrScreen( + state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Failed) + ) + } +} + +@SignalPreview +@Composable +private fun RestoreViaQrScreenScannedPreview() { + Previews.Preview { + RestoreViaQrScreen( + state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Scanned) + ) + } +} + +@SignalPreview +@Composable +private fun RestoreViaQrScreenRegisteringPreview() { + Previews.Preview { + RestoreViaQrScreen( + state = RestoreViaQrViewModel.RestoreViaQrState(isRegistering = true, qrState = RestoreViaQrViewModel.QrState.Scanned) + ) + } +} + +@SignalPreview +@Composable +private fun RestoreViaQrScreenRegistrationFailedPreview() { + Previews.Preview { + RestoreViaQrScreen( + state = RestoreViaQrViewModel.RestoreViaQrState(isRegistering = false, showRegistrationError = true, qrState = RestoreViaQrViewModel.QrState.Scanned) + ) + } +} + +@Composable +private fun InstructionRow( + icon: Painter, + instruction: String +) { + Row( + modifier = Modifier + .padding(vertical = 12.dp) + ) { + Icon( + painter = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Text( + text = instruction, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@SignalPreview +@Composable +private fun InstructionRowPreview() { + Previews.Preview { + InstructionRow( + icon = painterResource(R.drawable.symbol_phone_24), + instruction = "Instruction!" + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt new file mode 100644 index 0000000000..5bdb7a01c0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.restore + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import org.signal.core.util.logging.Log +import org.signal.registration.proto.RegistrationProvisionMessage +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData +import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.whispersystems.signalservice.api.registration.ProvisioningSocket +import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher +import java.io.Closeable + +class RestoreViaQrViewModel : ViewModel() { + + companion object { + private val TAG = Log.tag(RestoreViaQrViewModel::class) + } + + private val store: MutableStateFlow = MutableStateFlow(RestoreViaQrState()) + + val state: StateFlow = store + + private var socketHandle: Closeable + + init { + socketHandle = start() + } + + fun restart() { + socketHandle.close() + socketHandle = start() + } + + fun handleRegistrationFailure() { + store.update { + if (it.isRegistering) { + it.copy( + isRegistering = false, + provisioningMessage = null, + showRegistrationError = true + ) + } else { + it + } + } + } + + fun clearRegistrationError() { + store.update { it.copy(showRegistrationError = false) } + } + + override fun onCleared() { + socketHandle.close() + } + + private fun start(): Closeable { + SignalStore.registration.restoreMethodToken = null + store.update { it.copy(qrState = QrState.Loading) } + + return ProvisioningSocket.start( + identityKeyPair = IdentityKeyUtil.generateIdentityKeyPair(), + configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration(), + handler = CoroutineExceptionHandler { _, _ -> store.update { it.copy(qrState = QrState.Failed) } } + ) { socket -> + val url = socket.getProvisioningUrl() + store.update { it.copy(qrState = QrState.Loaded(qrData = QrCodeData.forData(data = url, supportIconOverlay = false))) } + + val result = socket.getRegistrationProvisioningMessage() + + if (result is SecondaryProvisioningCipher.RegistrationProvisionResult.Success) { + Log.i(TAG, "Saving restore method token: ***${result.message.restoreMethodToken.takeLast(4)}") + SignalStore.registration.restoreMethodToken = result.message.restoreMethodToken + store.update { it.copy(isRegistering = true, provisioningMessage = result.message, qrState = QrState.Scanned) } + } else { + store.update { it.copy(showProvisioningError = true, qrState = QrState.Scanned) } + } + } + } + + data class RestoreViaQrState( + val isRegistering: Boolean = false, + val qrState: QrState = QrState.Loading, + val provisioningMessage: RegistrationProvisionMessage? = null, + val showProvisioningError: Boolean = false, + val showRegistrationError: Boolean = false + ) + + sealed interface QrState { + data object Loading : QrState + data class Loaded(val qrData: QrCodeData) : QrState + data object Failed : QrState + data object Scanned : QrState + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectManualRestoreMethodFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectManualRestoreMethodFragment.kt new file mode 100644 index 0000000000..aa3afca9d8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectManualRestoreMethodFragment.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.restore + +import android.app.Activity +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel +import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode +import org.thoughtcrime.securesms.restore.RestoreActivity +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +/** + * Provide options to select restore/transfer operation and flow during manual registration. + */ +class SelectManualRestoreMethodFragment : ComposeFragment() { + + companion object { + private val TAG = Log.tag(SelectManualRestoreMethodFragment::class) + } + + private val sharedViewModel by activityViewModels() + + private val launchRestoreActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + when (val resultCode = result.resultCode) { + Activity.RESULT_OK -> { + sharedViewModel.onBackupSuccessfullyRestored() + findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL)) + } + Activity.RESULT_CANCELED -> { + Log.w(TAG, "Backup restoration canceled.") + } + else -> Log.w(TAG, "Backup restoration activity ended with unknown result code: $resultCode") + } + } + + @Composable + override fun FragmentContent() { + SelectRestoreMethodScreen( + restoreMethods = listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1), + onRestoreMethodClicked = this::startRestoreMethod, + onSkip = { findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL)) } + ) + } + + private fun startRestoreMethod(method: RestoreMethod) { + when (method) { + RestoreMethod.FROM_SIGNAL_BACKUPS -> findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE)) + RestoreMethod.FROM_LOCAL_BACKUP_V1 -> launchRestoreActivity.launch(RestoreActivity.getLocalRestoreIntent(requireContext())) + RestoreMethod.FROM_OLD_DEVICE -> error("Device transfer not supported in manual restore flow") + RestoreMethod.FROM_LOCAL_BACKUP_V2 -> error("Not currently supported") + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectRestoreMethodScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectRestoreMethodScreen.kt new file mode 100644 index 0000000000..2815c9b383 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectRestoreMethodScreen.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.restore + +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import org.signal.core.ui.SignalPreview +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen + +/** + * Screen showing various restore methods available during quick and manual re-registration. + */ +@Composable +fun SelectRestoreMethodScreen( + restoreMethods: List, + onRestoreMethodClicked: (RestoreMethod) -> Unit = {}, + onSkip: () -> Unit = {} +) { + RegistrationScreen( + title = stringResource(id = R.string.SelectRestoreMethodFragment__restore_or_transfer_account), + subtitle = stringResource(id = R.string.SelectRestoreMethodFragment__get_your_signal_account), + bottomContent = { + TextButton( + onClick = onSkip, + modifier = Modifier.align(Alignment.Center) + ) { + Text(text = stringResource(R.string.registration_activity__skip)) + } + } + ) { + for (method in restoreMethods) { + RestoreRow( + icon = painterResource(method.iconRes), + title = stringResource(method.titleRes), + subtitle = stringResource(method.subtitleRes), + onRowClick = { onRestoreMethodClicked(method) } + ) + } + } +} + +@SignalPreview +@Composable +private fun SelectRestoreMethodScreenPreview() { + SignalTheme { + SelectRestoreMethodScreen(listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_OLD_DEVICE, RestoreMethod.FROM_LOCAL_BACKUP_V1)) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/shared/RegistrationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/shared/RegistrationScreen.kt new file mode 100644 index 0000000000..6ecdf070ed --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/shared/RegistrationScreen.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.shared + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.signal.core.ui.horizontalGutters + +/** + * A base framework for rendering the various v3 registration screens. + */ +@Composable +fun RegistrationScreen( + title: String, + subtitle: String, + bottomContent: @Composable (BoxScope.() -> Unit), + mainContent: @Composable () -> Unit +) { + RegistrationScreen(title, AnnotatedString(subtitle), bottomContent, mainContent) +} + +/** + * A base framework for rendering the various v3 registration screens. + */ +@Composable +fun RegistrationScreen( + title: String, + subtitle: AnnotatedString?, + bottomContent: @Composable (BoxScope.() -> Unit), + mainContent: @Composable () -> Unit +) { + Surface { + Column( + verticalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .verticalScroll(scrollState) + .weight(weight = 1f, fill = false) + .padding(top = 40.dp, bottom = 16.dp) + .horizontalGutters() + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier + ) + + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 16.dp) + ) + } + + Spacer(modifier = Modifier.height(40.dp)) + + mainContent() + } + + Surface( + shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .padding(top = 8.dp, bottom = 24.dp) + .horizontalGutters() + ) { + bottomContent() + } + } + } + } +} + +@SignalPreview +@Composable +private fun RegistrationScreenPreview() { + Previews.Preview { + RegistrationScreen( + title = "Title", + subtitle = "Subtitle", + bottomContent = { + TextButton(onClick = {}) { + Text("Bottom Button") + } + } + ) { + Text("Main content") + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/RestoreWelcomeBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/RestoreWelcomeBottomSheet.kt new file mode 100644 index 0000000000..c76ebdb173 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/RestoreWelcomeBottomSheet.kt @@ -0,0 +1,164 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.welcome + +import android.content.DialogInterface +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResult +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.signal.core.ui.horizontalGutters +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment + +/** + * Restore flow starting bottom sheet that allows user to progress through quick restore or manual restore flows + * from the Welcome screen. + */ +class RestoreWelcomeBottomSheet : ComposeBottomSheetDialogFragment() { + + private var result: WelcomeUserSelection = WelcomeUserSelection.CONTINUE + + companion object { + const val REQUEST_KEY = "RestoreWelcomeBottomSheet" + } + + @Composable + override fun SheetContent() { + Sheet( + onHasOldPhone = { + result = WelcomeUserSelection.RESTORE_WITH_OLD_PHONE + dismissAllowingStateLoss() + }, + onNoPhone = { + result = WelcomeUserSelection.RESTORE_WITH_NO_PHONE + dismissAllowingStateLoss() + } + ) + } + + override fun onDismiss(dialog: DialogInterface) { + setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to result)) + + super.onDismiss(dialog) + } +} + +@Composable +private fun Sheet( + onHasOldPhone: () -> Unit = {}, + onNoPhone: () -> Unit = {} +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + .padding(bottom = 54.dp) + ) { + BottomSheets.Handle() + + val context = LocalContext.current + + Spacer(modifier = Modifier.size(26.dp)) + + RestoreActionRow( + icon = painterResource(R.drawable.symbol_qrcode_24), + title = stringResource(R.string.WelcomeFragment_restore_action_i_have_my_old_phone), + subtitle = stringResource(R.string.WelcomeFragment_restore_action_scan_qr), + onRowClick = onHasOldPhone + ) + + RestoreActionRow( + icon = painterResource(R.drawable.symbol_no_phone_44), + title = stringResource(R.string.WelcomeFragment_restore_action_i_dont_have_my_old_phone), + subtitle = stringResource(R.string.WelcomeFragment_restore_action_reinstalling), + onRowClick = onNoPhone + ) + } +} + +@Composable +@SignalPreview +private fun SheetPreview() { + Previews.BottomSheetPreview { + Sheet() + } +} + +@Composable +fun RestoreActionRow( + icon: Painter, + title: String, + subtitle: String, + onRowClick: () -> Unit = {} +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .horizontalGutters() + .padding(vertical = 8.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(18.dp)) + .background(MaterialTheme.colorScheme.background) + .clickable(enabled = true, onClick = onRowClick) + .padding(horizontal = 24.dp, vertical = 16.dp) + ) { + Icon( + painter = icon, + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + modifier = Modifier.size(44.dp) + ) + + Column( + modifier = Modifier.padding(start = 16.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@SignalPreview +@Composable +private fun RestoreActionRowPreview() { + Previews.Preview { + RestoreActionRow( + icon = painterResource(R.drawable.symbol_qrcode_24), + title = stringResource(R.string.WelcomeFragment_restore_action_i_have_my_old_phone), + subtitle = stringResource(R.string.WelcomeFragment_restore_action_scan_qr) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeFragment.kt new file mode 100644 index 0000000000..d3b8ee79a9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeFragment.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.welcome + +import android.content.pm.PackageManager +import android.os.Bundle +import android.view.View +import androidx.core.content.ContextCompat +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.findNavController +import org.signal.core.util.getSerializableCompat +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.databinding.FragmentRegistrationWelcomeV3Binding +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView +import org.thoughtcrime.securesms.registration.fragments.WelcomePermissions +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint +import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel +import org.thoughtcrime.securesms.registrationv3.ui.permissions.GrantPermissionsFragment +import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode +import org.thoughtcrime.securesms.util.BackupUtil +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +/** + * First screen that is displayed on the very first app launch. + */ +class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v3) { + companion object { + private val TAG = Log.tag(WelcomeFragment::class.java) + private const val TERMS_AND_CONDITIONS_URL = "https://signal.org/legal" + } + + private val sharedViewModel by activityViewModels() + private val binding: FragmentRegistrationWelcomeV3Binding by ViewBinderDelegate(FragmentRegistrationWelcomeV3Binding::bind) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + setDebugLogSubmitMultiTapView(binding.image) + setDebugLogSubmitMultiTapView(binding.title) + + binding.welcomeContinueButton.setOnClickListener { onContinueClicked() } + binding.welcomeTermsButton.setOnClickListener { onTermsClicked() } + binding.welcomeTransferOrRestore.setOnClickListener { onRestoreOrTransferClicked() } + + childFragmentManager.setFragmentResultListener(RestoreWelcomeBottomSheet.REQUEST_KEY, viewLifecycleOwner) { requestKey, bundle -> + if (requestKey == RestoreWelcomeBottomSheet.REQUEST_KEY) { + when (val userSelection = bundle.getSerializableCompat(RestoreWelcomeBottomSheet.REQUEST_KEY, WelcomeUserSelection::class.java)) { + WelcomeUserSelection.RESTORE_WITH_OLD_PHONE, + WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> afterRestoreOrTransferClicked(userSelection) + else -> Unit + } + } + } + + if (Permissions.isRuntimePermissionsRequired()) { + parentFragmentManager.setFragmentResultListener(GrantPermissionsFragment.REQUEST_KEY, viewLifecycleOwner) { requestKey, bundle -> + if (requestKey == GrantPermissionsFragment.REQUEST_KEY) { + when (val userSelection = bundle.getSerializableCompat(GrantPermissionsFragment.REQUEST_KEY, WelcomeUserSelection::class.java)) { + WelcomeUserSelection.RESTORE_WITH_OLD_PHONE, + WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> navigateToNextScreenViaRestore(userSelection) + WelcomeUserSelection.CONTINUE -> navigateToNextScreenViaContinue() + null -> Unit + } + } + } + } + } + + private fun onContinueClicked() { + if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) { + findNavController().safeNavigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(WelcomeUserSelection.CONTINUE)) + } else { + navigateToNextScreenViaContinue() + } + } + + private fun navigateToNextScreenViaContinue() { + sharedViewModel.maybePrefillE164(requireContext()) + findNavController().safeNavigate(WelcomeFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL)) + } + + private fun onTermsClicked() { + CommunicationActions.openBrowserLink(requireContext(), TERMS_AND_CONDITIONS_URL) + } + + private fun onRestoreOrTransferClicked() { + RestoreWelcomeBottomSheet().show(childFragmentManager, null) + } + + private fun afterRestoreOrTransferClicked(userSelection: WelcomeUserSelection) { + if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) { + findNavController().safeNavigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(userSelection)) + } else { + navigateToNextScreenViaRestore(userSelection) + } + } + + private fun navigateToNextScreenViaRestore(userSelection: WelcomeUserSelection) { + sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED) + + when (userSelection) { + WelcomeUserSelection.CONTINUE -> throw IllegalArgumentException() + WelcomeUserSelection.RESTORE_WITH_OLD_PHONE -> findNavController().safeNavigate(WelcomeFragmentDirections.goToRestoreViaQr()) + WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> findNavController().safeNavigate(WelcomeFragmentDirections.goToSelectRestoreMethod(userSelection)) + } + } + + private fun hasAllPermissions(): Boolean { + val isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext()) + return WelcomePermissions.getWelcomePermissions(isUserSelectionRequired).all { ContextCompat.checkSelfPermission(requireContext(), it) == PackageManager.PERMISSION_GRANTED } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeUserSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeUserSelection.kt new file mode 100644 index 0000000000..b20e310cc7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeUserSelection.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.ui.welcome + +/** + * User options available to start registration flow. + */ +enum class WelcomeUserSelection { + CONTINUE, RESTORE_WITH_OLD_PHONE, RESTORE_WITH_NO_PHONE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt index 522081ee41..60cc702677 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt @@ -9,16 +9,20 @@ import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels -import androidx.navigation.findNavController +import androidx.navigation.NavController +import androidx.navigation.Navigation +import androidx.navigation.fragment.NavHostFragment import org.signal.core.util.getParcelableExtraCompat +import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.BaseActivity import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.backup.v2.MessageBackupTier -import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity +import org.thoughtcrime.securesms.RestoreDirections import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme +import org.thoughtcrime.securesms.util.RemoteConfig +import org.thoughtcrime.securesms.util.navigation.safeNavigate /** * Activity to hold the restore from backup flow. @@ -29,6 +33,8 @@ class RestoreActivity : BaseActivity() { private val dynamicTheme = DynamicNoActionBarTheme() private val sharedViewModel: RestoreViewModel by viewModels() + private lateinit var navController: NavController + override fun onCreate(savedInstanceState: Bundle?) { dynamicTheme.onCreate(this) super.onCreate(savedInstanceState) @@ -36,16 +42,42 @@ class RestoreActivity : BaseActivity() { setResult(RESULT_CANCELED) setContentView(R.layout.activity_restore) + + if (savedInstanceState == null) { + val fragment: NavHostFragment = NavHostFragment.create(R.navigation.restore) + + supportFragmentManager + .beginTransaction() + .replace(R.id.nav_host_fragment, fragment) + .commitNow() + + navController = fragment.navController + } else { + val fragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment + navController = fragment.navController + } + intent.getParcelableExtraCompat(PassphraseRequiredActivity.NEXT_INTENT_EXTRA, Intent::class.java)?.let { sharedViewModel.setNextIntent(it) } - val navTarget = NavTarget.deserialize(intent.getIntExtra(EXTRA_NAV_TARGET, NavTarget.NONE.value)) + val navTarget = NavTarget.deserialize(intent.getIntExtra(EXTRA_NAV_TARGET, NavTarget.LEGACY_LANDING.value)) + when (navTarget) { - NavTarget.LOCAL_RESTORE -> findNavController(R.id.nav_host_fragment).navigate(R.id.choose_local_backup_fragment) - NavTarget.TRANSFER -> findNavController(R.id.nav_host_fragment).navigate(R.id.newDeviceTransferInstructions) + NavTarget.NEW_LANDING -> navController.safeNavigate(RestoreDirections.goDirectlyToNewLanding()) + NavTarget.LOCAL_RESTORE -> navController.safeNavigate(RestoreDirections.goDirectlyToChooseLocalBackup()) + NavTarget.TRANSFER -> navController.safeNavigate(RestoreDirections.goDirectlyToDeviceTransfer()) else -> Unit } + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + onNavigateUp() + } + } + ) } override fun onResume() { @@ -53,21 +85,38 @@ class RestoreActivity : BaseActivity() { dynamicTheme.onResume(this) } - fun finishActivitySuccessfully() { + override fun onNavigateUp(): Boolean { + return if (!Navigation.findNavController(this, R.id.nav_host_fragment).popBackStack()) { + finish() + true + } else { + false + } + } + + fun onBackupCompletedSuccessfully() { + sharedViewModel.getNextIntent()?.let { + Log.d(TAG, "Launching ${it.component}", Throwable()) + startActivity(it) + } + setResult(RESULT_OK) finish() } companion object { + private val TAG = Log.tag(RestoreActivity::class) + enum class NavTarget(val value: Int) { - NONE(0), - TRANSFER(1), - LOCAL_RESTORE(2); + LEGACY_LANDING(0), + NEW_LANDING(1), + TRANSFER(2), + LOCAL_RESTORE(3); companion object { fun deserialize(value: Int): NavTarget { - return entries.firstOrNull { it.value == value } ?: NONE + return entries.firstOrNull { it.value == value } ?: LEGACY_LANDING } } } @@ -75,26 +124,26 @@ class RestoreActivity : BaseActivity() { private const val EXTRA_NAV_TARGET = "nav_target" @JvmStatic - fun getIntentForTransfer(context: Context): Intent { + fun getDeviceTransferIntent(context: Context): Intent { return Intent(context, RestoreActivity::class.java).apply { putExtra(EXTRA_NAV_TARGET, NavTarget.TRANSFER.value) } } @JvmStatic - fun getIntentForLocalRestore(context: Context): Intent { + fun getLocalRestoreIntent(context: Context): Intent { return Intent(context, RestoreActivity::class.java).apply { putExtra(EXTRA_NAV_TARGET, NavTarget.LOCAL_RESTORE.value) } } @JvmStatic - fun getIntentForTransferOrRestore(context: Context): Intent { - val tier = SignalStore.backup.backupTier - if (tier == MessageBackupTier.PAID) { - return Intent(context, RemoteRestoreActivity::class.java) + fun getRestoreIntent(context: Context): Intent { + return Intent(context, RestoreActivity::class.java).apply { + if (RemoteConfig.restoreAfterRegistration) { + putExtra(EXTRA_NAV_TARGET, NavTarget.NEW_LANDING.value) + } } - return Intent(context, RestoreActivity::class.java) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreState.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreState.kt index e2df0cf003..bf66b88581 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreState.kt @@ -7,7 +7,7 @@ package org.thoughtcrime.securesms.restore import android.content.Intent import android.net.Uri -import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType +import org.thoughtcrime.securesms.restore.transferorrestore.BackupRestorationType /** * Shared state holder for the restore flow. diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt index c75adbd296..87ec134b11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt @@ -11,7 +11,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update -import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType +import org.thoughtcrime.securesms.restore.transferorrestore.BackupRestorationType /** * Shared view model for the restore flow. @@ -38,12 +38,6 @@ class RestoreViewModel : ViewModel() { } } - fun onRestoreFromRemoteBackupSelected() { - store.update { - it.copy(restorationType = BackupRestorationType.REMOTE_BACKUP) - } - } - fun getBackupRestorationType(): BackupRestorationType { return store.value.restorationType } diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/devicetransfer/DeviceTransferFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/devicetransfer/DeviceTransferFragment.kt index 4e511edf63..efed2c8d53 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/devicetransfer/DeviceTransferFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/devicetransfer/DeviceTransferFragment.kt @@ -7,9 +7,9 @@ package org.thoughtcrime.securesms.restore.devicetransfer import android.os.Bundle import android.view.View +import android.widget.TextView import androidx.activity.OnBackPressedCallback import androidx.annotation.StringRes -import androidx.fragment.app.activityViewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe @@ -20,14 +20,24 @@ import org.thoughtcrime.securesms.LoggingFragment import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.databinding.FragmentDeviceTransferBinding -import org.thoughtcrime.securesms.restore.RestoreViewModel import org.thoughtcrime.securesms.util.visible -sealed class DeviceTransferFragment : LoggingFragment(R.layout.fragment_device_transfer) { +/** + * Drives the UI for the actual device transfer progress. Shown after setup is complete + * and the two devices are transferring. + *

+ * Handles show progress and error state. + */ +abstract class DeviceTransferFragment : LoggingFragment(R.layout.fragment_device_transfer) { + + companion object { + private const val TRANSFER_FINISHED_KEY = "transfer_finished" + } + private val onBackPressed = OnBackPressed() private val transferModeListener = TransferModeListener() - protected val navigationViewModel: RestoreViewModel by activityViewModels() protected val binding: FragmentDeviceTransferBinding by ViewBinderDelegate(FragmentDeviceTransferBinding::bind) + protected val status: TextView by lazy { binding.deviceTransferFragmentStatus } protected var transferFinished: Boolean = false @@ -38,6 +48,13 @@ sealed class DeviceTransferFragment : LoggingFragment(R.layout.fragment_device_t } } + override fun onStart() { + super.onStart() + if (transferFinished) { + navigateToTransferComplete() + } + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putBoolean(TRANSFER_FINISHED_KEY, transferFinished) @@ -132,8 +149,4 @@ sealed class DeviceTransferFragment : LoggingFragment(R.layout.fragment_device_t } } } - - companion object { - private const val TRANSFER_FINISHED_KEY = "transfer_finished" - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/restorecomplete/RestoreCompleteFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/restorecomplete/RestoreCompleteFragment.kt deleted file mode 100644 index 44b1979a77..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/restorecomplete/RestoreCompleteFragment.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.restore.restorecomplete - -import android.os.Bundle -import android.view.View -import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.LoggingFragment -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.restore.RestoreActivity - -/** - * This is a hack placeholder fragment so we can reuse the existing V1 device transfer fragments without changing their navigation calls. - * The original calls expect to be navigating from the [NewDeviceTransferCompleteFragment] to [EnterPhoneNumberFragment] - * This approximates that by taking the place of [EnterPhoneNumberFragment], - * then bridging us back to [RegistrationV2Activity] by immediately closing the [RestoreActivity]. - */ -class RestoreCompleteFragment : LoggingFragment(R.layout.fragment_registration_blank) { - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - Log.d(TAG, "Finishing activity…") - onBackupCompletedSuccessfully() - } - - private fun onBackupCompletedSuccessfully() { - Log.d(TAG, "onBackupCompletedSuccessfully()") - val activity = requireActivity() as RestoreActivity - activity.finishActivitySuccessfully() - } - - companion object { - private val TAG = Log.tag(RestoreCompleteFragment::class.java) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupFragment.kt index d7f5bb5626..3e96ce9d4f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupFragment.kt @@ -41,10 +41,10 @@ import java.util.Locale * This fragment is used to monitor and manage an in-progress backup restore. */ class RestoreLocalBackupFragment : LoggingFragment(R.layout.fragment_restore_local_backup) { - private val navigationViewModel: RestoreViewModel by activityViewModels() + private val sharedViewModel: RestoreViewModel by activityViewModels() private val restoreLocalBackupViewModel: RestoreLocalBackupViewModel by viewModels( factoryProducer = ViewModelFactory.factoryProducer { - val fileBackupUri = navigationViewModel.getBackupFileUri()!! + val fileBackupUri = sharedViewModel.getBackupFileUri()!! RestoreLocalBackupViewModel(fileBackupUri) } ) @@ -55,7 +55,7 @@ class RestoreLocalBackupFragment : LoggingFragment(R.layout.fragment_restore_loc setDebugLogSubmitMultiTapView(binding.verifyHeader) Log.i(TAG, "Backup restore.") - if (navigationViewModel.getBackupFileUri() == null) { + if (sharedViewModel.getBackupFileUri() == null) { Log.i(TAG, "No backup URI found, must navigate back to choose one.") findNavController().navigateUp() return @@ -110,11 +110,7 @@ class RestoreLocalBackupFragment : LoggingFragment(R.layout.fragment_restore_loc private fun onBackupCompletedSuccessfully() { Log.d(TAG, "onBackupCompletedSuccessfully()") val activity = requireActivity() as RestoreActivity - navigationViewModel.getNextIntent()?.let { - Log.d(TAG, "Launching ${it.component}") - activity.startActivity(it) - } - activity.finishActivitySuccessfully() + activity.onBackupCompletedSuccessfully() } override fun onStart() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupViewModel.kt index db358d1e8c..03673def0b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupViewModel.kt @@ -90,7 +90,7 @@ class RestoreLocalBackupViewModel(fileBackupUri: Uri) : ViewModel() { if (importResult == RestoreRepository.BackupImportResult.SUCCESS) { SignalStore.registration.localRegistrationMetadata?.let { RegistrationRepository.registerAccountLocally(context, it) - SignalStore.registration.clearLocalRegistrationMetadata() + SignalStore.registration.localRegistrationMetadata = null RegistrationUtil.maybeMarkRegistrationComplete() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt new file mode 100644 index 0000000000..3e1fc2730d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.restore.selection + +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import kotlinx.coroutines.launch +import org.thoughtcrime.securesms.MainActivity +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository +import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity +import org.thoughtcrime.securesms.registrationv3.ui.restore.RestoreMethod +import org.thoughtcrime.securesms.registrationv3.ui.restore.SelectRestoreMethodScreen +import org.thoughtcrime.securesms.util.navigation.safeNavigate +import org.whispersystems.signalservice.api.registration.RestoreMethod as ApiRestoreMethod + +/** + * Provide options to select restore/transfer operation and flow during quick registration. + */ +class SelectRestoreMethodFragment : ComposeFragment() { + @Composable + override fun FragmentContent() { + SelectRestoreMethodScreen( + restoreMethods = listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_OLD_DEVICE, RestoreMethod.FROM_LOCAL_BACKUP_V1), // TODO [backups] make dynamic + onRestoreMethodClicked = this::startRestoreMethod, + onSkip = { + SignalStore.registration.markSkippedTransferOrRestore() + + lifecycleScope.launch { + QuickRegistrationRepository.setRestoreMethodForOldDevice(ApiRestoreMethod.DECLINE) + } + + startActivity(MainActivity.clearTop(requireContext())) + activity?.finish() + } + ) + } + + private fun startRestoreMethod(method: RestoreMethod) { + val apiRestoreMethod = when (method) { + RestoreMethod.FROM_SIGNAL_BACKUPS -> ApiRestoreMethod.REMOTE_BACKUP + RestoreMethod.FROM_LOCAL_BACKUP_V1, RestoreMethod.FROM_LOCAL_BACKUP_V2 -> ApiRestoreMethod.LOCAL_BACKUP + RestoreMethod.FROM_OLD_DEVICE -> ApiRestoreMethod.DEVICE_TRANSFER + } + + lifecycleScope.launch { + QuickRegistrationRepository.setRestoreMethodForOldDevice(apiRestoreMethod) + } + + when (method) { + RestoreMethod.FROM_SIGNAL_BACKUPS -> startActivity(Intent(requireContext(), RemoteRestoreActivity::class.java)) + RestoreMethod.FROM_OLD_DEVICE -> findNavController().safeNavigate(SelectRestoreMethodFragmentDirections.goToDeviceTransfer()) + RestoreMethod.FROM_LOCAL_BACKUP_V1 -> findNavController().safeNavigate(SelectRestoreMethodFragmentDirections.goToLocalBackupRestore()) + RestoreMethod.FROM_LOCAL_BACKUP_V2 -> error("Not currently supported") + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/BackupRestorationType.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/BackupRestorationType.kt similarity index 75% rename from app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/BackupRestorationType.kt rename to app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/BackupRestorationType.kt index ea34c70d1a..22de9070e4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/BackupRestorationType.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/BackupRestorationType.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.thoughtcrime.securesms.devicetransfer.newdevice +package org.thoughtcrime.securesms.restore.transferorrestore /** * What kind of backup restore the user wishes to perform. @@ -11,6 +11,5 @@ package org.thoughtcrime.securesms.devicetransfer.newdevice enum class BackupRestorationType { DEVICE_TRANSFER, LOCAL_BACKUP, - REMOTE_BACKUP, NONE } diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreMoreOptionsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreMoreOptionsDialog.kt deleted file mode 100644 index 3eae9cc613..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreMoreOptionsDialog.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.restore.transferorrestore - -import android.os.Bundle -import android.view.ContextThemeWrapper -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.os.bundleOf -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.viewModels -import org.thoughtcrime.securesms.MainActivity -import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment -import org.thoughtcrime.securesms.databinding.TransferOrRestoreOptionsBottomSheetDialogFragmentBinding -import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType -import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity -import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity -import org.thoughtcrime.securesms.restore.RestoreActivity -import org.thoughtcrime.securesms.util.visible - -class TransferOrRestoreMoreOptionsDialog : FixedRoundedCornerBottomSheetDialogFragment() { - - override val peekHeightPercentage: Float = 1f - - private val viewModel by viewModels() - private lateinit var binding: TransferOrRestoreOptionsBottomSheetDialogFragmentBinding - - companion object { - - const val TAG = "TRANSFER_OR_RESTORE_OPTIONS_DIALOG_FRAGMENT" - const val ARG_SKIP_ONLY = "skip_only" - - fun show(fragmentManager: FragmentManager, skipOnly: Boolean) { - TransferOrRestoreMoreOptionsDialog().apply { - arguments = bundleOf(ARG_SKIP_ONLY to skipOnly) - }.show(fragmentManager, TAG) - } - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - binding = TransferOrRestoreOptionsBottomSheetDialogFragmentBinding.inflate(inflater.cloneInContext(ContextThemeWrapper(inflater.context, themeResId)), container, false) - if (arguments?.getBoolean(ARG_SKIP_ONLY, false) ?: false) { - binding.transferCard.visible = false - binding.localRestoreCard.visible = false - } - binding.transferOrRestoreFragmentNext.setOnClickListener { launchSelection(viewModel.getBackupRestorationType()) } - binding.transferCard.setOnClickListener { viewModel.onTransferFromAndroidDeviceSelected() } - binding.localRestoreCard.setOnClickListener { viewModel.onRestoreFromLocalBackupSelected() } - binding.skipCard.setOnClickListener { viewModel.onSkipRestoreOrTransferSelected() } - binding.cancel.setOnClickListener { dismiss() } - - viewModel.uiState.observe(viewLifecycleOwner) { state -> - updateSelection(state.restorationType) - } - - return binding.root - } - - private fun launchSelection(restorationType: BackupRestorationType?) { - when (restorationType) { - BackupRestorationType.DEVICE_TRANSFER -> { - startActivity(RestoreActivity.getIntentForTransfer(requireContext())) - } - BackupRestorationType.LOCAL_BACKUP -> { - startActivity(RestoreActivity.getIntentForLocalRestore(requireContext())) - } - BackupRestorationType.REMOTE_BACKUP -> { - startActivity(RemoteRestoreActivity.getIntent(requireContext())) - } - BackupRestorationType.NONE -> { - SignalStore.registration.markSkippedTransferOrRestore() - val startIntent = MainActivity.clearTop(requireContext()).apply { - putExtra("next_intent", CreateProfileActivity.getIntentForUserProfile(requireContext())) - } - startActivity(startIntent) - } - else -> { - return - } - } - dismiss() - } - - private fun updateSelection(restorationType: BackupRestorationType?) { - binding.transferCard.isSelected = restorationType == BackupRestorationType.DEVICE_TRANSFER - binding.localRestoreCard.isSelected = restorationType == BackupRestorationType.LOCAL_BACKUP - binding.skipCard.isSelected = restorationType == BackupRestorationType.NONE - binding.transferOrRestoreFragmentNext.isEnabled = restorationType != null - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreV2Fragment.kt index a2818976a9..a4739655fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreV2Fragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreV2Fragment.kt @@ -14,15 +14,10 @@ import org.thoughtcrime.securesms.LoggingFragment import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.databinding.FragmentTransferRestoreV2Binding -import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType -import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate -import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity import org.thoughtcrime.securesms.restore.RestoreViewModel -import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.SpanUtil import org.thoughtcrime.securesms.util.navigation.safeNavigate -import org.thoughtcrime.securesms.util.visible /** * This presents a list of options for the user to restore (or skip) a backup. @@ -37,18 +32,7 @@ class TransferOrRestoreV2Fragment : LoggingFragment(R.layout.fragment_transfer_r RegistrationViewDelegate.setDebugLogSubmitMultiTapView(binding.transferOrRestoreTitle) binding.transferOrRestoreFragmentTransfer.setOnClickListener { sharedViewModel.onTransferFromAndroidDeviceSelected() } binding.transferOrRestoreFragmentRestore.setOnClickListener { sharedViewModel.onRestoreFromLocalBackupSelected() } - binding.transferOrRestoreFragmentRestoreRemote.setOnClickListener { sharedViewModel.onRestoreFromRemoteBackupSelected() } binding.transferOrRestoreFragmentNext.setOnClickListener { launchSelection(sharedViewModel.getBackupRestorationType()) } - binding.transferOrRestoreFragmentMoreOptions.setOnClickListener { - TransferOrRestoreMoreOptionsDialog.show(fragmentManager = childFragmentManager, skipOnly = true) - } - - if (SignalStore.backup.backupTier == null) { - binding.transferOrRestoreFragmentRestoreRemoteCard.visible = false - } - - binding.transferOrRestoreFragmentRestoreRemoteCard.visible = RemoteConfig.messageBackups - binding.transferOrRestoreFragmentMoreOptions.visible = RemoteConfig.messageBackups val description = getString(R.string.TransferOrRestoreFragment__transfer_your_account_and_messages_from_your_old_android_device) val toBold = getString(R.string.TransferOrRestoreFragment__you_need_access_to_your_old_device) @@ -65,7 +49,6 @@ class TransferOrRestoreV2Fragment : LoggingFragment(R.layout.fragment_transfer_r private fun updateSelection(restorationType: BackupRestorationType) { binding.transferOrRestoreFragmentTransferCard.isSelected = restorationType == BackupRestorationType.DEVICE_TRANSFER binding.transferOrRestoreFragmentRestoreCard.isSelected = restorationType == BackupRestorationType.LOCAL_BACKUP - binding.transferOrRestoreFragmentRestoreRemoteCard.isSelected = restorationType == BackupRestorationType.REMOTE_BACKUP } private fun launchSelection(restorationType: BackupRestorationType) { @@ -76,9 +59,6 @@ class TransferOrRestoreV2Fragment : LoggingFragment(R.layout.fragment_transfer_r BackupRestorationType.LOCAL_BACKUP -> { NavHostFragment.findNavController(this).safeNavigate(TransferOrRestoreV2FragmentDirections.actionTransferOrRestoreToLocalRestore()) } - BackupRestorationType.REMOTE_BACKUP -> { - startActivity(RemoteRestoreActivity.getIntent(requireContext())) - } else -> { throw IllegalArgumentException() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreViewModel.kt deleted file mode 100644 index d309c35da1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreViewModel.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.restore.transferorrestore - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.asLiveData -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update -import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType - -class TransferOrRestoreViewModel : ViewModel() { - - private val store = MutableStateFlow(State()) - val uiState = store.asLiveData() - - fun onSkipRestoreOrTransferSelected() { - store.update { - it.copy(restorationType = BackupRestorationType.NONE) - } - } - - fun onTransferFromAndroidDeviceSelected() { - store.update { - it.copy(restorationType = BackupRestorationType.DEVICE_TRANSFER) - } - } - - fun onRestoreFromLocalBackupSelected() { - store.update { - it.copy(restorationType = BackupRestorationType.LOCAL_BACKUP) - } - } - - fun getBackupRestorationType(): BackupRestorationType? { - return store.value.restorationType - } -} - -data class State(val restorationType: BackupRestorationType? = null) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java index 04a7ad18f2..02c0a7bca3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupPreJoinActionProcessor.java @@ -127,7 +127,7 @@ protected GroupPreJoinActionProcessor(@NonNull MultiPeerActionProcessorFactory a WebRtcServiceStateBuilder.CallInfoStateBuilder builder = currentState.builder() .changeCallInfoState() - .remoteDevicesCount(peekInfo.getDeviceCount()) + .remoteDevicesCount(peekInfo.getDeviceCountExcludingPendingDevices()) .participantLimit(peekInfo.getMaxDevices()) .clearParticipantMap(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java deleted file mode 100644 index 6621bc92ce..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.java +++ /dev/null @@ -1,267 +0,0 @@ -package org.thoughtcrime.securesms.storage; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.signal.core.util.StringUtil; -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.whispersystems.signalservice.api.storage.SignalAccountRecord; -import org.whispersystems.signalservice.api.storage.SignalAccountRecord.PinnedConversation; -import org.whispersystems.signalservice.api.util.OptionalUtil; -import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; -import org.whispersystems.signalservice.internal.storage.protos.OptionalBool; - -import java.util.Arrays; -import java.util.List; -import java.util.Objects; -import java.util.Optional; - -/** - * Processes {@link SignalAccountRecord}s. Unlike some other {@link StorageRecordProcessor}s, this - * one has some statefulness in order to reject all but one account record (since we should have - * exactly one account record). - */ -public class AccountRecordProcessor extends DefaultStorageRecordProcessor { - - private static final String TAG = Log.tag(AccountRecordProcessor.class); - - private final Context context; - private final SignalAccountRecord localAccountRecord; - private final Recipient self; - - private boolean foundAccountRecord = false; - - public AccountRecordProcessor(@NonNull Context context, @NonNull Recipient self) { - this(context, self, StorageSyncHelper.buildAccountRecord(context, self).getAccount().get()); - } - - AccountRecordProcessor(@NonNull Context context, @NonNull Recipient self, @NonNull SignalAccountRecord localAccountRecord) { - this.context = context; - this.self = self; - this.localAccountRecord = localAccountRecord; - } - - /** - * We want to catch: - * - Multiple account records - */ - @Override - boolean isInvalid(@NonNull SignalAccountRecord remote) { - if (foundAccountRecord) { - Log.w(TAG, "Found an additional account record! Considering it invalid."); - return true; - } - - foundAccountRecord = true; - return false; - } - - @Override - public @NonNull Optional getMatching(@NonNull SignalAccountRecord record, @NonNull StorageKeyGenerator keyGenerator) { - return Optional.of(localAccountRecord); - } - - @Override - public @NonNull SignalAccountRecord merge(@NonNull SignalAccountRecord remote, @NonNull SignalAccountRecord local, @NonNull StorageKeyGenerator keyGenerator) { - String givenName; - String familyName; - - if (remote.getGivenName().isPresent() || remote.getFamilyName().isPresent()) { - givenName = remote.getGivenName().orElse(""); - familyName = remote.getFamilyName().orElse(""); - } else { - givenName = local.getGivenName().orElse(""); - familyName = local.getFamilyName().orElse(""); - } - - SignalAccountRecord.Payments payments; - - if (remote.getPayments().getEntropy().isPresent()) { - payments = remote.getPayments(); - } else { - payments = local.getPayments(); - } - - SignalAccountRecord.Subscriber subscriber; - - if (remote.getSubscriber().getId().isPresent()) { - subscriber = remote.getSubscriber(); - } else { - subscriber = local.getSubscriber(); - } - - SignalAccountRecord.Subscriber backupsSubscriber; - - if (remote.getSubscriber().getId().isPresent()) { - backupsSubscriber = remote.getSubscriber(); - } else { - backupsSubscriber = local.getSubscriber(); - } - - OptionalBool storyViewReceiptsState; - if (remote.getStoryViewReceiptsState() == OptionalBool.UNSET) { - storyViewReceiptsState = local.getStoryViewReceiptsState(); - } else { - storyViewReceiptsState = remote.getStoryViewReceiptsState(); - } - - byte[] unknownFields = remote.serializeUnknownFields(); - String avatarUrlPath = OptionalUtil.or(remote.getAvatarUrlPath(), local.getAvatarUrlPath()).orElse(""); - byte[] profileKey = OptionalUtil.or(remote.getProfileKey(), local.getProfileKey()).orElse(null); - boolean noteToSelfArchived = remote.isNoteToSelfArchived(); - boolean noteToSelfForcedUnread = remote.isNoteToSelfForcedUnread(); - boolean readReceipts = remote.isReadReceiptsEnabled(); - boolean typingIndicators = remote.isTypingIndicatorsEnabled(); - boolean sealedSenderIndicators = remote.isSealedSenderIndicatorsEnabled(); - boolean linkPreviews = remote.isLinkPreviewsEnabled(); - boolean unlisted = remote.isPhoneNumberUnlisted(); - List pinnedConversations = remote.getPinnedConversations(); - AccountRecord.PhoneNumberSharingMode phoneNumberSharingMode = remote.getPhoneNumberSharingMode(); - boolean preferContactAvatars = remote.isPreferContactAvatars(); - int universalExpireTimer = remote.getUniversalExpireTimer(); - boolean primarySendsSms = SignalStore.account().isPrimaryDevice() ? local.isPrimarySendsSms() : remote.isPrimarySendsSms(); - String e164 = SignalStore.account().isPrimaryDevice() ? local.getE164() : remote.getE164(); - List defaultReactions = remote.getDefaultReactions().size() > 0 ? remote.getDefaultReactions() : local.getDefaultReactions(); - boolean displayBadgesOnProfile = remote.isDisplayBadgesOnProfile(); - boolean subscriptionManuallyCancelled = remote.isSubscriptionManuallyCancelled(); - boolean keepMutedChatsArchived = remote.isKeepMutedChatsArchived(); - boolean hasSetMyStoriesPrivacy = remote.hasSetMyStoriesPrivacy(); - boolean hasViewedOnboardingStory = remote.hasViewedOnboardingStory() || local.hasViewedOnboardingStory(); - boolean storiesDisabled = remote.isStoriesDisabled(); - boolean hasSeenGroupStoryEducation = remote.hasSeenGroupStoryEducationSheet() || local.hasSeenGroupStoryEducationSheet(); - boolean hasSeenUsernameOnboarding = remote.hasCompletedUsernameOnboarding() || local.hasCompletedUsernameOnboarding(); - String username = remote.getUsername(); - AccountRecord.UsernameLink usernameLink = remote.getUsernameLink(); - boolean matchesRemote = doParamsMatch(remote, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, hasSeenUsernameOnboarding, storiesDisabled, storyViewReceiptsState, username, usernameLink, backupsSubscriber); - boolean matchesLocal = doParamsMatch(local, unknownFields, givenName, familyName, avatarUrlPath, profileKey, noteToSelfArchived, noteToSelfForcedUnread, readReceipts, typingIndicators, sealedSenderIndicators, linkPreviews, phoneNumberSharingMode, unlisted, pinnedConversations, preferContactAvatars, payments, universalExpireTimer, primarySendsSms, e164, defaultReactions, subscriber, displayBadgesOnProfile, subscriptionManuallyCancelled, keepMutedChatsArchived, hasSetMyStoriesPrivacy, hasViewedOnboardingStory, hasSeenUsernameOnboarding, storiesDisabled, storyViewReceiptsState, username, usernameLink, backupsSubscriber); - - if (matchesRemote) { - return remote; - } else if (matchesLocal) { - return local; - } else { - SignalAccountRecord.Builder builder = new SignalAccountRecord.Builder(keyGenerator.generate(), unknownFields) - .setGivenName(givenName) - .setFamilyName(familyName) - .setAvatarUrlPath(avatarUrlPath) - .setProfileKey(profileKey) - .setNoteToSelfArchived(noteToSelfArchived) - .setNoteToSelfForcedUnread(noteToSelfForcedUnread) - .setReadReceiptsEnabled(readReceipts) - .setTypingIndicatorsEnabled(typingIndicators) - .setSealedSenderIndicatorsEnabled(sealedSenderIndicators) - .setLinkPreviewsEnabled(linkPreviews) - .setUnlistedPhoneNumber(unlisted) - .setPhoneNumberSharingMode(phoneNumberSharingMode) - .setUnlistedPhoneNumber(unlisted) - .setPinnedConversations(pinnedConversations) - .setPreferContactAvatars(preferContactAvatars) - .setPayments(payments.isEnabled(), payments.getEntropy().orElse(null)) - .setUniversalExpireTimer(universalExpireTimer) - .setPrimarySendsSms(primarySendsSms) - .setDefaultReactions(defaultReactions) - .setSubscriber(subscriber) - .setStoryViewReceiptsState(storyViewReceiptsState) - .setDisplayBadgesOnProfile(displayBadgesOnProfile) - .setSubscriptionManuallyCancelled(subscriptionManuallyCancelled) - .setKeepMutedChatsArchived(keepMutedChatsArchived) - .setHasSetMyStoriesPrivacy(hasSetMyStoriesPrivacy) - .setHasViewedOnboardingStory(hasViewedOnboardingStory) - .setStoriesDisabled(storiesDisabled) - .setHasSeenGroupStoryEducationSheet(hasSeenGroupStoryEducation) - .setHasCompletedUsernameOnboarding(hasSeenUsernameOnboarding) - .setUsername(username) - .setUsernameLink(usernameLink) - .setBackupsSubscriber(backupsSubscriber); - - return builder.build(); - } - } - - @Override - void insertLocal(@NonNull SignalAccountRecord record) { - throw new UnsupportedOperationException("We should always have a local AccountRecord, so we should never been inserting a new one."); - } - - @Override - void updateLocal(@NonNull StorageRecordUpdate update) { - StorageSyncHelper.applyAccountStorageSyncUpdates(context, self, update, true); - } - - @Override - public int compare(@NonNull SignalAccountRecord lhs, @NonNull SignalAccountRecord rhs) { - return 0; - } - - private static boolean doParamsMatch(@NonNull SignalAccountRecord contact, - @Nullable byte[] unknownFields, - @NonNull String givenName, - @NonNull String familyName, - @NonNull String avatarUrlPath, - @Nullable byte[] profileKey, - boolean noteToSelfArchived, - boolean noteToSelfForcedUnread, - boolean readReceipts, - boolean typingIndicators, - boolean sealedSenderIndicators, - boolean linkPreviewsEnabled, - AccountRecord.PhoneNumberSharingMode phoneNumberSharingMode, - boolean unlistedPhoneNumber, - @NonNull List pinnedConversations, - boolean preferContactAvatars, - SignalAccountRecord.Payments payments, - int universalExpireTimer, - boolean primarySendsSms, - String e164, - @NonNull List defaultReactions, - @NonNull SignalAccountRecord.Subscriber subscriber, - boolean displayBadgesOnProfile, - boolean subscriptionManuallyCancelled, - boolean keepMutedChatsArchived, - boolean hasSetMyStoriesPrivacy, - boolean hasViewedOnboardingStory, - boolean hasCompletedUsernameOnboarding, - boolean storiesDisabled, - @NonNull OptionalBool storyViewReceiptsState, - @Nullable String username, - @Nullable AccountRecord.UsernameLink usernameLink, - @NonNull SignalAccountRecord.Subscriber backupsSubscriber) - { - return Arrays.equals(contact.serializeUnknownFields(), unknownFields) && - Objects.equals(contact.getGivenName().orElse(""), givenName) && - Objects.equals(contact.getFamilyName().orElse(""), familyName) && - Objects.equals(contact.getAvatarUrlPath().orElse(""), avatarUrlPath) && - Objects.equals(contact.getPayments(), payments) && - Objects.equals(contact.getE164(), e164) && - Objects.equals(contact.getDefaultReactions(), defaultReactions) && - Arrays.equals(contact.getProfileKey().orElse(null), profileKey) && - contact.isNoteToSelfArchived() == noteToSelfArchived && - contact.isNoteToSelfForcedUnread() == noteToSelfForcedUnread && - contact.isReadReceiptsEnabled() == readReceipts && - contact.isTypingIndicatorsEnabled() == typingIndicators && - contact.isSealedSenderIndicatorsEnabled() == sealedSenderIndicators && - contact.isLinkPreviewsEnabled() == linkPreviewsEnabled && - contact.getPhoneNumberSharingMode() == phoneNumberSharingMode && - contact.isPhoneNumberUnlisted() == unlistedPhoneNumber && - contact.isPreferContactAvatars() == preferContactAvatars && - contact.getUniversalExpireTimer() == universalExpireTimer && - contact.isPrimarySendsSms() == primarySendsSms && - Objects.equals(contact.getPinnedConversations(), pinnedConversations) && - Objects.equals(contact.getSubscriber(), subscriber) && - contact.isDisplayBadgesOnProfile() == displayBadgesOnProfile && - contact.isSubscriptionManuallyCancelled() == subscriptionManuallyCancelled && - contact.isKeepMutedChatsArchived() == keepMutedChatsArchived && - contact.hasSetMyStoriesPrivacy() == hasSetMyStoriesPrivacy && - contact.hasViewedOnboardingStory() == hasViewedOnboardingStory && - contact.hasCompletedUsernameOnboarding() == hasCompletedUsernameOnboarding && - contact.isStoriesDisabled() == storiesDisabled && - contact.getStoryViewReceiptsState().equals(storyViewReceiptsState) && - Objects.equals(contact.getUsername(), username) && - Objects.equals(contact.getUsernameLink(), usernameLink) && - Objects.equals(contact.getBackupsSubscriber(), backupsSubscriber); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt new file mode 100644 index 0000000000..3a0c516b24 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/AccountRecordProcessor.kt @@ -0,0 +1,165 @@ +package org.thoughtcrime.securesms.storage + +import android.content.Context +import okio.ByteString +import org.signal.core.util.isNotEmpty +import org.signal.core.util.logging.Log +import org.signal.core.util.nullIfEmpty +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.storage.StorageSyncHelper.applyAccountStorageSyncUpdates +import org.whispersystems.signalservice.api.storage.SignalAccountRecord +import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.api.storage.safeSetBackupsSubscriber +import org.whispersystems.signalservice.api.storage.safeSetPayments +import org.whispersystems.signalservice.api.storage.safeSetSubscriber +import org.whispersystems.signalservice.api.storage.toSignalAccountRecord +import org.whispersystems.signalservice.internal.storage.protos.OptionalBool +import java.util.Optional + +/** + * Processes [SignalAccountRecord]s. Unlike some other [StorageRecordProcessor]s, this + * one has some statefulness in order to reject all but one account record (since we should have + * exactly one account record). + */ +class AccountRecordProcessor( + private val context: Context, + private val self: Recipient, + private val localAccountRecord: SignalAccountRecord +) : DefaultStorageRecordProcessor() { + + companion object { + private val TAG = Log.tag(AccountRecordProcessor::class.java) + } + + private var foundAccountRecord = false + + constructor(context: Context, self: Recipient) : this( + context = context, + self = self, + localAccountRecord = StorageSyncHelper.buildAccountRecord(context, self).let { it.proto.account!!.toSignalAccountRecord(it.id) } + ) + + /** + * We want to catch: + * - Multiple account records + */ + override fun isInvalid(remote: SignalAccountRecord): Boolean { + if (foundAccountRecord) { + Log.w(TAG, "Found an additional account record! Considering it invalid.") + return true + } + + foundAccountRecord = true + return false + } + + override fun getMatching(remote: SignalAccountRecord, keyGenerator: StorageKeyGenerator): Optional { + return Optional.of(localAccountRecord) + } + + override fun merge(remote: SignalAccountRecord, local: SignalAccountRecord, keyGenerator: StorageKeyGenerator): SignalAccountRecord { + val mergedGivenName: String + val mergedFamilyName: String + + if (remote.proto.givenName.isNotBlank() || remote.proto.familyName.isNotBlank()) { + mergedGivenName = remote.proto.givenName + mergedFamilyName = remote.proto.familyName + } else { + mergedGivenName = local.proto.givenName + mergedFamilyName = local.proto.familyName + } + + val payments = if (remote.proto.payments?.entropy != null) { + remote.proto.payments + } else { + local.proto.payments + } + + val donationSubscriberId: ByteString + val donationSubscriberCurrencyCode: String + + if (remote.proto.subscriberId.isNotEmpty()) { + donationSubscriberId = remote.proto.subscriberId + donationSubscriberCurrencyCode = remote.proto.subscriberCurrencyCode + } else { + donationSubscriberId = local.proto.subscriberId + donationSubscriberCurrencyCode = remote.proto.subscriberCurrencyCode + } + + val backupsSubscriberId: ByteString + val backupsSubscriberCurrencyCode: String + + if (remote.proto.backupsSubscriberId.isNotEmpty()) { + backupsSubscriberId = remote.proto.backupsSubscriberId + backupsSubscriberCurrencyCode = remote.proto.backupsSubscriberCurrencyCode + } else { + backupsSubscriberId = local.proto.backupsSubscriberId + backupsSubscriberCurrencyCode = remote.proto.backupsSubscriberCurrencyCode + } + + val storyViewReceiptsState = if (remote.proto.storyViewReceiptsEnabled == OptionalBool.UNSET) { + local.proto.storyViewReceiptsEnabled + } else { + remote.proto.storyViewReceiptsEnabled + } + + val unknownFields = remote.serializedUnknowns + + val merged = SignalAccountRecord.newBuilder(unknownFields).apply { + givenName = mergedGivenName + familyName = mergedFamilyName + avatarUrlPath = remote.proto.avatarUrlPath.nullIfEmpty() ?: local.proto.avatarUrlPath + profileKey = remote.proto.profileKey.nullIfEmpty() ?: local.proto.profileKey + noteToSelfArchived = remote.proto.noteToSelfArchived + noteToSelfMarkedUnread = remote.proto.noteToSelfMarkedUnread + readReceipts = remote.proto.readReceipts + typingIndicators = remote.proto.typingIndicators + sealedSenderIndicators = remote.proto.sealedSenderIndicators + linkPreviews = remote.proto.linkPreviews + unlistedPhoneNumber = remote.proto.unlistedPhoneNumber + pinnedConversations = remote.proto.pinnedConversations + phoneNumberSharingMode = remote.proto.phoneNumberSharingMode + preferContactAvatars = remote.proto.preferContactAvatars + universalExpireTimer = remote.proto.universalExpireTimer + primarySendsSms = false + e164 = if (SignalStore.account.isPrimaryDevice) local.proto.e164 else remote.proto.e164 + preferredReactionEmoji = remote.proto.preferredReactionEmoji.takeIf { it.isNotEmpty() } ?: local.proto.preferredReactionEmoji + displayBadgesOnProfile = remote.proto.displayBadgesOnProfile + subscriptionManuallyCancelled = remote.proto.subscriptionManuallyCancelled + keepMutedChatsArchived = remote.proto.keepMutedChatsArchived + hasSetMyStoriesPrivacy = remote.proto.hasSetMyStoriesPrivacy + hasViewedOnboardingStory = remote.proto.hasViewedOnboardingStory || local.proto.hasViewedOnboardingStory + storiesDisabled = remote.proto.storiesDisabled + storyViewReceiptsEnabled = storyViewReceiptsState + hasSeenGroupStoryEducationSheet = remote.proto.hasSeenGroupStoryEducationSheet || local.proto.hasSeenGroupStoryEducationSheet + hasCompletedUsernameOnboarding = remote.proto.hasCompletedUsernameOnboarding || local.proto.hasCompletedUsernameOnboarding + username = remote.proto.username + usernameLink = remote.proto.usernameLink + + safeSetPayments(payments?.enabled == true, payments?.entropy?.toByteArray()) + safeSetSubscriber(donationSubscriberId, donationSubscriberCurrencyCode) + safeSetBackupsSubscriber(backupsSubscriberId, backupsSubscriberCurrencyCode) + }.toSignalAccountRecord(StorageId.forAccount(keyGenerator.generate())) + + return if (doParamsMatch(remote, merged)) { + remote + } else if (doParamsMatch(local, merged)) { + local + } else { + merged + } + } + + override fun insertLocal(record: SignalAccountRecord) { + throw UnsupportedOperationException("We should always have a local AccountRecord, so we should never been inserting a new one.") + } + + override fun updateLocal(update: StorageRecordUpdate) { + applyAccountStorageSyncUpdates(context, self, update, true) + } + + override fun compare(lhs: SignalAccountRecord, rhs: SignalAccountRecord): Int { + return 0 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/CallLinkRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/CallLinkRecordProcessor.kt index adb77a5673..0fc7858984 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/CallLinkRecordProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/CallLinkRecordProcessor.kt @@ -5,43 +5,52 @@ package org.thoughtcrime.securesms.storage +import okio.ByteString.Companion.toByteString +import org.signal.core.util.isNotEmpty import org.signal.core.util.logging.Log +import org.signal.core.util.toOptional import org.signal.ringrtc.CallLinkRootKey import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord +import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.api.storage.toSignalCallLinkRecord import java.util.Optional -internal class CallLinkRecordProcessor : DefaultStorageRecordProcessor() { +/** + * Record processor for [SignalCallLinkRecord]. + * Handles merging and updating our local store when processing remote call link storage records. + */ +class CallLinkRecordProcessor : DefaultStorageRecordProcessor() { companion object { private val TAG = Log.tag(CallLinkRecordProcessor::class) } override fun compare(o1: SignalCallLinkRecord?, o2: SignalCallLinkRecord?): Int { - return if (o1?.rootKey.contentEquals(o2?.rootKey)) { + return if (o1?.proto?.rootKey == o2?.proto?.rootKey) { 0 } else { 1 } } - internal override fun isInvalid(remote: SignalCallLinkRecord): Boolean { - return remote.adminPassKey.isNotEmpty() && remote.deletionTimestamp > 0L + override fun isInvalid(remote: SignalCallLinkRecord): Boolean { + return remote.proto.adminPasskey.isNotEmpty() && remote.proto.deletedAtTimestampMs > 0L } - internal override fun getMatching(remote: SignalCallLinkRecord, keyGenerator: StorageKeyGenerator): Optional { + override fun getMatching(remote: SignalCallLinkRecord, keyGenerator: StorageKeyGenerator): Optional { Log.d(TAG, "Attempting to get matching record...") - val rootKey = CallLinkRootKey(remote.rootKey) - val roomId = CallLinkRoomId.fromCallLinkRootKey(rootKey) + val callRootKey = CallLinkRootKey(remote.proto.rootKey.toByteArray()) + val roomId = CallLinkRoomId.fromCallLinkRootKey(callRootKey) val callLink = SignalDatabase.callLinks.getCallLinkByRoomId(roomId) + if (callLink != null && callLink.credentials?.adminPassBytes != null) { - val builder = SignalCallLinkRecord.Builder(keyGenerator.generate(), null).apply { - setRootKey(rootKey.keyBytes) - setAdminPassKey(callLink.credentials.adminPassBytes) - setDeletedTimestamp(callLink.deletionTimestamp) - } - return Optional.of(builder.build()) + return SignalCallLinkRecord.newBuilder(null).apply { + rootKey = callRootKey.keyBytes.toByteString() + adminPasskey = callLink.credentials.adminPassBytes.toByteString() + deletedAtTimestampMs = callLink.deletionTimestamp + }.build().toSignalCallLinkRecord(StorageId.forCallLink(keyGenerator.generate())).toOptional() } else { return Optional.empty() } @@ -52,16 +61,16 @@ internal class CallLinkRecordProcessor : DefaultStorageRecordProcessor 0 && local.proto.deletedAtTimestampMs > 0) { + if (remote.proto.deletedAtTimestampMs < local.proto.deletedAtTimestampMs) { remote } else { local } - } else if (remote.isDeleted()) { + } else if (remote.proto.deletedAtTimestampMs > 0) { remote - } else if (local.isDeleted()) { + } else if (local.proto.deletedAtTimestampMs > 0) { local } else { remote @@ -77,12 +86,12 @@ internal class CallLinkRecordProcessor : DefaultStorageRecordProcessor { - - private static final String TAG = Log.tag(ContactRecordProcessor.class); - - private static final Pattern E164_PATTERN = Pattern.compile("^\\+[1-9]\\d{0,18}$"); - - private final RecipientTable recipientTable; - - private final ACI selfAci; - private final PNI selfPni; - private final String selfE164; - - public ContactRecordProcessor() { - this(SignalStore.account().getAci(), - SignalStore.account().getPni(), - SignalStore.account().getE164(), - SignalDatabase.recipients()); - } - - ContactRecordProcessor(@Nullable ACI selfAci, @Nullable PNI selfPni, @Nullable String selfE164, @NonNull RecipientTable recipientTable) { - this.recipientTable = recipientTable; - this.selfAci = selfAci; - this.selfPni = selfPni; - this.selfE164 = selfE164; - } - - /** - * For contact records specifically, we have some extra work that needs to be done before we process all of the records. - * - * We have to find all unregistered ACI-only records and split them into two separate contact rows locally, if necessary. - * The reasons are nuanced, but the TL;DR is that we want to split unregistered users into separate rows so that a user - * could re-register and get a different ACI. - */ - @Override - public void process(@NonNull Collection remoteRecords, @NonNull StorageKeyGenerator keyGenerator) throws IOException { - List unregisteredAciOnly = new ArrayList<>(); - - for (SignalContactRecord remoteRecord : remoteRecords) { - if (isInvalid(remoteRecord)) { - continue; - } - - if (remoteRecord.getUnregisteredTimestamp() > 0 && remoteRecord.getAci().isPresent() && remoteRecord.getPni().isEmpty() && remoteRecord.getNumber().isEmpty()) { - unregisteredAciOnly.add(remoteRecord); - } - } - - if (unregisteredAciOnly.size() > 0) { - for (SignalContactRecord aciOnly : unregisteredAciOnly) { - SignalDatabase.recipients().splitForStorageSyncIfNecessary(aciOnly.getAci().get()); - } - } - - super.process(remoteRecords, keyGenerator); - } - - /** - * Error cases: - * - You can't have a contact record without an ACI or PNI. - * - You can't have a contact record for yourself. That should be an account record. - * - * Note: This method could be written more succinctly, but the logs are useful :) - */ - @Override - boolean isInvalid(@NonNull SignalContactRecord remote) { - boolean hasAci = remote.getAci().isPresent() && remote.getAci().get().isValid(); - boolean hasPni = remote.getPni().isPresent() && remote.getPni().get().isValid(); - - if (!hasAci && !hasPni) { - Log.w(TAG, "Found a ContactRecord with neither an ACI nor a PNI -- marking as invalid."); - return true; - } else if (selfAci != null && selfAci.equals(remote.getAci().orElse(null)) || - (selfPni != null && selfPni.equals(remote.getPni().orElse(null))) || - (selfE164 != null && remote.getNumber().isPresent() && remote.getNumber().get().equals(selfE164))) - { - Log.w(TAG, "Found a ContactRecord for ourselves -- marking as invalid."); - return true; - } else if (remote.getNumber().isPresent() && !isValidE164(remote.getNumber().get())) { - Log.w(TAG, "Found a record with an invalid E164. Marking as invalid."); - return true; - } else { - return false; - } - } - - @Override - @NonNull Optional getMatching(@NonNull SignalContactRecord remote, @NonNull StorageKeyGenerator keyGenerator) { - Optional found = remote.getAci().isPresent() ? recipientTable.getByAci(remote.getAci().get()) : Optional.empty(); - - if (found.isEmpty() && remote.getNumber().isPresent()) { - found = recipientTable.getByE164(remote.getNumber().get()); - } - - if (found.isEmpty() && remote.getPni().isPresent()) { - found = recipientTable.getByPni(remote.getPni().get()); - } - - return found.map(recipientTable::getRecordForSync) - .map(settings -> { - if (settings.getStorageId() != null) { - return StorageSyncModels.localToRemoteRecord(settings); - } else { - Log.w(TAG, "Newly discovering a registered user via storage service. Saving a storageId for them."); - recipientTable.updateStorageId(settings.getId(), keyGenerator.generate()); - - RecipientRecord updatedSettings = Objects.requireNonNull(recipientTable.getRecordForSync(settings.getId())); - return StorageSyncModels.localToRemoteRecord(updatedSettings); - } - }) - .map(r -> r.getContact().get()); - } - - @Override - @NonNull SignalContactRecord merge(@NonNull SignalContactRecord remote, @NonNull SignalContactRecord local, @NonNull StorageKeyGenerator keyGenerator) { - String profileGivenName; - String profileFamilyName; - - if (remote.getProfileGivenName().isPresent() || remote.getProfileFamilyName().isPresent()) { - profileGivenName = remote.getProfileGivenName().orElse(""); - profileFamilyName = remote.getProfileFamilyName().orElse(""); - } else { - profileGivenName = local.getProfileGivenName().orElse(""); - profileFamilyName = local.getProfileFamilyName().orElse(""); - } - - IdentityState identityState; - byte[] identityKey; - - if ((remote.getIdentityState() != local.getIdentityState() && remote.getIdentityKey().isPresent()) || - (remote.getIdentityKey().isPresent() && local.getIdentityKey().isEmpty()) || - (remote.getIdentityKey().isPresent() && local.getUnregisteredTimestamp() > 0)) - { - identityState = remote.getIdentityState(); - identityKey = remote.getIdentityKey().get(); - } else { - identityState = local.getIdentityState(); - identityKey = local.getIdentityKey().orElse(null); - } - - if (local.getAci().isPresent() && identityKey != null && remote.getIdentityKey().isPresent() && !Arrays.equals(identityKey, remote.getIdentityKey().get())) { - Log.w(TAG, "The local and remote identity keys do not match for " + local.getAci().orElse(null) + ". Enqueueing a profile fetch."); - RetrieveProfileJob.enqueue(Recipient.trustedPush(local.getAci().get(), local.getPni().orElse(null), local.getNumber().orElse(null)).getId()); - } - - PNI pni; - String e164; - - boolean e164sMatchButPnisDont = local.getNumber().isPresent() && - local.getNumber().get().equals(remote.getNumber().orElse(null)) && - local.getPni().isPresent() && - remote.getPni().isPresent() && - !local.getPni().get().equals(remote.getPni().get()); - - boolean pnisMatchButE164sDont = local.getPni().isPresent() && - local.getPni().get().equals(remote.getPni().orElse(null)) && - local.getNumber().isPresent() && - remote.getNumber().isPresent() && - !local.getNumber().get().equals(remote.getNumber().get()); - - if (e164sMatchButPnisDont) { - Log.w(TAG, "Matching E164s, but the PNIs differ! Trusting our local pair."); - // TODO [pnp] Schedule CDS fetch? - pni = local.getPni().get(); - e164 = local.getNumber().get(); - } else if (pnisMatchButE164sDont) { - Log.w(TAG, "Matching PNIs, but the E164s differ! Trusting our local pair."); - // TODO [pnp] Schedule CDS fetch? - pni = local.getPni().get(); - e164 = local.getNumber().get(); - } else { - pni = OptionalUtil.or(remote.getPni(), local.getPni()).orElse(null); - e164 = OptionalUtil.or(remote.getNumber(), local.getNumber()).orElse(null); - } - - byte[] unknownFields = remote.serializeUnknownFields(); - ACI aci = local.getAci().isEmpty() ? remote.getAci().orElse(null) : local.getAci().get(); - byte[] profileKey = OptionalUtil.or(remote.getProfileKey(), local.getProfileKey()).orElse(null); - String username = OptionalUtil.or(remote.getUsername(), local.getUsername()).orElse(""); - boolean blocked = remote.isBlocked(); - boolean profileSharing = remote.isProfileSharingEnabled(); - boolean archived = remote.isArchived(); - boolean forcedUnread = remote.isForcedUnread(); - long muteUntil = remote.getMuteUntil(); - boolean hideStory = remote.shouldHideStory(); - long unregisteredTimestamp = remote.getUnregisteredTimestamp(); - boolean hidden = remote.isHidden(); - String systemGivenName = SignalStore.account().isPrimaryDevice() ? local.getSystemGivenName().orElse("") : remote.getSystemGivenName().orElse(""); - String systemFamilyName = SignalStore.account().isPrimaryDevice() ? local.getSystemFamilyName().orElse("") : remote.getSystemFamilyName().orElse(""); - String systemNickname = remote.getSystemNickname().orElse(""); - String nicknameGivenName = remote.getNicknameGivenName().orElse(""); - String nicknameFamilyName = remote.getNicknameFamilyName().orElse(""); - boolean pniSignatureVerified = remote.isPniSignatureVerified() || local.isPniSignatureVerified(); - String note = remote.getNote().or(local::getNote).orElse(""); - boolean matchesRemote = doParamsMatch(remote, unknownFields, aci, pni, e164, profileGivenName, profileFamilyName, systemGivenName, systemFamilyName, systemNickname, profileKey, username, identityState, identityKey, blocked, profileSharing, archived, forcedUnread, muteUntil, hideStory, unregisteredTimestamp, hidden, pniSignatureVerified, nicknameGivenName, nicknameFamilyName, note); - boolean matchesLocal = doParamsMatch(local, unknownFields, aci, pni, e164, profileGivenName, profileFamilyName, systemGivenName, systemFamilyName, systemNickname, profileKey, username, identityState, identityKey, blocked, profileSharing, archived, forcedUnread, muteUntil, hideStory, unregisteredTimestamp, hidden, pniSignatureVerified, nicknameGivenName, nicknameFamilyName, note); - - if (matchesRemote) { - return remote; - } else if (matchesLocal) { - return local; - } else { - return new SignalContactRecord.Builder(keyGenerator.generate(), aci, unknownFields) - .setE164(e164) - .setPni(pni) - .setProfileGivenName(profileGivenName) - .setProfileFamilyName(profileFamilyName) - .setSystemGivenName(systemGivenName) - .setSystemFamilyName(systemFamilyName) - .setSystemNickname(systemNickname) - .setProfileKey(profileKey) - .setUsername(username) - .setIdentityState(identityState) - .setIdentityKey(identityKey) - .setBlocked(blocked) - .setProfileSharingEnabled(profileSharing) - .setArchived(archived) - .setForcedUnread(forcedUnread) - .setMuteUntil(muteUntil) - .setHideStory(hideStory) - .setUnregisteredTimestamp(unregisteredTimestamp) - .setHidden(hidden) - .setPniSignatureVerified(pniSignatureVerified) - .setNicknameGivenName(nicknameGivenName) - .setNicknameFamilyName(nicknameFamilyName) - .setNote(note) - .build(); - } - } - - @Override - void insertLocal(@NonNull SignalContactRecord record) { - recipientTable.applyStorageSyncContactInsert(record); - } - - @Override - void updateLocal(@NonNull StorageRecordUpdate update) { - recipientTable.applyStorageSyncContactUpdate(update); - } - - @Override - public int compare(@NonNull SignalContactRecord lhs, @NonNull SignalContactRecord rhs) { - if ((lhs.getAci().isPresent() && Objects.equals(lhs.getAci(), rhs.getAci())) || - (lhs.getNumber().isPresent() && Objects.equals(lhs.getNumber(), rhs.getNumber())) || - (lhs.getPni().isPresent() && Objects.equals(lhs.getPni(), rhs.getPni()))) - { - return 0; - } else { - return 1; - } - } - - private static boolean isValidE164(String value) { - return E164_PATTERN.matcher(value).matches(); - } - - private static boolean doParamsMatch(@NonNull SignalContactRecord contact, - @Nullable byte[] unknownFields, - @Nullable ACI aci, - @Nullable PNI pni, - @Nullable String e164, - @NonNull String profileGivenName, - @NonNull String profileFamilyName, - @NonNull String systemGivenName, - @NonNull String systemFamilyName, - @NonNull String systemNickname, - @Nullable byte[] profileKey, - @NonNull String username, - @Nullable IdentityState identityState, - @Nullable byte[] identityKey, - boolean blocked, - boolean profileSharing, - boolean archived, - boolean forcedUnread, - long muteUntil, - boolean hideStory, - long unregisteredTimestamp, - boolean hidden, - boolean pniSignatureVerified, - @NonNull String nicknameGivenName, - @NonNull String nicknameFamilyName, - @NonNull String note) - { - return Arrays.equals(contact.serializeUnknownFields(), unknownFields) && - Objects.equals(contact.getAci().orElse(null), aci) && - Objects.equals(contact.getPni().orElse(null), pni) && - Objects.equals(contact.getNumber().orElse(null), e164) && - Objects.equals(contact.getProfileGivenName().orElse(""), profileGivenName) && - Objects.equals(contact.getProfileFamilyName().orElse(""), profileFamilyName) && - Objects.equals(contact.getSystemGivenName().orElse(""), systemGivenName) && - Objects.equals(contact.getSystemFamilyName().orElse(""), systemFamilyName) && - Objects.equals(contact.getSystemNickname().orElse(""), systemNickname) && - Arrays.equals(contact.getProfileKey().orElse(null), profileKey) && - Objects.equals(contact.getUsername().orElse(""), username) && - Objects.equals(contact.getIdentityState(), identityState) && - Arrays.equals(contact.getIdentityKey().orElse(null), identityKey) && - contact.isBlocked() == blocked && - contact.isProfileSharingEnabled() == profileSharing && - contact.isArchived() == archived && - contact.isForcedUnread() == forcedUnread && - contact.getMuteUntil() == muteUntil && - contact.shouldHideStory() == hideStory && - contact.getUnregisteredTimestamp() == unregisteredTimestamp && - contact.isHidden() == hidden && - contact.isPniSignatureVerified() == pniSignatureVerified && - Objects.equals(contact.getNicknameGivenName().orElse(""), nicknameGivenName) && - Objects.equals(contact.getNicknameFamilyName().orElse(""), nicknameFamilyName) && - Objects.equals(contact.getNote().orElse(""), note); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.kt new file mode 100644 index 0000000000..e45df31fa6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/ContactRecordProcessor.kt @@ -0,0 +1,267 @@ +package org.thoughtcrime.securesms.storage + +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.signal.core.util.isEmpty +import org.signal.core.util.isNotEmpty +import org.signal.core.util.logging.Log +import org.signal.core.util.nullIfBlank +import org.signal.core.util.nullIfEmpty +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.RecipientRecord +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob.Companion.enqueue +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient.Companion.trustedPush +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.storage.StorageSyncModels.localToRemoteRecord +import org.whispersystems.signalservice.api.push.ServiceId.ACI +import org.whispersystems.signalservice.api.push.ServiceId.PNI +import org.whispersystems.signalservice.api.storage.SignalContactRecord +import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.api.storage.signalAci +import org.whispersystems.signalservice.api.storage.signalPni +import org.whispersystems.signalservice.api.storage.toSignalContactRecord +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState +import java.io.IOException +import java.util.Optional +import java.util.regex.Pattern + +/** + * Record processor for [SignalContactRecord]. + * Handles merging and updating our local store when processing remote contact storage records. + */ +class ContactRecordProcessor( + private val selfAci: ACI?, + private val selfPni: PNI?, + private val selfE164: String?, + private val recipientTable: RecipientTable +) : DefaultStorageRecordProcessor() { + + companion object { + private val TAG = Log.tag(ContactRecordProcessor::class.java) + + private val E164_PATTERN: Pattern = Pattern.compile("^\\+[1-9]\\d{0,18}$") + + private fun isValidE164(value: String): Boolean { + return E164_PATTERN.matcher(value).matches() + } + } + + constructor() : this( + selfAci = SignalStore.account.aci, + selfPni = SignalStore.account.pni, + selfE164 = SignalStore.account.e164, + recipientTable = SignalDatabase.recipients + ) + + /** + * For contact records specifically, we have some extra work that needs to be done before we process all of the records. + * + * We have to find all unregistered ACI-only records and split them into two separate contact rows locally, if necessary. + * The reasons are nuanced, but the TL;DR is that we want to split unregistered users into separate rows so that a user + * could re-register and get a different ACI. + */ + @Throws(IOException::class) + override fun process(remoteRecords: Collection, keyGenerator: StorageKeyGenerator) { + val unregisteredAciOnly: MutableList = ArrayList() + + for (remoteRecord in remoteRecords) { + if (isInvalid(remoteRecord)) { + continue + } + + if (remoteRecord.proto.unregisteredAtTimestamp > 0 && remoteRecord.proto.signalAci != null && remoteRecord.proto.signalPni == null && remoteRecord.proto.e164.isBlank()) { + unregisteredAciOnly.add(remoteRecord) + } + } + + if (unregisteredAciOnly.size > 0) { + for (aciOnly in unregisteredAciOnly) { + SignalDatabase.recipients.splitForStorageSyncIfNecessary(aciOnly.proto.signalAci!!) + } + } + + super.process(remoteRecords, keyGenerator) + } + + /** + * Error cases: + * - You can't have a contact record without an ACI or PNI. + * - You can't have a contact record for yourself. That should be an account record. + * + * Note: This method could be written more succinctly, but the logs are useful :) + */ + override fun isInvalid(remote: SignalContactRecord): Boolean { + val hasAci = remote.proto.signalAci?.isValid == true + val hasPni = remote.proto.signalPni?.isValid == true + + if (!hasAci && !hasPni) { + Log.w(TAG, "Found a ContactRecord with neither an ACI nor a PNI -- marking as invalid.") + return true + } else if (selfAci != null && selfAci == remote.proto.signalAci || + (selfPni != null && selfPni == remote.proto.signalPni) || + (selfE164 != null && remote.proto.e164.isNotBlank() && remote.proto.e164 == selfE164) + ) { + Log.w(TAG, "Found a ContactRecord for ourselves -- marking as invalid.") + return true + } else if (remote.proto.e164.isNotBlank() && !isValidE164(remote.proto.e164)) { + Log.w(TAG, "Found a record with an invalid E164. Marking as invalid.") + return true + } else { + return false + } + } + + override fun getMatching(remote: SignalContactRecord, keyGenerator: StorageKeyGenerator): Optional { + var found: Optional = remote.proto.signalAci?.let { recipientTable.getByAci(it) } ?: Optional.empty() + + if (found.isEmpty && remote.proto.e164.isNotBlank()) { + found = recipientTable.getByE164(remote.proto.e164) + } + + if (found.isEmpty && remote.proto.signalPni != null) { + found = recipientTable.getByPni(remote.proto.signalPni!!) + } + + return found + .map { recipientTable.getRecordForSync(it)!! } + .map { settings: RecipientRecord -> + if (settings.storageId != null) { + return@map localToRemoteRecord(settings) + } else { + Log.w(TAG, "Newly discovering a registered user via storage service. Saving a storageId for them.") + recipientTable.updateStorageId(settings.id, keyGenerator.generate()) + + val updatedSettings = recipientTable.getRecordForSync(settings.id)!! + return@map localToRemoteRecord(updatedSettings) + } + } + .map { record -> SignalContactRecord(record.id, record.proto.contact!!) } + } + + override fun merge(remote: SignalContactRecord, local: SignalContactRecord, keyGenerator: StorageKeyGenerator): SignalContactRecord { + val mergedProfileGivenName: String + val mergedProfileFamilyName: String + + val localAci = local.proto.signalAci + val localPni = local.proto.signalPni + + val remoteAci = remote.proto.signalAci + val remotePni = remote.proto.signalPni + + if (remote.proto.givenName.isNotBlank() || remote.proto.familyName.isNotBlank()) { + mergedProfileGivenName = remote.proto.givenName + mergedProfileFamilyName = remote.proto.familyName + } else { + mergedProfileGivenName = local.proto.givenName + mergedProfileFamilyName = local.proto.familyName + } + + val mergedIdentityState: IdentityState + val mergedIdentityKey: ByteArray? + + if ((remote.proto.identityState != local.proto.identityState && remote.proto.identityKey.isNotEmpty()) || + (remote.proto.identityKey.isNotEmpty() && local.proto.identityKey.isEmpty()) || + (remote.proto.identityKey.isNotEmpty() && local.proto.unregisteredAtTimestamp > 0) + ) { + mergedIdentityState = remote.proto.identityState + mergedIdentityKey = remote.proto.identityKey.takeIf { it.isNotEmpty() }?.toByteArray() + } else { + mergedIdentityState = local.proto.identityState + mergedIdentityKey = local.proto.identityKey.takeIf { it.isNotEmpty() }?.toByteArray() + } + + if (localAci != null && mergedIdentityKey != null && remote.proto.identityKey.isNotEmpty() && !mergedIdentityKey.contentEquals(remote.proto.identityKey.toByteArray())) { + Log.w(TAG, "The local and remote identity keys do not match for " + localAci + ". Enqueueing a profile fetch.") + enqueue(trustedPush(localAci, localPni, local.proto.e164).id) + } + + val mergedPni: PNI? + val mergedE164: String? + + val e164sMatchButPnisDont = local.proto.e164.isNotBlank() && + local.proto.e164 == remote.proto.e164 && + localPni != null && + remotePni != null && + localPni != remotePni + + val pnisMatchButE164sDont = localPni != null && + localPni == remotePni && + local.proto.e164.isNotBlank() && + remote.proto.e164.isNotBlank() && + local.proto.e164 != remote.proto.e164 + + if (e164sMatchButPnisDont) { + Log.w(TAG, "Matching E164s, but the PNIs differ! Trusting our local pair.") + // TODO [pnp] Schedule CDS fetch? + mergedPni = localPni + mergedE164 = local.proto.e164 + } else if (pnisMatchButE164sDont) { + Log.w(TAG, "Matching PNIs, but the E164s differ! Trusting our local pair.") + // TODO [pnp] Schedule CDS fetch? + mergedPni = localPni + mergedE164 = local.proto.e164 + } else { + mergedPni = remotePni ?: localPni + mergedE164 = remote.proto.e164.nullIfBlank() ?: local.proto.e164.nullIfBlank() + } + + val merged = SignalContactRecord.newBuilder(remote.serializedUnknowns).apply { + e164 = mergedE164 ?: "" + aci = local.proto.aci.nullIfBlank() ?: remote.proto.aci + pni = mergedPni?.toStringWithoutPrefix() ?: "" + givenName = mergedProfileGivenName + familyName = mergedProfileFamilyName + profileKey = remote.proto.profileKey.nullIfEmpty() ?: local.proto.profileKey + username = remote.proto.username.nullIfBlank() ?: local.proto.username + identityState = mergedIdentityState + identityKey = mergedIdentityKey?.toByteString() ?: ByteString.EMPTY + blocked = remote.proto.blocked + whitelisted = remote.proto.whitelisted + archived = remote.proto.archived + markedUnread = remote.proto.markedUnread + mutedUntilTimestamp = remote.proto.mutedUntilTimestamp + hideStory = remote.proto.hideStory + unregisteredAtTimestamp = remote.proto.unregisteredAtTimestamp + hidden = remote.proto.hidden + systemGivenName = if (SignalStore.account.isPrimaryDevice) local.proto.systemGivenName else remote.proto.systemGivenName + systemFamilyName = if (SignalStore.account.isPrimaryDevice) local.proto.systemFamilyName else remote.proto.systemFamilyName + systemNickname = remote.proto.systemNickname + nickname = remote.proto.nickname + pniSignatureVerified = remote.proto.pniSignatureVerified || local.proto.pniSignatureVerified + note = remote.proto.note.nullIfBlank() ?: local.proto.note + }.build().toSignalContactRecord(StorageId.forContact(keyGenerator.generate())) + + val matchesRemote = doParamsMatch(remote, merged) + val matchesLocal = doParamsMatch(local, merged) + + return if (matchesRemote) { + remote + } else if (matchesLocal) { + local + } else { + merged + } + } + + override fun insertLocal(record: SignalContactRecord) { + recipientTable.applyStorageSyncContactInsert(record) + } + + override fun updateLocal(update: StorageRecordUpdate) { + recipientTable.applyStorageSyncContactUpdate(update) + } + + override fun compare(lhs: SignalContactRecord, rhs: SignalContactRecord): Int { + return if ( + (lhs.proto.signalAci != null && lhs.proto.aci == rhs.proto.aci) || + (lhs.proto.e164.isNotBlank() && lhs.proto.e164 == rhs.proto.e164) || + (lhs.proto.signalPni != null && lhs.proto.pni == rhs.proto.pni) + ) { + 0 + } else { + 1 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/DefaultStorageRecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/DefaultStorageRecordProcessor.java deleted file mode 100644 index 9e21eaf629..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/DefaultStorageRecordProcessor.java +++ /dev/null @@ -1,103 +0,0 @@ -package org.thoughtcrime.securesms.storage; - -import androidx.annotation.NonNull; - -import org.signal.core.util.logging.Log; -import org.whispersystems.signalservice.api.storage.SignalRecord; - -import java.io.IOException; -import java.util.Collection; -import java.util.Comparator; -import java.util.Optional; -import java.util.Set; -import java.util.TreeSet; - -/** - * An implementation of {@link StorageRecordProcessor} that solidifies a pattern and reduces - * duplicate code in individual implementations. - * - * Concerning the implementation of {@link #compare(Object, Object)}, it's purpose is to detect if - * two items would map to the same logical entity (i.e. they would correspond to the same record in - * our local store). We use it for a {@link TreeSet}, so mainly it's just important that the '0' - * case is correct. Other cases are whatever, just make it something stable. - */ -abstract class DefaultStorageRecordProcessor implements StorageRecordProcessor, Comparator { - - private static final String TAG = Log.tag(DefaultStorageRecordProcessor.class); - - /** - * One type of invalid remote data this handles is two records mapping to the same local data. We - * have to trim this bad data out, because if we don't, we'll upload an ID set that only has one - * of the IDs in it, but won't properly delete the dupes, which will then fail our validation - * checks. - * - * This is a bit tricky -- as we process records, ID's are written back to the local store, so we - * can't easily be like "oh multiple records are mapping to the same local storage ID". And in - * general we rely on SignalRecords to implement an equals() that includes the StorageId, so using - * a regular set is out. Instead, we use a {@link TreeSet}, which allows us to define a custom - * comparator for checking equality. Then we delegate to the subclass to tell us if two items are - * the same based on their actual data (i.e. two contacts having the same UUID, or two groups - * having the same MasterKey). - */ - @Override - public void process(@NonNull Collection remoteRecords, @NonNull StorageKeyGenerator keyGenerator) throws IOException { - Set matchedRecords = new TreeSet<>(this); - int i = 0; - - for (E remote : remoteRecords) { - if (isInvalid(remote)) { - warn(i, remote, "Found invalid key! Ignoring it."); - } else { - Optional local = getMatching(remote, keyGenerator); - - if (local.isPresent()) { - E merged = merge(remote, local.get(), keyGenerator); - - if (matchedRecords.contains(local.get())) { - warn(i, remote, "Multiple remote records map to the same local record! Ignoring this one."); - } else { - matchedRecords.add(local.get()); - - if (!merged.equals(remote)) { - info(i, remote, "[Remote Update] " + new StorageRecordUpdate<>(remote, merged).toString()); - } - - if (!merged.equals(local.get())) { - StorageRecordUpdate update = new StorageRecordUpdate<>(local.get(), merged); - info(i, remote, "[Local Update] " + update.toString()); - updateLocal(update); - } - } - } else { - info(i, remote, "No matching local record. Inserting."); - insertLocal(remote); - } - } - - i++; - } - } - - private void info(int i, E record, String message) { - Log.i(TAG, "[" + i + "][" + record.getClass().getSimpleName() + "] " + message); - } - - private void warn(int i, E record, String message) { - Log.w(TAG, "[" + i + "][" + record.getClass().getSimpleName() + "] " + message); - } - - /** - * @return True if the record is invalid and should be removed from storage service, otherwise false. - */ - abstract boolean isInvalid(@NonNull E remote); - - /** - * Only records that pass the validity check (i.e. return false from {@link #isInvalid(SignalRecord)} - * make it to here, so you can assume all records are valid. - */ - abstract @NonNull Optional getMatching(@NonNull E remote, @NonNull StorageKeyGenerator keyGenerator); - - abstract @NonNull E merge(@NonNull E remote, @NonNull E local, @NonNull StorageKeyGenerator keyGenerator); - abstract void insertLocal(@NonNull E record) throws IOException; - abstract void updateLocal(@NonNull StorageRecordUpdate update); -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/DefaultStorageRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/DefaultStorageRecordProcessor.kt new file mode 100644 index 0000000000..5ff6d74772 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/DefaultStorageRecordProcessor.kt @@ -0,0 +1,101 @@ +package org.thoughtcrime.securesms.storage + +import org.signal.core.util.logging.Log +import org.whispersystems.signalservice.api.storage.SignalRecord +import java.io.IOException +import java.util.Optional +import java.util.TreeSet + +/** + * An implementation of [StorageRecordProcessor] that solidifies a pattern and reduces + * duplicate code in individual implementations. + * + * Concerning the implementation of [.compare], it's purpose is to detect if + * two items would map to the same logical entity (i.e. they would correspond to the same record in + * our local store). We use it for a [TreeSet], so mainly it's just important that the '0' + * case is correct. Other cases are whatever, just make it something stable. + */ +abstract class DefaultStorageRecordProcessor> : StorageRecordProcessor, Comparator { + companion object { + private val TAG = Log.tag(DefaultStorageRecordProcessor::class.java) + } + + /** + * One type of invalid remote data this handles is two records mapping to the same local data. We + * have to trim this bad data out, because if we don't, we'll upload an ID set that only has one + * of the IDs in it, but won't properly delete the dupes, which will then fail our validation + * checks. + * + * This is a bit tricky -- as we process records, ID's are written back to the local store, so we + * can't easily be like "oh multiple records are mapping to the same local storage ID". And in + * general we rely on SignalRecords to implement an equals() that includes the StorageId, so using + * a regular set is out. Instead, we use a [TreeSet], which allows us to define a custom + * comparator for checking equality. Then we delegate to the subclass to tell us if two items are + * the same based on their actual data (i.e. two contacts having the same UUID, or two groups + * having the same MasterKey). + */ + @Throws(IOException::class) + override fun process(remoteRecords: Collection, keyGenerator: StorageKeyGenerator) { + val matchedRecords: MutableSet = TreeSet(this) + + for ((i, remote) in remoteRecords.withIndex()) { + if (isInvalid(remote)) { + warn(i, remote, "Found invalid key! Ignoring it.") + } else { + val local = getMatching(remote, keyGenerator) + + if (local.isPresent) { + val merged: E = merge(remote, local.get(), keyGenerator) + + if (matchedRecords.contains(local.get())) { + warn(i, remote, "Multiple remote records map to the same local record! Ignoring this one.") + } else { + matchedRecords.add(local.get()) + + if (merged != remote) { + info(i, remote, "[Remote Update] " + remote.describeDiff(merged)) + } + + if (merged != local.get()) { + val update = StorageRecordUpdate(local.get(), merged) + info(i, remote, "[Local Update] $update") + updateLocal(update) + } + } + } else { + info(i, remote, "No matching local record. Inserting.") + insertLocal(remote) + } + } + } + } + + fun doParamsMatch(base: E, test: E): Boolean { + return base.serializedUnknowns.contentEquals(test.serializedUnknowns) && base.proto == test.proto + } + + private fun info(i: Int, record: E, message: String) { + Log.i(TAG, "[$i][${record.javaClass.getSimpleName()}] $message") + } + + private fun warn(i: Int, record: E, message: String) { + Log.w(TAG, "[$i][${record.javaClass.getSimpleName()}] $message") + } + + /** + * @return True if the record is invalid and should be removed from storage service, otherwise false. + */ + abstract fun isInvalid(remote: E): Boolean + + /** + * Only records that pass the validity check (i.e. return false from [.isInvalid] + * make it to here, so you can assume all records are valid. + */ + abstract fun getMatching(remote: E, keyGenerator: StorageKeyGenerator): Optional + + abstract fun merge(remote: E, local: E, keyGenerator: StorageKeyGenerator): E + + @Throws(IOException::class) + abstract fun insertLocal(record: E) + abstract fun updateLocal(update: StorageRecordUpdate) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1RecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1RecordProcessor.java deleted file mode 100644 index f7c05af275..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1RecordProcessor.java +++ /dev/null @@ -1,137 +0,0 @@ -package org.thoughtcrime.securesms.storage; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.database.GroupTable; -import org.thoughtcrime.securesms.database.RecipientTable; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.database.model.GroupRecord; -import org.thoughtcrime.securesms.groups.BadGroupIdException; -import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; - -import java.util.Arrays; -import java.util.Optional; - -/** - * Handles merging remote storage updates into local group v1 state. - */ -public final class GroupV1RecordProcessor extends DefaultStorageRecordProcessor { - - private static final String TAG = Log.tag(GroupV1RecordProcessor.class); - - private final GroupTable groupDatabase; - private final RecipientTable recipientTable; - - public GroupV1RecordProcessor(@NonNull Context context) { - this(SignalDatabase.groups(), SignalDatabase.recipients()); - } - - GroupV1RecordProcessor(@NonNull GroupTable groupDatabase, @NonNull RecipientTable recipientTable) { - this.groupDatabase = groupDatabase; - this.recipientTable = recipientTable; - } - - /** - * We want to catch: - * - Invalid group ID's - * - GV1 ID's that map to GV2 ID's, meaning we've already migrated them. - * - * Note: This method could be written more succinctly, but the logs are useful :) - */ - @Override - boolean isInvalid(@NonNull SignalGroupV1Record remote) { - try { - GroupId.V1 id = GroupId.v1(remote.getGroupId()); - Optional v2Record = groupDatabase.getGroup(id.deriveV2MigrationGroupId()); - - if (v2Record.isPresent()) { - Log.w(TAG, "We already have an upgraded V2 group for this V1 group -- marking as invalid."); - return true; - } else { - return false; - } - } catch (BadGroupIdException e) { - Log.w(TAG, "Bad Group ID -- marking as invalid."); - return true; - } - } - - @Override - @NonNull Optional getMatching(@NonNull SignalGroupV1Record record, @NonNull StorageKeyGenerator keyGenerator) { - GroupId.V1 groupId = GroupId.v1orThrow(record.getGroupId()); - - Optional recipientId = recipientTable.getByGroupId(groupId); - - return recipientId.map(recipientTable::getRecordForSync) - .map(StorageSyncModels::localToRemoteRecord) - .map(r -> r.getGroupV1().get()); - } - - @Override - @NonNull SignalGroupV1Record merge(@NonNull SignalGroupV1Record remote, @NonNull SignalGroupV1Record local, @NonNull StorageKeyGenerator keyGenerator) { - byte[] unknownFields = remote.serializeUnknownFields(); - boolean blocked = remote.isBlocked(); - boolean profileSharing = remote.isProfileSharingEnabled(); - boolean archived = remote.isArchived(); - boolean forcedUnread = remote.isForcedUnread(); - long muteUntil = remote.getMuteUntil(); - - boolean matchesRemote = doParamsMatch(remote, unknownFields, blocked, profileSharing, archived, forcedUnread, muteUntil); - boolean matchesLocal = doParamsMatch(local, unknownFields, blocked, profileSharing, archived, forcedUnread, muteUntil); - - if (matchesRemote) { - return remote; - } else if (matchesLocal) { - return local; - } else { - return new SignalGroupV1Record.Builder(keyGenerator.generate(), remote.getGroupId(), unknownFields) - .setBlocked(blocked) - .setProfileSharingEnabled(profileSharing) - .setArchived(archived) - .setForcedUnread(forcedUnread) - .setMuteUntil(muteUntil) - .build(); - } - } - - @Override - void insertLocal(@NonNull SignalGroupV1Record record) { - recipientTable.applyStorageSyncGroupV1Insert(record); - } - - @Override - void updateLocal(@NonNull StorageRecordUpdate update) { - recipientTable.applyStorageSyncGroupV1Update(update); - } - - @Override - public int compare(@NonNull SignalGroupV1Record lhs, @NonNull SignalGroupV1Record rhs) { - if (Arrays.equals(lhs.getGroupId(), rhs.getGroupId())) { - return 0; - } else { - return 1; - } - } - - private boolean doParamsMatch(@NonNull SignalGroupV1Record group, - @Nullable byte[] unknownFields, - boolean blocked, - boolean profileSharing, - boolean archived, - boolean forcedUnread, - long muteUntil) - { - return Arrays.equals(unknownFields, group.serializeUnknownFields()) && - blocked == group.isBlocked() && - profileSharing == group.isProfileSharingEnabled() && - archived == group.isArchived() && - forcedUnread == group.isForcedUnread() && - muteUntil == group.getMuteUntil(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1RecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1RecordProcessor.kt new file mode 100644 index 0000000000..14432b55c0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV1RecordProcessor.kt @@ -0,0 +1,99 @@ +package org.thoughtcrime.securesms.storage + +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.GroupTable +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.RecipientRecord +import org.thoughtcrime.securesms.groups.BadGroupIdException +import org.thoughtcrime.securesms.groups.GroupId +import org.whispersystems.signalservice.api.storage.SignalGroupV1Record +import org.whispersystems.signalservice.api.storage.SignalStorageRecord +import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.api.storage.toSignalGroupV1Record +import java.util.Optional + +/** + * Record processor for [SignalGroupV1Record]. + * Handles merging and updating our local store when processing remote gv1 storage records. + */ +class GroupV1RecordProcessor(private val groupDatabase: GroupTable, private val recipientTable: RecipientTable) : DefaultStorageRecordProcessor() { + companion object { + private val TAG = Log.tag(GroupV1RecordProcessor::class.java) + } + + constructor() : this(SignalDatabase.groups, SignalDatabase.recipients) + + /** + * We want to catch: + * - Invalid group ID's + * - GV1 ID's that map to GV2 ID's, meaning we've already migrated them. + * + * Note: This method could be written more succinctly, but the logs are useful :) + */ + override fun isInvalid(remote: SignalGroupV1Record): Boolean { + try { + val id = GroupId.v1(remote.proto.id.toByteArray()) + val v2Record = groupDatabase.getGroup(id.deriveV2MigrationGroupId()) + + if (v2Record.isPresent) { + Log.w(TAG, "We already have an upgraded V2 group for this V1 group -- marking as invalid.") + return true + } else { + return false + } + } catch (e: BadGroupIdException) { + Log.w(TAG, "Bad Group ID -- marking as invalid.") + return true + } + } + + override fun getMatching(remote: SignalGroupV1Record, keyGenerator: StorageKeyGenerator): Optional { + val groupId = GroupId.v1orThrow(remote.proto.id.toByteArray()) + + val recipientId = recipientTable.getByGroupId(groupId) + + return recipientId + .map { recipientTable.getRecordForSync(it)!! } + .map { settings: RecipientRecord -> StorageSyncModels.localToRemoteRecord(settings) } + .map { record: SignalStorageRecord -> record.proto.groupV1!!.toSignalGroupV1Record(record.id) } + } + + override fun merge(remote: SignalGroupV1Record, local: SignalGroupV1Record, keyGenerator: StorageKeyGenerator): SignalGroupV1Record { + val merged = SignalGroupV1Record.newBuilder(remote.serializedUnknowns).apply { + id = remote.proto.id + blocked = remote.proto.blocked + whitelisted = remote.proto.whitelisted + archived = remote.proto.archived + markedUnread = remote.proto.markedUnread + mutedUntilTimestamp = remote.proto.mutedUntilTimestamp + }.build().toSignalGroupV1Record(StorageId.forGroupV1(keyGenerator.generate())) + + val matchesRemote = doParamsMatch(remote, merged) + val matchesLocal = doParamsMatch(local, merged) + + return if (matchesRemote) { + remote + } else if (matchesLocal) { + local + } else { + merged + } + } + + override fun insertLocal(record: SignalGroupV1Record) { + recipientTable.applyStorageSyncGroupV1Insert(record) + } + + override fun updateLocal(update: StorageRecordUpdate) { + recipientTable.applyStorageSyncGroupV1Update(update) + } + + override fun compare(lhs: SignalGroupV1Record, rhs: SignalGroupV1Record): Int { + return if (lhs.proto.id == rhs.proto.id) { + 0 + } else { + 1 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.java deleted file mode 100644 index c0909f5b88..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.java +++ /dev/null @@ -1,139 +0,0 @@ -package org.thoughtcrime.securesms.storage; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.signal.core.util.logging.Log; -import org.signal.libsignal.zkgroup.groups.GroupMasterKey; -import org.thoughtcrime.securesms.database.GroupTable; -import org.thoughtcrime.securesms.database.RecipientTable; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; -import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record; - -import java.util.Arrays; -import java.util.Map; -import java.util.Optional; - -public final class GroupV2RecordProcessor extends DefaultStorageRecordProcessor { - - private static final String TAG = Log.tag(GroupV2RecordProcessor.class); - - private final Context context; - private final RecipientTable recipientTable; - private final GroupTable groupDatabase; - private final Map gv1GroupsByExpectedGv2Id; - - public GroupV2RecordProcessor(@NonNull Context context) { - this(context, SignalDatabase.recipients(), SignalDatabase.groups()); - } - - GroupV2RecordProcessor(@NonNull Context context, @NonNull RecipientTable recipientTable, @NonNull GroupTable groupDatabase) { - this.context = context; - this.recipientTable = recipientTable; - this.groupDatabase = groupDatabase; - this.gv1GroupsByExpectedGv2Id = groupDatabase.getAllExpectedV2Ids(); - } - - @Override - boolean isInvalid(@NonNull SignalGroupV2Record remote) { - return remote.getMasterKeyBytes().length != GroupMasterKey.SIZE; - } - - @Override - @NonNull Optional getMatching(@NonNull SignalGroupV2Record record, @NonNull StorageKeyGenerator keyGenerator) { - GroupId.V2 groupId = GroupId.v2(record.getMasterKeyOrThrow()); - - Optional recipientId = recipientTable.getByGroupId(groupId); - - return recipientId.map(recipientTable::getRecordForSync) - .map(settings -> { - if (settings.getSyncExtras().getGroupMasterKey() != null) { - return StorageSyncModels.localToRemoteRecord(settings); - } else { - Log.w(TAG, "No local master key. Assuming it matches remote since the groupIds match. Enqueuing a fetch to fix the bad state."); - groupDatabase.fixMissingMasterKey(record.getMasterKeyOrThrow()); - return StorageSyncModels.localToRemoteRecord(settings, record.getMasterKeyOrThrow()); - } - }) - .map(r -> r.getGroupV2().get()); - } - - @Override - @NonNull SignalGroupV2Record merge(@NonNull SignalGroupV2Record remote, @NonNull SignalGroupV2Record local, @NonNull StorageKeyGenerator keyGenerator) { - byte[] unknownFields = remote.serializeUnknownFields(); - boolean blocked = remote.isBlocked(); - boolean profileSharing = remote.isProfileSharingEnabled(); - boolean archived = remote.isArchived(); - boolean forcedUnread = remote.isForcedUnread(); - long muteUntil = remote.getMuteUntil(); - boolean notifyForMentionsWhenMuted = remote.notifyForMentionsWhenMuted(); - boolean hideStory = remote.shouldHideStory(); - GroupV2Record.StorySendMode storySendMode = remote.getStorySendMode(); - - boolean matchesRemote = doParamsMatch(remote, unknownFields, blocked, profileSharing, archived, forcedUnread, muteUntil, notifyForMentionsWhenMuted, hideStory, storySendMode); - boolean matchesLocal = doParamsMatch(local, unknownFields, blocked, profileSharing, archived, forcedUnread, muteUntil, notifyForMentionsWhenMuted, hideStory, storySendMode); - - if (matchesRemote) { - return remote; - } else if (matchesLocal) { - return local; - } else { - return new SignalGroupV2Record.Builder(keyGenerator.generate(), remote.getMasterKeyBytes(), unknownFields) - .setBlocked(blocked) - .setProfileSharingEnabled(profileSharing) - .setArchived(archived) - .setForcedUnread(forcedUnread) - .setMuteUntil(muteUntil) - .setNotifyForMentionsWhenMuted(notifyForMentionsWhenMuted) - .setHideStory(hideStory) - .setStorySendMode(storySendMode) - .build(); - } - } - - @Override - void insertLocal(@NonNull SignalGroupV2Record record) { - recipientTable.applyStorageSyncGroupV2Insert(record); - } - - @Override - void updateLocal(@NonNull StorageRecordUpdate update) { - recipientTable.applyStorageSyncGroupV2Update(update); - } - - @Override - public int compare(@NonNull SignalGroupV2Record lhs, @NonNull SignalGroupV2Record rhs) { - if (Arrays.equals(lhs.getMasterKeyBytes(), rhs.getMasterKeyBytes())) { - return 0; - } else { - return 1; - } - } - - private boolean doParamsMatch(@NonNull SignalGroupV2Record group, - @Nullable byte[] unknownFields, - boolean blocked, - boolean profileSharing, - boolean archived, - boolean forcedUnread, - long muteUntil, - boolean notifyForMentionsWhenMuted, - boolean hideStory, - @NonNull GroupV2Record.StorySendMode storySendMode) - { - return Arrays.equals(unknownFields, group.serializeUnknownFields()) && - blocked == group.isBlocked() && - profileSharing == group.isProfileSharingEnabled() && - archived == group.isArchived() && - forcedUnread == group.isForcedUnread() && - muteUntil == group.getMuteUntil() && - notifyForMentionsWhenMuted == group.notifyForMentionsWhenMuted() && - hideStory == group.shouldHideStory() && - storySendMode == group.getStorySendMode(); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.kt new file mode 100644 index 0000000000..1aab13fe53 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/GroupV2RecordProcessor.kt @@ -0,0 +1,90 @@ +package org.thoughtcrime.securesms.storage + +import org.signal.core.util.logging.Log +import org.signal.libsignal.zkgroup.groups.GroupMasterKey +import org.thoughtcrime.securesms.database.GroupTable +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.RecipientRecord +import org.thoughtcrime.securesms.groups.GroupId +import org.whispersystems.signalservice.api.storage.SignalGroupV2Record +import org.whispersystems.signalservice.api.storage.SignalStorageRecord +import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.api.storage.toSignalGroupV2Record +import java.util.Optional + +/** + * Record processor for [SignalGroupV2Record]. + * Handles merging and updating our local store when processing remote gv2 storage records. + */ +class GroupV2RecordProcessor(private val recipientTable: RecipientTable, private val groupDatabase: GroupTable) : DefaultStorageRecordProcessor() { + companion object { + private val TAG = Log.tag(GroupV2RecordProcessor::class.java) + } + + constructor() : this(SignalDatabase.recipients, SignalDatabase.groups) + + override fun isInvalid(remote: SignalGroupV2Record): Boolean { + return remote.proto.masterKey.size != GroupMasterKey.SIZE + } + + override fun getMatching(remote: SignalGroupV2Record, keyGenerator: StorageKeyGenerator): Optional { + val groupId = GroupId.v2(GroupMasterKey(remote.proto.masterKey.toByteArray())) + + val recipientId = recipientTable.getByGroupId(groupId) + + return recipientId + .map { recipientTable.getRecordForSync(it)!! } + .map { settings: RecipientRecord -> + if (settings.syncExtras.groupMasterKey != null) { + StorageSyncModels.localToRemoteRecord(settings) + } else { + Log.w(TAG, "No local master key. Assuming it matches remote since the groupIds match. Enqueuing a fetch to fix the bad state.") + groupDatabase.fixMissingMasterKey(GroupMasterKey(remote.proto.masterKey.toByteArray())) + StorageSyncModels.localToRemoteRecord(settings, GroupMasterKey(remote.proto.masterKey.toByteArray())) + } + } + .map { record: SignalStorageRecord -> record.proto.groupV2!!.toSignalGroupV2Record(record.id) } + } + + override fun merge(remote: SignalGroupV2Record, local: SignalGroupV2Record, keyGenerator: StorageKeyGenerator): SignalGroupV2Record { + val merged = SignalGroupV2Record.newBuilder(remote.serializedUnknowns).apply { + masterKey = remote.proto.masterKey + blocked = remote.proto.blocked + whitelisted = remote.proto.whitelisted + archived = remote.proto.archived + markedUnread = remote.proto.markedUnread + mutedUntilTimestamp = remote.proto.mutedUntilTimestamp + dontNotifyForMentionsIfMuted = remote.proto.dontNotifyForMentionsIfMuted + hideStory = remote.proto.hideStory + storySendMode = remote.proto.storySendMode + }.build().toSignalGroupV2Record(StorageId.forGroupV2(keyGenerator.generate())) + + val matchesRemote = doParamsMatch(remote, merged) + val matchesLocal = doParamsMatch(local, merged) + + return if (matchesRemote) { + remote + } else if (matchesLocal) { + local + } else { + merged + } + } + + override fun insertLocal(record: SignalGroupV2Record) { + recipientTable.applyStorageSyncGroupV2Insert(record) + } + + override fun updateLocal(update: StorageRecordUpdate) { + recipientTable.applyStorageSyncGroupV2Update(update) + } + + override fun compare(lhs: SignalGroupV2Record, rhs: SignalGroupV2Record): Int { + return if (lhs.proto.masterKey == rhs.proto.masterKey) { + 0 + } else { + 1 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordProcessor.java deleted file mode 100644 index 43f860d134..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordProcessor.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.thoughtcrime.securesms.storage; - -import androidx.annotation.NonNull; - -import org.whispersystems.signalservice.api.storage.SignalRecord; - -import java.io.IOException; -import java.util.Collection; - -/** - * Handles processing a remote record, which involves applying any local changes that need to be - * made based on the remote records. - */ -public interface StorageRecordProcessor { - void process(@NonNull Collection remoteRecords, @NonNull StorageKeyGenerator keyGenerator) throws IOException; -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordProcessor.kt new file mode 100644 index 0000000000..32c2576e12 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordProcessor.kt @@ -0,0 +1,13 @@ +package org.thoughtcrime.securesms.storage + +import org.whispersystems.signalservice.api.storage.SignalRecord +import java.io.IOException + +/** + * Handles processing a remote record, which involves applying any local changes that need to be + * made based on the remote records. + */ +interface StorageRecordProcessor> { + @Throws(IOException::class) + fun process(remoteRecords: Collection, keyGenerator: StorageKeyGenerator) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordUpdate.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordUpdate.java deleted file mode 100644 index b66da90d5e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordUpdate.java +++ /dev/null @@ -1,49 +0,0 @@ -package org.thoughtcrime.securesms.storage; - -import androidx.annotation.NonNull; -import androidx.annotation.VisibleForTesting; - -import org.whispersystems.signalservice.api.storage.SignalRecord; - -import java.util.Objects; - -/** - * Represents a pair of records: one old, and one new. The new record should replace the old. - */ -public class StorageRecordUpdate { - private final E oldRecord; - private final E newRecord; - - @VisibleForTesting - public StorageRecordUpdate(@NonNull E oldRecord, @NonNull E newRecord) { - this.oldRecord = oldRecord; - this.newRecord = newRecord; - } - - public @NonNull E getOld() { - return oldRecord; - } - - public @NonNull E getNew() { - return newRecord; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - StorageRecordUpdate that = (StorageRecordUpdate) o; - return oldRecord.equals(that.oldRecord) && - newRecord.equals(that.newRecord); - } - - @Override - public int hashCode() { - return Objects.hash(oldRecord, newRecord); - } - - @Override - public @NonNull String toString() { - return newRecord.describeDiff(oldRecord); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordUpdate.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordUpdate.kt new file mode 100644 index 0000000000..7fa6b55403 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageRecordUpdate.kt @@ -0,0 +1,27 @@ +package org.thoughtcrime.securesms.storage + +import org.whispersystems.signalservice.api.storage.SignalRecord + +/** + * Represents a pair of records: one old, and one new. The new record should replace the old. + */ +class StorageRecordUpdate>(val old: E, val new: E) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as StorageRecordUpdate<*> + + if (old != other.old) return false + if (new != other.new) return false + + return true + } + + override fun hashCode(): Int { + var result = old.hashCode() + result = 31 * result + new.hashCode() + return result + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java deleted file mode 100644 index d9c1daf74a..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.java +++ /dev/null @@ -1,357 +0,0 @@ -package org.thoughtcrime.securesms.storage; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; - -import com.annimon.stream.Collectors; -import com.annimon.stream.Stream; - -import org.signal.core.util.Base64; -import org.signal.core.util.SetUtil; -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository; -import org.thoughtcrime.securesms.database.RecipientTable; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord; -import org.thoughtcrime.securesms.database.model.RecipientRecord; -import org.thoughtcrime.securesms.dependencies.AppDependencies; -import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob; -import org.thoughtcrime.securesms.jobs.StorageSyncJob; -import org.thoughtcrime.securesms.keyvalue.AccountValues; -import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.payments.Entropy; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.util.Util; -import org.whispersystems.signalservice.api.push.UsernameLinkComponents; -import org.whispersystems.signalservice.api.storage.SignalAccountRecord; -import org.whispersystems.signalservice.api.storage.SignalContactRecord; -import org.whispersystems.signalservice.api.storage.SignalStorageManifest; -import org.whispersystems.signalservice.api.storage.SignalStorageRecord; -import org.whispersystems.signalservice.api.storage.StorageId; -import org.whispersystems.signalservice.api.util.OptionalUtil; -import org.whispersystems.signalservice.api.util.UuidUtil; -import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; -import org.whispersystems.signalservice.internal.storage.protos.OptionalBool; - -import java.util.Arrays; -import java.util.Collection; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -import okio.ByteString; - -public final class StorageSyncHelper { - - private static final String TAG = Log.tag(StorageSyncHelper.class); - - public static final StorageKeyGenerator KEY_GENERATOR = () -> Util.getSecretBytes(16); - - private static StorageKeyGenerator keyGenerator = KEY_GENERATOR; - - private static final long REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(2); - - /** - * Given a list of all the local and remote keys you know about, this will return a result telling - * you which keys are exclusively remote and which are exclusively local. - * - * @param remoteIds All remote keys available. - * @param localIds All local keys available. - * @return An object describing which keys are exclusive to the remote data set and which keys are - * exclusive to the local data set. - */ - public static @NonNull IdDifferenceResult findIdDifference(@NonNull Collection remoteIds, - @NonNull Collection localIds) - { - Map remoteByRawId = Stream.of(remoteIds).collect(Collectors.toMap(id -> Base64.encodeWithPadding(id.getRaw()), id -> id)); - Map localByRawId = Stream.of(localIds).collect(Collectors.toMap(id -> Base64.encodeWithPadding(id.getRaw()), id -> id)); - - boolean hasTypeMismatch = remoteByRawId.size() != remoteIds.size() || localByRawId.size() != localIds.size(); - - Set remoteOnlyRawIds = SetUtil.difference(remoteByRawId.keySet(), localByRawId.keySet()); - Set localOnlyRawIds = SetUtil.difference(localByRawId.keySet(), remoteByRawId.keySet()); - Set sharedRawIds = SetUtil.intersection(localByRawId.keySet(), remoteByRawId.keySet()); - - for (String rawId : sharedRawIds) { - StorageId remote = Objects.requireNonNull(remoteByRawId.get(rawId)); - StorageId local = Objects.requireNonNull(localByRawId.get(rawId)); - - if (remote.getType() != local.getType()) { - remoteOnlyRawIds.remove(rawId); - localOnlyRawIds.remove(rawId); - hasTypeMismatch = true; - Log.w(TAG, "Remote type " + remote.getType() + " did not match local type " + local.getType() + "!"); - } - } - - List remoteOnlyKeys = Stream.of(remoteOnlyRawIds).map(remoteByRawId::get).toList(); - List localOnlyKeys = Stream.of(localOnlyRawIds).map(localByRawId::get).toList(); - - return new IdDifferenceResult(remoteOnlyKeys, localOnlyKeys, hasTypeMismatch); - } - - public static @NonNull byte[] generateKey() { - return keyGenerator.generate(); - } - - @VisibleForTesting - static void setTestKeyGenerator(@Nullable StorageKeyGenerator testKeyGenerator) { - keyGenerator = testKeyGenerator != null ? testKeyGenerator : KEY_GENERATOR; - } - - public static boolean profileKeyChanged(StorageRecordUpdate update) { - return !OptionalUtil.byteArrayEquals(update.getOld().getProfileKey(), update.getNew().getProfileKey()); - } - - public static SignalStorageRecord buildAccountRecord(@NonNull Context context, @NonNull Recipient self) { - RecipientTable recipientTable = SignalDatabase.recipients(); - RecipientRecord record = recipientTable.getRecordForSync(self.getId()); - List pinned = Stream.of(SignalDatabase.threads().getPinnedRecipientIds()) - .map(recipientTable::getRecordForSync) - .toList(); - - final OptionalBool storyViewReceiptsState = SignalStore.story().getViewedReceiptsEnabled() ? OptionalBool.ENABLED - : OptionalBool.DISABLED; - - if (self.getStorageId() == null || (record != null && record.getStorageId() == null)) { - Log.w(TAG, "[buildAccountRecord] No storageId for self or record! Generating. (Self: " + (self.getStorageId() != null) + ", Record: " + (record != null && record.getStorageId() != null) + ")"); - SignalDatabase.recipients().updateStorageId(self.getId(), generateKey()); - self = Recipient.self().fresh(); - record = recipientTable.getRecordForSync(self.getId()); - } - - if (record == null) { - Log.w(TAG, "[buildAccountRecord] Could not find a RecipientRecord for ourselves! ID: " + self.getId()); - } else if (!Arrays.equals(record.getStorageId(), self.getStorageId())) { - Log.w(TAG, "[buildAccountRecord] StorageId on RecipientRecord did not match self! ID: " + self.getId()); - } - - byte[] storageId = record != null && record.getStorageId() != null ? record.getStorageId() : self.getStorageId(); - - SignalAccountRecord.Builder account = new SignalAccountRecord.Builder(storageId, record != null ? record.getSyncExtras().getStorageProto() : null) - .setProfileKey(self.getProfileKey()) - .setGivenName(self.getProfileName().getGivenName()) - .setFamilyName(self.getProfileName().getFamilyName()) - .setAvatarUrlPath(self.getProfileAvatar()) - .setNoteToSelfArchived(record != null && record.getSyncExtras().isArchived()) - .setNoteToSelfForcedUnread(record != null && record.getSyncExtras().isForcedUnread()) - .setTypingIndicatorsEnabled(TextSecurePreferences.isTypingIndicatorsEnabled(context)) - .setReadReceiptsEnabled(TextSecurePreferences.isReadReceiptsEnabled(context)) - .setSealedSenderIndicatorsEnabled(TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context)) - .setLinkPreviewsEnabled(SignalStore.settings().isLinkPreviewsEnabled()) - .setUnlistedPhoneNumber(SignalStore.phoneNumberPrivacy().getPhoneNumberDiscoverabilityMode() == PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE) - .setPhoneNumberSharingMode(StorageSyncModels.localToRemotePhoneNumberSharingMode(SignalStore.phoneNumberPrivacy().getPhoneNumberSharingMode())) - .setPinnedConversations(StorageSyncModels.localToRemotePinnedConversations(pinned)) - .setPreferContactAvatars(SignalStore.settings().isPreferSystemContactPhotos()) - .setPayments(SignalStore.payments().mobileCoinPaymentsEnabled(), Optional.ofNullable(SignalStore.payments().getPaymentsEntropy()).map(Entropy::getBytes).orElse(null)) - .setPrimarySendsSms(false) - .setUniversalExpireTimer(SignalStore.settings().getUniversalExpireTimer()) - .setDefaultReactions(SignalStore.emoji().getReactions()) - .setSubscriber(StorageSyncModels.localToRemoteSubscriber(InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION))) - .setBackupsSubscriber(StorageSyncModels.localToRemoteSubscriber(InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP))) - .setDisplayBadgesOnProfile(SignalStore.inAppPayments().getDisplayBadgesOnProfile()) - .setSubscriptionManuallyCancelled(InAppPaymentsRepository.isUserManuallyCancelled(InAppPaymentSubscriberRecord.Type.DONATION)) - .setKeepMutedChatsArchived(SignalStore.settings().shouldKeepMutedChatsArchived()) - .setHasSetMyStoriesPrivacy(SignalStore.story().getUserHasBeenNotifiedAboutStories()) - .setHasViewedOnboardingStory(SignalStore.story().getUserHasViewedOnboardingStory()) - .setStoriesDisabled(SignalStore.story().isFeatureDisabled()) - .setStoryViewReceiptsState(storyViewReceiptsState) - .setHasSeenGroupStoryEducationSheet(SignalStore.story().getUserHasSeenGroupStoryEducationSheet()) - .setUsername(SignalStore.account().getUsername()) - .setHasCompletedUsernameOnboarding(SignalStore.uiHints().hasCompletedUsernameOnboarding()); - - UsernameLinkComponents linkComponents = SignalStore.account().getUsernameLink(); - if (linkComponents != null) { - account.setUsernameLink(new AccountRecord.UsernameLink.Builder() - .entropy(ByteString.of(linkComponents.getEntropy())) - .serverId(UuidUtil.toByteString(linkComponents.getServerId())) - .color(StorageSyncModels.localToRemoteUsernameColor(SignalStore.misc().getUsernameQrCodeColorScheme())) - .build()); - } else { - account.setUsernameLink(null); - } - - return SignalStorageRecord.forAccount(account.build()); - } - - public static void applyAccountStorageSyncUpdates(@NonNull Context context, @NonNull Recipient self, @NonNull SignalAccountRecord updatedRecord, boolean fetchProfile) { - SignalAccountRecord localRecord = buildAccountRecord(context, self).getAccount().get(); - applyAccountStorageSyncUpdates(context, self, new StorageRecordUpdate<>(localRecord, updatedRecord), fetchProfile); - } - - public static void applyAccountStorageSyncUpdates(@NonNull Context context, @NonNull Recipient self, @NonNull StorageRecordUpdate update, boolean fetchProfile) { - SignalDatabase.recipients().applyStorageSyncAccountUpdate(update); - - TextSecurePreferences.setReadReceiptsEnabled(context, update.getNew().isReadReceiptsEnabled()); - TextSecurePreferences.setTypingIndicatorsEnabled(context, update.getNew().isTypingIndicatorsEnabled()); - TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, update.getNew().isSealedSenderIndicatorsEnabled()); - SignalStore.settings().setLinkPreviewsEnabled(update.getNew().isLinkPreviewsEnabled()); - SignalStore.phoneNumberPrivacy().setPhoneNumberDiscoverabilityMode(update.getNew().isPhoneNumberUnlisted() ? PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE : PhoneNumberDiscoverabilityMode.DISCOVERABLE); - SignalStore.phoneNumberPrivacy().setPhoneNumberSharingMode(StorageSyncModels.remoteToLocalPhoneNumberSharingMode(update.getNew().getPhoneNumberSharingMode())); - SignalStore.settings().setPreferSystemContactPhotos(update.getNew().isPreferContactAvatars()); - SignalStore.payments().setEnabledAndEntropy(update.getNew().getPayments().isEnabled(), Entropy.fromBytes(update.getNew().getPayments().getEntropy().orElse(null))); - SignalStore.settings().setUniversalExpireTimer(update.getNew().getUniversalExpireTimer()); - SignalStore.emoji().setReactions(update.getNew().getDefaultReactions()); - SignalStore.inAppPayments().setDisplayBadgesOnProfile(update.getNew().isDisplayBadgesOnProfile()); - SignalStore.settings().setKeepMutedChatsArchived(update.getNew().isKeepMutedChatsArchived()); - SignalStore.story().setUserHasBeenNotifiedAboutStories(update.getNew().hasSetMyStoriesPrivacy()); - SignalStore.story().setUserHasViewedOnboardingStory(update.getNew().hasViewedOnboardingStory()); - SignalStore.story().setFeatureDisabled(update.getNew().isStoriesDisabled()); - SignalStore.story().setUserHasSeenGroupStoryEducationSheet(update.getNew().hasSeenGroupStoryEducationSheet()); - SignalStore.uiHints().setHasCompletedUsernameOnboarding(update.getNew().hasCompletedUsernameOnboarding()); - - if (update.getNew().getStoryViewReceiptsState() == OptionalBool.UNSET) { - SignalStore.story().setViewedReceiptsEnabled(update.getNew().isReadReceiptsEnabled()); - } else { - SignalStore.story().setViewedReceiptsEnabled(update.getNew().getStoryViewReceiptsState() == OptionalBool.ENABLED); - } - - if (update.getNew().getStoryViewReceiptsState() == OptionalBool.UNSET) { - SignalStore.story().setViewedReceiptsEnabled(update.getNew().isReadReceiptsEnabled()); - } else { - SignalStore.story().setViewedReceiptsEnabled(update.getNew().getStoryViewReceiptsState() == OptionalBool.ENABLED); - } - - InAppPaymentSubscriberRecord remoteSubscriber = StorageSyncModels.remoteToLocalSubscriber(update.getNew().getSubscriber(), InAppPaymentSubscriberRecord.Type.DONATION); - if (remoteSubscriber != null) { - InAppPaymentsRepository.setSubscriber(remoteSubscriber); - } - - if (update.getNew().isSubscriptionManuallyCancelled() && !update.getOld().isSubscriptionManuallyCancelled()) { - SignalStore.inAppPayments().updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION); - } - - if (fetchProfile && update.getNew().getAvatarUrlPath().isPresent()) { - AppDependencies.getJobManager().add(new RetrieveProfileAvatarJob(self, update.getNew().getAvatarUrlPath().get())); - } - - if (!update.getNew().getUsername().equals(update.getOld().getUsername())) { - SignalStore.account().setUsername(update.getNew().getUsername()); - SignalStore.account().setUsernameSyncState(AccountValues.UsernameSyncState.IN_SYNC); - SignalStore.account().setUsernameSyncErrorCount(0); - } - - if (update.getNew().getUsernameLink() != null) { - SignalStore.account().setUsernameLink( - new UsernameLinkComponents( - update.getNew().getUsernameLink().entropy.toByteArray(), - UuidUtil.parseOrThrow(update.getNew().getUsernameLink().serverId.toByteArray()) - ) - ); - SignalStore.misc().setUsernameQrCodeColorScheme(StorageSyncModels.remoteToLocalUsernameColor(update.getNew().getUsernameLink().color)); - } - } - - public static void scheduleSyncForDataChange() { - if (!SignalStore.registration().isRegistrationComplete()) { - Log.d(TAG, "Registration still ongoing. Ignore sync request."); - return; - } - AppDependencies.getJobManager().add(new StorageSyncJob()); - } - - public static void scheduleRoutineSync() { - long timeSinceLastSync = System.currentTimeMillis() - SignalStore.storageService().getLastSyncTime(); - - if (timeSinceLastSync > REFRESH_INTERVAL) { - Log.d(TAG, "Scheduling a sync. Last sync was " + timeSinceLastSync + " ms ago."); - scheduleSyncForDataChange(); - } else { - Log.d(TAG, "No need for sync. Last sync was " + timeSinceLastSync + " ms ago."); - } - } - - public static final class IdDifferenceResult { - private final List remoteOnlyIds; - private final List localOnlyIds; - private final boolean hasTypeMismatches; - - private IdDifferenceResult(@NonNull List remoteOnlyIds, - @NonNull List localOnlyIds, - boolean hasTypeMismatches) - { - this.remoteOnlyIds = remoteOnlyIds; - this.localOnlyIds = localOnlyIds; - this.hasTypeMismatches = hasTypeMismatches; - } - - public @NonNull List getRemoteOnlyIds() { - return remoteOnlyIds; - } - - public @NonNull List getLocalOnlyIds() { - return localOnlyIds; - } - - /** - * @return True if there exist some keys that have matching raw ID's but different types, - * otherwise false. - */ - public boolean hasTypeMismatches() { - return hasTypeMismatches; - } - - public boolean isEmpty() { - return remoteOnlyIds.isEmpty() && localOnlyIds.isEmpty(); - } - - @Override - public @NonNull String toString() { - return "remoteOnly: " + remoteOnlyIds.size() + ", localOnly: " + localOnlyIds.size() + ", hasTypeMismatches: " + hasTypeMismatches; - } - } - - public static final class WriteOperationResult { - private final SignalStorageManifest manifest; - private final List inserts; - private final List deletes; - - public WriteOperationResult(@NonNull SignalStorageManifest manifest, - @NonNull List inserts, - @NonNull List deletes) - { - this.manifest = manifest; - this.inserts = inserts; - this.deletes = deletes; - } - - public @NonNull SignalStorageManifest getManifest() { - return manifest; - } - - public @NonNull List getInserts() { - return inserts; - } - - public @NonNull List getDeletes() { - return deletes; - } - - public boolean isEmpty() { - return inserts.isEmpty() && deletes.isEmpty(); - } - - @Override - public @NonNull String toString() { - if (isEmpty()) { - return "Empty"; - } else { - return String.format(Locale.ENGLISH, - "ManifestVersion: %d, Total Keys: %d, Inserts: %d, Deletes: %d", - manifest.getVersion(), - manifest.getStorageIds().size(), - inserts.size(), - deletes.size()); - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt new file mode 100644 index 0000000000..2ad49b9bc5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncHelper.kt @@ -0,0 +1,301 @@ +package org.thoughtcrime.securesms.storage + +import android.content.Context +import androidx.annotation.VisibleForTesting +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.signal.core.util.Base64.encodeWithPadding +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.getSubscriber +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.isUserManuallyCancelled +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository.setSubscriber +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord +import org.thoughtcrime.securesms.database.model.RecipientRecord +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobs.RetrieveProfileAvatarJob +import org.thoughtcrime.securesms.jobs.StorageSyncJob +import org.thoughtcrime.securesms.keyvalue.AccountValues +import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.payments.Entropy +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.Recipient.Companion.self +import org.thoughtcrime.securesms.util.TextSecurePreferences +import org.thoughtcrime.securesms.util.Util +import org.whispersystems.signalservice.api.push.UsernameLinkComponents +import org.whispersystems.signalservice.api.storage.SignalAccountRecord +import org.whispersystems.signalservice.api.storage.SignalContactRecord +import org.whispersystems.signalservice.api.storage.SignalStorageManifest +import org.whispersystems.signalservice.api.storage.SignalStorageRecord +import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.api.storage.safeSetBackupsSubscriber +import org.whispersystems.signalservice.api.storage.safeSetPayments +import org.whispersystems.signalservice.api.storage.safeSetSubscriber +import org.whispersystems.signalservice.api.storage.toSignalAccountRecord +import org.whispersystems.signalservice.api.storage.toSignalStorageRecord +import org.whispersystems.signalservice.api.util.UuidUtil +import org.whispersystems.signalservice.api.util.toByteArray +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord +import org.whispersystems.signalservice.internal.storage.protos.OptionalBool +import java.util.Optional +import java.util.concurrent.TimeUnit + +object StorageSyncHelper { + private val TAG = Log.tag(StorageSyncHelper::class.java) + + val KEY_GENERATOR: StorageKeyGenerator = StorageKeyGenerator { Util.getSecretBytes(16) } + + private var keyGenerator = KEY_GENERATOR + + private val REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(2) + + /** + * Given a list of all the local and remote keys you know about, this will return a result telling + * you which keys are exclusively remote and which are exclusively local. + * + * @param remoteIds All remote keys available. + * @param localIds All local keys available. + * @return An object describing which keys are exclusive to the remote data set and which keys are + * exclusive to the local data set. + */ + @JvmStatic + fun findIdDifference( + remoteIds: Collection, + localIds: Collection + ): IdDifferenceResult { + val remoteByRawId: Map = remoteIds.associateBy { encodeWithPadding(it.raw) } + val localByRawId: Map = localIds.associateBy { encodeWithPadding(it.raw) } + + var hasTypeMismatch = remoteByRawId.size != remoteIds.size || localByRawId.size != localIds.size + + val remoteOnlyRawIds: MutableSet = (remoteByRawId.keys - localByRawId.keys).toMutableSet() + val localOnlyRawIds: MutableSet = (localByRawId.keys - remoteByRawId.keys).toMutableSet() + val sharedRawIds: Set = localByRawId.keys.intersect(remoteByRawId.keys) + + for (rawId in sharedRawIds) { + val remote = remoteByRawId[rawId]!! + val local = localByRawId[rawId]!! + + if (remote.type != local.type) { + remoteOnlyRawIds.remove(rawId) + localOnlyRawIds.remove(rawId) + hasTypeMismatch = true + Log.w(TAG, "Remote type ${remote.type} did not match local type ${local.type}!") + } + } + + val remoteOnlyKeys = remoteOnlyRawIds.mapNotNull { remoteByRawId[it] } + val localOnlyKeys = localOnlyRawIds.mapNotNull { localByRawId[it] } + + return IdDifferenceResult(remoteOnlyKeys, localOnlyKeys, hasTypeMismatch) + } + + @JvmStatic + fun generateKey(): ByteArray { + return keyGenerator.generate() + } + + @JvmStatic + @VisibleForTesting + fun setTestKeyGenerator(testKeyGenerator: StorageKeyGenerator?) { + keyGenerator = testKeyGenerator ?: KEY_GENERATOR + } + + @JvmStatic + fun profileKeyChanged(update: StorageRecordUpdate): Boolean { + return update.old.proto.profileKey != update.new.proto.profileKey + } + + @JvmStatic + fun buildAccountRecord(context: Context, self: Recipient): SignalStorageRecord { + var self = self + var selfRecord: RecipientRecord? = SignalDatabase.recipients.getRecordForSync(self.id) + val pinned: List = SignalDatabase.threads.getPinnedRecipientIds() + .mapNotNull { SignalDatabase.recipients.getRecordForSync(it) } + + val storyViewReceiptsState = if (SignalStore.story.viewedReceiptsEnabled) { + OptionalBool.ENABLED + } else { + OptionalBool.DISABLED + } + + if (self.storageId == null || (selfRecord != null && selfRecord.storageId == null)) { + Log.w(TAG, "[buildAccountRecord] No storageId for self or record! Generating. (Self: ${self.storageId != null}, Record: ${selfRecord?.storageId != null})") + SignalDatabase.recipients.updateStorageId(self.id, generateKey()) + self = self().fresh() + selfRecord = SignalDatabase.recipients.getRecordForSync(self.id) + } + + if (selfRecord == null) { + Log.w(TAG, "[buildAccountRecord] Could not find a RecipientRecord for ourselves! ID: ${self.id}") + } else if (!selfRecord.storageId.contentEquals(self.storageId)) { + Log.w(TAG, "[buildAccountRecord] StorageId on RecipientRecord did not match self! ID: ${self.id}") + } + + val storageId = selfRecord?.storageId ?: self.storageId + + val accountRecord = SignalAccountRecord.newBuilder(selfRecord?.syncExtras?.storageProto).apply { + profileKey = self.profileKey?.toByteString() ?: ByteString.EMPTY + givenName = self.profileName.givenName + familyName = self.profileName.familyName + avatarUrlPath = self.profileAvatar ?: "" + noteToSelfArchived = selfRecord != null && selfRecord.syncExtras.isArchived + noteToSelfMarkedUnread = selfRecord != null && selfRecord.syncExtras.isForcedUnread + typingIndicators = TextSecurePreferences.isTypingIndicatorsEnabled(context) + readReceipts = TextSecurePreferences.isReadReceiptsEnabled(context) + sealedSenderIndicators = TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(context) + linkPreviews = SignalStore.settings.isLinkPreviewsEnabled + unlistedPhoneNumber = SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode == PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE + phoneNumberSharingMode = StorageSyncModels.localToRemotePhoneNumberSharingMode(SignalStore.phoneNumberPrivacy.phoneNumberSharingMode) + pinnedConversations = StorageSyncModels.localToRemotePinnedConversations(pinned) + preferContactAvatars = SignalStore.settings.isPreferSystemContactPhotos + primarySendsSms = false + universalExpireTimer = SignalStore.settings.universalExpireTimer + preferredReactionEmoji = SignalStore.emoji.reactions + displayBadgesOnProfile = SignalStore.inAppPayments.getDisplayBadgesOnProfile() + subscriptionManuallyCancelled = isUserManuallyCancelled(InAppPaymentSubscriberRecord.Type.DONATION) + keepMutedChatsArchived = SignalStore.settings.shouldKeepMutedChatsArchived() + hasSetMyStoriesPrivacy = SignalStore.story.userHasBeenNotifiedAboutStories + hasViewedOnboardingStory = SignalStore.story.userHasViewedOnboardingStory + storiesDisabled = SignalStore.story.isFeatureDisabled + storyViewReceiptsEnabled = storyViewReceiptsState + hasSeenGroupStoryEducationSheet = SignalStore.story.userHasSeenGroupStoryEducationSheet + hasCompletedUsernameOnboarding = SignalStore.uiHints.hasCompletedUsernameOnboarding() + username = SignalStore.account.username ?: "" + usernameLink = SignalStore.account.usernameLink?.let { linkComponents -> + AccountRecord.UsernameLink( + entropy = linkComponents.entropy.toByteString(), + serverId = linkComponents.serverId.toByteArray().toByteString(), + color = StorageSyncModels.localToRemoteUsernameColor(SignalStore.misc.usernameQrCodeColorScheme) + ) + } + + getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)?.let { + safeSetSubscriber(it.subscriberId.bytes.toByteString(), it.currency.currencyCode) + } + + getSubscriber(InAppPaymentSubscriberRecord.Type.BACKUP)?.let { + safeSetBackupsSubscriber(it.subscriberId.bytes.toByteString(), it.currency.currencyCode) + } + + safeSetPayments(SignalStore.payments.mobileCoinPaymentsEnabled(), Optional.ofNullable(SignalStore.payments.paymentsEntropy).map { obj: Entropy -> obj.bytes }.orElse(null)) + } + + return accountRecord.toSignalAccountRecord(StorageId.forAccount(storageId)).toSignalStorageRecord() + } + + @JvmStatic + fun applyAccountStorageSyncUpdates(context: Context, self: Recipient, updatedRecord: SignalAccountRecord, fetchProfile: Boolean) { + val localRecord = buildAccountRecord(context, self).let { it.proto.account!!.toSignalAccountRecord(it.id) } + applyAccountStorageSyncUpdates(context, self, StorageRecordUpdate(localRecord, updatedRecord), fetchProfile) + } + + @JvmStatic + fun applyAccountStorageSyncUpdates(context: Context, self: Recipient, update: StorageRecordUpdate, fetchProfile: Boolean) { + SignalDatabase.recipients.applyStorageSyncAccountUpdate(update) + + TextSecurePreferences.setReadReceiptsEnabled(context, update.new.proto.readReceipts) + TextSecurePreferences.setTypingIndicatorsEnabled(context, update.new.proto.typingIndicators) + TextSecurePreferences.setShowUnidentifiedDeliveryIndicatorsEnabled(context, update.new.proto.sealedSenderIndicators) + SignalStore.settings.isLinkPreviewsEnabled = update.new.proto.linkPreviews + SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode = if (update.new.proto.unlistedPhoneNumber) PhoneNumberDiscoverabilityMode.NOT_DISCOVERABLE else PhoneNumberDiscoverabilityMode.DISCOVERABLE + SignalStore.phoneNumberPrivacy.phoneNumberSharingMode = StorageSyncModels.remoteToLocalPhoneNumberSharingMode(update.new.proto.phoneNumberSharingMode) + SignalStore.settings.isPreferSystemContactPhotos = update.new.proto.preferContactAvatars + SignalStore.payments.setEnabledAndEntropy(update.new.proto.payments?.enabled == true, Entropy.fromBytes(update.new.proto.payments?.entropy?.toByteArray())) + SignalStore.settings.universalExpireTimer = update.new.proto.universalExpireTimer + SignalStore.emoji.reactions = update.new.proto.preferredReactionEmoji + SignalStore.inAppPayments.setDisplayBadgesOnProfile(update.new.proto.displayBadgesOnProfile) + SignalStore.settings.setKeepMutedChatsArchived(update.new.proto.keepMutedChatsArchived) + SignalStore.story.userHasBeenNotifiedAboutStories = update.new.proto.hasSetMyStoriesPrivacy + SignalStore.story.userHasViewedOnboardingStory = update.new.proto.hasViewedOnboardingStory + SignalStore.story.isFeatureDisabled = update.new.proto.storiesDisabled + SignalStore.story.userHasSeenGroupStoryEducationSheet = update.new.proto.hasSeenGroupStoryEducationSheet + SignalStore.uiHints.setHasCompletedUsernameOnboarding(update.new.proto.hasCompletedUsernameOnboarding) + + if (update.new.proto.storyViewReceiptsEnabled == OptionalBool.UNSET) { + SignalStore.story.viewedReceiptsEnabled = update.new.proto.readReceipts + } else { + SignalStore.story.viewedReceiptsEnabled = update.new.proto.storyViewReceiptsEnabled == OptionalBool.ENABLED + } + + val remoteSubscriber = StorageSyncModels.remoteToLocalSubscriber(update.new.proto.subscriberId, update.new.proto.subscriberCurrencyCode, InAppPaymentSubscriberRecord.Type.DONATION) + if (remoteSubscriber != null) { + setSubscriber(remoteSubscriber) + } + + if (update.new.proto.subscriptionManuallyCancelled && !update.old.proto.subscriptionManuallyCancelled) { + SignalStore.inAppPayments.updateLocalStateForManualCancellation(InAppPaymentSubscriberRecord.Type.DONATION) + } + + if (fetchProfile && update.new.proto.avatarUrlPath.isNotBlank()) { + AppDependencies.jobManager.add(RetrieveProfileAvatarJob(self, update.new.proto.avatarUrlPath)) + } + + if (update.new.proto.username != update.old.proto.username) { + SignalStore.account.username = update.new.proto.username + SignalStore.account.usernameSyncState = AccountValues.UsernameSyncState.IN_SYNC + SignalStore.account.usernameSyncErrorCount = 0 + } + + if (update.new.proto.usernameLink != null) { + SignalStore.account.usernameLink = UsernameLinkComponents( + update.new.proto.usernameLink!!.entropy.toByteArray(), + UuidUtil.parseOrThrow(update.new.proto.usernameLink!!.serverId.toByteArray()) + ) + + SignalStore.misc.usernameQrCodeColorScheme = StorageSyncModels.remoteToLocalUsernameColor(update.new.proto.usernameLink!!.color) + } + } + + @JvmStatic + fun scheduleSyncForDataChange() { + if (!SignalStore.registration.isRegistrationComplete) { + Log.d(TAG, "Registration still ongoing. Ignore sync request.") + return + } + AppDependencies.jobManager.add(StorageSyncJob()) + } + + @JvmStatic + fun scheduleRoutineSync() { + val timeSinceLastSync = System.currentTimeMillis() - SignalStore.storageService.lastSyncTime + + if (timeSinceLastSync > REFRESH_INTERVAL) { + Log.d(TAG, "Scheduling a sync. Last sync was $timeSinceLastSync ms ago.") + scheduleSyncForDataChange() + } else { + Log.d(TAG, "No need for sync. Last sync was $timeSinceLastSync ms ago.") + } + } + + class IdDifferenceResult( + @JvmField val remoteOnlyIds: List, + @JvmField val localOnlyIds: List, + val hasTypeMismatches: Boolean + ) { + val isEmpty: Boolean + get() = remoteOnlyIds.isEmpty() && localOnlyIds.isEmpty() + + override fun toString(): String { + return "remoteOnly: ${remoteOnlyIds.size}, localOnly: ${localOnlyIds.size}, hasTypeMismatches: $hasTypeMismatches" + } + } + + class WriteOperationResult( + @JvmField val manifest: SignalStorageManifest, + @JvmField val inserts: List, + @JvmField val deletes: List + ) { + val isEmpty: Boolean + get() = inserts.isEmpty() && deletes.isEmpty() + + override fun toString(): String { + return if (isEmpty) { + "Empty" + } else { + "ManifestVersion: ${manifest.version}, Total Keys: ${manifest.storageIds.size}, Inserts: ${inserts.size}, Deletes: ${deletes.size}" + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java deleted file mode 100644 index 0765a01ac1..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.java +++ /dev/null @@ -1,349 +0,0 @@ -package org.thoughtcrime.securesms.storage; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.annimon.stream.Stream; - -import org.signal.libsignal.zkgroup.groups.GroupMasterKey; -import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme; -import org.thoughtcrime.securesms.database.CallLinkTable; -import org.thoughtcrime.securesms.database.GroupTable; -import org.thoughtcrime.securesms.database.IdentityTable; -import org.thoughtcrime.securesms.database.RecipientTable; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.database.model.DistributionListId; -import org.thoughtcrime.securesms.database.model.DistributionListRecord; -import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord; -import org.thoughtcrime.securesms.database.model.RecipientRecord; -import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData; -import org.thoughtcrime.securesms.groups.GroupId; -import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues; -import org.thoughtcrime.securesms.keyvalue.SignalStore; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId; -import org.whispersystems.signalservice.api.push.SignalServiceAddress; -import org.whispersystems.signalservice.api.storage.SignalAccountRecord; -import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord; -import org.whispersystems.signalservice.api.storage.SignalContactRecord; -import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; -import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; -import org.whispersystems.signalservice.api.storage.SignalStorageRecord; -import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord; -import org.whispersystems.signalservice.api.subscriptions.SubscriberId; -import org.whispersystems.signalservice.api.util.UuidUtil; -import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; -import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; -import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record; - -import java.util.Currency; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -public final class StorageSyncModels { - - private StorageSyncModels() {} - - public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientRecord settings) { - if (settings.getStorageId() == null) { - throw new AssertionError("Must have a storage key!"); - } - - return localToRemoteRecord(settings, settings.getStorageId()); - } - - public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientRecord settings, @NonNull GroupMasterKey groupMasterKey) { - if (settings.getStorageId() == null) { - throw new AssertionError("Must have a storage key!"); - } - - return SignalStorageRecord.forGroupV2(localToRemoteGroupV2(settings, settings.getStorageId(), groupMasterKey)); - } - - public static @NonNull SignalStorageRecord localToRemoteRecord(@NonNull RecipientRecord settings, @NonNull byte[] rawStorageId) { - switch (settings.getRecipientType()) { - case INDIVIDUAL: return SignalStorageRecord.forContact(localToRemoteContact(settings, rawStorageId)); - case GV1: return SignalStorageRecord.forGroupV1(localToRemoteGroupV1(settings, rawStorageId)); - case GV2: return SignalStorageRecord.forGroupV2(localToRemoteGroupV2(settings, rawStorageId, settings.getSyncExtras().getGroupMasterKey())); - case DISTRIBUTION_LIST: return SignalStorageRecord.forStoryDistributionList(localToRemoteStoryDistributionList(settings, rawStorageId)); - case CALL_LINK: return SignalStorageRecord.forCallLink(localToRemoteCallLink(settings, rawStorageId)); - default: throw new AssertionError("Unsupported type!"); - } - } - - public static AccountRecord.PhoneNumberSharingMode localToRemotePhoneNumberSharingMode(PhoneNumberPrivacyValues.PhoneNumberSharingMode phoneNumberPhoneNumberSharingMode) { - switch (phoneNumberPhoneNumberSharingMode) { - case DEFAULT : return AccountRecord.PhoneNumberSharingMode.NOBODY; - case EVERYBODY: return AccountRecord.PhoneNumberSharingMode.EVERYBODY; - case NOBODY : return AccountRecord.PhoneNumberSharingMode.NOBODY; - default : throw new AssertionError(); - } - } - - public static PhoneNumberPrivacyValues.PhoneNumberSharingMode remoteToLocalPhoneNumberSharingMode(AccountRecord.PhoneNumberSharingMode phoneNumberPhoneNumberSharingMode) { - switch (phoneNumberPhoneNumberSharingMode) { - case EVERYBODY : return PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYBODY; - case NOBODY : return PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY; - default : return PhoneNumberPrivacyValues.PhoneNumberSharingMode.DEFAULT; - } - } - - public static List localToRemotePinnedConversations(@NonNull List settings) { - return Stream.of(settings) - .filter(s -> s.getRecipientType() == RecipientTable.RecipientType.GV1 || - s.getRecipientType() == RecipientTable.RecipientType.GV2 || - s.getRegistered() == RecipientTable.RegisteredState.REGISTERED || - s.getRegistered() == RecipientTable.RegisteredState.NOT_REGISTERED - ) - .map(StorageSyncModels::localToRemotePinnedConversation) - .toList(); - } - - private static @NonNull SignalAccountRecord.PinnedConversation localToRemotePinnedConversation(@NonNull RecipientRecord settings) { - switch (settings.getRecipientType()) { - case INDIVIDUAL: return SignalAccountRecord.PinnedConversation.forContact(new SignalServiceAddress(settings.getServiceId(), settings.getE164())); - case GV1: return SignalAccountRecord.PinnedConversation.forGroupV1(settings.getGroupId().requireV1().getDecodedId()); - case GV2: return SignalAccountRecord.PinnedConversation.forGroupV2(settings.getSyncExtras().getGroupMasterKey().serialize()); - default : throw new AssertionError("Unexpected group type!"); - } - } - - public static @NonNull AccountRecord.UsernameLink.Color localToRemoteUsernameColor(UsernameQrCodeColorScheme local) { - switch (local) { - case Blue: return AccountRecord.UsernameLink.Color.BLUE; - case White: return AccountRecord.UsernameLink.Color.WHITE; - case Grey: return AccountRecord.UsernameLink.Color.GREY; - case Tan: return AccountRecord.UsernameLink.Color.OLIVE; - case Green: return AccountRecord.UsernameLink.Color.GREEN; - case Orange: return AccountRecord.UsernameLink.Color.ORANGE; - case Pink: return AccountRecord.UsernameLink.Color.PINK; - case Purple: return AccountRecord.UsernameLink.Color.PURPLE; - default: return AccountRecord.UsernameLink.Color.BLUE; - } - } - - public static @NonNull UsernameQrCodeColorScheme remoteToLocalUsernameColor(AccountRecord.UsernameLink.Color remote) { - switch (remote) { - case BLUE: return UsernameQrCodeColorScheme.Blue; - case WHITE: return UsernameQrCodeColorScheme.White; - case GREY: return UsernameQrCodeColorScheme.Grey; - case OLIVE: return UsernameQrCodeColorScheme.Tan; - case GREEN: return UsernameQrCodeColorScheme.Green; - case ORANGE: return UsernameQrCodeColorScheme.Orange; - case PINK: return UsernameQrCodeColorScheme.Pink; - case PURPLE: return UsernameQrCodeColorScheme.Purple; - default: return UsernameQrCodeColorScheme.Blue; - } - } - - private static @NonNull SignalContactRecord localToRemoteContact(@NonNull RecipientRecord recipient, byte[] rawStorageId) { - if (recipient.getAci() == null && recipient.getPni() == null && recipient.getE164() == null) { - throw new AssertionError("Must have either a UUID or a phone number!"); - } - - boolean hideStory = recipient.getExtras() != null && recipient.getExtras().hideStory(); - - return new SignalContactRecord.Builder(rawStorageId, recipient.getAci(), recipient.getSyncExtras().getStorageProto()) - .setE164(recipient.getE164()) - .setPni(recipient.getPni()) - .setProfileKey(recipient.getProfileKey()) - .setProfileGivenName(recipient.getProfileName().getGivenName()) - .setProfileFamilyName(recipient.getProfileName().getFamilyName()) - .setSystemGivenName(recipient.getSystemProfileName().getGivenName()) - .setSystemFamilyName(recipient.getSystemProfileName().getFamilyName()) - .setSystemNickname(recipient.getSyncExtras().getSystemNickname()) - .setBlocked(recipient.isBlocked()) - .setProfileSharingEnabled(recipient.isProfileSharing() || recipient.getSystemContactUri() != null) - .setIdentityKey(recipient.getSyncExtras().getIdentityKey()) - .setIdentityState(localToRemoteIdentityState(recipient.getSyncExtras().getIdentityStatus())) - .setArchived(recipient.getSyncExtras().isArchived()) - .setForcedUnread(recipient.getSyncExtras().isForcedUnread()) - .setMuteUntil(recipient.getMuteUntil()) - .setHideStory(hideStory) - .setUnregisteredTimestamp(recipient.getSyncExtras().getUnregisteredTimestamp()) - .setHidden(recipient.getHiddenState() != Recipient.HiddenState.NOT_HIDDEN) - .setUsername(recipient.getUsername()) - .setPniSignatureVerified(recipient.getSyncExtras().getPniSignatureVerified()) - .setNicknameGivenName(recipient.getNickname().getGivenName()) - .setNicknameFamilyName(recipient.getNickname().getFamilyName()) - .setNote(recipient.getNote()) - .build(); - } - - private static @NonNull SignalGroupV1Record localToRemoteGroupV1(@NonNull RecipientRecord recipient, byte[] rawStorageId) { - GroupId groupId = recipient.getGroupId(); - - if (groupId == null) { - throw new AssertionError("Must have a groupId!"); - } - - if (!groupId.isV1()) { - throw new AssertionError("Group is not V1"); - } - - return new SignalGroupV1Record.Builder(rawStorageId, groupId.getDecodedId(), recipient.getSyncExtras().getStorageProto()) - .setBlocked(recipient.isBlocked()) - .setProfileSharingEnabled(recipient.isProfileSharing()) - .setArchived(recipient.getSyncExtras().isArchived()) - .setForcedUnread(recipient.getSyncExtras().isForcedUnread()) - .setMuteUntil(recipient.getMuteUntil()) - .build(); - } - - private static @NonNull SignalGroupV2Record localToRemoteGroupV2(@NonNull RecipientRecord recipient, byte[] rawStorageId, @NonNull GroupMasterKey groupMasterKey) { - GroupId groupId = recipient.getGroupId(); - - if (groupId == null) { - throw new AssertionError("Must have a groupId!"); - } - - if (!groupId.isV2()) { - throw new AssertionError("Group is not V2"); - } - - if (groupMasterKey == null) { - throw new AssertionError("Group master key not on recipient record"); - } - - boolean hideStory = recipient.getExtras() != null && recipient.getExtras().hideStory(); - GroupTable.ShowAsStoryState showAsStoryState = SignalDatabase.groups().getShowAsStoryState(groupId); - GroupV2Record.StorySendMode storySendMode; - - switch (showAsStoryState) { - case ALWAYS: - storySendMode = GroupV2Record.StorySendMode.ENABLED; - break; - case NEVER: - storySendMode = GroupV2Record.StorySendMode.DISABLED; - break; - default: - storySendMode = GroupV2Record.StorySendMode.DEFAULT; - } - - return new SignalGroupV2Record.Builder(rawStorageId, groupMasterKey, recipient.getSyncExtras().getStorageProto()) - .setBlocked(recipient.isBlocked()) - .setProfileSharingEnabled(recipient.isProfileSharing()) - .setArchived(recipient.getSyncExtras().isArchived()) - .setForcedUnread(recipient.getSyncExtras().isForcedUnread()) - .setMuteUntil(recipient.getMuteUntil()) - .setNotifyForMentionsWhenMuted(recipient.getMentionSetting() == RecipientTable.MentionSetting.ALWAYS_NOTIFY) - .setHideStory(hideStory) - .setStorySendMode(storySendMode) - .build(); - } - - private static @NonNull SignalCallLinkRecord localToRemoteCallLink(@NonNull RecipientRecord recipient, @NonNull byte[] rawStorageId) { - CallLinkRoomId callLinkRoomId = recipient.getCallLinkRoomId(); - - if (callLinkRoomId == null) { - throw new AssertionError("Must have a callLinkRoomId!"); - } - - CallLinkTable.CallLink callLink = SignalDatabase.callLinks().getCallLinkByRoomId(callLinkRoomId); - if (callLink == null) { - throw new AssertionError("Must have a call link record!"); - } - - if (callLink.getCredentials() == null) { - throw new AssertionError("Must have call link credentials!"); - } - - long deletedTimestamp = Math.max(0, SignalDatabase.callLinks().getDeletedTimestampByRoomId(callLinkRoomId)); - byte[] adminPassword = deletedTimestamp > 0 ? new byte[]{} : Objects.requireNonNull(callLink.getCredentials().getAdminPassBytes(), "Non-deleted call link requires admin pass!"); - - return new SignalCallLinkRecord.Builder(rawStorageId, null) - .setRootKey(callLink.getCredentials().getLinkKeyBytes()) - .setAdminPassKey(adminPassword) - .setDeletedTimestamp(deletedTimestamp) - .build(); - } - - private static @NonNull SignalStoryDistributionListRecord localToRemoteStoryDistributionList(@NonNull RecipientRecord recipient, @NonNull byte[] rawStorageId) { - DistributionListId distributionListId = recipient.getDistributionListId(); - - if (distributionListId == null) { - throw new AssertionError("Must have a distributionListId!"); - } - - DistributionListRecord record = SignalDatabase.distributionLists().getListForStorageSync(distributionListId); - if (record == null) { - throw new AssertionError("Must have a distribution list record!"); - } - - if (record.getDeletedAtTimestamp() > 0L) { - return new SignalStoryDistributionListRecord.Builder(rawStorageId, recipient.getSyncExtras().getStorageProto()) - .setIdentifier(UuidUtil.toByteArray(record.getDistributionId().asUuid())) - .setDeletedAtTimestamp(record.getDeletedAtTimestamp()) - .build(); - } - - return new SignalStoryDistributionListRecord.Builder(rawStorageId, recipient.getSyncExtras().getStorageProto()) - .setIdentifier(UuidUtil.toByteArray(record.getDistributionId().asUuid())) - .setName(record.getName()) - .setRecipients(record.getMembersToSync() - .stream() - .map(Recipient::resolved) - .filter(Recipient::getHasServiceId) - .map(Recipient::requireServiceId) - .map(SignalServiceAddress::new) - .collect(Collectors.toList())) - .setAllowsReplies(record.getAllowsReplies()) - .setIsBlockList(record.getPrivacyMode().isBlockList()) - .build(); - } - - public static @NonNull IdentityTable.VerifiedStatus remoteToLocalIdentityStatus(@NonNull IdentityState identityState) { - switch (identityState) { - case VERIFIED: return IdentityTable.VerifiedStatus.VERIFIED; - case UNVERIFIED: return IdentityTable.VerifiedStatus.UNVERIFIED; - default: return IdentityTable.VerifiedStatus.DEFAULT; - } - } - - private static IdentityState localToRemoteIdentityState(@NonNull IdentityTable.VerifiedStatus local) { - switch (local) { - case VERIFIED: return IdentityState.VERIFIED; - case UNVERIFIED: return IdentityState.UNVERIFIED; - default: return IdentityState.DEFAULT; - } - } - - /** - * TODO - need to store the subscriber type - */ - public static @NonNull SignalAccountRecord.Subscriber localToRemoteSubscriber(@Nullable InAppPaymentSubscriberRecord subscriber) { - if (subscriber == null) { - return new SignalAccountRecord.Subscriber(null, null); - } else { - return new SignalAccountRecord.Subscriber(subscriber.getCurrency().getCurrencyCode(), subscriber.getSubscriberId().getBytes()); - } - } - - public static @Nullable InAppPaymentSubscriberRecord remoteToLocalSubscriber( - @NonNull SignalAccountRecord.Subscriber subscriber, - @NonNull InAppPaymentSubscriberRecord.Type type - ) { - if (subscriber.getId().isPresent()) { - SubscriberId subscriberId = SubscriberId.fromBytes(subscriber.getId().get()); - InAppPaymentSubscriberRecord localSubscriberRecord = SignalDatabase.inAppPaymentSubscribers().getBySubscriberId(subscriberId); - boolean requiresCancel = localSubscriberRecord != null && localSubscriberRecord.getRequiresCancel(); - InAppPaymentData.PaymentMethodType paymentMethodType = localSubscriberRecord != null ? localSubscriberRecord.getPaymentMethodType() : InAppPaymentData.PaymentMethodType.UNKNOWN; - - Currency currency; - if (subscriber.getCurrencyCode().isEmpty()) { - return null; - } else { - try { - currency = Currency.getInstance(subscriber.getCurrencyCode().get()); - } catch (IllegalArgumentException e) { - return null; - } - } - - return new InAppPaymentSubscriberRecord(subscriberId, currency, type, requiresCancel, paymentMethodType); - } else { - return null; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt new file mode 100644 index 0000000000..c0df1e911f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncModels.kt @@ -0,0 +1,313 @@ +package org.thoughtcrime.securesms.storage + +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.signal.core.util.isNotEmpty +import org.signal.libsignal.zkgroup.groups.GroupMasterKey +import org.thoughtcrime.securesms.components.settings.app.usernamelinks.UsernameQrCodeColorScheme +import org.thoughtcrime.securesms.database.GroupTable.ShowAsStoryState +import org.thoughtcrime.securesms.database.IdentityTable.VerifiedStatus +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.RecipientTable.RecipientType +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.callLinks +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.distributionLists +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groups +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.inAppPaymentSubscribers +import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord +import org.thoughtcrime.securesms.database.model.RecipientRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.InAppPaymentData +import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues +import org.thoughtcrime.securesms.recipients.Recipient +import org.whispersystems.signalservice.api.storage.SignalCallLinkRecord +import org.whispersystems.signalservice.api.storage.SignalContactRecord +import org.whispersystems.signalservice.api.storage.SignalGroupV1Record +import org.whispersystems.signalservice.api.storage.SignalGroupV2Record +import org.whispersystems.signalservice.api.storage.SignalStorageRecord +import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord +import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.api.storage.toSignalCallLinkRecord +import org.whispersystems.signalservice.api.storage.toSignalContactRecord +import org.whispersystems.signalservice.api.storage.toSignalGroupV1Record +import org.whispersystems.signalservice.api.storage.toSignalGroupV2Record +import org.whispersystems.signalservice.api.storage.toSignalStorageRecord +import org.whispersystems.signalservice.api.storage.toSignalStoryDistributionListRecord +import org.whispersystems.signalservice.api.subscriptions.SubscriberId +import org.whispersystems.signalservice.api.util.UuidUtil +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState +import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record +import java.util.Currency +import kotlin.math.max + +object StorageSyncModels { + + @JvmStatic + fun localToRemoteRecord(settings: RecipientRecord): SignalStorageRecord { + if (settings.storageId == null) { + throw AssertionError("Must have a storage key!") + } + + return localToRemoteRecord(settings, settings.storageId) + } + + @JvmStatic + fun localToRemoteRecord(settings: RecipientRecord, groupMasterKey: GroupMasterKey): SignalStorageRecord { + if (settings.storageId == null) { + throw AssertionError("Must have a storage key!") + } + + return localToRemoteGroupV2(settings, settings.storageId, groupMasterKey).toSignalStorageRecord() + } + + @JvmStatic + fun localToRemoteRecord(settings: RecipientRecord, rawStorageId: ByteArray): SignalStorageRecord { + return when (settings.recipientType) { + RecipientType.INDIVIDUAL -> localToRemoteContact(settings, rawStorageId).toSignalStorageRecord() + RecipientType.GV1 -> localToRemoteGroupV1(settings, rawStorageId).toSignalStorageRecord() + RecipientType.GV2 -> localToRemoteGroupV2(settings, rawStorageId, settings.syncExtras.groupMasterKey!!).toSignalStorageRecord() + RecipientType.DISTRIBUTION_LIST -> localToRemoteStoryDistributionList(settings, rawStorageId).toSignalStorageRecord() + RecipientType.CALL_LINK -> localToRemoteCallLink(settings, rawStorageId).toSignalStorageRecord() + else -> throw AssertionError("Unsupported type!") + } + } + + @JvmStatic + fun localToRemotePhoneNumberSharingMode(phoneNumberPhoneNumberSharingMode: PhoneNumberPrivacyValues.PhoneNumberSharingMode): AccountRecord.PhoneNumberSharingMode { + return when (phoneNumberPhoneNumberSharingMode) { + PhoneNumberPrivacyValues.PhoneNumberSharingMode.DEFAULT -> AccountRecord.PhoneNumberSharingMode.NOBODY + PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYBODY -> AccountRecord.PhoneNumberSharingMode.EVERYBODY + PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY -> AccountRecord.PhoneNumberSharingMode.NOBODY + } + } + + @JvmStatic + fun remoteToLocalPhoneNumberSharingMode(phoneNumberPhoneNumberSharingMode: AccountRecord.PhoneNumberSharingMode): PhoneNumberPrivacyValues.PhoneNumberSharingMode { + return when (phoneNumberPhoneNumberSharingMode) { + AccountRecord.PhoneNumberSharingMode.EVERYBODY -> PhoneNumberPrivacyValues.PhoneNumberSharingMode.EVERYBODY + AccountRecord.PhoneNumberSharingMode.NOBODY -> PhoneNumberPrivacyValues.PhoneNumberSharingMode.NOBODY + else -> PhoneNumberPrivacyValues.PhoneNumberSharingMode.DEFAULT + } + } + + @JvmStatic + fun localToRemotePinnedConversations(records: List): List { + return records + .filter { it.recipientType == RecipientType.GV1 || it.recipientType == RecipientType.GV2 || it.registered == RecipientTable.RegisteredState.REGISTERED } + .map { localToRemotePinnedConversation(it) } + } + + @JvmStatic + private fun localToRemotePinnedConversation(settings: RecipientRecord): AccountRecord.PinnedConversation { + return when (settings.recipientType) { + RecipientType.INDIVIDUAL -> { + AccountRecord.PinnedConversation( + contact = AccountRecord.PinnedConversation.Contact( + serviceId = settings.serviceId?.toString() ?: "", + e164 = settings.e164 ?: "" + ) + ) + } + RecipientType.GV1 -> { + AccountRecord.PinnedConversation( + legacyGroupId = settings.groupId!!.requireV1().decodedId.toByteString() + ) + } + RecipientType.GV2 -> { + AccountRecord.PinnedConversation( + groupMasterKey = settings.syncExtras.groupMasterKey!!.serialize().toByteString() + ) + } + else -> throw AssertionError("Unexpected group type!") + } + } + + @JvmStatic + fun localToRemoteUsernameColor(local: UsernameQrCodeColorScheme): AccountRecord.UsernameLink.Color { + return when (local) { + UsernameQrCodeColorScheme.Blue -> AccountRecord.UsernameLink.Color.BLUE + UsernameQrCodeColorScheme.White -> AccountRecord.UsernameLink.Color.WHITE + UsernameQrCodeColorScheme.Grey -> AccountRecord.UsernameLink.Color.GREY + UsernameQrCodeColorScheme.Tan -> AccountRecord.UsernameLink.Color.OLIVE + UsernameQrCodeColorScheme.Green -> AccountRecord.UsernameLink.Color.GREEN + UsernameQrCodeColorScheme.Orange -> AccountRecord.UsernameLink.Color.ORANGE + UsernameQrCodeColorScheme.Pink -> AccountRecord.UsernameLink.Color.PINK + UsernameQrCodeColorScheme.Purple -> AccountRecord.UsernameLink.Color.PURPLE + } + } + + @JvmStatic + fun remoteToLocalUsernameColor(remote: AccountRecord.UsernameLink.Color): UsernameQrCodeColorScheme { + return when (remote) { + AccountRecord.UsernameLink.Color.BLUE -> UsernameQrCodeColorScheme.Blue + AccountRecord.UsernameLink.Color.WHITE -> UsernameQrCodeColorScheme.White + AccountRecord.UsernameLink.Color.GREY -> UsernameQrCodeColorScheme.Grey + AccountRecord.UsernameLink.Color.OLIVE -> UsernameQrCodeColorScheme.Tan + AccountRecord.UsernameLink.Color.GREEN -> UsernameQrCodeColorScheme.Green + AccountRecord.UsernameLink.Color.ORANGE -> UsernameQrCodeColorScheme.Orange + AccountRecord.UsernameLink.Color.PINK -> UsernameQrCodeColorScheme.Pink + AccountRecord.UsernameLink.Color.PURPLE -> UsernameQrCodeColorScheme.Purple + else -> UsernameQrCodeColorScheme.Blue + } + } + + private fun localToRemoteContact(recipient: RecipientRecord, rawStorageId: ByteArray): SignalContactRecord { + if (recipient.aci == null && recipient.pni == null && recipient.e164 == null) { + throw AssertionError("Must have either a UUID or a phone number!") + } + + return SignalContactRecord.newBuilder(recipient.syncExtras.storageProto).apply { + aci = recipient.aci?.toString() ?: "" + e164 = recipient.e164 ?: "" + pni = recipient.pni?.toStringWithoutPrefix() ?: "" + profileKey = recipient.profileKey?.toByteString() ?: ByteString.EMPTY + givenName = recipient.signalProfileName.givenName + familyName = recipient.signalProfileName.familyName + systemGivenName = recipient.systemProfileName.givenName + systemFamilyName = recipient.systemProfileName.familyName + systemNickname = recipient.syncExtras.systemNickname ?: "" + blocked = recipient.isBlocked + whitelisted = recipient.profileSharing || recipient.systemContactUri != null + identityKey = recipient.syncExtras.identityKey?.toByteString() ?: ByteString.EMPTY + identityState = localToRemoteIdentityState(recipient.syncExtras.identityStatus) + archived = recipient.syncExtras.isArchived + markedUnread = recipient.syncExtras.isForcedUnread + mutedUntilTimestamp = recipient.muteUntil + hideStory = recipient.extras != null && recipient.extras.hideStory() + unregisteredAtTimestamp = recipient.syncExtras.unregisteredTimestamp + hidden = recipient.hiddenState != Recipient.HiddenState.NOT_HIDDEN + username = recipient.username ?: "" + pniSignatureVerified = recipient.syncExtras.pniSignatureVerified + nickname = recipient.nickname.takeUnless { it.isEmpty }?.let { ContactRecord.Name(given = it.givenName, family = it.familyName) } + note = recipient.note ?: "" + }.build().toSignalContactRecord(StorageId.forContact(rawStorageId)) + } + + private fun localToRemoteGroupV1(recipient: RecipientRecord, rawStorageId: ByteArray): SignalGroupV1Record { + val groupId = recipient.groupId ?: throw AssertionError("Must have a groupId!") + + if (!groupId.isV1) { + throw AssertionError("Group is not V1") + } + + return SignalGroupV1Record.newBuilder(recipient.syncExtras.storageProto).apply { + id = recipient.groupId.requireV1().decodedId.toByteString() + blocked = recipient.isBlocked + whitelisted = recipient.profileSharing + archived = recipient.syncExtras.isArchived + markedUnread = recipient.syncExtras.isForcedUnread + mutedUntilTimestamp = recipient.muteUntil + }.build().toSignalGroupV1Record(StorageId.forGroupV1(rawStorageId)) + } + + private fun localToRemoteGroupV2(recipient: RecipientRecord, rawStorageId: ByteArray?, groupMasterKey: GroupMasterKey): SignalGroupV2Record { + val groupId = recipient.groupId ?: throw AssertionError("Must have a groupId!") + + if (!groupId.isV2) { + throw AssertionError("Group is not V2") + } + + return SignalGroupV2Record.newBuilder(recipient.syncExtras.storageProto).apply { + masterKey = groupMasterKey.serialize().toByteString() + blocked = recipient.isBlocked + whitelisted = recipient.profileSharing + archived = recipient.syncExtras.isArchived + markedUnread = recipient.syncExtras.isForcedUnread + mutedUntilTimestamp = recipient.muteUntil + dontNotifyForMentionsIfMuted = recipient.mentionSetting == RecipientTable.MentionSetting.ALWAYS_NOTIFY + hideStory = recipient.extras != null && recipient.extras.hideStory() + storySendMode = when (groups.getShowAsStoryState(groupId)) { + ShowAsStoryState.ALWAYS -> GroupV2Record.StorySendMode.ENABLED + ShowAsStoryState.NEVER -> GroupV2Record.StorySendMode.DISABLED + else -> GroupV2Record.StorySendMode.DEFAULT + } + }.build().toSignalGroupV2Record(StorageId.forGroupV2(rawStorageId)) + } + + private fun localToRemoteCallLink(recipient: RecipientRecord, rawStorageId: ByteArray): SignalCallLinkRecord { + val callLinkRoomId = recipient.callLinkRoomId ?: throw AssertionError("Must have a callLinkRoomId!") + + val callLink = callLinks.getCallLinkByRoomId(callLinkRoomId) ?: throw AssertionError("Must have a call link record!") + + if (callLink.credentials == null) { + throw AssertionError("Must have call link credentials!") + } + + val deletedTimestamp = max(0.0, callLinks.getDeletedTimestampByRoomId(callLinkRoomId).toDouble()).toLong() + val adminPassword = if (deletedTimestamp > 0) byteArrayOf() else callLink.credentials.adminPassBytes!! + + return SignalCallLinkRecord.newBuilder(null).apply { + rootKey = callLink.credentials.linkKeyBytes.toByteString() + adminPasskey = adminPassword.toByteString() + deletedAtTimestampMs = deletedTimestamp + }.build().toSignalCallLinkRecord(StorageId.forCallLink(rawStorageId)) + } + + private fun localToRemoteStoryDistributionList(recipient: RecipientRecord, rawStorageId: ByteArray): SignalStoryDistributionListRecord { + val distributionListId = recipient.distributionListId ?: throw AssertionError("Must have a distributionListId!") + + val record = distributionLists.getListForStorageSync(distributionListId) ?: throw AssertionError("Must have a distribution list record!") + + if (record.deletedAtTimestamp > 0L) { + return SignalStoryDistributionListRecord.newBuilder(recipient.syncExtras.storageProto).apply { + identifier = UuidUtil.toByteArray(record.distributionId.asUuid()).toByteString() + deletedAtTimestamp = record.deletedAtTimestamp + }.build().toSignalStoryDistributionListRecord(StorageId.forStoryDistributionList(rawStorageId)) + } + + return SignalStoryDistributionListRecord.newBuilder(recipient.syncExtras.storageProto).apply { + identifier = UuidUtil.toByteArray(record.distributionId.asUuid()).toByteString() + name = record.name + recipientServiceIds = record.getMembersToSync() + .map { Recipient.resolved(it) } + .filter { it.hasServiceId } + .map { it.requireServiceId().toString() } + allowsReplies = record.allowsReplies + isBlockList = record.privacyMode.isBlockList + }.build().toSignalStoryDistributionListRecord(StorageId.forStoryDistributionList(rawStorageId)) + } + + fun remoteToLocalIdentityStatus(identityState: IdentityState): VerifiedStatus { + return when (identityState) { + IdentityState.VERIFIED -> VerifiedStatus.VERIFIED + IdentityState.UNVERIFIED -> VerifiedStatus.UNVERIFIED + else -> VerifiedStatus.DEFAULT + } + } + + private fun localToRemoteIdentityState(local: VerifiedStatus): IdentityState { + return when (local) { + VerifiedStatus.VERIFIED -> IdentityState.VERIFIED + VerifiedStatus.UNVERIFIED -> IdentityState.UNVERIFIED + else -> IdentityState.DEFAULT + } + } + + fun remoteToLocalSubscriber( + subscriberId: ByteString, + subscriberCurrencyCode: String, + type: InAppPaymentSubscriberRecord.Type + ): InAppPaymentSubscriberRecord? { + if (subscriberId.isNotEmpty()) { + val subscriberId = SubscriberId.fromBytes(subscriberId.toByteArray()) + val localSubscriberRecord = inAppPaymentSubscribers.getBySubscriberId(subscriberId) + val requiresCancel = localSubscriberRecord != null && localSubscriberRecord.requiresCancel + val paymentMethodType = localSubscriberRecord?.paymentMethodType ?: InAppPaymentData.PaymentMethodType.UNKNOWN + + val currency: Currency + if (subscriberCurrencyCode.isBlank()) { + return null + } else { + try { + currency = Currency.getInstance(subscriberCurrencyCode) + } catch (e: IllegalArgumentException) { + return null + } + } + + return InAppPaymentSubscriberRecord(subscriberId, currency, type, requiresCancel, paymentMethodType) + } else { + return null + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java index 981e56f515..65fcd668fe 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StorageSyncValidations.java @@ -9,10 +9,12 @@ import org.thoughtcrime.securesms.recipients.Recipient; import org.signal.core.util.Base64; import org.signal.core.util.SetUtil; +import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.storage.SignalContactRecord; import org.whispersystems.signalservice.api.storage.SignalStorageManifest; import org.whispersystems.signalservice.api.storage.SignalStorageRecord; import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord; import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; import java.nio.ByteBuffer; @@ -31,12 +33,12 @@ public static void validate(@NonNull StorageSyncHelper.WriteOperationResult resu boolean forcePushPending, @NonNull Recipient self) { - validateManifestAndInserts(result.getManifest(), result.getInserts(), self); + validateManifestAndInserts(result.manifest, result.inserts, self); - if (result.getDeletes().size() > 0) { - Set allSetEncoded = Stream.of(result.getManifest().getStorageIds()).map(StorageId::getRaw).map(Base64::encodeWithPadding).collect(Collectors.toSet()); + if (result.deletes.size() > 0) { + Set allSetEncoded = Stream.of(result.manifest.storageIds).map(StorageId::getRaw).map(Base64::encodeWithPadding).collect(Collectors.toSet()); - for (byte[] delete : result.getDeletes()) { + for (byte[] delete : result.deletes) { String encoded = Base64.encodeWithPadding(delete); if (allSetEncoded.contains(encoded)) { throw new DeletePresentInFullIdSetError(); @@ -44,12 +46,12 @@ public static void validate(@NonNull StorageSyncHelper.WriteOperationResult resu } } - if (previousManifest.getVersion() == 0) { + if (previousManifest.version == 0) { Log.i(TAG, "Previous manifest is empty, not bothering with additional validations around the diffs between the two manifests."); return; } - if (result.getManifest().getVersion() != previousManifest.getVersion() + 1) { + if (result.manifest.version != previousManifest.version + 1) { throw new IncorrectManifestVersionError(); } @@ -58,14 +60,14 @@ public static void validate(@NonNull StorageSyncHelper.WriteOperationResult resu return; } - Set previousIds = Stream.of(previousManifest.getStorageIds()).map(id -> ByteBuffer.wrap(id.getRaw())).collect(Collectors.toSet()); - Set newIds = Stream.of(result.getManifest().getStorageIds()).map(id -> ByteBuffer.wrap(id.getRaw())).collect(Collectors.toSet()); + Set previousIds = Stream.of(previousManifest.storageIds).map(id -> ByteBuffer.wrap(id.getRaw())).collect(Collectors.toSet()); + Set newIds = Stream.of(result.manifest.storageIds).map(id -> ByteBuffer.wrap(id.getRaw())).collect(Collectors.toSet()); Set manifestInserts = SetUtil.difference(newIds, previousIds); Set manifestDeletes = SetUtil.difference(previousIds, newIds); - Set declaredInserts = Stream.of(result.getInserts()).map(r -> ByteBuffer.wrap(r.getId().getRaw())).collect(Collectors.toSet()); - Set declaredDeletes = Stream.of(result.getDeletes()).map(ByteBuffer::wrap).collect(Collectors.toSet()); + Set declaredInserts = Stream.of(result.inserts).map(r -> ByteBuffer.wrap(r.getId().getRaw())).collect(Collectors.toSet()); + Set declaredDeletes = Stream.of(result.deletes).map(ByteBuffer::wrap).collect(Collectors.toSet()); if (declaredInserts.size() > manifestInserts.size()) { Log.w(TAG, "DeclaredInserts: " + declaredInserts.size() + ", ManifestInserts: " + manifestInserts.size()); @@ -103,7 +105,7 @@ public static void validateForcePush(@NonNull SignalStorageManifest manifest, @N private static void validateManifestAndInserts(@NonNull SignalStorageManifest manifest, @NonNull List inserts, @NonNull Recipient self) { int accountCount = 0; - for (StorageId id : manifest.getStorageIds()) { + for (StorageId id : manifest.storageIds) { accountCount += id.getType() == ManifestRecord.Identifier.Type.ACCOUNT.getValue() ? 1 : 0; } @@ -115,11 +117,11 @@ private static void validateManifestAndInserts(@NonNull SignalStorageManifest ma throw new MissingAccountError(); } - Set allSet = new HashSet<>(manifest.getStorageIds()); + Set allSet = new HashSet<>(manifest.storageIds); Set insertSet = new HashSet<>(Stream.of(inserts).map(SignalStorageRecord::getId).toList()); Set rawIdSet = Stream.of(allSet).map(id -> ByteBuffer.wrap(id.getRaw())).collect(Collectors.toSet()); - if (allSet.size() != manifest.getStorageIds().size()) { + if (allSet.size() != manifest.storageIds.size()) { throw new DuplicateStorageIdError(); } @@ -166,18 +168,18 @@ private static void validateManifestAndInserts(@NonNull SignalStorageManifest ma throw new UnknownInsertError(); } - if (insert.getContact().isPresent()) { - SignalContactRecord contact = insert.getContact().get(); + if (insert.getProto().contact != null) { + ContactRecord contact = insert.getProto().contact; - if (self.requireAci().equals(contact.getAci().orElse(null)) || - self.requirePni().equals(contact.getPni().orElse(null)) || - self.requireE164().equals(contact.getNumber().orElse(""))) + if (self.requireAci().equals(ServiceId.ACI.parseOrNull(contact.aci)) || + self.requirePni().equals(ServiceId.PNI.parseOrNull(contact.pni)) || + self.requireE164().equals(contact.e164)) { throw new SelfAddedAsContactError(); } } - if (insert.getAccount().isPresent() && !insert.getAccount().get().getProfileKey().isPresent()) { + if (insert.getProto().account != null && insert.getProto().account.profileKey.size() == 0) { Log.w(TAG, "Uploading a null profile key in our AccountRecord!"); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.java deleted file mode 100644 index 3bcc6a3aef..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.java +++ /dev/null @@ -1,188 +0,0 @@ -package org.thoughtcrime.securesms.storage; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.signal.core.util.StringUtil; -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.database.RecipientTable; -import org.thoughtcrime.securesms.database.SignalDatabase; -import org.thoughtcrime.securesms.database.model.RecipientRecord; -import org.thoughtcrime.securesms.recipients.RecipientId; -import org.whispersystems.signalservice.api.push.DistributionId; -import org.whispersystems.signalservice.api.push.SignalServiceAddress; -import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord; -import org.whispersystems.signalservice.api.util.UuidUtil; - -import java.io.IOException; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; - -public class StoryDistributionListRecordProcessor extends DefaultStorageRecordProcessor { - - private static final String TAG = Log.tag(StoryDistributionListRecordProcessor.class); - - private boolean haveSeenMyStory; - - /** - * At a minimum, we require: - *

    - *
  • A valid identifier
  • - *
  • A non-visually-empty name field OR a deleted at timestamp
  • - *
- */ - @Override - boolean isInvalid(@NonNull SignalStoryDistributionListRecord remote) { - UUID remoteUuid = UuidUtil.parseOrNull(remote.getIdentifier()); - if (remoteUuid == null) { - Log.d(TAG, "Bad distribution list identifier -- marking as invalid"); - return true; - } - - boolean isMyStory = remoteUuid.equals(DistributionId.MY_STORY.asUuid()); - if (haveSeenMyStory && isMyStory) { - Log.w(TAG, "Found an additional MyStory record -- marking as invalid"); - return true; - } - - haveSeenMyStory |= isMyStory; - - if (remote.getDeletedAtTimestamp() > 0L) { - if (isMyStory) { - Log.w(TAG, "Refusing to delete My Story -- marking as invalid"); - return true; - } else { - return false; - } - } - - if (StringUtil.isVisuallyEmpty(remote.getName())) { - Log.d(TAG, "Bad distribution list name (visually empty) -- marking as invalid"); - return true; - } - - return false; - } - - @Override - @NonNull Optional getMatching(@NonNull SignalStoryDistributionListRecord remote, @NonNull StorageKeyGenerator keyGenerator) { - Log.d(TAG, "Attempting to get matching record..."); - RecipientId matching = SignalDatabase.distributionLists().getRecipientIdForSyncRecord(remote); - if (matching == null && UuidUtil.parseOrThrow(remote.getIdentifier()).equals(DistributionId.MY_STORY.asUuid())) { - Log.e(TAG, "Cannot find matching database record for My Story."); - throw new MyStoryDoesNotExistException(); - } - - if (matching != null) { - Log.d(TAG, "Found a matching RecipientId for the distribution list..."); - RecipientRecord recordForSync = SignalDatabase.recipients().getRecordForSync(matching); - if (recordForSync == null) { - Log.e(TAG, "Could not find a record for the recipient id in the recipient table"); - throw new IllegalStateException("Found matching recipient but couldn't generate record for sync."); - } - - if (recordForSync.getRecipientType().getId() != RecipientTable.RecipientType.DISTRIBUTION_LIST.getId()) { - Log.d(TAG, "Record has an incorrect group type."); - throw new InvalidGroupTypeException(); - } - - Optional record = StorageSyncModels.localToRemoteRecord(recordForSync).getStoryDistributionList(); - if (record.isPresent()) { - Log.d(TAG, "Found a matching record."); - return record; - } else { - Log.e(TAG, "Could not resolve the record"); - throw new UnexpectedEmptyOptionalException(); - } - } else { - Log.d(TAG, "Could not find a matching record. Returning an empty."); - return Optional.empty(); - } - } - - @Override - @NonNull SignalStoryDistributionListRecord merge(@NonNull SignalStoryDistributionListRecord remote, @NonNull SignalStoryDistributionListRecord local, @NonNull StorageKeyGenerator keyGenerator) { - byte[] unknownFields = remote.serializeUnknownFields(); - byte[] identifier = remote.getIdentifier(); - String name = remote.getName(); - List recipients = remote.getRecipients(); - long deletedAtTimestamp = remote.getDeletedAtTimestamp(); - boolean allowsReplies = remote.allowsReplies(); - boolean isBlockList = remote.isBlockList(); - - boolean matchesRemote = doParamsMatch(remote, unknownFields, identifier, name, recipients, deletedAtTimestamp, allowsReplies, isBlockList); - boolean matchesLocal = doParamsMatch(local, unknownFields, identifier, name, recipients, deletedAtTimestamp, allowsReplies, isBlockList); - - if (matchesRemote) { - return remote; - } else if (matchesLocal) { - return local; - } else { - return new SignalStoryDistributionListRecord.Builder(keyGenerator.generate(), unknownFields) - .setIdentifier(identifier) - .setName(name) - .setRecipients(recipients) - .setDeletedAtTimestamp(deletedAtTimestamp) - .setAllowsReplies(allowsReplies) - .setIsBlockList(isBlockList) - .build(); - } - } - - @Override - void insertLocal(@NonNull SignalStoryDistributionListRecord record) throws IOException { - SignalDatabase.distributionLists().applyStorageSyncStoryDistributionListInsert(record); - } - - @Override - void updateLocal(@NonNull StorageRecordUpdate update) { - SignalDatabase.distributionLists().applyStorageSyncStoryDistributionListUpdate(update); - } - - @Override - public int compare(SignalStoryDistributionListRecord o1, SignalStoryDistributionListRecord o2) { - if (Arrays.equals(o1.getIdentifier(), o2.getIdentifier())) { - return 0; - } else { - return 1; - } - } - - private boolean doParamsMatch(@NonNull SignalStoryDistributionListRecord record, - @Nullable byte[] unknownFields, - @Nullable byte[] identifier, - @Nullable String name, - @NonNull List recipients, - long deletedAtTimestamp, - boolean allowsReplies, - boolean isBlockList) { - return Arrays.equals(unknownFields, record.serializeUnknownFields()) && - Arrays.equals(identifier, record.getIdentifier()) && - Objects.equals(name, record.getName()) && - Objects.equals(recipients, record.getRecipients()) && - deletedAtTimestamp == record.getDeletedAtTimestamp() && - allowsReplies == record.allowsReplies() && - isBlockList == record.isBlockList(); - } - - /** - * Thrown when the RecipientSettings object for a given distribution list is not the - * correct group type (4). - */ - private static class InvalidGroupTypeException extends RuntimeException {} - - /** - * Thrown when the distribution list object returned from the storage sync helper is - * absent, even though a RecipientSettings was found. - */ - private static class UnexpectedEmptyOptionalException extends RuntimeException {} - - /** - * Thrown when we try to ge the matching record for the "My Story" distribution ID but - * it isn't in the database. - */ - private static class MyStoryDoesNotExistException extends RuntimeException {} -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.kt new file mode 100644 index 0000000000..52988e1d6e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/storage/StoryDistributionListRecordProcessor.kt @@ -0,0 +1,144 @@ +package org.thoughtcrime.securesms.storage + +import org.signal.core.util.StringUtil +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.RecipientTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.whispersystems.signalservice.api.push.DistributionId +import org.whispersystems.signalservice.api.storage.SignalStoryDistributionListRecord +import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.api.storage.toSignalStoryDistributionListRecord +import org.whispersystems.signalservice.api.util.OptionalUtil.asOptional +import org.whispersystems.signalservice.api.util.UuidUtil +import java.io.IOException +import java.util.Optional + +/** + * Record processor for [SignalStoryDistributionListRecord]. + * Handles merging and updating our local store when processing remote dlist storage records. + */ +class StoryDistributionListRecordProcessor : DefaultStorageRecordProcessor() { + + companion object { + private val TAG = Log.tag(StoryDistributionListRecordProcessor::class.java) + } + + private var haveSeenMyStory = false + + /** + * At a minimum, we require: + * + * - A valid identifier + * - A non-visually-empty name field OR a deleted at timestamp + */ + override fun isInvalid(remote: SignalStoryDistributionListRecord): Boolean { + val remoteUuid = UuidUtil.parseOrNull(remote.proto.identifier) + if (remoteUuid == null) { + Log.d(TAG, "Bad distribution list identifier -- marking as invalid") + return true + } + + val isMyStory = remoteUuid == DistributionId.MY_STORY.asUuid() + if (haveSeenMyStory && isMyStory) { + Log.w(TAG, "Found an additional MyStory record -- marking as invalid") + return true + } + + haveSeenMyStory = haveSeenMyStory or isMyStory + + if (remote.proto.deletedAtTimestamp > 0L) { + if (isMyStory) { + Log.w(TAG, "Refusing to delete My Story -- marking as invalid") + return true + } else { + return false + } + } + + if (StringUtil.isVisuallyEmpty(remote.proto.name)) { + Log.d(TAG, "Bad distribution list name (visually empty) -- marking as invalid") + return true + } + + return false + } + + override fun getMatching(remote: SignalStoryDistributionListRecord, keyGenerator: StorageKeyGenerator): Optional { + Log.d(TAG, "Attempting to get matching record...") + val matching = SignalDatabase.distributionLists.getRecipientIdForSyncRecord(remote) + if (matching == null && UuidUtil.parseOrThrow(remote.proto.identifier) == DistributionId.MY_STORY.asUuid()) { + Log.e(TAG, "Cannot find matching database record for My Story.") + throw MyStoryDoesNotExistException() + } + + if (matching != null) { + Log.d(TAG, "Found a matching RecipientId for the distribution list...") + val recordForSync = SignalDatabase.recipients.getRecordForSync(matching) + if (recordForSync == null) { + Log.e(TAG, "Could not find a record for the recipient id in the recipient table") + throw IllegalStateException("Found matching recipient but couldn't generate record for sync.") + } + + if (recordForSync.recipientType.id != RecipientTable.RecipientType.DISTRIBUTION_LIST.id) { + Log.d(TAG, "Record has an incorrect group type.") + throw InvalidGroupTypeException() + } + + return StorageSyncModels.localToRemoteRecord(recordForSync).let { it.proto.storyDistributionList!!.toSignalStoryDistributionListRecord(it.id) }.asOptional() + } else { + Log.d(TAG, "Could not find a matching record. Returning an empty.") + return Optional.empty() + } + } + + override fun merge(remote: SignalStoryDistributionListRecord, local: SignalStoryDistributionListRecord, keyGenerator: StorageKeyGenerator): SignalStoryDistributionListRecord { + val merged = SignalStoryDistributionListRecord.newBuilder(remote.serializedUnknowns).apply { + identifier = remote.proto.identifier + name = remote.proto.name + recipientServiceIds = remote.proto.recipientServiceIds + deletedAtTimestamp = remote.proto.deletedAtTimestamp + allowsReplies = remote.proto.allowsReplies + isBlockList = remote.proto.isBlockList + }.build().toSignalStoryDistributionListRecord(StorageId.forStoryDistributionList(keyGenerator.generate())) + + val matchesRemote = doParamsMatch(remote, merged) + val matchesLocal = doParamsMatch(local, merged) + + return if (matchesRemote) { + remote + } else if (matchesLocal) { + local + } else { + merged + } + } + + @Throws(IOException::class) + override fun insertLocal(record: SignalStoryDistributionListRecord) { + SignalDatabase.distributionLists.applyStorageSyncStoryDistributionListInsert(record) + } + + override fun updateLocal(update: StorageRecordUpdate) { + SignalDatabase.distributionLists.applyStorageSyncStoryDistributionListUpdate(update) + } + + override fun compare(o1: SignalStoryDistributionListRecord, o2: SignalStoryDistributionListRecord): Int { + return if (o1.proto.identifier == o2.proto.identifier) { + 0 + } else { + 1 + } + } + + /** + * Thrown when the RecipientSettings object for a given distribution list is not the + * correct group type (4). + */ + private class InvalidGroupTypeException : RuntimeException() + + /** + * Thrown when we try to ge the matching record for the "My Story" distribution ID but + * it isn't in the database. + */ + private class MyStoryDoesNotExistException : RuntimeException() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationUtil.java index bfd8128a03..f1f35d9bff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConfigurationUtil.java @@ -20,4 +20,10 @@ public static int getNightModeConfiguration(@NonNull Configuration configuration public static float getFontScale(@NonNull Configuration configuration) { return configuration.fontScale; } + + public static boolean isUiModeChanged(@NonNull Configuration configuration, @NonNull Configuration newConfiguration) { + int oldTheme = configuration.uiMode & Configuration.UI_MODE_NIGHT_MASK; + int newTheme = newConfiguration.uiMode & Configuration.UI_MODE_NIGHT_MASK; + return oldTheme == newTheme; + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java index 9f046fec57..46ee3e01ce 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ProfileUtil.java @@ -361,7 +361,7 @@ private static void uploadProfile(@NonNull ProfileName profileName, avatar, badgeIds, SignalStore.phoneNumberPrivacy().isPhoneNumberSharingEnabled()).orElse(null); - SignalStore.registration().markHasUploadedProfile(); + SignalStore.registration().setHasUploadedProfile(true); if (!avatar.keepTheSame) { SignalDatabase.recipients().setProfileAvatar(Recipient.self().getId(), avatarPath); } diff --git a/app/src/main/res/drawable/ic_launcher_alt_bubbles_foreground.xml b/app/src/main/res/drawable/ic_launcher_alt_bubbles_foreground.xml index c2684f30f5..5b85d67be1 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_bubbles_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_bubbles_foreground.xml @@ -4,50 +4,43 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_bubbles_monochrome.xml b/app/src/main/res/drawable/ic_launcher_alt_bubbles_monochrome.xml index b0849b5584..39d44ae287 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_bubbles_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_bubbles_monochrome.xml @@ -3,17 +3,10 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_chat_background.xml b/app/src/main/res/drawable/ic_launcher_alt_chat_background.xml index caa8469dab..b920b3d03b 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_chat_background.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_chat_background.xml @@ -15,10 +15,10 @@ android:endX="54" android:endY="108"> diff --git a/app/src/main/res/drawable/ic_launcher_alt_chat_foreground.xml b/app/src/main/res/drawable/ic_launcher_alt_chat_foreground.xml index fa2ab7db41..f04a1a984a 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_chat_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_chat_foreground.xml @@ -3,14 +3,7 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_chat_monochrome.xml b/app/src/main/res/drawable/ic_launcher_alt_chat_monochrome.xml index ebf5ec2ebb..b70e2a8190 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_chat_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_chat_monochrome.xml @@ -3,14 +3,7 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_news_foreground.xml b/app/src/main/res/drawable/ic_launcher_alt_news_foreground.xml index f6bd7cd284..a8f858593c 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_news_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_news_foreground.xml @@ -3,34 +3,28 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_news_monochrome.xml b/app/src/main/res/drawable/ic_launcher_alt_news_monochrome.xml index dab4cfdd01..9edab81fce 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_news_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_news_monochrome.xml @@ -3,20 +3,13 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_notes_foreground.xml b/app/src/main/res/drawable/ic_launcher_alt_notes_foreground.xml index dbd8a1e75c..b4a3ae0f1b 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_notes_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_notes_foreground.xml @@ -4,56 +4,49 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_notes_monochrome.xml b/app/src/main/res/drawable/ic_launcher_alt_notes_monochrome.xml index 070bdc3f8f..c0b229bdd5 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_notes_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_notes_monochrome.xml @@ -3,20 +3,13 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_signal_color_background.xml b/app/src/main/res/drawable/ic_launcher_alt_signal_color_background.xml index 52a7e248b7..c65a01c347 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_signal_color_background.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_signal_color_background.xml @@ -4,34 +4,29 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - - - + + + + + + + + + + + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_signal_color_foreground.xml b/app/src/main/res/drawable/ic_launcher_alt_signal_color_foreground.xml index 7e5316583b..fc7c517e7c 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_signal_color_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_signal_color_foreground.xml @@ -3,62 +3,55 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_signal_dark_foreground.xml b/app/src/main/res/drawable/ic_launcher_alt_signal_dark_foreground.xml index c0c3dc4a10..f26f4d119c 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_signal_dark_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_signal_dark_foreground.xml @@ -4,319 +4,344 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_signal_dark_variant_foreground.xml b/app/src/main/res/drawable/ic_launcher_alt_signal_dark_variant_foreground.xml index ca648021e8..5a7561f79f 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_signal_dark_variant_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_signal_dark_variant_foreground.xml @@ -4,319 +4,344 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_signal_white_foreground.xml b/app/src/main/res/drawable/ic_launcher_alt_signal_white_foreground.xml index 5fe2c6719b..1bc0a0a465 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_signal_white_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_signal_white_foreground.xml @@ -4,317 +4,310 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_waves_background.xml b/app/src/main/res/drawable/ic_launcher_alt_waves_background.xml index 0d2c4c7a3c..9b04f05f32 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_waves_background.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_waves_background.xml @@ -10,7 +10,7 @@ + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_waves_foreground.xml b/app/src/main/res/drawable/ic_launcher_alt_waves_foreground.xml index b206f8290b..42393aca91 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_waves_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_waves_foreground.xml @@ -3,20 +3,13 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_waves_monochrome.xml b/app/src/main/res/drawable/ic_launcher_alt_waves_monochrome.xml index 077bfec7e9..68d6544edf 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_waves_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_waves_monochrome.xml @@ -3,14 +3,7 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_weather_background.xml b/app/src/main/res/drawable/ic_launcher_alt_weather_background.xml index 88f70b0a7d..49b008e26a 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_weather_background.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_weather_background.xml @@ -9,17 +9,20 @@ + - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_weather_foreground.xml b/app/src/main/res/drawable/ic_launcher_alt_weather_foreground.xml index e60abbb515..ddad492336 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_weather_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_weather_foreground.xml @@ -4,47 +4,40 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_weather_monochrome.xml b/app/src/main/res/drawable/ic_launcher_alt_weather_monochrome.xml index 9faa93b33d..07d7a8dc64 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_weather_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_weather_monochrome.xml @@ -3,17 +3,10 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_yellow_background.xml b/app/src/main/res/drawable/ic_launcher_alt_yellow_background.xml index d81fc8b4d7..dcf8ef748e 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_yellow_background.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_yellow_background.xml @@ -5,27 +5,6 @@ android:viewportWidth="108" android:viewportHeight="108"> - - - - - - - - - @@ -35,15 +14,12 @@ android:startY="0" android:endX="54" android:endY="108"> - + android:offset="0.12"/> + android:offset="0.88"/> @@ -51,75 +27,107 @@ + android:pathData="M0 47.58v-4.04L43.54 0h4.04L0 47.58Z"/> + + + + + + + + + android:pathData="M14.3 108h-4.04L108 10.26v4.04L14.3 108Z"/> + android:pathData="M22.66 108h-4.04L108 18.62v4.04L22.66 108Z"/> + android:pathData="M31.02 108h-4.04L108 26.98v4.04L31.02 108Z"/> + android:pathData="M39.38 108h-4.04L108 35.34v4.04L39.38 108Z"/> + android:pathData="M47.74 108H43.7L108 43.7v4.04L47.74 108Z"/> + android:pathData="M56.1 108h-4.04L108 52.06v4.04L56.1 108Z"/> + android:pathData="M64.46 108h-4.04L108 60.42v4.04L64.46 108Z"/> + android:pathData="M72.82 108h-4.04L108 68.78v4.04L72.82 108Z"/> + android:pathData="M39.22 0L0 39.22v-4.04L35.18 0h4.04Z"/> + android:pathData="M30.84 0L0 30.84v-4.03L26.81 0h4.03Z"/> + android:pathData="M22.52 0L0 22.52v-4.04L18.48 0h4.04Z"/> + android:pathData="M14.12 0L0 14.12v-4.04L10.08 0h4.04Z"/> + android:pathData="M5.73 0L0 5.72V1.7L1.7 0h4.03Z"/> + android:pathData="M81.22 108H77.2L108 77.19v4.03L81.22 108Z"/> + android:pathData="M89.6 108h-4.02L108 85.58v4.03L89.6 108Z"/> + android:pathData="M97.96 108h-4.03L108 93.93v4.03L97.96 108Z"/> + android:pathData="M106.31 108h-4.03l5.72-5.72v4.03l-1.69 1.69Z"/> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_yellow_foreground.xml b/app/src/main/res/drawable/ic_launcher_alt_yellow_foreground.xml index 09a47f54a1..f04a1a984a 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_yellow_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_yellow_foreground.xml @@ -3,15 +3,7 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_alt_yellow_monochrome.xml b/app/src/main/res/drawable/ic_launcher_alt_yellow_monochrome.xml index ebf5ec2ebb..b70e2a8190 100644 --- a/app/src/main/res/drawable/ic_launcher_alt_yellow_monochrome.xml +++ b/app/src/main/res/drawable/ic_launcher_alt_yellow_monochrome.xml @@ -3,14 +3,7 @@ android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> - - - - - + + \ No newline at end of file diff --git a/app/src/main/res/drawable/image_other_device.xml b/app/src/main/res/drawable/image_other_device.xml new file mode 100644 index 0000000000..fef0674912 --- /dev/null +++ b/app/src/main/res/drawable/image_other_device.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/image_transfer_phones.xml b/app/src/main/res/drawable/image_transfer_phones.xml new file mode 100644 index 0000000000..942203ca09 --- /dev/null +++ b/app/src/main/res/drawable/image_transfer_phones.xml @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/symbol_backup_40.xml b/app/src/main/res/drawable/symbol_backup_40.xml new file mode 100644 index 0000000000..3db52a49da --- /dev/null +++ b/app/src/main/res/drawable/symbol_backup_40.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/symbol_check_circle_40.xml b/app/src/main/res/drawable/symbol_check_circle_40.xml new file mode 100644 index 0000000000..7996eebc07 --- /dev/null +++ b/app/src/main/res/drawable/symbol_check_circle_40.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/symbol_folder_24.xml b/app/src/main/res/drawable/symbol_folder_24.xml index 31a6fa6131..9afc7adcad 100644 --- a/app/src/main/res/drawable/symbol_folder_24.xml +++ b/app/src/main/res/drawable/symbol_folder_24.xml @@ -3,7 +3,7 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - + diff --git a/app/src/main/res/drawable/symbol_no_phone_44.xml b/app/src/main/res/drawable/symbol_no_phone_44.xml new file mode 100644 index 0000000000..92f3477421 --- /dev/null +++ b/app/src/main/res/drawable/symbol_no_phone_44.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/symbol_signal_backups_24.xml b/app/src/main/res/drawable/symbol_signal_backups_24.xml new file mode 100644 index 0000000000..54d7013afe --- /dev/null +++ b/app/src/main/res/drawable/symbol_signal_backups_24.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/symbol_transfer_24.xml b/app/src/main/res/drawable/symbol_transfer_24.xml new file mode 100644 index 0000000000..2c7b67c1aa --- /dev/null +++ b/app/src/main/res/drawable/symbol_transfer_24.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout/activity_registration_navigation_v3.xml b/app/src/main/res/layout/activity_registration_navigation_v3.xml new file mode 100644 index 0000000000..571a69947f --- /dev/null +++ b/app/src/main/res/layout/activity_registration_navigation_v3.xml @@ -0,0 +1,22 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_remote_restore.xml b/app/src/main/res/layout/activity_remote_restore.xml deleted file mode 100644 index 883b564737..0000000000 --- a/app/src/main/res/layout/activity_remote_restore.xml +++ /dev/null @@ -1,244 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/activity_restore.xml b/app/src/main/res/layout/activity_restore.xml index 09b7fa8162..4b4112779a 100644 --- a/app/src/main/res/layout/activity_restore.xml +++ b/app/src/main/res/layout/activity_restore.xml @@ -3,23 +3,11 @@ ~ SPDX-License-Identifier: AGPL-3.0-only --> - - - - - \ No newline at end of file + android:background="@color/signal_background_primary" + android:transitionName="window_content" /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_registration_restore_backup.xml b/app/src/main/res/layout/fragment_registration_restore_backup.xml deleted file mode 100644 index 00fb3c6e28..0000000000 --- a/app/src/main/res/layout/fragment_registration_restore_backup.xml +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_registration_welcome_v3.xml b/app/src/main/res/layout/fragment_registration_welcome_v3.xml new file mode 100644 index 0000000000..ee4ccca2b7 --- /dev/null +++ b/app/src/main/res/layout/fragment_registration_welcome_v3.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_restore_local_backup.xml b/app/src/main/res/layout/fragment_restore_local_backup.xml index e0f7121202..32101aad0c 100644 --- a/app/src/main/res/layout/fragment_restore_local_backup.xml +++ b/app/src/main/res/layout/fragment_restore_local_backup.xml @@ -4,8 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:fillViewport="true" - tools:context=".registration.fragments.RestoreBackupFragment"> + android:fillViewport="true"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_transfer_restore_v2.xml b/app/src/main/res/layout/fragment_transfer_restore_v2.xml index 9d2cd64ebd..13d7b99c2f 100644 --- a/app/src/main/res/layout/fragment_transfer_restore_v2.xml +++ b/app/src/main/res/layout/fragment_transfer_restore_v2.xml @@ -93,67 +93,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/registration_v3.xml b/app/src/main/res/navigation/registration_v3.xml new file mode 100644 index 0000000000..cb42553bbd --- /dev/null +++ b/app/src/main/res/navigation/registration_v3.xml @@ -0,0 +1,279 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/restore.xml b/app/src/main/res/navigation/restore.xml index 53e60d012e..4b0b520697 100644 --- a/app/src/main/res/navigation/restore.xml +++ b/app/src/main/res/navigation/restore.xml @@ -1,5 +1,4 @@ - - @@ -7,13 +6,41 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/restore" - app:startDestination="@id/transferOrRestore"> + app:startDestination="@id/transferOrRestoreV2"> + + + + + + + android:id="@+id/transferOrRestoreV2" + android:name="org.thoughtcrime.securesms.restore.transferorrestore.TransferOrRestoreV2Fragment"> + android:id="@+id/action_transfer_or_restore_to_local_restore" + app:destination="@id/choose_local_backup_fragment" + app:enterAnim="@anim/nav_default_enter_anim" + app:exitAnim="@anim/nav_default_exit_anim" + app:popEnterAnim="@anim/nav_default_pop_enter_anim" + app:popExitAnim="@anim/nav_default_pop_exit_anim" /> + + + + + + app:popExitAnim="@anim/nav_default_pop_exit_anim" /> - - - - - - - - - - - - + tools:layout="@layout/fragment_restore_local_backup" /> - - + app:popUpTo="@id/restore" + app:popUpToInclusive="true" /> - - - - - - + tools:layout="@layout/new_device_transfer_complete_fragment" /> \ No newline at end of file diff --git a/app/src/main/res/values-af/strings.xml b/app/src/main/res/values-af/strings.xml index 69ccef7639..f406604c4f 100644 --- a/app/src/main/res/values-af/strings.xml +++ b/app/src/main/res/values-af/strings.xml @@ -1325,20 +1325,6 @@ meer Voeg groepbeskrywing by… - - - Dra oor van Android-toestel af - - Dra jou rekening en boodskapgeskiedenis vanaf jou ou iOS-toestel oor. - - Teken aan sonder om oor te dra - - Gaan voort sonder om jou boodskappe en media oor te dra - - Herwin plaaslike rugsteun - - Laai jou boodskappe terug van \'n rugsteunlêer wat jy op jou toestel gestoor het. - Laai tans rugsteun af… @@ -1356,12 +1342,16 @@ Al jou boodskappe Herwin van rugsteun af - + Slegs media wat in die afgelope %1$d dae gestuur of ontvang is, is ingesluit. Jou rugsteun sluit in: Laai rugsteun terug + + Jou laaste rugsteun is op %1$s om %2$s gemaak. + + Herwin tans rugsteunbesonderhede… Stel my in kennis van Vermeldings @@ -3463,7 +3453,7 @@ Ander Betalings (MobileCoin) Skenkings & Wapens - Signal Android Backup + Signal Android-rugsteun Indiening van Signal Android-ontfoutlog @@ -4304,6 +4294,8 @@ Ek het hierdie wagwoordfrase neergeskryf. Daarsonder sal ek nie \'n rugsteun kan herwin nie. Laai rugsteun terug Dra rekening oor of herwin dit. + + Herwin of dra oor Dra rekening oor Slaan oor Klets rugsteune @@ -5124,7 +5116,7 @@ Groepe - Only messages from group chats + Slegs boodskappe uit groepkletse Voeg toe @@ -6687,7 +6679,7 @@ Tik op die \"Gaan na instellings\"-knoppie hier onder - Skakel \"Laat instellingsalarms en -herinneringe toe\" aan. + Skakel \"Laat stel van alarms en herinneringe toe\" aan. Gaan na instellings @@ -7426,11 +7418,11 @@ Jou Signal-mediarugsteunplan is gekanselleer omdat ons nie jou betaling kon verwerk nie. Dis jou laaste kans om die media in jou rugsteun af te laai voordat dit geskrap word. - Free up %1$s on this device + Stel %1$s op hierdie toestel beskikbaar - To finish downloading your Signal Backup your device needs %1$s of storage space. + Om die aflaai van jou Signal-rugsteun te voltooi, benodig jou toestel %1$s stoorruimte. - To free up space offload or delete unused apps or content large in file size. + Om stoorruimte beskikbaar te stel, laai ongebruikte toepassings of inhoud met \'n groot lêergrootte af of skrap dit. Jou rugsteun-intekening kon nie hernu word nie @@ -7458,7 +7450,7 @@ Nie nou nie - Try later + Probeer later Media sal geskrap word @@ -7470,9 +7462,9 @@ Slaan oor - Skip restore? + Slaan herstel oor? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + As jy herstel oorslaan, sal die oorblywende media en aanhegsels in jou rugsteun geskrap word die volgende keer wanneer jou toestel \'n nuwe rugsteun voltooi. @@ -7529,10 +7521,6 @@ Verander of kanselleer intekening - - - Jou laaste rugsteun is op %1$s om %2$s gemaak. - Kletsgetalbeperkings @@ -7850,5 +7838,101 @@ Herinnering-ikoon + + + Ek het my ou telefoon + + Skandeer \'n QR-kode in vanaf jou huidige Signal-rekening om vinnig te begin + + + Ek het nie my ou telefoon nie + + Of jy is besig om Signal op dieselfde toestel te herinstalleer + + + Herwin of dra rekening oor + + Kry jou Signal-rekening en boodskapgeskiedenis op hierdie toestel. + + Vanaf Signal-rugsteun + + Jou gratis of betaalde Signal-rugsteunplan + + Vanaf \'n rugsteunvouer + + Vanaf \'n rugsteunlêer + + Kies \'n rugsteun wat jy gestoor het + + Vanaf jou ou foon + + Dra direk vanaf jou ou Android oor + + + Herwin plaaslike rugsteun + + Herwin jou boodskappe van \'n rugsteun wat jy op jou toestel gestoor het. As jy dit nie nou herwin nie, sal jy dit nie later kan herwin nie. + + + Voer jou rugsteunsleutel in + + Jou rugsteunsleutel is \'n 64-syfer-kode wat nodig is om jou rekening en data te herwin. + + Geen rugsteunsleutel nie? + + Rugsteunsleutel + + Rugsteun kan nie sonder die 64-syfer-herwinningskode herwin word nie. As jy jou rugsteunsleutel verloor het, kan Signal jou nie help om jou rugsteun te herstel nie. + + As jy jou ou toestel het, kan jy jou rugsteunsleutel in Instellings > Klets > Signal-rugsteun bekyk. Tik dan op Bekyk rugsteunsleutel. + + Vind meer uit + + Slaan oor en moenie herwin nie + + + Skandeer hierdie kode met jou ou foon + + Maak Signal op jou ou toestel oop + + Tik op die kamera-ikoon + + Skandeer hierdie kode met die kamera + + Kan nie QR-kode genereer nie + + Op ou toestel geskandeer + + Probeer weer + + + Dra rekening oor + + Jou rekening sal na \'n nuwe toestel oorgedra word. Hierdie toestel sal jou groepe en kontakte kan sien, toegang tot jou kletse kan kry en boodskappe in jou naam kan stuur. %1$s + + Vind meer uit + + Dra rekening oor + + Boodskappe en kletsinligting word deur end-tot-end-enkriptering op alle toestelle beskerm + + Ontsluit om rekening oor te dra + + Gaan voort op jou ander toestel + + Gaan voort om jou rekening na jou ander toestel oor te dra. + + + Herstel vanaf rugsteun voltooi + + Daar is begin om jou Signal-rekening en boodskappe na jou ander toestel oor te dra. Signal is nou onaktief op hierdie toestel. + + Oordrag voltooi + + Jou Signal-rekening en boodskappe is na jou ander toestel oorgedra. Signal is nou onaktief op hierdie toestel. + + Reg so + + \ No newline at end of file diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index be9eb86ad0..93f4abb67d 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1477,20 +1477,6 @@ المزيد إضافة وصف للمجموعة… - - - نقل من جهاز أندرويد - - انقل حسابك ورسائلك من جهاز أندرويد القديم. - - تسجيل الدخول من دون نقل - - اِستِمر من دُون نقلِ رسائلك والوسائط - - استعادة نسخة احتياطية محلية - - استعِد رسائلك من ملف نسخة احتياطية حفظته على جهازك. - جارٍتنزيل النسخة الاحتياطية… @@ -1508,12 +1494,16 @@ جميع الرسائل الخاصة بك الاستعادة مِن نسخة احتياطية - + فقط الوسائط المُرسلة أو المُستلمة في الأيام %1$d الماضية هي المُضمّنة. تتضمن النسخة الاحتياطية: استعادة نسخة احتياطية + + أُجريت آخر نسخة احتياطية في %1$s على الساعة %2$s. + + جارٍ إعداد تفاصيل النسخة الاحتياطية… نبّهني عندما يذكر أحدهم اسمي @@ -3859,7 +3849,7 @@ أخرى عمليات الدفع (MobileCoin) تبرُّعات وشارات - Signal Android Backup + النسخة الاحتياطية على سيجنال أندرويد إرسال سِجل تصحيح أخطاء سيجنال للأندرويد @@ -4263,7 +4253,7 @@ 2. انقر على \"الأذونات\" - 3. %1$s امنح الإذن بالوصول إلى \"الصور والفيديوهات\" + 3. %1$s امنح الصلاحية للوصول إلى \"الصور والفيديوهات\" الإعدادات @@ -4541,7 +4531,7 @@ انقر مرّتين بسرعة على رسائلك لتعديلها. يُمكنك تعديل رسائلك لمدة تصل إلى 24 ساعة بعد إرسالها. - عُلم + تم مجموعة جديدة @@ -4550,7 +4540,7 @@ اعتبار كل الرسائل مقروءة اُدْعُ الأصدقاء - تصفية الدردشات غير المقروءة + فلترة الدردشات غير المقروءة مسح فلتر المحادثات غير المقروءة @@ -4562,13 +4552,13 @@ يواجه سيجنال بعض المشاكل التقنيّة حاليًا. نعمل بجد لإعادة الخدمة بأسرع وقتٍ ممكن. %1$d%% - لا يُمكن لاكتشاف جهات الاتصال الخاصة لتطبيق سيجنال مُعالجة جهات اتصال هاتفك مؤقتًا. + لا يُمكن لميزة اكتشاف جهات الاتصال الخاصة في سيجنال مُعالجة جهات اتصال هاتفك مؤقتًا. - معرفة المزيد + اعرف المزيد - لا يُمكن لاكتشاف جهات الاتصال الخاصة لتطبيق سيجنال مُعالجة جهات اتصال هاتفك. + لا يُمكن لميزة اكتشاف جهات الاتصال الخاصة في سيجنال مُعالجة جهات اتصال هاتفك. - معرفة المزيد + اعرف المزيد حفظ @@ -4627,98 +4617,98 @@ جارٍإنشاء رقم التعريف الشخصي… - نقدم لكم أرقام التعريف الشخصية (PINS) - الأرقام التعريفية الشخصية تجعل المعلومات المحفوظة في سيجنال مُعمَّاة، ولا يمكن لأحد سواك أن يصل إليها. سيتم استرجاع حسابك الشخصي و إعداداتك وجهات اتصالك عندما يُعاد تثبيت سيجنال. لن تكون لك الحاجة إلى الرقم التعريفي لفتح واستخدام التطبيق. - لمعرفة المزيد + نقدم لكم أرقام التعريف الشخصية (PINs) + الأرقام التعريفية الشخصية تجعل المعلومات المحفوظة في سيجنال مُشفَّرة، ولا يمكن لأحد غيرك أن يصل إليها. سيتمُّ استرجاع حسابك الشخصي و إعداداتك وجهات اتصالك عندما يُعاد تثبيت سيجنال. لن تحتاج رقم التعريف الشخصي لفتح التطبيق. + اعرف المزيد - قفل التسجيل = الرقم التعريفي الشخصي - قفل التسجيل يسمَّى الآن الرقم التعريفي الشخصي، وهو مسؤول عن أشياء أخرى. حدِّثه الآن. - تحديث الرقم التعريفي الشخصي + قفل التسجيل = رقم التعريف الشخصي (PIN) + قفل التسجيل يسمَّى الآن رقم التعريف الشخصي، وهو مسؤول عن أشياء أخرى. حدِّثه الآن. + تحديث رقم التعريف الشخصي إنشاء الرقم التعريفي الشخصي الخاص بك - معرفة المزيد عن الأرقام التعريفية الشخصية - إلغاء الرقم التعريفي الشخصي + اعرف المزيد عن أرقام التعريف الشخصية + تعطيل رقم التعريف الشخصي - أدخل رقم سيجنال التعريفي الشخصي - لمساعدتك على تذكر رقمك التعريفي الشخصي سنطلب منك إعادة إدخاله من حين لآخر. مع مرور الوقت سنطلبه منك مرات أقل. + أدخِل رقم التعريف الشخصي لديك على سيجنال + لمساعدتك على تذكر رقم التعريف الشخصي، سنطلب منك إعادة إدخاله من حين لآخر. مع مرور الوقت سنطلبه منك مرّات أقل. تخطّي تقديم - هل نسيت الرقم التعريفي الشخصي؟ - الرقم التعريفي الشخصي غير صحيح. يرجى إعادة المحاولة. + هل نسيت رقم التعريف الشخصي (PIN)؟ + رقم التعريف الشخصي (PIN) غير صحيح. يُرجى المحاولة مرّة أخرى. - تم إقفال الحساب - تم قفل حسابك لحماية خصوصيتك وأمانك. بعد %1$d يوم من خمول حسابك، سيكون بإمكانك إعادة التسجيل برقم هاتفك دون الحاجة إلى رقم التعريف الشخصي. سيُحذَف جميع محتويات حسابك. + تمَّ قفل الحساب + تمَّ قفل حسابك لحماية خصوصيتك وأمانك. بعد %1$d يوم من خمول حسابك، سيكون بإمكانك إعادة التسجيل برقم هاتفك دون الحاجة إلى رقم التعريف الشخصي (PIN). سيتم حذف جميع محتويات حسابك. التالي - أعرف أكثر + اعرف أكثر - أدخل الرقم التعريفي الشخصي - عليك بإدخال الرقم التعريفي الشخصي الذي قمت بإنشائه لحسابك. إنه مختلف عن رمز التحقق الذي وصلك عبر رسالة قصيرة. + أدخِل رقم التعريف الشخصي + أدخِل رقم التعريف الشخصي الذي قمت بإنشائه لحسابك. هو مختلف عن رمز التحقُّق الذي وصلك عبر رسالة قصيرة. - أدخل رقم التعريف الشخصي الذي أنشأته لحسابك. + أدخل رقم التعريف الشخصي (PIN) الذي أنشأته لحسابك. تغيير لوحة المفاتيح - الرقم التعريفي الشخصي غير صحيح. يرجى إعادة المحاولة. - هل نسيت الرقم التعريفي الشخصي؟ - الرقم التعريفي الشخصي غير صحيح - هل نسيت الرقم التعريفي الشخصي؟ + رقم التعريف الشخصي غير صحيح. يُرجى المحاولة مرّة أخرى. + هل نسيت رقم التعريف الشخصي؟ + رقم التعريف الشخصي غير صحيح + هل نسيت رقم التعريف الشخصي؟ لم يتبق الكثير من المحاولات! - تسجيل سيجنال - تحتاج مساعدة بالرقم التعريفي الشخصي في أندرويد (النسخة الثانية للرقم التعريفي الشخصي) + تسجيل سيجنال - تحتاج مساعدة لرقم التعريف الشخصي في أندرويد (النسخة الثانية لرقم التعريف الشخصي) - لخصوصيتك ولأمانك، لا توجد طريقة لاسترجاع رقمك التعريفي الشخصي. إن لم تستطع تذكره، يمكنك إعادة التسجيل باستخدام رسالة قصيرة بعد %1$d أيام من عدم الاستخدام. في هذه الحالة، سيتم حذف حسابك وجميع المحتوى الخاص بك. - لخصوصيتك ولأمانك، لا توجد طريقة لاسترجاع رقمك التعريفي الشخصي. إن لم تستطع تذكره، يمكنك إعادة التسجيل باستخدام رسالة قصيرة بعد %1$d يوم من عدم الاستخدام. في هذه الحالة، سيتم حذف حسابك وجميع المحتوى الخاص بك. - لخصوصيتك ولأمانك، لا توجد طريقة لاسترجاع رقمك التعريفي الشخصي. إن لم تستطع تذكره، يمكنك إعادة التسجيل باستخدام رسالة قصيرة بعد %1$d أيام من عدم الاستخدام. في هذه الحالة، سيتم حذف حسابك وجميع المحتوى الخاص بك. - لخصوصيتك ولأمانك، لا توجد طريقة لاسترجاع رقمك التعريفي الشخصي. إن لم تستطع تذكره، يمكنك إعادة التسجيل باستخدام رسالة قصيرة بعد %1$d أيام من عدم الاستخدام. في هذه الحالة، سيتم حذف حسابك وجميع المحتوى الخاص بك. - لخصوصيتك ولأمانك، لا توجد طريقة لاسترجاع رقمك التعريفي الشخصي. إن لم تستطع تذكره، يمكنك إعادة التسجيل باستخدام رسالة قصيرة بعد %1$d يوما من عدم الاستخدام. في هذه الحالة، سيتم حذف حسابك وجميع المحتوى الخاص بك. - لخصوصيتك ولأمانك، لا توجد طريقة لاسترجاع رقمك التعريفي الشخصي. إن لم تستطع تذكره، يمكنك إعادة التسجيل باستخدام رسالة قصيرة بعد %1$d يوم من عدم الاستخدام. في هذه الحالة، سيتم حذف حسابك وجميع المحتوى الخاص بك. + لخصوصيتك ولأمانك، لا توجد طريقة لاسترجاع رقم التعريف الشخصي. إن لم تستطع تذكره، يمكنك إعادة التسجيل باستخدام رسالة قصيرة بعد %1$d أيام من عدم الاستخدام. في هذه الحالة، سيتم حذف حسابك وجميع المحتوى الخاص بك. + لخصوصيتك ولأمانك، لا توجد طريقة لاسترجاع رقم التعريف الشخصي. إن لم تستطع تذكره، يمكنك إعادة التسجيل باستخدام رسالة قصيرة بعد %1$d يوم من عدم الاستخدام. في هذه الحالة، سيتم حذف حسابك وجميع المحتوى الخاص بك. + لخصوصيتك ولأمانك، لا توجد طريقة لاسترجاع رقم التعريف الشخصي. إن لم تستطع تذكره، يمكنك إعادة التسجيل باستخدام رسالة قصيرة بعد %1$d يومين من عدم الاستخدام. في هذه الحالة، سيتم حذف حسابك وجميع المحتوى الخاص بك. + لخصوصيتك ولأمانك، لا توجد طريقة لاسترجاع رقم التعريف الشخصي. إن لم تستطع تذكره، يمكنك إعادة التسجيل باستخدام رسالة قصيرة بعد %1$d أيام من عدم الاستخدام. في هذه الحالة، سيتم حذف حسابك وجميع المحتوى الخاص بك. + لخصوصيتك ولأمانك، لا توجد طريقة لاسترجاع رقم التعريف الشخصي. إن لم تستطع تذكره، يمكنك إعادة التسجيل باستخدام رسالة قصيرة بعد %1$d يومًا من عدم الاستخدام. في هذه الحالة، سيتم حذف حسابك وجميع المحتوى الخاص بك. + لخصوصيتك ولأمانك، لا توجد طريقة لاسترجاع رقم التعريف الشخصي. إن لم تستطع تذكره، يمكنك إعادة التسجيل باستخدام رسالة قصيرة بعد %1$d يوم من عدم الاستخدام. في هذه الحالة، سيتم حذف حسابك وجميع المحتوى الخاص بك. - الرمز غير صحيح. %1$d محاولات متبقية. - الرمز غير صحيح. %1$d محاولة متبقية. - الرمز غير صحيح. %1$d محاولة متبقية. - الرمز غير صحيح. %1$d محاولات متبقية. - الرمز غير صحيح.%1$d محاولة متبقية. - الرمز غير صحيح. %1$d محاولة متبقية. + رقم التعريف الشخصي (PIN) غير صحيح. %1$d محاولات متبقية. + رقم التعريف الشخصي (PIN) غير صحيح. %1$d محاولة متبقية. + رقم التعريف الشخصي (PIN) غير صحيح. %1$d محاولة متبقية. + رقم التعريف الشخصي (PIN) غير صحيح. %1$d محاولات متبقية. + رقم التعريف الشخصي (PIN) غير صحيح. %1$d محاولة متبقية. + رقم التعريف الشخصي (PIN) غير صحيح. %1$d محاولة متبقية. - إذا نفذت محاولاتك، سيتم قفل حسابك لمدة %1$d أيام. وبعد %1$d أيام من عدم الاستخدام، تستطيع إعادة التسجيل دون الرقم التعريفي الشخصي. سيتم حذف حسابك وجميع المحتوى الخاص به. - إذا نفذت محاولاتك، سيتم قفل حسابك لمدة %1$d يوم. وبعد %1$d يوم من عدم الاستخدام، تستطيع إعادة التسجيل دون الرقم التعريفي الشخصي. سيتم حذف حسابك وجميع المحتوى الخاص به. - إذا نفذت محاولاتك، سيتم قفل حسابك لمدة %1$d يومين. وبعد %1$d أيام من عدم الاستخدام، تستطيع إعادة التسجيل دون الرقم التعريفي الشخصي. سيتم حذف حسابك وجميع المحتوى الخاص به. - إذا نفذت محاولاتك، سيتم قفل حسابك لمدة %1$d أيام. وبعد %1$d أيام من عدم الاستخدام، تستطيع إعادة التسجيل دون الرقم التعريفي الشخصي. سيتم حذف حسابك وجميع المحتوى الخاص به. - إذا نفذت محاولاتك، سيتم قفل حسابك لمدة %1$d يوما. وبعد %1$d يوما من عدم الاستخدام، تستطيع إعادة التسجيل دون الرقم التعريفي الشخصي. سيتم حذف حسابك وجميع المحتوى الخاص به. - إذا نفذت محاولاتك، سيتم قفل حسابك لمدة %1$d يوم. وبعد %1$d يوم من عدم الاستخدام، تستطيع إعادة التسجيل دون الرقم التعريفي الشخصي. سيتم حذف حسابك وجميع المحتوى الخاص به. + إذا نفدت محاولاتك، سيتم قفل حسابك لمدة %1$d أيام. وبعد %1$d أيام من عدم الاستخدام، تستطيع إعادة التسجيل دون رقم التعريف الشخصي. سيتم حذف حسابك وجميع المحتوى الخاص به. + إذا نفدت محاولاتك، سيتم قفل حسابك لمدة %1$d يوم. وبعد %1$d يوم من عدم الاستخدام، تستطيع إعادة التسجيل دون رقم التعريف الشخصي. سيتم حذف حسابك وجميع المحتوى الخاص به. + إذا نفدت محاولاتك، سيتم قفل حسابك لمدة %1$d يومين. وبعد %1$d أيام من عدم الاستخدام، تستطيع إعادة التسجيل دون رقم التعريف الشخصي. سيتم حذف حسابك وجميع المحتوى الخاص به. + إذا نفدت محاولاتك، سيتم قفل حسابك لمدة %1$d أيام. وبعد %1$d أيام من عدم الاستخدام، تستطيع إعادة التسجيل دون رقم التعريف الشخصي. سيتم حذف حسابك وجميع المحتوى الخاص به. + إذا نفدت محاولاتك، سيتم قفل حسابك لمدة %1$d يومًا. وبعد %1$d يوما من عدم الاستخدام، تستطيع إعادة التسجيل دون رقم التعريف الشخصي. سيتم حذف حسابك وجميع المحتوى الخاص به. + إذا نفدت محاولاتك، سيتم قفل حسابك لمدة %1$d يوم. وبعد %1$d يوم من عدم الاستخدام، تستطيع إعادة التسجيل دون رقم التعريف الشخصي. سيتم حذف حسابك وجميع المحتوى الخاص به. لديك %1$d محاولة متبقية. لديك %1$d محاولة متبقية. - لديك %1$d محاولات متبقية. - لديك %1$d محاولات متبقية. + لديك %1$d محاولتين متبقيتين. + لديك %1$d محاولاتٍ متبقية. لديك %1$d محاولة متبقية. لديك %1$d محاولة متبقية. - %1$d محاولة متبقية. - %1$d محاولة متبقية. - %1$d محاولات متبقية. - %1$d محاولات متبقية. + %1$d  محاولة متبقية. + %1$d  محاولة متبقية. + %1$d محاولتين متبقيتين. + %1$d محاولاتٍ متبقية. %1$d محاولة متبقية. %1$d محاولة متبقية. - سيتلقى منك %1$s طلبا للتراسل. يمكنك الاتصال به عندما يقبل طلب مراسلتك. + سيتلقى %1$s منك طلبًا للتراسل. يمكنك الاتصال به عندما يقبل طلب مراسلتك. - إنشاء رقم تعريفي شخصي - الأرقام التعريفية الشخصية تجعل المعلومات المُخزَّنة مع سيجنال مُعمَّاة. - إنشاء رقم تعريفي شخصي + إنشاء رقم تعريفي شخصي (PIN) + أرقام التعريف الشخصية تجعل المعلومات المُخزَّنة في سيجنال مُشفَّرة. + إنشاء رقم تعريف شخصي (PIN) @@ -4726,7 +4716,7 @@ مكالمة فيديو واردة على سيجنال - مكالمة سيجنال واردة من مجموعة + مكالمة جماعية واردة على سيجنال مكالمة صوتية جارية على سيجنال @@ -4735,144 +4725,146 @@ مكالمة جماعية جارية على سيجنال - قيد التحميل… + جارٍ التحميل… جار الرّبط… - منح أحد الأذونات مطلوب + منح الإذن مطلوب استمرار ليس الآن جارٍ نقل قاعدة بيانات سيجنال - العبارة السرّية للنسخة الإحتياطية - سيتم حفظ النسخ الاحتياطية في سعة التخزين الخارجية وستكون مُعمَّاة بالعبارة السرية أدناه. من الضروري حفظ هذه العبارة من أجل استعادة أي نسخة احتياطية. - يجب أن تكون لديك عبارة السر هذه لاستعادة نسخة احتياطية. + العبارة السرية للنسخة الإحتياطية + سيتمُّ حفظ النسخ الاحتياطية في مساحة التخزين الخارجية، وستكون مُشفَّرة بالعبارة السرية أدناه. من الضروري حفظ هذه العبارة من أجل استعادة أي نسخة احتياطية. + يجب أن تكون لديك العبارة السرية هذه لاستعادة نسخة احتياطية. المجلد - قمت بتدوين هذه العبارة السرية. بدونها، لن يمكنني استعادة أي نسخة احتياطية. + قمتُ بتدوين هذه العبارة السرية. بدونها، لن يمكنني استعادة أي نسخة احتياطية. استعادة نسخة احتياطية نقل أو استعادة الحساب + + الاستعادة أو النقل نقل الحساب تخطّي النسخ الاحتياطية للدردشات نقل الحساب - نقل الحساب نحو جهاز أندرويد جديد + نقل الحساب إلى جهاز أندرويد جديد - ادخل العبارة السرية الخاصة بالنسخ الاحتياطية + أدخِل العبارة السرية للنسخة الاحتياطية استعادة لا يمكن استيراد نسخ احتياطية من إصدارات أحدث من سيجنال - يحتوي النسخ الاحتياطي على بيانات مُشوهة - العبارة السرية غير صحيحة - عملية التحقق جارية … - %1$dرسالة حتى الآن… - الاستعادة مِن نسخة احتياطية؟ - استعادة الرسائل والوسائط من نسخة احتياطية محليّة. إن استعادتها الآن، لن يمكنك الاستعادة لاحقا. + تحتوي النسخة الاحتياطية على بيانات فيها خلل. + العبارة السرية للنسخة الاحتياطية غير صحيحة + جارٍ التحقُّق… + %1$d رسالة (رسائل) حتى الآن… + هل ترغب بالاستعادة مِن نسخة احتياطية؟ + استعِد الرسائل والوسائط من نسخة احتياطية محليّة. إذا لم تقم باستعادتها الآن، لن يمكنك الاستعادة لاحقًا. حجم النسخة الإحتياطية: %1$s - موعد النسخة الاحتياطية: %1$s - تفعيل النُّسخ الإحتياطية المحلية؟ - تفعيل النُسخ الإحتياطية - يرجى الإقرار بفهم الخاصية عبر وضع علامة تأكيد في الصندوق. - حذف النسخ الإحتياطية؟ - تعطيل وحذف كافّة النُّسخ الإحتياطية المحلية؟ - حذف النسخ الإحتياطية - لتفعيل النسخ اﻻحتياطية، يُرجى اختيار المجلد. سوف تُخزَّن النسخ اﻻحتياطية في هذا الموقع. - اختر مجلداً - تمّ النسخ إلى الحافظة - إن متصفح الملفات غير متاح. - ادخل رمز أمان النسخة الاحتياطية للتحقق - تحقق - لقد أدخلت العبارة السرية للنسخة الاحتياطية بنجاح + السجل الزمني للنسخ الاحتياطي: %1$s + هل ترغب بتفعيل النسخ الإحتياطية المحلية؟ + تفعيل النسخ الاحتياطية + يُرجى الإقرار بفهم الخاصية عبر وضع علامة تأكيد في الصندوق. + هل ترغبُ بحذف النسخ الإحتياطية؟ + هل ترغبُ بتعطيل وحذف كل النسخ الإحتياطية المحلية؟ + حذف النسخ الاحتياطية + لتفعيل النسخ اﻻحتياطية، اختر مجلد. سوف تُخزَّن النسخ اﻻحتياطية في هذا الموقع. + اختر مجلدًا + تمَّ النسخ إلى الحافظة + مُتصفِّح الملفات غير متاح. + أدخِل العبارة السرية للنسخة الاحتياطية للتحقُّق + تحقُّق + أدخلتَ العبارة السرية للنسخة الاحتياطية بنجاح. العبارة السرية غير صحيحة - ‫يجري إنشاء نسخة سيجنال احتياطية… + ‫جارٍ إنشاء نسخة احتياطية على سيجنال… - ‫جارٍ التحقّق من نسخة سيجنال الاحتياطية… + ‫جارٍ التحقُّق من النسخة الاحتياطية على سيجنال… فشل النسخ الاحتياطي - حُذف أو نُقل مجلد نُسخِك الاحتياطية. - لا يمكن حفظ ملف النسخة الاحتياطية في وحدة التخزين هذه لأنه كبير جدا. + حُذِفَ أو نُقِل مجلد النسخ الاحتياطية لديك. + لا يمكن حفظ ملف النسخة الاحتياطية في وحدة التخزين هذه لأنه كبير جدًا. لم يبق مكان كافٍ لحفظ نسختك الاحتياطية. - تعذّر إنشاء نسختك الاحتياطية الأخيرة والتحقّق منها. يُرجى إنشاء واحدة أخرى. + تعذَّر إنشاء نسختك الاحتياطية الأخيرة والتحقُّق منها. يُرجى إنشاء واحدة أخرى. تحتوي نسختك الاحتياطية على ملف كبير جدًا لا يُمكن نسخه احتياطيًا. يُرجى حذفه وإنشاء نسخة احتياطية جديدة. - يُرجى اللمس لإدارة النسخ الاحتياطية. - رقم غير صحيح؟ + انقر لإدارة النسخ الاحتياطية. + هل هو رقم غير صحيح؟ اتصل بي (%1$02d:%2$02d) إعادة إرسال الرمز (%1$02d:%2$02d) - الاتصال بخدمة دعم سيجنال - تسجيل سيجنال - رمز التّحقق لِ Android - رمز غير صحيح - أبداً - مجهول + الاتصال بقسم الدعم في سيجنال + تسجيل سيجنال - كود التحقُّق لـ Android + كود غير صحيح + أبدًا + غير معروف رقم الهاتف - اختر من يُمكنه رؤية رقم هاتفك ومن يمكنه الاتصال بك على سيجنال باستخدامه. + اختر من يُمكنه رؤية رقم هاتفك ومن يمكنه التواصل معك على سيجنال باستخدامه. من يُمكنه رؤية رقمي سَيكون رقم هاتفك مرئيًا للأشخاص والمجموعات التي تراسلها. - لن يكون رقم هاتفك مرئيًا لأي أحد إلا إذا كان مسجلاً على هاتفه في جهات اتصاله. + لن يكون رقم هاتفك مرئيًا لأي أحد إلّا إذا كان مُسجَّلًا على هاتفه ضمن جهات اتصاله. رقم هاتفك لن يكون مرئيًا لأي أحد. مَن يُمكنه العثور عليَّ باستخدام رقمي - أي شخص لديه رقم هاتفك سَيرى أنك متواجد على سيجنال وسَيتمكن من بدء دردشات معك. + أي شخص لديه رقم هاتفك، سَيرى أنك متواجد على سيجنال وسَيتمكن من بدء دردشات معك. لن يتمكّن أي أحد من رؤيتك على سيجنال إلاّ إذا راسلته أو كانت لديك دردشة موجودة معه. - "لتغيير هذا الإعداد، اختر لا أحد عن تحديد من يُمكنه رؤية رقمي." + "لتغيير هذا الإعداد، اختر \"لا أحد\" عند تعيين \"من يُمكنه رؤية رقمي\"." هل أنت متأكد؟ - يمكن أن يُصعّب اختيار \"لا أحد\" في إعداد \"من يمكنه العثور عليَّ باستخدام رقمي\" على الناس العثور عليك على سيجنال. + يمكن أن يُصعِّب اختيار \"لا أحد\" في إعداد \"من يمكنه إيجادي باستخدام رقمي\" على الناس إيجادك على سيجنال. إلغاء - الكل + الجميع لا أحد قفل الشاشة - منع الوصول إلى سيجنال عبر قفل الشاشة أو بصمة الإصبع - نفاذ مهلة قفل الشاشة + منع الوصول إلى سيجنال عبر قفل الشاشة للأندرويد أو بصمة الإصبع + فترة عدم النشاط لقفل الشاشة - لاستخدام قفل الشاشة يُرجى تعيين رقم تعريف شخصي أو نمط أو كلمة مرور على هذا الجهاز. - رقم سيجنال التعريفي الشخصي - إنشاء رقم تعريفي شخصي - غيِّر رقمك التعريف الشخصي - تذكيرات الرقم التعريفي الشخصي - أطفىء - تأكيد الرقم التعريفي الشخصي - عليك بتأكيد رقمك التعريف الشخصي - يجب عليك التأكد من حفظ رقمك التعريفي الشخصي أو تخزينه في مكان آمن بحيث لا يمكن استعادته. في حال نسيانك الرقم التعريفي الشخصي، قد تضيع البيانات عند إعادة تسجيل حساب سيجنال. - الرقم التعريفي الشخصي غير صحيح. يرجى إعادة المحاولة. - تعذّر تفعيل قفل التسجيل. - تعذّر تعطيل قفل التسجيل. + لاستخدام قفل الشاشة، قُم بتعيين رقم تعريف شخصي (PIN) أو نمط أو كلمة مرور على هذا الجهاز. + رقم التعريف الشخصي على سيجنال + إنشاء رقم تعريف شخصي + غيِّر رقم التعريف الشخصي + تذكيرات رقم التعريف الشخصي + إيقاف + تأكيد رقم التعريف الشخصي + قُم بتأكيد رقم التعريف الشخصي + يجب عليك التأكُّد من حفظ رقم التعريف الشخصي أو تخزينه في مكان آمن، لأنه لا يمكن استعادته في حال فقدانه. في حال نسيانك رقم التعريف الشخصي، قد تخسر البيانات عند إعادة تسجيل حسابك على سيجنال. + رقم التعريف الشخصي (PIN) غير صحيح. يُرجى المحاولة مرّة أخرى. + تعذَّر تفعيل قفل التسجيل. + تعذَّر تعطيل قفل التسجيل. لا شيء - الرقم التعريفي الشخصي لحماية التسجيل - يجب إدخال الرقم التعريفي الشخصي لحماية التسجيل - يحوي رقم تعريفك الشخصي على الأقل %1$d أرقام أو حروف + قفل التسجيل + يجب إدخال رقم التعريف الشخصي لقفل التسجيل + يحوي رقم التعريف الشخصي على %1$d أرقام أو حروف على الأقل. محاولات كثيرة - لقد قمت بمحاولات عديدة لإدخال الرقم التعريفي الشخصي. الرجاء المحاولة مرة أخرى بعد يوم. - قمت بمحاولات كثيرة. يُرجى المحاولة لاحقاً. - عطل في الاتصال بالخدمة - النُسخ الإحتياطية + قمتَ بمحاولاتٍ عديدة لإدخال رقم التعريف الشخصي لقفل الشاشة. يُرجى المحاولة مرّة أخرى بعد يوم. + قمتَ بمحاولاتٍ كثيرة. يُرجى المحاولة مرّة أخرى لاحقًا. + خطأ في الاتصال بالخدمة + النُسخ الاحتياطية - فك قفل سيجنال + فتح قفل سيجنال - استخدم إعدادات قفل جهاز الأندرويد لِفتح قفل سيجنال. + استخدِم إعدادات قفل جهاز الأندرويد لِفتح قفل سيجنال. - قفل الشاشة مُفعل وسيجنال آمن باستخدام إعدادات قفل جهازك. افتح قفل سيجنال بالطريقة نفسها التي تفتح بها قفل جهازك. قد يكون هذا باستخدام فتح القفل بالوجه أو البصمة أو باستخدام رقم تعريف شخصي أو كلمة مرور أو نمط. + قفل الشاشة مُفعَّل وسيجنال مؤمَّن باستخدام إعدادات قفل جهازك. افتح قفل سيجنال بالطريقة نفسها التي تفتح بها قفل جهازك. قد يكون هذا باستخدام فتح القفل بالوجه أو بالبصمة أو باستخدام رقم التعريف الشخصي أو كلمة المرور أو النمط. الاتصال بالدعم - حاول مجددًا + حاوِل مُجدَّدًا - غير مُشغَّل + في حالة إيقاف استخدم قفل الشاشة - سَتكون إعدادات قفل جاهز الأندرويد الخاص بك مطلوبة لفتح قفل سيجنال عند مغادرة أو تبديل التطبيقات. عندما يكون سيجنال مقفولا، لن تُظهر معاينة الإشعارات محتوى الرسالات. + سَتكون إعدادات قفل جاهز الأندرويد الخاص بك مطلوبة لفتح قفل سيجنال عند مغادرة أو تبديل التطبيقات. عندما يكون سيجنال مقفولًا، لن تُظهِر معاينة الإشعارات محتوى الرسائل. ابدأ قفل الشاشة @@ -4884,28 +4876,28 @@ بعد 30 دقيقة - تحديد المُهلة + وقت مُخصَّص - استخدم قفل شاشة سيجنال + استخدِم قفل شاشة سيجنال - أوقف تشغيل قفل شاشة سيجنال + أوقِف تشغيل قفل شاشة سيجنال - مجهول + غير معروف حساب محذوف - إعادة-تسجيل الحساب + إعادة تسجيل الحساب تحديث سيجنال حذف جميع البيانات - حذف جميع البيانات ؟ + هل ترغبُ بحذف جميع البيانات؟ - سَيُؤدِّي هَذَا إلى إعادة تَعيِين التَّطبيق وحَذف رَسائِلك. سيتم إغلاق التطبيق بعد اكتمال هذه العملية. + سَيُؤدِّي هذا إلى إعادة تعيين التطبيق وحذف كل الرسائل. سيتمُّ إغلاق التطبيق بعد اكتمال هذه العملية. - واصل + مواصلة إلغاء @@ -4913,135 +4905,135 @@ نقل أو استعادة الحساب - إذا سبق لك التسجيل بحساب سيجنال، يمكنك نقل أو استعادة حسابك وكذا رسائلك - نقل انطلاقا من جهاز أندرويد - يمكنك نقل حسابك ورسائلك من جهاز أندرويد القديم. إنك بحاجة إلى الوصول إلى جهازك القديم. + إذا سبق لك التسجيل بحساب سيجنال، يمكنك نقل أو استعادة حسابك وكذلك رسائلك. + نقل من جهاز أندرويد + يمكنك نقل حسابك ورسائلك من جهاز أندرويد القديم. أنت بحاجة إلى الوصول إلى جهازك القديم. يلزمك الوصول إلى جهازك القديم. - الاستعادة مِن نسخة احتياطية - استعادة رسائلك من نسخة احتياطية محلية. إذا تجاهلت الاستعادة الآن، فسيتعذر عليك الاستعادة لاحقا. - استعادة نسخ احتياطية محلية - استعادة نسخة سيجنال الاحتياطية - اِستعد جميع رسائلك + والوسائط التي توصلت بها آخر 30 يومًا + استعادة مِن نسخة احتياطية + استعِد رسائلك من نسخة احتياطية محلية. إذا تجاهلت الاستعادة الآن، فسيتعذَّر عليك الاستعادة لاحقًا. + استعادة نسخة احتياطية محلية + استعادة النسخة الاحتياطية على سيجنال + اِستعد جميع رسائلك النصية + والوسائط خلال آخر 30 يومًا خيارات أكثر إلغاء تسجيل الدخول بدون نقل - اِستِمر دُون نقلِ رسائلك والوسائط + اِستِمر دون نقل رسائلك والوسائط - يُرجى فتح سيجنال في هاتف أندرويد القديم - الاِستِمرار + افتح سيجنال في هاتف أندرويد القديم الخاص بك + متابعة 1. - يجب لمس صورة حسابك في الأعلى على اليسار لفتح الإعدادات + انقر على صورة حسابك الشخصي في أعلى الجزء الأيسر لفتح الإعدادات. 2. - "ثم لمس « الحِساب »" + "انقر على \"الحساب\"" 3. - "يُرجى لمس « نقل الحِساب » ثم بعدها « الاستِمرار »" + "انقر على \"نقل الحساب\"، ثم \"متابعة\" على كلا الجهازين." - يجري إعداد الاتصال بجهاز أندرويد الجديد… - يحتاج لبعض الوقت، سيصبح جاهزا قريبا + جارٍ إعداد الاتصال بجهاز أندرويد الجديد لديك… + يحتاج لبعض الوقت… سيصبح جاهزًا قريبًا. بانتظار اتصال جهاز أندرويد القديم… - يحتاج سيجنال إلى إذن الوصول إلى الموقع لاكتشاف ثم الاتصال بجهاز أندرويد القديم. - يحتاج سيجنال إلى أن تكون خدمات الموقع مُشغَّلة، لاكتشاف ثم الاتصال بجهاز أندرويد القديم. - يحتاج سيجنال إلى أن يكون Wi-Fi الجهاز مُشغَّلا، لاكتشاف ثم الاتصال بجهاز أندرويد القديم. يكفي أن يكون Wi-Fi الجهاز مُشغَّلا فقط، إذ لا داعي لأن يكون متصلا بشبكة Wi-Fi. - آسف، يبدو أن جهازك لا يدعم ‫« Wi-Fi المباشر ». يستخدم سيجنال ‫« Wi-Fi المباشر » لاكتشاف جهاز أندرويد القديم ثم الاتصال به. يمكنك دوما استعادة نسخة احتياطية، وذلك لاستعادة حسابك انطلاقا من جهاز أندرويد القديم. + يحتاج سيجنال إلى إذن الوصول إلى الموقع لاكتشاف جهاز أندرويد القديم الخاص بك والاتصال به. + يحتاج سيجنال إلى أن تكون خدمات الموقع مُفعَّلة لكي يكتشف جهاز أندرويد القديم الخاص بك ويتصل به. + يحتاج سيجنال إلى أن يكون الواي فاي مُفعَّلًا لكي يكتشف جهاز أندرويد القديم ويتصل به. يكفي أن يكون الواي فاي مُفعَّلًا فقط، إذ لا داعي لأن يكون متصلًا بشبكة واي فاي. + عُذرًا، يبدو أن هذا الجهاز لا يدعم Wi-Fi Direct. يستخدم سيجنال ‫Wi-Fi Direct لاكتشاف جهاز أندرويد القديم الخاص بك والاتصال به. ما يزال بإمكانك استعادة نسخة احتياطية، وذلك لاستعادة حسابك من جهاز أندرويد القديم الخاص بك. استعادة نسخة احتياطية - حدث خطأ غير متوقع خلال محاولة الاتصال بجهازك أندرويد القديم. + حدث خطأ غير متوقع خلال محاولة الاتصال بجهاز أندرويد القديم الخاص بك. - يجري البحث عن جهاز أندرويد الجديد… - يحتاج سيجنال إلى إذن الوصول إلى الموقع للعثور على جهاز أندرويد الجديد لديك والاتصال به. - يحتاج سيجنال إلى تفعيل خدمات الموقع للعثور على جهاز أندرويد الجديد لديك والاتصال به. - يحتاج سيجنال إلى أن يكون Wi-Fi الجهاز مُشغَّلا، لاكتشاف ثم الاتصال بجهاز أندرويد القديم. يكفي أن يكون Wi-Fi الجهاز مُشغَّلا فقط، إذ لا داعي لأن يكون متصلا بشبكة Wi-Fi. - آسف، يبدو أن جهازك لا يدعم ‫« Wi-Fi المباشر ». يستخدم سيجنال ‫« Wi-Fi المباشر » لاكتشاف جهاز أندرويد الجديد ثم الاتصال به. يمكنك دوما استعادة نسخة احتياطية، وذلك لاستعادة حسابك انطلاقا من جهاز أندرويد الجديد. + جارٍ البحث عن جهاز أندرويد جديد… + يحتاج سيجنال إلى إذن الوصول إلى الموقع لاكتشاف جهاز أندرويد الجديد الخاص بك والاتصال به. + يحتاج سيجنال إلى تفعيل خدمات الموقع لاكتشاف جهاز أندرويد الجديد الخاص بك والاتصال به. + يحتاج سيجنال إلى أن يكون الواي فاي مُشغَّلًا لاكتشاف جهاز أندرويد الجديد الخاص بك والاتصال به. يكفي أن يكون واي فاي الجهاز مُشغَّلًا فقط، إذ لا داعي لأن يكون متصلًا بشبكة واي فاي. + عُذرًا، يبدو أن هذا الجهاز لا يدعم Wi-Fi Direct. يستخدم سيجنال ‫Wi-Fi Direct لاكتشاف جهاز أندرويد الجديد الخاص بك والاتصال به. ما يزال بإمكانك إنشاء نسخة احتياطية، وذلك لاستعادة حسابك على جهاز أندرويد الجديد الخاص بك. إنشاء نسخة احتياطية - حدث خطأ غير متوقع خلال محاولة الاتصال بجهازك أندرويد الجديد. + حدث خطأ غير متوقع خلال محاولة الاتصال بجهاز أندرويد الجديد الخاص بك. - تعذر فتح إعدادات Wi-Fi. يُرجى تشغيل Wi-Fi يدويا. + تعذَّر فتح إعدادات الواي فاي. يُرجى تشغيل الواي فاي يدويًا. - منح إذن الوصول للموقع + منح إذن الوصول إلى الموقع تشغيل خدمات الموقع - شغِّل Wi-Fi - حدث خطأ في الاتصال بالشبكة + تشغيل الواي فاي + خطأ في الاتصال إعادة المُحاولة - إرسال سِجل التصحيح - التحقق من الرمز - يُرجى التحقق من أن الرمز أسفله هو نفسه الذي يظهر في كلا جهازيك. بعدها يُرجى لمس الاستمرار. - الأعداد غير متوافقة - الاِستِمرار - إن لم يظهر لك نفس العددين في كلا جهازيك، فمن المحتمل أنك اتصلت بالجهاز الخطأ. لإصلاح ذلك، يُرجى إيقاف النقل والمحاولة مرة أخرى، مع إبقاء جهازيك قريبين من بعضهما. + إرسال سجلات التصحيح + التحقُّق من الكود + يُرجى التحقُّق من أن الكود في الأسفل هو نفسه الذي يظهر في كلا جهازيك. بعدها انقر على \"متابعة\". + الأعداد غير متطابقة + متابعة + إن لم يظهر لك نفس العددين في كلا جهازيك، فمن المحتمل أنك اتصلت بالجهاز الخطأ. لإصلاح ذلك، يُرجى إيقاف النقل والمحاولة مرّة أخرى، مع إبقاء جهازيك قريبين من بعضهما. إيقاف النقل - تعذر العثور على الجهاز القديم - تعذر العثور على الجهاز الجديد - يُرجى التحقق من أن التصريحات والخدمات التالية مُفعَّلة : - إذن الوصول للموقع + تعذَّر اكتشاف الجهاز القديم + تعذَّر اكتشاف الجهاز الجديد + يُرجى التحقُّق من أن الأُذونات والخدمات التالية مُفعَّلة: + إذن الوصول إلى الموقع خدمات الموقع - شبكة Wi-Fi - في شاشة \"WiFi المباشر\"، يُرجى إزالة كل المجموعات المحفوظة ثم فك الارتباط مع أي جهاز سبق دعوته أو الاتصال به. - شاشة «‫ WiFi المباشر » - يُرجى محاولة إطفاء ثم تشغيل Wi-Fi في كلا الجهازين. - يُرجى التأكد من أن كلا الجهازين في وضع النقل. - زر صفحة الدعم - حاول مجدداً - في انتظار الجهاز آخر - يُرجى لمس الاستمرار في جهازك الآخر لبدء النقل. - يُرجى لمس الاستمرار في جهازك الآخر… + الواي فاي + على شاشة WiFi Direct، قُم بإزالة كل المجموعات المحفوظة، ثم أزِل الارتباط مع أي أجهزة سبق دعوتها أو اتصلتَ بها. + شاشة WiFi Direct + حاوِل إيقاف الواي فاي ثم تشغيلها في كلا الجهازين. + تأكَّد من أن كلا الجهازين في وضع النقل. + انتقل إلى صفحة الدعم + حاوِل مُجدَّدًا + في انتظار الجهاز الآخر + انقر على \"متابعة\" في جهازك الآخر لبدء النقل. + انقر على \"متابعة\" في جهازك الآخر… - لم يتمكن من النقل انطلاقا من إصدارات سيجنال الأحدث + لم يتمكن من النقل من إصدار أحدث من سيجنال - تشوّهت البيانات المنقولة + حصَل خلَل في البيانات المنقولة. - ينقل البيانات - يُرجى إبقاء الجهازين بالقرب من بعضهما البعض. يجب عدم إيقاف تشغيل أي منهما مع المحافظة على سيجنال مفتوحا. إن عمليات النقل مُعمَّاة من طرف لطرف. - %1$d رسالة حتى الآن… + جارٍ نقل البيانات + أبقِ الجهازين بالقرب من بعضهما البعض. لا توقِف تشغيل أي منهما، وحافِظ على سيجنال مفتوحًا. عمليات النقل مُشفَّرة من طرفٍ لطرف. + %1$d رسالة (رسائل) حتى الآن… ‫%1$s%% من الرسائل لحد الآن… إلغاء - حاول مجدداً + حاوِل مُجدَّدًا إيقاف النقل - سوف تضيع جميع عمليات النقل. + سوف يضيع كل التقدُّم الذي تمَّ تحقيقه. فشل النقل - تعذر النقل + تعذَّر النقل نقل الحساب 1. - يُرجى تنزيل سيجنال في جهاز أندرويد الجديد + قُم بتنزيل سيجنال في جهاز أندرويد الجديد الخاص بك 2. - "بعدها، يكفي لمس « نقل أو استعادة الحساب »" + "انقر على \"نقل أو استعادة الحساب\"" 3. - "يُرجى تحديد « نقل انطلاقا من جهاز أندرويد » عند ظهورها، ثم « الاستمرار ». يُرجى جعل الجهازين قريبين من بعضهما." - الاِستِمرار + "قُم بتحديد \"نقل من جهاز أندرويد\" عند ظهورها، ثم انقر على \"متابعة\". يُرجى إبقاء الجهازين قريبين من بعضهما." + متابعة - يُرجى العودة إلى جهازك الجديد - لقد نُقلَت ‫بياناتك بسيجنال إلى جهازك الجديد. لإتمام عملية النقل، يجب عليك القيام بالتسجيل في جهازك الجديد. + انتقل إلى جهازك الجديد + نُقِلَت ‫بياناتك بسيجنال إلى جهازك الجديد. لإتمام عملية النقل، يجب عليك متابعة التسجيل في جهازك الجديد. إغلاق - لقد نجح النقل - انتهى النقل - لإتمام عملية النقل، يجب عليك القيام بالتسجيل. - الاستمرار في التسجيل + نجحَ النقل + اكتمَلَ النقل + لإتمام عملية النقل، يجب عليك متابعة التسجيل. + متابعة التسجيل نقل الحساب - يجري إعداد اتصالك بجهاز أندرويد الآخر… - يجري إعداد اتصالك بجهاز أندرويد الآخر… - يبحث لك عن جهاز أندرويد الآخر… - يجري اتصالك بجهاز أندرويد الآخر… - التحقق ضروري - ينقل الحساب… + جارٍ إعداد الاتصال بجهاز أندرويد الآخر لديك… + جارٍ إعداد الاتصال بجهاز أندرويد الآخر لديك… + جارٍ البحث عن جهاز أندرويد الآخر لديك… + جارٍ الاتصال بجهاز أندرويد الآخر لديك… + التحقُّق مطلوب + جارٍ نقل الحساب… - إتمام التسجيل في جهازك الجديد - لقد نُقل حسابك بسيجنال إلى جهازك الجديد، لكن يجب عليك إتمام التسجيل فيه للاستمرار. سيصبح سيجنال غير نشط في هذا الجهاز. - تمّ + أكمِل التسجيل على جهازك الجديد + نُقِلَ حسابك بسيجنال إلى جهازك الجديد، لكن يجب عليك إكمال التسجيل فيه للاستمرار. سيصبح سيجنال غير نشِط على هذا الجهاز. + تم إلغاء ثم تفعيل هذا الجهاز @@ -5049,48 +5041,48 @@ حظر رفع الحظر - أضف إلى جهات الاتصال + إضافة إلى جهات الاتصال - لقد تعذر إيجاد تطبيق يستطيع فتح جهات الاتصال. + تعذَّر إيجاد تطبيق يستطيع فتح جهات الاتصال. إضافة إلى مجموعة إضافة إلى مجموعة أخرى إظهار رقم الأمان - اجعله مشرفا - أزل عنه ميزة الإشراف - أزل من المجموعة + اجعله مشرفًا + أزِل عنه ميزة الإشراف + إزالة من المجموعة - إزالة ميزة الإشراف على المجموعة عن %1$s؟ - "سيتمكن العضو \"%1$s\" من تحرير بيانات هذه المجموعة وأيضا تعديل المنخرطين فيها." + هل ترغب بإزالة ميزة الإشراف على المجموعة عن %1$s؟ + "سيتمكن العضو %1$s من تعديل هذه المجموعة وأيضًا تعديل صلاحيات الأعضاء فيها." - إزالة %1$s من المجموعة؟ + هل ترغبُ بإزالة %1$s من المجموعة؟ - هل أزيل المستخدم %1$s من المجموعة؟ لن يتمكن من إعادة الانضمام عبر وصلة المجموعة. + هل ترغبُ بإزالة المستخدم %1$s من المجموعة؟ لن يتمكن من إعادة الانضمام عبر رابط المجموعة. إزالة - تمّ النسخ إلى الحافظة + تمَّ النسخ إلى الحافظة - مشرف + مُشرِف - الموافقة + موافقة رفض - المجموعات من الطراز القديم مقارنة بالمجموعات الجديدة - ماهي المجموعات من الطراز القديم؟ - إن المجموعات من الطراز القديم هي المجموعات غير المتوائمة مع الميزات الجديدة الموجودة في الطراز الجديد مثل خاصية المشرفين وتحديثات وصف المجموعة. - أيمكنني ترقية المجموعات من الطراز القديم ؟ - المجموعات من الطراز القديم لا يمكن ترقيتها إلى مجموعة جديدة، ولكن يمكنك إنشاء مجموعة جديدة بنفس الأعضاء ولكن من الطراز الجديد، بشرط استخدام الجميع آخر إصدار لـ سيجنال. - سيوفر سيجنال طريقة لترقية المجموعات من الطراز القديم مستقبلا. + المجموعات بنمط سابق مقارنةً بالمجموعات الجديدة + ماهي المجموعات ذات النمط السابق؟ + المجموعات ذات النمط السابق هي المجموعات غير المتوافقة مع ميزات المجموعات الجديدة مثل خاصية المشرفين وتحديثات وصف المجموعة. + هل يمكنني ترقية المجموعات ذات النمط السابق؟ + المجموعات ذات النمط السابق لا يمكن ترقيتها إلى مجموعة جديدة، ولكن يمكنك إنشاء مجموعة بنمط جديد بنفس الأعضاء، بشرط استخدام الجميع آخر إصدار لسيجنال. + سيوفر سيجنال طريقة لترقية المجموعات ذات النمط السابق مستقبلًا. - يمكن لكل من لديه هذه الوصلة أن يعرض اسم المجموعة وصورتها وكذا طلب الانضمام إليها. يكفيك مشاركتها مع من لديك فيهم الثقة. - يمكن لأي شخص لديه هذه الوصلة أن يعرض اسم المجموعة وصورتها وكذا طلب الانضمام إليها. يكفيك مشاركتها مع من لديك فيهم الثقة. - المشاركة عبر سيجنال + يمكن لكل من لديه هذا الرابط أن يرى اسم المجموعة وصورتها، وكذلك طلب الانضمام إليها. شارِكه فقط مع من تثق به. + يمكن لأي شخص لديه هذا الرابط أن يرى اسم المجموعة وصورتها وكذلك الانضمام إليها. شارِكه فقط مع من تثق به. + مشاركة عبر سيجنال نسخ - الرمز المربع - المشاركة - تم النسخ إلى الحافظة - هذه الوصلة غير مُفعَّلة حاليا + كود الـ QR + مشاركة + تمَّ النسخ إلى الحافظة + هذا الرابط غير مُفعَّل حاليًا. فشل تشغيل الرسالة الصوتية @@ -5102,130 +5094,130 @@ %1$s/%2$s - تم حظر \"%1$s\". + تمَّ حظر \"%1$s\". فشل حظر \"%1$s\" - تم إلغاء حظر \"%1$s\". + تمَّ إلغاء الحظر عن \"%1$s\". مراجعة الأعضاء - مُراجعة الطلب + مراجعة الطلب - %1$d عضو بالمجموعة يحمل نفس الاسم، يُرجى مراجعة الأعضاء أسفله أو اتخاذ إجراء. - %1$d عضو بالمجموعة يحمل نفس الاسم، يُرجى مراجعة العضو أسفله واتخاذ إجراء. - %1$d عضوان بالمجموعة يحملان نفس الاسم، يُرجى مراجعة الأعضاء أسفله واتخاذ إجراء. - %1$d أعضاء بالمجموعة يحملون نفس الاسم، يُرجى مراجعة الأعضاء أسفله واتخاذ إجراء. - %1$d عضوا بالمجموعة يحملون نفس الاسم، يُرجى مراجعة الأعضاء أسفله واتخاذ إجراء. - %1$d عضوا بالمجموعة يحملون نفس الاسم، يُرجى مراجعة الأعضاء أسفله واتخاذ إجراء + %1$d عضو بالمجموعة يحمل نفس الاسم، يُرجى مراجعة الأعضاء في الأسفل واتخاذ إجراء. + %1$d عضو بالمجموعة يحمل نفس الاسم، يُرجى مراجعة العضو في الأسفل واتخاذ إجراء. + %1$d عضوان بالمجموعة يحملان نفس الاسم، يُرجى مراجعة الأعضاء في الأسفل واتخاذ إجراء. + %1$d أعضاء بالمجموعة يحملون نفس الاسم، يُرجى مراجعة الأعضاء في الأسفل واتخاذ إجراء. + %1$d عضوًا بالمجموعة يحملون نفس الاسم، يُرجى مراجعة الأعضاء في الأسفل واتخاذ إجراء. + %1$d عضوٍ بالمجموعة يحملون نفس الاسم، يُرجى مراجعة الأعضاء في الأسفل واتخاذ إجراء. - إذا التبس عليك مصدر الطلب، يُرجى مُراجعة جهات الاتصال أدناه والقيام بالإجراء المناسب. - إذا التبس عليك مصدر الطلب، يُرجى مراجعة جهة الاتصال أدناه والقيام بالإجراء المناسب. - إذا التبس عليك مصدر الطلب، يُرجى مراجعة جهتي الاتصال أدناه والقيام بالإجراء المناسب. - إذا التبس عليك مصدر الطلب، يُرجى مراجعة جهات الاتصال أدناه والقيام بالإجراء المناسب. - إذا التبس عليك مصدر الطلب، يُرجى مراجعة جهات الاتصال أدناه والقيام بالإجراء المناسب. - إذا التبس عليك مصدر الطلب، يُرجى مراجعة جهات الاتصال أدناه والقيام بالإجراء المناسب. + إذا لم تكن متأكِّدًا من مصدر الطلب، يُرجى مُراجعة جهات الاتصال أدناه والقيام بالإجراء المناسب. + إذا لم تكن متأكِّدًا من مصدر الطلب، يُرجى مراجعة جهة الاتصال أدناه والقيام بالإجراء المناسب. + إذا لم تكن متأكِّدًا من مصدر الطلب، يُرجى مراجعة جهتي الاتصال أدناه والقيام بالإجراء المناسب. + إذا لم تكن متأكِّدًا من مصدر الطلب، يُرجى مراجعة جهات الاتصال أدناه والقيام بالإجراء المناسب. + إذا لم تكن متأكِّدًا من مصدر الطلب، يُرجى مراجعة جهات الاتصال أدناه والقيام بالإجراء المناسب. + إذا لم تكن متأكِّدًا من مصدر الطلب، يُرجى مراجعة جهات الاتصال أدناه والقيام بالإجراء المناسب. لا وجود لمجموعات أخرى مشتركة. لا وجود لمجموعات مشتركة. %1$d مجموعة مشتركة - %1$dمجموعة مشتركة - %1$d مجموعات مشتركة + %1$d مجموعة مشتركة + %1$d مجموعتان مشتركتان %1$d مجموعات مشتركة - %1$d مجموعة مشتركة + %1$d مجموعةً مشتركة %1$d مجموعة مشتركة %1$d مجموعة مشتركة %1$d مجموعة مشتركة - %1$d مجموعات مشتركة - %1$dمجموعات مشتركة - %1$d مجموعة مشتركة + %1$d مجموعتان مشتركتان + %1$d مجموعاتٍ مشتركة + %1$d مجموعةً مشتركة %1$d مجموعة مشتركة - إزالة %1$s من هذه المجموعة؟ + هل ترغبُ بإزالة %1$s من هذه المجموعة؟ إزالة فشلت إزالة عضو من المجموعة. الطلب جهة اتصالك - أزل من المجموعة + إزالة من المجموعة تحديث جهة الاتصال حظر حذف غيّر مؤخرًا %1$s اسم حسابه الشخصي من %2$s إلى %3$s - %1$s موجود في جهات اتصال هاتفك + %1$s موجود(ة) في جهات اتصال هاتفك انضم %1$s - انضم %1$s و %2$s - انضم %1$s، %2$s و%3$s + انضم %1$s و%2$s + انضم %1$s، و%2$s، و%3$s انضمّ %1$s و%2$s و%3$d شخص آخر انضمّ %1$s و%2$s و%3$d شخص آخر - انضمّ %1$s و%2$s و%3$d شخصين آخرين - انضمّ %1$s و%2$s و%3$d أشخاص آخرين - انضمّ %1$s و%2$s و%3$d شخص آخر - انضم %1$s، %2$s و%3$d شخص آخر + انضمّ %1$s و%2$s و%3$d شخصان آخران + انضمّ %1$s و%2$s و%3$d أشخاصٍ آخرين + انضمّ %1$s و%2$s و%3$d شخصًا آخر + انضم %1$s، %2$s و%3$d شخصٍ آخر - غادر العضو %1$s - غادر %1$s و %2$s - غادر %1$s، %2$s و%3$s + غادرَ %1$s + غادرَ %1$s و%2$s + غادرَ %1$s، و%2$s، و%3$s - غادر %1$s و%2$s و%3$d آخر - غادر %1$s و%2$s و%3$d شخص آخر - غادر %1$s و%2$s و%3$d شخصين آخرين - غادر %1$s و%2$s و%3$d أشخاص آخرين - غادر %1$s و%2$s و%3$d شخصا آخر - غادر %1$s، %2$s و%3$d شخص آخر + غادرَ %1$s و%2$s و%3$d آخر + غادرَ %1$s و%2$s و%3$d شخص آخر + غادرَ %1$s و%2$s و%3$d شخصين آخرين + غادرَ %1$s و%2$s و%3$d أشخاصٍ آخرين + غادرَ %1$s و%2$s و%3$d شخصًا آخر + غادر %1$s، %2$s و%3$d شخصٍ آخر أنت - أنت (في جهاز آخر) - %1$s (في جهاز آخر) + أنت (على جهاز آخر) + %1$s (على جهاز آخر) - شبكة Wi-Fi ضعيفة. تم التبديل إلى شبكة خلوية. + شبكة الواي فاي ضعيفة. تمَّ التبديل إلى شبكة الجوّال. سيؤدي حذف حسابك إلى: - يُرجى إدخال رقم هاتفك + قُم بإدخال رقم هاتفك حذف الحساب حذف معلومات حسابك وصورة حسابك الشخصي حذف كل رسائلك - حذف %1$s في حساب دفوعاتك. - لم يُحدَّد رمز البلد + حذف %1$s في حساب مدفوعاتك. + لم يُحدَّد رمز الدولة لم يُحدَّد أي رقم - رقم الهاتف الذي أُدخِل لا يطابق رقم حسابك. + رقم الهاتف الذي أدخلت لا يطابق رقم حسابك. هل أنت متأكد من أنك تريد حذف حسابك؟ - سَيؤدي هذا إلى حذف حساب سيجنال الخاص بك وإعادة تعيين التطبيق. سيتم إغلاق التطبيق بعد انتهاء العملية. - فشل حذف البيانات المحلية. يمكنك مَحْوه يدويًا في إعدادات تطبيق النظام. - إعدادات تشغيل التطبيق + سَيؤدي هذا إلى حذف حساب سيجنال الخاص بك وإعادة تعيين التطبيق. سيتمُّ إغلاق التطبيق بعد انتهاء العملية. + فشل حذف البيانات المحلية. يمكنك مَحْوها يدويًا في إعدادات تطبيق النظام. + فتح إعدادات التطبيق - يَجري مغادرة المجموعات… + جارٍ مغادرة المجموعات… - يَجري حَذفُ الحِساب… + جارٍ حذف الحساب… جارٍ إلغاء اشتراكك… - قد يتطلب هذا بضع دقائق، بناءا على عدد المجموعات التي لديك عضوية فيها. + قد يتطلب هذا بضع دقائق، بناءًا على عدد المجموعات التي لديك عضوية فيها. يجري حذف بيانات المستخدم ثم سيُعاد إعداد التطبيق. - لم يتم حذف الحِساب + لم يتم حذف الحساب - لقد حدث خطأ أثناء إتمام عملية الحذف. يُرجى التحقق من اتصالك بالشبكة ثم إعادة المحاولة مرة أخرى. + حدثت مشكلة أثناء إتمام عملية الحذف. يُرجى التحقُّق من اتصالك بالشبكة ثم إعادة المحاولة مرّة أخرى. البحث في البلدان @@ -5237,21 +5229,21 @@ عضو واحد %1$d عضوان %1$d %1$d أعضاء - %1$d عضوا + %1$d عضوًا %1$d عضو مشاركة - أرسلْ + إرسال ، %1$s - تعذّر مشاركة البيانات. + تعذَّر الحصول على بيانات المشاركة من وسيلة التواصل. - فشل اﻹرسال إلى بعض المستخدمين. - يُمكنك فقط مشاركة ما لا يزيد عن %1$d دردشات. + فشل اﻹرسال إلى بعض المُستخدِمين. + يُمكنك فقط مشاركة ما لا يزيد عن %1$d دردشة (دردشات). @@ -5259,33 +5251,33 @@ لون الدردشة إعادة تعيين ألوان الدردشة إعادة تعيين لون الدردشة - إعادة تعيين لون الدردشة؟ - ضبط الخلفية - إن الوضع الداكن يعتم الخلفية + أترغبُ بإعادة تعيين لون الدردشة؟ + إعادة تعيين الخلفية + الوضع الداكن يعتم الخلفية اسم جهة الاتصال إعادة التعيين معاينة الخلفية - هل لديك الرغبة في إلغاء جميع ألوان الدردشات؟ - أ لديك الرغبة في إلغاء جميع ألوان المحادثات ؟ + هل لديك الرغبة في تجاهل جميع ألوان الدردشات؟ + هل لديك الرغبة في تجاهل جميع الخلفيات؟ إعادة تعيين الألوان الافتراضية إعادة تعيين كل الألوان إعادة تعيين الخلفية الافتراضية - إعادة التعيين كل الخلفيات + إعادة تعيين كل الخلفيات إعادة تعيين الخلفيات إعادة تعيين الخلفية - أأعيد تعيين الخلفية ؟ + هل ترغبُ بإعادة تعيين الخلفية؟ - الاختيار من الصور + اختر من الصور الإعدادات الأولية - مُعاينة - تعيين خلفية - يُرجى السحب لمعاينة بقية الخلفيات - ضبط الخلفية لجميع الدردشات - ضبط الخلفية لـ %1$s - إن إظهار معرض صورك يتطلب إذن الوصول إلى سعة التخزين. + معاينة + تعيين الخلفية + اسحب لمعاينة المزيد من الخلفيات + تعيين الخلفية لجميع الدردشات + تعيين الخلفية لـ %1$s + مشاهدة معرض الصور لديك يتطلب إذن الوصول إلى مساحة التخزين. @@ -5293,26 +5285,26 @@ وسِّع للتكبير، اسحب للتعديل. تعيين خلفية لجميع الدردشات. ضبط الخلفية لـ %1$s.‏ - حدث خطأ خلال ضبط الخلفية. - تضبيب الصورة + حدث خطأ خلال تعيين الخلفية. + تمويه الصورة - حَول MobileCoin - إن MobileCoin عُملة رقمية جديدة ترتكز على الخصوصية. + حول MobileCoin + MobileCoin هي عملة رقمية جديدة ترتكز على الخصوصية. إضافة مبالغ - يمكنك إضافة المبالغ لاستخدامها في سيجنال عبر إرسال MobileCoin إلى عنوان محفظتك. + يمكنك إضافة المبالغ لاستخدامها في سيجنال عبر إرسال عملات MobileCoin إلى عنوان محفظتك. صرف المبالغ - يمكنك صرف Mobilecoin في أي وقت في منصة التداول التي تدعم Mobilecoin. يكفي فقط إرسال مبالغ إلى حسابك في تلك المنصة. - إخفاء هذه البطاقة ؟ + يمكنك صرف عملات Mobilecoin في أي وقت على أي منصة تداول تدعم عملات Mobilecoin. يكفي فقط إرسال مبلغ إلى حسابك في تلك المنصة. + هل ترغب بإخفاء هذه البطاقة؟ إخفاء - حفظ جملة الاستعادة - توفر لك جملة الاستعادة وسيلة أخرى لاستعادة حساب دفوعاتك. + حفظ عبارة الاستعادة + توفر لك عبارة الاستعادة وسيلة أخرى لاستعادة حساب مدفوعاتك. حفظ جملتك - تحديث رقمك التعريفي الشخصي - قد تكون لديك الرغبة في زيادة حماية حسابك عبر تحديث رقمك التعريفي الشخصي ليكون أبجَدرَقميا، بالذات عند توفرك على رصيد كبير من العملة الرقمية. - تحديث الرقم التعريفي الشخصي + تحديث رقم التعريف الشخصي (PIN) + عند توفُّر رصيد كبير لديك، قد تكون لديك الرغبة في زيادة حماية حسابك عبر تحديث رقم التعريف الشخصي إلى أرقام وحروف. + تحديث رقم التعريف الشخصي (PIN) @@ -5321,102 +5313,102 @@ تعطيل المحفظة رصيدك - يُوصَى بإرسال رصيدك المالي إلى عنوان محفظة أخرى قبل تعطيل عمليات الدفع. إذا اخترت عدم إرسال رصيدك المالي الآن، ستبقى في محفظتك مرتبطة بـ سيجنال إذا أعدت تفعيل الدفوعات. + يُوصَى بإرسال رصيدك المالي إلى عنوان محفظة أخرى قبل تعطيل عمليات الدفع. إذا اخترت عدم إرسال رصيدك المالي الآن، سيبقى في محفظتك المرتبطة بسيجنال إذا أعدت تفعيل المدفوعات. إرسال الرصيد المتبقي تعطيل بدون أي إرسال تعطيل - تعطيل بدون أي إرسال ؟ - سيبقى رصيدك في محفظتك المرتبطة بـ سيجنال إذا اخترت إعادة تفعيل عمليات الدفع. + هل ترغب بالتعطيل بدون أي إرسال؟ + سيبقى رصيدك في محفظتك المرتبطة بـسيجنال إذا اخترتَ إعادة تفعيل عمليات الدفع. حدث خطأ خلال تعطيل المحفظة. - جملة الاستعادة - عرض جملة الاستعادة + عبارة الاستعادة + عرض عبارة الاستعادة - حفظ جملة الاستعادة - إدخال جملة الاستعادة + حفظ عبارة الاستعادة + إدخال عبارة الاستعادة - سَتستعيد رصيدك تلقائيًا فور الانتهاء من إعادة تثبيت سيجنال، وذلك إذا قمت بتأكيد ال‫رقم التعريفي الشخصي‬ لسيجنال. يمكنك أيضًا استعادة حسابك باستخدام جملة الاستعادة، والتي تتكون من %1$d كلمة فريدة خاصك بك فقط. يُرجى كتابتها ثم حفظها في مكان آمن. - سَتستعيد رصيدك تلقائيًا فور الانتهاء من إعادة تثبيت سيجنال، وذلك إذا قمت بتأكيد ال‫رقم التعريفي الشخصي‬ لسيجنال. يمكنك أيضًا استعادة حسابك باستخدام جملة الاستعادة، والتي تتكون من %1$d كلمة فريدة خاصك بك فقط. يُرجى كتابتها ثم حفظها في مكان آمن. - سَتستعيد رصيدك تلقائيًا فور الانتهاء من إعادة تثبيت سيجنال، وذلك إذا قمت بتأكيد ال‫رقم التعريفي الشخصي‬ لسيجنال. يمكنك أيضًا استعادة حسابك باستخدام جملة الاستعادة، والتي تتكون من %1$d كلمات فريدة خاصك بك فقط. يُرجى كتابتها ثم حفظها في مكان آمن. - سَتستعيد رصيدك تلقائيا فور الانتهاء من إعادة تثبيت سيجنال، وذلك إذا قمت بتأكيد ال‫رقم التعريفي الشخصي‬ لسيجنال. يمكنك أيضًا استعادة حسابك باستخدام جملة الاستعادة، والتي تتكون من %1$d كلمات فريدة خاصك بك فقط. يُرجى كتابتها ثم حفظها في مكان آمن. - سَتستعيد رصيدك تلقائيًا فور الانتهاء من إعادة تثبيت سيجنال، وذلك إذا قمت بتأكيد ال‫رقم التعريفي الشخصي‬ لسيجنال. يمكنك أيضًا استعادة حسابك باستخدام جملة الاستعادة، والتي تتكون من %1$d كلمة فريدة خاصك بك فقط. يُرجى كتابتها ثم حفظها في مكان آمن. - سَتستعيد رصيدك تلقائيًا فور الانتهاء من إعادة تثبيت سيجنال، وذلك إذا قمت بتأكيد ال‫رقم التعريفي الشخصي‬ لسيجنال. يمكنك أيضًا استعادة حسابك باستخدام جملة الاستعادة، والتي تتكون من %1$d كلمة فريدة خاصك بك فقط. يُرجى كتابتها ثم حفظها في مكان آمن. + سَتستعيد رصيدك تلقائيًا فور الانتهاء من إعادة تثبيت سيجنال، وذلك إذا قمت بتأكيد رقم التعريف الشخصي‬ على سيجنال. يمكنك أيضًا استعادة حسابك باستخدام عبارة الاستعادة، والتي تتكون من %1$d كلمة فريدة خاصك بك فقط. يُرجى كتابتها ثم حفظها في مكانٍ آمن. + سَتستعيد رصيدك تلقائيًا فور الانتهاء من إعادة تثبيت سيجنال، وذلك إذا قمت بتأكيد رقم التعريف الشخصي‬ على سيجنال. يمكنك أيضًا استعادة حسابك باستخدام عبارة الاستعادة، والتي تتكون من %1$d كلمة فريدة خاصك بك فقط. يُرجى كتابتها ثم حفظها في مكانٍ آمن. + سَتستعيد رصيدك تلقائيًا فور الانتهاء من إعادة تثبيت سيجنال، وذلك إذا قمت بتأكيد رقم التعريف الشخصي‬ على سيجنال. يمكنك أيضًا استعادة حسابك باستخدام عبارة الاستعادة، والتي تتكون من %1$d كلمات فريدة خاصك بك فقط. يُرجى كتابتها ثم حفظها في مكانٍ آمن. + سَتستعيد رصيدك تلقائيا فور الانتهاء من إعادة تثبيت سيجنال، وذلك إذا قمت بتأكيد رقم التعريف الشخصي‬ على سيجنال. يمكنك أيضًا استعادة حسابك باستخدام عبارة الاستعادة، والتي تتكون من %1$d كلمات فريدة خاصك بك فقط. يُرجى كتابتها ثم حفظها في مكانٍ آمن. + سَتستعيد رصيدك تلقائيًا فور الانتهاء من إعادة تثبيت سيجنال، وذلك إذا قمت بتأكيد رقم التعريف الشخصي‬ على سيجنال. يمكنك أيضًا استعادة حسابك باستخدام عبارة الاستعادة، والتي تتكون من %1$d كلمة فريدة خاصك بك فقط. يُرجى كتابتها ثم حفظها في مكانٍ آمن. + سَتستعيد رصيدك تلقائيًا فور الانتهاء من إعادة تثبيت سيجنال، وذلك إذا قمت بتأكيد رقم التعريف الشخصي‬ على سيجنال. يمكنك أيضًا استعادة حسابك باستخدام عبارة الاستعادة، والتي تتكون من %1$d كلمة فريدة خاصك بك فقط. يُرجى كتابتها ثم حفظها في مكانٍ آمن. - لديك رصيد! حان وقت حفظ جملة الاستعادة الخاصة بك—مفتاح من 24 كلمة يُمكنك استخدامه لاستعادة رصيدك. + لديك رصيد! حان وقت حفظ عبارة الاستعادة الخاصة بك—مفتاح من 24 كلمة يُمكنك استخدامه لاستعادة رصيدك. - حان وقت حفظ جملة الاستعادة الخاصة بك—مفتاح من 24 كلمة يُمكنك استخدامه لاستعادة رصيدك. - إن جملة الاستعادة هي جملة مكونة من %1$d كلمة فريدة خاصة بك فقط. يُرجى استخدامها لاستعادة حساب دفوعاتك. - البدأ - إدخال يدويا + حان وقت حفظ عبارة الاستعادة الخاصة بك—مفتاح من 24 كلمة يُمكنك استخدامه لاستعادة رصيدك. + عبارة الاستعادة هي عبارة مُكوَّنة من %1$d كلمة فريدة خاصة بك فقط. يُرجى استخدامها لاستعادة رصيدك. + ابدأ + إدخال يدويًا لصق من الحافظة - متابعة بدون حفظ؟ + هل ترغبُ بالمتابعة بدون حفظ؟ - تُتيح لك جملة الاستعادة الخاصة بك اِسترجاع رصيدك في حالة حدوث أمر ما. نوصي بشدة بحفظها. + تُتيح لك عبارة الاستعادة الخاصة بك استعادة رصيدك في حالة حدوث أمر ما. نوصي بشدة بحفظها. - تَخَطَّ جملة الاستعادة + تَخَطَّ عبارة الاستعادة إلغاء - لصق جملة الاستعادة - جملة الاستعادة + لصق عبارة الاستعادة + عبارة الاستعادة التالي - جملة الاستعادة باطلة - يُرجى التأكد من إدخالك الـ %1$d كلمات ثم المحاولة مرة أخرى. + عبارة الاستعادة غير صحيحة + تأكَّد من إدخالك الـ %1$d كلمات ثم حاوِل مرّة أخرى. التالي - تحرير - جملة الاستعادة التي تخصك - يٌرجى كتابة الكلمات الـ %1$d بالترتيب. كما يجب حفظ لائحتك في مكان آمن. - يٌرجى التأكد من إدخالك الجملة بشكل صحيح. - عدم أخذ لقطة الشاشة ولا إرسالها بالبريد الإلكتروني. - استُعيد حساب الدفوعات. - جملة الاستعادة باطلة - يٌرجى التأكد من إدخالك الجملة بشكل صحيح ثم المحاولة مرة أخرى. - نسخ إلى الحافظة ؟ - إذا اخترت حفظ جملة الاستعادة رقميا، يٌرجى التأكد من حفظها في مكان آمن وموثوق به. + تعديل + عبارة الاستعادة الخاصة بك + اكتب الكلمات الـ %1$d التالية بالترتيب. احفظ لائحتك في مكانٍ آمن. + يُرجى التأكُّد من إدخال العبارة بشكلٍ صحيح. + لا تأخذ لقطة الشاشة ولا ترسل بالبريد الإلكتروني. + تمَّت استعادة حساب المدفوعات. + عبارة الاستعادة غير صحيحة + تأكَّد من إدخالك العبارة بشكلٍ صحيح ثم حاوِل مرّة أخرى. + هل ترغبُ بالنسخ إلى الحافظة؟ + إذا اخترتَ حفظ عبارة الاستعادة رقميًا، يُرجى التأكُّد من حفظها في مكانٍ آمن وموثوق به. نسخ - تأكيد جملة الاستعادة - يُرجى إدخال الكلمات التالية من جملة الاستعادة التي تخصك. + تأكيد عبارة الاستعادة + أدخِل الكلمات التالية من عبارة الاستعادة الخاصة بك. الكلمة %1$d - الاطلاع مجددا على الجملة - تمّ - تم تأكيد جملة الاستعادة + الاطِّلاع على العبارة مُجدَّدًا + تم + تمَّ تأكيد عبارة الاستعادة. - إدخال جملة الاستعادة - يُرجى إدخال الكلمة %1$d + إدخال عبارة الاستعادة + أدخِل الكلمة %1$d الكلمة %1$d التالي - كلمة باطلة + كلمة غير صحيحة - لقد أرسل لك %1$s مبلغا قدره %2$s - هناك %1$d إشعار دفع جديد + أرسلَ %1$s لك مبلغًا قدره %2$s + هناك %1$d إشعار (إشعارات) بدفع جديد - تعذَّر إرسال الدفوعات - على هذا المستخدم أن يوافق على طلب التراسل معك لأجل التمكن من إرسال المبلغ إليه. يٌرجى إرسال رسالة إليه لإنشاء طلب التراسل. + تعذَّر إرسال المدفوعات + لإرسال مبلغ إلى هذا المستخدم، يحتاج للموافقة على طلب المراسلة منك. أرسِل رسالة إليه لإنشاء طلب مراسلة. إرسال رسالة ليس لديك أي مجموعة مشتركة مع هذا الشخص. يُرجى مراجعة الطلبات بعناية قبل قبولها لتفادي أي رسائل غير مرغوب فيها. ليس لديك أي جهات اتصال ولا أي شخص سبق لك التواصل معه في هذه المجموعة. يُرجى مراجعة الطلبات بعناية قبل قبولها لتفادي أي رسائل غير مرغوب فيها. - حول طلبات التراسُل - حسنا + حول طلبات المراسلة + حسنًا إليك معاينة للون الدردشة. - إن اللون يظهر عندك فقط. + اللون يظهر لك فقط. وصف المجموعة @@ -5427,7 +5419,7 @@ أسرع، بيانات أقل - بالغ الأهميّة + عالي الدقة أبطأ، بيانات أكثر @@ -5439,18 +5431,18 @@ تعذّر إكمال النسخ الاحتياطي - دعوة أصدقائك + ادْعُ أصدقائك - تمّ نسخ مُعرف المُتبرّع إلى الحافظة + تمّ نسخ مُعرِّف المُشترِك إلى الحافظة. - تمّ نسخ النسخ الاحتياطية لمُعرّف المُتبرّع إلى الحافظة + تمّ نسخ النسخ الاحتياطية لمُعرِّف المُشترِك إلى الحافظة - لا يوجد أي مُعرّف مُشترك + لا يوجد أي مجموعة مُعرِّف للمُشترِك الحِساب - سيُطلب منك مرات أقل بمرور الوقت - إنه يتطلب ‫رقم سيجنال التعريفي الشخصي‬ لتسجيل رقم هاتفك في سيجنال مرة أخرى + سيُطلَب منك بعدد مرّاتٍ أقل مع مرور الوقت. + يتطلَّب ‫رقم التعريف الشخصي‬ الخاص بك على سيجنال لتسجيل رقم هاتفك في سيجنال مرّة أخرى تغيير رقم الهاتف بيانات حسابك @@ -5461,7 +5453,7 @@ تصدير تقرير عن بيانات حساب سيجنال الخاص بك. لا يتضمّن هذا التقرير أي رسائل أو وسائط. %1$s - معرفة المزيد + اعرف المزيد تصدير التقرير @@ -5480,29 +5472,29 @@ موافق - تعذّر إنشاء تقرير + تعذَّر إنشاء التقرير - تحقّق من اتصالك بالشبكة ثم حاول مُجددًا. + تحقّق من اتصالك بالشبكة ثم حاوِل مُجدَّدًا. - تصدير البيانات؟ + هل ترغبُ بتصدير البيانات؟ - لا تُشارك بيانات حساب سيجنال الخاص بك إلا مع الأشخاص أو التطبيقات التي تثق بها. + شارِك بيانات حساب سيجنال الخاص بك مع الأشخاص أو التطبيقات التي تثق بها. تصدير جارٍ إنشاء تقرير… - يتم إنشاء تقريرك فقط في وقت التصدير ولا يتم تخزينُه بواسطة سيجنال على جهازك. + يتم إنشاء تقريرك فقط في وقت التصدير، ولا يتم تخزينُه بواسطة سيجنال على جهازك. - يُرجى استخدام هذا لاستبدال رقم هاتفك الحالي بالجديد. لن يكون بإمكانك التراجع عن هذا اﻷمر.\n\nلذا، يجب عليك، قبل الاستمرار، التأكد من أن رقم هاتفك الجديد يستطيع تلقي المكالمات والرسائل القصيرة. - الاِستِمرار + استخدِم هذا لتغييررقم هاتفك الحالي إلى رقم جديد. لن يكون بإمكانك التراجع عن هذا اﻷمر.\n\nلذا، يجب عليك، قبل الاستمرار، التأكُّد من أن رقم هاتفك الجديد يستطيع تلقي المكالمات والرسائل القصيرة. + متابعة - ‫لقد أصبح رقم هاتفك الآن هو %1$s + أصبح رقم هاتفك الآن هو %1$s - حسنا + حسنًا تغيير الرقم @@ -5510,47 +5502,47 @@ رقم الهاتف القديم رقم هاتفك الجديد رقم الهاتف الجديد - رقم الهاتف الذي أُدخِل لا يطابق رقم حسابك. - يجب عليك تحديد الرمز الدولي لرقم هاتفك القديم - يجب عليك تحديد رقم هاتفك القديم - يجب عليك تحديد الرمز الدولي لرقم هاتفك الجديد - يجب عليك تحديد رقم هاتفك الجديد + رقم الهاتف الذي أدخلته لا يطابق الرقم المُرفَق مع حسابك. + يجب عليك تحديد رمز الدولة لرقم هاتفك القديم. + يجب عليك تحديد رقم هاتفك القديم. + يجب عليك تحديد رمز الدولة لرقم هاتفك الجديد. + يجب عليك تحديد رقم هاتفك الجديد. تغيير الرقم - يجري التحقق من %1$s - كلمة التحقق ضرورية + جارٍ التحقُّق من %1$s + حل الاختبار مطلوب تغيير الرقم - إنك على وشك تغيير رقم هاتفك من %1$s إلى %2$s.\n\nيٌرجى التحقق من أن رقم الهاتف أسفله صحيح قبل الاستمرار. + أنت على وشك تغيير رقم هاتفك من %1$s إلى %2$s.\n\nيُرجى التحقُّق من أن رقم الهاتف في الأسفل صحيح قبل الاستمرار. تعديل الرقم - رقم التغيير لسيجنال- تلزم المساعدة بواسطة الرقم التعريفي الشخصي لأندرويد (الإصدار الثاني للرقم التعريفي الشخصي) + رقم التغيير لسيجنال - تلزم المساعدة بواسطة رقم التعريف الشخصي لأندرويد (الإصدار الثاني لرقم التعريف الشخصي) رقما التعريف الشخصي غير متوافقان. - إن الرقم التعريفي الشخصي المرتبط برقم هاتف الجديد يختلف عن ذاك المرتبط بهاتفك القديم. أأقوم بالمحافظة على الرقم التعريفي الشخصي القديم أم أقوم بتحديثه ؟ - حافظْ على الرقم التعريفي الشخصي القديم - تحديث الرقم التعريفي الشخصي - أأحافظْ على الرقم التعريفي الشخصي القديم ؟ + رقم التعريف الشخصي المرتبط برقم هاتفك الجديد يختلف عن ذلك المرتبط برقم هاتفك القديم. هل ترغبُ بالاحتفاظ برقم التعريف الشخصي القديم أم ترغبُ بتحديثه؟ + احتفِظ برقم التعريف الشخصي القديم + تحديث رقم التعريف الشخصي (PIN) + هل ترغب بالاحتفاظ برقم التعريف الشخصي القديم؟ - يبدو أنك حاولت تغيير رقم هاتفك، لكنه تعذر علينا تحديد نجاح ذلك\n\nتجري إعادة التحقق اﻵن… + يبدو أنك حاولت تغيير رقم هاتفك، لكنه تعذَّر علينا تحديد نجاح ذلك.\n\nجارٍ إعادة التحقُّق اﻵن… - تغيير الحالة مؤكد + تمَّ تأكيد تغيير الحالة - لقد تم التأكد من أن رقم هاتفك هو %1$s. إن لم يكن هو رقم هاتفك الجديد، يُرجى إعادة البدء بعملية تغيير رقم الهاتف. + تمَّ التأكُّد من أن رقم هاتفك هو %1$s. إن لم يكن هو رقم هاتفك الجديد، يُرجى إعادة البدء بعملية تغيير رقم الهاتف. - تغيير الحالة غير مؤكد + لم يتم تأكيد تغيير الحالة - تعذر علينا تحديد حالة طلب تغير رقم هاتفك.\n\n(الخطأ : %1$s) + تعذَّر علينا تحديد حالة طلب تغيير رقم هاتفك.\n\n(الخطأ : %1$s) إعادة المُحاولة - غادِر + مغادرة إرسال سِجل التصحيح @@ -5604,7 +5596,7 @@ المجموعات - Only messages from group chats + فقط الرسائل من الدردشات الجماعية إضافة @@ -5700,23 +5692,23 @@ الرسائل المكالمات - أخبرني بـ.… + أخبرني عندما… انضمام جهة اتصال لسيجنال - أنماط الإشعارات + ملفات الإشعارات - الهيئات + الملفات - يُرجى إنشاء حسابك لتلقي اﻹشعارات فقط من اﻷفراد والمجموعات التي اخترت. + أنشِئ ملف لتلقي اﻹشعارات فقط من اﻷفراد والمجموعات التي اخترت. - أنماط الإشعارات + ملفات الإشعارات - إنشاء هيئة للإشعار + إنشاء ملف للإشعارات - محظورة + الجهات المحظورة %1$d جهات اتصال أو مجموعة @@ -5726,16 +5718,16 @@ %1$d جهة اتصال أو مجموعة %1$d جهة اتصال أو مجموعة - التراسُل - الرسائل المختفية + المراسلة + ميزة إخفاء الرسائل أمان التطبيق - منع لقطات الشاشة داخل التطبيق وفي قائمة الأحدَث - محادثات ومكالمات سيجنال، مناوبة الاتصالات دوما، والمُرسِل المختوم - المُهلة الافتراضية للدردشات الجديدة - تحديد مدة افتراضية لمؤقت اختفاء الرسائل، لجميع الدردشات الجديدة التي قمت ببدئها. + منع لقطات الشاشة داخل التطبيق وفي قائمة الأحداث + محادثات ومكالمات سيجنال، دعم المكالمات دومًا، ودعم المُرسِل المُشفَّر + المؤقِّت الافتراضي للدردشات الجديدة + تعيين مؤقِّت افتراضي لاختفاء الرسائل لجميع الدردشات الجديدة التي بدأتْ من طرفك. - اُطلب قفل شاشة هاتفك الأندرويد أو بصمة الإصبع لتحويل المبالغ + يتطلَّب قفل شاشة هاتفك الأندرويد أو بصمة الإصبع لتحويل المبالغ لا يُمكن تفعيل قفل عملية الدفع @@ -5748,20 +5740,20 @@ إظهار أيقونة الحالة - عرض أيقونة في تفاصيل الرسالة للرسائل التي تم إيصالها باستخدام المُرسِل المختوم. + عرض أيقونة في تفاصيل الرسالة للرسائل التي تمَّ إيصالها باستخدام المُرسِل المُشفَّر. - عند تفعيلها، سَتختفي الرسائل الجديدة المُرسلَة أو المُستلَمة في هذه الدردشة، بعد رؤيتها. - عند التفعيل، فإن الرسائل الجديدة المُرسلَة أو المُستلَمة في هذه الدردشة سَتختفي بعد رؤيتها. - مُعطَّل + عند تفعيلها، سَتختفي الرسائل الجديدة، المُرسلَة والمُستلَمة في الدردشات الجديدة التي بدأتَ بها، بعد رؤيتها. + عند تفعيلها، ستختفي الرسائل الجديدة، المُرسلَة والمُستلَمة في هذه الدردشة، بعد رؤيتها. + في حالة إيقاف 4 أسابيع أسبوع واحد يوم واحد ثمانِ ساعات ساعة واحدة - بعد 5 دقائق - بعد 30 ثانية - تحديد المهلة + 5 دقائق + 30 ثانية + تخصيص الوقت تعيين حفظ @@ -5773,7 +5765,7 @@ مركز الدعم - التواصل معنا + تواصَل معنا اﻹصدار سِجل التصحيح @@ -5785,8 +5777,8 @@ جودة الوسائط - جودة الوسائط المرسلة - إن إرسال وسائط بجودة عالية سوف يستخدم كما أكبر من البيانات. + جودة الوسائط المُرسَلة + إرسال وسائط بجودة عالية سوف يستخدم المزيد من البيانات. بالغ الأهميّة @@ -5796,38 +5788,38 @@ تلقائي - استخدام ألوان مخصصة + استخدام ألوان مُخصَّصَة لون الدردشة تحرير تكرار حذف حذف اللون - هذا اللون المخصص يُستخدَم في %1$d دردشة. هل تريد حذفه من جميع الدردشات؟ - هذا اللون المخصص يُستخدَم في %1$d دردشة. هل تريد حذفه من جميع الدردشات؟ - هذا اللون المخصص يُستخدَم في %1$d دردشتين. هل تريد حذفه من جميع الدردشات؟ - هذا اللون المخصص يُستخدَم في %1$d دردشات. هل تريد حذفه من جميع الدردشات؟ - هذا اللون المخصص يُستخدَم في %1$d دردشة. هل تريد حذفه من جميع الدردشات؟ - هذا اللون المخصص يُستخدَم في %1$d دردشة. هل تريد حذفه من جميع الدردشات؟ + هذا اللون المُخصَّص يُستخدَم في %1$d دردشة. هل تريد حذفه من جميع الدردشات؟ + هذا اللون المُخصَّص يُستخدَم في %1$d دردشة. هل تريد حذفه من جميع الدردشات؟ + هذا اللون المُخصَّص يُستخدَم في %1$d دردشتين. هل تريد حذفه من جميع الدردشات؟ + هذا اللون المُخصَّص يُستخدَم في %1$d دردشاتٍ. هل تريد حذفه من جميع الدردشات؟ + هذا اللون المُخصَّص يُستخدَم في %1$d دردشةً. هل تريد حذفه من جميع الدردشات؟ + هذا اللون المُخصَّص يُستخدَم في %1$d دردشةٍ. هل تريد حذفه من جميع الدردشات؟ - حذف لون الدردشة؟ + أترغبُ بحذف لون الدردشة؟ موحَّد مُتدرِّج الصبغة - الصفاء + التشبُّع اللوني حفظ تحرير الألوان - إن هذا اللون المخصص يُستخدَم في %1$d دردشات. هل تريد حفظ التغييرات في جميع الدردشات؟ - إن هذا اللون المخصص يُستخدَم في %1$d دردشة. هل تريد حفظ التغييرات في جميع الدردشات؟ - إن هذا اللون المخصص يُستخدَم في %1$d دردشتين. هل تريد حفظ التغييرات في جميع الدردشات؟ - إن هذا اللون المخصص يُستخدَم في %1$d دردشات. هل تريد حفظ التغييرات في جميع الدردشات؟ - إن هذا اللون المخصص يُستخدَم في %1$d دردشة. هل تريد حفظ التغييرات في جميع الدردشات؟ - إن هذا اللون المخصص يُستخدَم في %1$d دردشة. هل تريد حفظ التغييرات في جميع الدردشات؟ + هذا اللون المُخصَّص يُستخدَم في %1$d دردشات. هل تريد حفظ التغييرات في جميع الدردشات؟ + هذا اللون المُخصَّص يُستخدَم في %1$d دردشة. هل تريد حفظ التغييرات في جميع الدردشات؟ + هذا اللون المُخصَّص يُستخدَم في %1$d دردشتين. هل تريد حفظ التغييرات في جميع الدردشات؟ + هذا اللون المُخصَّص يُستخدَم في %1$d دردشاتٍ. هل تريد حفظ التغييرات في جميع الدردشات؟ + هذا اللون المُخصَّص يُستخدَم في %1$d دردشةً. هل تريد حفظ التغييرات في جميع الدردشات؟ + هذا اللون المُخصَّص يُستخدَم في %1$d دردشةٍ. هل تريد حفظ التغييرات في جميع الدردشات؟ @@ -5835,82 +5827,82 @@ تبرَّع لسيجنال - تستمد سيجنال قوتها من أشخاص مثلك. تبرّع شهريًا واحصل على شارة. + تستمد سيجنال دعمها من أشخاص مثلك. تبرَّع شهريًا واحصل على شارة. - تبرع + تبرَّع ليس الآن تخصيص التفاعلات - المس لاستبدال الوجه المُعبِّر + انقر لاستبدال الرمز التعبيري (الإيموجي) إعادة التعيين حفظ - وافِق تلقائيا اللون مع الخلفية - اسحب لتغيير اتجاه التدرج + طابِق تلقائيًا اللون مع الخلفية + اسحب لتغيير اتجاه التدرُّج اللوني إضافة صورة للملف الشخصي - يُرجى اختيار النسق واللون أو تخصيص اختصاراتك. + اختر المظهر واللون أو خصِّص اختصاراتك. ليس الآن إضافة صورة - كن من الداعمين + كُن من الداعمين - إن سيجنال مدعوم بفضل أفراد مثلك. يُرجى التبرع للحصول على شارة. + تستمد سيجنال دعمها من أشخاص مثلك. تبرَّع شهريًا واحصل على شارة. ليس الآن - تبرع + تبرَّع - وجه مُعبِّر - فتح البحث عن الوجوه المُعبِّرة + رمز تعبيري (إيموجي) + فتح البحث عن الرمز التعبيرية (الإيموجي) فتح البحث عن الملصقات فتح البحث عن الصور المتحركة - المُلصقات + المُلصَقات مسافة للخلف الصور المتحركة - البحث عن الوجوه المُعبِّرة - الرجوع للوجوه المُعبِّرة - محو مُدخلات البحث + البحث في الرمز التعبيري (الإيموجي) + الرجوع للرمز التعبيري (الإيموجي) + مسح مُدخلات البحث البحث في GIPHY - البحث عن الملصقات - لم يعثر على أي نتيجة - لم يعثر على أي نتيجة - رنة مجهولة + البحث في المُلصَقات + لم يتم العثور على أي نتائج. + لم يتم العثور على أي نتائج. + رنة غير معروفة - تعذّر الإضافة إلى قصة المجموعة + تعذَّرت الإضافة إلى قصة المجموعة - فقط المشرفون على هذه المجموعة هم من يمكنُهم الإضافة إلى قصتها + فقط المُشرِفون على هذه المجموعة هم من يمكنُهم الإضافة إلى قصتها تعذّر العثور على تطبيق جهات الاتصال - بدء مكالمة بالصورة + بدء مكالمة فيديو بدء مكالمة صوتية - القصص + القصة رِسالَة - مقاطع الڤيديو + فيديو - الصوت + صوت اتصال كتم - تم كتم الدردشات + تمَّ كتم الدردشات البحث - الرسائل المختفية + ميزة إخفاء الرسائل الأصوات والإشعارات معلومات عن جهة اتصال الهاتف - عرض رقم الأمان + إظهار رقم الأمان حظر حظر المجموعة رفع الحظر @@ -5918,15 +5910,15 @@ إضافة إلى مجموعة مشاهدة الكل إضافة أعضاء - الصلاحيات - الطلبات و الدعوات - وصلة المجموعة - الإضافة كجهة اتصال + الأذونات + الطلبات والدعوات + رابط المجموعة + إضافة كجهة اتصال إلغاء كتم الدردشات كتم الدردشة حتى %1$s كُتمَت الدردشة للأبد - لقد نُسخ رقم الهاتف إلى الحافظة. + نُسخَ رقم الهاتف إلى الحافظة. رقم الهاتف يمكنك الحصول على شارات لحسابك الشخصي عبر دعم سيجنال. يكفي النقر على شارة لمعرفة المزيد. @@ -5935,27 +5927,27 @@ تعديل معلومات المجموعة إرسال الرسائل جميع الأعضاء - للمشرفين فقط - من يمكنه إضافة أعضاء جدد ؟ + للمُشرِفين فقط + من يمكنه إضافة أعضاء جُدُد؟ من يمكنه تعديل معلومات هذه المجموعة ؟ من يمكنه إرسال رسائل وبدء مكالمات؟ كتم الإشعارات - لم يُكتم - إشارات إلى اﻷفراد - نبِّه دوما - لا تُـنـبِّـه - إشعارات مخصصة + لم تُكتَم + الإشارات إلى اﻷفراد + التنبيه دومًا + عدم التنبيه + إشعارات مُخصَّصة - استُخدِم مؤخرا + استُخدِمَت مؤخَّرًا - ‎½ ×‎ - ‎1 ×‎ - ‎1½ ×‎ - ‎2 ×‎ + x5.0 + x1 + x1.5 + x2 دفع جديد @@ -5967,37 +5959,37 @@ مكالمة صوتية - مكالمة بالصورة + مكالمة فيديو إزالة حظر - إزالة %1$s؟ + هل ترغب بإزالة %1$s؟ لن يظهر لك هذا الشخص عند البحث. سَتتلقّى طلب رسالة إذا راسلوك في المستقبل. - تم حذف %1$s + تمَّ حذف %1$s - تم حظر %1$s + تمَّ حظر %1$s لا يمكن إزالة %1$s - هذا الشخص محفوظ في جهات اتصالك. اِحذفه من جهات اتصالك وحاول مرة أخرى. + هذا الشخص محفوظ في جهات اتصالك. اِحذفه من جهات اتصالك وحاوِل مرّة أخرى. - عرض جهات الاتصال + عرض جهة الاتصال لم يعُد %1$s من مستخدمي سيجنال - لم يعُد %1$s من مستخدمي سيجنال + لم يعُد %1$s من مُستخدِمي سيجنال. - %1$s ليسوا مستخدمي سيجنال + %1$s ليسوا من مُستخدِمي سيجنال %1$s ليس مستخدم سيجنال - %1$s ليسا مستخدمي سيجنال - %1$s ليسوا مستخدمي سيجنال - %1$s ليسوا مستخدم سيجنال - %1$s ليسوا مستخدمي سيجنال + %1$s ليسا من مُستخدِمي سيجنال + %1$s ليسوا من مُستخدِمي سيجنال + %1$s ليسوا من مُستخدِمي سيجنال + %1$s ليسوا من مُستخدِمي سيجنال @@ -6007,26 +5999,26 @@ · %1$s إيقاف الرسالة الصوتية تغيير سرعة الرسالة الصوتية - إيقاف الرسالة الصوتية مؤقتا + إيقاف الرسالة الصوتية مؤقتًا تشغيل الرسالة الصوتية - التصفح نحو الرسالة الصوتية + انتقل إلى الرسالة الصوتية - معاينة صورة الحساب + معاينة الصورة الرمزية الكاميرا التقط صورة - اختيار صورة + اختر صورة صورة نص حفظ - محو صورة الحساب - فشل حفظ صورة حسابك + إزالة الصورة الرمزية + فشل حفظ الصورة الرمزية - مُعاينة - تمّ + معاينة + تم النص اللون @@ -6034,27 +6026,27 @@ تحديد اللون - المُشاركة + مشاركة - التصفح نحو اﻷعلى + التصفُّح نحو اﻷعلى إعادة التوجيه نحو - شارك مع - إضافة نص + شارِك مع + إضافة رسالة إعادة توجيه أسرع سَيتم تجزئة الفيديوهات إلى مقاطع من 30 ثانية وإرسالها كعدة قِصص. - لا يمكن أن تتعدى الفيديوهات المرسلة كقِصص 30 ثانية. - لقد أُرسِلت اﻵن الرسائل الموجَّهة فورا. + لا يمكن أن تتعدى مقاطع الفيديو المرسَلة إلى القِصص 30 ثانية. + تمَّ اﻵن إرسال الرسائل الموجَّهة فورًا. لم تُرسَل أي رسالة. (%1$d) إرسال رسالة واحدة. (%1$d) إرسال رسالتين (%1$d) إرسال %1$d رسائل - إرسال %1$d رسالة - إرسال %1$d رسالة + إرسال %1$d رسالةً + إرسال %1$d رسالةٍ لم تُرسَل أي رسالة @@ -6066,22 +6058,22 @@ لم يفشل إرسال أي رسالة - لقد فشل إرسال رسالتك - لقد فشل إرسال رسالتيْك - لقد فشل إرسال رسائلك - لقد فشل إرسال رسائلك - لقد فشل إرسال رسائلك + فشل إرسال رسالتك + فشل إرسال رسالتيْن + فشل إرسال رسائلك + فشل إرسال رسائلك + فشل إرسال رسائلك - لم يحدث أي مشكل في إعادة توجيه رسائلك - لقد تعذر إعادة توجيه رسالتك لأنها لم تعد متاحة. - لقد تعذر إعادة توجيه رسالتيْك لأنها لم تعد متاحة. - لقد تعذر إعادة توجيه رسائلك لأنها لم تعد متاحة. - لقد تعذر إعادة توجيه رسائلك لأنها لم تعد متاحة. - لقد تعذر إعادة توجيه رسائلك لأنها لم تعد متاحة. + تعذَّر إعادة توجيه رسائلك لأنها لم تعد متاحة. + تعذَّر إعادة توجيه رسالتك لأنها لم تعد متاحة. + تعذَّر إعادة توجيه رسالتيْك لأنها لم تعد متاحة. + تعذَّر إعادة توجيه رسائلك لأنها لم تعد متاحة. + تعذَّر إعادة توجيه رسائلك لأنها لم تعد متاحة. + تعذَّر إعادة توجيه رسائلك لأنها لم تعد متاحة. - يُمكن فقط للمُشرفين إرسال الرسائل في هذه المجموعة. + يُمكن فقط للمُشرِفين إرسال الرسائل في هذه المجموعة. لا يمكنك تحديد أكثر من 5 محادثات @@ -6090,14 +6082,14 @@ الإضافة إلى قصة المجموعة \"%1$s\" إضافة للقصة - إضافة نص + إضافة رسالة إضافة رد إرسال إلى وسائط للمُشاهدة مرة واحدة عنصر واحد أو أكثر كان كبيرًا جدًا عنصر واحد أو أكثر غير صالح - تم تحديد عدد كبير جدًا من العناصر + تمَّ تحديد عدد كبير جدًا من العناصر تم تحديد عرض الفيديو لمرة واحدة @@ -6132,10 +6124,10 @@ إلغاء رسم كتابة نص - إضافة ملصق - ضبِّب - انتهى التحرير - محو الكل + إضافة مُلصَق + تمويه + انتهى التعديل + إزالة الكل تراجع التبديل بين قلم الوسم وبين قلم التمييز التبديل بين أنماط النصوص @@ -6143,14 +6135,14 @@ موصى به - أرسلْ + إرسال - يُرجى اللمس للإزالة + انقر للإزالة أنقر للاختيار تجاهل - تجاهل التغييرات ؟ - سوف تضيع كل تغييرات قمت بها على هذه الصورة. + هل ترغب بتجاهل التغييرات؟ + سوف تفقد كل التغييرات التي قمتَ بها على هذه الصورة. تم العثور على %1$s @@ -6168,9 +6160,9 @@ شاراتي - شارة مُميزة + شارة مُمَيَّزة عرض الشارات على الحساب الشخصي - فشل تحديث الملف الشخصي + فشل تحديث الحساب الشخصي @@ -6190,17 +6182,17 @@ شارة إلغاء الاشتراك - تأكيد الإلغاء؟ + هل ترغب بتأكيد الإلغاء؟ لن يتم خصم المبلغ مرة أخرى. سَتتم إزالة شارتِك من حسابك الشخصي في نهاية المدة الزمنية لإعداد الفواتير. ليس الآن تأكيد تحديث الاشتراك - تم إلغاء اشتراكك. - تحديث الاشتراك؟ - تحديث التطبيق - سَتدفع مبلغ (%1$s) بالكامل من سعر الاشتراك الجديد اليوم. وسَيتجدد اشتراكك الجديد شهرياً. + تمَّ إلغاء اشتراكك. + أترغبُ بتحديث الاشتراك؟ + تحديث + سَتدفع مبلغ (%1$s) بالكامل من سعر الاشتراك الجديد اليوم. وسَيتجدَّد اشتراكك الجديد شهريًا. - %1$s شهرياً + %1$s شهريًا تجديد %1$s @@ -6218,10 +6210,10 @@ شكرا لدعمك! لقد حصلت على شارة مُتبرع من سيجنال! اعرضها في حسابك الشخصي لإظهار دعمك. - يُمكنك أيضاً - كُن داعماً شهرياً. - عرض في الملف الشخصي - اصنع شارة مميزة + يُمكنك أيضًا + كُن داعمًا شهريًا. + عرض في الحساب الشخصي + اصنع شارة مُميَّزة واصل عندما يكون لديك أكثر من شارة واحدة، يُمكنك اختيار واحدة لإظهارها ليراها الآخرين في حسابك الشخصي. @@ -6243,7 +6235,7 @@ طرق أخرى للتبرّع - تبرّع لصديق + تبرَّع لصديق تعذر تأكيد التبرع @@ -6253,7 +6245,7 @@ إدخال مَبلَغ مُخَصَّص - الحد الأدنى الذي يُمكنك التبرع به هو %1$s + الحد الأدنى الذي يُمكنك التبرُّع به هو %1$s %1$s/شهر تجديد %1$s @@ -7295,7 +7287,7 @@ انقر على زر \"الانتقال إلى الإعدادات\" أدناه - شغِّل \"السماح بتنبيهات الإعدادات والتذكيرات\". + شغِّل \"السماح بإعداد التنبيهات والتذكيرات\". الانتقال إلى الإعدادات @@ -8070,11 +8062,11 @@ لقد تم إلغاء خطة النسخ الاحتياطي لوسائط سيجنال الخاصة بك لأننا لم نتمكّن من معالجة عملية الدفع الخاصة بك. هذه فرصتك الأخيرة لتنزيل الوسائط الموجودة في النسخة الاحتياطية قبل حذفها. - Free up %1$s on this device + وفِّر مساحة %1$s على هذا الجهاز - To finish downloading your Signal Backup your device needs %1$s of storage space. + لإنهاء تحميل النسخة الاحتياطية الخاصة بك على سيجنال، يَحتاج جهازك إلى %1$s من مساحة التخزين. - To free up space offload or delete unused apps or content large in file size. + لتوفير المساحة، قُم بإفراغ الجهاز أو حذف التطبيقات غير المُستخدَمة أو الملفات كبيرة الحجم. تعذَّر تجديد اشتراك النسخ الاحتياطي الخاص بحسابك. @@ -8086,9 +8078,9 @@ معرفة المزيد - تخطى الاسترجاع + تخطّى الاسترجاع - قُم بالنسخ الاحتياطي الآن + إنشاء نسخة احتياطية الآن تم @@ -8102,7 +8094,7 @@ ليس الآن - Try later + حاوِل مرّة أخرى سيتم حذف الوسائط @@ -8114,9 +8106,9 @@ تخطّي - Skip restore? + أترغبُ بتخطي الاستعادة؟ - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + إذا تخطّيت الاستعادة، فستُحذف الوسائط والمُرفقات في نسختك الاحتياطية في المرّة القادمة التي يُكمل فيها جهازك حفظ نسخة احتياطية جديدة. @@ -8173,10 +8165,6 @@ تغيير أو إلغاء الاشتراك - - - أُجريت آخر نسخة احتياطية في %1$s على الساعة %2$s. - العدد الأقصى للرسائل في الدردشة @@ -8518,5 +8506,101 @@ أيقونة تذكير + + + هاتفي القديم بحوزتي + + امسح كود QR من حساب سيجنال الحالي الخاص بك للبدء بسرعة + + + هاتفي القديم ليس بحوزتي + + أو تقوم بإعادة تثبيت سيجنال على الجهاز نفسه. + + + استعادة أو نقل الحساب + + حوّل حساب سيجنال الخاص بك وسِجل رسائلك إلى جهازك الحالي. + + من النسخ الاحتياطية على سيجنال + + خطة النسخ الاحتياطي لسيجنال المجانية أو المدفوعة + + من مجلد نسخة احتياطية + + من ملف النسخة الاحتياطية + + اختر نسخة احتياطية حفظتها + + من جهازك القديم + + النقل مباشرةً من جهاز أندرويد القديم + + + استعادة نسخة احتياطية محلية + + استعِد رسائلك من ملف النسخة الاحتياطية الذي حفظته على جهازك. إذا لم تقُم بالاستعادة الآن، فسيتعذَّر عليك الاستعادة لاحقًا. + + + أدخِل مفتاح النسخة الاحتياطية الخاص بك + + مفتاح النسخة الاحتياطية الخاص بك هو كود مُكوَّن من 64 رقمًا، وهو مطلوب لاسترجاع حسابك وبياناتك. + + لا ترغبُ باستخدام مفتاح النسخة الاحتياطية؟ + + مفتاح النسخة الاحتياطية + + لا يُمكن استرجاع النسخ الاحتياطية دون كود الاستعادة المُكوَّن من 64 رقمًا. إذا فقدت مفتاح النسخة الاحتياطية الخاص بك، فلا يُمكن لسيجنال مساعدتك في استعادة نسختك الاحتياطية. + + إذا كنت تستعمل جهازك القديم، يمكنك عرض مفتاح النسخة الاحتياطية الخاص بك في \"الإعدادات\" > \"الدردشات\" > \"النسخ الاحتياطية على سيجنال\". ثم انقر على عرض مفتاح النسخة الاحتياطية. + + اعرف المزيد + + تخطَّ الاستعادة + + + امسح هذا الكود بجهازك القديم + + افتح سيجنال على جهازك القديم + + انقر على أيقونة الكاميرا + + امسح هذا الكود باستخدام الكاميرا + + تعذَّر إنشاء كود QR + + تمَّ المسح ضوئيًا على الجهاز القديم + + إعادة المُحاولة + + + نقل الحساب + + سيتمُّ نقل حسابك إلى جهاز جديد. سَيتمكن هذا الجهاز من رؤية مجموعاتك وجهات اتصالك، والوصول إلى دردشاتك، وإرسال رسائل باسمك. %1$s + + اعرف المزيد + + نقل الحساب + + معلومات الرسائل والدردشات محمية من خلال التشفير من طرف لِطرف على جميع الأجهزة + + افتح القفل لنقل الحساب + + المتابعة على جهازك الآخر + + تابِع نقل حسابك على جهازك الآخر. + + + اكتملَت عملية الاستعادة. + + بدأ نقل حساب سيجنال الخاص بك ورسائلك إلى جهازك الآخر. سيجنال غير مُفعّل الآن على هذا الجهاز. + + اكتمَلَ النقل + + تمَّ نقل حسابك ورسائلك على سيجنال إلى جهازك الآخر. سيجنال غير مُفعّل الآن على هذا الجهاز. + + حسنًا + + \ No newline at end of file diff --git a/app/src/main/res/values-az/strings.xml b/app/src/main/res/values-az/strings.xml index 5960f65273..55d3691ac6 100644 --- a/app/src/main/res/values-az/strings.xml +++ b/app/src/main/res/values-az/strings.xml @@ -1325,20 +1325,6 @@ daha çox Qrup açıqlaması əlavə edin… - - - Android cihazınızdan köçürün - - Köhnə Android cihazındakı hesab və mesajlarınızı köçürün. - - Köçürmədən daxil olun - - Mesajlarınızı və media fayllarını ötürmədən davam edin - - Yerli ehtiyat nüsxəni bərpa edin - - Mesajları cihazınızda saxladığınız ehtiyat nüsxə faylından bərpa edin. - Ehtiyat nüsxə endirilir… @@ -1356,12 +1342,16 @@ Bütün mesajlarınız Nüsxədən geri yükləyin - + Yalnız son %1$d gün ərzində göndərilən və qəbul edilən media fayllar əhatə olunub. Ehtiyat nüsxənizə bunlar daxildir: Nüsxəni geri yüklə + + Son ehtiyat nüsxəniz çıxarılıb: %1$s tarixi saat %2$s + + Ehtiyat nüsxə təfərrüatları əldə edilir… Adım çəkiləndə bildir @@ -3463,7 +3453,7 @@ Digər Ödənişlər (MobileCoin) İanələr və Nişanlar - Signal Android Backup + Signal Android Ehtiyat nüsxəsi Signal Android sazlama jurnalının göndərilməsi @@ -4304,6 +4294,8 @@ Bu parolu güvənli bir yerə yazdım. Parol olmadan, bir nüsxəni geri yükləyə bilmərəm. Nüsxəni geri yüklə Hesabınızı köçürün və ya geri yükləyin + + Bərpa et və ya köçür Hesabı köçür Ötür Çat nüsxələri @@ -5124,7 +5116,7 @@ Qruplar - Only messages from group chats + Yalnız qrup çatlarından olan mesajlar Əlavə et @@ -6687,7 +6679,7 @@ Aşağıdan \"Parametrlərə keç\" düyməsinə toxunun - \"Xəbərdarlıq və xatırladıcı parametrlərinə icazə ver\" funksiyasını aktivləşdirin. + \"Signal və xatırlatma parametrlərinin quraşdırılmasına icazə ver\" funksiyasını aktivləşdirin. Parametrlərə keçin @@ -7426,11 +7418,11 @@ Ödənişinizi emal edə bilmədiyimizə görə Signal media faylı üçün ehtiyat nüsxə planınız ləğv edilib. Silinməzdən əvvəl ehtiyat nüsxənizdəki media faylını endirmək üçün bu son şansınızdır. - Free up %1$s on this device + Bu cihazda %1$s həcmində yer boşaldın - To finish downloading your Signal Backup your device needs %1$s of storage space. + Signal Ehtiyat nüsxənizin endirilməsini tamamlamaq üçün cihazınızda %1$s həcmində yaddaşa ehtiyac var. - To free up space offload or delete unused apps or content large in file size. + Yer boşaltmaq üçün istifadə olunmayan tətbiqləri və ya fayl həcmi böyük olan məzmunu boşaldın və ya silin. Ehtiyat nüsxə abunəliyinizi yeniləmək mümkün olmadı @@ -7458,7 +7450,7 @@ İndi yox - Try later + Daha sonra cəhd et Media faylı silinəcək @@ -7470,9 +7462,9 @@ Ötür - Skip restore? + Bərpaetmə ötürülsün? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Bərpaetməni ötürsəniz, ehtiyat nüsxənizdəki media faylları və qoşmalar növbəti dəfə cihazınız yeni ehtiyat nüsxəsini tamamladıqda silinəcəkdir. @@ -7529,10 +7521,6 @@ Abunəliyi dəyişin və ya ləğv edin - - - Son ehtiyat nüsxəniz çıxarılıb: %1$s tarixi saat %2$s - Çat limitləri @@ -7850,5 +7838,101 @@ Xatırlatma piktoqramı + + + Köhnə telefonum əlimdədir + + Prosesi sürətləndirmək üçün cari Signal hesabınızdan QR kodunu skan edin + + + Köhnə telefonum əlimdə deyil + + Yaxud da Signal-ı eyni cihazda yenidən quraşdırırsınız + + + Hesabı köçür və ya bərpa et + + Signal hesabınızı və mesaj tarixçəsini bu cihaza köçürün. + + Signal ehtiyat nüsxələrindən + + Pulsuz və ya ödənişli Signal Ehtiyat nüsxə planı + + Ehtiyat nüsxə qovluğundan + + Ehtiyat nüsxə faylından + + Yaddaşda saxladığınız ehtiyat nüsxəni seçin + + Köhnə telefonunuzdan + + Birbaşa köhnə Androiddən köçür + + + Yerli ehtiyat nüsxəni bərpa edin + + Cihazınızda saxlanmış ehtiyat nüsxədən mesajlarınızı bərpa edin. İndi bərpa etməsəniz, daha sonra bərpa etmək mümkün olmayacaq. + + + Ehtiyat nüsxə şifrəsini daxil edin + + Ehtiyat nüsxə şifrəniz hesabınızı və verilənlərinizi bərpa etmək üçün tələb olunan 64 rəqəmli bir koddur. + + Ehtiyat nüsxə şifrəsi yoxdur? + + Ehtiyat nüsxə şifrəsi + + 64 rəqəmli bərpa kodu olmadan ehtiyat nüsxələri bərpa etmək mümkün deyil. Ehtiyat nüsxə şifrənizi itirsəniz, Signal ehtiyat nüsxənizin bərpasına kömək edə bilməz. + + Köhnə cihazınız varsa, Parametrlər > Çatlar > Signal ehtiyat nüsxələri bölmələrinə keçərək ehtiyat nüsxə şifrənizə baxa bilərsiniz. Sonra isə \"Ehtiyat nüsxə şifrəsinə bax\" seçiminə klikəyin. + + Daha ətraflı + + Ötür və bərpa etmə + + + Bu kodu köhnə telefonunuzla skan edin + + Signal-ı köhnə cihazınızda açın + + Kamera piktoqramına klikləyin + + Bu kodu kamera ilə skan edin + + QR kodunu yaratmaq mümkün olmadı + + Köhnə cihazla skan edildi + + Yenidən sına + + + Hesabı köçür + + Hesabınız yeni bir cihaza köçürüləcəkdir. Bu cihazdan qrup və kontaktlarınızı görə, çatlarınıza daxil ola və öz adınızla mesaj göndərə bilərsiniz. %1$s + + Daha ətraflı + + Hesabı köçür + + Mesajlar və çat məlumatları bütün cihazlarda tam şifrələmə ilə qorunur + + Hesabın köçürülməsi üçün kilidi açın + + Digər cihazınızdan davam edin + + Digər cihazınızda hesabınızın köçürülməsinə davam edin. + + + Geri yükləmə tamamlandı + + Signal hesabınız və mesajlarınız digər cihazınıza köçürülməyə başlayıb. Artıq Signal bu cihazda aktiv deyil. + + Köçürmə tamamlandı + + Signal hesabınız və mesajlarınız digər cihazınıza köçürülüb. Artıq Signal bu cihazda aktiv deyil. + + Oldu + + \ No newline at end of file diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 3b6dcad139..0f12e4a0f8 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -25,8 +25,8 @@ Да Не Изтриване - Моля, изчакай… - Запази + Моля, изчакайте… + Запазване Лична бележка @@ -45,14 +45,14 @@ Molly се обновява… - Все още не си задал/а парола! - Деактивирай паролата? + Все още не сте задали тайна фраза! + Деактивиране на тайната фраза? Това ще отключи изцяло всички известия за съобщения и Molly. - Изключи + Изключване Грешка при свързване със сървъра! - ПИН кодовете са задължителни за регистрационното заключване. За да деактивирате ПИН кодовете, моля първо деактивирайте регистрационното заключване. - ПИН-ът е създаден. - ПИН-ът е деактивиран. + ПИН кодовете са задължителни за заключване на регистрацията. За да деактивирате ПИН кодовете, моля първо деактивирайте заключването на регистрацията. + ПИН кодът е създаден. + ПИН кодът е деактивиран. Записване на фраза за възстановяване на плащания Записване на фраза Преди да можете да деактивирате своя PIN, трябва да запишете фразата си за възстановяване на плащания, за да можете да възстановите своя акаунт за плащания. @@ -86,10 +86,10 @@ Местоположение Molly има нужда от разрешение, за да показва вашите снимки и видеа - Разреши достъп + Разрешаване на достъп Плащане - Управлявайте + Управление Изберете още снимки @@ -1325,20 +1325,6 @@ още Добави описание на групата… - - - Прехвърляне от Android устройство - - Прехвърляне на акаунта и съобщенията ви от старото ви устройство с Android. - - Влизане без прехвърляне - - Продължаване без прехвърляне на вашите съобщения и мултимедия - - Възстановяване на локално резервно копие - - Възстановете съобщенията си от файл с резервно копие, запазен на вашето устройство. - Изтегляне на резервно копие… @@ -1356,12 +1342,16 @@ Всички ваши съобщения Възстановяване от резервно копие - + Включена е само мултимедията, изпратена или получена през последните %1$d дни. Вашето резервно копие включва: Възстановяване на архивно копие + + Последното ви резервно копиране е направено на %1$s в %2$s. + + Извличане на данни за резервно копие… Уведоми ме за споменавания @@ -3460,10 +3450,10 @@ Заявка за функция Въпрос Обратна връзка - Друг + Друго Плащания (MobileCoin) Дарения и значки - Signal Android Backup + Резервно копие на Signal Android Signal Android Подаден сигнал за грешка @@ -4304,6 +4294,8 @@ Записах тази парола. Без нея, няма да мога да възстановя архива. Възстановяване на архивно копие Прехвърли или възстанови акаунт + + Възстановяване или трансфер Прехвърляне на акаунт Пропусни Архив на чатовете @@ -5124,7 +5116,7 @@ Групи - Only messages from group chats + Само съобщения от групови чатове Добавяне @@ -6687,7 +6679,7 @@ Докоснете бутона „Към настройките“ по-долу - Включете „Разрешаване на аларми и напомняния за настройки“. + Включете „Разрешаване на задаване на аларми и напомняния“. Отидете на настройки @@ -7426,11 +7418,11 @@ Вашият план за резервно копиране на мултимедия в Signal е анулиран, защото не можахме да обработим плащането ви. Това е последният ви шанс да изтеглите мултимедията от резервното си копие, преди да бъде изтрита. - Free up %1$s on this device + Освободете %1$s на това устройство - To finish downloading your Signal Backup your device needs %1$s of storage space. + За да завършите изтеглянето на резервното копие на Signal, устройството ви трябва да има %1$s място за съхранение. - To free up space offload or delete unused apps or content large in file size. + За да освободите място, прехвърлете или изтрийте неизползвани приложения или съдържание с голям размер на файла. Вашият абонамент за резервни копия не успя да се поднови @@ -7444,7 +7436,7 @@ Пропуснете възстановяването - Архивиране сега + Направете резервно копие сега Ясно @@ -7458,7 +7450,7 @@ Не сега - Try later + Опитайте по-късно Мултимедията ще бъде изтрита @@ -7470,9 +7462,9 @@ Пропускане - Skip restore? + Пропускане на възстановяването? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Ако пропуснете възстановяването, останалите прикачени файлове и мултимедия в резервното ви копие ще бъдат изтрити при следващото извършване на ново резервно копиране от устройството ви. @@ -7529,10 +7521,6 @@ Промяна или анулиране на абонамента - - - Последното ви резервно копиране е направено на %1$s в %2$s. - Лимити за чат @@ -7850,5 +7838,101 @@ Икона за напомняне + + + Старият ми телефон е подръка + + За бързо започване сканирайте QR код от текущия си акаунт в Signal + + + Старият ми телефон не е подръка + + Или отново инсталирате Signal на същото устройство + + + Възстановяване или прехвърляне на акаунт + + Получете акаунта и историята на съобщенията си в Signal на това устройство. + + От Резервни копия на Signal + + Вашият безплатен или платен план за Резервни копия на Signal + + От архивна папка + + От архивен файл + + Изберете резервно копие, което сте запазили + + От стария ви телефон + + Директен трансфер от стария ви Android + + + Възстановяване на локално резервно копие + + Възстановете съобщенията си от резервното копие, запазено на вашето устройство. Ако не ги възстановите сега, няма да можете да ги възстановите по-нататък. + + + Въведете своя резервен ключ + + Вашият резервен ключ е 64-цифрен код, необходим за възстановяване на акаунта и данните ви. + + Нямате резервен ключ? + + Резервен ключ + + Резервните копия не могат да бъдат възстановени без техния 64-цифрен код за възстановяване. Ако сте изгубили резервния ключ, Signal не може да ви помогне за възстановяване на резервното копие. + + Ако старото ви устройство е достъпно, можете да видите резервния си ключ в „Настройки > Чатове > Архиви“. След това докоснете „Преглед на резервен ключ“. + + Научете повече + + Пропускане без възстановяване + + + Сканирайте този код със стария ви телефон + + Отворете Signal на старото ви устройство + + Докоснете иконата с камера + + Сканирайте този код с камерата + + Неуспешно генериране на QR код + + Сканиран на старото устройство + + Опитайте отново + + + Прехвърляне на акаунт + + Акаунтът ви ще бъде прехвърлен на ново устройство. Това устройство ще може да вижда групите и контактите ви, да има достъп до чатовете ви и ще може да изпраща съобщения от ваше име. %1$s + + Научете повече + + Прехвърляне на акаунт + + Съобщенията и информацията за чатовете са защитени чрез криптиране от край до край на всички устройства + + Отключете, за да прехвърлите акаунта + + Продължете на другото си устройство + + Продължете с прехвърлянето на акаунта си на другото устройство. + + + Възстановяването е завършено + + Акаунтът и съобщенията ви в Signal започнаха да се прехвърлят на другото ви устройство. Signal вече не е активен на това устройство. + + Прехвърлянето приключи + + Акаунтът и съобщенията ви в Signal са прехвърлени на другото ви устройство. Signal вече не е активен на това устройство. + + Добре + + \ No newline at end of file diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index 58804ccc99..a119d6d7a0 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -1325,20 +1325,6 @@ আরও গ্রুপের বিবরণ যুক্ত করুন … - - - Android ডিভাইস থেকে ট্রান্সফার করুন - - আপনার পুরানো Android ডিভাইস থেকে আপনার অ্যাকাউন্ট এবং মেসেজ স্থানান্তর করুন। - - স্থানান্তর করা ছাড়াই লগ ইন করুন - - আপনার মেসেজ এবং মিডিয়া স্থানান্তর না করেই চালিয়ে যান - - স্থানীয় ব্যাকআপ পুনর্বহাল করুন - - আপনার ডিভাইসে সংরক্ষিত একটি ব্যাকআপ ফাইল থেকে আপনার মেসেজগুলি পুনরুদ্ধার করুন। - ব্যাকআপ ডাউনলোড করা হচ্ছে… @@ -1356,12 +1342,16 @@ আপনার সকল মেসেজ ব্যাকআপ থেকে পুনরুদ্ধার করুন - + শুধুমাত্র গত %1$d দিনে পাঠানো বা প্রাপ্ত মিডিয়া অন্তর্ভুক্ত করা হয়েছে। আপনার ব্যাকআপের মধ্য়ে যা যা রয়েছে: ব্যাকঅাপ পুনরুদ্ধার + + আপনার সর্বশেষ ব্যাকআপ করা হয়েছিল %1$s তারিখে %2$s-টার সময়। + + ব্যাকআপের তথ্য আনা হচ্ছে… মেনশন করা হলে আমাকে অবহিত করুন @@ -3463,7 +3453,7 @@ অন্যান্য পেমেন্ট (MobileCoin) ডোনেশন এবং ব্যাজ - Signal Android Backup + Signal Android ব্যাকআপ Signal Android বাগ সংক্রান্ত সংশোধন লগ জমা @@ -4304,6 +4294,8 @@ আমি এই পাসফ্রেজ লিখে রেখেছি। এটি ছাড়া, আমি একটি ব্যাকঅাপ পুনরুদ্ধার করতে পারব না। ব্যাকঅাপ পুনরুদ্ধার অ্যাকাউন্ট স্থানান্তর করুন বা পুনরুদ্ধার করুন + + পুনর্বহাল বা ট্রান্সফার করুন অ্যাকাউন্ট স্থানান্তর করুন বাদ দিয়ে যান চ্যাট ব্যাকআপ @@ -5124,7 +5116,7 @@ গ্রুপসমূহ - Only messages from group chats + শুধুমাত্র গ্রুপ চ্যাট থেকে আসা মেসেজ যোগ করুন @@ -6687,7 +6679,7 @@ নীচের \"সেটিংসে যান\" বাটনে ট্যাপ করুন - \"সেটিংস অ্যালার্ম এবং রিমাইন্ডারের অনুমতি দিন\" চালু করুন। + \"অ্যালার্ম এবং রিমাইন্ডার সেট করার অনুমতি দিন\" চালু করুন। সেটিংস-এ যান @@ -7426,11 +7418,11 @@ আপনার Signal মিডিয়া ব্যাকআপ প্ল্যান বাতিল করা হয়েছে, কারণ আমরা আপনার পেমেন্ট প্রক্রিয়া করতে পারিনি। মুছে ফেলার আগে আপনার ব্যাকআপে থাকা মিডিয়া ডাউনলোড করার এটিই শেষ সুযোগ। - Free up %1$s on this device + এই ডিভাইসে %1$s খালি করুন - To finish downloading your Signal Backup your device needs %1$s of storage space. + আপনার সিগন্যাল ব্যাকআপ ডাউনলোড শেষ করতে আপনার ডিভাইসের %1$s স্টোরেজ স্পেস প্রয়োজন। - To free up space offload or delete unused apps or content large in file size. + স্পেস খালি করতে, অব্যবহৃত অ্যাপ বা আকারে বড় এমন বড় আকারের ফাইল অফলোড করুন বা মুছে ফেলুন। আপনার ব্যাকআপ সাবস্ক্রিপশন নবায়ন ব্যর্থ হয়েছে @@ -7458,7 +7450,7 @@ এখন না - Try later + পরে চেষ্টা করুন মিডিয়া মুছে ফেলা হবে @@ -7470,9 +7462,9 @@ বাদ দিয়ে যান - Skip restore? + পুনর্বহাল এড়িয়ে যাবেন? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + আপনি যদি অবশিষ্ট মিডিয়া ও সংযুক্তি পুনর্বহাল এড়িয়ে যান, তবে পরের বার আপনার ডিভাইসে কোনো নতুন ব্যাকআপ সম্পন্ন হলে ব্যাকআপ মুছে যাবে। @@ -7529,10 +7521,6 @@ সাবস্ক্রিপশন পরিবর্তন বা বাতিল করুন - - - আপনার সর্বশেষ ব্যাকআপ করা হয়েছিল %1$s তারিখে %2$s-টার সময়। - চ্যাট সংখ্যার সীমা @@ -7850,5 +7838,101 @@ রিমাইন্ডার আইকন + + + আমার পুরানো ফোন আছে + + দ্রুত শুরু করতে আপনার বর্তমান Signal অ্যাকাউন্ট থেকে একটি QR কোড স্ক্যান করুন + + + আমার পুরানো ফোনটি নেই + + অথবা আপনি একই ডিভাইসে Signal পুনরায় ইনস্টল করছেন + + + অ্যাকাউন্ট পুনর্বহাল বা ট্রান্সফার করুন + + এই ডিভাইসে আপনার Signal অ্যাকাউন্ট এবং মেসেজের ইতিহাস নিন। + + Signal ব্যাকআপ থেকে + + ফ্রি বা পেমেন্টের ভিত্তিতে থাকা ব্যাকআপ প্ল্যান + + একটি ব্যাকআপ ফোল্ডার থেকে + + একটি ব্যাকআপ ফাইল থেকে + + আপনার সংরক্ষিত একটি ব্যাকআপ বেছে নিন + + আপনার পুরানো ফোন থেকে + + আপনার পুরানো Android থেকে সরাসরি ট্রান্সফার করুন + + + স্থানীয় ব্যাকআপ পুনর্বহাল করুন + + আপনার ডিভাইসে আপনার সংরক্ষিত ব্যাকআপ থেকে মেসেজগুলো পুনর্বহাল করুন। আপনি এখন পুনর্বহাল না করলে, আপনি পরে পুনর্বহাল করতে পারবেন না। + + + আপনার ব্যাকআপ \'কি\' লিখুন + + আপনার ব্যাকআপ \'কি\' একটি 64-সংখ্যার কোড যা আপনার অ্যাকাউন্ট ও ডেটা পুনর্বহাল করার সময় প্রয়োজন হবে। + + কোনো ব্যাকআপ \'কি\' নেই? + + ব্যাকআপ \'কি\' + + ব্যাকআপ 64-ডিজিটের পুনর্বহাল কোড ছাড়া পুনর্বহাল করা যাবে না। আপনি যদি আপনার ব্যাকআপ \'কি\' হারিয়ে ফেলেন তবে Signal আপনার ব্যাকআপ পুনর্বহাল করতে সাহায্য করতে সক্ষম হবে না। + + আপনার পুরানো ডিভাইস থাকলে আপনি সেটিংস > চ্যাট > Signal ব্যাকআপে আপনার ব্যাকআপ \'কি\' দেখতে পারবেন। তারপর আপনার \"ব্যাকআপ \'কি\' দেখান\" ট্যাপ করুন। + + আরো জানুন + + এড়িয়ে যান এবং পুনর্বহালের প্রয়োজন নেই + + + আপনার পুরানো ফোন দিয়ে এই কোডটি স্ক্যান করুন + + আপনার পুরানো ডিভাইসে Signal খুলুন + + ক্যামেরা আইকনে ট্যাপ করুন + + ক্যামেরা দিয়ে এই কোডটি স্ক্যান করুন + + QR কোড তৈরি করতে সক্ষম হয়নি + + পুরানো ডিভাইসে স্ক্যান করা হয়েছে + + পুনরায় চেষ্টা করুন + + + অ্যাকাউন্ট স্থানান্তর করুন + + আপনার অ্যাকাউন্টটি একটি নতুন ডিভাইসে ট্রান্সফার হবে। এই ডিভাইসটি আপনার গ্ৰুপ ও কন্টাক্ট দেখতে পাবে, আপনার চ্যাট অ্যাক্সেস করতে পারবে এবং আপনার নামে মেসেজ পাঠাতে সক্ষম হবে। %1$s + + আরো জানুন + + অ্যাকাউন্ট স্থানান্তর করুন + + মেসেজ এবং চ্যাটের তথ্য সকল ডিভাইসে এন্ড-টু-এন্ড এনক্রিপশন দ্বারা সুরক্ষিত + + অ্যাকাউন্ট ট্রান্সফার করতে আনলক করুন + + আপনার অন্য ডিভাইসে চালিয়ে যান + + আপনার অন্য ডিভাইসে আপনার অ্যাকাউন্ট ট্রান্সফার চালিয়ে যান। + + + পুনরুদ্ধার সম্পূর্ণ + + আপনার Signal অ্যাকাউন্ট এবং মেসেজ আপনার অন্য ডিভাইসে ট্রান্সফার শুরু হয়েছে। এই ডিভাইসে Signal এখন নিষ্ক্রিয়। + + স্থানান্তর সম্পূর্ণ + + আপনার Signal অ্যাকাউন্ট এবং মেসেজ আপনার অন্য ডিভাইসে ট্রান্সফার হয়েছে। এই ডিভাইসে Signal এখন নিষ্ক্রিয়। + + ঠিক আছে + + \ No newline at end of file diff --git a/app/src/main/res/values-bs/strings.xml b/app/src/main/res/values-bs/strings.xml index 36b4c451c4..f401c2a37b 100644 --- a/app/src/main/res/values-bs/strings.xml +++ b/app/src/main/res/values-bs/strings.xml @@ -1401,20 +1401,6 @@ više Unesite opis grupe… - - - Prenesi sa Android uređaja - - Prenesite svoj račun i poruke sa starog Android uređaja. - - Prijavite se bez prijenosa - - Nastavite bez prijenosa vaših poruka i medija - - Vratite lokalnu sigurnosnu kopiju - - Vratite poruke iz sigurnosne kopije koju ste sačuvali na svom uređaju. - Preuzimanje sigurnosne kopije… @@ -1432,12 +1418,16 @@ Sve vaše poruke Vrati iz rezervne kopije - + Uključeni su samo medijski sadržaji poslani ili primljeni u proteklih %1$d dan(a). Vaša sigurnosna kopija uključuje: Vrati podatke + + Vaša zadnja sigurnosna kopija je napravljena %1$s u %2$s. + + Preuzimanje detalja sigurnosne kopije… Obavijesti me kad me neko spomene @@ -3661,7 +3651,7 @@ Ostalo Plaćanja (MobileCoin) Donacije i značke - Signal Android Backup + Sigurnosna kopija za Signal Android Signal Android evidencija o otklanjanju grešaka @@ -4526,6 +4516,8 @@ Zapisao/la sam ovu lozinku. Bez nje neću moći vratiti podatke iz rezervne kopije. Vrati podatke Prenesi ili vrati račun + + Vratite ili prenesite Prenesi račun Preskoči Rezervne kopije chata @@ -5364,7 +5356,7 @@ Grupe - Only messages from group chats + Samo poruke iz grupnih chatova Dodaj @@ -7748,11 +7740,11 @@ Vaš Signal paket sigurnosne kopije medija je otkazan jer nismo mogli obraditi vašu uplatu. Ovo je vaša posljednja prilika da preuzmete medij u vašoj sigurnosnoj kopiji prije nego što se izbriše. - Free up %1$s on this device + Oslobodite %1$s na ovom uređaju - To finish downloading your Signal Backup your device needs %1$s of storage space. + Da završite preuzimanje sigurnosne kopije Signala, vašem uređaju je potrebno %1$s prostora za pohranu. - To free up space offload or delete unused apps or content large in file size. + Da oslobodite prostor, skinite ili izbrišite nekorištene aplikacije ili sadržaj velike veličine datoteke. Vaša pretplata na sigurnosne kopije nije obnovljena @@ -7780,7 +7772,7 @@ Ne sada - Try later + Pokušajte kasnije Medijski sadržaji će se izbrisati @@ -7792,9 +7784,9 @@ Preskoči - Skip restore? + Preskočiti vraćanje? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Ako preskočite vraćanje, preostali medijski sadržaji i prilozi u vašoj sigurnosnoj kopiji bit će izbrisani sljedeći put kada vaš uređaj dovrši novu sigurnosnu kopiju. @@ -7851,10 +7843,6 @@ Promijenite ili otkažite pretplatu - - - Vaša zadnja sigurnosna kopija je napravljena %1$s u %2$s. - Ograničenja dužine razgovora @@ -8184,5 +8172,101 @@ Ikona podsjetnika + + + Imam svoj stari telefon + + Skenirajte QR kod sa svog trenutnog Signal računa da brzo započnete + + + Nemam svoj stari telefon + + Ili ponovo instalirate Signal na istom uređaju + + + Vratite ili prenesite račun + + Prenesite svoj Signal račun i historiju poruka na ovaj uređaj. + + Iz sigurnosnih kopija Signala + + Vaš besplatni ili plaćeni plan sigurnosne kopije Signala + + Iz foldera rezervne kopije + + Iz datoteke rezervne kopije + + Odaberite rezervnu kopiju koju ste sačuvali + + Sa starog telefona + + Prenesite direktno sa svog starog Androida + + + Vratite lokalnu sigurnosnu kopiju + + Vratite svoje poruke iz sigurnosne kopije koju ste sačuvali na svom uređaju. Ako ih ne vratite sada, nećete ih moći vratiti kasnije. + + + Unesite rezervni ključ + + Vaš rezervni ključ je 64-cifreni kod potreban za oporavak vašeg računa i podataka. + + Nemate rezervni ključ? + + Rezervni ključ + + Sigurnosne kopije se ne mogu oporaviti bez 64-cifrenog koda za oporavak. Ako ste izgubili rezervni ključ, Signal vam ne može pomoći da vratite rezervnu kopiju. + + Ako imate svoj stari uređaj, možete vidjeti svoj rezervni ključ u Postavkama > Chatovi > Sigurnosne kopije Signala. Zatim dodirnite Prikaži rezervni ključ. + + Saznaj više + + Preskoči i ne vraćaj + + + Skenirajte ovaj kod svojim starim telefonom + + Otvorite Signal na svom starom uređaju + + Dodirnite ikonu kamere + + Skenirajte ovaj kod kamerom + + Nije moguće generirati QR kod + + Skenirano na starom uređaju + + Pokušaj + + + Prenesi račun + + Vaš račun će se prebaciti na novi uređaj. Na ovom uređaju ćete moći vidjeti grupe i kontakte, pristupiti vašim razgovorima i slati poruke u svoje ime. %1$s + + Saznaj više + + Prenesi račun + + Poruke i informacije o chatu su zaštićene sveobuhvatnim šifriranjem na svim uređajima + + Otključajte za prijenos računa + + Nastavite na svom drugom uređaju + + Nastavite s prijenosom računa na svom drugom uređaju. + + + Vraćanje podataka je završeno + + Vaš Signal račun i poruke su počeli da se prenose na vaš drugi uređaj. Signal je sada neaktivan na ovom uređaju. + + Prenos je završen + + Vaš Signal račun i poruke su prebačeni na vaš drugi uređaj. Signal je sada neaktivan na ovom uređaju. + + U redu + + \ No newline at end of file diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 8197ea9d92..9aa1df81c5 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -1325,20 +1325,6 @@ més Afegiu-hi una descripció del grup. - - - Transfereix des d\'un dispositiu d\'Android - - Transfereix el teu compte i missatges des del teu antic dispositiu Android. - - Inicia sessió sense transferir - - Continua sense transferir els teus missatges i arxius multimèdia - - Restaurar la còpia de seguretat local - - Restaurar els teus missatges des d\'una còpia de seguretat del teu dispositiu. - S\'està descarregant la còpia de seguretat… @@ -1356,12 +1342,16 @@ Tots els teus missatges Restaura des d\'una còpia de seguretat - + Només s\'inclouen els arxius enviats o rebuts durant els darrers %1$d dies. La teva còpia de seguretat inclou: Restaura la còpia de seguretat + + La teva darrera còpia de seguretat es va fer el dia %1$s a les %2$s. + + Obtenint detalls de la còpia de seguretat… Notifica\'m les mencions @@ -3463,7 +3453,7 @@ Altres Pagaments (MobileCoin) Donacions; Insígnies - Signal Android Backup + Còpia de seguretat de Signal Android Enviar registre de depuració de Signal per a Android @@ -4304,6 +4294,8 @@ He anotat aquesta contrasenya. Sense, no podré restaurar cap còpia de seguretat. Restaura la còpia de seguretat Transfereix o restaura el compte + + Restaurar o transferir Transfereix el compte Omet Còpies de seguretat de xats @@ -5124,7 +5116,7 @@ Grups - Only messages from group chats + Només missatges de xats grupals Afegeix @@ -7426,11 +7418,11 @@ S\'ha cancel·lat el teu pla de còpia de seguretat d\'arxius a Signal perquè no hem pogut processar el teu pagament. Aquesta és la darrera oportunitat de descarregar els arxius de la teva còpia de seguretat abans que aquesta sigui eliminada. - Free up %1$s on this device + Allibera %1$s en aquest dispositiu - To finish downloading your Signal Backup your device needs %1$s of storage space. + Per acabar de descarregar la teva còpia de seguretat de Signal, el teu dispositiu necessita %1$s d\'espai d\'emmagatzematge. - To free up space offload or delete unused apps or content large in file size. + Per alliberar espai, elimina les aplicacions o contingut amb arxius grans que no utilitzis. La teva subscripció al pla de còpies de seguretat no s\'ha pogut renovar @@ -7458,7 +7450,7 @@ Ara no - Try later + Provar-ho més tard Els arxius s\'eliminaran @@ -7470,9 +7462,9 @@ Omet - Skip restore? + Ometre la restauració? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Si omets la restauració, la resta d\'arxius multimèdia de la teva còpia de seguretat s\'eliminaran la pròxima vegada que el teu dispositiu realitzi una còpia de seguretat. @@ -7529,10 +7521,6 @@ Canviar o cancel·lar la subscripció - - - La teva darrera còpia de seguretat es va fer el dia %1$s a les %2$s. - Límits del xat @@ -7850,5 +7838,101 @@ Icona de recordatori + + + Tinc el meu telèfon antic + + Per començar ràpidament, escaneja un codi QR des del teu compte actual de Signal + + + No conservo el meu telèfon antic + + O estàs reinstal·lant Signal en el mateix dispositiu + + + Restaurar o transferir compte + + Recupera el teu compte de Signal i l\'historial de missatges en aquest dispositiu. + + Des de Còpies de seguretat de Signal + + El teu pla gratuït o de pagament de Còpies de seguretat de Signal + + Des d\'una carpeta de còpies de seguretat + + Des d\'una còpia de seguretat + + Tria una còpia de seguretat que hagis guardat + + Des del teu telèfon antic + + Transfereix directament des del teu antic Android + + + Restaurar la còpia de seguretat local + + Restaura els teus missatges des d\'una còpia de seguretat del teu dispositiu. Si no la restaures ara, no podràs restaurar-la més tard. + + + Introdueix la còpia de seguretat de la clau + + La teva còpia de seguretat de la clau és un codi de 64 dígits i és necessària per recuperar el teu compte i les teves dades. + + ¿No tens la teva clau? + + Còpia de seguretat de la clau + + Les còpies de seguretat no es poden recuperar sense el seu codi de recuperació de 64 dígits. Si has perdut la teva clau, Signal no pot ajudar-te a restaurar la còpia de seguretat. + + Si conserves el teu dispositiu antic, podràs veure la teva còpia de seguretat de la clau a Ajustos> Xats > Còpies de seguretat de Signal. Llavors, toca Veure la còpia de seguretat de la clau. + + Més informació + + Ometre la restauració + + + Escaneja aquest codi amb el teu mòbil antic + + Obre Signal al teu dispositiu antic + + Toca la icona de la càmera + + Escaneja aquest codi amb la càmera + + No es pot generar un codi QR + + Escanejat en l\'antic dispositiu + + Torna a provar-ho + + + Transfereix el compte + + El teu compte es transferirà al teu nou dispositiu. El nou dispositiu podrà veure els teus grups i contactes, accedir als xats i enviar missatges en nom teu. %1$s + + Més informació + + Transfereix el compte + + Els missatges i la informació del xat estan protegits mitjançant una codificació d\'extrem a extrem a tots els dispositius + + Desbloqueja per transferir el compte + + Continua en el teu altre dispositiu + + Continua transferint el teu compte en el teu altre dispositiu. + + + Restauració completa + + El teu compte de Signal i els teus missatges s\'han començat a transferir al teu altre dispositiu. Actualment, Signal està inactiu en aquest dispositiu. + + Transferència completa + + El teu compte de Signal i els teus missatges no s\'han pogut transferir al teu altre dispositiu. Actualment, Signal està inactiu en aquest dispositiu. + + D\'acord + + \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 6b411cc119..28cfc29587 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1401,20 +1401,6 @@ více Přidejte popisek skupiny… - - - Přenést z Android zařízení - - Přeneste svůj účet a zprávy ze svého starého zařízení se systémem Android. - - Přihlásit se bez přenosu - - Pokračovat bez přenosu zpráv a médií - - Obnovit místní zálohu - - Obnovte zprávy ze záložního souboru, který jste uložili do svého zařízení. - Stahování zálohy… @@ -1432,12 +1418,16 @@ Všechny vaše zprávy Obnovit ze zálohy - + Zahrnuta jsou pouze média odeslaná nebo přijatá v posledních %1$d dnech. Vaše záloha obsahuje: Obnovit zálohu + + Poslední záloha proběhla dne %1$s v %2$s. + + Načítají se údaje o zálohování… Upozornit mne na zmínky @@ -3653,15 +3643,15 @@ Nepodařilo se nahrát logy Popište detailně váš problém, aby to bylo pro nás srozumitelné. - Vyberte prosím jednu z možností + Vyberte jednu z možností Něco nefunguje - Žádost o novou funkci + Požadavek na funkci Dotaz Zpětná vazba - Ostatní + Jiné Platby (MobileCoin) Příspěvky a odznaky - Signal Android Backup + Signal Android zálohování Odeslání protokolu ladění aplikace Signal pro Android @@ -4526,6 +4516,8 @@ Heslo jsem si zapsal. Bez něj nebudu schopen obnovit data ze zálohy. Obnovit zálohu Přenést nebo obnovit účet + + Obnovení nebo přenos Přenést účet Přeskočit Zálohy chatů @@ -5364,7 +5356,7 @@ Skupiny - Only messages from group chats + Pouze zprávy ze skupinových chatů Přidat @@ -6991,7 +6983,7 @@ Klepněte na tlačítko „Přejít do nastavení“ níže - Zapněte možnost „Povolit varování a připomenutí“. + Zapněte možnost „Povolit nastavení upozornění a připomenutí“. Přejít do nastavení @@ -7748,11 +7740,11 @@ Váš plán zálohování médií v aplikaci Signal byl zrušen, protože nešlo zpracovat vaši platbu. Toto je poslední možnost stáhnout média ze zálohy, než budou odstraněna. - Free up %1$s on this device + Uvolněte %1$s v tomto zařízení - To finish downloading your Signal Backup your device needs %1$s of storage space. + Pro kompletní stažení vaší Signal zálohy je nutné, aby vaše zařízení mělo %1$s volného místa. - To free up space offload or delete unused apps or content large in file size. + Místo můžete uvolnit odstraněním nepoužívaných aplikací nebo velkých souborů. Předplatné pro zálohování se nepodařilo obnovit @@ -7780,7 +7772,7 @@ Nyní ne - Try later + Zkuste to později Média budou odstraněna @@ -7792,9 +7784,9 @@ Přeskočit - Skip restore? + Přeskočit obnovení? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Pokud zvolíte možnost Přeskočit obnovení, budou média a přílohy v záloze odstraněny při příštím dokončení nové zálohy v zařízení. @@ -7851,10 +7843,6 @@ Změnit nebo zrušit předplatné - - - Poslední záloha proběhla dne %1$s v %2$s. - Limity délky historie chatu @@ -8184,5 +8172,101 @@ Ikona připomenutí + + + Mám svůj starý telefon + + Naskenujte QR kód ze svého aktuálního účtu Signal a účet můžete hned začít používat + + + Nemám svůj starý telefon + + Nebo přeinstalováváte aplikaci Signal na stejném zařízení + + + Obnovení nebo přenos účtu + + Přeneste svůj účet Signal a historii zpráv do tohoto zařízení. + + Ze zálohování služby Signal + + Váš bezplatný nebo placený plán zálohování služby Signal + + Ze záložní složky + + Ze záložního souboru + + Vyberte uloženou zálohu + + Ze starého telefonu + + Přenos přímo ze starého systému Android + + + Obnovit místní zálohu + + Obnovte své zprávy ze zálohy uložené v tomto zařízení. Pokud je neobnovíte teď, nebude to už později možné. + + + Zadejte svůj záložní klíč + + Záložní klíč je 64místný kód potřebný k obnovení účtu a dat. + + Nemáte záložní klíč? + + Záložní klíč + + Bez 64místného kódu pro obnovení nelze zálohy obnovit. Pokud jste záložní klíč ztratili, nemůže vám služba Signal s obnovením zálohy pomoci. + + Pokud máte staré zařízení, můžete si záložní klíč zobrazit v Nastavení > Chaty > Zálohování služby Signal. Poté klepněte na Zobrazit záložní klíč. + + Zjistit více + + Přeskočit a neobnovovat + + + Naskenujte tento kód pomocí starého telefonu + + Otevřete aplikaci Signal ve starém zařízení + + Klepněte na ikonu fotoaparátu + + Naskenujte tento kód pomocí fotoaparátu + + QR kód se nepodařilo vygenerovat + + Již naskenován na starém zařízení + + Zkusit znovu + + + Přenést účet + + Váš účet bude přenesen do nového zařízení. Toto zařízení bude moci vidět vaše skupiny a kontakty, mít přístup k chatům a posílat zprávy vaším jménem. %1$s + + Zjistit více + + Přenést účet + + Zprávy a informace o chatech jsou na všech zařízeních chráněny koncovým šifrováním + + Odblokování pro přenos účtu + + Pokračujte na svém druhém zařízení + + Pokračujte v přenosu účtu do druhého zařízení. + + + Obnovení dokončeno + + Váš účet Signal a zprávy se začaly přenášet do vašeho druhého zařízení. Aplikace Signal už není v tomto zařízení aktivní. + + Přenos dokončen + + Váš účet Signal a zprávy byly přeneseny do vašeho druhého zařízení. Aplikace Signal už není v tomto zařízení aktivní. + + V pořádku + + \ No newline at end of file diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index abb02d1df1..522855adf3 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1325,20 +1325,6 @@ mere Tilføj gruppebeskrivelse … - - - Overfør fra Android-enhed - - Overfør din konto og beskeder fra din gamle Android-enhed. - - Log ind uden at overføre - - Fortsæt med at overføre dine beskeder og medier - - Genopret lokal sikkerhedskopi - - Gendanner dine beskeder fra en sikkerhedskopi, du har gemt på din enhed. - Downloader sikkerhedskopi… @@ -1356,12 +1342,16 @@ Alle dine beskeder Gendan fra sikkerhedskopi - + Inkluderer kun medier sendt eller modtaget i løbet af de seneste %1$d dage. Din sikkerhedskopi omfatter: Gendan sikkerhedskopi + + Din sidste sikkerhedskopi blev lavet %1$s kl. %2$s. + + Henter sikkerhedskopieringsoplysninger… Underret mig ved omtaler @@ -3463,7 +3453,7 @@ Andet Betalinger (MobileCoin) Donationer og badges - Signal Android Backup + Signal-sikkerhedskopi på Android Indsendelse af fejlretningslog fra Signal Android @@ -4304,6 +4294,8 @@ Jeg har skrevet adgangssætningen ned. Uden den vil jeg ikke have mulighed for at gendanne fra sikkerhedskopi. Gendan sikkerhedskopi Overfør eller gendan konto + + Gendan eller overfør Overfør konto Spring over Sikkerhedskopier af chats @@ -5124,7 +5116,7 @@ Grupper - Only messages from group chats + Kun beskeder fra gruppechats Tilføj @@ -6687,7 +6679,7 @@ Tryk på knappen \"Gå til indstillinger\" nedenfor - Slå \"Tillad indstillinger alarmer og påmindelser\" til. + Slå \"Tillad indstilling af alarmer og påmindelser\" til. Gå til indstillinger @@ -7426,11 +7418,11 @@ Dit abonnement for sikkerhedskopiering af Signal medier er blevet opsagt, fordi vi ikke kunne behandle din betaling. Dette er din sidste chance for at downloade medierne i din sikkerhedskopi, før de slettes. - Free up %1$s on this device + Frigør %1$s på denne enhed - To finish downloading your Signal Backup your device needs %1$s of storage space. + For at afslutte download af din Signal-sikkerhedskopi skal din enhed have %1$s lagerplads. - To free up space offload or delete unused apps or content large in file size. + For at frigøre plads kan du afinstallere eller slette ubrugte apps eller indhold med stor filstørrelse. Dit sikkerhedskopieringsabonnement kunne ikke fornyes @@ -7458,7 +7450,7 @@ Ikke nu - Try later + Prøv senere Medier slettes @@ -7470,9 +7462,9 @@ Spring over - Skip restore? + Vil du springe gendannelse over? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Hvis du springer gendannelse over, slettes medierne og de vedhæftede filer i sikkerhedskopien næste gang din enhed laver en sikkerhedskopi. @@ -7529,10 +7521,6 @@ Ændring eller opsigelse af abonnement - - - Din sidste sikkerhedskopi blev lavet %1$s kl. %2$s. - Chatgrænser @@ -7850,5 +7838,101 @@ Påmindelsesikon + + + Jeg har min gamle telefon + + Scan en QR-kode fra din nuværende Signal-konto for at komme hurtigt i gang + + + Jeg har ikke min gamle telefon + + Eller hvis du geninstallerer Signal på den samme enhed + + + Gendan eller overfør konto + + Få din Signal-konto og -beskedhistorik på denne enhed. + + Fra Signal-sikkerhedskopier + + Dit gratis eller betalte abonnement på Signal-sikkerhedskopier + + Fra en sikkerhedskopimappe + + Fra en sikkerhedskopifil + + Vælg en sikkerhedskopi, du har gemt + + Fra din gamle telefon + + Overfør direkte fra din gamle Android + + + Genopret lokal sikkerhedskopi + + Gendan dine beskeder fra en sikkerhedskopi, du har gemt på din enhed. Hvis du ikke gendanner nu, kan du ikke gendanne senere. + + + Angiv din sikkerhedskopinøgle + + Din sikkerhedskopinøgle er en 64-cifret kode, der bruges til at gendanne din konto og dine data. + + Har du ingen sikkerhedskopinøgle? + + Sikkerhedskopinøgle + + Sikkerhedskopier kan ikke gendannes uden den 64-cifrede gendannelseskode. Hvis du har mistet din sikkerhedskopinøgle, kan Signal ikke hjælpe med at gendanne din sikkerhedskopi. + + Hvis du har din gamle enhed, kan du se din sikkerhedskopinøgle under Indstillinger > Chats > Signal-sikkerhedskopier. Klik derefter Vis sikkerhedskopinøgle. + + Få mere at vide + + Spring over uden gendannelse + + + Scan denne kode med din gamle telefon + + Åbn Signal på din gamle enhed + + Tryk på kameraikonet + + Scan denne kode med kameraet + + Kunne ikke generere QR-kode + + Scannet på gammel enhed + + Prøv igen + + + Overfør konto + + Din konto bliver overført til en ny enhed. På denne enhed vil du kunne se dine grupper og kontakter, finde dine chats og sende beskeder fra din konto. %1$s + + Få mere at vide + + Overfør konto + + Beskeder og chatoplysninger er beskyttet af end-to-end-kryptering på alle enheder + + Lås op for at overføre konto + + Fortsæt på din anden enhed + + Fortsæt med at overføre din konto på din anden enhed. + + + Gendannelse afsluttet + + Din Signal-konto og dine beskeder er ved at blive overført til din anden enhed. Signal er nu inaktiv på denne enhed. + + Overførslen fuldført + + Din Signal-konto og dine beskeder er blevet overført til din anden enhed. Signal er nu inaktiv på denne enhed. + + Okay + + \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 072fefeac7..74cc39bd4b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1325,20 +1325,6 @@ mehr Gruppenbeschreibung hinzufügen … - - - Von Android-Gerät übertragen - - Übertrage dein Konto und deine Nachrichten von deinem alten Android-Gerät. - - Ohne Übertragung einloggen - - Fortfahren, ohne deine Nachrichten und Medien zu übertragen - - Lokale Datensicherung wiederherstellen - - Stelle deine Nachrichten mithilfe einer auf deinem Gerät gespeicherten Datensicherungs-Datei wieder her. - Sicherungsdaten werden heruntergeladen … @@ -1356,12 +1342,16 @@ Alle deine Nachrichten Aus Sicherung wiederherstellen - + Enthält ausschließlich Medien, die in den letzten %1$d Tagen gesendet oder erhalten wurden. Deine Datensicherung umfasst: Sicherung wiederherstellen + + Deine letzte Datensicherung wurde am %1$s um %2$s durchgeführt. + + Backup-Details abrufen… Mich bei Erwähnungen benachrichtigen @@ -3460,7 +3450,7 @@ Erweiterungswunsch Frage Rückmeldung - Sonstige + Sonstiges Zahlungen (MobileCoin) Spenden und Abzeichen Signal Android Backup @@ -4304,6 +4294,8 @@ Ich habe mir diese Passphrase notiert. Ohne sie können keine Sicherungen wiederhergestellt werden. Sicherung wiederherstellen Konto übertragen oder wiederherstellen + + Wiederherstellen oder übertragen Konto übertragen Überspringen Chat-Sicherungen @@ -5124,7 +5116,7 @@ Gruppen - Only messages from group chats + Nur Nachrichten von Gruppenchats Hinzufügen @@ -6275,7 +6267,7 @@ Signal-Kontakte - Signal-Kontakte sind Menschen, denen du vertraust, entweder durch: + Signal-Kontakte sind Personen, denen du vertraust, entweder durch: Starten eines Chats @@ -6687,7 +6679,7 @@ Tippe unten auf die Schaltfläche »Zu den Einstellungen«. - Aktiviere »Alarme und Erinnerungen zulassen«. + Aktiviere die Einstellung »Alarme und Erinnerungen zulassen«. Zu Einstellungen @@ -6846,7 +6838,7 @@ Deine Kontodaten finden - Du findest deine IBAN oben auf deinem Kontoauszug. IBAN bestehen aus bis zu 34 Zeichen. Der Name, den du eingibst, sollte mit dem vollständigen Namen, der für dein Bankkonto angegeben ist, übereinstimmen. Kontaktiere deine Bank, um weitere Informationen zu erhalten. + Du findest deine IBAN auf deinem Kontoauszug. IBAN bestehen aus bis zu 34 Zeichen. Der Name, den du eingibst, sollte mit dem vollständigen Namen, der für dein Bankkonto angegeben ist, übereinstimmen. Kontaktiere deine Bank, um weitere Informationen zu erhalten. Spende ausstehend @@ -7244,7 +7236,7 @@ Scannen - Scanne den QR-Code auf dem Gerät deines Kontakts ein. + Scanne den QR-Code deines Kontakts ein. Farbe @@ -7424,13 +7416,13 @@ Deine Medien werden heute gelöscht. - Dein Signal Medien-Datensicherungsplan wurde gekündigt, weil wir deine Zahlung nicht bearbeiten konnten. Dies ist deine letzte Chance, die Medien in deiner Datensicherung herunterzuladen, bevor sie gelöscht werden. + Dein Signal Backup-Abo für Medien wurde gekündigt, weil wir deine Zahlung nicht bearbeiten konnten. Dies ist deine letzte Chance, die Medien in deiner Datensicherung herunterzuladen, bevor sie gelöscht werden. - Free up %1$s on this device + Gib auf diesem Gerät %1$s frei. - To finish downloading your Signal Backup your device needs %1$s of storage space. + Um das Herunterladen deines Signal Backups abzuschließen, braucht dein Gerät %1$s Speicherplatz. - To free up space offload or delete unused apps or content large in file size. + Lagere ungenutzte Apps oder Inhalte mit großer Dateigröße aus oder lösche sie, um Speicherplatz freizugeben. Dein Backup-Abo konnte nicht verlängert werden @@ -7458,7 +7450,7 @@ Jetzt nicht - Try later + Versuche es später. Medien werden gelöscht @@ -7470,9 +7462,9 @@ Überspringen - Skip restore? + Wiederherstellen überspringen? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Wenn du »Wiederherstellen« überspringst, werden die verbleibenden Medien und Anhänge in deinem Backup gelöscht, wenn dein Gerät das nächste Mal eine Datensicherung durchführt. @@ -7529,10 +7521,6 @@ Regelmäßige Spende ändern oder kündigen - - - Deine letzte Datensicherung wurde am %1$s um %2$s durchgeführt. - Chat-Beschränkungen @@ -7633,7 +7621,7 @@ Du hast Datensicherungen ausgeschaltet - Datensicherungsplan + Backup-Abo Datensicherung deaktiviert @@ -7643,7 +7631,7 @@ Warten auf Netzwerk… - Dein Datensicherungsplan ist kostenlos + Dein Backup-Abo ist kostenlos Verlängert sich am %1$s @@ -7834,7 +7822,7 @@ Deine Medien der letzten %1$d Tage - Aktueller Plan + Aktuelles Abo @@ -7850,5 +7838,101 @@ Erinnerung-Icon + + + Ich habe mein altes Telefon + + Scanne einen QR-Code von deinem aktuellen Signal-Konto, um schnell loslegen zu können + + + Ich habe mein altes Telefon nicht + + Oder du installierst Signal auf dem gleichen Gerät erneut + + + Konto wiederherstellen oder übertragen + + Hol dir dein Signal-Konto und deinen Nachrichtenverlauf auf dieses Gerät. + + Von Signal Backups + + Dein kostenloses oder bezahltes Signal Backup-Abo + + Von einem Backup-Ordner + + Von einer Backup-Datei + + Wähle ein von dir gespeichertes Backup aus + + Von deinem alten Telefon + + Direkt von deinem alten Android-Telefon übertragen + + + Lokale Datensicherung wiederherstellen + + Stelle deine Nachrichten mithilfe einer auf deinem Gerät gespeicherten Datensicherung wieder her. Falls du sie jetzt nicht wiederherstellst, wirst du dies später nicht mehr nachholen können. + + + Gib deinen Datensicherungsschlüssel ein + + Dein Datensicherungsschlüssel ist ein 64-stelliger Code, mit dem du dein Konto und deine Daten wiederherstellen kannst. + + Kein Datensicherungsschlüssel? + + Datensicherungsschlüssel + + Datensicherungen können ohne den 64-stelligen Code nicht wiederhergestellt werden. Wenn du deinen Datensicherungsschlüssel verloren hast, kann dir Signal nicht helfen, dein Backup wiederherzustellen. + + Auf deinem alten Gerät kannst du deinen Datensicherungsschlüssel in Einstellungen > Chats > Signal-Backups sehen. Tippe dann auf »Datensicherungsschlüssel anzeigen« + + Mehr erfahren + + Überspringen und nicht wiederherstellen + + + Scanne diesen Code mit deinem alten Telefon + + Öffne Signal auf deinem alten Gerät + + Tippe auf das Kamera-Symbol + + Scanne diesen Code mit der Kamera + + QR-Code kann nicht erstellt werden + + Auf altem Gerät gescannt + + Erneut versuchen + + + Konto übertragen + + Dein Konto wird auf ein neues Gerät übertragen. Dieses Gerät kann deine Gruppen und Kontakte sehen, auf deine Chats zugreifen und Nachrichten in deinem Namen versenden. %1$s + + Mehr erfahren + + Konto übertragen + + Nachrichten und Chat-Infos sind auf allen Geräten durch eine Ende-zu-Ende-Verschlüsselung geschützt + + Entsperren, um Konto zu übertragen + + Fahre auf deinem anderen Gerät fort + + Fahre damit fort, dein Konto auf dein anderes Gerät zu übertragen. + + + Wiederherstellung abgeschlossen + + Die Übertragung deines Signal-Kontos und deiner Nachrichten auf dein anderes Gerät hat begonnen. Signal ist jetzt auf diesem Gerät inaktiv. + + Übertragung vollständig + + Dein Signal-Konto und deine Nachrichten wurden auf dein anderes Gerät übertragen. Signal ist jetzt auf diesem Gerät inaktiv. + + OK + + \ No newline at end of file diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 00126c276e..e8c4431d45 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1325,20 +1325,6 @@ περισσότερα Εισαγωγή περιγραφής της ομάδας… - - - Μεταφορά από συσκευή Android - - Μετάφερε τον λογαριασμό σου και τα μηνύματά σου από την παλιά σου συσκευή Android. - - Σύνδεση χωρίς μεταφορά - - Συνέχεια χωρίς μεταφορά μηνυμάτων και πολυμέσων - - Επαναφορά τοπικού αντιγράφου ασφαλείας - - Επανάφερε τα μηνύματά σου από ένα αρχείο αντιγράφων ασφαλείας που έχεις αποθηκεύσει στη συσκευή σου. - Λήψη αντιγράφων ασφαλείας… @@ -1356,12 +1342,16 @@ Όλα τα μηνύματά σου Επαναφορά από αντίγραφο ασφαλείας - + Συμπεριλαμβάνονται μόνο τα πολυμέσα που εστάλησαν ή ελήφθησαν τις τελευταίες %1$d ημέρες. Τα αντίγραφα ασφαλείας περιλαμβάνουν: Επαναφορά αντίγραφου ασφαλείας + + Το τελευταίο σου αντίγραφο ασφαλείας έγινε στις %1$s στις %2$s. + + Λήψη στοιχείων αντιγράφων ασφαλείας… Να ειδοποιούμαι για αναφορές @@ -3463,7 +3453,7 @@ Άλλο Πληρωμές (MobileCoin) Δωρεές και Σήματα - Signal Android Backup + Αντίγραφα ασφαλείας Signal Android Αποστολή αρχείου συμβάντων αποσφαλμάτωσης Signal Android @@ -4304,6 +4294,8 @@ Σημείωσα σε χαρτί αυτό το συνθηματικό. Χωρίς αυτό, δε θα μπορέσω να επανακτήσω το αντίγραφο ασφαλείας. Επαναφορά αντίγραφου ασφαλείας Μεταφορά ή επαναφορά λογαριασμού + + Επαναφορά ή μεταφορά Μεταφορά λογαριασμού Παράλειψη Αντίγραφα ασφαλείας συνομιλιών @@ -5124,7 +5116,7 @@ Ομάδες - Only messages from group chats + Μόνο μηνύματα από ομαδικές συνομιλίες Προσθήκη @@ -7426,11 +7418,11 @@ Το πρόγραμμα δημιουργίας αντιγράφων ασφαλείας των πολυμέσων Signal ακυρώθηκε επειδή δεν μπορέσαμε να επεξεργαστούμε την πληρωμή σου. Αυτή είναι η τελευταία σου ευκαιρία να κατεβάσεις τα πολυμέσα σου ως αντίγραφα ασφαλείας πριν διαγραφούν. - Free up %1$s on this device + Απελευθέρωσε %1$s σε αυτή τη συσκευή - To finish downloading your Signal Backup your device needs %1$s of storage space. + Για να ολοκληρώσεις τη λήψη του αντιγράφου ασφαλείας Signal, η συσκευή σου χρειάζεται %1$s αποθηκευτικό χώρο. - To free up space offload or delete unused apps or content large in file size. + Για να ελευθερώσεις χώρο, μείωσε τον φόρτο ή διάγραψε αχρησιμοποίητες εφαρμογές ή περιεχόμενο μεγάλου μεγέθους. Η συνδρομή σου για δημιουργία αντιγράφων ασφαλείας δεν ανανεώθηκε @@ -7458,7 +7450,7 @@ Όχι τώρα - Try later + Δοκίμασε αργότερα Τα πολυμέσα θα διαγραφούν @@ -7470,9 +7462,9 @@ Παράλειψη - Skip restore? + Παράλειψη επαναφοράς; - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Αν επιλέξεις «Παράλειψη επαναφοράς», τα υπόλοιπα πολυμέσα και συνημμένα στο αντίγραφο ασφαλείας σου θα διαγραφούν την επόμενη φορά που η συσκευή σου θα ολοκληρώσει ένα νέο αντίγραφο ασφαλείας. @@ -7529,10 +7521,6 @@ Αλλαγή ή ακύρωση συνδρομής - - - Το τελευταίο σου αντίγραφο ασφαλείας έγινε στις %1$s στις %2$s. - Όρια συνομιλίας @@ -7850,5 +7838,101 @@ Εικονίδιο υπενθύμισης + + + Έχω το παλιό μου τηλέφωνο + + Σάρωσε έναν κωδικό QR από τον τρέχοντα λογαριασμό σου στο Signal για να ξεκινήσεις γρήγορα + + + Δεν έχω το παλιό μου τηλέφωνο + + Ή επανεγκαθιστάς το Signal στην ίδια συσκευή + + + Επαναφορά ή μεταφορά λογαριασμού + + Απόκτησε τον λογαριασμό σου στο Signal και το ιστορικό μηνυμάτων σε αυτήν τη συσκευή. + + Από τα αντίγραφα ασφαλείας Signal + + Το δωρεάν ή επί πληρωμή πρόγραμμα Signal Backup + + Από έναν φάκελο αντιγράφου ασφαλείας + + Από ένα αρχείο αντιγράφου ασφαλείας + + Επίλεξε ένα αντίγραφο ασφαλείας που έχεις αποθηκεύσει + + Από το παλιό σου τηλέφωνο + + Απευθείας μεταφορά από το παλιό σου Android + + + Επαναφορά τοπικού αντιγράφου ασφαλείας + + Επανάφερε τα μηνύματά σου από το αντίγραφο ασφαλείας που έχεις αποθηκεύσει στη συσκευή σου. Αν δεν τα επαναφέρεις τώρα, δε θα μπορείς να τα επαναφέρεις αργότερα. + + + Γράψε το εφεδρικό κλειδί σου + + Το εφεδρικό κλειδί σου είναι ένας 64ψήφιος κωδικός που απαιτείται για την ανάκτηση του λογαριασμού και των δεδομένων σου. + + Δεν υπάρχει εφεδρικό κλειδί; + + Εφεδρικό κλειδί + + Τα αντίγραφα ασφαλείας δεν μπορούν να ανακτηθούν χωρίς τον 64ψήφιο κωδικό ανάκτησης. Εάν έχεις χάσει το εφεδρικό κλειδί σου, το Signal δεν μπορεί να βοηθήσει στην επαναφορά του αντιγράφου ασφαλείας σου. + + Εάν έχεις την παλιά σου συσκευή, μπορείς να δεις το εφεδρικό κλειδί σου στις Ρυθμίσεις > Συνομιλίες > Αντίγραφα ασφαλείας Signal. Στη συνέχεια, πάτα Προβολή εφεδρικού κλειδιού. + + Μάθε περισσότερα + + Παράλειψη, χωρίς επαναφορά + + + Σάρωσε αυτόν τον κωδικό με το παλιό σου τηλέφωνο + + Άνοιξε το Signal στη παλιά σου συσκευή + + Πάτα το εικονίδιο της κάμερας + + Σάρωσε αυτόν τον κωδικό με την κάμερα + + Δεν είναι δυνατή η δημιουργία κωδικού QR + + Σαρώθηκε σε παλιά συσκευή + + Επανάληψη + + + Μεταφορά λογαριασμού + + Ο λογαριασμός σου θα μεταφερθεί σε μια νέα συσκευή. Αυτή η συσκευή θα μπορεί να βλέπει τις ομάδες και τις επαφές σου, να έχει πρόσβαση στις συνομιλίες σου και να στέλνει μηνύματα σαν να είναι εσύ. %1$s + + Μάθε περισσότερα + + Μεταφορά λογαριασμού + + Τα μηνύματα και οι πληροφορίες συνομιλίας προστατεύονται με κρυπτογράφηση από άκρο σε άκρο σε όλες τις συσκευές + + Ξεκλείδωμα για μεταφορά λογαριασμού + + Συνέχεια στην άλλη σου συσκευή + + Συνέχισε τη μεταφορά του λογαριασμού σου στην άλλη σου συσκευή. + + + Η ανάκτηση ολοκληρώθηκε + + Ο λογαριασμός και τα μηνύματά σου στο Signal έχουν ξεκινήσει τη μεταφορά στην άλλη σου συσκευή. Το Signal είναι πλέον ανενεργό σε αυτήν τη συσκευή. + + Η μεταφορά ολοκληρώθηκε + + Ο λογαριασμός και τα μηνύματά σου στο Signal έχουν μεταφερθεί στην άλλη σου συσκευή. Το Signal είναι πλέον ανενεργό σε αυτήν τη συσκευή. + + Εντάξει + + \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 1424a8c417..c7c7c9b631 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -874,11 +874,11 @@ Las copias de seguridad se cifran con una frase de contraseña y se guardan en tu dispositivo. Crear copia de seguridad Última copia: %1$s - Carpeta de copias de seguridad + Carpeta de copia de seguridad Hora de la copia de seguridad - Verificar contraseña de copia de seguridad - Prueba tu frase de contraseña de la copia de seguridad y comprueba que coincida + Verificar contraseña de la copia de seguridad + Prueba la frase de contraseña de tu copia de seguridad y comprueba que coincida Activar Desactivar "Para restaurar una copia de seguridad, reinstala Molly. Abre la aplicación, toca \"Restaurar copia\" y busca el archivo de copia de seguridad. %1$s" @@ -964,7 +964,7 @@ Vincular un nuevo dispositivo - %1$s Los mensajes y la información del chat están protegidos por cifrado de extremo a extremo en todos los dispositivos + %1$s Los mensajes y los chats están protegidos con cifrado de extremo a extremo en todos los dispositivos Signal para escritorio o iPad @@ -1325,20 +1325,6 @@ más Añadir descripción del grupo… - - - Transferir desde dispositivo Android - - Transfiere tu cuenta y tus mensajes desde tu dispositivo Android anterior. - - Iniciar sesión sin transferir - - Continuar sin transferir tus mensajes ni archivos multimedia - - Restaurar copia de seguridad local - - Restaura tus mensajes desde un archivo de copia de seguridad que hayas guardado en tu dispositivo. - Descargando copia de seguridad… @@ -1356,12 +1342,16 @@ Todos tus mensajes Restaurar desde copia de seguridad - + Solo se incluyen los archivos multimedia enviados o recibidos en los últimos %1$d días. Tu copia de seguridad incluye: Restaurar copia + + Última copia de seguridad: %2$s del %1$s. + + Obteniendo detalles de la copia de seguridad… Notificarme cuando alguien me mencione @@ -2661,7 +2651,7 @@ Vídeo Sesión de chat reiniciada - %1$s ha hecho una donación por ti + %1$s ha hecho una donación en tu nombre Has hecho una donación por %1$s @@ -2967,9 +2957,9 @@ - Este dispositivo ya no está registrado. Probablemente se debe a que se registró el número en Molly con un dispositivo distinto. + Este dispositivo ya no está registrado. Probablemente se debe a que has registrado tu número de teléfono en Molly con un dispositivo diferente. - Volver a registrar el dispositivo + Volver a registrar dispositivo Se ha cerrado tu sesión de Signal en este dispositivo. @@ -3433,7 +3423,7 @@ Chats archivados - ¿Ya has leído nuestra sección de preguntas frecuentes? + ¿Ya has leído nuestras preguntas frecuentes? Siguiente Contacta con nosotros Cuéntanos qué pasa @@ -3456,14 +3446,14 @@ Describe el problema detalladamente para ayudarnos a entender la incidencia. Selecciona una opción - Hay algo que no funciona bien - Solicitar función + Algo no funciona + Solicitud de función Pregunta - Feedback - Otro + Comentarios + Otra Pagos (MobileCoin) Donaciones e insignias - Signal Android Backup + Copia de seguridad de Signal Android Envío de registro de depuración de Android Signal @@ -3475,7 +3465,7 @@ Este mensaje Usados recientemente - Emoticonos y personas + Emojis y personas Naturaleza Comida Actividades @@ -4304,6 +4294,8 @@ He anotado esta contraseña. Comprendo que sin ella no podré restaurar la copia de seguridad. Restaurar copia Transferir o restaurar cuenta + + Restaurar o transferir Transferir cuenta Omitir Copias de seguridad de los chats @@ -4315,7 +4307,7 @@ No se pueden importar nuevas copias de seguridad La copia de seguridad contiene datos mal formados - Frase de contraseña la copia de seguridad incorrecta + Contraseña de la copia de seguridad incorrecta Comprobando… %1$d mensajes completados… ¿Restaurar desde copia de seguridad? @@ -4332,7 +4324,7 @@ Seleccionar carpeta Se ha copiado al portapapeles No hay selector de archivos disponible. - Introduce tu frase de contraseña de la copia de seguridad para verificarla + Introduce la frase de contraseña de tu copia de seguridad para verificarla Verificar ¡Frase de contraseña correcta! Frase de contraseña incorrecta @@ -4340,7 +4332,7 @@ Verificando copia de seguridad de Molly… Error en la copia de seguridad - Parece que tu carpeta de copias de seguridad se ha eliminado o movido. + Parece que tu carpeta de copia de seguridad se ha eliminado o movido. Tu archivo de copia de seguridad es demasiado grande para guardarlo en este dispositivo. No hay suficiente espacio para guardar tu copia de seguridad. @@ -5124,7 +5116,7 @@ Grupos - Only messages from group chats + Solo mensajes de chats grupales Añadir @@ -5533,7 +5525,7 @@ Reenviar a Compartir con - Agregar un mensaje + Añadir un mensaje Reenvío de mensajes más rápido Los vídeos se recortarán en clips de 30 segundos y se subirán como múltiples Historias. @@ -5721,7 +5713,7 @@ Introduce una cantidad personalizada - La cantidad mínima que puedes donar es de %1$s + El importe mínimo que puedes donar es de %1$s %1$s/mes Se renueva el %1$s @@ -5814,7 +5806,7 @@ Más información No se ha podido procesar la donación.%1$s - No se ha podido procesar tu donación y no hemos realizado el cargo. Inténtalo de nuevo. + No se ha podido procesar tu donación y no se ha aplicado ningún cargo. Inténtalo de nuevo. Aún se está procesando Fallo al añadir insignia @@ -5822,7 +5814,7 @@ No se ha podido validar la respuesta del servidor. Contacta con el equipo de asistencia. - Donación fallida + No se ha podido realizar la donación Tu donación se ha procesado, pero Signal no ha podido enviar tu mensaje de donación. Contacta con el equipo de asistencia. No se ha podido añadir la insignia a tu perfil, pero es posible que se haya realizado el cargo. Contacta con el equipo de asistencia. @@ -5872,7 +5864,7 @@ Ir a Google Pay - Intentar de nuevo + Reintentar Tu número de tarjeta es incorrecto. Actualízalo en Google Pay e inténtalo de nuevo. @@ -6089,11 +6081,11 @@ Compartir justificante - Si has vuelto a instalar Signal, los justificantes de donaciones pasadas no están disponibles. + Si has vuelto a instalar Signal, los justificantes de donaciones anteriores no estarán disponibles. Justificante de donación - Cantidad + Importe Gracias por apoyar a Signal. Con tu contribución, impulsas nuestra misión de desarrollar tecnología de código abierto centrada en la privacidad, que protege la libertad de expresión y permite una comunicación segura para millones de personas en todo el mundo. Si resides en Estados Unidos, guarda este recibo para tus declaraciones fiscales. Signal Technology Foundation es una organización sin ánimo de lucro exenta de impuestos en Estados Unidos bajo la sección 501(c)(3) del código tributario de EE. UU (Internal Revenue Code). Nuestro número de identificación fiscal es 82–4506840. @@ -6265,7 +6257,7 @@ %1$d personas - Elige quién puede ver tu historia. Estos cambios no afectarán a las historias que ya hayas enviado. + Elige quién puede ver tus historias. Estos cambios no afectarán a las historias que ya hayas compartido. Respuestas y reacciones @@ -6275,15 +6267,15 @@ Contactos de Signal - Los contactos de Signal son personas en las que has decidido confiar, ya que: + Tus contactos de Signal son personas en las que has decidido confiar por alguno de los siguientes motivos: - Iniciar un chat + Has iniciado un chat con ellas. - Has aceptado una solicitud de chat que te enviaron + Has aceptado una solicitud de mensaje que te enviaron. - Están en tus contactos del teléfono + Están en tus contactos del teléfono. - "Tus contactos pueden ver tu nombre y foto, así como las historias que subas, a no ser que decidas ocultarlas para personas específicas." + "Tus contactos pueden ver tu nombre y foto, así como las historias que subas, a menos que decidas ocultarlas para personas específicas." Añadir personas @@ -6336,7 +6328,7 @@ ¿Eliminar historia personalizada? - Se eliminará \"%1$s\" y todas las actualizaciones compartidas en esta historia. + Se eliminará \"%1$s\" y todo el contenido que hayas compartido en esta historia. Eliminar @@ -6459,7 +6451,7 @@ Verificando persona… - %1$s ha hecho una donación por ti + %1$s ha hecho una donación en tu nombre ¡Gracias por tu apoyo! @@ -6649,7 +6641,7 @@ Confirmaciones de visualización - Mira y comparte cuándo se ven las historias. Si lo desactivas, no podrás saber cuándo otras personas ven tus historias. + Mira y comparte cuándo se ven las historias. Si desactivas esta opción, no podrás saber cuándo otras personas ven tus historias. Nueva historia @@ -6687,9 +6679,9 @@ Toca el botón \"Ir a Ajustes\" que aparece más abajo - Activar \"Permitir la configuración de alarmas y recordatorios\". + Activa \"Permitir la configuración de alarmas y recordatorios\". - Ir a ajustes + Ir a Ajustes @@ -6709,11 +6701,11 @@ Tu donación aún se está procesando. Puede llevar unos minutos dependiendo de tu conexión. Espera hasta que se complete este pago antes de realizar otra donación. - Tu donación a través de iDEAL aún se está procesando. Consulta tu app bancaria para aprobar el pago antes de realizar otra donación. + Tu donación a través de iDEAL aún se está procesando. Consulta la aplicación de tu banco para aprobar el pago antes de hacer otra donación. El importe de la donación es demasiado alto - Puedes enviar hasta %1$s mediante transferencia bancaria. Prueba con una cantidad o un método de pago diferentes. + Puedes enviar hasta %1$s mediante transferencia bancaria. Prueba con un importe o un método de pago diferentes. Mensual @@ -6866,7 +6858,7 @@ Estamos teniendo problemas para procesar tu transferencia bancaria. No se te ha cobrado. Selecciona otro método de pago o contacta a tu banco para más información. - Intentar de nuevo + Reintentar Ahora no @@ -7426,11 +7418,11 @@ Se ha cancelado tu plan de copia de seguridad de archivos multimedia en Signal porque no se ha podido procesar tu pago. Esta es tu última oportunidad de descargar los archivos de tu copia de seguridad antes de que se eliminen. - Free up %1$s on this device + Libera %1$s en este dispositivo - To finish downloading your Signal Backup your device needs %1$s of storage space. + Para terminar de descargar tu copia de seguridad de Signal, tu dispositivo necesita %1$s de espacio de almacenamiento. - To free up space offload or delete unused apps or content large in file size. + Para liberar espacio, elimina las aplicaciones o contenido con archivos grandes que no uses. No se ha renovar tu suscripción al plan de copias de seguridad @@ -7458,7 +7450,7 @@ Ahora no - Try later + Más tarde Se eliminarán los archivos multimedia @@ -7470,9 +7462,9 @@ Omitir - Skip restore? + ¿Omitir restauración? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Si omites la restauración, el resto de los archivos adjuntos y multimedia de tu copia de seguridad se eliminarán la próxima vez que tu dispositivo haga una nueva copia de seguridad. @@ -7529,10 +7521,6 @@ Cambiar o cancelar suscripción - - - Última copia de seguridad: %2$s del %1$s. - Límites del chat @@ -7850,5 +7838,101 @@ Icono de recordatorio + + + Tengo mi teléfono anterior + + Para empezar, escanea el código QR con tu teléfono anterior. + + + No tengo mi teléfono anterior + + O estás reinstalando Signal en el mismo dispositivo + + + Restaurar o transferir cuenta + + Recupera tu cuenta e historial de mensajes de Signal en este dispositivo. + + Desde una copia de seguridad de Signal + + Tu plan gratuito o de pago de copias de seguridad de Signal + + Desde una carpeta de copia de seguridad + + Desde un archivo de copia de seguridad + + Elige una copia de seguridad que hayas guardado + + Desde tu teléfono anterior + + Transfiere tu cuenta desde tu Android anterior + + + Restaurar copia de seguridad local + + Restaura tus mensajes desde una copia de seguridad que hayas guardado en tu dispositivo. Si no los restauras ahora, no podrás hacerlo más tarde. + + + Introduce la clave de tu copia de seguridad + + La clave de tu copia de seguridad es un código de 64 dígitos que necesitarás para recuperar tu cuenta y tus datos. + + ¿No tienes tu clave? + + Clave de copia de seguridad + + Las copias de seguridad no se pueden recuperar sin su código de recuperación de 64 dígitos. Si has perdido tu clave, Signal no puede ayudarte a restaurar tu copia de seguridad. + + Si tienes tu dispositivo anterior, puedes ver la clave de tu copia de seguridad en Ajustes > Chats > Copias de seguridad de Signal. Luego, toca Ver clave de copia de seguridad. + + Más información + + Omitir y no restaurar + + + Escanea este código con tu teléfono anterior + + Abre Signal en tu dispositivo anterior + + Toca el icono de la cámara + + Escanea este código con la cámara + + No se puede generar el código QR + + Escaneado en dispositivo anterior + + Reintentar + + + Transferir cuenta + + Tu cuenta se transferirá a un nuevo dispositivo. En este dispositivo podrás acceder a tus grupos y contactos, acceder a todos tus chats y enviar mensajes desde tu perfil. %1$s + + Más información + + Transferir cuenta + + Los mensajes y los chats están protegidos con cifrado de extremo a extremo en todos los dispositivos + + Desbloquea el acceso para transferir tu cuenta + + Continúa en tu otro dispositivo + + Debes continuar la transferencia de tu cuenta en tu otro dispositivo. + + + Restauración completada + + Se ha comenzado a transferir tu cuenta y tus mensajes de Signal a tu otro dispositivo. Actualmente, Signal está inactivo en este dispositivo. + + Transferencia completada + + Se ha transferido tu cuenta y tus mensajes de Signal a tu otro dispositivo. Actualmente, Signal está inactivo en este dispositivo. + + Aceptar + + \ No newline at end of file diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 4b3dc75eb9..352e8470d4 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -1325,20 +1325,6 @@ rohkem Lisa grupi kirjeldus… - - - Kanna üle Android-seadmest - - Kanna oma konto ja sõnumite ajalugu vanast Android-seadmest üle. - - Logi sisse ilma andmeid üle kandmata - - Jätka ilma sõnumeid ja meediat üle kandmata - - Taasta kohalik varukoopia - - Taasta oma sõnumid sinu seadmesse salvestatud varukoopiafailist. - Varukoopia allalaadimine … @@ -1356,12 +1342,16 @@ Kõik su sõnumid Taasta varukoopiast - + Hõlmab ainult viimase %1$d päeva jooksul saadetud või saadud meediat. Sinu varukoopia sisaldab järgmist: Taasta varukoopia + + Sinu viimane varukoopia tehti %1$s kell %2$s. + + Varundamidandmete hankimine … Teavita mind mainimise korral @@ -3462,8 +3452,8 @@ Tagasiside Muu Maksed (MobileCoin) - Annetused & Märgid - Signal Android Backup + Annetused & märgid + Signal Androidi varukoopia Signal Android silumislogi esitamine @@ -4304,6 +4294,8 @@ Ma olen salasõna üles kirjutanud. Ilma selleta ei saa ma varukoopiat taastada. Taasta varukoopia Kanna üle või taasta konto + + Taasta või edasta Kanna konto üle Jäta vahele Vestluste varukoopiad @@ -5124,7 +5116,7 @@ Grupid - Only messages from group chats + Ainult sõnumid grupivestlustest Lisa @@ -6687,7 +6679,7 @@ Toksa allpool olevat nuppu „Ava sätted“ - Võimalda valik „Luba alarmide ja meeldetuletuste lisamine“ + Võimalda valik „Luba alarmide ja meeldetuletuste lisamine“. Mine sätetesse @@ -7426,11 +7418,11 @@ Sinu Signali meedia varundamise plaan on tühistatud, sest meil ei õnnestunud su makset töödelda. See on su viimane võimalus varundatud meediat alla laadida enne kui need kustutatakse. - Free up %1$s on this device + Vabasta selles seadmes %1$s - To finish downloading your Signal Backup your device needs %1$s of storage space. + Sinu Signali varukoopia allalaadimiseks vajab seade %1$s salvestusruumi. - To free up space offload or delete unused apps or content large in file size. + Ruumi vabastamiseks kustuta kasutamata äpid või suured failid. Sinu varukoopiate tellimust ei saanud uuendada @@ -7458,7 +7450,7 @@ Mitte praegu - Try later + Proovi hiljem Meedia kustutatakse @@ -7470,9 +7462,9 @@ Jäta vahele - Skip restore? + Kas jätta taastamine vahele? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Kui jätad taastamise vahele, kustutatakse su varukoopias olev meedia ja manused järgmisel korral, kui su seade uue varukoopia loob. @@ -7529,10 +7521,6 @@ Muuda või tühista tellimus - - - Sinu viimane varukoopia tehti %1$s kell %2$s. - Vestluse pikkuse piirangud @@ -7850,5 +7838,101 @@ Meeldetuletuse ikoon + + + Mul on mu vana telefon alles + + Kiiresti alustamiseks skanni oma praeguse Signali konto alt QR-kood + + + Mul ei ole mu vana telefoni + + Või paigaldad Signalit uuesti samasse seadmesse + + + Taasta või edasta konto + + Liiguta oma Signali konto ja sõnumiajalugu sellesse seadmesse. + + Signali varukoopiatest + + Sinu tasuta või tasuline Signali varundamisplaan + + Varundamiskaustast + + Varukoopiafailist + + Vali salvestatud varukoopia + + Sinu vanast telefonist + + Edasta otse oma vanast Android-seadmest + + + Taasta kohalik varukoopia + + Taasta oma sõnumid sinu seadmesse salvestatud varukoopiast. Kui sa nüüd ei taasta, siis sa ei saa ka hiljem taastada. + + + Sisesta oma varukoopia võti + + Sinu varukoopia võti on 64-kohaline kood, mida on vaja sinu andmete ja konto taastamiseks. + + Kas sul ei ole varukoopia võtit? + + Varukoopia võti + + Ilma 64-kohalise koodita ei saa varukoopiat taastada. Kui oled oma varukoopia võtme kaotanud, ei saa Signal sul aidata varukoopiat taastada. + + Kui sul on vana seade alles, saad vaadata varukoopia võtit, kui valid Sätted > Vestlused > Signali varukoopiad. Seejärel vali Vaata varukoopia võti. + + Rohkem teavet + + Jäta vahele ja ära taasta + + + Skanni oma vana telefoniga see kood + + Ava Signal oma vanas seadmes + + Toksa kaamera ikooni + + Skanni kaameraga see kood + + QR-koodi ei saanud luua + + Vanas seadmes skannitud + + Proovi uuesti + + + Kanna konto üle + + Sinu konto edastatakse uude seadmesse. Selle seadme kaudu näed nüüd oma gruppe, kontakte, vestlusi, ning saad oma nime alt sõnumeid saata. %1$s + + Rohkem teavet + + Kanna konto üle + + Sõnumid ja vestluste info on kaitstud kõigis seadmetes otspunktkrüpteeringuga + + Konto edastamiseks lukusta lahti + + Jätka oma teises seadmes + + Jätka oma konto edastamist oma teises seadmes. + + + Taastamine õnnestus + + Sinu Signali konto ja sõnumite edastamine su teise seadmesse on alanud. Signal on selles seadmes nüüd mitteaktiivne. + + Edastamine lõpetatud + + Sinu Signali konto ja sõnumid on edastatud su teise seadmesse. Signal on selles seadmes nüüd mitteaktiivne. + + Olgu + + \ No newline at end of file diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index e78212f591..9992e6b1e6 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -1325,20 +1325,6 @@ gehiago Gehitu taldearen deskribapena… - - - Transferitu Android gailu berri batetik - - Transferitu kontua eta mezuak Android-eko gailu zaharretik. - - Hasi saioa transferitu gabe - - Jarraitu zure mezuak eta artxiboak transferitu gabe - - Leheneratu babeskopia lokala - - Leheneratu gailuan gordetako babeskopia-fitxategi bateko mezuak. - Babeskopia deskargatzen… @@ -1356,12 +1342,16 @@ Mezu guztiak Babeskopia berreskuratu - + Azken %1$d egunetan bidalitako edo jasotako multimedia-edukia bakarrik dauka. Babeskopian, hauek gordetzen dira: Berreskuratu babeskopia + + Azken babeskopia: %1$s (%2$s). + + Babeskopiaren xehetasunak lortzen… Nahi dut aipamenak jakinaraztea @@ -3463,7 +3453,7 @@ Besteak Ordainketak (MobileCoin) Dohaintzak eta bereizgarriak - Signal Android Backup + Android-erako Signal-en Babeskopia Signal Android arazketa-erregistroaren bidalketa @@ -4304,6 +4294,8 @@ Pasaesaldia gorde dut. Hori gabe, ezin izango dut babeskopia bat berreskuratu. Berreskuratu babeskopia Transferitu edo berrezarri kontua + + Leheneratu edo transferitu Transferitu kontua Saltatu Txaten babeskopiak @@ -5124,7 +5116,7 @@ Taldeak - Only messages from group chats + Taldeko txatetako mezuak bakarrik Gehitu @@ -6687,7 +6679,7 @@ Sakatu beheko \"Joan ezarpenetara\" botoia. - Aktibatu \"Baimendu ezarpenen alarmak eta abisuak\". + Aktibatu \"Baimendu alarmak eta abisuak konfiguratzea\". Joan ezarpenetara @@ -7426,11 +7418,11 @@ Signal-eko multimedia-edukiaren babeskopia-plana bertan behera utzi da, ezin izan dugulako prozesatu zure ordainketa. Babeskopiako multimedia-edukia ezabatu aurretik deskargatzeko azken aukera duzu hau. - Free up %1$s on this device + Utzi %1$s libre gailu honetan - To finish downloading your Signal Backup your device needs %1$s of storage space. + Signal-en babeskopia deskargatzen amaitzeko, gailuak %1$s libre behar ditu memorian. - To free up space offload or delete unused apps or content large in file size. + Tokia egiteko, desgaitu edo ezabatu erabiltzen ez dituzun aplikazioak edo eduki handiak. Ezin izan da berritu babeskopien harpidetza @@ -7458,7 +7450,7 @@ Orain ez - Try later + Saiatu geroago Multimedia-edukia ezabatu egingo da @@ -7470,9 +7462,9 @@ Saltatu - Skip restore? + Leheneratzea saltatu nahi duzu? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Leheneratzea saltatzen baduzu, babeskopian dauden gainerako multimedia-eduki eta eranskinak ezabatu egingo dira gailuak beste babeskopia bat egiten duenean. @@ -7529,10 +7521,6 @@ Aldatu harpidetza edo utzi bertan behera - - - Azken babeskopia: %1$s (%2$s). - Txaten mugak @@ -7850,5 +7838,101 @@ Gogorarazpen-ikonoa + + + Telefono zaharra daukat + + Eskaneatu QR kode bat Signal-eko oraingo kontutik bizkor hasteko + + + Ez daukat telefono zaharra + + Edo Signal gailu berean berriro instalatzen ari zara + + + Leheneratu edo transferitu kontua + + Ekarri Signal-eko kontua eta mezu-historia gailu honetara. + + Signal-en babeskopietatik + + Signal-en doako edo ordainpeko babeskopia-plana + + Babeskopia-karpeta batetik + + Babeskopia-fitxategi batetik + + Aukeratu gordetako babeskopia bat + + Telefono zaharretik + + Transferitu zuzenean Android zaharretik + + + Leheneratu babeskopia lokala + + Leheneratu gailuan gordetako babeskopiako mezuak. Orain leheneratu ezean, ezingo dituzu geroago leheneratu. + + + Idatzi babeskopia-gakoa + + Kontua eta datuak berreskuratzeko behar den 64 digituko kode bat da babeskopia-gakoa. + + Ez duzu babeskopia-gakorik? + + Babeskopia-gakoa + + Babeskopiak ezin dira berreskuratu 64 digituko berreskuratze-koderik gabe. Babeskopia-gakoa galdu baduzu, Signal-ek ezin dizu lagundu babeskopia leheneratzen. + + Gailu zaharra baduzu, babeskopia-gakoa ikusteko, joan Ezarpenak > Txatak > Signal-en babeskopiak atalera. Ondoren, sakatu Ikusi babeskopia-gakoa. + + Informazio gehiago + + Saltatu eta ez leheneratu + + + Eskaneatu kode hau telefono zaharrarekin + + Ireki Signal gailu zaharrean + + Sakatu kameraren ikonoa + + Eskaneatu kode hau kamerarekin + + Ezin da sortu QR kodea + + Gailu zaharrean eskaneatu da + + Berriro saiatu + + + Transferitu kontua + + Kontua gailu berri batera transferituko da. Gailu honetan, talde eta kontaktuak ikusi ahalko dituzu, bai eta txatak atzitu eta mezuak zure izenean bidali ere. %1$s + + Informazio gehiago + + Transferitu kontua + + Mezuak eta txat-informazioa muturreko enkriptatzeaz babestuta daude gailu guztietan + + Desblokeatu kontua transferitzeko + + Jarraitu beste gailuan + + Jarraitu kontua transferitzen beste gailuan. + + + Leheneratzea osatuta + + Signal-eko kontua eta mezuak beste gailura transferitzen hasi dira. Signal inaktibo dago gailu honetan. + + Transferentzia amaituta + + Signal-eko kontua eta mezuak beste gailura transferitu dira. Signal inaktibo dago gailu honetan. + + Ados + + \ No newline at end of file diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index a7169c3742..3312057407 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -1325,20 +1325,6 @@ بیشتر افزودن توضیحات گروه… - - - انتقال از دستگاه اندروید - - حساب و پیام‌هایتان را از دستگاه اندروید قدیمی خود منتقل کنید. - - ورود بدون انتقال - - ادامه بدون انتقال پیام‌ها و رسانه‌های خود - - بازیابی پشتیبان محلی - - پیام‌های خود را از فایل پشتیبانی که در دستگاه خود ذخیره کرده‌اید بازیابی کنید. - در حال دانلود نسخه پشتیبان… @@ -1356,12 +1342,16 @@ همه پیام‌های شما بازگردانی از پشتیبان - + فقط شامل رسانه ارسالی یا دریافتی در %1$d روز گذشته است. نسخه پشتیبان شما شامل موارد زیر است: بازگردانی پشتیبان + + آخرین پشتیبان‌گیری در %1$s در %2$s انجام شده است. + + در حال دریافت جزئیات نسخه پشتیبان… برای اشاره‌ها من را آگاه کن @@ -3463,7 +3453,7 @@ سایر پرداخت‌ها (MobileCoin) کمک‌های مالی و نشان‌ها - Signal Android Backup + پشتیبان‌گیری سیگنال اندروید گزارش ارسالی رفع عیب سیگنال اندروید @@ -4304,6 +4294,8 @@ من این گذرواژه را یادداشت کرده‌ام. بدون آن، قادر به بازگردانی پشتیبان خود نخواهم بود. بازگردانی پشتیبان انتقال یا بازگردانی حساب کاربری + + بازیابی یا انتقال انتقال حساب کاربری رد کردن پشتیبان‌های گفتگو @@ -5124,7 +5116,7 @@ گروه‌ها‌ - Only messages from group chats + فقط پیام گفتگوهای گروهی افزودن @@ -6687,7 +6679,7 @@ روی دکمه «رفتن به تنظیمات» در زیر ضربه بزنید - «تنظیم زنگ‌های هشدار و یادآورها اجازه داده شود» را روشن کنید. + «اجازه دادن به تنظیم زنگ‌های هشدار و یادآورها» را روشن کنید. رفتن به تنظیمات @@ -7426,11 +7418,11 @@ طرح پشتیبان‌گیری رسانه سیگنال شما لغو شده است زیرا نتوانستیم پرداخت شما را پردازش کنیم. این آخرین فرصت شما برای دانلود رسانه موجود در نسخه پشتیبان قبل از حذف آن است. - Free up %1$s on this device + تا %1$s روی این دستگاه فضا آزاد کنید - To finish downloading your Signal Backup your device needs %1$s of storage space. + برای تکمیل دانلود نسخه پشتیبان سیگنال خود، دستگاهتان به %1$s فضای ذخیره‌سازی نیاز دارد. - To free up space offload or delete unused apps or content large in file size. + برای آزادسازی فضا، برنامه‌ها بدون‌استفاده یا محتواهایی که حجم فایلشان زیاد است را آفلود یا حذف کنید. اشتراک نسخه پشتیبان شما تمدید نشد @@ -7458,7 +7450,7 @@ حالا نه - Try later + بعداً امتحان کنید رسانه حذف خواهد شد @@ -7470,9 +7462,9 @@ رد کردن - Skip restore? + بازیابی رد شود؟ - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + اگر بازیابی رسانه‌ها و پیوست‌های باقیمانده را در پشتیبان‌گیری رد کنید، دفعه بعدی که دستگاهتان پشتیبان‌گیری جدید انجام می‌دهد حذف خواهند شد. @@ -7529,10 +7521,6 @@ تغییر یا لغو اشتراک - - - آخرین پشتیبان‌گیری در %1$s در %2$s انجام شده است. - محدودیت‌های گفتگو @@ -7850,5 +7838,101 @@ آیکون یادآوری + + + تلفن قدیمی‌ام را دارم + + برای شروع سریع، کد QR را از حساب فعلی سیگنال خود اسکن کنید + + + تلفن قدیمی‌ام را ندارم + + یا در حال نصب مجدد سیگنال در همان دستگاه هستید + + + بازیابی یا انتقال حساب + + حساب سیگنال و تاریخچه پیام خود را در این دستگاه دریافت کنید. + + از پشتیبان‌های سیگنال + + طرح پشتیبان‌گیری رایگان یا پولی سیگنال شما + + از یک پوشه پشتیبان + + از یک فایل پشتیبان + + نسخه پشتیبانی را که ذخیره کرده‌اید انتخاب کنید + + از تلفن قدیمی خود + + مستقیماً از اندروید قدیمی خود انتقال دهید + + + بازیابی پشتیبان محلی + + پیام‌های خود را از نسخه پشتیبانی که در دستگاه خود ذخیره کرده‌اید بازیابی کنید. اگر اکنون بازیابی نکنید، بعداً نمی‌توانید بازیابی کنید. + + + رمز پشتیبان خود را وارد کنید + + رمز پشتیبان شما یک کد ۶۴ رقمی است که برای بازیابی حساب و اطلاعاتتان الزامی است. + + رمز پشتیبان ندارید؟ + + رمز پشتیبان + + فایل‌های پشتیبان بدون کد بازیابی ۶۴ رقمی قابل بازیابی نیستند. اگر رمز پشتیبان خود را گم کرده‌اید، سیگنال نمی‌تواند به بازیابی نسخه پشتیبان شما کمک کند. + + اگر دستگاه قدیمی خود را دارید، می‌توانید رمز پشتیبان خود را در «تنظیمات > گفتگوها > پشتیبان‌های سیگنال» مشاهده کنید. سپس روی «مشاهده رمز پشتیبان» ضربه بزنید. + + اطلاعات بیشتر + + رد کردن و عدم بازیابی + + + این کد را با تلفن قدیمی خود اسکن کنید + + سیگنال را در دستگاه قدیمی خود باز کنید + + روی نماد دوربین ضربه بزنید + + این کد را با دوربین اسکن کنید + + امکان تولید کد QR وجود ندارد + + در دستگاه قدیمی اسکن شده است + + تلاش مجدد + + + انتقال حساب کاربری + + حساب شما به یک دستگاه جدید منتقل می‌شود. این دستگاه می‌تواند گروه‌ها و مخاطبان شما را ببیند، به گفتگوهای شما دسترسی داشته باشد و پیام‌هایی را به نام شما ارسال کند. %1$s + + اطلاعات بیشتر + + انتقال حساب کاربری + + پیام‌ها و اطلاعات گفتگو با رمزگذاری سرتاسری در همه دستگاه‌ها محافظت می‌شوند + + برای انتقال حساب، قفل دستگاه را باز کنید + + در دستگاه دیگر خود ادامه دهید + + به انتقال حساب خود در دستگاه دیگرتان ادامه دهید. + + + بازگردانی کامل شد + + حساب سیگنال و پیام‌های شما در حال انتقال به دستگاه دیگر شماست. اکنون سیگنال در این دستگاه غیرفعال است. + + انتقال کامل شد + + حساب سیگنال و پیام‌های شما به دستگاه دیگرتان منتقل شده است. اکنون سیگنال در این دستگاه غیرفعال است. + + تأیید + + \ No newline at end of file diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 183cf254ac..e2b9dbe0df 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1325,20 +1325,6 @@ lisää Lisää ryhmän kuvaus… - - - Siirrä Android-laitteesta - - Siirrä tilisi ja viestit vanhasta Android-laitteestasi. - - Kirjaudu sisään ilman siirtoa - - Jatka ilman viestien ja median siirtoa - - Palauta paikallinen varmuuskopio - - Palauta viestisi laitteellesi tallentamastasi varmuuskopiotiedostosta. - Ladataan varmuuskopiota… @@ -1356,12 +1342,16 @@ Kaikki viestisi Palauta varmuuskopiosta - + Mukana on vain viimeisten %1$d päivän aikana lähetetty tai vastaanotettu media. Varmuuskopio sisältää: Palauta varmuuskopio + + Viimeisin varmuuskopio tehtiin %1$s klo %2$s. + + Haetaan varmuuskopion tietoja… Ilmoita minulle maininnoista @@ -3463,7 +3453,7 @@ Muu Maksut (MobileCoin) Lahjoitukset ja merkit - Signal Android Backup + Signal Android -varmuuskopiointi Signal Androidin virheenkorjauslokin lähetys @@ -4304,6 +4294,8 @@ Olen kirjoittanut ylös tämän salalauseen, joka on välttämätön varmuuskopion palauttamiseen. Palauta varmuuskopio Siirrä tai palauta tili + + Palauta tai siirrä Siirrä tili Ohita Keskustelujen varmuuskopiot @@ -5124,7 +5116,7 @@ Ryhmät - Only messages from group chats + Vain viestit ryhmäkeskusteluista Lisää @@ -7426,11 +7418,11 @@ Signal-median varmuuskopiointi on peruutettu, koska emme voineet käsitellä maksua. Tämä on viimeinen mahdollisuutesi ladata varmuuskopiossasi oleva media ennen sen poistamista. - Free up %1$s on this device + Vapauta %1$s tällä laitteella - To finish downloading your Signal Backup your device needs %1$s of storage space. + Signal-varmuuskopion loppuun lataamiseksi laitteessasi tarvitaan %1$s tallennustilaa. - To free up space offload or delete unused apps or content large in file size. + Vapauta tilaa siirtämällä tai poistamalla käyttämättömiä sovelluksia tai tilaa vievää sisältöä. Varmuuskopiointitilauksesi uusiminen epäonnistui @@ -7458,7 +7450,7 @@ Ei nyt - Try later + Yritä myöhemmin Mediasisältö poistetaan @@ -7470,9 +7462,9 @@ Ohita - Skip restore? + Ohitetaanko palauttaminen? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Jos ohitat palauttamisen, varmuuskopion jäljellä olevat mediat ja liitteet poistetaan, kun laite seuraavan kerran tekee uuden varmuuskopion. @@ -7529,10 +7521,6 @@ Muuta tilausta tai peruuta se - - - Viimeisin varmuuskopio tehtiin %1$s klo %2$s. - Keskustelun rajoitukset @@ -7850,5 +7838,101 @@ Muistutuskuvake + + + Minulla on vanha puhelimeni + + Aloita nopeasti skannaamalla nykyisen Signal-tilisi QR-koodi + + + Minulla ei ole vanhaa puhelintani + + Tai jos asennat Signalia uudelleen samalle laitteelle + + + Palauta tai siirrä tili + + Siirrä Signal-tilisi ja viestihistoriasi tälle laitteelle. + + Signal-varmuuskopiosta + + Ilmainen tai maksullinen Signal-varmuuskopioinnin tilaus + + Varmuuskopiokansiosta + + Varmuuskopiotiedostosta + + Valitse tallentamasi varmuuskopio + + Vanhasta puhelimestasi + + Siirrä suoraan vanhasta Androidista + + + Palauta paikallinen varmuuskopio + + Palauta viestisi laitteellesi tallentamastasi varmuuskopiosta. Jos et palauta niitä nyt, et voi palauttaa niitä enää myöhemmin. + + + Anna varmuuskopion avain + + Varmuuskopion avain on 64-numeroinen koodi, jota tarvitaan tilisi ja tietosi palauttamiseen. + + Eikö sinulla ole varmuuskopion avainta? + + Varmuuskopion avain + + Varmuuskopioita ei voi palauttaa ilman 64-numeroista palautuskoodia. Jos olet kadottanut varmuuskopion avaimen, Signal ei voi palauttaa varmuuskopiotasi. + + Jos vanha laitteesi on tallella, löydät varmuuskopion avaimen kohdasta Asetukset > Keskustelut > Signal-varmuuskopiot. Napauta sitten Näytä varmuuskopion avain. + + Lue lisää + + Ohita äläkä palauta + + + Skannaa tämä koodi vanhalla puhelimellasi + + Avaa Signal vanhassa laitteessasi + + Napauta kamerakuvaketta + + Skannaa tämä koodi kameralla + + QR-koodin luominen ei onnistu + + Skannattu vanhalla laitteella + + Yritä uudelleen + + + Siirrä tili + + Tilisi siirretään uuteen laitteeseen. Uusi laite voi jatkossa nähdä ryhmäsi ja yhteystietosi, lukea kaikki keskustelusi ja lähettää viestejä sinun nimissäsi. %1$s + + Lue lisää + + Siirrä tili + + Viestit ja keskustelujen tiedot on suojattu päästä päähän -salauksella kaikilla laitteilla + + Avaa lukitus siirtääksesi tilin + + Jatka toisella laitteellasi + + Jatka tilin siirtämistä toisella laitteellasi. + + + Palautus suoritettu + + Signal-tilisi ja viestiesi siirto toiseen laitteeseen on käynnissä. Signal ei ole enää aktiivisena tällä laitteella. + + Siirto valmis + + Signal-tilisi ja viestisi on siirretty toiseen laitteeseen. Signal ei ole enää aktiivisena tällä laitteella. + + Selvä + + \ No newline at end of file diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 2c20fa5aa2..455f188c1b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -474,10 +474,10 @@ Vous ne pouvez pas envoyer de messages à ce groupe, car vous n\'en faites plus partie. Seuls les %1$s peuvent envoyer des messages. admins - Envoyer un message à un administrateur - Impossible de démarrer l’appel de groupe + Écrire à un admin + Impossible de démarrer l\'appel de groupe Seuls les administrateurs de ce groupe peuvent démarrer un appel. - Il n’y a aucune appli pour gérer ce lien sur votre appareil. + Aucune appli disponible pour ouvrir ce lien sur votre appareil. Votre demande a bien été envoyée à l’admin. Un message vous signalera si vous êtes autorisé à rejoindre le groupe. Annuler la demande @@ -491,18 +491,18 @@ Pour enregistrer un message vocal, autorisez Molly à accéder au microphone de votre téléphone. Pour envoyer des messages audio, Molly doit accéder à l\'application Microphone, mais vous lui en avez interdit l’accès. Accédez à l’application Paramètres de votre appareil > Applications > Autorisations et activez \"Microphone\". - Molly a besoin des autorisations Microphone et Appareil photo pour appeler %1$s, mais elles ont été refusées définitivement. Veuillez accéder aux paramètres de l’appli, sélectionner Autorisations et activer Microphone et Appareil photo. - Pour prendre des photos et des vidéos, autorisez l’accès de Molly à l’appareil photo. - Pour prendre des photos et des vidéos, Molly doit accéder à l\'appareil photo, mais vous lui en avez interdit l’accès. Accédez à l’application Paramètres de votre appareil > Applications > Autorisations et activez \"Appareil photo\". - Molly a besoin de l’autorisation Appareil photo pour prendre des photos ou des vidéos - Autoriser l’accès au microphone afin de prendre des vidéos avec du son. + Pour appeler %1$s, Molly doit accéder au microphone et à l\'appareil photo, mais vous lui en avez interdit l\'accès. Ouvrez l\'application Paramètres de votre téléphone, sélectionnez Applications > Autorisations et activez \"Microphone\" et \"Appareil photo\". + Pour prendre des photos et des vidéos, autorisez Molly à accéder à l\'appareil photo. + Pour prendre des photos et des vidéos, Molly doit accéder à l\'appareil photo, mais vous lui en avez interdit l’accès. Accédez à l\'application Paramètres de votre appareil > Applications > Autorisations et activez \"Appareil photo\". + Pour prendre des photos ou enregistrer des vidéos, Molly a besoin de l\'autorisation \"Appareil photo\". + Pour enregistrer des vidéos avec du son, autorisez l\'accès au microphone. Pour enregistrer des vidéos, Molly doit accéder à l\'application Microphone, mais vous lui en avez interdit l’accès. Dans les paramètres Android, touchez Applications > Molly > Autorisations, puis activez \"Microphone\" et \"Appareil photo\". Molly doit accéder au Microphone pour enregistrer des vidéos. %1$s %2$s Non %1$d sur %2$d - Il n’y a aucun résultat + Aucun résultat Pack de stickers installé Nouveauté : dites-le avec des stickers @@ -525,9 +525,9 @@ Rejoindre - Complet + Appel complet - Erreur d’envoi du média + Impossible d\'envoyer le média La messagerie SMS n’est plus prise en charge dans Signal. @@ -551,27 +551,27 @@ - %1$d message non lu - %1$d messages non lus + %1$d message non lu + %1$d messages non lus - Impossible de retrouver l\'application Contacts. + Application Contacts introuvable. Supprimer le message sélectionné ? Supprimer les messages sélectionnés ? - Enregistrer dans la mémoire ? + Enregistrer dans la mémoire  du téléphone ? - L’enregistrement de ce contenu dans l\'espace de stockage permettra à n’importe quelle autre appli de votre appareil d’y accéder.\n\nPoursuivre ? - L’enregistrement des %1$d médias dans l\'espace de stockage permettra à n’importe quelle autre appli de votre appareil d’y accéder.\n\nPoursuivre ? + Si vous enregistrez ce média dans l\'espace de stockage, toutes les applis installées sur votre appareil pourront y accéder.\n\nContinuer ? + Si vous enregistrez ces %1$d médias dans l\'espace de stockage, toutes les applis installées sur votre appareil pourront y accéder.\n\nContinuer  ? Impossible d\'enregistrer la pièce jointe dans l\'espace de stockage. Impossible d\'enregistrer les pièces jointes dans l\'espace de stockage. - Impossible d’écrire dans l\'espace de stockage + Enregistrement dans l\'espace de stockage impossible. Enregistrement de la pièce jointe Enregistrement de %1$d pièces jointes @@ -580,12 +580,12 @@ Enregistrement de la pièce jointe dans l\'espace de stockage… Enregistrement de %1$d pièces jointes dans l\'espace de stockage… - En attente… + Envoi en cours… Données (Signal) - Message multimédia - Texto - Suppression + MMS + SMS + Suppression en cours Suppression des messages… Supprimer pour moi Supprimer pour tout le monde @@ -596,11 +596,11 @@ Supprimer de tous les appareils Ce message sera supprimé pour tous les membres participant à la conversation s’ils utilisent une version récente de Signal. Ils pourront voir que vous avez supprimé un message. - Le message original est introuvable - Le message original n’est plus disponible - Échec d’ouverture du message - Vous pouvez balayer un message vers la droite pour y répondre rapidement - Vous pouvez balayer un message vers la gauche pour y répondre rapidement + Message d\'origine introuvable + Le message d\'origine n\'est plus disponible + Impossible d\'ouvrir le message + Balayez un message vers la droite pour y répondre rapidement + Balayez un message vers la gauche pour y répondre rapidement Les contenus éphémères sont supprimés après être envoyés Vous avez déjà vu ce message Cette conversation est dédiée à vos notes personnelles. Si des appareils sont associés à votre compte, les nouvelles notes que vous ajoutez ici sont synchronisées sur tous vos appareils. @@ -1323,21 +1323,7 @@ Ce groupe est un groupe de messages multimédias non sécurisé. Pour converser en toute confidentialité, invitez vos contacts sur Signal. Inviter maintenant plus - Ajouter une description du groupe… - - - - Transférer depuis un appareil Android - - Transférer votre compte et vos messages depuis votre ancien appareil Android. - - Se connecter sans transfert - - Continuer sans transférer vos messages et médias - - Restaurer une sauvegarde locale - - Restaurer vos messages depuis un ficher de sauvegarde enregistré sur votre appareil. + Présenter le groupe en quelques mots… @@ -1356,12 +1342,16 @@ tous vos messages Restaurer depuis une sauvegarde - + Elle ne contient que les médias envoyés ou reçus %1$d jours avant cette date. Votre sauvegarde contient : Restaurer la sauvegarde + + Dernière sauvegarde effectuée le %1$s à %2$s. + + Récupération des infos de la sauvegarde… Me signaler les mentions @@ -1774,9 +1764,9 @@ Le nom du groupe a été remplacé par \"%1$s\". - Vous avez modifié la description du groupe. - %1$s a modifié la description du groupe. - La description du groupe a changé. + Vous avez modifié la présentation du groupe. + %1$s a modifié la présentation du groupe. + La présentation du groupe a été modifiée. Vous avez changé la photo du groupe. @@ -2455,8 +2445,8 @@ Erreur des Services Google Play Les services Google Play sont en cours de mise à jour ou temporairement indisponibles. Veuillez réessayer. Conditions générales et règles de confidentialité - Pour vous permettre de contacter vos amis et d’envoyer des messages, Signal a besoin des autorisations Contacts et Médias. Vos contacts sont importés grâce au service de découverte confidentielle de Signal : autrement dit, ils sont chiffrés de bout en bout et nous n’y avons jamais accès. - Pour vous permettre de contacter vos amis, Signal a besoin de l’autorisation Contacts. Vos contacts sont importés grâce au service de découverte confidentielle de Signal•: autrement dit, ils sont chiffrés de bout en bout et nous n’y avons jamais accès. + Pour vous permettre de contacter vos amis et d\'envoyer des messages, Signal a besoin des autorisations \"Contacts\" et \"Médias\". Vos contacts sont importés grâce au service de découverte confidentielle de Signal : autrement dit, ils sont chiffrés de bout en bout et nous n\'y avons jamais accès. + Pour vous permettre de contacter vos amis, Signal a besoin de l\'autorisation \"Contacts\". Vos contacts sont importés grâce au service de découverte confidentielle de Signal : autrement dit, ils sont chiffrés de bout en bout et nous n\'y avons jamais accès. Vous avez fait trop d’essais pour inscrire ce numéro. Veuillez réessayer plus tard. Vous avez atteint le nombre maximal de tentatives d’inscription de ce numéro. Veuillez réessayer dans %1$s. @@ -3337,7 +3327,7 @@ Nom de famille (facultatif) Suivant Vous seul pouvez afficher le nom personnalisé du groupe MMS et ses photos. - La description du groupe sera visible aux membres de ce groupe et aux personnes invitées. + La présentation du groupe sera accessible aux membres de ce groupe et aux invités. À propos @@ -3354,7 +3344,7 @@ Modifier le groupe Nom du groupe - Description du groupe + Présentation du groupe Installez la dernière version de Molly @@ -3455,15 +3445,15 @@ Impossible d’importer les journaux. Veuillez nous donner autant de détails que possible, afin de nous aider à comprendre la situation. - Veuillez sélectionner une option + Veuillez choisir une option Quelque chose ne fonctionne pas - Suggestion relative aux fonctionnalités + Suggestion de fonctionnalité Question Commentaires Autre Paiements (MobileCoin) Dons et macarons - Signal Android Backup + Sauvegarde Signal pour Android Journal de débogage de Signal pour Android @@ -4304,6 +4294,8 @@ J’ai noté cette phrase de passe. Sans elle, je ne pourrai pas restaurer une sauvegarde. Restaurer une sauvegarde Transférer ou restaurer le compte + + Restaurer ou transférer le compte Transfert de compte Ignorer Sauvegarde des conversations @@ -4499,7 +4491,7 @@ Préparation de la connexion à votre ancien appareil Android… Cela prend un moment, mais ça devrait bientôt être prêt Attente de connexion de l’ancien appareil Android… - Molly a besoin de l’autorisation de localisation pour découvrir et se connecter à votre ancien appareil Android. + Pour détecter votre ancien appareil Android et s\'y connecter, Molly a besoin de l\'autorisation \"Localisation\". Molly nécessite l’activation des services de localisation pour découvrir et se connecter à votre ancien appareil Android. Le Wi-Fi doit être activé pour que Molly découvre votre ancien appareil Android et s’y connecte. Le Wi-Fi doit être activé, mais il n’a pas à être connecté à un réseau Wi-Fi. Il semblerait que cet appareil soit incompatible avec le Wi-Fi Direct. Molly utilise le Wi-Fi Direct pour détecter votre ancien appareil Android et s’y connecter. Pour récupérer votre compte depuis votre ancien appareil Android, vous avez toujours la possibilité de restaurer une sauvegarde. @@ -4508,7 +4500,7 @@ Recherche d’un nouvel appareil Android… - Molly a besoin de l’autorisation Localisation pour découvrir votre ancien appareil Android et s’y connecter. + Pour détecter votre ancien appareil Android et s\'y connecter, Molly a besoin de l\'autorisation \"Localisation\". Molly a besoin que les services de localisation soient activés pour découvrir votre ancien appareil Android et s’y connecter. Le Wi-Fi doit être activé pour que Molly découvre votre nouvel appareil Android et s’y connecte. Le Wi-Fi doit être activé, mais il n’a pas à être connecté à un réseau Wi-Fi. Il semblerait que cet appareil soit incompatible avec le Wi-Fi•Direct. Molly utilise le Wi-Fi Direct pour détecter votre ancien appareil Android et s’y connecter. Pour récupérer votre compte sur votre nouvel appareil Android, vous avez toujours la possibilité d’effectuer une sauvegarde. @@ -4943,7 +4935,7 @@ Vous seul pouvez voir cette couleur. - Description du groupe + Présentation du groupe @@ -5124,7 +5116,7 @@ Groupes - Only messages from group chats + Messages des conversations de groupe uniquement Créer @@ -5657,7 +5649,7 @@ Macaron - Annuler l’abonnement + Résilier les dons mensuels Confirmer l’annulation ? Vous ne serez plus facturé. Votre macaron sera supprimé de votre profil à la fin de la période de facturation. Plus tard @@ -5702,8 +5694,8 @@ Plus - Mon soutien - Gérer l’abonnement + Ma contribution + Gérer les dons mensuels Reçus de don Macarons @@ -5827,7 +5819,7 @@ Votre don a été traité, mais Signal n\'a pas pu envoyer le message de confirmation. Veuillez contacter l’assistance. Nous n\'avons pas pu ajouter votre macaron à votre compte, mais vous avez peut-être été facturé. Merci de contacter l\'assistance. Votre don est en cours de traitement. Cela peut prendre quelques minutes en fonction de votre connexion. - Échec de l’annulation de l’abonnement + Résiliation des dons mensuels : échec de l\'opération L’annulation de l’abonnement nécessite une connexion Internet. Votre appareil n\'est pas compatible avec Google Pay : vous ne pouvez donc pas vous abonner pour gagner un macaron, mais vous pouvez encore soutenir Signal et faire un don via notre site web. Erreur réseau. Vérifiez votre connexion et réessayez. @@ -6675,7 +6667,7 @@ 4 - Impossible de sauvegarder les Conversations + Impossible de sauvegarder les conversations Vos conversations ne sont plus sauvegardées automatiquement. @@ -7426,11 +7418,11 @@ Votre forfait de sauvegarde de médias Signal a été résilié, car nous n’avons pas pu traiter votre paiement. Téléchargez les fichiers de votre sauvegarde avant qu’ils ne soient supprimés. - Free up %1$s on this device + Libérez %1$s sur cet appareil - To finish downloading your Signal Backup your device needs %1$s of storage space. + Pour finir de télécharger votre sauvegarde Signal, vous devez disposer de %1$s d\'espace de stockage. - To free up space offload or delete unused apps or content large in file size. + Pour libérer de l\'espace, désinstallez les applications inutilisées ou supprimez les fichiers média volumineux. Impossible de renouveler votre abonnement de sauvegarde @@ -7446,7 +7438,7 @@ Sauvegarder maintenant - J’ai compris + J\'ai compris Gérer l\'abonnement @@ -7458,7 +7450,7 @@ Plus tard - Try later + Réessayer plus tard Vos médias vont être supprimés @@ -7470,9 +7462,9 @@ Ignorer - Skip restore? + Ignorer la restauration ? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Si vous choisissez d\'ignorer la restauration, les pièces jointes et médias stockés dans votre sauvegarde seront supprimés lors de la prochaine sauvegarde. @@ -7529,10 +7521,6 @@ Modifier ou résilier l\'abonnement - - - Dernière sauvegarde effectuée le %1$s à %2$s. - Définir des limites de stockage @@ -7808,7 +7796,7 @@ Modifier le type de sauvegarde - Annuler l’abonnement + Annuler l\'abonnement Gratuit @@ -7850,5 +7838,101 @@ Icône de rappel + + + Vous avez accès à votre ancien téléphone + + Utilisez votre ancien téléphone pour scanner l\'un des codes QR qui s\'affichent + + + Vous n\'avez pas accès à votre ancien téléphone + + ou vous réinstallez Signal sur le même appareil + + + Restaurer ou transférer le compte + + Restaurez ou transférez votre compte et tous vos messages Signal sur cet appareil. + + À partir d\'une sauvegarde Signal + + Via votre forfait de sauvegarde Signal gratuit ou payant + + À partir d\'un dossier de sauvegarde + + À partir d\'un fichier de sauvegarde + + Choisissez une sauvegarde que vous avez enregistrée + + À partir d\'un ancien téléphone + + Transférez votre compte directement depuis votre ancien appareil Android + + + Restaurer une sauvegarde locale + + Restaurez vos messages depuis la sauvegarde enregistrée sur votre appareil. Si vous ne les restaurez pas maintenant, vous ne pourrez pas le faire plus tard. + + + Saisir la clé de sauvegarde + + Votre clé de sauvegarde est un code de 64 caractères alphanumériques qui vous permet de récupérer votre compte et vos données. + + Clé de sauvegarde introuvable ? + + Clé de sauvegarde + + Vous ne pouvez pas restaurer vos sauvegardes sans votre code à 64 caractères. Si vous l\'avez perdu, Signal ne peut pas vous aider à restaurer votre sauvegarde. + + Si vous avez toujours accès à votre ancien appareil, vous pouvez retrouver votre clé sous Paramètres > Conversations > Sauvegardes Signal > Afficher la clé de sauvegarde. + + En savoir plus + + Ignorer cette étape et ne pas restaurer + + + Scannez ce code avec votre ancien téléphone + + Ouvrez Signal sur votre ancien téléphone + + Appuyez sur l\'icône de l\'appareil photo + + Scannez ce code avec l\'appareil photo + + Impossible de générer un code QR + + Code QR scanné sur l\'ancien appareil + + Réessayer + + + Transférer le compte + + Vous allez transférer votre compte vers un autre appareil. Ce nouvel appareil pourra afficher vos groupes et vos contacts, accéder à vos conversations et envoyer des messages via votre profil. %1$s + + En savoir plus + + Transférer le compte + + Avec son protocole de chiffrement de bout en bout, Signal protège vos messages et vos conversations sur tous vos appareils. + + Pour transférer le compte, déverrouiller l\'appareil + + Continuer sur l\'autre appareil + + Poursuivez le transfert du compte sur l\'autre appareil. + + + Restauration terminée + + Nous avons commencé le transfert de votre compte et de vos messages Signal vers l\'autre appareil. Signal n\'est plus actif sur cet appareil-ci. + + Transfert terminé + + Nous avons bien transféré votre compte et vos messages Signal vers l\'autre appareil. Signal n\'est plus actif sur cet appareil-ci. + + OK + + \ No newline at end of file diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index eb5d89b08f..2ec791eeb1 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -1439,20 +1439,6 @@ more Cuir cur síos ar an ngrúpa leis… - - - Aistrigh ó ghléas Android - - Aistrigh do chuntas agus do theachtaireachtaí ó do sheanghléas Android. - - Logáil isteach gan aistriú - - Lean ort gan do theachtaireachtaí agus do mheáin a aistriú - - Aischuir cúltaca logánta - - Aischuir do theachtaireachtaí ó chomhad cúltaca a shábháil tú ar do ghléas. - Cúltaca á íoslódáil… @@ -1470,12 +1456,16 @@ Do theachtaireachtaí uile Aischuir ó chúltaca - + Ní áirítear ach meáin a seoladh nó a fuarthas le %1$d lá anuas. Áirítear i do chúltaca: Aischuir cúltaca + + Rinneadh do chúltaca deiridh %1$s ag %2$s. + + Sonraí cúltaca á bhfáil… Cuir Tráchtanna in iúl dom @@ -3753,14 +3743,14 @@ Mínigh go mion cad atá cearr le do thoil ionas gur féidir linn an fhadhb a thuiscint. Déan rogha - Tá rud éigin as gléas + Níl Rud Éigin ag Obair Iarratas ar Ghné Nua Ceist Aiseolas Eile Íocaíochtaí (MobileCoin) Tabhartais & Suaitheantais - Signal Android Backup + Cúltaca Signal Android Cur Isteach Loga Dífhabhtaithe Android Signal @@ -4637,6 +4627,8 @@ Tá an pasfhrása seo scríofa síos agam. Ní bheidh mé in ann cúltaca a aischur gan é. Athchóirigh an cúltaca Aistrigh cuntas nó cuir ar ais é + + Aischuir nó aistrigh Cuntas a Aistriú Léim thar seo Cúltacaí na gcomhráite @@ -5484,7 +5476,7 @@ Grúpaí - Only messages from group chats + Teachtaireachtaí ó ghrúpchomhráite amháin Cuir leis @@ -7143,7 +7135,7 @@ Tapáil an cnaipe \"Téigh chuig Socruithe\" thíos - Cas air \"Ceadaigh socruithe aláram agus meabhrúchán.\" + Cas air \"Ceadaigh socrú aláram agus meabhrúchán.\" Socruithe @@ -7909,11 +7901,11 @@ Cuireadh do phlean Signal um chúltacú meán ar ceal toisc nárbh fhéidir linn d\'íocaíocht a phróiseáil. Seo do dheis dheiridh na meáin i do chúltaca a íoslódáil sula scriosfar é. - Free up %1$s on this device + Déan %1$s de spás ar an ngléas seo - To finish downloading your Signal Backup your device needs %1$s of storage space. + Chun íoslódáil Chúltaca Signal a chur i gcrích tá %1$s de spás stórála de dhíth ar do ghléas. - To free up space offload or delete unused apps or content large in file size. + Chun spás a dhéanamh, scrios aipeanna neamhúsáidte nó inneachar de mhéid mhór nó faigh réidh leo. Theip ar nuashonrú do shíntiúis le cúltacaí @@ -7941,7 +7933,7 @@ Ní anois - Try later + Triail níos déanaí Scriosfar meáin @@ -7953,9 +7945,9 @@ Léim thar seo - Skip restore? + Scipeáil aischur? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Má scipeálann tú aischur scriosfar na meáin agus ceangaltáin i do chúltaca an chéad uair eile a dhéanfar cúltaca nua ar do ghléas. @@ -8012,10 +8004,6 @@ Athraigh an síntiús nó cuir ar ceal é - - - Rinneadh do chúltaca deiridh %1$s ag %2$s. - Teorainneacha an chomhrá @@ -8351,5 +8339,101 @@ Deilbhín meabhrúchán + + + Tá mo sheanghuthán agam + + Scan cód QR ó do chuntas reatha Signal chun tosú air go tapa + + + Níl mo sheanghuthán agam + + Nó tá tú ag athshuiteáil Signal ar an ngléas céanna + + + Cuntas a aischur nó a aistriú + + Faigh do chuntas Signal agus stair teachtaireachtaí ar an ngléas seo. + + Ó Chúltacaí Signal + + Plean cúltacaí saor in aisce nó íoctha + + Ó fhillteán cúltaca + + Ó chomhad cúltaca + + Roghnaigh cúltaca atá sábháilte agat + + Ó do sheanghuthán + + Aistrigh go díreach ó do sheanghléas Android + + + Aischur cúltaca logánta + + Aischuir do theachtaireachtaí ó chúltaca a shábháil tú ar do ghléas. Mura ndéanann tú an t-aischur anois, ní bheidh tú in ann iad a aischur níos déanaí. + + + Cuir isteach d\'eochair chúltaca + + Cód 64 digit is ea d\'eochair chúltaca a éilítear chun do chuntas agus sonraí a aischur. + + Níl eochair chúltaca agat? + + Eochair chúltaca + + Ní féidir cúltacaí a aischur gan a gcód aischurtha 64 digit. Má tá d\'eochair chúltaca caillte agat ní féidir le Signal cabhrú leat le haischur do chúltaca. + + Má tá do sheanghléas agat, is féidir leat féachaint ar d\'eochair chúltaca i Socruithe > Comhráite > Cúltacaí Signal. Ansin tapáil Féach ar eochair chúltaca. + + Tuilleadh faisnéise + + Scipeáil agus ná haischuir + + + Scan an cód seo le do sheanghuthán + + Oscail Signal ar do sheanghléas + + Tapáil deilbhín an cheamara + + Scan an cód seo leis an gceamara + + Ní féidir cód QR a ghiniúint + + Scanta ar sheanghléas + + Bain triail eile as + + + Aistriú cuntais + + Déanfar do chuntas a aistriú chuig gléas nua. Beidh an gléas sin in ann do ghrúpaí agus teagmhálaithe a fheiceáil, do chomhráite a rochtain, agus teachtaireachtaí a sheoladh faoi d\'ainm féin. %1$s + + Tuilleadh faisnéise + + Aistrigh cuntas + + Déantar teachtaireachtaí agus faisnéis comhráite a chosaint le criptiú ó cheann ceann ar gach gléas + + Díghlasáil chun an cuntas a aistriú + + Lean ar aghaidh ar do ghléas eile + + Lean le haistriú do chuntais ar do ghléas eile. + + + Tá an t-aischur críochnaithe + + Tá tús curtha le haistriú do chuntais agus teachtaireachtaí Signal chuig do ghléas eile. Tá Signal neamhghníomhach ar an ngléas seo anois. + + Tá an t-aistriú críochnaithe + + Tá do chuntas agus teachtaireachtaí Signal aistrithe chuig do ghléas eile. Tá Signal neamhghníomhach ar an ngléas seo anois. + + Cgl + + \ No newline at end of file diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index f680943340..c059f59185 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -1325,20 +1325,6 @@ máis Engadir descrición do grupo… - - - Transferir desde dispositivo Android - - Transfire a túa conta e as mensaxes do teu antigo dispositivo Android. - - Iniciar sesión sen transferencia - - Continúa o proceso sen transferir as mensaxes e arquivos - - Restaurar copia de seguranza local - - Restaurar as túas mensaxes empregando un ficheiro de copia de segurranza gardado no teu dispositivo. - Descargando copia de seguranza… @@ -1356,12 +1342,16 @@ Todas as túas mensaxes Restaurar desde copia de seguranza - + Só se inclúe o contido enviado ou recibido nos últimos %1$d días. A túa copia de seguranza inclúe: Restaurar copia de seguranza + + A túa última copia de seguranza foi o %1$s ás %2$s. + + Buscando información da copia de seguranza… Notificarme as mencións @@ -3463,7 +3453,7 @@ Outros Pagamentos (MobileCoin) Donativos e insignias - Signal Android Backup + Copia de seguranza de Signal Android Envío de rexistro de depuración de Signal Android @@ -4304,6 +4294,8 @@ Escribín esta frase de acceso. Sen ela non poderei restaurar unha copia de seguranza. Restaurar copia de seguranza Transferir ou restaurar a conta + + Restaurar ou transferir Transferir conta Omitir Copias de seguranza das conversas @@ -5124,7 +5116,7 @@ Grupos - Only messages from group chats + Só mostrar mensaxes de conversas grupais Engadir @@ -7426,11 +7418,11 @@ Cancelouse o teu plan de copia de seguranza de Signal porque non se puido procesar o pagamento. Esta é a túa última oportunidade de descargar os teus arquivos almacenados na copia antes de que se eliminen. - Free up %1$s on this device + Libera %1$s do dispositivo - To finish downloading your Signal Backup your device needs %1$s of storage space. + Para rematar a descarga da copia de seguranza de Signal, o dispositivo necesita %1$s de espazo de almacenamento. - To free up space offload or delete unused apps or content large in file size. + Para liberar espazo descarga ou elimina as aplicacións que non empregues ou arquivos de gran tamaño. A túa subscrición da copia de seguranza non se puido renovar @@ -7458,7 +7450,7 @@ Agora non - Try later + Intentar máis tarde Eliminaranse os arquivos @@ -7470,9 +7462,9 @@ Omitir - Skip restore? + Saltar restauración? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Se non completas a restauración, os arquivos da túa copia de seguranza eliminaranse a próxima vez que o teu dispositivo complete unha nova copia de seguranza. @@ -7529,10 +7521,6 @@ Cambiar ou cancelar subscrición - - - A túa última copia de seguranza foi o %1$s ás %2$s. - Límete da conversa @@ -7850,5 +7838,101 @@ Icona de recordatorio + + + Teño o meu antigo teléfono + + Escanea o código QR dende a túa conta actual de Signal para unha configuración rápida + + + Non teño o meu antigo teléfono + + Ou estás a reinstalar Signal no mesmo dispositivo + + + Restaurar ou transferir conta + + Descarga o teu historial de mensaxes e a conta de Signal neste dispositivo. + + Dende unha copia de seguranza de Signal + + O teu plan gratuíto ou de pago de Copia de seguranza de Signal + + Dende un cartafol de copia de seguranza + + Dende un arquivo de copia de seguranza + + Escolle a copia de seguranza que gardaches + + Desde o teu antigo teléfono + + Transferencia directa dende o teu antigo dispositivo Android + + + Restaurar copia de seguranza local + + Restaura as túas mensaxes empregando unha copia de seguranza gardada no teu dispositivo. Se non o fas agora non poderás restaurala posteriormente. + + + Escribe a túa clave de seguranza + + A túa clave de seguranza é un código de 64 díxitos esencial para recuperar a túa conta e os datos. + + Non tes a clave de seguranza? + + Clave de seguranza + + As copias de seguranza non poden recuperarse sen o código de 64 díxitos. Se perdiches a clave de seguranza, Signal non restaurará a túa copia de seguranza. + + Se tes o antigo dispositivo podes consultar a clave de seguranza en Configuración > Conversas > Copias de seguranza de Signal. Despois selecciona «Ver clave de seguranza». + + Máis información + + Omitir e non restaurar + + + Escanea este código no teu antigo teléfono + + Abre Signal no antigo dispositivo + + Preme a icona da cámara + + Escanea este código coa cámara + + Non é posible xerar un código QR + + Xa escaneado no antigo dispositivo + + Tentar de novo + + + Transferir conta + + A túa conta transferirase a un novo dispositivo. O dispositivo poderá ver os teus grupos e contactos, acceder ás conversas e enviar mensaxes no teu nome. %1$s + + Máis información + + Transferir conta + + As mensaxes e a información das conversas están protexidas por unha encriptación de extremo a extremo en todos os dispositivos + + Desbloquea para transferir a conta + + Continúa no teu outro dispositivo + + Continúa a transferencia da túa conta no outro dispositivo. + + + Recuperación completada + + Comezou a transferencia das túas mensaxes e da conta de Signal ao outro dispositivo. Signal xa non funcionará neste dispositivo. + + Transferencia completada + + Transferíronse as túas mensaxes e a conta de Signal ao outro dispositivo. Signal xa non funcionará neste dispositivo. + + De acordo + + \ No newline at end of file diff --git a/app/src/main/res/values-gu/strings.xml b/app/src/main/res/values-gu/strings.xml index d76073252a..d23ae3726b 100644 --- a/app/src/main/res/values-gu/strings.xml +++ b/app/src/main/res/values-gu/strings.xml @@ -1325,20 +1325,6 @@ વધારે ગ્રુપ ડિસ્ક્રિપ્શન ઉમેરો … - - - Android ડિવાઇસથી ટ્રાન્સફર કરો - - તમારા જૂના Android ડિવાઇસ પરથી તમારું એકાઉન્ટ અને મેસેજ ટ્રાન્સફર કરો. - - ટ્રાન્સફર કર્યા વગર લૉગ ઇન કરો - - તમારા મેસેજ અને મીડિયાને ટ્રાન્સફર કર્યા વિના ચાલુ રાખો - - લોકલ બેકઅપ રિસ્ટોર કરો - - તમે તમારા ડિવાઇસ પર સેવ કરેલી બેકઅપ ફાઇલમાંથી તમારા મેસેજ રિસ્ટોર કરો. - બેકઅપ ડાઉનલોડ કરી રહ્યાં છીએ… @@ -1356,12 +1342,16 @@ તમારા બધા મેસેજ બેકઅપમાંથી રિસ્ટોર કરો - + ફક્ત પાછલા %1$d દિવસમાં મોકલેલા કે મેળવેલા મીડિયા શામેલ છે. તમારા બેકઅપમાં શામેલ છે: બેકઅપ રિસ્ટોર કરો + + તમારું છેલ્લું બેકઅપ %1$sના રોજ %2$s વાગ્યે લેવામાં આવ્યું હતું. + + બેકઅપ વિગતો મેળવી રહ્યાં છીએ… મને ઉલ્લેખો માટે સૂચિત કરો @@ -3456,14 +3446,14 @@ કૃપા કરીને સમસ્યાને સમજવામાં અમારી સહાય માટે શક્ય તેટલું વર્ણનાત્મક બનો. કૃપા કરીને એક વિકલ્પ પસંદ કરો - કશું કામ નથી આપી રહ્યું - સુવિધા વિનંતી + કંઈક કામ નથી કરી રહ્યું + સુવિધાની વિનંતી પ્રશ્ન પ્રતિસાદ અન્ય ચુકવણી (MobileCoin) યોગદાન અને બૅજ - Signal Android Backup + Signal Android બેકઅપ Signal Android ડીબગ લૉગ સબમિશન @@ -4304,6 +4294,8 @@ મેં આ પાસફ્રેઝ લખ્યો છે. તેના વિના, હું બૅકઅપ ને રિસ્ટોર કરવામાં અસમર્થ હોઈશ. બૅકઅપ રિસ્ટોર કરો એકાઉન્ટ ટ્રાન્સફર અથવા રિસ્ટોર કરો + + રિસ્ટોર અથવા ટ્રાન્સફર કરો એકાઉન્ટ ટ્રાન્સફર કરો અવગણો ચેટ બૅકઅપ @@ -5124,7 +5116,7 @@ ગ્રૂપ - Only messages from group chats + ફક્ત ગ્રૂપ ચેટના જ મેસેજ ઉમેરો @@ -6687,7 +6679,7 @@ નીચેના \"સેટિંગ્સ પર જાઓ\" બટનને ટૅપ કરો - \"એલાર્મ અને રીમાઇન્ડર સેટિંગ્સને મંજૂરી આપો\" ચાલુ કરો. + \"એલાર્મ અને રિમાઇન્ડર સેટિંગને મંજૂરી આપો\" ચાલુ કરો. સેટિંગ્સ પર જાઓ @@ -7426,11 +7418,11 @@ તમારો Signal મીડિયા બેકઅપ પ્લાન રદ કરવામાં આવ્યો છે કારણ કે અમે તમારી ચુકવણી પર પ્રક્રિયા કરી શક્યા નથી. તમારા બેકઅપમાં મીડિયાને ડિલીટ કરવામાં આવે તે પહેલાં તેને ડાઉનલોડ કરવાની આ તમારી છેલ્લી તક છે. - Free up %1$s on this device + આ ડિવાઇસ પર %1$s ખાલી કરો - To finish downloading your Signal Backup your device needs %1$s of storage space. + તમારા Signal બેકઅપને ડાઉનલોડ કરવાનું સમાપ્ત કરવા માટે તમારા ડિવાઇસમાં %1$s સ્ટોરેજ જગ્યાની જરૂર છે. - To free up space offload or delete unused apps or content large in file size. + જગ્યા ખાલી કરવા માટે ન વપરાયેલ ઍપ અથવા ફાઈલ સાઇઝ મોટી હોય તેવી કન્ટેન્ટ ઑફલોડ કરો અથવા ડિલીટ કરો. તમારા બેકઅપ સબ્સ્ક્રિપ્શનને રિન્યૂ કરવાનું નિષ્ફળ થયું @@ -7458,7 +7450,7 @@ અત્યારે નહીં - Try later + પછી પ્રયાસ કરો મીડિયા ડિલીટ કરવામાં આવશે @@ -7470,9 +7462,9 @@ અવગણો - Skip restore? + રિસ્ટોર કરવાનું સ્કિપ કરવું છે? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + જો તમે રિસ્ટોર કરવાનું સ્કિપ કરો છો તો હવેની વખતે જ્યારે તમારું ડિવાઇસ નવું બેકઅપ પૂર્ણ કરે ત્યારે તમારા બેકઅપમાંના બાકીના મીડિયા અને અટેચમેન્ટ ડિલીટ કરવામાં આવશે. @@ -7529,10 +7521,6 @@ સબ્સ્ક્રિપ્શન બદલો અથવા રદ કરો - - - તમારું છેલ્લું બેકઅપ %1$sના રોજ %2$s વાગ્યે લેવામાં આવ્યું હતું. - ચેટની મર્યાદા @@ -7850,5 +7838,101 @@ રિમાઇન્ડર આઇકન + + + મારી પાસે મારો જૂનો ફોન છે + + ઝડપથી શરૂ કરવા માટે તમારા વર્તમાન Signal અકાઉન્ટમાંથી QR કોડ સ્કેન કરો + + + મારી પાસે મારો જૂનો ફોન નથી + + અથવા તમે તે જ ડિવાઇસ પર Signal ફરીથી ઇન્સ્ટોલ કરી રહ્યાં છો + + + એકાઉન્ટ રિસ્ટોર અથવા ટ્રાન્સફર કરો + + આ ડિવાઇસ પર તમારું Signal એકાઉન્ટ અને મેસેજ હિસ્ટ્રી મેળવો. + + Signal બેકઅપમાંથી + + તમારો મફત અથવા ચુકવણી સાથેનો Signal બેકઅપ પ્લાન + + બેકઅપ ફોલ્ડરમાંથી + + બેકઅપ ફાઈલમાંથી + + તમે સેવ કરેલ બેકઅપ પસંદ કરો + + તમારા જૂના ફોનમાંથી + + તમારા જૂના Android પરથી સીધા જ ટ્રાન્સફર કરો + + + લોકલ બેકઅપ રિસ્ટોર કરો + + તમે તમારા ડિવાઇસ પર સેવ કરેલા બેકઅપમાંથી તમારા મેસેજ રિસ્ટોર કરો. જો તમે અત્યારે રિસ્ટોર ન કરો, તો તમે પછીથી રિસ્ટોર કરી શકશો નહીં. + + + તમારી બેકઅપ કી દાખલ કરો + + તમારી બેકઅપ કી એ તમારા એકાઉન્ટ અને ડેટાને પુનઃપ્રાપ્ત કરવા માટે જરૂરી એવો 64-અંકનો કોડ છે. + + કોઈ બેકઅપ કી નથી? + + બેકઅપ કી + + બેકઅપને તેમના 64-અંકના રિકવરી કોડ વિના પુનઃપ્રાપ્ત કરી શકાતા નથી. જો તમે તમારી બેકઅપ કી ગુમાવી દીધી હોય તો Signal તમારા બેકઅપને રિસ્ટોર કરવામાં મદદ કરી શકશે નહીં. + + જો તમારી પાસે તમારું જૂનું ડિવાઇસ હોય તો તમે સેટિંગ્સ > ચેટ > Signal બેકઅપમાં તમારી બેકઅપ કી જોઈ શકો છો. પછી બેકઅપ કી જુઓ પર ટેપ કરો. + + વધુ જાણો + + છોડો અને રિસ્ટોર કરશો નહીં + + + આ કોડને તમારા જૂના ફોનથી સ્કેન કરો + + તમારા જૂના ડિવાઇસ પર Signal ખોલો + + કેમેરા આઇકન પર ટેપ કરો + + કેમેરા વડે આ કોડને સ્કેન કરો + + QR કોડ જનરેટ કરવામાં અસમર્થ + + જૂના ડિવાઇસ પર સ્કેન કરેલ છે + + ફરી પ્રયાસ કરો + + + એકાઉન્ટ ટ્રાન્સફર કરો + + તમારું એકાઉન્ટ એક નવા ડિવાઇસ પર ટ્રાન્સફર થશે. આ ડિવાઇસ તમારા ગ્રૂપ અને સંપર્કો જોઈ શકશે, તમારી ચેટને ઍક્સેસ કરી શકશે અને તમારા નામે મેસેજ મોકલી શકશે. %1$s + + વધુ જાણો + + એકાઉન્ટ ટ્રાન્સફર કરો + + મેસેજ અને ચેટ માહિતી બધા ડિવાઇસ પર એન્ડ-ટૂ-એન્ડ એન્ક્રિપ્શન દ્વારા સુરક્ષિત છે + + એકાઉન્ટ ટ્રાન્સફર કરવા માટે અનલૉક કરો + + તમારા અન્ય ડિવાઇસ પર ચાલુ રાખો + + તમારા અન્ય ડિવાઇસ પર તમારું એકાઉન્ટ ટ્રાન્સફર કરવાનું ચાલુ રાખો. + + + રિસ્ટોર પૂર્ણ થયું + + તમારું Signal એકાઉન્ટ અને મેસેજ તમારા અન્ય ડિવાઇસ પર ટ્રાન્સફર કરવાનું શરૂ થઈ ગયું છે. Signal હવે આ ડિવાઇસ પર નિષ્ક્રિય છે. + + ટ્રાન્સફર પૂર્ણ + + તમારું Signal એકાઉન્ટ અને મેસેજ તમારા અન્ય ડિવાઇસ પર ટ્રાન્સફર કરવામાં આવ્યા છે. Signal હવે આ ડિવાઇસ પર નિષ્ક્રિય છે. + + બરાબર + + \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index a34d1c82f2..3a1b1cc05c 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -1325,20 +1325,6 @@ अधिक ग्रुप विवरण जोड़ें… - - - Android डिवाइस से ट्रांसफ़र करें - - अपने पुराने Android डिवाइस से अपना अकाउंट और संदेश ट्रांसफ़र करें। - - बिना ट्रांसफर किए लॉग इन करें - - अपने संदेशों और मीडिया को स्थानांतरित किए बिना जारी रखें - - स्थानीय बैकअप को रीस्टोर करें - - आपने अपने डिवाइश पर जो बैकअप फ़ाइल सेव की थी उसे अपने संदेश रीस्टोर करें। - बैकअप डाउनलोड किया जा रहा है… @@ -1356,12 +1342,16 @@ आपके सभी संदेश बैकअप से रीस्टोर करें - + सिर्फ़ उस मीडिया को शामिल किया गया है जो पिछले %1$d दिनों में भेजा या मिला है। आपके बैकअप में शामिल है: बैकअप पुनर्स्थापित करें + + आपका पिछला बैकअप %1$s को %2$s पर बनाया गया था। + + बैकअप जानकारी फ़ेच की जा रही है… मेंछन किए जाने पर मुझे सूचित करें @@ -3463,7 +3453,7 @@ अन्य भुगतान (मोबाइल कॉयन) दान और बैज - Signal Android Backup + Signal Android का बैकअप सिग्नल एंड्रॉयड डीबग लॉग सबमिशन @@ -4304,6 +4294,8 @@ मैंने इस पासफ्रेज को लिखा है। इसके बिना, मैं बैकअप को पुनर्स्थापित करने में असमर्थ हूं। बैकअप पुनर्स्थापित करें खाता ट्रांसफ़र या रीस्टोर करें + + रीस्टोर या ट्रांसफ़र करें खाता ट्रांसफ़र करें छोड़ दे बैकअप चैट करें @@ -5124,7 +5116,7 @@ ग्रुप - Only messages from group chats + सिर्फ़ ग्रुप चैट से संदेश जोड़ें @@ -6687,7 +6679,7 @@ नीचे दिए \"सेटिंग्स पर जाएँ\" बटन को टैप करें - \"सेटिंग्स अलार्म व रिमाइंडर अनुमत करें\" को चालू करें। + \"सेटिंग्स अलार्म और रिमाइंडर की अनुमति दें\" को चालू करें। सेटिंग्स पर जाएँ @@ -7426,11 +7418,11 @@ आपका सिग्नल मीडिया बैकअप प्लान रद्द कर दिया गया है क्योंकि हम आपके भुगतान को प्रोसेस नहीं कर सके। डिलीट होने से पहले अपने बैकअप में मीडिया को डाउनलोड करने का यह आपका आखिरी मौका है। - Free up %1$s on this device + इस डिवाइस से %1$s स्पेस खाली करें - To finish downloading your Signal Backup your device needs %1$s of storage space. + अपने Signal बैकअप का डाउनलोड पूरा करने के लिए, आपके डिवाइस को %1$s स्टोरेज स्पेस की ज़रूरत है। - To free up space offload or delete unused apps or content large in file size. + स्पेस खाली करने के लिए, इस्तेमाल न किए जाने वाले ऐप या बड़े फ़ाइल आकार वाली सामग्री को ऑफ़लोड या डिलीट करें। आपके बैकअप का सब्सक्रिप्शन रिन्यू नहीं हो पाया @@ -7458,7 +7450,7 @@ अभी नहीं - Try later + फिर से कोशिश करें मीडिया डिलीट कर दिया जाएगा @@ -7470,9 +7462,9 @@ छोड़ दे - Skip restore? + रीस्टोर करना छोड़ें? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + अगर आप अपने बैकअप में बाकी के मीडिया और अटैचमेंट को रीस्टोर नहीं करते हैं, तो जब आपका डिवाइस अगली बार नया बैकअप पूरा करेगा, तब उन्हें डिलीट कर दिया जाएगा। @@ -7529,10 +7521,6 @@ सब्सक्रिप्शन बदलें या रद्द करें - - - आपका पिछला बैकअप %1$s को %2$s पर बनाया गया था। - चैट करने की सीमा @@ -7850,5 +7838,101 @@ रिमाइंडर आइकन + + + मेरे पास मेरा पुराना फ़ोन है + + जल्दी शुरुआत करने के लिए, अपने मौजूदा Signal अकाउंट से QR कोड स्कैन करें + + + मेरे पास मेरा पुराना फ़ोन नहीं है + + या आप उसी डिवाइस पर Signal को फिर से इंस्टॉल कर रहे हैं + + + अकाउंट ट्रांसफ़र या रीस्टोर करें + + इस डिवाइस पर अपना Signal अकाउंट और संदेश इतिहास पाएँ। + + Signal बैकअप से + + आपका मुफ़्त या भुगतान वाला Signal बैकअप प्लान + + किसी बैकअप फ़ोल्डर से + + किसी बैकअप फ़ाइल से + + अपना सेव किया गया कोई बैकअप चुनें + + आपके पुराने फ़ोन से + + सीधे आपके पुराने Android से ट्रांसफ़र करें + + + स्थानीय बैकअप को रीस्टोर करें + + अपने डिवाइस पर सेव किए गए बैकअप से अपने संदेश रीस्टोर करें। अगर आप अभी रीस्टोर नहीं करते, तो आप बाद में रीस्टोर नहीं कर पाएँगे। + + + अपनी बैकअप की दर्ज करें + + आपकी बैकअप की 64 अंकों वाला एक कोड होती है, जो आपके अकाउंट और डेटा को रिकवर करने के लिए ज़रूरी होती है। + + आपकी बैकअप की नहीं है? + + आपकी बैकअप की + + बैकअप की को उनके 64 अंकों वाले रिकवरी कोड के बिना रिकवर नहीं किया जा सकता। अगर आपकी बैकअप की खो गई है, तो Signal आपका बैकअप रीस्टोर करने में मदद नहीं कर सकता। + + अगर आपके पास अपना पुराना डिवाइस है, तो आप सेटिंग्स > चैट > Signal बैकअप में अपनी बैकअप की देख सकते हैं। फिर \'बैकअप की देखें\' पर टैप करें। + + अधिक जानें + + छोड़ें और रीस्टोर न करें + + + अपने पुराने फ़ोन से यह कोड स्कैन करें + + अपने पुराने डिवाइस पर Signal खोलें + + कैमरा आइकन पर टैप करें + + कैमरे से यह कोड स्कैन करें + + QR कोड जनरेट नहीं कर पाए + + पुराने डिवाइस पर स्कैन किया गया + + दोबारा कोशिश करें + + + खाता ट्रांसफ़र करें + + आपका अकाउंट एक नए डिवाइस पर ट्रांसफ़र किया जाएगा। यह डिवाइस आपके ग्रुप और संपर्क देख पाएगा, आपकी चैट ऐक्सेस कर पाएगा और आपके नाम से संदेश भेज पाएगा। %1$s + + अधिक जानें + + खाता ट्रांसफ़र करें + + सभी डिवाइस पर संदेश और चैट जानकारी शुरू से अंत तक एनक्रिप्शन से सुरक्षित हैं + + अकाउंट ट्रांसफ़र करने के लिए अनलॉक करें + + अपने दूसरे डिवाइस पर जारी रखें + + अपने अकाउंट को अपने दूसरे डिवाइस पर ट्रांसफ़र करना जारी रखें। + + + पुनर्स्थापित पूर्ण + + आपके Signal अकाउंट और संदेशों को आपके दूसरे डिवाइस पर ट्रांसफ़र करना शुरू हो गया है। Signal अब इस डिवाइस पर ऐक्टिव नहीं है। + + ट्रांसफ़र पूरा हुआ + + आपके Signal अकाउंट और संदेशों को आपके दूसरे डिवाइस पर ट्रांसफ़र कर दिया गया है। Signal अब इस डिवाइस पर ऐक्टिव नहीं है। + + ठीक है + + \ No newline at end of file diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 586b65e4a7..7022dc805f 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -1401,20 +1401,6 @@ više Dodaj opis grupe… - - - Prijenos s Android uređaja - - Prenesite svoj račun i poruke sa svog starog Android uređaja. - - Prijavite se bez prijenosa podataka - - Nastavite bez prijenosa poruka i medijskih zapisa - - Vratite podatke iz lokalne sigurnosne kopije - - Vratite svoje poruke iz datoteke sigurnosne kopije spremljene na vašem uređaju. - Preuzimanje sigurnosne kopije… @@ -1432,12 +1418,16 @@ Sve vaše poruke Vraćanje iz sigurnosne kopije - + Uključeni su samo medijski zapisi koji su poslani ili primljeni u zadnjih %1$d dana. Vaša sigurnosna kopija uključuje: Vrati iz sigurnosne kopije + + Posljednje sigurnosno kopiranje obavljeno je %1$s u %2$s. + + Učitavanje podataka o sigurnosnoj kopiji… Obavijesti me samo za Spominjanja @@ -3661,7 +3651,7 @@ Ostalo Plaćanja (MobileCoin) Donacije i značke - Signal Android Backup + Sigurnosne kopije za Signal Android Prijenos zapisnika pogreške za Signal Android @@ -4526,6 +4516,8 @@ Zapisao/la sam ovu lozinku. Bez toga neću moći vratiti sigurnosnu kopiju. Vrati iz sigurnosne kopije Prijenos ili vraćanje račun + + Vrati ili prenesi Prijenos računa Preskoči Sigurnosne kopije razgovora @@ -5364,7 +5356,7 @@ Grupe - Only messages from group chats + Samo poruke iz grupnih razgovora Dodaj @@ -6991,7 +6983,7 @@ Dodirnite tipku \"Idi na postavke\" u nastavku - Uključite \"Dopusti postavke, alarme i podsjetnike.\" + Uključite \"Dopusti postavljanje alarma i podsjetnika\". Otvori postavke @@ -7748,11 +7740,11 @@ Sigurnosno kopiranje medijskih zapisa je otkazano jer nije bilo moguće provesti plaćanje. Ovo je vaša posljednja prilika za preuzimanje medijskih zapisa iz sigurnosne kopije prije nego što budu izbrisani. - Free up %1$s on this device + Oslobodite %1$s prostora na ovom uređaju - To finish downloading your Signal Backup your device needs %1$s of storage space. + Za dovršetak preuzimanja sigurnosne kopije Signala potrebno je %1$s slobodnog prostora za pohranu. - To free up space offload or delete unused apps or content large in file size. + Za oslobađanje prostora izbrišite nekorištene aplikacije ili velike datoteke. Obnova vaše pretplate na sigurnosno kopiranje nije uspjela @@ -7780,7 +7772,7 @@ Ne sada - Try later + Pokušaj kasnije Medijski zapisi bit će izbrisani @@ -7792,9 +7784,9 @@ Preskoči - Skip restore? + Preskočiti vraćanje datoteka? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Ako preskočite ovaj korak, medijski zapisi i privitci spremljeni u sigurnosnoj kopiji bit će izbrisani sljedeći put kada vaš uređaj dovrši sigurnosno kopiranje. @@ -7851,10 +7843,6 @@ Promijenite ili otkažite pretplatu - - - Posljednje sigurnosno kopiranje obavljeno je %1$s u %2$s. - Ograničenje broja poruka u razgovoru @@ -8184,5 +8172,101 @@ Ikona podsjetnika + + + Imam svoj stari telefon + + Skenirajte QR kôd sa svog trenutnog Signal računa za brzi početak + + + Nemam svoj stari telefon + + Ili ako želite ponovno instalirati Signal na isti uređaj + + + Vratite ili prenesite račun + + Prenesite svoj Signal račun i povijest poruka na ovaj uređaj. + + Iz sigurnosne kopije Signala + + Vaš besplatni ili plaćeni plan za sigurnosno kopiranje Signala + + Iz mape za sigurnosne kopije + + Iz datoteke sigurnosne kopije + + Odaberite sigurnosnu kopiju koju ste spremili + + Sa svog starog telefona + + Prenesite izravno sa svog starog Android uređaja + + + Vratite podatke iz lokalne sigurnosne kopije + + Vratite svoje poruke iz sigurnosne kopije spremljene na vašem uređaju. Ako to ne učinite sada, kasnije više nećete moći vratiti podatke iz sigurnosne kopije. + + + Unesite svoj ključ za sigurnosnu kopiju + + Ključ za sigurnosnu kopiju je 64-znamenkasti kôd koji je potreban za oporavak vašeg računa i podataka. + + Nemate ključ za sigurnosnu kopiju? + + Ključ za sigurnosnu kopiju + + Sigurnosne kopije nije moguće vratiti bez 64-znamenkastog koda za oporavak. Ako ste zaboravili svoj ključ, nećete moći vratiti podatke iz sigurnosne kopije. + + Ako imate svoj stari uređaj, svoj ključ za sigurnosnu kopiju možete pregledati u Postavkama > Razgovori > Sigurnosne kopije Signala. Zatim dodirnite Prikaži ključ za sigurnosnu kopiju. + + Saznajte više + + Preskoči sigurnosno kopiranje + + + Skenirajte ovaj kôd svojim starim uređajem + + Otvorite Signal na starom uređaju + + Dodirnite ikonu kamere + + Skenirajte ovaj kôd kamerom + + Nije moguće generirati QR kôd + + Skenirano na starom uređaju + + Pokušajte ponovno + + + Prijenos računa + + Vaš će se račun prenijeti na novi uređaj. Taj će uređaj moći vidjeti vaše grupe i kontakte, pristupiti vašim razgovorima i slati poruke u vaše ime. %1$s + + Saznajte više + + Prijenos računa + + Poruke i podaci o razgovorima zaštićeni su sveobuhvatnim šifriranjem na svim uređajima + + Otključajte za prijenos računa + + Nastavite na drugom uređaju + + Prijenos računa nastavit će se na drugom uređaju. + + + Dovršeno vraćanje sigurnosne kopije + + Započeo je prijenos vašeg Signal računa i poruka na drugi uređaj. Signal više nije aktivan na ovom uređaju. + + Prijenos je dovršen + + Vaš Signal račun i poruke preneseni su na drugi uređaj. Signal više nije aktivan na ovom uređaju. + + U redu + + \ No newline at end of file diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index f984b583fb..000f014277 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1325,20 +1325,6 @@ tovább Csoportleírás hozzáadása… - - - Átvitel Android eszközről - - Vidd át a fiókodat és az üzeneteidet régi Android-eszközödről. - - Bejelentkezés átvitel nélkül - - Folytatás az üzenetek és a médiafájlok átvitele nélkül - - Helyi biztonsági mentés visszaállítása - - Állítsd vissza az üzeneteidet az eszközödre mentett biztonsági fájlból. - Biztonsági mentés letöltése… @@ -1356,12 +1342,16 @@ Az összes üzeneted Visszaállítás biztonsági mentésből - + Csak az elmúlt %1$d napban küldött vagy fogadott médiafájlokat tartalmazza. A biztonsági másolat a következőket tartalmazza: Biztonsági mentés visszaállítása + + Az utolsó biztonsági mentés dátuma: %1$s, %2$s. + + Biztonsági mentés részleteinek lekérése… Értesítsen említés esetén @@ -3463,7 +3453,7 @@ Egyéb Fizetések (MobileCoin) Adományok és jelvények - Signal Android Backup + Signal Android biztonsági mentés A Signal Android hibajavítási naplójának benyújtása @@ -4304,6 +4294,8 @@ Feljegyeztem ezt a jelmondatot. Enélkül nem leszek képes visszaállítani biztonsági mentést. Biztonsági mentés visszaállítása Fiók átvitele vagy helyreállítása + + Visszaállítás vagy átvitel Fiók átvitele Kihagyás Csevegések biztonsági mentése @@ -5124,7 +5116,7 @@ Csoportok - Only messages from group chats + Csak a csoportos csevegésekből származó üzenetek Hozzáadás @@ -7426,11 +7418,11 @@ A médiafájlok biztonsági mentésére vonatkozó Signal-előfizetésedet töröltük, mert nem tudtuk feldolgozni a tranzakciót. Ez az utolsó lehetőséged arra, hogy letöltsd a biztonsági mentésben lévő médiafájlt, mielőtt törlésre kerül. - Free up %1$s on this device + Szabadíts fel %1$s tárhelyet ezen az eszközön - To finish downloading your Signal Backup your device needs %1$s of storage space. + A Signal Backup letöltésének befejezéséhez eszközödnek %1$s tárhelyre van szüksége. - To free up space offload or delete unused apps or content large in file size. + Hely felszabadításához töltsd le vagy töröld a nem használt alkalmazásokat vagy nagy fájlméretű tartalmakat. Nem sikerült megújítani a biztonsági mentésre vonatkozó előfizetésedet @@ -7458,7 +7450,7 @@ Később - Try later + Próbáld újra később! A médiafájl törlésre kerül @@ -7470,9 +7462,9 @@ Kihagyás - Skip restore? + Visszaállítás kihagyása? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Ha a visszaállítás kihagyása lehetőséget választod, a biztonsági másolatban lévő, fennmaradó médiafájlok és mellékletek törlődni fognak, amikor az eszköz legközelebb egy új biztonsági mentést végez. @@ -7529,10 +7521,6 @@ Előfizetés módosítása vagy lemondása - - - Az utolsó biztonsági mentés dátuma: %1$s, %2$s. - A csevegés hosszának korlátja @@ -7850,5 +7838,101 @@ Emlékeztető ikon + + + Nálam van a régi telefonom + + A gyors kezdéshez olvasd be a QR-kódot jelenlegi Signal-fiókodból + + + Nincs nálam a régi telefonom + + Vagy telepítsd újra a Signalt ugyanarra az eszközre + + + Fiók visszaállítása vagy átvitele + + Helyezd át Signal-fiókodat és üzenetelőzményeidet erre az eszközre. + + A Signal biztonsági mentéseiből + + Az ingyenes vagy fizetett Signal biztonsági mentési csomag + + Egy biztonsági mentés mappából + + Biztonsági másolati fájlból + + Válassz egy mentett biztonsági másolatot + + A régi telefonodról + + Vidd át közvetlenül régi Android-eszközödről + + + Helyi biztonsági mentés visszaállítása + + Állítsd vissza az üzeneteidet az eszközödre mentett biztonsági másolatból. Ha most nem állítod vissza, később nem fogod tudni visszaállítani. + + + Add meg a biztonsági kulcsot + + A biztonsági kulcs egy 64 számjegyű kód, amely a fiók és az adatok helyreállításához szükséges. + + Nincs biztonsági kulcsod? + + Biztonsági kulcs + + A biztonsági másolatokat nem lehet visszaállítani a 64 számjegyű helyreállítási kód nélkül. Ha elvesztetted a biztonsági kulcsod, a Signal nem tud segíteni a biztonsági másolat visszaállításában. + + Ha rendelkezel a régi eszközzel, megtekintheted a biztonsági kulcsot a Beállítások > Csevegés > Signal-biztonsági mentések menüpontban. Ezután koppints a Biztonsági kulcs megtekintése lehetőségre. + + Tudj meg többet + + Kihagyás és visszaállítás + + + Olvasd be ezt a kódot a régi telefonoddal + + Nyisd meg a Signalt a régi eszközödön + + Koppints a kamera ikonra + + Olvasd be ezt a kódot a kamerával + + QR-kód generálása sikertelen + + Régi eszközön beolvasva + + Újra + + + Fiók átvitele + + Fiókod átkerül egy új eszközre. Ez az eszköz láthatja a csoportjaidat és a névjegyeidet, hozzáférhet a csevegéseidhez, és üzeneteket küldhet a nevedben. %1$s + + Tudj meg többet + + Fiók átvitele + + Az üzeneteket és a csevegési információkat végpontok közötti titkosítás védi minden eszközön + + A fiók átviteléhez oldd fel + + Folytasd a másik eszközön + + Folytasd a fiókod átvitelét a másik eszközödön. + + + Visszaállítás kész + + A Signal-fiók és az üzenetek átvitele megkezdődött a másik eszközre. A Signal most inaktív ezen az eszközön. + + Átvitel kész + + A Signal-fiók és az üzenetek átkerültek a másik eszközödre. A Signal most inaktív ezen az eszközön. + + OK + + \ No newline at end of file diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 9cc4268680..110f145b20 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -1287,20 +1287,6 @@ selanjutnya Tambahkan deskripsi grup… - - - Transfer dari perangkat Android - - Transfer akun dan pesan dari perangkat Android lama Anda. - - Masuk tanpa mentransfer - - Lanjutkan tanpa mentransfer pesan dan media Anda - - Pulihkan pencadangan lokal - - Pulihkan pesan dari file cadangan yang disimpan di perangkat Anda. - Mengunduh cadangan … @@ -1318,12 +1304,16 @@ Semua pesan Anda Pulihkan dari cadangan - + Hanya media yang dikirim atau diterima dalam %1$d hari terakhir yang disertakan. Cadangan Anda mencakup: Pulihkan cadangan + + Pencadangan terakhir Anda dibuat pada %1$s pukul %2$s. + + Mengambil detail cadangan … Beritahu saya ketika ada penyebutan @@ -3364,7 +3354,7 @@ Lainnya Pembayaran (MobileCoin) Donasi & Lencana - Signal Android Backup + Pencadangan Signal Android Pengiriman Catatan Debug Android Signal @@ -4193,6 +4183,8 @@ Saya telah mencatat frasa ini. Tanpa ini, saya tidak dapat memulihkan cadangan. Pulihkan cadangan Transfer atau pulihkan akun + + Pulihkan atau transfer Transfer akun Lewati Cadangan obrolan @@ -5004,7 +4996,7 @@ Grup - Only messages from group chats + Hanya pesan dari obrolan grup Tambahkan @@ -6535,7 +6527,7 @@ Ketuk tombol \"Buka pengaturan\" di bawah ini - Aktifkan \"Izinkan pengaturan alarm dan pengingat.\" + Aktifkan \"Izinkan pengaturan alarm dan pengingat\". Buka pengaturan @@ -7265,11 +7257,11 @@ Paket pencadangan media di Signal telah dibatalkan karena kami tidak dapat memproses pembayaran Anda. Ini kesempatan terakhir Anda untuk mengunduh media di cadangan sebelum dihapus. - Free up %1$s on this device + Kosongkan ruang %1$s di perangkat ini - To finish downloading your Signal Backup your device needs %1$s of storage space. + Untuk menyelesaikan pengunduhan Cadangan Signal, perangkat butuh ruang penyimpanan sebesar %1$s. - To free up space offload or delete unused apps or content large in file size. + Untuk mengosongkan ruang, hapus apl tidak terpakai atau file berukuran besar. Langganan pencadangan Anda gagal diperpanjang @@ -7297,7 +7289,7 @@ Tidak sekarang - Try later + Coba nanti Media akan dihapus @@ -7309,9 +7301,9 @@ Lewati - Skip restore? + Lewati pemulihan? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Jika Anda melewati pemulihan, media dan lampiran yang masih ada di cadangan akan dihapus saat nantinya perangkat menyelesaikan pencadangan baru. @@ -7368,10 +7360,6 @@ Mengubah atau membatalkan langganan - - - Pencadangan terakhir Anda dibuat pada %1$s pukul %2$s. - Batas pesan @@ -7683,5 +7671,101 @@ Ikon pengingat + + + Ponsel lama saya masih ada + + Pindai kode QR dari akun Signal Anda saat ini untuk memulai dengan cepat + + + Ponsel lama saya sudah tidak ada + + Atau Anda menginstal ulang Signal di perangkat yang sama + + + Pulihkan atau transfer akun + + Transfer akun dan riwayat pesan Signal Anda ke perangkat ini. + + Dari Cadangan Signal + + Paket Pencadangan Signal gratis atau berbayar + + Dari folder cadangan + + Dari file cadangan + + Pilih cadangan yang telah Anda simpan + + Dari ponsel lama Anda + + Transfer langsung dari Android lama Anda + + + Pulihkan pencadangan lokal + + Pulihkan pesan dari cadangan yang disimpan di perangkat Anda. Jika tidak memulihkan sekarang, Anda tidak akan bisa memulihkannya nanti. + + + Masukkan kunci cadangan Anda + + Kunci cadangan adalah kode 64 digit yang diperlukan untuk memulihkan akun dan data Anda. + + Tidak punya kunci cadangan? + + Kunci cadangan + + Cadangan tidak dapat dipulihkan tanpa kode pemulihan 64 digit. Jika Anda kehilangan kunci cadangan, Signal tidak dapat membantu memulihkan cadangan Anda. + + Jika perangkat lama masih ada, Anda dapat melihat kunci cadangan di Pengaturan > Obrolan > Cadangan Signal. Lalu ketuk Lihat kunci cadangan. + + Pelajari selengkapnya + + Lewati dan jangan pulihkan + + + Pindai kode ini dengan ponsel lama Anda + + Buka Signal di perangkat lama Anda + + Ketuk ikon kamera + + Pindai kode ini dengan kamera + + Tidak dapat membuat kode QR + + Dipindai di perangkat lama + + Coba lagi + + + Transfer akun + + Akun Anda akan ditransfer ke perangkat baru. Perangkat ini akan dapat melihat grup dan kontak Anda, mengakses obrolan, dan mengirimkan pesan atas nama Anda. %1$s + + Pelajari selengkapnya + + Transfer akun + + Pesan dan info obrolan dilindungi enkripsi menyeluruh di semua perangkat + + Buka kunci untuk mentransfer akun + + Lanjutkan di perangkat yang lain + + Lanjut transfer akun Anda di perangkat yang lain. + + + Pemulihan selesai + + Akun dan pesan Signal Anda sudah mulai ditransfer ke perangkat yang lain. Sekarang, Signal sudah tidak aktif di perangkat ini. + + Transfer selesai + + Akun dan pesan Signal Anda telah ditransfer ke perangkat Anda yang lain. Sekarang, Signal sudah tidak aktif di perangkat ini. + + Oke + + \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2e56e49e98..664a4d4d64 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1325,20 +1325,6 @@ altro Aggiungi descrizione gruppo… - - - Trasferisci da dispositivo Android - - Trasferisci il tuo account e i messaggi dal tuo dispositivo Android precedente. - - Accedi senza il trasferimento dati - - Continua senza trasferire i tuoi messaggi, foto e video - - Ripristina backup locale - - Ripristina i tuoi messaggi da un file di backup che hai salvato sul tuo dispositivo. - Download backup in corso… @@ -1356,12 +1342,16 @@ Tutti i tuoi messaggi Ripristina da backup - + Sono inclusi solo i media inviati o ricevuti negli ultimi %1$d giorni. Il tuo backup include: Ripristina backup + + Il tuo ultimo backup è stato eseguito alle ore %2$s del giorno %1$s. + + Recupero dettagli del backup… Notificami per le menzioni @@ -3463,7 +3453,7 @@ Altro Pagamenti (MobileCoin) Donazioni e Badge Signal - Signal Android Backup + Backup di Signal Android Invio log di debug per Signal Android @@ -4304,6 +4294,8 @@ Ho scritto questa passphrase. Senza di questa, non sarò in grado di ripristinare un backup. Ripristina backup Trasferisci o ripristina account + + Ripristina o trasferisci Trasferisci account Salta Backup delle chat @@ -5124,7 +5116,7 @@ Gruppi - Only messages from group chats + Solo messaggi da chat di gruppo Aggiungi @@ -7426,11 +7418,11 @@ Il tuo abbonamento per il backup dei media su Signal è stato annullato perché non siamo riusciti ad elaborare il tuo pagamento. Questa è la tua ultima possibilità per scaricare i media presenti nel tuo backup prima che vengano eliminati. - Free up %1$s on this device + Libera %1$s di spazio su questo dispositivo - To finish downloading your Signal Backup your device needs %1$s of storage space. + Per completare il download del tuo Backup di Signal servono altri %1$s di spazio sul tuo dispositivo. - To free up space offload or delete unused apps or content large in file size. + Per liberare spazio, rimuovi o trasferisci altrove le app che non usi o i contenuti di grandi dimensioni. Il tuo piano per i backup non è stato rinnovato @@ -7458,7 +7450,7 @@ Non ora - Try later + Prova dopo I tuoi media verranno eliminati @@ -7470,9 +7462,9 @@ Salta - Skip restore? + Saltare il ripristino? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Se scegli di saltare il ripristino, i media e gli allegati rimanenti nel tuo backup verranno eliminati la prossima volta che il tuo dispositivo completa un nuovo backup. @@ -7529,10 +7521,6 @@ Cambia o annulla il piano - - - Il tuo ultimo backup è stato eseguito alle ore %2$s del giorno %1$s. - Limiti messaggi chat @@ -7850,5 +7838,101 @@ Icona del promemoria + + + Ho il mio vecchio telefono + + Scansiona un codice QR dal tuo attuale account Signal per iniziare subito + + + Non ho il mio vecchio telefono + + O se stai reinstallando Signal sullo stesso dispositivo + + + Ripristina o trasferisci il tuo account + + Aggiungi i dati del tuo account Signal e la cronologia messaggi su questo dispositivo. + + Dai backup di Signal + + Backup fatti con l\'abbonamento di Signal (gratis o a pagamento) + + Da una cartella di backup + + Da un file di backup + + Un backup fatto per conto tuo + + Dal tuo vecchio telefono + + Trasferisci direttamente dal tuo vecchio Android + + + Ripristina backup locale + + Ripristina i messaggi da un backup che hai salvato sul tuo dispositivo. Se non fai ora il ripristino, non potrai farlo più tardi. + + + Inserisci la tua chiave di backup + + La chiave di backup è un codice di 64 cifre che ti consente di ripristinare il tuo account e i relativi dati. + + Non hai una chiave di backup? + + Chiave di backup + + Non puoi ripristinare un backup senza l\'apposito codice di 64 cifre. Se hai perso la tua chiave di backup, Signal non può aiutarti a recuperare il backup. + + Se hai ancora il tuo dispositivo precedente, puoi vedere la chiave di backup nelle Impostazioni > Chat > Backup di Signal. Poi tocca su \"Vedi chiave di backup\". + + Scopri di più + + Salta e non ripristinare + + + Scansiona questo codice con il tuo precedente telefono + + Apri Signal sul tuo dispositivo precedente + + Tocca l\'icona della fotocamera + + Scansiona questo codice con la fotocamera + + Impossibile generare un codice QR + + Scansionato su dispositivo precedente + + Riprova + + + Trasferisci account + + Il tuo account verrà trasferito su un nuovo dispositivo. Su questo dispositivo potrai vedere i tuoi gruppi e contatti, accedere alle tue chat e inviare messaggi a tuo nome. %1$s + + Scopri di più + + Trasferisci account + + I messaggi e le informazioni delle chat sono protetti da una crittografia end-to-end su tutti i dispositivi + + Sblocca o trasferisci il tuo account + + Continua sull\'altro dispositivo + + Prosegui il trasferimento del tuo account sull\'altro dispositivo. + + + Ripristino completato + + Il tuo account Signal e i relativi messaggi sono in fase di trasferimento sul nuovo dispositivo. Da ora Signal diventerà inattivo su questo dispositivo. + + Trasferimento completato + + Il tuo account Signal e i relativi messaggi sono stati trasferiti sul nuovo dispositivo. Da ora Signal diventerà inattivo su questo dispositivo. + + Ok + + \ No newline at end of file diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 1d52e2600a..0b34397120 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -1401,20 +1401,6 @@ עוד הוסף תיאור קבוצה… - - - העבר ממכשיר Android - - העברת החשבון וההודעות שלך ממכשיר ה–Android הישן שלך. - - להתחבר בלי להעביר - - להמשיך בלי להעביר את ההודעות והמדיה שלך - - שחזור גיבוי מקומי - - שחזור ההודעות שלך מקובץ גיבוי ששמרת במכשיר שלך. - מורידים גיבוי… @@ -1432,12 +1418,16 @@ כל ההודעות שלך שחזר מגיבוי - + רק מדיה שנשלחה או התקבלה ב–%1$d הימים האחרונים כלולה. הגיבוי שלך כולל את: שחזר גיבוי + + הגיבוי האחרון שלך התבצע ב–%1$s בשעה %2$s. + + טוענים פרטי גיבוי… יידע אותי לגבי אזכורים @@ -3661,7 +3651,7 @@ אחר תשלומים (MobileCoin) תרומות ותגים - Signal Android Backup + גיבוי Signal Android הגשת יומן ניפוי באגים של Signal Android @@ -4526,6 +4516,8 @@ כתבתי משפט־סיסמה זה. בלעדיו, לא אוכל לשחזר גיבוי. שחזר גיבוי העבר או שחזר חשבון + + שחזור או העברה העבר חשבון דלג גיבויי צ׳אטים @@ -5364,7 +5356,7 @@ קבוצות - Only messages from group chats + רק הודעות מצ׳אטים קבוצתיים הוסף @@ -7748,11 +7740,11 @@ תכנית גיבוי המדיה שלך ב–Signal בוטלה כי לא הצלחנו לעבד את התשלום שלך. זו ההזדמנות האחרונה שלך להוריד את המדיה שבגיבוי שלך לפני שהיא נמחקת. - Free up %1$s on this device + יש לפנות %1$s במכשיר הזה - To finish downloading your Signal Backup your device needs %1$s of storage space. + כדי להשלים את ההורדה של גיבוי Signal שלך, המכשיר שלך צריך %1$s של שטח אחסון. - To free up space offload or delete unused apps or content large in file size. + כדי לפנות מקום, יש להעביר או למחוק אפליקציות או תוכן שתופסים נפח רב ואינם בשימוש. לא ניתן היה לחדש את מנוי הגיבויים שלך @@ -7780,7 +7772,7 @@ לא עכשיו - Try later + לנסות אחר כך המדיה תימחק @@ -7792,9 +7784,9 @@ דלג - Skip restore? + לדלג על שחזור? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + בחירה בדילוג על שחזור תגרום למחיקת המדיה והקבצים המצורפים הנותרים בגיבוי שלך בפעם הבאה שהמכשיר שלך ישלים גיבוי חדש. @@ -7851,10 +7843,6 @@ שינוי או ביטול מנוי - - - הגיבוי האחרון שלך התבצע ב–%1$s בשעה %2$s. - מגבלות אורך צ׳אט @@ -8184,5 +8172,101 @@ סמל תזכורת + + + יש לי את הטלפון הישן שלי + + אפשר לסרוק קוד QR מחשבון Signal הנוכחי שלך כדי להתחיל במהירות + + + אין לי את הטלפון הישן שלי + + או שמדובר בהתקנה חוזרת של Signal על אותו מכשיר + + + שחזור או העברת חשבון + + להעביר את חשבון Signal והיסטוריית ההודעות שלך למכשיר הזה. + + מגיבויי Signal + + תכנית הגיבוי שלך ב–Signal (בחינם או בתשלום) + + מתיקיית גיבוי + + מקובץ גיבוי + + לבחור גיבוי ששמרת + + מהטלפון הישן שלך + + להעביר ישירות ממכשיר ה–Android הישן שלך + + + שחזור גיבוי מקומי + + שחזור ההודעות שלך מקובץ גיבוי ששמרת במכשיר שלך. אם לא תשחזר/י עכשיו, לא תהיה לך אפשרות לשחזר מאוחר יותר. + + + הזנת מפתח הגיבוי שלך + + מפתח הגיבוי שלך הוא קוד בן 64 ספרות שמאפשר לך לשחזר את החשבון והנתונים שלך. + + אין לך מפתח גיבוי? + + מפתח גיבוי + + לא ניתן לשחזר גיבויים ללא קוד השחזור בן 64 הספרות שלהם. אם איבדת את מפתח הגיבוי, Signal לא תוכל לעזור לשחזר את הגיבוי שלך. + + אם יש לך את המכשיר הישן שלך, יש לך אפשרות לראות את מפתח הגיבוי באמצעות כניסה להגדרות > צ׳אטים > גיבויי Signal. לאחר מכן יש ללחוץ על ״הצגת מפתח גיבוי״. + + למידע נוסף + + דילוג בלי לשחזר + + + סריקת הקוד הזה עם הטלפון הישן שלך + + יש לפתוח את Signal במכשיר הישן שלך + + ללחוץ על סמל המצלמה + + לסרוק את הקוד הזה עם המצלמה + + לא ניתן ליצור קוד QR + + נסרק במכשיר הישן + + נסה שוב + + + העבר חשבון + + החשבון שלך יועבר למכשיר חדש. המכשיר הזה יוכל לראות את הקבוצות ואנשי הקשר שלך, לקבל גישה לצ׳אטים שלך, ולשלוח הודעות בשמך. %1$s + + למידע נוסף + + העבר חשבון + + הודעות ופרטי צ׳אט מוגנים באמצעות הצפנה מקצה לקצה בכל המכשירים + + יש לבטל את הנעילה כדי להעביר את החשבון + + המשך במכשיר האחר שלך + + יש להמשיך את העברת החשבון שלך במכשיר האחר שלך. + + + שחזור הושלם + + התחילה ההעברה של חשבון Signal וההודעות שלך למכשיר האחר שלך. Signal לא פעילה כעת במכשיר זה. + + העברה הושלמה + + חשבון Signal שלך וההודעות שלך הועברו למכשיר האחר שלך. Signal לא פעילה כעת במכשיר זה. + + בסדר + + \ No newline at end of file diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index fbf85680d5..b1b6a7976d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1287,20 +1287,6 @@ 続き グループの説明を追加してください… - - - Android端末から移行 - - 以前のAndroidデバイスからアカウントとメッセージを転送します。 - - 転送せずにログインする - - メッセージやメディアを転送せずに続ける - - ローカルバックアップを復元する - - 端末に保存したバックアップファイルからメッセージを復元します。 - バックアップをダウンロードしています… @@ -1318,12 +1304,16 @@ すべてのメッセージ バックアップから復元 - + 過去 %1$d 日間に送受信されたメディアのみが対象です。 バックアップの内容 バックアップを復元する + + 最終バックアップは%1$sの%2$sです。 + + バックアップの詳細を取得中… メンションの通知 @@ -3364,7 +3354,7 @@ その他 決済機能 (MobileCoin) 寄付 & バッジ - Signal Android Backup + Signal Androidバックアップ Signal Androidデバッグログの提供 @@ -4193,6 +4183,8 @@ パスフレーズを書き留めました。パスフレーズがなければ、バックアップは復元できません。 バックアップを復元する アカウントの移行または復元 + + 復元または移行する アカウントの移行 スキップする チャットのバックアップ @@ -5004,7 +4996,7 @@ グループ - Only messages from group chats + グループチャットのメッセージのみ 追加する @@ -6535,7 +6527,7 @@ 下の「設定へ」ボタンをタップしてください - 「アラームとリマインダーの設定を許可する」を有効にする。 + 「アラームとリマインダーの設定を許可する」を有効にしてください。 設定へ @@ -7265,11 +7257,11 @@ 決済手続きができなかったため、Signalメディアのバックアッププランが無効になりました。バックアップ内のメディアをダウンロードできるのは今日が最終日となります。 - Free up %1$s on this device + 端末に%1$sのストレージを確保してください - To finish downloading your Signal Backup your device needs %1$s of storage space. + Signalバックアップのダウンロードを完了するには、ご利用の端末に%1$sのストレージが必要です。 - To free up space offload or delete unused apps or content large in file size. + ストレージ容量を増やすには、使っていないアプリやサイズの大きいファイルをオフロード、または消去します。 バックアップサブスクリプションの更新ができませんでした @@ -7297,7 +7289,7 @@ 今はしない - Try later + あとで試す メディアが消去されます @@ -7309,9 +7301,9 @@ スキップ - Skip restore? + 復元をスキップしますか? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + 復元をスキップすると、バックアップ内のメディアと添付ファイルは、お使いの端末が次回新しいバックアップを完了したときに消去されます。 @@ -7368,10 +7360,6 @@ サブスクリプションの変更またはキャンセル - - - 最終バックアップは%1$sの%2$sです。 - チャット内の最大メッセージ件数 @@ -7683,5 +7671,101 @@ リマインダーアイコン + + + 以前利用していた端末がある場合 + + 新しいSignalアカウントからQRコードをスキャンすれば、すぐに再開できます + + + 以前利用していた端末がない場合 + + または、Signalを同じ端末に再インストールする場合 + + + アカウントの復元または移行 + + Signalアカウントとメッセージ履歴をこの端末に復元します。 + + Signalバックアップから復元する + + 無料、または有料のSignalバックアッププランから復元します + + バックアップフォルダから復元する + + バックアップファイルから復元する + + 保存したバックアップフォルダを選択してください + + 以前利用していた端末から復元する + + 以前利用していたAndroidから直接移行する + + + ローカルバックアップを復元する + + 端末に保存したバックアップからメッセージを復元します。今復元しないと、後で復元することはできません。 + + + バックアップキーを入力してください + + バックアップキーは、アカウントとデータを復元するために必要な64桁のコードです。 + + バックアップキーをお持ちでない場合 + + バックアップキー + + バックアップは、64桁のバックアップキーがないと復元できません。また、バックアップキーを紛失した場合、Signalはバックアップの復元対応はできません。 + + 以前利用していた端末をお持ちの場合は、設定 > チャット > 「Signalバックアップ」でバックアップキーを確認できます。「バックアップキーを表示する」が表示されたらタップします。 + + 詳しく見る + + スキップして復元しない + + + 以前利用していた端末でこのコードをスキャンしてください + + 以前利用していた端末でSignalを開きます + + カメラのアイコンをタップします + + そのカメラでこのコードをスキャンします + + QRコードを生成できません + + 以前利用していた端末でスキャンされました + + 再試行 + + + アカウントの移行 + + アカウントが新しい端末に移行されます。新しい端末でグループや連絡先を確認したり、チャットを見たり、自分の名前でメッセージを送信したりできます。%1$s + + 詳しく見る + + アカウントの移行 + + メッセージ情報とチャット情報は、すべての端末でエンドツーエンドの暗号化で保護されています + + ロックを解除してアカウントを移行する + + 他の端末で続ける + + 引き続き、他の端末でアカウントの移行をおこなってください。 + + + 復元が完了しました + + Signalアカウントとメッセージは他の端末への移行を開始しました。この端末上でSignalはご利用できなくなりました。 + + 移行が完了しました + + Signalアカウントとメッセージは他の端末に移行されました。この端末上でSignalはご利用できなくなりました。 + + OK + + \ No newline at end of file diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index e2157ee800..484f27cbf4 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -1325,20 +1325,6 @@ მეტი ჯგუფის აღწერის დამატება… - - - Android მოწყობილობიდან გადმოტანა - - გადმოიტანე შენი მონაცემები და შეტყობინებები ძველი Android მოწყობილობიდან. - - სისტემაში შესვლა გადატანის გარეშე - - განაგრძე შენი შეტყობინებების და მედია ფაილების გადატანის გარეშე - - ადგილობრივი სარეზერვო კოპიების აღდგენა - - აღადგინე შენი შეტყობინებები შენს მოწყობილობაში შენახული სარეზერვო კოპიების ფაილიდან. - მიმდინარეობს სარეზერვო კოპიების გადმოწერა… @@ -1356,12 +1342,16 @@ შენი ყველა შეტყობინება სარეზერვო კოპიიდან აღდგენა - + შედის მხოლოდ ბოლო %1$d დღის განმავლობაში გაგზავნილი ან მიღებული მედია ფაილები. შენს სარეზერვო კოპირებაში შედის: სარეზერვო კოპიების აღდგენა + + შენი ბოლო სარეზერვო კოპია შეიქმნა %1$s-ში %2$s-ზე. + + მიმდინარეობს სათადარიგო ასლების დეტალების მიღება… შემატყობინეთ, როცა მომნიშნავენ @@ -3463,7 +3453,7 @@ სხვა ტრანზაქციები (MobileCoin) დონაციები & ემბლემები - Signal Android Backup + Signal Android-ის სათადარიგო ასლები Signal Android-ის გამართვის ჟურნალის ატვირთვა @@ -4304,6 +4294,8 @@ ეს პაროლ-ფრაზა ჩავიწერე. ამის გარეშე ვერ შევძლებ სათადარიგო ასლის აღდგენას. რეზერვის აღდგენა გადაიტანე ან აღადგინე მონაცემები + + აღდგენა ან გადატანა მონაცემების გადატანა გამოტოვება ჩატის სარეზერვო კოპიები @@ -5124,7 +5116,7 @@ ჯგუფები - Only messages from group chats + მხოლოდ ჯგუფური ჩატების შეტყობინებები დამატება @@ -6687,7 +6679,7 @@ დააჭირე \"პარამეტრებში გადასვლა\"-ს ღილაკს დაბლა - ჩართე \"შეტყობინებების და შეხსენებების დაყენების დაშვება.\" + ჩართე \"მაღვიძარებისა და შეხსენებების დაყენების დაშვება.\" შედი პარამეტრებში @@ -7426,11 +7418,11 @@ შენი Signal-ის მედია ფაილების სარეზერვო კოპირების გამოწერა გაუქმდა, რადგან შენი ტრანზაქციის დამუშავება ვერ მოვახერხეთ. შენს სარეზერვო კოპიებში არსებული მედია ფაილების წაშლამდე მათი გადმოწერისთვის ბოლო შანსი გაქვს. - Free up %1$s on this device + გაათავისუფლე %1$s ამ მოწყობილობაზე - To finish downloading your Signal Backup your device needs %1$s of storage space. + Signal-ის სათადარიგო ასლის ჩამოტვირთვის დასასრულებლად შენს მოწყობილობას მეხსიერების %1$s სივრცე სჭირდება. - To free up space offload or delete unused apps or content large in file size. + სივრცის გასათავისუფლებლად გადატვირთე ან წაშალე გამოუყენებელი აპები ან დიდი ზომის ფაილები. შენი სათადარიგო ასლების გამოწერის განახლება ვერ მოხერხდა @@ -7458,7 +7450,7 @@ ახლა არა - Try later + მოგვიანებით სცადე მედია ფაილები წაიშლება @@ -7470,9 +7462,9 @@ გამოტოვება - Skip restore? + გსურს აღდგენის გამოტოვება? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + თუ აღდგენას გამოტოვებ, შენი მედია ფაილებისა და დანართების სათადარიგო ასლები წაიშლება შემდეგ ჯერზე, როცა შენი მოწყობილობა ახალი სათადარიგო ასლების შექმნას დაასრულებს. @@ -7529,10 +7521,6 @@ გამოწერის შეცვლა ან გაუქმება - - - შენი ბოლო სარეზერვო კოპია შეიქმნა %1$s-ში %2$s-ზე. - მიმოწერის სიგრძის ლიმიტები @@ -7850,5 +7838,101 @@ შეხსენების ხატულა + + + ჩემი ძველი მობილური მაქვს + + სწრაფად დასაწყებად დაასკანირე QR კოდი შენი ახლანდელი Signal-ის ანგარიშიდან + + + ჩემი ძველი მობილური არ მაქვს + + ან Signal-ს თავიდან იწერ იმავე მოწყობილობაზე + + + აღდგენა ან ანგარიშის გადატანა + + გადმოიტანე შენი Signal-ის ანგარიში და შეტყობინებების ისტორია ამ მოწყობილობაზე. + + Signal-ის სათადარიგო ასლებიდან + + შენი უფასო ან ფასიანი Signal-ის სათადარიგო ასლების გამოწერა + + სათადარიგო ასლების საქაღალდიდან + + სათადარიგო ასლების ფაილიდან + + აირჩიე შენი შენახული სათადარიგო ასლი + + შენი ძველი მობილურიდან + + გადმოიტანე პირდაპირ შენი ძველი მობილურიდან + + + ადგილობრივი სარეზერვო კოპიების აღდგენა + + აღადგინე შენი შეტყობინებები შენს მოწყობილობაში შენახული სათადარიგო ასლიდან. თუ ახლა არ აღადგენ, მოგვიანებით აღდგენას ვეღარ შეძლებ. + + + შეიყვანე შენი სათადარიგო ასლების გასაღები + + შენი სათადარიგო ასლების გასაღები 64 ციფრიანი კოდია, რომელიც შენი ანგარიშისა და მონაცემების აღსადგენადაა საჭირო. + + არ გაქვს სათადარიგო ასლების გასაღები? + + სათადარიგო ასლების გასაღები + + სათადარიგო ასლების აღდგენა მათი აღდგენის 64 ციფრიანი კოდის გარეშე შეუძლებელია. თუ შენი სათადარიგო ასლების გასაღები დაკარგე, Signal შენი სათადარიგო ასლების აღდგენაში დახმარებას ვერ შეძლებს. + + თუ შენი ძველი მოწყობილობა გაქვს, შეგიძლია, შენი სათადარიგო ასლების გასაღები აქ ნახო: პარამეტრები > ჩატები > Signal-ის სათადარიგო ასლები. შემდეგ სათადარიგო ასლების გასაღების ნახვას დააჭირე. + + გაიგე მეტი + + გამოტოვე და არ აღადგინო + + + დაასკანირე ეს კოდი შენი ძველი მობილურით + + გახსენი Signal-ი შენს ძველ მობილურში + + დააჭირე კამერის ნიშანს + + დაასკანირე ეს კოდი კამერით + + QR კოდის გენერირება ვერ მოხერხდა + + დასკანირდა ძველ მოწყობილობაზე + + თავიდან ცდა + + + მონაცემების გადატანა + + შენი ანგარიში ახალ მოწყობილობაზე გადავა. ამ მოწყობილობიდან წვდომა გექნება შენს ჯგუფებზე, კონტაქტებზე, ჩატებზე და შენი სახელით შეტყობინებების გაგზავნას შეძლებ. %1$s + + გაიგე მეტი + + მონაცემების გადატანა + + შეტყობინებები და ჩატის ინფორმაცია ყველა მოწყობილობაზე ბოლომდე დაშიფრულია + + ბლოკი მოხსენი, რომ ანგარიში გადაიტანო + + გააგრძელე შენს სხვა მოწყობილობაზე + + განაგრძე შენი ანგარიშის გადატანა სხვა მოწყობილობაზე. + + + აღდგენა დასრულებულია + + შენი Signal-ის ანგარიში და შეტყობინებები შენს სხვა მოწყობილობაზეა გადატანილი. ამ მოწყობილობაზე Signal აღარ არის აქტიური. + + გადატანა დასრულებულია + + შენი Signal-ის ანგარიში და შეტყობინებები შენს სხვა მოწყობილობაზეა გადატანილი. ამ მოწყობილობაზე Signal აღარ არის აქტიური. + + კარგი + + \ No newline at end of file diff --git a/app/src/main/res/values-kk/strings.xml b/app/src/main/res/values-kk/strings.xml index f928e65165..f70e54190c 100644 --- a/app/src/main/res/values-kk/strings.xml +++ b/app/src/main/res/values-kk/strings.xml @@ -1325,20 +1325,6 @@ толығырақ Топ сипаттамасын қосу… - - - Android құрылғысынан тасымалдау - - Аккаунтыңызды және хаттарыңызды ескі Android құрылғыңыздан аударыңыз. - - Тасымалдамай кіру - - Хаттар мен мультимедианы тасымалдамай жалғастыру - - Жергілікті резервтік көшірмені қалпына келтіру - - Құрылғыңызда сақталған резервтік файлдан хаттарды қалпына келтіріңіз. - Резервтік көшірме жүктеп алынуда… @@ -1356,12 +1342,16 @@ Барлық хат Резервтік көшірмеден қалпына келтіру - + Соңғы %1$d күнде жіберілген немесе қабылданған мультимедиа ғана кіреді. Резервтік көшірме: Сақтық көшірмені қалпына келтіру + + Соңғы резервтік көшірме %1$s күні сағат%2$s жасалды. + + Сақтық көшірме мәліметтері алынып жатыр… Менің атым аталғанда хабарландыру алғым келеді @@ -3463,7 +3453,7 @@ Басқа Төлемдер (MobileCoin) Демеушіліктер және Таңбалар - Signal Android Backup + Signal Android сақтық көшірмесі Signal Android ақауларды түзету журналын жіберу @@ -4304,6 +4294,8 @@ Бұл құпия сөйлемді жазып алдым. Ол болмаса, мен резервтік көшірмені қалпына келтіре алмаймын. Сақтық көшірмені қалпына келтіру Аккаунтты тасымалдау немесе қалпына келтіру + + Қалпына келтіру немесе тасымалдау Аккаунтты тасымалдау Өткізіп жіберу Чаттың резервтік көшірмелері @@ -5124,7 +5116,7 @@ Топтар - Only messages from group chats + Топтық чаттардан келген хабарлар ғана Қосу @@ -6687,7 +6679,7 @@ Төменде көрсетілген \"Параметрлерге өту\" түймесін басыңыз - \"Параметрлер туралы дабылдар мен еске салғыштарға рұқсат ету\" функциясын қосыңыз. + \"Оятқыштар мен еске салғыштар орнатуға рұқсат ету\" функциясын қосыңыз. Параметрлерге өту @@ -7426,11 +7418,11 @@ Signal мультимедиасының резервтік көшірмесі жоспарынан бас тартылды, себебі төлеміңізді қабылдай алмадық. Резервтік көшірмеңіздегі мультимедианы жүктеп алудың соңғы мүмкіндігі, одан кейін ол жойылады. - Free up %1$s on this device + Құрылғыңызда %1$s орын босатыңыз - To finish downloading your Signal Backup your device needs %1$s of storage space. + Signal сақтық көшірмелерін жүктеп алуды аяқтау үшін құрылғыңызда %1$s орын болуы керек. - To free up space offload or delete unused apps or content large in file size. + Орын босату үшін көп орын алатын әрі қолданылмайтын қолданбалар мен контентті жойыңыз. Сақтық көшірме жазылымының мерзімі ұзартылмады @@ -7458,7 +7450,7 @@ Кейін - Try later + Кейінірек көру Мультимедиа жойылады @@ -7470,9 +7462,9 @@ Өткізіп жіберу - Skip restore? + Қалпына келтіруді өткізіп жіберу керек пе? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Қалпына келтіру процесін өткізіп жіберсеңіз, келесі жолы құрылғыңыз жаңа сақтық көшірме жасағанда, сақтық көшірмеңізде қалған мультимедиа және тіркемелер жойылып кетеді. @@ -7529,10 +7521,6 @@ Жазылымды өзгерту немесе одан бас тарту - - - Соңғы резервтік көшірме %1$s күні сағат%2$s жасалды. - Чат лимиттері @@ -7850,5 +7838,101 @@ Еске салғыш белгішесі + + + Ескі телефоным бар + + Қазіргі Signal аккаунтыңыздан QR кодын сканерлеп, жылдам бастаңыз + + + Ескі телефоным жоқ + + Signal қолданбасын бір құрылғыға қайта орнатып жатырсыз + + + Аккаунтты қалпына келтіру немесе тасымалдау + + Signal аккаунтыңызды және хабарлар тарихын осы құрылғыға сақтаңыз. + + Signal-дың сақтық көшірмелерінен + + Тегін немесе ақылы Signal cақтық көшірме жоспары + + Сақтық көшірме қалтасынан + + Сақтық көшірме файлынан + + Сіз сақтаған сақтық көшірмені таңдаңыз + + Ескі телефоннан + + Ескі Android құрылғыңыздан тікелей тасымалдау + + + Жергілікті резервтік көшірмені қалпына келтіру + + Құрылғыңызда сақталған сақтық көшірмеден хабарларды қалпына келтіріңіз. Оны қазір қалпына келтірмесеңіз, кейінірек қалпына келтіре алмай қаласыз. + + + Сақтық кілтті енгізіңіз + + Сақтық кілт – аккаунтыңыз бен деректеріңізді қалпына келтіруге қажетті 64 таңбалы код. + + Сақтық кілт жоқ па? + + Сақтық кілт + + Сақтық көшірмелеріңіз 64 таңбалы кодсыз қалпына келтірілмейді. Сақтық кілтіңізді жоғалтып алсаңыз, Signal сақтық көшірмеңізді қалпына келтіруге көмектесе алмайды. + + Ескі құрылғыңыз болса, сақтық кілтіңізді көру үшін \"Параметрлер > Чаттар > Signal-дың сақтық көшірмелері\" тармағына өтіңіз. Содан кейін \"Сақтық кілтті көру\" түймесін түртіңіз. + + Толық ақпарат + + Өткізіп жіберу және қалпына келтірмеу + + + Осы кодты ескі телефоныңызбен сканерлеңіз + + Signal қолданбасын ескі құрылғыңызда ашыңыз + + Камера белгішесін түртіңіз + + Осы кодты камерамен сканерлеңіз + + QR коды жасалмады + + Ескі құрылғыда сканерленді + + Қайтадан байқап көру + + + Аккаунтты тасымалдау + + Аккаунтыңыз жаңа құрылғыға тасымалданады. Бұл құрылғы сіздің топтарыңыз бен контактілеріңізді көре алады, чаттарыңызды пайдалана алады және сіздің атыңыздан хабарлар жібере алады. %1$s + + Толық ақпарат + + Аккаунтты тасымалдау + + Хабарлар мен чат туралы ақпарат барлық құрылғыда тура шифрлау әдісімен қорғалады + + Аккаунтты тасымалдау үшін құрылғының құлпын ашыңыз + + Басқа құрылғыда жалғастыру + + Аккаунтыңызды тасымалдауды басқа құрылғыңызда жалғастырыңыз. + + + Қалпына келтірілді + + Signal аккаунтыңыз бен хабарларыңыз басқа құрылғыңызға тасымалдана бастады. Signal бұл құрылғыда қазір жұмыс істеп тұрған жоқ. + + Тасымалдау аяқталды + + Signal аккаунтыңыз бен хабарларыңыз басқа құрылғыңызға тасымалданды. Signal бұл құрылғыда қазір жұмыс істеп тұрған жоқ. + + Жарайды + + \ No newline at end of file diff --git a/app/src/main/res/values-km/strings.xml b/app/src/main/res/values-km/strings.xml index 063bb481df..99454e4577 100644 --- a/app/src/main/res/values-km/strings.xml +++ b/app/src/main/res/values-km/strings.xml @@ -1287,20 +1287,6 @@ បន្ថែម បន្ថែមការពណ៌នាក្រុម… - - - ផ្ទេរពីឧបករណ៍ Android - - ផ្ទេរគណនី និងសាររបស់អ្នកពីឧបករណ៍ Android ចាស់របស់អ្នក។ - - ចូលដោយមិនចាំបាច់ផ្ទេរ - - បន្តដោយមិនបាច់ផ្ទេរសារ និងមេឌៀរបស់អ្នក - - ស្តារការបម្រុងទុកក្នុងឧបករណ៍ - - ស្ដារសាររបស់អ្នកពីឯកសារបម្រុងទុកដែលអ្នកបានរក្សាទុកនៅលើឧបករណ៍របស់អ្នក។ - កំពុងទាញយកការបម្រុងទុក… @@ -1318,12 +1304,16 @@ សារទាំងអស់របស់អ្នក ស្តារចេញពីការបម្រុងទុក - + មានតែមេឌៀដែលបានផ្ញើ ឬទទួលបានក្នុងរយៈពេល %1$d ថ្ងៃចុងក្រោយប៉ុណ្ណោះដែលត្រូវបានរួមបញ្ចូល។ ការបម្រុងទុករបស់អ្នករួមមាន៖ ស្តារការបម្រុងទុក + + ការបម្រុងទុកចុងក្រោយរបស់អ្នកត្រូវបានធ្វើឡើងនៅ %1$s ម៉ោង %2$s។ + + កំពុងទាញយកព័ត៌មានលម្អិតនៃការបម្រុងទុក… ជូនដំណឹងខ្ញុំសម្រាប់ការហៅឈ្មោះ @@ -3364,7 +3354,7 @@ ផ្សេងៗ ការទូទាត់ (MobileCoin) ការបរិច្ចាគ និងស្លាក - Signal Android Backup + ការបម្រុងទុក Signal Android ការដាក់បញ្ជូនកំណត់ហេតុបញ្ហា Signal Android @@ -4193,6 +4183,8 @@ ខ្ញុំបានកត់ឃ្លាសម្ងាត់ទុកហើយ។ បើគ្មានវា ខ្ញុំនឹងមិនអាចស្តារការបម្រុងទុកបានទេ។ ស្តារការបម្រុងទុក បញ្ជូន ឬ ស្តារ គណនី + + ស្តារ ឬផ្ទេរ បញ្ជូនគណនី រំលង ការបម្រុងទុកការជជែក @@ -5004,7 +4996,7 @@ ក្រុម - Only messages from group chats + សម្រាប់តែសារពីការជជែកជាក្រុមប៉ុណ្ណោះ បញ្ចូល @@ -7265,11 +7257,11 @@ គម្រោងបម្រុងទុកមេឌៀ Signal របស់អ្នកត្រូវបានលុបចោល ដោយសារយើងមិនអាចដំណើរការការបង់ប្រាក់របស់អ្នកបាន។ នេះជាឱកាសចុងក្រោយរបស់អ្នកក្នុងការទាញយកមេឌៀនៅក្នុងការបម្រុងទុករបស់អ្នក មុនពេលវាត្រូវបានលុបចោល។ - Free up %1$s on this device + បង្កើនទំហំទំនេរ %1$s នៅលើឧបករណ៍នេះ - To finish downloading your Signal Backup your device needs %1$s of storage space. + ដើម្បីបញ្ចប់ការទាញយកការបម្រុងទុក Signal ឧបករណ៍របស់អ្នកត្រូវការទំហំផ្ទុក %1$s។ - To free up space offload or delete unused apps or content large in file size. + ដើម្បីបង្កើនទំហំទំនេរ ឬលុបកម្មវិធីដែលមិនប្រើ ឬខ្លឹមសារដែលមានទំហំឯកសារធំៗ។ ការជាវការបម្រុងទុករបស់អ្នកមិនអាចបន្តបានទេ @@ -7297,7 +7289,7 @@ ឥឡូវកុំទាន់ - Try later + ព្យាយាមនៅពេលក្រោយ មេឌៀនឹងត្រូវបានលុបចោល @@ -7309,9 +7301,9 @@ រំលង - Skip restore? + រំលងការស្តារឡើងវិញ? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + ប្រសិនបើអ្នករំលងការស្ដារឡើងវិញ នោះមេឌៀ និងឯកសារភ្ជាប់ដែលនៅសល់នៅក្នុងការបម្រុងទុករបស់អ្នកនឹងត្រូវបានលុប នៅពេលដែលឧបករណ៍របស់អ្នកបញ្ចប់ការបម្រុងទុកថ្មីនៅលើកក្រោយ។ @@ -7323,7 +7315,7 @@ ការស្តារបានបញ្ចប់ - លម្អិត + ព័ត៌មានលម្អិត កំពុងរង់ចាំ Wi-Fi… @@ -7368,10 +7360,6 @@ ផ្លាស់ប្តូរ ឬបោះបង់ការជាវ - - - ការបម្រុងទុកចុងក្រោយរបស់អ្នកត្រូវបានធ្វើឡើងនៅ %1$s ម៉ោង %2$s។ - ដែនកំណត់នៃចំនួនសារជជែក @@ -7683,5 +7671,101 @@ រូបក្រើនរំលឹក + + + ខ្ញុំមានទូរសព្ទចាស់របស់ខ្ញុំ + + ស្គែនកូដ QR ពីគណនី Signal បច្ចុប្បន្នរបស់អ្នកដើម្បីចាប់ផ្តើមបានលឿន + + + ខ្ញុំមិនមានទូរសព្ទចាស់ទេ + + ឬអ្នកកំពុងដំឡើង Signal ឡើងវិញនៅលើឧបករណ៍តែមួយ + + + ស្តារ ឬផ្ទេរគណនី + + ទាញគណនី Signal និងប្រវត្តិសាររបស់អ្នកដាក់នៅលើឧបករណ៍នេះ។ + + ពីការបម្រុងទុក Signal + + គម្រោងបម្រុងទុក Signal ឥតគិតថ្លៃ ឬបង់ប្រាក់របស់អ្នក + + ពីថតឯកសារបម្រុងទុក + + ពីឯកសារបម្រុងទុក + + ជ្រើសរើសការបម្រុងទុកដែលអ្នកបានរក្សាទុក + + ពីទូរសព្ទចាស់របស់អ្នក + + ផ្ទេរដោយផ្ទាល់ពីឧបករណ៍ Android ចាស់របស់អ្នក + + + ស្តារការបម្រុងទុកក្នុងឧបករណ៍ + + ស្ដារសាររបស់អ្នកពីការបម្រុងទុកដែលអ្នកបានរក្សាទុកនៅលើឧបករណ៍របស់អ្នក។ ប្រសិនបើអ្នកមិនស្តារឥឡូវនេះទេ អ្នកនឹងមិនអាចស្តារនៅពេលក្រោយបានទេ។ + + + បញ្ចូលសោបម្រុងទុករបស់អ្នក + + សោបម្រុងទុករបស់អ្នកគឺជាលេខកូដ 64 ខ្ទង់ដែលត្រូវការចាំបាច់ដើម្បីស្តារយកគណនី និងទិន្នន័យរបស់អ្នកមកវិញ។ + + គ្មានសោបម្រុងទុកទេ? + + សោបម្រុងទុក + + ការបម្រុងទុកមិនអាចស្តារបានទេបើវាគ្មានលេខកូដស្តារ 64 ខ្ទង់នោះទេ។ ប្រសិនបើអ្នកបាត់សោបម្រុងទុករបស់អ្នក Signal មិនអាចជួយស្ដារការបម្រុងទុករបស់អ្នកបានទេ។ + + ប្រសិនបើអ្នកមានឧបករណ៍ចាស់ អ្នកអាចមើលសោបម្រុងទុករបស់អ្នកនៅក្នុងការកំណត់ > ការជជែក > ការបម្រុងទុក Signal។ បន្ទាប់មកចុច មើលសោបម្រុងទុក។ + + ស្វែងយល់បន្ថែម + + រំលង ហើយកុំស្តារ + + + ស្គែនកូដនេះដោយប្រើទូរសព្ទចាស់របស់អ្នក + + បើក Signal នៅលើឧបករណ៍ចាស់របស់អ្នក + + ចុចលើរូបកាមេរ៉ា + + ស្គែនកូដនេះដោយប្រើកាមេរ៉ា + + មិនអាចបង្កើតកូដ QR បានទេ + + បានស្គែននៅលើឧបករណ៍ចាស់ + + ព្យាយាមម្តងទៀត + + + ផ្ទេរគណនី + + គណនីរបស់អ្នកនឹងត្រូវបានផ្ទេរទៅឧបករណ៍ថ្មីមួយ។ ឧបករណ៍នេះនឹងអាចមើលឃើញក្រុម និងទំនាក់ទំនងរបស់អ្នក ចូលទៅការជជែករបស់អ្នក និងផ្ញើសារជាឈ្មោះរបស់អ្នកបាន។ %1$s + + ស្វែងយល់បន្ថែម + + ផ្ទេរគណនី + + សារ និងព័ត៌មានជជែកត្រូវបានការពារដោយការអ៊ីនគ្រីបទាំងសងខាងនៅលើឧបករណ៍ទាំងអស់ + + ដោះសោដើម្បីផ្ទេរគណនី + + បន្តនៅលើឧបករណ៍ផ្សេងទៀតរបស់អ្នក + + បន្តផ្ទេរគណនីរបស់អ្នកនៅលើឧបករណ៍ផ្សេងទៀតរបស់អ្នក។ + + + ការស្តារបានបញ្ចប់ + + គណនី និងសារ Signal របស់អ្នកបានចាប់ផ្តើមផ្ទេរទៅឧបករណ៍ផ្សេងទៀតរបស់អ្នកហើយ។ ឥឡូវនេះ Signal លែងសកម្មនៅលើឧបករណ៍នេះហើយ។ + + ការផ្ទេរបានបញ្ចប់ + + គណនី និងសារ Signal របស់អ្នកត្រូវបានផ្ទេរទៅឧបករណ៍ផ្សេងទៀតរបស់អ្នកហើយ។ ឥឡូវនេះ Signal លែងសកម្មនៅលើឧបករណ៍នេះហើយ។ + + យល់ព្រម + + \ No newline at end of file diff --git a/app/src/main/res/values-kn/strings.xml b/app/src/main/res/values-kn/strings.xml index 92cf4a1168..14305fc855 100644 --- a/app/src/main/res/values-kn/strings.xml +++ b/app/src/main/res/values-kn/strings.xml @@ -1325,20 +1325,6 @@ ಇನ್ನಷ್ಟು ಗ್ರೂಪ್ ವಿವರವನ್ನು ಸೇರಿಸಿ… - - - ಆಂಡ್ರಾಯ್ಡ್ ಸಾಧನದಿಂದ ವರ್ಗಾವಣೆ ಮಾಡಿ - - ನಿಮ್ಮ ಹಳೆಯ Android ಸಾಧನದಿಂದ ನಿಮ್ಮ ಖಾತೆ ಮತ್ತು ಮೆಸೇಜ್‌ಗಳನ್ನು ವರ್ಗಾಯಿಸಿ. - - ವರ್ಗಾವಣೆ ಮಾಡದೆಯೇ ಲಾಗ್ ಇನ್ ಮಾಡಿ - - ನಿಮ್ಮ ಮೆಸೇಜ್‌ಗಳು ಮತ್ತು ಮೀಡಿಯಾವನ್ನು ವರ್ಗಾಯಿಸದೆಯೇ ಮುಂದುವರಿಸಿ - - ಸ್ಥಳೀಯ ಬ್ಯಾಕಪ್ ಅನ್ನು ರಿಸ್ಟೋರ್ ಮಾಡಿ - - ನಿಮ್ಮ ಸಾಧನದಲ್ಲಿ ನೀವು ಸೇವ್ ಮಾಡಿದ ಬ್ಯಾಕಪ್ ಫೈಲ್‌ನಿಂದ ನಿಮ್ಮ ಮೆಸೇಜ್‌ಗಳನ್ನು ರಿಸ್ಟೋರ್ ಮಾಡಿ. - ಬ್ಯಾಕಪ್ ಅನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡಲಾಗುತ್ತಿದೆ… @@ -1356,12 +1342,16 @@ ನಿಮ್ಮ ಎಲ್ಲಾ ಮೆಸೇಜ್‌ಗಳು ಬ್ಯಾಕಪ್‌ನಿಂದ ರಿಸ್ಟೋರ್ ಮಾಡಿ - + ಕಳೆದ %1$d ದಿನಗಳಲ್ಲಿ ಕಳುಹಿಸಿದ ಅಥವಾ ಸ್ವೀಕರಿಸಿದ ಮೀಡಿಯಾವನ್ನು ಮಾತ್ರ ಸೇರಿಸಲಾಗಿದೆ. ನಿಮ್ಮ ಬ್ಯಾಕಪ್ ಇವುಗಳನ್ನು ಒಳಗೊಂಡಿರುತ್ತದೆ: ಬ್ಯಾಕಪ್ ಮರುಸ್ಥಾಪಿಸಿ + + ನಿಮ್ಮ ಕೊನೆಯ ಬ್ಯಾಕಪ್ ಅನ್ನು %1$s ರಂದು %2$s ಕ್ಕೆ ಮಾಡಲಾಗಿದೆ. + + ಬ್ಯಾಕಪ್ ವಿವರಗಳನ್ನು ಪಡೆಯಲಾಗುತ್ತಿದೆ… \@ಉಲ್ಲೇಖಗಳು ಬಂದರೆ ಎಚ್ಚರಿಸು @@ -3463,7 +3453,7 @@ ಇತರೆ ಪಾವತಿಗಳು (MobileCoin) ದೇಣಿಗೆಗಳು & ಬ್ಯಾಡ್ಜ್‌ಗಳು - Signal Android Backup + Signal Android ಬ್ಯಾಕಪ್ Signal Android ಡೀಬಗ್ ಲಾಗ್ ಸಲ್ಲಿಕೆ @@ -4304,6 +4294,8 @@ ನಾನು ಈ ಪಾಸ್‌ಫ್ರೇಸ್ ಬರೆದಿದ್ದೇನೆ. ಅದು ಇಲ್ಲದೆ, ನನಗೆ ಬ್ಯಾಕಪ್ ಮರುಸ್ಥಾಪಿಸಲು ಸಾಧ್ಯವಾಗುವುದಿಲ್ಲ. ಬ್ಯಾಕಪ್ ಮರುಸ್ಥಾಪಿಸಿ ಖಾತೆಗಳನ್ನು ವರ್ಗಾವಣೆ ಮಾಡಿ ಅಥವಾ ಪುನಶ್ಚೇತನ ಮಾಡಿ + + ರಿಸ್ಟೋರ್ ಮಾಡಿ ಅಥವಾ ವರ್ಗಾಯಿಸಿ ಖಾತೆ ವರ್ಗಾಯಿಸಿ ಬಿಟ್ಟು ಮುಂದುವರಿ ಚಾಟ್ ಬ್ಯಾಕಪ್‌ಗಳು @@ -5124,7 +5116,7 @@ ಗುಂಪುಗಳು - Only messages from group chats + ಗುಂಪು ಚಾಟ್‌ಗಳ ಮೆಸೇಜ್‌ಗಳು ಮಾತ್ರ ಸೇರಿಸಿ @@ -6687,7 +6679,7 @@ ಕೆಳಗಿನ \"ಸೆಟ್ಟಿಂಗ್ ಗಳಿಗೆ ಹೋಗಿ\" ಬಟನ್ ಟ್ಯಾಪ್ ಮಾಡಿ - \"ಸೆಟ್ಟಿಂಗ್ ಗಳ ಅಲಾರಂಗಳು ಮತ್ತು ಜ್ಞಾಪನೆಗಳನ್ನು ಅನುಮತಿಸಿ\" ಆನ್ ಮಾಡಿ. + \"ಸೆಟ್ಟಿಂಗ್ ಅಲಾರಂಗಳು ಮತ್ತು ರಿಮೈಂಡರ್‌ಗಳನ್ನು ಅನುಮತಿಸಿ\" ಎಂಬುದನ್ನು ಆನ್ ಮಾಡಿ. ಸೆಟ್ಟಿಂಗ್‌ಗಳಿಗೆ ಹೋಗಿ @@ -7426,11 +7418,11 @@ ನಿಮ್ಮ ಪಾವತಿಯನ್ನು ಪ್ರಕ್ರಿಯೆಗೊಳಿಸಲು ನಮಗೆ ಸಾಧ್ಯವಾಗದ ಕಾರಣ ನಿಮ್ಮ Signal ಮೀಡಿಯಾ ಬ್ಯಾಕಪ್ ಪ್ಲಾನ್ ಅನ್ನು ರದ್ದುಗೊಳಿಸಲಾಗಿದೆ. ಅಳಿಸುವ ಮೊದಲು ನಿಮ್ಮ ಬ್ಯಾಕಪ್‌ನಲ್ಲಿ ಮೀಡಿಯಾವನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡಲು ಇದು ನಿಮಗೆ ಕೊನೆಯ ಅವಕಾಶವಾಗಿದೆ. - Free up %1$s on this device + ಈ ಸಾಧನದಲ್ಲಿ %1$s ಮುಕ್ತಗೊಳಿಸಿ - To finish downloading your Signal Backup your device needs %1$s of storage space. + ನಿಮ್ಮ Signal ಬ್ಯಾಕಪ್ ಡೌನ್‌ಲೋಡ್ ಮಾಡುವುದನ್ನು ಪೂರ್ಣಗೊಳಿಸಲು ನಿಮ್ಮ ಸಾಧನಕ್ಕೆ %1$s ಸ್ಟೋರೇಜ್ ಸ್ಥಳಾವಕಾಶದ ಅಗತ್ಯವಿದೆ. - To free up space offload or delete unused apps or content large in file size. + ಸ್ಥಳಾವಕಾಶವನ್ನು ಮುಕ್ತಗೊಳಿಸಲು ಬಳಕೆಯಾಗದ ಆ್ಯಪ್‌ಗಳು ಅಥವಾ ದೊಡ್ಡ ಫೈಲ್ ಗಾತ್ರದ ಕಂಟೆಂಟ್ ನ್ನು ಆಫ್‌ಲೋಡ್ ಮಾಡಿ ಅಥವಾ ಅಳಿಸಿ. ನಿಮ್ಮ ಬ್ಯಾಕಪ್ ಚಂದಾದಾರಿಕೆ ನವೀಕರಣವಾಗಲು ವಿಫಲವಾಗಿದೆ @@ -7458,7 +7450,7 @@ ಈಗಲ್ಲ - Try later + ನಂತರ ಪ್ರಯತ್ನಿಸಿ ಮೀಡಿಯಾವನ್ನು ಅಳಿಸಲಾಗುತ್ತದೆ @@ -7470,9 +7462,9 @@ ಬಿಟ್ಟು ಮುಂದುವರಿ - Skip restore? + ರಿಸ್ಟೋರ್ ಸ್ಕಿಪ್ ಮಾಡಬೇಕೇ? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + ನೀವು ರಿಸ್ಟೋರ್ ಸ್ಕಿಪ್ ಮಾಡಿದರೆ ನಿಮ್ಮ ಬ್ಯಾಕಪ್‌ನಲ್ಲಿ ಉಳಿದಿರುವ ಮೀಡಿಯಾ ಮತ್ತು ಅಟ್ಯಾಚ್‌ಮೆಂಟ್‌ಗಳನ್ನು ಮುಂದಿನ ಬಾರಿ ನಿಮ್ಮ ಸಾಧನವು ಹೊಸ ಬ್ಯಾಕಪ್ ಅನ್ನು ಪೂರ್ಣಗೊಳಿಸಿದಾಗ ಅಳಿಸಲಾಗುತ್ತದೆ. @@ -7529,10 +7521,6 @@ ಚಂದಾದಾರಿಕೆಯನ್ನು ಬದಲಾಯಿಸಿ ಅಥವಾ ರದ್ದುಮಾಡಿ - - - ನಿಮ್ಮ ಕೊನೆಯ ಬ್ಯಾಕಪ್ ಅನ್ನು %1$s ರಂದು %2$s ಕ್ಕೆ ಮಾಡಲಾಗಿದೆ. - ಚಾಟ್ ಮಿತಿಗಳು @@ -7850,5 +7838,101 @@ ರಿಮೈಂಡರ್ ಐಕಾನ್ + + + ನನ್ನ ಬಳಿ ನನ್ನ ಹಳೆಯ ಫೋನ್ ಇದೆ + + ತ್ವರಿತವಾಗಿ ಪ್ರಾರಂಭಿಸಲು ನಿಮ್ಮ ಪ್ರಸ್ತುತ Signal ಖಾತೆಯಿಂದ QR ಕೋಡ್ ಅನ್ನು ಸ್ಕ್ಯಾನ್ ಮಾಡಿ + + + ನನ್ನ ಬಳಿ ನನ್ನ ಹಳೆಯ ಫೋನ್ ಇಲ್ಲ + + ಅಥವಾ ನೀವು ಅದೇ ಸಾಧನದಲ್ಲಿ Signal ಅನ್ನು ಮರುಸ್ಥಾಪಿಸುತ್ತಿರುವಿರಿ + + + ಖಾತೆಯನ್ನು ರಿಸ್ಟೋರ್ ಮಾಡಿ ಅಥವಾ ವರ್ಗಾಯಿಸಿ + + ಈ ಸಾಧನದಲ್ಲಿ ನಿಮ್ಮ Signal ಖಾತೆ ಮತ್ತು ಸಂದೇಶ ಇತಿಹಾಸವನ್ನು ಪಡೆಯಿರಿ. + + Signal ಬ್ಯಾಕಪ್‌ಗಳಿಂದ + + ನಿಮ್ಮ ಉಚಿತ ಅಥವಾ ಪಾವತಿಸಿದ Signal ಬ್ಯಾಕಪ್ ಪ್ಲ್ಯಾನ್ + + ಬ್ಯಾಕಪ್ ಫೋಲ್ಡರ್‌ನಿಂದ + + ಬ್ಯಾಕಪ್ ಫೈಲ್‌ನಿಂದ + + ನೀವು ಸೇವ್ ಮಾಡಿರುವ ಬ್ಯಾಕಪ್ ಅನ್ನು ಆಯ್ಕೆಮಾಡಿ + + ನಿಮ್ಮ ಹಳೆಯ ಫೋನ್‌ನಿಂದ + + ನಿಮ್ಮ ಹಳೆಯ Android ನಿಂದ ನೇರವಾಗಿ ವರ್ಗಾಯಿಸಿ + + + ಸ್ಥಳೀಯ ಬ್ಯಾಕಪ್ ಅನ್ನು ರಿಸ್ಟೋರ್ ಮಾಡಿ + + ನಿಮ್ಮ ಸಾಧನದಲ್ಲಿ ನೀವು ಸೇವ್ ಮಾಡಿರುವ ಬ್ಯಾಕಪ್‌ನಿಂದ ನಿಮ್ಮ ಮೆಸೇಜ್‌ಗಳನ್ನು ರಿಸ್ಟೋರ್ ಮಾಡಿ. ನೀವು ಈಗ ರಿಸ್ಟೋರ್ ಮಾಡದಿದ್ದರೆ, ನಂತರದಲ್ಲಿ ರಿಸ್ಟೋರ್ ಮಾಡಲು ನಿಮಗೆ ಸಾಧ್ಯವಾಗುವುದಿಲ್ಲ. + + + ನಿಮ್ಮ ಬ್ಯಾಕಪ್ ಕೀ ನಮೂದಿಸಿ + + ನಿಮ್ಮ ಬ್ಯಾಕಪ್ ಕೀ ನಿಮ್ಮ ಖಾತೆ ಮತ್ತು ಡೇಟಾವನ್ನು ರಿಕವರ್ ಮಾಡಲು ಅಗತ್ಯವಿರುವ 64-ಅಂಕಿಯ ಕೋಡ್ ಆಗಿದೆ. + + ಬ್ಯಾಕಪ್ ಕೀ ಇಲ್ಲವೇ? + + ಬ್ಯಾಕಪ್ ಕೀ + + 64-ಅಂಕಿಯ ರಿಕವರಿ ಕೋಡ್ ಇಲ್ಲದೆ ಬ್ಯಾಕಪ್‌ಗಳನ್ನು ರಿಕವರ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗುವುದಿಲ್ಲ. ನಿಮ್ಮ ಬ್ಯಾಕಪ್ ಕೀಯನ್ನು ನೀವು ಕಳೆದುಕೊಂಡಿದ್ದರೆ ನಿಮ್ಮ ಬ್ಯಾಕಪ್ ಅನ್ನು ರಿಸ್ಟೋರ್ ಮಾಡಲು Signal ಸಹಾಯ ಮಾಡಲಾಗುವುದಿಲ್ಲ. + + ನಿಮ್ಮ ಬಳಿ ನಿಮ್ಮ ಹಳೆಯ ಸಾಧನವಿದ್ದರೆ ಸೆಟ್ಟಿಂಗ್‌ಗಳು > ಚಾಟ್‌ಗಳು > Signal ಬ್ಯಾಕಪ್‌ಗಳು ಎಂಬುದರಲ್ಲಿ ನಿಮ್ಮ ಬ್ಯಾಕಪ್ ಕೀಯನ್ನು ವೀಕ್ಷಿಸಿ. ನಂತರ ಬ್ಯಾಕಪ್ ಕೀ ವೀಕ್ಷಿಸಿ ಎಂಬುದನ್ನು ಟ್ಯಾಪ್ ಮಾಡಿ. + + ಇನ್ನಷ್ಟು ತಿಳಿಯಿರಿ + + ಸ್ಕಿಪ್ ಮಾಡಿ ಹಾಗೂ ರಿಸ್ಟೋರ್ ಮಾಡಬೇಡಿ + + + ನಿಮ್ಮ ಹಳೆಯ ಫೋನ್ ಮೂಲಕ ಈ ಕೋಡ್ ಅನ್ನು ಸ್ಕ್ಯಾನ್ ಮಾಡಿ + + ನಿಮ್ಮ ಹಳೆಯ ಸಾಧನದಲ್ಲಿ Signal ಅನ್ನು ತೆರೆಯಿರಿ + + ಕ್ಯಾಮರಾ ಐಕಾನ್ ಅನ್ನು ಟ್ಯಾಪ್ ಮಾಡಿ + + ಕ್ಯಾಮರಾ ಮೂಲಕ ಈ ಕೋಡ್ ಅನ್ನು ಸ್ಕ್ಯಾನ್ ಮಾಡಿ + + QR ಕೋಡ್ ಅನ್ನು ಜನರೇಟ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗುತ್ತಿಲ್ಲ + + ಹಳೆಯ ಸಾಧನದಲ್ಲಿ ಸ್ಕ್ಯಾನ್ ಮಾಡಲಾಗಿದೆ + + ಮರುಪ್ರಯತ್ನಿಸಿ + + + ಖಾತೆ ವರ್ಗಾಯಿಸಿ + + ನಿಮ್ಮ ಖಾತೆಯನ್ನು ನಿಮ್ಮ ಹೊಸ ಸಾಧನಕ್ಕೆ ವರ್ಗಾಯಿಸಲಾಗುತ್ತದೆ. ಈ ಸಾಧನವು ನಿಮ್ಮ ಗುಂಪುಗಳು ಮತ್ತು ಸಂಪರ್ಕಗಳನ್ನು ನೋಡಲು, ನಿಮ್ಮ ಚಾಟ್‌ಗಳನ್ನು ಪ್ರವೇಶಿಸಲು ಮತ್ತು ನಿಮ್ಮ ಹೆಸರಿನಲ್ಲಿ ಮೆಸೇಜ್‌ಗಳನ್ನು ಕಳುಹಿಸಲು ಸಾಧ್ಯವಾಗುತ್ತದೆ. %1$s + + ಇನ್ನಷ್ಟು ತಿಳಿಯಿರಿ + + ಖಾತೆ ವರ್ಗಾಯಿಸಿ + + ಮೆಸೇಜ್‌ಗಳು ಮತ್ತು ಚಾಟ್ ಮಾಹಿತಿಯನ್ನು ಎಲ್ಲಾ ಸಾಧನಗಳಲ್ಲಿ ಎಂಡ್-ಟು-ಎಂಡ್ ಎನ್‌ಕ್ರಿಪ್ಶನ್ ಮೂಲಕ ರಕ್ಷಿಸಲಾಗಿದೆ + + ಖಾತೆಯನ್ನು ವರ್ಗಾಯಿಸಲು ಅನ್‌ಲಾಕ್ ಮಾಡಿ + + ನಿಮ್ಮ ಇನ್ನೊಂದು ಸಾಧನದಲ್ಲಿ ಮುಂದುವರಿಯಿರಿ + + ನಿಮ್ಮ ಇನ್ನೊಂದು ಸಾಧನದಲ್ಲಿ ನಿಮ್ಮ ಖಾತೆಯನ್ನು ವರ್ಗಾಯಿಸುವುದನ್ನು ಮುಂದುವರಿಸಿ. + + + ಮರುಸ್ಥಾಪನೆ ಪೂರ್ಣಗೊಂಡಿದೆ + + ನಿಮ್ಮ Signal ಖಾತೆ ಮತ್ತು ಮೆಸೇಜ್‌ಗಳನ್ನು ನಿಮ್ಮ ಇನ್ನೊಂದು ಸಾಧನಕ್ಕೆ ವರ್ಗಾಯಿಸುವುದನ್ನು ಪ್ರಾರಂಭಿಸಲಾಗಿದೆ. ಈ ಸಾಧನದಲ್ಲಿ Signal ಈಗ ನಿಷ್ಕ್ರಿಯವಾಗಿದೆ. + + ವರ್ಗಾವಣೆ ಪೂರ್ಣಗೊಂಡಿದೆ + + ನಿಮ್ಮ Signal ಖಾತೆ ಮತ್ತು ಮೆಸೇಜ್‌ಗಳನ್ನು ನಿಮ್ಮ ಇನ್ನೊಂದು ಸಾಧನಕ್ಕೆ ವರ್ಗಾಯಿಸಲಾಗಿದೆ. ಈ ಸಾಧನದಲ್ಲಿ Signal ಈಗ ನಿಷ್ಕ್ರಿಯವಾಗಿದೆ. + + ಸರಿ + + \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index bf1b9d7551..18aab2a3c5 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -1287,20 +1287,6 @@ 그룹 설명 추가하기… - - - Android 기기에서 전송 - - 이전 Android 기기에서 내 계정과 메시지를 전송합니다. - - 전송하지 않고 로그인 - - 메시지와 미디어를 전송하지 않고 계속 - - 로컬 백업 복원 - - 내 기기에 저장한 백업 파일에서 내 메시지를 복원합니다. - 백업을 다운로드하는 중… @@ -1318,12 +1304,16 @@ 모든 메시지 백업에서 복원 - + 지난 %1$d일간 보내거나 받은 미디어만 포함합니다. 백업 내용: 백업 복원 + + 마지막 백업이 완료된 시간은 %1$s %2$s입니다. + + 백업 세부 정보를 가져오는 중… 멘션 알림 @@ -3357,14 +3347,14 @@ 저희가 문제를 해결할 수 있도록 최대한으로 설명해 주세요. 옵션을 선택하세요. - 오류 + 오류 발생 기능 요청 질문 피드백 기타 결제(MobileCoin) 기부 및 배지 - Signal Android Backup + Signal Android 백업 Signal Android 디버그 로그 제출 @@ -4193,6 +4183,8 @@ 암호를 적었습니다. 비밀번호를 잊었을 때 백업을 복구할 수 없다는 점에 동의합니다. 백업 복원 계정 이전 또는 복원 + + 복원 또는 전송 계정 이전 건너뛰기 대화 백업 @@ -5004,7 +4996,7 @@ 그룹 - Only messages from group chats + 그룹 대화의 메시지만 추가 @@ -7054,7 +7046,7 @@ 링크를 삭제할까요? - 이 링크는 더 이상 누구도 사용할 수 없습니다. + 이 링크는 더 이상 아무도 사용할 수 없습니다. 링크 @@ -7265,11 +7257,11 @@ 결제를 처리할 수 없어 Signal 미디어 백업 플랜을 취소했습니다. 미디어가 삭제되기 전에 다운로드할 수 있는 마지막 기회입니다. - Free up %1$s on this device + 기기에서 %1$s 공간을 확보하세요 - To finish downloading your Signal Backup your device needs %1$s of storage space. + 기기에서 Signal 백업 다운로드를 완료하려면 %1$s 저장 공간이 필요합니다. - To free up space offload or delete unused apps or content large in file size. + 공간을 확보하려면 사용하지 않는 앱이나 파일 크기가 큰 콘텐츠를 오프로드하거나 삭제하세요. 백업 구독 갱신 실패 @@ -7297,7 +7289,7 @@ 나중에 - Try later + 나중에 시도 미디어가 삭제됩니다. @@ -7309,9 +7301,9 @@ 건너뛰기 - Skip restore? + 복원을 건너뛰시겠어요? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + 복원을 건너뛸 경우 다음번에 기기가 새로운 백업을 완료할 시 백업에 남은 미디어와 첨부 파일이 삭제됩니다. @@ -7368,10 +7360,6 @@ 구독 변경 또는 취소 - - - 마지막 백업이 완료된 시간은 %1$s %2$s입니다. - 대화 한도 @@ -7683,5 +7671,101 @@ 미리 알림 아이콘 + + + 이전 휴대폰을 가지고 있음 + + 현재 Signal 계정에서 QR 코드를 스캔하여 빠르게 시작하세요 + + + 이전 휴대폰이 없음 + + 또는 동일한 기기에 Signal을 다시 설치하는 중입니다 + + + 계정 복원 또는 전송 + + Signal 계정과 메시지 기록을 이 기기로 가져옵니다. + + Signal 백업에서 복원 + + 무료 또는 유료 Signal 백업 플랜 + + 백업 폴더에서 복원 + + 백업 파일에서 복원 + + 저장한 백업 선택 + + 이전 휴대폰에서 전송 + + 이전 Android에서 바로 전송 + + + 로컬 백업 복원 + + 기기에 저장한 백업에서 메시지를 복원합니다. 지금 복원하지 않으면 나중에 복원할 수 없습니다. + + + 백업 키 입력 + + 백업 키는 계정과 데이터를 복원하는 데 필요한 64자 코드입니다. + + 백업 키가 없으신가요? + + 백업 키 + + 64자 복원 코드 없이는 백업을 복원할 수 없습니다. 백업 키를 분실한 경우 Signal에서 백업을 복원할 수 없습니다. + + 이전 기기를 가지고 있다면 설정 > 대화 > Signal 백업에서 백업 키를 확인할 수 있습니다. 백업 키 보기를 탭하세요. + + 자세히 알아보기 + + 건너 뛰고 복원하지 않음 + + + 이전 휴대폰으로 이 코드 스캔 + + 이전 기기에서 Signal을 엽니다 + + 카메라 아이콘을 탭합니다 + + 카메라로 이 코드를 스캔합니다 + + QR 코드를 생성하지 못함 + + 이전 기기에서 스캔했습니다 + + 재시도 + + + 계정 전송 + + 계정을 새 기기로 전송합니다. 이 기기는 그룹과 연락처를 보고, 대화에 액세스하고, 사용자의 이름으로 메시지를 보낼 수 있습니다. %1$s + + 자세히 알아보기 + + 계정 이전 + + 메시지와 대화 정보는 모든 장치에서 엔드투엔드 암호화로 보호됩니다 + + 잠금 해제하여 계정 전송 + + 다른 기기에서 계속 + + 다른 기기에서 계정 전송을 계속하세요. + + + 복구가 완료되었습니다. + + Signal 계정과 메시지를 다른 기기로 전송하기 시작했습니다. 이제 이 기기의 Signal이 비활성화됩니다. + + 전송 완료 + + Signal 계정과 메시지를 다른 기기로 전송했습니다. 이제 이 기기의 Signal이 비활성화됩니다. + + 확인 + + \ No newline at end of file diff --git a/app/src/main/res/values-ky/strings.xml b/app/src/main/res/values-ky/strings.xml index 72d983d5ec..e0fdd000ae 100644 --- a/app/src/main/res/values-ky/strings.xml +++ b/app/src/main/res/values-ky/strings.xml @@ -1287,20 +1287,6 @@ дагы Топ тууралуу учкай маалымат кошуу… - - - Android түзмөгүнөн которуу - - Эски Android түзмөгүңүздөгү аккаунтуңузду жана билдирүүлөрүңүздү өткөрүп аласыз. - - Өткөрбөй туруп кирүү - - Билдирүүлөрүңүз менен медиа файлдарыңызды өткөрбөй туруп улантасыз - - Түзмөктөгү камдык көчүрмөлөрдү калыбына келтирүү - - Билдирүүлөрүңүздү түзмөгүңүздө сакталган камдык көчүрмөлөрдөн калыбына келтиресиз. - Камдык көчүрмөлөрүңүз жүктөлүп алынууда… @@ -1318,12 +1304,16 @@ Бардык билдирүүлөрүңүз Камдык көчүрмөдөн калыбына келтирүү - + Соңку %1$d күндө жөнөтүлгөн/алынган медиа файлдар гана камтылды. Камдык көчүрмөлөрүңүздө эмнелер камтылган: Резервдик копияны эски калыбына келтирүү + + Камдык көчүрмө акыркы жолу качан сакталган: %1$s, %2$s. + + Камдык көчүрмөнүн чоо-жайы алынууда… Мени айтып өткөндөр тууралуу билип турайын @@ -3364,7 +3354,7 @@ Башка Төлөмдөр (MobileCoin) Салым кошуу жана төшбелгилер - Signal Android Backup + Signal Android камдык көчүрмөсү Signal Android мүчүлүштүктөрдү аныктоо журналын жөнөтүү @@ -4193,6 +4183,8 @@ Мен бул купуя сөз айкашын жазып алдым. Ансыз мен камдык көчүрмөнү калыбына келтире албайм. Резервдик копияны эски калыбына келтирүү Аккаунтту которуу же калыбына келтирүү + + Калыбына келтирүү же өткөрүү Аккаунтту которуу Өткөрүп жиберүү Маектердин камдык көчүрмөсү @@ -5004,7 +4996,7 @@ Топтор - Only messages from group chats + Топтук маектердеги билдирүүлөр гана Кошуу @@ -7265,11 +7257,11 @@ Акы төлөнбөгөндүктөн, Signal\'дагы медиа файлдардын камдык көчүрмөлөрүнүн планы токтотулду. Аларды жоготуп алгыңыз келбесе, бүгүнтөн калбай жүктөп алыңыз. - Free up %1$s on this device + Бул түзмөктө %1$s бошотуңуз - To finish downloading your Signal Backup your device needs %1$s of storage space. + Signal\'дын камдык көчүрмөсүн жүктөп бүтүрүү үчүн түзмөгүңүзгө %1$s сактагыч мейкиндиги керек. - To free up space offload or delete unused apps or content large in file size. + Орун бошотуу үчүн пайдаланылбаган колдонмолорду же файлдын көлөмү чоң мазмунду жүктөп же жок кылыңыз. Жазылууңуз узартылган жок @@ -7297,7 +7289,7 @@ Азыр эмес - Try later + Кийинчерээк аракет кылуу Медиа файлдар өчөт @@ -7309,9 +7301,9 @@ Өткөрүп жиберүү - Skip restore? + Калыбына келтирүү өткөрүп жиберилсинби? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Калыбына келтирүүнү өткөрүп жиберсеңиз, медиа файлдарыңыздын камдык көчүрмөлөрү өчүп, түзмөгүңүз жаңы көчүрмөлөрдү сактайт. @@ -7368,10 +7360,6 @@ Жазылууну өзгөртүү же токтотуу - - - Камдык көчүрмө акыркы жолу качан сакталган: %1$s, %2$s. - Маектин чектери @@ -7683,5 +7671,101 @@ Эстетүү сүрөтчөсү + + + Эски телефонум бар + + Баштоо үчүн учурдагы Signal аккаунтуңуздан QR кодду скандаңыз + + + Эски телефонум жок + + Же Signal\'ды ошол эле телефонго кайра орнотуңуз + + + Аккаунтту калыбына келтирүү же өткөрүү + + Signal аккаунтуңузду жана билдирүүлөр таржымалын ушул түзмөккө өткөрөсүз. + + Signal\'дын камдык көчүрмөлөрүнөн + + Signal\'дын камдык көчүрмөлөрүнүн акысыз же акы алынуучу тарифтик планы + + Камдык көчүрмөлөр папкасынан + + Камдык көчүрмө файлынан + + Керектүү камдык көчүрмөнү тандаңыз + + Эски телефонуңуздан + + Дароо эски Android телефонуңуздан өткөрөсүз + + + Түзмөктөгү камдык көчүрмөлөрдү калыбына келтирүү + + Түзмөгүңүздө сакталган камдык көчүрмөлөрдөн билдирүүлөрүңүздү калыбына келтириңиз. Азыр калыбына келтирбесеңиз, кийин таптакыр жоготуп алышыңыз мүмкүн. + + + Камдык көчүрмөнүн ачкычын киргизиңиз + + Ал аккаунтуңуз менен андагы нерселерди калыбына келтирүүчү 64 орундуу код. + + Камдык көчүрмөнүн ачкычы жокпу? + + Камдык көчүрмөнүн ачкычы + + Камдык көчүрмөлөрүңүздү 64 орундуу кодсуз калыбына келтире албайсыз. Эгер ачкычыңызды жоготуп алсаңыз, Signal камдык көчүрмөлөрүңүздү калыбына келтире албайт. + + Эски түзмөгүңүз болсо, камдык көчүрмөнүн ачкычын көрүү үчүн Параметрлер > Маектер > Signal Камдык көчүрмөлөрү деген жерге өтүңүз. Андан соң Камдык көчүрмөнүн ачкычын көрүү дегенди басыңыз. + + Кененирээк маалымат + + Өткөрүп жиберүү + + + Ушул кодду эски телефонуңуз менен скандаңыз + + Эски телефонуңузда Signal\'ды ачыңыз + + Камеранын сүрөтчөсүн басыңыз + + Камера менен ушул кодду скандаңыз + + QR код түзүлгөн жок + + Эски телефондо скандалды + + Кайра аракет кылуу + + + Аккаунтту которуу + + Аккаунтуңуз жаңы түзмөккө өткөрүлөт. Бул түзмөктөн топторуңуз менен байланыштарыңызды, маектериңизди көрүп, өз атыңыздан билдирүүлөрдү жөнөтө аласыз. %1$s + + Кененирээк маалымат + + Аккаунтту которуу + + Билдирүүлөр жана маектеги нерселер бардык түзмөктөрдө баштан аяк шифрленип корголот + + Аккаунтту өткөрүү үчүн кулпуну ачыңыз + + Башка түзмөгүңүздө улантыңыз + + Аккаунтуңуз башка түзмөгүңүзгө өтө берет. + + + Калыбына келди + + Signal аккаунтуңуз менен билдирүүлөрүңүз башка түзмөгүңүзгө өтүп баштады. Эми Signal бул түзмөктө иштебейт. + + Которуу аяктады + + Signal аккаунтуңуз жана билдирүүлөрүңүз башка түзмөгүңүзгө өттү. Эми Signal бул түзмөктө иштебейт. + + Макул + + \ No newline at end of file diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 7816f628b8..0098d30a9d 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -1401,20 +1401,6 @@ daugiau Pridėti grupės aprašą… - - - Perkelti iš „Android“ įrenginio - - Perkelkite paskyrą ir žinučių istoriją iš savo senojo „Android“ įrenginio. - - Prisijungti be perkėlimo - - Tęsti neperkeliant savo žinučių ir įrašų - - Atkurti vietinę atsarginę kopiją - - Atkurkite žinutes iš atsarginės kopijos failo, kurį išsaugojote įrenginyje. - Atsisiunčiama atsarginė kopija… @@ -1432,12 +1418,16 @@ visas jūsų žinutes. Atkurti iš atsarginės kopijos - + Įtraukiami tik per pastarąsias %1$d d. išsiųsti arba gauti įrašai. Jūsų atsarginė kopija apima: Atkurti atsarginę kopiją + + Paskutinė atsarginė kopija buvo sukurta %1$s, %2$s. + + Gaunama informacija apie atsarginę kopiją… Pranešti apie paminėjimus @@ -3661,7 +3651,7 @@ Kitas Mokėjimai („MobileCoin“) Parama ir ženkleliai - Signal Android Backup + „Signal Android“ atsarginė kopija „Signal“ „Android“ derinimo žurnalo pateikimas @@ -4526,6 +4516,8 @@ Aš užsirašiau šią slaptafrazę. Be jos aš negalėsiu atkurti atsarginės kopijos. Atkurti atsarginę kopiją Perkelti ar atkurti paskyrą + + Atkurti arba perkelti Perkelti paskyrą Praleisti Pokalbių atsarginės kopijos @@ -5364,7 +5356,7 @@ Grupės - Only messages from group chats + Tik žinutės iš grupės pokalbių Pridėti @@ -6991,7 +6983,7 @@ Bakstelėk mygtuką „Eiti į nustatymus“. - Įjunk „Leisti nustatymų signalus ir priminimus“. + Įjunkite „Leisti nustatymų signalus ir priminimus.“ Eiti į nustatymus @@ -7748,11 +7740,11 @@ Jūsų „Signal“ įrašų atsarginės kopijos planas buvo atšauktas, nes negalėjome apdoroti mokėjimo. Tai paskutinė galimybė atsisiųsti įrašus iš atsarginės kopijos prieš ją ištrinant. - Free up %1$s on this device + Atlaisvinkite %1$s šiame įrenginyje - To finish downloading your Signal Backup your device needs %1$s of storage space. + Norint užbaigti „Signal“ atsarginės kopijos atsisiuntimą, įrenginyje reikia %1$s saugyklos vietos. - To free up space offload or delete unused apps or content large in file size. + Norėdami atlaisvinti vietos, perkelkite arba ištrinkite nenaudojamas programas arba didelio turinio failus. Nepavyko atnaujinti atsarginių kopijų prenumeratos @@ -7780,7 +7772,7 @@ Ne dabar - Try later + Bandyti vėliau Įrašai bus ištrinti @@ -7792,9 +7784,9 @@ Praleisti - Skip restore? + Praleisti atkūrimą? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Jei pasirinksite praleisti, likę įrašai ir priedai iš atsarginių kopijų bus ištrinti, kai įrenginys užbaigs naują atsarginę kopiją. @@ -7851,10 +7843,6 @@ Pakeisti arba nutraukti prenumeratą - - - Paskutinė atsarginė kopija buvo sukurta %1$s, %2$s. - Pokalbio ilgio ribojimai @@ -8184,5 +8172,101 @@ Priminimo piktograma + + + Turiu savo senąjį telefoną + + Norėdami greitai pradėti, nuskaitykite QR kodą iš dabartinės „Signal“ paskyros + + + Neturiu savo senojo telefono + + Arba iš naujo įdiegiate „Signal“ tame pačiame įrenginyje + + + Atkurti arba perkelti paskyrą + + Gaukite savo „Signal“ paskyrą ir žinučių istoriją į šį įrenginį. + + Iš „Signal“ atsarginių kopijų + + Jūsų nemokamas arba mokamas „Signal“ atsarginių kopijų planas + + Iš atsarginės kopijos aplanko + + Iš atsarginės kopijos failo + + Pasirinkite išsaugotą atsarginę kopiją + + Iš senojo telefono + + Perkelkite tiesiai iš savo senojo „Android“ įrenginio + + + Atkurti vietinę atsarginę kopiją + + Atkurkite žinutes iš atsarginės kopijos, kurią išsaugojote įrenginyje. Jeigu neatkursite dabar, vėliau to padaryti nebegalėsite. + + + Įveskite savo atsarginės kopijos raktą + + Jūsų atsarginės kopijos raktas yra 64 skaitmenų kodas, reikalingas norint atkurti paskyrą ir duomenis. + + Neturite atsarginės kopijos rakto? + + Atsarginės kopijos raktas + + Atsarginių kopijų negalima atkurti be 64 skaitmenų atkūrimo kodo. Jei prarasite savo atsarginės kopijos raktą, „Signal“ negalės jums padėti atkurti atsarginės kopijos. + + Jei turite senąjį įrenginį, atsarginės kopijos raktą rasite skiltyje Nustatymai > Pokalbiai > „Signal“ atsarginės kopijos. Tada bakstelėkite „Peržiūrėti atsarginės kopijos raktą“. + + Sužinoti daugiau + + Praleisti ir neatkurti + + + Nuskaitykite šį kodą senuoju telefonu + + Atverkite „Signal“ senajame telefone + + Bakstelėkite kameros piktogramą + + Nuskaitykite šį kodą savo kamera + + Nepavyko sugeneruoti QR kodo + + Nuskaityta senajame įrenginyje + + Bandyti dar kartą + + + Perkelti paskyrą + + Jūsų paskyra bus perkelta į naują įrenginį. Šis įrenginys galės matyti jūsų grupes ir adresatus, gauti prieigą prie jūsų pokalbių bei siųsti žinutes jūsų vardu. %1$s + + Sužinoti daugiau + + Perkelti paskyrą + + Žinutės ir pokalbių informacija yra visiškai užšifruoti visuose įrenginiuose + + Atrakinti, kad perkeltumėte paskyrą + + Tęskite kitame įrenginyje + + Paskyros perkėlimą tęskite kitame įrenginyje. + + + Atkūrimas užbaigtas + + Jūsų „Signal“ paskyra ir žinutės pradedamos perkelti į kitą įrenginį. „Signal“ nebeveikia šiame įrenginyje. + + Perkėlimas užbaigtas + + Jūsų „Signal“ paskyra ir žinutės perkeltos į kitą įrenginį. „Signal“ nebeveikia šiame įrenginyje. + + Gerai + + \ No newline at end of file diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 0b18400a9d..62f2e54832 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -1363,20 +1363,6 @@ vēl Pievienot grupas aprakstu… - - - Pārnest no Android ierīces - - Pārsūtiet savu kontu un ziņas no vecās Android ierīces. - - Pieteikšanās, nepārsūtot datus - - Turpiniet, nepārsūtot ziņas un multivides saturu - - Lokālās rezerves kopijas atjaunošana - - Atjaunojiet ziņas no rezerves kopijas faila, ko saglabājāt savā ierīcē. - Rezerves kopija tiek lejupielādēta… @@ -1394,12 +1380,16 @@ Visas jūsu ziņas Atjaunot no rezerves kopijas - + Tiek iekļauta tikai pēdējās %1$d dienās sūtītā vai saņemtā multivide. Jūsu rezerves kopija ietver: Atjaunot rezerves kopiju + + Pēdējā rezerves kopija tika izveidota: %1$s plkst. %2$s. + + Meklē rezerves kopijas informāciju… Paziņot man par Pieminējumiem @@ -3562,7 +3552,7 @@ Cits Maksājumi (MobileCoin) Ziedojumi un nozīmītes - Signal Android Backup + Signal Android rezerves kopija Signal Android atkļūdošanas žurnāla nosūtīšana @@ -4415,6 +4405,8 @@ Esmu sev pierakstījis šo paroles frāzi. Bez tās nebūs iespējams atjaunot rezerves kopiju. Atjaunot rezerves kopiju Nodot vai atgūt kontu + + Atjaunot vai pārsūtīt Nodot kontu Izlaist Sarunu rezerves kopijas @@ -5244,7 +5236,7 @@ Grupas - Only messages from group chats + Tikai ziņas no grupu sarunām Pievienot @@ -6839,7 +6831,7 @@ Pieskarieties zemāk redzamajai pogai \"Atvērt iestatījumus\" - Ieslēdziet opciju \"Atļaut iestatījumu brīdinājumus un atgādinājumus\". + Ieslēdziet opciju \"Atļaut iestatīt brīdinājumus un atgādinājumus\". Atvērt iestatījumus @@ -7587,11 +7579,11 @@ Jūsu Signal multivides rezerves kopiju plāns tika atcelts, jo nevarējām apstrādāt maksājumu. Šī ir jūsu pēdējā iespēja lejupielādēt multivides rezerves kopiju, pirms tā tiks dzēsta. - Free up %1$s on this device + Atbrīvojiet līdz %1$s šajā ierīcē - To finish downloading your Signal Backup your device needs %1$s of storage space. + Lai pabeigtu Signal rezerves kopijas lejupielādi, ierīcē ir nepieciešama brīva vieta: %1$s. - To free up space offload or delete unused apps or content large in file size. + Lai atbrīvotu vietu, atinstalējiet vai dzēsiet neizmantotās lietotnes un lielus failus. Rezerves kopiju abonementu neizdevās atjaunot @@ -7619,7 +7611,7 @@ Ne tagad - Try later + Mēģināt vēlāk Multivide tiks dzēsta @@ -7631,9 +7623,9 @@ Izlaist - Skip restore? + Vai izlaist atjaunošanu? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Ja izlaidīsiet atjaunošanu, rezerves kopijā palikusī multivide un pielikumi tiks dzēsti nākamreiz, kad ierīce pabeigs jaunas rezerves kopijas izveidi. @@ -7690,10 +7682,6 @@ Mainīt vai atcelt abonementu - - - Pēdējā rezerves kopija tika izveidota: %1$s plkst. %2$s. - Ziņu ierobežojumi sarunās @@ -8017,5 +8005,101 @@ Atgādinājuma ikona + + + Man ir iepriekšējais tālrunis + + Lai ātri sāktu darbu, skenējiet QR kodu no pašreizējā Signal konta + + + Man nav iepriekšējā tālruņa + + Vai arī jūs atkārtoti instalējat Signal tajā pašā ierīcē + + + Atjaunojiet vai pārsūtiet kontu + + Iegūstiet savu Signal kontu un ziņu vēsturi šajā ierīcē. + + No Signal rezerves kopijām + + Jūsu bezmaksas vai maksas Signal rezerves kopiju plāns + + No rezerves kopijas mapes + + No rezerves kopijas faila + + Izvēlieties saglabātu rezerves kopiju + + No jūsu iepriekšējā tālruņa + + Pārsūtiet tieši no iepriekšējā Android tālruņa + + + Lokālās rezerves kopijas atjaunošana + + Atjaunojiet ziņas no rezerves kopijas, ko saglabājāt savā ierīcē. Ja neatjaunosiet tagad, vēlāk atjaunošanu veikt nevarēs. + + + Ievadiet rezerves kopijas atslēgu + + Rezerves kopijas atslēga ir 64 ciparu kods, kas nepieciešams konta un datu atgūšanai. + + Vai jums nav rezerves kopijas atslēgas? + + Rezerves kopijas atslēga + + Rezerves kopijas nevar atgūt bez 64 ciparu atgūšanas koda. Ja rezerves kopijas atslēga ir pazaudēta, Signal nevar palīdzēt atgūt rezerves kopiju. + + Ja iepriekšējā ierīce ir jūsu rīcībā, rezerves kopijas atslēgu varat skatīt sadaļā Iestatījumi > Sarunas > Signal rezerves kopijas. Pēc tam pieskarieties pie Skatīt rezerves kopijas atslēgu. + + Uzzināt vairāk + + Izlaist un neatjaunot + + + Noskenējiet šo kodu ar savu iepriekšējo tālruni + + Atveriet Signal savā iepriekšējā ierīcē. + + Pieskarieties kameras ikonai. + + Noskenējiet šo kodu ar kameru. + + Nevar ģenerēt QR kodu + + Noskenēts iepriekšējā ierīcē + + Mēģināt vēlreiz + + + Pārnest kontu + + Jūsu konts tiks pārsūtīts uz jaunu ierīci. Šī ierīce varēs skatīt jūsu grupas un kontaktpersonas, piekļūt jūsu sarunām un sūtīt ziņas jūsu vārdā. %1$s + + Uzzināt vairāk + + Pārnest kontu + + Ziņas un sarunu informāciju aizsargā pilnīga šifrēšana visās ierīcēs + + Atbloķējiet, lai pārsūtītu kontu + + Turpiniet otrā ierīcē + + Turpiniet konta pārsūtīšanu otrā ierīcē. + + + Atjaunošana pabeigta + + Ir uzsākta Signal konta un ziņu pārsūtīšana uz otru ierīci. Tagad Signal vairs nav aktīvs šajā ierīcē. + + Pārsūtīšana pabeigta + + Jūsu Signal konts un ziņas ir pārsūtītas uz otru ierīci. Tagad Signal vairs nav aktīvs šajā ierīcē. + + Labi + + \ No newline at end of file diff --git a/app/src/main/res/values-mk/strings.xml b/app/src/main/res/values-mk/strings.xml index fb8e6dd5c7..033bd1c8b9 100644 --- a/app/src/main/res/values-mk/strings.xml +++ b/app/src/main/res/values-mk/strings.xml @@ -1325,20 +1325,6 @@ повеќе Додадете опис на групата… - - - Префрли од Android уред - - Пренесете ги вашата корисничка сметка и историјата на пораки од вашиот стар Android уред. - - Најавете се без пренос - - Продолжете без пренос на вашите пораки и медиумски датотеки - - Вратете податоци од локална резервна копија - - Вратете ги вашите пораки од резервна копија сочувана на вашиот уред. - Се презема резервнатата копија… @@ -1356,12 +1342,16 @@ Сите ваши пораки Врати од резервна копија - + Вклучени се само медиумските датотеки кои биле испратени или примени во последните %1$d дена. Вашата резервна копија ги вклучува: Обнови последна копија + + Вашата последна резервна копија беше направена на %1$s во %2$s. + + Се преземаат деталите за резервната копија… Извести кога некој ќе ме спомне @@ -3463,7 +3453,7 @@ Друго Плаќања (MobileCoin) Донации и беџови - Signal Android Backup + Резервна копија на Signal Android Поднесување запис за отстранување грешки на Signal за Android @@ -4304,6 +4294,8 @@ Ја запишав оваа лозинка. Без неа нема да можам да ја вратам резервната копија. Обнови последна копија Префрли или поврати сметка + + Вратете или пренесете Префрли сметка Прескокни Резервни копии на разговори @@ -5124,7 +5116,7 @@ Групи - Only messages from group chats + Само пораки од групни разговори Додај @@ -6687,7 +6679,7 @@ Допрете на копчето „Оди во поставувањата“ подолу - Вклучете ја опцијата „Овозможи известувања и потсетувања за поставувањата“. + Вклучете ја опцијата „Овозможи известувања и потсетувања за поставувањето“. Оди во поставувањата @@ -7426,11 +7418,11 @@ Вашиот претплатен пакет на резервни копии за медиумски датотеки на Signal е откажан зашто не можевме да го обработиме вашето плаќање. Ова ви е последна можност да ги преземете медиумските датотеки од вашата резервна копија пред да се избришат. - Free up %1$s on this device + Ослободете %1$s на овој уред - To finish downloading your Signal Backup your device needs %1$s of storage space. + За да се заврши преземањето на вашата Signal резервна копија, потребен е простор за складирање од %1$s на вашиот уред. - To free up space offload or delete unused apps or content large in file size. + За да ослбодите простор, отстранете ги или избришете ги апликациите кои не ги користите или датотеките кои зафаќаат голем простор. Вашата претплата на резервни копии не успеа да се обнови @@ -7458,7 +7450,7 @@ Не сега - Try later + Обидете се подоцна Медиумските датотеки ќе бидат избришани @@ -7470,9 +7462,9 @@ Прескокни - Skip restore? + Да се прескокне враќањето? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Ако го прескокнете враќањето, преостанатите медиумските датотеки и прилози во вашата резервна копија ќе бидат избришани следниот пат кога вашиот уред ќе прави нова резервна копија. @@ -7529,10 +7521,6 @@ Сменете ја или откажете ја претплатата - - - Вашата последна резервна копија беше направена на %1$s во %2$s. - Максимален број на пораки во разговор @@ -7850,5 +7838,101 @@ Икона за потсетник + + + Го имам мојот стар телефон + + Скенирајте QR-код од вашата тековна корисничка сметка на Signal за да започнете брзо + + + Го немам мојот стар телефон + + Или ако реинсталирате Signal на истиот уред + + + Вратете или пренесете ја вашата корисничка сметка + + Пренесете ја вашата Signal корисничка сметка и историјата на пораки на овој уред. + + Од Signal резервни копии + + Вашиот бесплатен или платен пакет на резервни копии + + Од папка за резервни копии + + Од резервна копија + + Изберете резервна копија што сте ја зачувале + + Од вашиот стар телефон + + Пренесете директно од вашиот стар Android + + + Вратете податоци од локална резервна копија + + Вратете ги вашите пораки од резервната копија сочувана на вашиот уред. Ако не ги вратите сега нема да можете да го направите тоа подоцна. + + + Внесете го вашиот клуч за резервни копии + + Вашиот клуч за резервни копии е код од 64 цифри којшто ви помага да си ги вратите корисничката сметка и податоците. + + Немате клуч за резервни копии? + + Клуч за резервни копии + + Резервните копии не може да се вратат без нивниот код за враќање од 64 цифри. Ако го имате изгубено вашиот клуч за резервни копии, Signal не може да ви помогне да си ја вратите резервната копија. + + Ако го имате вашиот стар уред можете да го видите вашиот клуч за резервни копии во Поставувања > Разговори > Signal резервни копии. Потоа допрете на „Види го клучот за резервни копии“. + + Дознајте повеќе + + Прескокнете и не враќајте + + + Скенирајте го овој код со вашиот стар телефон + + Отворете го Signal на вашиот стар уред + + Допрете ја иконата на камерата + + Скенирајте го овој код со камерата + + Не може да се генерира QR-код + + Скенирано на стар уред + + Обидете се повторно + + + Префрли сметка + + Вашата корисничка сметка ќе биде пренесена на нов уред. Овој уред ќе може да ги види вашите групи и контакти, да има пристап до вашите разговори и да испраќа пораки во ваше име. %1$s + + Дознајте повеќе + + Префрли сметка + + Пораките и информациите за разговорите се заштитени со целосно шифрирање на сите уреди + + Отклучете за да ја пренесете корисничката сметка + + Продолжете на вашиот друг уред + + Продолжете со пренесување на вашата корисничка сметка на другиот уред. + + + Враќањето е завршено + + Вашата Signal корисничка сметка и пораките почнаа да се пренесуваат на другиот ваш уред. Signal сега е неактивен на овој уред. + + Трансферот е завршен + + Вашата Signal корисничка сметка и пораките беа пренесени на другиот ваш уред. Signal сега е неактивен на овој уред. + + Во ред + + \ No newline at end of file diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index 93c0ca4dbb..1cea938944 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -1325,20 +1325,6 @@ കൂടുതൽ ഗ്രൂപ്പ് വിവരണം ചേർക്കുക… - - - ആൻഡ്രോയിഡ് ഉപകരണത്തിൽ നിന്ന് ട്രാൻസ്ഫർ - - നിങ്ങളുടെ പഴയ Android ഉപകരണത്തിൽ നിന്ന് നിങ്ങളുടെ അക്കൗണ്ടും സന്ദേശങ്ങളും കൈമാറുക. - - കൈമാറ്റം ചെയ്യാതെ ലോഗിൻ ചെയ്യുക - - നിങ്ങളുടെ സന്ദേശങ്ങളും മീഡിയയും കൈമാറാതെ തുടരുക - - ലോക്കൽ ബാക്കപ്പ് പുനഃസ്ഥാപിക്കുക - - നിങ്ങളുടെ ഉപകരണത്തിൽ സംരക്ഷിച്ച ഒരു ബാക്കപ്പ് ഫയലിൽ നിന്ന് നിങ്ങളുടെ സന്ദേശങ്ങൾ പുനഃസ്ഥാപിക്കുക. - ബാക്കപ്പ് ഡൗൺലോഡ് ചെയ്യുന്നു… @@ -1356,12 +1342,16 @@ നിങ്ങളുടെ എല്ലാ സന്ദേശങ്ങളും ബാക്കപ്പിൽ നിന്ന് പുനഃസ്ഥാപിക്കുക - + കഴിഞ്ഞ %1$d ദിവസങ്ങളിൽ അയച്ചതോ സ്വീകരിച്ചതോ ആയ മീഡിയ മാത്രമേ ഉൾപ്പെടുത്തിയിട്ടുള്ളൂ. നിങ്ങളുടെ ബാക്കപ്പിൽ ഇവ ഉൾപ്പെടുന്നു: ബാക്കപ്പ് വീണ്ടെടുക്കൂ + + നിങ്ങളുടെ അവസാന ബാക്കപ്പ് %1$s, %2$s -ന് ചെയ്തു. + + ബാക്കപ്പ് വിശദാംശങ്ങൾ ലഭ്യമാക്കുന്നു… സൂചനകൾ എന്നെ അറിയിക്കുക @@ -3463,7 +3453,7 @@ മറ്റുള്ളവ പേയ്‌മെന്റുകൾ (MobileCoin) സംഭാവനകളും ബാഡ്‌ജുകളും - Signal Android Backup + Signal Android ബാക്കപ്പ് Signal Android ഡീബഗ് ലോഗ് സമർപ്പിക്കൽ @@ -4304,6 +4294,8 @@ ഞാൻ ഈ പാസ്‌ഫ്രെയ്‌സ് എഴുതി. ഇത് ഇല്ലാതെ, എനിക്ക് ഒരു ബാക്കപ്പ് പുന റിസ്റ്റോർ ചെയ്യാൻ കഴിയില്ല. ബാക്കപ്പ് വീണ്ടെടുക്കൂ അക്കൗണ്ട് മാറ്റിസ്ഥാപിക്കുകയോ പുനസ്ഥാപിക്കുകയോ ചെയ്യുക + + പുനഃസ്ഥാപിക്കുക അല്ലെങ്കിൽ കൈമാറ്റം ചെയ്യുക അക്കൗണ്ട് മാറ്റിസ്ഥാപിക്കുക ഒഴിവാക്കുക ചാറ്റ് ബാക്കപ്പുകൾ @@ -5124,7 +5116,7 @@ ഗ്രൂപ്പുകൾ - Only messages from group chats + ഗ്രൂപ്പ് ചാറ്റുകളിൽ നിന്നുള്ള സന്ദേശം മാത്രം ചേർക്കുക @@ -6687,7 +6679,7 @@ ചുവടെയുള്ള \"ക്രമീകരണങ്ങളിലേക്ക് പോകുക\" എന്ന ബട്ടണിൽ ടാപ്പ് ചെയ്യുക - \"അലാറങ്ങളും ഓർമ്മപ്പെടുത്തലുകളും ക്രമീകരിക്കുന്നത് അനുവദിക്കുക\" എന്നത് ഓണാക്കുക. + \"അലാറങ്ങളും ഓർമ്മപ്പെടുത്തലുകളും ക്രമീകരിക്കുന്നത് അനുവദിക്കുക\" ഓണാക്കുക. ക്രമീകരണത്തിലേക്ക് പോകുക @@ -7426,11 +7418,11 @@ നിങ്ങളുടെ പേയ്‌മെൻ്റ് പ്രോസസ്സ് ചെയ്യാൻ കഴിയാത്തതിനാൽ നിങ്ങളുടെ Signal മീഡിയ ബാക്കപ്പ് പ്ലാൻ റദ്ദാക്കപ്പെട്ടു. നിങ്ങളുടെ ബാക്കപ്പിൽ മീഡിയ ഇല്ലാതാക്കുന്നതിന് മുമ്പ് അത് ഡൗൺലോഡ് ചെയ്യാനുള്ള അവസാന അവസരമാണിത്. - Free up %1$s on this device + ഈ ഉപകരണത്തിൽ %1$s ശൂന്യമാക്കുക - To finish downloading your Signal Backup your device needs %1$s of storage space. + നിങ്ങളുടെ Signal ബാക്കപ്പ് ഡൗൺലോഡ് ചെയ്യുന്നത് പൂർത്തിയാക്കാൻ നിങ്ങളുടെ ഉപകരണത്തിന് %1$s സംഭരണ ഇടം വേണം. - To free up space offload or delete unused apps or content large in file size. + ഇടം ഉണ്ടാക്കാൻ, ഉപയോഗിക്കാത്ത ആപ്പുകളോ വലിയ ഫയൽ വലുപ്പമുള്ള ഉള്ളടക്കമോ ഓഫ്‌ലോഡ് ചെയ്യുകയോ ഇല്ലാതാക്കുകയോ ചെയ്യുക. നിങ്ങളുടെ ബാക്കപ്പ് സബ്‌സ്‌ക്രിപ്‌ഷൻ പുതുക്കാനായില്ല @@ -7458,7 +7450,7 @@ ഇപ്പോൾ വേണ്ട - Try later + പിന്നീട് ശ്രമിക്കുക മീഡിയ ഇല്ലാതാക്കപ്പെടും @@ -7470,9 +7462,9 @@ ഒഴിവാക്കുക - Skip restore? + പുനഃസ്ഥാപിക്കൽ ഒഴിവാക്കണോ? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + നിങ്ങൾ പുനഃസ്ഥാപിക്കുന്നത് ഒഴിവാക്കുകയാണെങ്കിൽ, നിങ്ങളുടെ ബാക്കപ്പിലെ ശേഷിക്കുന്ന മീഡിയയും അറ്റാച്ച്‌മെൻ്റുകളും അടുത്ത തവണ നിങ്ങളുടെ ഉപകരണം ഒരു പുതിയ ബാക്കപ്പ് പൂർത്തിയാക്കുമ്പോൾ ഇല്ലാതാക്കപ്പെടും. @@ -7529,10 +7521,6 @@ സബ്സ്ക്രിപ്ഷൻ മാറ്റുക അല്ലെങ്കിൽ റദ്ദാക്കുക - - - നിങ്ങളുടെ അവസാന ബാക്കപ്പ് %1$s, %2$s -ന് ചെയ്തു. - ചാറ്റ് പരിധികൾ @@ -7850,5 +7838,101 @@ ഓർമ്മപ്പെടുത്തൽ ഐക്കൺ + + + എൻ്റെ കയ്യിൽ പഴയ ഫോൺ ഉണ്ട് + + വേഗത്തിൽ ആരംഭിക്കുന്നതിന് നിങ്ങളുടെ നിലവിലെ Signal അക്കൗണ്ടിൽ നിന്ന് ഒരു QR കോഡ് സ്കാൻ ചെയ്യുക + + + എൻ്റെ കയ്യിൽ പഴയ ഫോൺ ഇല്ല + + അല്ലെങ്കിൽ നിങ്ങൾ അതേ ഉപകരണത്തിൽ Signal വീണ്ടും ഇൻസ്റ്റാൾ ചെയ്യുകയാണ് + + + അക്കൗണ്ട് പുനഃസ്ഥാപിക്കുക അല്ലെങ്കിൽ കൈമാറുക + + ഈ ഉപകരണത്തിലേക്ക് നിങ്ങളുടെ Signal അക്കൗണ്ടും സന്ദേശ ചരിത്രവും നേടുക. + + Signal ബാക്കപ്പുകളിൽ നിന്ന് + + നിങ്ങളുടെ സൗജന്യ അല്ലെങ്കിൽ പണമടച്ചുള്ള Signal ബാക്കപ്പ് പ്ലാൻ + + ഒരു ബാക്കപ്പ് ഫോൾഡറിൽ നിന്ന് + + ഒരു ബാക്കപ്പ് ഫയലിൽ നിന്ന് + + നിങ്ങൾ സംരക്ഷിച്ചിരിക്കുന്ന ഒരു ബാക്കപ്പ് തിരഞ്ഞെടുക്കുക + + നിങ്ങളുടെ പഴയ ഫോണിൽ നിന്ന് + + നിങ്ങളുടെ പഴയ Android-ൽ നിന്ന് നേരിട്ട് കൈമാറുക + + + ലോക്കൽ ബാക്കപ്പ് പുനഃസ്ഥാപിക്കുക + + നിങ്ങളുടെ ഉപകരണത്തിൽ സംരക്ഷിച്ചിരിക്കുന്ന ബാക്കപ്പിൽ നിന്ന് നിങ്ങളുടെ സന്ദേശങ്ങൾ പുനഃസ്ഥാപിക്കുക. നിങ്ങൾ ഇപ്പോൾ പുനഃസ്ഥാപിച്ചില്ലെങ്കിൽ, നിങ്ങൾക്ക് പിന്നീട് പുനഃസ്ഥാപിക്കാൻ കഴിയില്ല. + + + നിങ്ങളുടെ ബാക്കപ്പ് കീ നൽകുക + + നിങ്ങളുടെ അക്കൗണ്ടും ഡാറ്റയും വീണ്ടെടുക്കാൻ ആവശ്യമായ 64 അക്ക കോഡാണ് നിങ്ങളുടെ ബാക്കപ്പ് കീ. + + ബാക്കപ്പ് കീ ഇല്ലേ? + + ബാക്കപ്പ് കീ + + 64 അക്ക വീണ്ടെടുക്കൽ കോഡ് ഇല്ലാതെ ബാക്കപ്പുകൾ വീണ്ടെടുക്കാനാവില്ല. നിങ്ങളുടെ ബാക്കപ്പ് കീ നഷ്‌ടപ്പെട്ടാൽ, നിങ്ങളുടെ ബാക്കപ്പ് പുനഃസ്ഥാപിക്കാൻ Signal-ന് സഹായിക്കാനാകില്ല. + + നിങ്ങളുടെ പക്കൽ പഴയ ഉപകരണം ഉണ്ടെങ്കിൽ, ക്രമീകരണങ്ങൾ > ചാറ്റുകൾ > Signal ബാക്കപ്പുകൾ എന്നതിൽ നിങ്ങളുടെ ബാക്കപ്പ് കീ കാണാനാകും. തുടർന്ന് ബാക്കപ്പ് കീ കാണുക എന്നതിൽ ടാപ്പ് ചെയ്യുക. + + കൂടുതലറിയുക + + ഒഴിവാക്കുക, പുനഃസ്ഥാപിക്കരുത് + + + നിങ്ങളുടെ പഴയ ഫോൺ ഉപയോഗിച്ച് ഈ കോഡ് സ്കാൻ ചെയ്യുക + + നിങ്ങളുടെ പഴയ ഉപകരണത്തിൽ Signal തുറക്കുക + + ക്യാമറ ഐക്കൺ ടാപ്പ് ചെയ്യുക + + ക്യാമറ ഉപയോഗിച്ച് ഈ കോഡ് സ്കാൻ ചെയ്യുക + + QR കോഡ് സൃഷ്‌ടിക്കാനായില്ല + + പഴയ ഉപകരണത്തിൽ സ്കാൻ ചെയ്തു + + വീണ്ടും ശ്രമിക്കുക + + + അക്കൗണ്ട് കൈമാറുക + + നിങ്ങളുടെ അക്കൗണ്ട് ഒരു പുതിയ ഉപകരണത്തിലേക്ക് കൈമാറ്റം ചെയ്യപ്പെടും. ഈ ഉപകരണത്തിന് നിങ്ങളുടെ ഗ്രൂപ്പുകളും കോൺടാക്റ്റുകളും കാണാനും നിങ്ങളുടെ ചാറ്റുകൾ ആക്‌സസ് ചെയ്യാനും നിങ്ങളുടെ പേരിൽ സന്ദേശങ്ങൾ അയയ്‌ക്കാനും കഴിയും. %1$s + + കൂടുതലറിയുക + + അക്കൗണ്ട് കൈമാറുക + + എല്ലാ ഉപകരണങ്ങളിലും എൻഡ്-ടു-എൻഡ് എൻക്രിപ്ഷൻ മുഖേന സന്ദേശങ്ങളും ചാറ്റ് വിവരങ്ങളും പരിരക്ഷിച്ചിരിക്കുന്നു + + അക്കൗണ്ട് കൈമാറാൻ അൺലോക്ക് ചെയ്യുക + + നിങ്ങളുടെ മറ്റേ ഉപകരണത്തിൽ തുടരുക + + നിങ്ങളുടെ മറ്റേ ഉപകരണത്തിൽ അക്കൗണ്ട് കൈമാറുന്നത് തുടരുക. + + + പുനഃസ്ഥാപിക്കൽ പൂർത്തിയായി + + നിങ്ങളുടെ Signal അക്കൗണ്ടും സന്ദേശങ്ങളും നിങ്ങളുടെ മറ്റേ ഉപകരണത്തിലേക്ക് കൈമാറാൻ തുടങ്ങി. ഈ ഉപകരണത്തിൽ Signal ഇപ്പോൾ പ്രവർത്തനരഹിതമാണ്. + + കൈമാറ്റം പൂർത്തിയായി + + നിങ്ങളുടെ Signal അക്കൗണ്ടും സന്ദേശങ്ങളും നിങ്ങളുടെ മറ്റേ ഉപകരണത്തിലേക്ക് കൈമാറ്റം ചെയ്‌തു. ഈ ഉപകരണത്തിൽ Signal ഇപ്പോൾ പ്രവർത്തനരഹിതമാണ്. + + ശരി + + \ No newline at end of file diff --git a/app/src/main/res/values-mr/strings.xml b/app/src/main/res/values-mr/strings.xml index 0ff2f05048..b5c6dd2c44 100644 --- a/app/src/main/res/values-mr/strings.xml +++ b/app/src/main/res/values-mr/strings.xml @@ -1325,20 +1325,6 @@ आणखी गट विवरण जोडा… - - - Android डिव्हाइसवरून स्थानांतरित करा - - आपल्या जुन्या ॲन्ड्राइड डिव्हाइसवरून आपले अकाऊंट आणि संदेश स्थलांतरित करा. - - स्थानांतरित न करता लॉग इन करा - - आपले संदेश आणि मिडीया स्थानांतरित न करता सुरू ठेवा - - स्थानिक बॅकअप पुर्नस्थापित करा - - आपण आपल्या डिव्हाइस जतन केलेल्या बॅकअप फाइलमधून आपले संदेश पुर्नस्थापित करा. - बॅकअप डाउनलोड करत आहे… @@ -1356,12 +1342,16 @@ आपले सर्व संदेश बॅकअपवरून पुनर्स्थापित करा - + मागील %1$d दिवसांमध्ये पाठविलेला किंवा प्राप्त मिडीया समाविष्ट केलेला आहे. आपल्या बॅकअपमध्ये हे समाविष्ट आहे: बॅकअप पुनर्स्थापित करा + + आपला शेवटचा बॅकअप %1$s रोजी %2$s वाजता घेतला. + + बॅकअप तपशील आणत आहे… उल्लेखा साठी मला सूचित करा @@ -3463,7 +3453,7 @@ इतर पेमेंट्स (मोबाईलकॉईन) देणगी व बॅजेस - Signal Android Backup + Signal ॲन्ड्रॉइड बॅकअप Signal Android डीबग लॉग सबमिशन @@ -4304,6 +4294,8 @@ मी हे पासफ्रेझ लिहून ठेवले आहे. याविना, मी बॅकअपची पुनर्स्थापना करण्यास अक्षम असेल. बॅकअप पुनर्स्थापित करा स्थानांतरित करा किंवा खाते पुनर्स्थापित करा + + परत मिळवा किंवा हस्तांतरण करा खाते स्थानांतरित करा वगळा चॅट बॅकअप्स @@ -5124,7 +5116,7 @@ समूह - Only messages from group chats + फक्त सामूहिक चॅट्स मधील संदेश जोडा @@ -6687,7 +6679,7 @@ खाली \"सेटिंग्ज कडे जा\" बटणावर टॅप करा - \"सेटिंग्ज अलार्म आणि स्मरणपत्रे यांना अनुमती द्या.\" सुरू करा + \"सेटिंग्ज अलार्म आणि रिमाइंडर लावायला अनुमती द्या.\" सुरू करा सेटिंग्ज वर जा @@ -7426,11 +7418,11 @@ आम्ही आपल्या पेमेंट्सवर प्रक्रिया करू न शकल्याने आपला Signal मिडीया बॅकअप प्लॅन रद्द करण्यात आला आहे. आपल्या बॅकअप मधील आपला मिडीया हटवला जाण्यापूर्वी डाउनलोड करण्याची ही आपली शेवटची संधी आहे. - Free up %1$s on this device + या डिव्हाईसवरील %1$s जागा मोकळी करा - To finish downloading your Signal Backup your device needs %1$s of storage space. + तुमचा Signal बॅकअप डाऊनलोड करणे पूर्ण करण्यासाठी तुमच्या डिव्हाईसवर %1$s साठवणीची जागा लागेल. - To free up space offload or delete unused apps or content large in file size. + जागा मोकळी करण्यासाठी न वापरलेली ॲप्स किंवा फाईलचा आकार मोठा असणारे कंटेंट ऑफलोड करा किंवा हटवा. आपल्या बॅकअप सदस्यत्वाचे नूतनीकरण अयशस्वी झाले @@ -7458,7 +7450,7 @@ आता नाही - Try later + नंतर प्रयत्न करा मिडीया हटवला जाईल @@ -7470,9 +7462,9 @@ वगळा - Skip restore? + परत मिळवण्याची क्रिया वगळायची? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + जर तुम्ही परत मिळवण्याची क्रिया वगळलीत तर पुढच्या वेळी तुमच्या डिव्हाईसने नवा बॅकअप पूर्ण केेल्यावर तुमच्या बॅकअप मधील उरलेला मीडिया आणि अटॅचमेंट्स हटवले जातील. @@ -7529,10 +7521,6 @@ सदस्यत्व बदला किंवा रद्द करा - - - आपला शेवटचा बॅकअप %1$s रोजी %2$s वाजता घेतला. - चॅट मर्यादा @@ -7850,5 +7838,101 @@ स्मरणचत्र चिन्ह + + + माझ्याकडे माझा जुना फोन आहे + + पटकन सुरु करण्यासाठी आपल्या चालू Signal अकाऊंट मधून QR कोड स्कॅन करा + + + माझ्याकडे माझा जुना फोन नाही + + किंवा आपण त्याच डिव्हाईसवर Signal पुन्हा इन्स्टॉल करत आहात + + + अकाऊंट परत मिळवा किंवा हस्तांतरित करा + + आपला Signal अकाऊंट आणि संदेश इतिहास या डिव्हाईसवर आणा. + + Signal बॅकअप्स वरून + + आपला विनामूल्य किंवा सशुल्क Signal बॅकअप प्लॅन + + बॅकअप फोल्डर मधून + + बॅकअप फाईलमधून + + आपण जतन केलेला बॅकअप निवडा + + आपल्या जुन्या फोनमधून + + आपल्या जुन्या ॲन्ड्राइड वरून थेट हस्तांतरण करा + + + स्थानिक बॅकअप पुर्नस्थापित करा + + आपले संदेश आपण आपल्या डिव्हाईसवर जतन केलेल्या बॅकअपमधून परत मिळवा. जर आपण ते आत्ता परत मिळवले नाहीत, तर आपल्याला ते नंतर परत मिळवता येणार नाहीत. + + + आपली बॅकअप की प्रविष्ट करा + + आपली बॅकअप की एक ६४ अंकी कोड असतो जो आपला अकाऊंट आणि माहिती परत मिळवण्यासाठी आवश्यक असतो. + + बॅकअप की नाही? + + बॅकअप की + + बॅकअप्स त्यांच्या ६४ अंकी पुनःप्राप्ती कोडशिवाय परत मिळवता येत नाहीत. जर आपली बॅकअप की हरवली असेल तर Signal आपला बॅकअप परत मिळवण्यास मदत करू शकत नाही. + + जर आपल्याकडे आपला जुना डिव्हाईस असेल तर त्यात आपण आपली बॅकअप की सेटिंग्ज > चॅट्स > Signal बॅकअप्स मध्ये बघू शकतो. नंतर बॅकअप की दृश्य टॅप करा. + + अधिक जाणून घ्या + + वगळा आणि परत मिळवू नका + + + आपल्या जुन्या फोनने हा कोड स्कॅन करा + + आपल्या जुन्या डिव्हाईसवर Signal उघडा + + कॅमेरा आयकनवर टॅप करा + + हा कोड कॅमेराने स्कॅन करा + + QR कोड जनरेट होत नाही + + जुन्या डिव्हाईसवर स्कॅन केले + + पुन्हा प्रयत्न करा + + + खाते स्थानांतरित करा + + आपला अकाऊंट नवीन डिव्हाईसवर फिरवला जाईल. हा डिव्हाईस आपले गट आणि संपर्क पाहू शकेल, आपल्या चॅट्समध्ये जाऊ शकेल, आणि आपल्या नावाने संदेश पाठवू शकेल. %1$s + + अधिक जाणून घ्या + + खाते स्थानांतरित करा + + संदेश आणि चॅट्ची माहिती सर्व डिव्हाइसेसवर एन्ड-टू-एन्ड एन्क्रिप्शनने संरक्षित आहेत + + अकाऊंट हस्तांतरण करण्यासाठी अनलॉक करा + + आपल्या दुसऱ्या डिव्हाइसवर सुरू ठेवा + + आपला अकाऊंट आपल्या दुसऱ्या डिव्हाइसवर हस्तांतर करा. + + + पुनर्संचयन पूर्ण + + आपला Signal अकाऊंट आणि संदेश आपल्या दुसऱ्या डिव्हाईसवर हस्तांतरण कराणे सुरु झाले आहे. Signal आता या डिव्हाईसवर निष्क्रिय झाले आहे. + + स्थानांतरण पूर्ण झाले + + आपला Signal अकाऊंट आणि संदेश आपल्या दुसऱ्या डिव्हाईसवर हस्तांतर करा. Signal आता या डिव्हाईसवर निष्क्रिय झाले आहे. + + ठीक आहे + + \ No newline at end of file diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 4a41f502ca..aed6c64b2e 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -1287,20 +1287,6 @@ lebih Tambah penerangan kumpulan… - - - Pindah daripada peranti Android - - Pindahkan akaun dan mesej anda daripada peranti Android lama anda. - - Log masuk tanpa memindahkan - - Teruskan tanpa memindahkan mesej dan media anda - - Pulihkan sandaran setempat - - Pulihkan mesej anda daripada fail sandaran yang disimpan pada peranti anda. - Memuat turun sandaran… @@ -1318,12 +1304,16 @@ Semua mesej anda Pulihkan dari sandaran - + Hanya media yang dihantar atau diterima dalam %1$d hari yang lalu disertakan. Sandaran anda termasuk: Memulihkan sandaran + + Sandaran terakhir anda telah dibuat pada %1$s pada %2$s. + + Mengambil butiran sandaran… Maklumkan saya untuk Sebutan @@ -3364,7 +3354,7 @@ Lain-lain Pembayaran (MobileCoin) Derma & Lencana - Signal Android Backup + Sandaran Signal Android Penyerahan Log Nyahpepijat Signal Android @@ -4193,6 +4183,8 @@ Saya telah menulis frasa laluan tersebut. Saya tidak memulihkan sandaran tanpanya. Memulihkan sandaran Pindahkan atau pulihkan akaun + + Pulihkan atau pindahkan Pindahkan akaun Langkau Sandaran sembang @@ -5004,7 +4996,7 @@ Kumpulan - Only messages from group chats + Hanya mesej daripada sembang kumpulan Tambah @@ -7265,11 +7257,11 @@ Pelan sandaran media Signal anda telah dibatalkan kerana kami tidak dapat memproses pembayaran anda. Ini adalah peluang terakhir anda untuk memuat turun media dalam sandaran anda sebelum ia dipadamkan. - Free up %1$s on this device + Kosongkan %1$s pada peranti ini - To finish downloading your Signal Backup your device needs %1$s of storage space. + Untuk menyelesaikan muat turun Sandaran Signal anda, peranti anda memerlukan %1$s ruang storan. - To free up space offload or delete unused apps or content large in file size. + Untuk mengosongkan ruang, nyahpasang atau padamkan aplikasi yang tidak digunakan atau kandungan yang bersaiz besar. Langganan sandaran anda gagal diperbaharui @@ -7297,7 +7289,7 @@ Bukan sekarang - Try later + Cuba lagi nanti Media akan dipadamkan @@ -7309,9 +7301,9 @@ Langkau - Skip restore? + Langkau pemulihan? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Jika anda langkau pemulihan, baki media dan lampiran dalam sandaran anda akan dipadamkan pada kali seterusnya peranti anda melengkapkan sandaran baharu. @@ -7368,10 +7360,6 @@ Tukar atau batalkan langganan - - - Sandaran terakhir anda telah dibuat pada %1$s pada %2$s. - Had sembang @@ -7683,5 +7671,101 @@ Ikon peringatan + + + Saya mempunyai telefon lama saya + + Imbas kod QR daripada akaun Signal semasa anda untuk bermula dengan cepat + + + Saya tidak mempunyai telefon lama saya + + Atau anda memasang semula Signal pada peranti yang sama + + + Pulihkan atau pindahkan akaun + + Dapatkan akaun Signal dan sejarah mesej anda ke peranti ini. + + Daripada Sandaran Signal + + Pelan Sandaran Signal percuma atau berbayar anda + + Daripada folder sandaran + + Daripada fail sandaran + + Pilih sandaran yang telah anda simpan + + Dari telefon lama anda + + Pindahkan terus dari Android lama anda + + + Pulihkan sandaran setempat + + Pulihkan mesej anda daripada sandaran yang disimpan pada peranti anda. Jika anda tidak memulihkan sekarang, anda tidak akan dapat memulihkan kemudian. + + + Masukkan kunci sandaran anda + + Kunci sandaran anda ialah kod 64 digit yang diperlukan untuk memulihkan akaun dan data anda. + + Tiada kunci sandaran? + + Kunci sandaran + + Sandaran tidak boleh dipulihkan tanpa kod pemulihan 64 digit mereka. Jika anda kehilangan kunci sandaran, Signal tidak dapat membantu memulihkan sandaran anda. + + Jika mempunyai peranti lama anda, anda boleh melihat kunci sandaran dalam Tetapan > Sembang > Sandaran Signal. Kemudian ketik Lihat kunci sandaran. + + Ketahui lebih lanjut + + Langkau dan jangan pulihkan + + + Imbas kod ini dengan telefon lama anda + + Buka Signal pada peranti lama anda + + Ketik ikon kamera + + Imbas kod ini dengan kamera + + Tidak dapat menjana kod QR + + Diimbas pada peranti lama + + Cuba Semula + + + Pindahkan akaun + + Akaun anda akan dipindahkan ke peranti baharu. Peranti ini dapat melihat kumpulan dan kenalan anda, mengakses sembang dan menghantar mesej atas nama anda. %1$s + + Ketahui lebih lanjut + + Pindahkan akaun + + Mesej dan maklumat sembang dilindungi oleh penyulitan hujung ke hujung pada semua peranti + + Buka kunci untuk memindahkan akaun + + Teruskan pada peranti anda yang lain + + Teruskan memindahkan akaun anda pada peranti anda yang lain. + + + Restore selesai + + Akaun Signal dan mesej anda telah mula dipindahkan ke peranti anda yang lain. Signal kini tidak aktif pada peranti ini. + + Pemindahan selesai + + Akaun Signal dan mesej anda telah dipindahkan ke peranti anda yang lain. Signal kini tidak lagi aktif pada peranti ini. + + Okey + + \ No newline at end of file diff --git a/app/src/main/res/values-my/strings.xml b/app/src/main/res/values-my/strings.xml index 168aa39ce0..daa8ca6818 100644 --- a/app/src/main/res/values-my/strings.xml +++ b/app/src/main/res/values-my/strings.xml @@ -1287,20 +1287,6 @@ နောက်ထပ် အဖွဲ့ ဖော်ပြချက် ပေါင်းထည့်ရန်… - - - Android စက်မှ လွှဲပြောင်းမည် - - သင့်အကောင့်နှင့် မက်ဆေ့ချ်များကို သင့် Android စက်ဟောင်းမှ လွှဲပြောင်းပါ။ - - လွှဲပြောင်းခြင်းမရှိဘဲ ဝင်ရောက်ခြင်း - - သင့်မက်ဆေ့ချ်များနှင့် မီဒီယာများကို မလွှဲပြောင်းဘဲ ဆက်လက်လုပ်ဆောင်ပါ - - စက်အတွင်း ဘက်ခ်အပ် ပြုလုပ်ခြင်း - - သင့်စက်ပေါ်တွင် သင်သိမ်းဆည်းထားသော ဘက်ခ်အပ်ဖိုင်တစ်ခုမှ မက်ဆေ့ချ်များကို ပြန်လည်ရယူပါ။ - ဘက်ခ်အပ်များကို ဒေါင်းလုဒ်လုပ်နေသည်… @@ -1318,12 +1304,16 @@ သင်၏ မက်ဆေ့ချ်များအားလုံး ဘက်ခ်အပ်မှ ပြန်လည်ရယူမည် - + လွန်ခဲ့သော %1$d ရက်အတွင်း ပေးပို့ထားသည့် သို့မဟုတ် လက်ခံရရှိသည့် မီဒီယာများသာ ပါဝင်ပါသည်။ သင့် ဘက်ခ်အပ်တွင် အောက်ပါတို့ပါဝင်သည်- ဘက်ခ်အပ်မှ ပြန်လည်ရယူမည် + + သင်၏နောက်ဆုံး ဘက်ခ်အပ်ကို %1$s တွင် %2$s အချိန်က ပြုလုပ်ခဲ့သည်။ + + ဘက်ခ်အပ်အသေးစိတ်ကို ရယူနေသည်… မန်းရှင်းများရှိလျှင် ကျွန်ုပ်ကိုအသိပေးပါ @@ -3364,7 +3354,7 @@ အခြား ငွေပေးချေမှုများ (MobileCoin) လှူဒါန်းမှုများနှင့် ဘဲ့ဂျ်များ - Signal Android Backup + Signal Android ဘက်ခ်အပ် Signal Android ပြစ်ချက်မှတ်တမ်း ပေးပို့ခြင်း @@ -4193,6 +4183,8 @@ စကားဝှက်ကို ရေးမှတ်ပြီးပါပြီ။ စကားဝှက်မပါလျှင် အရံသိမ်းထားသောဖိုင်များကို ပြန်မရနိုင်ပါ။ အရန်သိမ်းထားသည်များကို ပြန်ရယူမယ် အကောင့် လွှဲပြောင်းမယ် သို့ ပြန်လည်ရယူမယ် + + ပြန်ယူရန် သို့မဟုတ် လွှဲပြောင်းရန် အကောင့်လွှဲမယ် ကျော်ပါ ချက်(တ်) အရန်သိမ်းဆည်းမှုများ @@ -5004,7 +4996,7 @@ အဖွဲ့များ - Only messages from group chats + အဖွဲ့လိုက်ချက်(တ်)မှ မက်ဆေ့ချ်များသာ ပေါင်းထည့်မည် @@ -7265,11 +7257,11 @@ သင့်ငွေပေးချေမှုကို ကျွန်ုပ်တို့ မလုပ်ဆောင်နိုင်ခဲ့သောကြောင့် သင်၏ Signal မီဒီယာ ဘက်ခ်အပ်အစီအစဉ်ကို ပယ်ဖျက်လိုက်ပါသည်။ မဖျက်မီ ဘက်ခ်အပ်လုပ်ထားသော မီဒီယာကို ဒေါင်းလုဒ်လုပ်ရန်အတွက် နောက်ဆုံးအခွင့်အရေးဖြစ်သည်။ - Free up %1$s on this device + ဤစက်တွင် နေရာလွတ် %1$s ထွက်လာအောင်ရှင်းထုတ်ပါ - To finish downloading your Signal Backup your device needs %1$s of storage space. + Signal ဘက်ခ်အပ်ကို ဒေါင်းလုဒ်လုပ်ရန် သင့်စက်တွင် သိုလှောင်ရန်နေရာ %1$s လိုအပ်ပါသည်။ - To free up space offload or delete unused apps or content large in file size. + နေရာလွတ် ထွက်လာအောင် အသုံးမပြုသော အက်ပ်များ သို့မဟုတ်အရွယ်အစားကြီးသော ဖိုင်များကို Offload လုပ်ရန် သို့မဟုတ် ဖျက်ရန်။ သင်၏ ဘက်ခ်အပ် ပုံမှန်လှူဒါန်းငွေ သက်တမ်းတိုးမှု မအောင်မြင်ပါ @@ -7297,7 +7289,7 @@ ယခု မလုပ်သေးပါ - Try later + နောက်မှ ကြိုးစားကြည့်ပါ မီဒီယာကို ဖျက်လိုက်ပါမည် @@ -7309,9 +7301,9 @@ ကျော်မည် - Skip restore? + ပြန်လည်ရယူခြင်းကို ကျော်လိုပါသလား။ - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + ပြန်လည်ရယူခြင်းကို ကျော်လိုက်ပါက သင့်စက်တွင် ဘက်ခ်အပ်အသစ်တစ်ကြိမ်ပြုလုပ်ပြီးနောက်၌ သင့် ဘက်ခ်အပ်တွင် ကျန်ရှိသည့်မီဒီယာနှင့် ပူးတွဲဖိုင်များအား ဖျက်လိုက်ပါမည်။ @@ -7368,10 +7360,6 @@ ပုံမှန်လှူဒါန်းငွေကို ပြောင်းရန် သို့မဟုတ် ပယ်ဖျက်ရန် - - - သင်၏နောက်ဆုံး ဘက်ခ်အပ်ကို %1$s တွင် %2$s အချိန်က ပြုလုပ်ခဲ့သည်။ - ချက်(တ်) ကန့်သတ်ချက်များ @@ -7683,5 +7671,101 @@ သတိပေးချက် သင်္ကေတ + + + ကျွန်ုပ်တွင် ဖုန်းအဟောင်းရှိသည် + + လျင်မြန်စွာစတင်ရန် သင့် လက်ရှိ Signal အကောင့်မှ QR ကုဒ်တစ်ခုကို စကင်ဖတ်ပါ + + + ကျွန်ုပ်တွင် ဖုန်းအဟောင်းမရှိပါ + + သို့မဟုတ် သင်သည် ထိုစက်တွင်ပင် Signal ကို ပြန်လည်ထည့်သွင်းနေသည် + + + အကောင့်ကို ပြန်ယူရန် သို့မဟုတ် လွှဲပြောင်းရန် + + သင်၏ Signal အကောင့်နှင့် မက်ဆေ့ချ်မှတ်တမ်းကို ဤစက်ပေါ်တွင် ရယူရန် + + Signal ဘက်ခ်အပ်မှ + + သင်၏ အခမဲ့ သို့မဟုတ် အခပေး Signal ဘက်ခ်အပ်အစီစဉ် + + ဘက်ခ်အပ်ဖိုင်တွဲမှ + + ဘက်ခ်အပ်ဖိုင်မှ + + သင်သိမ်းဆည်းထားသော ဘက်ခ်အပ်ကိုရွေးပါ + + သင့်ဖုန်းအဟောင်းမှ + + သင်၏ Android အဟောင်းမှ တိုက်ရိုက်လွှဲပြောင်းပါ + + + စက်အတွင်း ဘက်ခ်အပ် ပြုလုပ်ခြင်း + + သင့်စက်ပေါ်တွင် သင်သိမ်းဆည်းထားသော ဘက်ခ်အပ်မှ မက်ဆေ့ချ်များကို ပြန်လည်ရယူပါ။ ယခု ပြန်လည်မရယူလျှင် နောက်မှပြန်လည်မရယူနိုင်ပါ။ + + + သင့် ဘက်ခ်အပ်ကီးကို ထည့်ပါ။ + + သင့် ဘက်ခ်အပ်ကီးသည် သင့်အကောင့်နှင့် ဒေတာကို ပြန်လည်ရယူရန်လိုအပ်သော ဂဏန်း 64 လုံးပါ ကုဒ်တစ်ခုဖြစ်သည်။ + + ဘက်ခ်အပ်ကီး မရှိဘူးလား။ + + သင့် ဘက်ခ်အပ်ကီး + + ဂဏန်း 64 လုံးရှိသော ပြန်လည်ရယူရေးကုဒ်မပါဘဲ ဘက်ခ်အပ်များကို ပြန်လည်ရယူ၍မရပါ။ သင့် ဘက်ခ်အပ်ကီး ပျောက်ဆုံးသွားပါက သင်၏ဘက်ခ်အပ်ကို ပြန်လည်ရယူနိုင်တော့မည် မဟုတ်ပါ။ + + သင့်စက်အဟောင်းရှိလျှင် ဘက်ခ်အပ်ကီးကို ဆက်တင် > ချက်(တ်) > Signal ဘက်ခ်အပ် တွင် ကြည့်ရှုနိုင်သည်။ ထို့နောက် ဘက်ခ်အပ်ကီးကိုကြည့်ရန် ကိုနှိပ်ပါ။ + + ပိုမိုလေ့လာရန် + + ကျော်ပြီး ပြန်လည်ရယူခြင်းမပြုပါ + + + သင့်ဖုန်းအဟောင်းဖြင့် ဤကုဒ်ကို စကင်ဖတ်ပါ + + သင့်စက်အဟောင်းတွင် Signal ကိုဖွင့်ပါ + + ကင်မရာအိုင်ကွန်ကို နှိပ်ပါ + + ကင်မရာဖြင့် ဤကုဒ်ကို စကင်ဖတ်ပါ + + QR ကုဒ်ကို ပြုလုပ်၍မရပါ + + စက်အဟောင်းတွင် စကင်ဖတ်ထားသည် + + ထပ်ကြိုးစားပါ + + + အကောင့်လွှဲပြောင်းမည် + + သင့်အကောင့်ကို စက်အသစ်တစ်ခုသို့ လွှဲပြောင်းပေးမည်ဖြစ်သည်။ ဤစက်သည် သင့်အဖွဲ့များနှင့် အဆက်အသွယ်များကို မြင်နိုင်မည်ဖြစ်ပြီး၊ သင့်ချက်(တ်)များကို ဝင်ရောက်ကြည့်ရှုကာ သင့်အမည်ဖြင့် မက်ဆေ့ချ်များ ပေးပို့နိုင်မည်ဖြစ်သည်။ %1$s + + ပိုမိုလေ့လာရန် + + အကောင့်လွှဲပြောင်းမည် + + စက်အားလုံးရှိ မက်ဆေ့ချ်များနှင့် ချက်(တ်)အချက်အလက်ကို ဟိုဘက်သည်ဘက် ကုဒ်ပြောင်းဝှက်ခြင်းဖြင့် ကာကွယ်ထားသည် + + အကောင့်လွှဲပြောင်းရန် လော့ခ်ဖွင့်ပါ + + သင့် အခြားစက်ပေါ်တွင် ဆက်လုပ်ရန် + + သင့်အကောင့်ကို သင့်အခြားစက်ပေါ်တွင် ဆက်လက်လွှဲပြောင်းပါ။ + + + ပြန်လည်ရယူခြင်း ပြီးစီးပါပြီ + + သင်၏ Signal အကောင့်နှင့် မက်ဆေ့ချ်များကို သင့်အခြားစက်သို့ စတင်လွှဲပြောင်းနေပြီဖြစ်သည်။ Signal သည် ဤစက်တွင် အလုပ်မလုပ်တော့ပါ။ + + လွှဲပြောင်းမှု ပြီးဆုံးပြီ + + သင်၏ Signal အကောင့်နှင့် မက်ဆေ့ချ်များကို သင့်အခြားစက်သို့ လွှဲပြောင်းပြီးပါပြီ။ Signal သည် ဤစက်တွင် အလုပ်မလုပ်တော့ပါ။ + + အိုကေ + + \ No newline at end of file diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index f58993767e..39857039c2 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -1325,20 +1325,6 @@ mer Legg til gruppebeskrivelse… - - - Overfør fra Android-enhet - - Overfør kontoen og meldingene fra den gamle Android-enheten din. - - Logg inn uten å overføre - - Fortsett uten å overføre meldingene og mediefilene dine - - Gjenopprett lokal sikkerhetskopi - - Gjenopprett meldingene dine fra en sikkerhetskopi lagret lokalt på enheten. - Laster ned sikkerhetskopi … @@ -1356,12 +1342,16 @@ alle meldingene dine Gjenopprett fra sikkerhetskopi - + Dette inkluderer kun mediefiler du har sendt eller mottatt de siste %1$d dagene. Sikkerhetskopien inneholder: Gjenopprett sikkerhetskopi + + Den siste sikkerhetskopieringen ble gjort kl. %2$s den %1$s. + + Henter informasjon om sikkerhetskopien … Varsle meg om omtaler @@ -3463,7 +3453,7 @@ Annet Betalinger (MobileCoin) Pengebeløp og merker - Signal Android Backup + Sikkerhetskopiering i Signal for Android Innsending av feilsøkingslogg for Signal på Android @@ -4304,6 +4294,8 @@ Jeg har notert passordfrasen. Uten denne blir det umulig å bruke sikkerhetskopien til gjenoppretting. Gjenopprett fra sikkerhetskopi Overfør eller gjenopprett konto + + Gjenopprett eller overfør Overfør konto Hopp over Sikkerhetskopi av samtaler @@ -5124,7 +5116,7 @@ Gruppesamtaler - Only messages from group chats + Kun meldinger fra gruppesamtaler Legg til @@ -6687,7 +6679,7 @@ Trykk på «Gå til innstillinger» nedenfor - Slå på «Tillat innstillingsalarmer og -påminnelser» + Slå på «Gi tillatelse til å sette alarmer og påminnelser». Gå til innstillinger @@ -7426,11 +7418,11 @@ Abonnementet ditt på sikkerhetskopiering av mediefiler i Signal er kansellert fordi vi ikke kunne behandle betalingen din. Dette er din siste sjanse til å laste ned de sikkerhetskopierte mediefilene før de slettes. - Free up %1$s on this device + Du trenger %1$s mer lagringsplass - To finish downloading your Signal Backup your device needs %1$s of storage space. + Enheten trenger %1$s mer lagringsplass for å kunne laste ned hele sikkerhetskopien fra Signal. - To free up space offload or delete unused apps or content large in file size. + Det kan være lurt å slette eller avinstallere ubrukte apper og store filer for å få mer plass. Abonnementet på sikkerhetskopiering kunne ikke fornyes @@ -7458,7 +7450,7 @@ Ikke nå - Try later + Prøv senere Mediefilene slettes @@ -7470,9 +7462,9 @@ Hopp over - Skip restore? + Vil du hoppe over gjenoppretting? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Hvis du velger å hoppe over, vil resten av mediefilene og vedleggene i sikkerhetskopien din slettes neste gang enheten sikkerhetskopieres. @@ -7529,10 +7521,6 @@ Endre eller si opp abonnement - - - Den siste sikkerhetskopieringen ble gjort kl. %2$s den %1$s. - Meldingsgrense i samtaler @@ -7850,5 +7838,101 @@ Påminnelsesikon + + + Jeg har den gamle enheten + + Du kan skanne en QR-kode fra Signal-kontoen din for å komme kjapt i gang + + + Jeg har ikke den gamle enheten + + Eller du skal installere Signal på nytt på samme enhet + + + Gjenopprette eller overføre en konto + + Få tilgang til Signal-kontoen og meldingsloggen din på denne enheten. + + Fra Signal-sikkerhetskopi + + Fra abonnementet ditt på sikkerhetskopiering fra Signal + + Fra en nedlastet sikkerhetskopi + + Fra en sikkerhetskopi + + Velg en fil du har lagret tidligere + + Fra den gamle enheten + + Overfør direkte fra den gamle Android-enheten din + + + Gjenopprett lokal sikkerhetskopi + + Gjenopprett meldingene dine fra sikkerhetskopien som ble lagret på enheten. Du får ikke mulighet til å gjøre dette senere. + + + Skriv inn sikkerhetskoden + + Sikkerhetskoden består av 64 tall og kreves for å gjenopprette kontoen og dataene dine. + + Mangler du koden? + + Sikkerhetskode + + Det er ikke mulig å gjenopprette sikkerhetskopier uten den 64-sifrede koden. Dersom du mister den, kan ikke Signal hjelpe deg med å gjenopprette sikkerhetskopien. + + Hvis du har den gamle enheten din, finner du sikkerhetskoden ved å gå til Innstillinger > Samtaler > Sikkerhetskopiering av Signal. Der trykker du på «Se sikkerhetskode». + + Les mer + + Hopp over gjenoppretting + + + Skann denne QR-koden med den gamle enheten + + Åpne Signal på den gamle enheten. + + Trykk på kamera-ikonet. + + Skann koden med kameraet. + + QR-koden kunne ikke genereres + + Skannet på annen enhet + + Prøv på nytt + + + Overfør konto + + Dette innebærer at kontoen din overføres til en annen enhet. Enheten får tilgang til gruppene, kontaktene og samtalene dine og kan sende meldinger i ditt navn. %1$s + + Les mer + + Overfør konto + + Meldinger og samtaleinnhold beskyttes av ende-til-ende-kryptering på alle enheter + + Lås opp for å overføre kontoen + + Fortsett på den andre enheten + + Fortsett kontooverføringen på den andre enheten. + + + Gjenoppretting er utført + + Signal-kontoen og meldingene dine overføres til den andre enheten. Signal er nå deaktivert på denne enheten. + + Overføring fullført + + Signal-kontoen og meldingene dine ble overført til den andre enheten. Signal er nå deaktivert på denne enheten. + + Greit + + \ No newline at end of file diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 0df8b5dea0..0e6af7ae3b 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1325,20 +1325,6 @@ meer Voeg een groepsomschrijving toe… - - - Overzetten vanaf een Android-apparaat - - Zet je account en berichten over van je oude iOS-apparaat. - - Inloggen zonder overzetten - - Ga door zonder je berichten en media over te zetten - - Lokale back-upgegevens terugzetten - - Herstel je berichten vanaf een back-upbestand dat je op je apparaat hebt opgeslagen. - Back-up aan het downloaden… @@ -1356,12 +1342,16 @@ Al je berichten Gegevens vanuit een back-up terugzetten - + Uitsluitend media die in de afgelopen %1$d dagen zijn verzonden of ontvangen, zijn inbegrepen. Je back-up omvat: Back-upgegevens terugzetten + + De laatste back-up is gemaakt op %1$s om %2$s. + + Back-upgegevens aan het ophalen… Meldingen bij vermeldingen @@ -3457,13 +3447,13 @@ Selecteer een optie Er werkt iets niet goed - Verzoek voor nieuwe functie + Verzoek nieuwe functie Vraag Feedback Anders Betalingen (MobileCoin) Donaties & badges - Signal Android Backup + Signal Android back-up Signal Android-foutopsporingslog ingediend @@ -3611,7 +3601,7 @@ Annuleren - Wijzig het app-pictogram en de naam in %1$s + Wijzig het app-pictogram en de naam in ’%1$s’ Molly moet worden gesloten om het app-pictogram en de naam te wijzigen. Meldingen geven altijd het standaard Molly-pictogram en de standaard app-naam weer. @@ -4304,6 +4294,8 @@ Ik heb dit wachtwoord opgeschreven. Zonder dit wachtwoord kan ik back-ups niet gebruiken om gegevens terug te zetten. Back-upgegevens terugzetten Account overzetten of herstellen + + Herstellen of overzetten Account overzetten Overslaan Back-up van chats @@ -5124,7 +5116,7 @@ Groepen - Only messages from group chats + Alleen berichten van groepschats Toevoegen @@ -7076,7 +7068,7 @@ Begin door een contact te bellen. - Oproeplinks die je hebt gemaakt, zullen niet langer werken voor personen die ze al hebben ontvangen. + Oproeplinks die jij hebt gemaakt, zullen niet langer werken voor personen die ze al hebben ontvangen. @@ -7426,11 +7418,11 @@ Je mediaback-upabonnement in Signal is stopgezet omdat we je betaling niet konden verwerken. Dit is de laatste mogelijkheid om de media in je back-up te downloaden voordat deze worden verwijderd. - Free up %1$s on this device + Maak %1$s vrij op dit apparaat - To finish downloading your Signal Backup your device needs %1$s of storage space. + Om het downloaden van je Signal back-up te voltooien, heeft je apparaat %1$s opslagruimte nodig. - To free up space offload or delete unused apps or content large in file size. + Om ruimte vrij te maken, kun je ongebruikte apps of grote bestanden verwijderen. Je back-upabonnement kon niet worden verlengd @@ -7458,7 +7450,7 @@ Niet nu - Try later + Later proberen Media worden verwijderd @@ -7470,9 +7462,9 @@ Overslaan - Skip restore? + Herstel overslaan? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Als je herstellen overslaat, worden de resterende media en bijlagen in je back-up verwijderd de volgende keer dat je apparaat een back-up voltooit. @@ -7529,10 +7521,6 @@ Abonnement wijzigen of opzeggen - - - De laatste back-up is gemaakt op %1$s om %2$s. - Chatlimieten @@ -7850,5 +7838,101 @@ Herinneringspictogram + + + Ik heb mijn oude telefoon + + Scan een QR-code met je oude telefoon om snel aan de slag te gaan + + + Ik heb mijn oude telefoon niet + + Of je installeert Signal opnieuw op hetzelfde apparaat + + + Account herstellen of overzetten + + Zet je Signal-account en chatgeschiedenis op dit apparaat. + + Vanaf Signal back-ups + + Je gratis of betaalde Signal backup-abonnement + + Vanaf een back-upmap + + Vanaf een back-upbestand + + Selecteer een back-up die je hebt opgeslagen + + Vanaf je oude telefoon + + Zet rechtstreeks over vanaf je oude Android-apparaat + + + Lokale back-upgegevens terugzetten + + Herstel je berichten vanaf de back-up die je op je apparaat hebt opgeslagen. Als je nu niet herstelt, is dat op een later moment niet meer mogelijk. + + + Voer je back-upsleutel in + + Je back-upsleutel is een 64-cijferige code die je nodig hebt om je account en gegevens te herstellen. + + Geen back-upsleutel? + + Back-upsleutel + + Back-ups kunnen niet worden hersteld zonder de 64-cijferige herstelcode. Als je de back-upsleutel kwijt bent, kan Signal je niet helpen je back-up te herstellen. + + Op je oude apparaat kun je de back-upsleutel bekijken in Instellingen > Chats > Signal back-ups. Tik vervolgens op Back-upsleutel weergeven. + + Meer lezen + + Overslaan en niet herstellen + + + Scan deze code met je oude telefoon + + Open Signal op je oude apparaat + + Tik op het camerapictogram + + Scan deze code met de camera + + Kan geen QR-code genereren + + Gescand op oud apparaat + + Opnieuw proberen + + + Account overzetten + + Je account wordt overgezet naar een nieuw apparaat. Dit apparaat zal je groepen en contacten kunnen inzien, toegang krijgen tot je chats en berichten uit jouw naam kunnen versturen. %1$s + + Meer lezen + + Account overzetten + + Berichten en chatgegevens worden beschermd door end-to-end-versleuteling op al je apparaten + + Ontgrendel om account over te zetten + + Doorgaan op je andere apparaat + + Doorgaan met het overzetten van je account op je andere apparaat. + + + Herstellen is voltooid + + Je Signal-account en berichten worden overgezet naar je andere apparaat. Signal is nu inactief op dit apparaat. + + Overzetten voltooid + + Je Signal-account en berichten zijn overgezet naar je andere apparaat. Signal is nu inactief op dit apparaat. + + Oké + + \ No newline at end of file diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index b859cc6726..3a7de01d47 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -1325,20 +1325,6 @@ ਹੋਰ ਗਰੁੱਪ ਦੀ ਜਾਣਕਾਰੀ ਜੋੜੋ… - - - Android ਡਿਵਾਈਸ ਤੋਂ ਟ੍ਰਾਂਸਫਰ ਕਰੋ - - ਆਪਣੇ ਪੁਰਾਣੇ Android ਡਿਵਾਈਸ ਤੋਂ ਆਪਣਾ ਖਾਤਾ ਅਤੇ ਸੁਨੇਹੇ ਟ੍ਰਾਂਸਫਰ ਕਰੋ। - - ਟ੍ਰਾਂਸਫਰ ਕੀਤੇ ਬਿਨਾਂ ਲੌਗ ਇਨ ਕਰੋ - - ਆਪਣੇ ਸੁਨੇਹੇ ਅਤੇ ਮੀਡੀਆ ਟ੍ਰਾਂਸਫਰ ਕੀਤੇ ਬਿਨਾਂ ਜਾਰੀ ਰੱਖੋ - - ਸਥਾਨਕ ਬੈਕਅੱਪ ਰੀਸਟੋਰ ਕਰੋ - - ਇੱਕ ਬੈਕਅੱਪ ਫ਼ਾਈਲ ਤੋਂ ਆਪਣੇ ਸੁਨੇਹਿਆਂ ਨੂੰ ਰੀਸਟੋਰ ਕਰੋ ਜੋ ਤੁਸੀਂ ਆਪਣੇ ਡਿਵਾਈਸ \'ਤੇ ਸੁਰੱਖਿਅਤ ਕੀਤੀ ਸੀ। - ਬੈਕਅੱਪ ਡਾਊਨਲੋਡ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ… @@ -1356,12 +1342,16 @@ ਤੁਹਾਡੇ ਸਾਰੇ ਸੁਨੇਹੇ ਬੈਕਅੱਪ ਤੋਂ ਰੀਸਟੋਰ ਕਰੋ - + ਸਿਰਫ਼ ਪਿਛਲੇ %1$d ਦਿਨਾਂ ਵਿੱਚ ਭੇਜਿਆ ਜਾਂ ਪ੍ਰਾਪਤ ਹੋਇਆ ਮੀਡੀਆ ਸ਼ਾਮਲ ਹੈ। ਤੁਹਾਡੇ ਬੈਕਅੱਪ ਵਿੱਚ ਸ਼ਾਮਲ ਹੈ: ਬੈਕਅੱਪ ਰੀਸਟੋਰ ਕਰੋ + + ਤੁਹਾਡਾ ਆਖਰੀ ਬੈਕਅੱਪ %1$s ਨੂੰ %2$s ਲਿਆ ਗਿਆ ਸੀ। + + ਬੈਕਅੱਪ ਦੇ ਵੇਰਵੇ ਪ੍ਰਾਪਤ ਕੀਤੇ ਜਾ ਰਹੇ ਹਨ… ਹਵਾਲਿਆਂ ਲਈ ਮੈਨੂੰ ਸੂਚਿਤ ਕਰੋ @@ -3460,10 +3450,10 @@ ਫੀਚਰ ਲਈ ਬੇਨਤੀ ਸਵਾਲ ਫੀਡਬੈਕ - ਹੋਰ + ਕੁਝ ਹੋਰ ਭੁਗਤਾਨ (MobileCoin) ਦਾਨ ਅਤੇ ਬੈਜ - Signal Android Backup + Signal Android ਬੈਕਅੱਪ Signal Android ਡੀਬੱਗ ਲੌਗ ਸਪੁਰਦਗੀ @@ -4304,6 +4294,8 @@ ਮੈਂ ਇਹ ਪਾਸਫ਼੍ਰੇਜ਼ ਲਿੱਖ ਲਿਆ ਹੈ. ਇਸ ਤੋਂ ਬਿਨਾਂ, ਮੈਂ ਬੈਕਅੱਪ ਨੂੰ ਰੀਸਟੋਰ ਕਰਨ ਵਿੱਚ ਅਸਮਰੱਥ ਹੋਵਾਂਗਾ. ਬੈਕਅਪ ਬਹਾਲ ਕਰੋ ਖਾਤਾ ਟ੍ਰਾਂਸਫਰ ਕਰੋ ਜਾਂ ਬਹਾਲ ਕਰੋ + + ਰੀਸਟੋਰ ਜਾਂ ਟ੍ਰਾਂਸਫਰ ਕਰੋ ਖਾਤਾ ਟ੍ਰਾਂਸਫਰ ਕਰੋ ਛੱਡੋ ਚੈਟ ਬੈਕਅਪ @@ -5124,7 +5116,7 @@ ਗਰੁੱਪ - Only messages from group chats + ਸਿਰਫ਼ ਗਰੁੱਪ ਚੈਟ ਦੇ ਸੁਨੇਹੇ ਸ਼ਾਮਲ ਕਰੋ @@ -7426,11 +7418,11 @@ ਤੁਹਾਡਾ Signal ਮੀਡੀਆ ਬੈਕਅੱਪ ਪਲਾਨ ਰੱਦ ਕਰ ਦਿੱਤਾ ਗਿਆ ਹੈ ਕਿਉਂਕਿ ਅਸੀਂ ਤੁਹਾਡੇ ਭੁਗਤਾਨ ਉੱਤੇ ਕਾਰਵਾਈ ਨਹੀਂ ਕਰ ਸਕੇ। ਤੁਹਾਡੇ ਬੈਕਅੱਪ ਵਿੱਚ ਮੌਜੂਦ ਮੀਡੀਆ ਮਿਟਾਉਣ ਤੋਂ ਪਹਿਲਾਂ ਇਸਨੂੰ ਡਾਊਨਲੋਡ ਕਰਨ ਦਾ ਇਹ ਤੁਹਾਡਾ ਕੋਲ ਆਖਰੀ ਮੌਕਾ ਹੈ। - Free up %1$s on this device + ਇਸ ਡਿਵਾਈਸ \'ਤੇ %1$s ਖਾਲੀ ਕਰੋ - To finish downloading your Signal Backup your device needs %1$s of storage space. + ਤੁਹਾਡੇ Signal ਬੈਕਅੱਪ ਨੂੰ ਡਾਊਨਲੋਡ ਕਰਨਾ ਪੂਰਾ ਕਰਨ ਲਈ ਤੁਹਾਡੇ ਡਿਵਾਈਸ ਵਿੱਚ %1$s ਸਟੋਰੇਜ ਥਾਂ ਲੋੜੀਂਦੀ ਹੈ। - To free up space offload or delete unused apps or content large in file size. + ਥਾਂ ਖਾਲੀ ਕਰਨ ਲਈ, ਵਰਤੀਆਂ ਨਾ ਜਾਣ ਵਾਲੀਆਂ ਐਪਾਂ ਜਾਂ ਵੱਡੇ ਆਕਾਰ ਦੀ ਸਮੱਗਰੀ ਨੂੰ ਔਫਲੋਡ ਕਰੋ ਜਾਂ ਮਿਟਾਓ। ਤੁਹਾਡੀ ਬੈਕਅੱਪ ਸਬਸਕ੍ਰਿਪਸ਼ਨ ਨੂੰ ਰੀਨਿਊ ਕਰਨਾ ਅਸਫਲ ਰਿਹਾ @@ -7458,7 +7450,7 @@ ਹਾਲੇ ਨਹੀਂ - Try later + ਬਾਅਦ ਵਿੱਚ ਕੋਸ਼ਿਸ਼ ਕਰੋ ਮੀਡੀਆ ਮਿਟਾ ਦਿੱਤਾ ਜਾਵੇਗਾ @@ -7470,9 +7462,9 @@ ਛੱਡੋ - Skip restore? + ਕੀ ਰੀਸਟੋਰ ਕਰਨਾ ਛੱਡਣਾ ਹੈ? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + ਜੇਕਰ ਤੁਸੀਂ ਰੀਸਟੋਰ ਕਰਨਾ ਛੱਡ ਦਿੰਦੇ ਹੋ, ਤਾਂ ਅਗਲੀ ਵਾਰ ਜਦੋਂ ਤੁਹਾਡਾ ਡਿਵਾਈਸ ਇੱਕ ਨਵਾਂ ਬੈਕਅੱਪ ਪੂਰਾ ਕਰੇਗਾ ਤਾਂ ਤੁਹਾਡੇ ਬੈਕਅੱਪ ਵਿੱਚ ਬਾਕੀ ਬਚੇ ਮੀਡੀਆ ਅਤੇ ਅਟੈਚਮੈਂਟ ਨੂੰ ਮਿਟਾ ਦਿੱਤਾ ਜਾਵੇਗਾ। @@ -7529,10 +7521,6 @@ ਸਬਸਕ੍ਰਿਪਸ਼ਨ ਨੂੰ ਬਦਲੋ ਜਾਂ ਰੱਦ ਕਰੋ - - - ਤੁਹਾਡਾ ਆਖਰੀ ਬੈਕਅੱਪ %1$s ਨੂੰ %2$s ਲਿਆ ਗਿਆ ਸੀ। - ਚੈਟ ਦੀਆਂ ਸੀਮਾਵਾਂ @@ -7850,5 +7838,101 @@ ਰੀਮਾਈਂਡਰ ਆਈਕਨ + + + ਮੇਰੇ ਕੋਲ ਮੇਰਾ ਪੁਰਾਣਾ ਫ਼ੋਨ ਹੈ + + ਫਟਾਫਟ ਸ਼ੁਰੂ ਕਰਨ ਲਈ ਆਪਣੇ ਮੌਜੂਦਾ Signal ਖਾਤੇ ਤੋਂ QR ਕੋਡ ਸਕੈਨ ਕਰੋ + + + ਮੇਰੇ ਕੋਲ ਮੇਰਾ ਪੁਰਾਣਾ ਫ਼ੋਨ ਨਹੀਂ ਹੈ + + ਜਾਂ ਤੁਸੀਂ ਉਸੇ ਡਿਵਾਈਸ \'ਤੇ Signal ਨੂੰ ਮੁੜ ਇੰਸਟਾਲ ਕਰ ਰਹੇ ਹੋ + + + ਖਾਤਾ ਰੀਸਟੋਰ ਕਰੋ ਜਾਂ ਟ੍ਰਾਂਸਫਰ ਕਰੋ + + ਇਸ ਡਿਵਾਈਸ \'ਤੇ ਆਪਣਾ Signal ਖਾਤਾ ਅਤੇ ਪੁਰਾਣੇ ਸੁਨੇਹੇ ਪ੍ਰਾਪਤ ਕਰੋ। + + Signal ਬੈਕਅੱਪ ਤੋਂ + + ਤੁਹਾਡੀ ਮੁਫ਼ਤ ਜਾਂ ਭੁਗਤਾਨਸ਼ੁਦਾ Signal ਬੈਕਅੱਪ ਪਲਾਨ + + ਬੈਕਅੱਪ ਫੋਲਡਰ ਤੋਂ + + ਬੈਕਅੱਪ ਫ਼ਾਈਲ ਤੋਂ + + ਆਪਣੇ ਸੇਵ ਕੀਤੇ ਬੈਕਅੱਪ ਨੂੰ ਚੁਣੋ + + ਆਪਣੇ ਪੁਰਾਣੇ ਫ਼ੋਨ ਤੋਂ + + ਸਿੱਧਾ ਆਪਣੇ ਪੁਰਾਣੇ Android ਡਿਵਾਈਸ ਤੋਂ ਟ੍ਰਾਂਸਫਰ ਕਰੋ + + + ਲੋਕਲ ਬੈਕਅੱਪ ਰੀਸਟੋਰ ਕਰੋ + + ਆਪਣੇ ਡਿਵਾਈਸ \'ਤੇ ਸੇਵ ਕੀਤੇ ਬੈਕਅੱਪ ਵਿੱਚੋਂ ਆਪਣੇ ਸੁਨੇਹਿਆਂ ਨੂੰ ਰੀਸਟੋਰ ਕਰੋ। ਜੇਕਰ ਤੁਸੀਂ ਹੁਣੇ ਰੀਸਟੋਰ ਨਹੀਂ ਕਰਦੇ ਹੋ, ਤਾਂ ਤੁਸੀਂ ਬਾਅਦ ਵਿੱਚ ਰੀਸਟੋਰ ਨਹੀਂ ਕਰ ਸਕੋਗੇ। + + + ਆਪਣੀ ਬੈਕਅੱਪ ਕੁੰਜੀ ਦਰਜ ਕਰੋ + + ਤੁਹਾਡੀ ਬੈਕਅੱਪ ਕੁੰਜੀ ਤੁਹਾਡੇ ਖਾਤੇ ਅਤੇ ਡਾਟਾ ਨੂੰ ਰਿਕਵਰ ਕਰਨ ਲਈ ਲੋੜੀਂਦਾ 64-ਅੰਕਾਂ ਵਾਲਾ ਕੋਡ ਹੈ। + + ਤੁਹਾਡੇ ਕੋਲ ਬੈਕਅੱਪ ਕੁੰਜੀ ਨਹੀਂ ਹੈ? + + ਬੈਕਅੱਪ ਕੁੰਜੀ + + ਉਹਨਾਂ ਦੇ 64-ਅੰਕਾਂ ਦੇ ਰਿਕਵਰੀ ਕੋਡ ਤੋਂ ਬਿਨਾਂ ਬੈਕਅੱਪ ਰਿਕਵਰ ਨਹੀਂ ਕੀਤੇ ਜਾ ਸਕਦੇ ਹਨ। ਜੇਕਰ ਤੁਸੀਂ ਆਪਣੀ ਬੈਕਅੱਪ ਕੁੰਜੀ ਗੁਆ ਦਿੱਤੀ ਹੈ ਤਾਂ Signal ਤੁਹਾਡੇ ਬੈਕਅੱਪ ਨੂੰ ਰੀਸਟੋਰ ਕਰਨ ਵਿੱਚ ਮਦਦ ਨਹੀਂ ਕਰ ਸਕਦਾ। + + ਜੇਕਰ ਤੁਹਾਡੇ ਕੋਲ ਤੁਹਾਡਾ ਪੁਰਾਣੀ ਡਿਵਾਈਸ ਹੈ ਤਾਂ ਤੁਸੀਂ ਸੈਟਿੰਗਾਂ > ਚੈਟ > Signal ਬੈਕਅੱਪ ਵਿੱਚ ਆਪਣੀ ਬੈਕਅੱਪ ਕੁੰਜੀ ਦੇਖ ਸਕਦੇ ਹੋ। ਫਿਰ ਬੈਕਅੱਪ ਕੁੰਜੀ ਦੇਖੋ \'ਤੇ ਟੈਪ ਕਰੋ। + + ਹੋਰ ਜਾਣੋ + + ਛੱਡੋ ਅਤੇ ਰੀਸਟੋਰ ਨਾ ਕਰੋ + + + ਆਪਣੇ ਪੁਰਾਣੇ ਫ਼ੋਨ ਨਾਲ ਇਸ ਕੋਡ ਨੂੰ ਸਕੈਨ ਕਰੋ + + ਆਪਣੇ ਪੁਰਾਣੇ ਡਿਵਾਈਸ \'ਤੇ Signal ਖੋਲ੍ਹੋ + + ਕੈਮਰਾ ਆਈਕਨ \'ਤੇ ਟੈਪ ਕਰੋ + + ਕੈਮਰੇ ਨਾਲ ਇਸ ਕੋਡ ਨੂੰ ਸਕੈਨ ਕਰੋ + + QR ਕੋਡ ਬਣਾਉਣ ਵਿੱਚ ਅਸਮਰੱਥ ਰਹੇ + + ਪੁਰਾਣੇ ਡਿਵਾਈਸ \'ਤੇ ਸਕੈਨ ਕੀਤਾ ਗਿਆ + + ਮੁੜ-ਕੋਸ਼ਿਸ਼ ਕਰੋ + + + ਖਾਤਾ ਟ੍ਰਾਂਸਫਰ ਕਰੋ + + ਤੁਹਾਡਾ ਖਾਤਾ ਇੱਕ ਨਵੇਂ ਡਿਵਾਈਸ \'ਤੇ ਟ੍ਰਾਂਸਫਰ ਕੀਤਾ ਜਾਵੇਗਾ। ਇਸ ਡਿਵਾਈਸ \'ਤੇ ਤੁਹਾਡੇ ਗਰੁੱਪਾਂ ਅਤੇ ਸੰਪਰਕਾਂ ਨੂੰ ਦੇਖਿਆ ਜਾ ਸਕਦਾ ਹੈ, ਤੁਹਾਡੀਆਂ ਚੈਟਾਂ ਤੱਕ ਪਹੁੰਚ ਕੀਤੀ ਜਾ ਸਕਦੀ ਹੈ ਅਤੇ ਤੁਹਾਡੇ ਨਾਮ ਨਾਲ ਸੁਨੇਹੇ ਭੇਜੇ ਜਾ ਸਕਦੇ ਹਨ। %1$s + + ਹੋਰ ਜਾਣੋ + + ਖਾਤਾ ਟ੍ਰਾਂਸਫਰ ਕਰੋ + + ਸੁਨੇਹੇ ਅਤੇ ਚੈਟ ਦੀ ਜਾਣਕਾਰੀ ਸਾਰੇ ਡਿਵਾਈਸਾਂ ਉੱਤੇ ਸਿਰੇ-ਤੋਂ-ਸਿਰੇ ਤੱਕ ਇਨਕ੍ਰਿਪਸ਼ਨ ਨਾਲ ਸੁਰੱਖਿਅਤ ਹੁੰਦੀ ਹੈ + + ਖਾਤਾ ਟ੍ਰਾਂਸਫਰ ਕਰਨ ਲਈ ਅਨਲੌਕ ਕਰੋ + + ਆਪਣੇ ਦੂਜੇ ਡਿਵਾਈਸ \'ਤੇ ਜਾਰੀ ਰੱਖੋ + + ਆਪਣੇ ਦੂਜੇ ਡਿਵਾਈਸ \'ਤੇ ਆਪਣਾ ਖਾਤਾ ਟ੍ਰਾਂਸਫਰ ਕਰਨਾ ਜਾਰੀ ਰੱਖੋ। + + + ਰੀਸਟੋਰ ਕਰਨ ਦਾ ਕਾਰਜ ਪੂਰਾ ਹੋਇਆ + + ਤੁਹਾਡੇ Signal ਖਾਤੇ ਅਤੇ ਸੁਨੇਹਿਆਂ ਨੂੰ ਤੁਹਾਡੇ ਦੂਜੇ ਡਿਵਾਈਸ \'ਤੇ ਟ੍ਰਾਂਸਫਰ ਕਰਨਾ ਸ਼ੁਰੂ ਕਰ ਦਿੱਤਾ ਗਿਆ ਹੈ। Signal ਹੁਣ ਇਸ ਡਿਵਾਈਸ \'ਤੇ ਅਕਿਰਿਆਸ਼ੀਲ ਹੈ। + + ਟ੍ਰਾਂਸਫਰ ਪੂਰਾ ਹੋਇਆ + + ਤੁਹਾਡੇ Signal ਖਾਤੇ ਅਤੇ ਸੁਨੇਹਿਆਂ ਨੂੰ ਤੁਹਾਡੇ ਦੂਜੇ ਡਿਵਾਈਸ \'ਤੇ ਟ੍ਰਾਂਸਫਰ ਕਰ ਦਿੱਤਾ ਗਿਆ ਹੈ। Signal ਹੁਣ ਇਸ ਡਿਵਾਈਸ \'ਤੇ ਅਕਿਰਿਆਸ਼ੀਲ ਹੈ। + + ਠੀਕ ਹੈ + + \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 33fcbc9170..c91f812d03 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -1401,20 +1401,6 @@ więcej Dodaj opis grupy… - - - Przenieś z urządzenia z systemem Android - - Przenieś swoje konto i wiadomości ze starego urządzenia z systemem Android. - - Zaloguj się bez przenoszenia - - Kontynuuj bez przenoszenia wiadomości i multimediów - - Przywróć lokalną kopię zapasową - - Przywróć wiadomości z kopii zapasowej zapisanej na urządzeniu. - Pobieranie kopii zapasowej… @@ -1432,12 +1418,16 @@ Wszystkie wiadomości Przywróć z kopii zapasowej - + Uwzględniane są tylko multimedia wysłane lub odebrane w ciągu ostatnich %1$d dni. Kopia zapasowa obejmuje: Przywróć kopię zapasową + + Ostatnia kopia zapasowa została utworzona w dniu %1$s o godz. %2$s. + + Pobieranie szczegółów kopii zapasowej… Powiadom mnie o wzmiankach @@ -3658,10 +3648,10 @@ Prośba o dodanie funkcji Pytanie Opinia - Inny + Inne Płatności (MobileCoin) Darowizny i odznaki - Signal Android Backup + Kopia zapasowa Signal na Androida Przesłanie raportu debugowania Signal Android @@ -4526,6 +4516,8 @@ Zapisałem(am) to hasło. Bez tego nie będę mógł(a) przywrócić kopii zapasowej. Przywróć kopię zapasową Przenieś lub przywróć konto + + Przywróć lub przenieś Przenieś konto Pomiń Kopia zapasowa czatów @@ -5199,7 +5191,7 @@ Odnów subskrypcję usługi kopii zapasowych Signal - Nie udało się ukończyć tworzenia kopii zapasowej + Nie udało się utworzyć kopii zapasowej Zaproś znajomych @@ -5364,7 +5356,7 @@ Grupy - Only messages from group chats + Tylko wiadomości z czatów grupowych Dodaj @@ -6991,7 +6983,7 @@ Stuknij poniższy przycisk „Przejdź do ustawień” - Włącz opcję „Zezwalaj na alarmy i przypomnienia” + Włącz opcję „Zezwalaj na ustawianie alarmów i przypomnień”. Przejdź do ustawień @@ -7748,11 +7740,11 @@ Usługa tworzenia kopii zapasowych multimediów w Signal została wyłączona, ponieważ nie mogliśmy przetworzyć Twojej płatności. To ostatnia szansa na pobranie multimediów z kopii zapasowej, zanim zostaną usunięte. - Free up %1$s on this device + Zwolnij %1$s na tym urządzeniu - To finish downloading your Signal Backup your device needs %1$s of storage space. + Aby ukończyć pobieranie kopii zapasowej Signal, potrzebujesz %1$s miejsca na dysku urządzenia. - To free up space offload or delete unused apps or content large in file size. + Aby zwolnić miejsce, usuń nieużywane aplikacje lub duże pliki. Nie udało się odnowić usługi kopii zapasowych @@ -7766,7 +7758,7 @@ Pomiń przywracanie - Archiwizuj teraz + Utwórz kopię zapasową teraz Rozumiem @@ -7780,7 +7772,7 @@ Nie teraz - Try later + Spróbuj później Multimedia zostaną usunięte @@ -7792,9 +7784,9 @@ Pomiń - Skip restore? + Pominąć przywracanie? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Jeśli pominiesz przywracanie, przy następnym tworzeniu kopii zapasowej przez Twoje urządzenie zostaną z niej usunięte multimedia i załączniki. @@ -7851,10 +7843,6 @@ Zmień lub anuluj subskrypcję - - - Ostatnia kopia zapasowa została utworzona w dniu %1$s o godz. %2$s. - Limity wiadomości @@ -8184,5 +8172,101 @@ Ikona przypomnienia + + + Mam swój stary telefon + + Zeskanuj kod QR ze swojego obecnego konta, aby szybko zacząć korzystać z aplikacji Signal + + + Nie mam swojego starego telefonu + + Wybierz tę opcję również wtedy, gdy ponownie instalujesz Signal na tym samym urządzeniu + + + Przywróć lub przenieś konto + + Zyskaj dostęp do swojego konta Signal i historii wiadomości na tym urządzeniu. + + Z kopii zapasowej Signal + + Czyli bezpłatnej lub płatnej usługi kopii zapasowych Signal + + Z folderu kopii zapasowej + + Z pliku kopii zapasowej + + Wybierz kopię zapasową zapisaną przez siebie + + Ze starego telefonu + + Przenieś bezpośrednio ze swojego starego urządzenia z Androidem + + + Przywróć lokalną kopię zapasową + + Przywróć wiadomości z kopii zapasowej zapisanej na urządzeniu. Jeśli nie przywrócisz ich teraz, nie będzie można tego zrobić później. + + + Wprowadź kod kopii zapasowej + + Kod kopii zapasowej to sekwencja 64 znaków potrzebna do przywrócenia konta i danych. + + Nie masz kodu kopii zapasowej? + + Kod kopii zapasowej + + Bez kodu składającego się z 64 znaków nie odzyskasz swojej kopii zapasowej. Jeśli nie pamiętasz kodu kopii zapasowej, nie będziemy w stanie przywrócić kopii zapasowej Twojego konta Signal. + + Jeśli masz dostęp do swojego starego urządzenia, kod kopii zapasowej znajdziesz w sekcji Ustawienia > Czaty > Kopie zapasowe Signal. Następnie wybierz opcję Wyświetl kod kopii zapasowej. + + Dowiedz się więcej + + Pomiń przywracanie + + + Zeskanuj ten kod swoim starym telefonem + + Otwórz Signal na swoim starym urządzeniu + + Wybierz ikonę aparatu + + Zeskanuj ten kod aparatem + + Nie udało się wygenerować kodu QR + + Zeskanowano na starym urządzeniu + + Ponów + + + Przenieś konto + + Twoje konto zostanie przeniesione na nowe urządzenie, które otrzyma dostęp do Twoich grup, kontaktów i czatów oraz będzie mogło wysyłać wiadomości w Twoim imieniu. %1$s + + Dowiedz się więcej + + Przenieś konto + + Wiadomości i informacje o czacie są zabezpieczone szyfrowaniem end-to-end na wszystkich urządzeniach + + Odblokuj, aby przenieść konto + + Kontynuuj na drugim urządzeniu + + Aby kontynuować przenoszenie konta, użyj drugiego urządzenia. + + + Przywracanie zakończone + + Rozpoczęto przenoszenie konta Signal oraz wiadomości na drugie urządzenie. Na tym urządzeniu Signal jest już nieaktywny. + + Przenoszenie zakończone + + Udało się przenieść Twoje konto Signal oraz wiadomości na drugie urządzenie. Na tym urządzeniu Signal jest już nieaktywny. + + OK + + \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 8f887cf5be..147362e9f0 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -45,8 +45,8 @@ O Molly está atualizando… - Você ainda não definiu uma frase-chave! - Desabilitar frase-chave? + Você ainda não definiu uma frase de recuperação! + Desabilitar frase de recuperação? Isto irá destravar permanentemente as notificações do Molly e de mensagens. Desabilitar Erro ao conectar-se com o servidor! @@ -871,14 +871,14 @@ Backup de chats - Os backups são criptografados com uma frase-chave e armazenados no seu dispositivo. + Os backups são criptografados com uma frase de recuperação e armazenados no seu dispositivo. Criar backup Último backup: %1$s Pasta do backup Horário do backup - Verificar frase-chave de backup - Teste sua frase-chave de backup e verifique se ela corresponde + Verificar frase de recuperação de backup + Teste sua frase de recuperação de backup e verifique se ela corresponde Ativar Desativar "Para restaurar um backup, faça uma nova instalação do Molly. Abra o aplicativo e toque no \"Restaurar o backup\", depois localize um arquivo de backup. %1$s" @@ -1325,20 +1325,6 @@ mais Adicionar descrição do grupo… - - - Transferir de aparelho Android - - Transfira sua conta e mensagens do seu dispositivo Android antigo. - - Fazer login sem transferir - - Continue sem transferir suas mensagens e arquivos de mídia - - Restaurar backup local - - Restaure suas mensagens de um arquivo de backup salvo no dispositivo. - Baixando backup… @@ -1356,12 +1342,16 @@ Todas as mensagens Restaurar do backup - + Somente arquivos de mídia enviados ou recebidos nos últimos %1$d dias serão incluídos. Seu backup inclui: Restaurar backup + + Seu último backup foi feito em %1$s à(s) %2$s. + + Buscando detalhes do backup… Notifique-me ao ser mencionado @@ -2001,8 +1991,8 @@ Frases-chave não correspondem! - Frase-chave anterior incorreta! - Insira uma nova frase-chave! + Frase de recuperação anterior incorreta! + Insira uma nova frase de recuperação! Vincular este dispositivo? @@ -2036,10 +2026,10 @@ - Inserir frase-chave + Inserir frase de recuperação Ícone do Molly - Enviar frase-chave - Frase-chave inválida! + Enviar frase de recuperação + Frase de recuperação inválida! Destrancar Molly Molly Android - Tela de bloqueio @@ -3023,9 +3013,9 @@ Recusar chamada - Frase-chave antiga - Nova frase-chave - Repita a nova frase-chave + Frase de recuperação antiga + Nova frase de recuperação + Repita a nova frase de recuperação Convidar para o Molly @@ -3422,9 +3412,9 @@ Ver histórico de edições - Criar frase-chave + Criar frase de recuperação Selecionar contatos - Alterar frase-chave + Alterar frase de recuperação Verificar número de segurança Pré-visualização de mídia Detalhes da mensagem @@ -3463,7 +3453,7 @@ Outro Pagamentos (MobileCoin) Doações e selos - Signal Android Backup + Backup do Signal para Android Envio de log de depuração do Signal para Android @@ -3536,14 +3526,14 @@ Chats silenciados que estão arquivados permanecerão arquivados quando uma nova mensagem chegar. Gerar pré-visualização de links Carregar pré-visualizações de links diretamente dos sites em mensagens que você envia. - Alterar frase-chave - Alterar sua frase-chave + Alterar frase de recuperação + Alterar sua frase de recuperação - Habilitar o bloqueio da tela com frase-chave - Bloquear tela e notificações com frase-chave + Habilitar o bloqueio da tela com frase de recuperação + Bloquear tela e notificações com frase de recuperação Segurança da tela Trancar automaticamente o Signal após um determinado período de inatividade - Frase-chave de expiração por inatividade + Frase de recuperação após período de inatividade Período de expiração por inatividade Notificações Cor do LED @@ -4297,25 +4287,27 @@ Continuar Agora não Migrando a base de dados do Signal - Frase-chave de backup - Os backups serão salvos no armazenamento externo e criptografados com a frase-chave abaixo. Você precisa desta frase-chave para restaurar um backup. - Você deve ter esta frase-chave para restaurar um backup. + Frase de recuperação de backup + Os backups serão salvos no armazenamento externo e criptografados com a frase de recuperação abaixo. Você precisa desta frase de recuperação para restaurar um backup. + Você deve ter esta frase de recuperação para restaurar um backup. Pasta - Eu anotei esta frase-chave. Sem ela eu não conseguirei restaurar um backup. + Eu anotei esta frase de recuperação. Sem ela eu não conseguirei restaurar um backup. Restaurar backup Transferir ou restaurar conta + + Restaurar ou transferir Transferir conta Ignorar Backups de chat Transferir conta Transferir conta para um novo dispositivo Android - Digite a frase-chave de backup + Digite a frase de recuperação de backup Restaurar Não é possível importar backups de versões recentes O backup contém dados incorretos - Frase-chave de backup incorreta + Frase de recuperação de backup incorreta Checando… %1$d mensagens até agora… Restaurar do backup? @@ -4332,9 +4324,9 @@ Escolher pasta Copiado para a área de transferência Nenhum seletor de arquivos disponível. - Digite sua frase-chave de backup para verificação + Digite sua frase de recuperação de backup para verificação Verificar - Você digitou com sucesso sua frase-chave de backup + Você digitou com sucesso sua frase de recuperação de backup A senha está incorreta Criando backup do Molly… @@ -5124,7 +5116,7 @@ Grupos - Only messages from group chats + Somente mensagens de conversas em grupo Adicionar @@ -6687,7 +6679,7 @@ Toque no botão \"Acessar configurações\" abaixo - Toque em \"Permitir alarmes e lembretes\". + Toque em \"Permitir configurações de alarmes e lembretes\". Acessar configurações @@ -7426,11 +7418,11 @@ Seu plano de backup de mídia do Signal foi cancelado porque o pagamento não pôde ser processado. Essa é sua última chance de baixar os arquivos de mídia do backup antes que eles sejam excluídos. - Free up %1$s on this device + Libere %1$s neste dispositivo - To finish downloading your Signal Backup your device needs %1$s of storage space. + Para concluir o download do Signal Backup, seu dispositivo precisa de %1$s de espaço de armazenamento. - To free up space offload or delete unused apps or content large in file size. + Para liberar espaço, descarregue ou exclua aplicativos não utilizados ou arquivos pesados. Não foi possível renovar seu plano de assinatura de backups. @@ -7458,7 +7450,7 @@ Agora não - Try later + Tente mais tarde Seus arquivos de mídia serão excluídos @@ -7470,9 +7462,9 @@ Pular - Skip restore? + Pular restauração? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Se você pular a restauração, os arquivos de mídia e anexos no seu backup serão excluídos na próxima vez que seu dispositivo completar um novo backup. @@ -7529,10 +7521,6 @@ Alterar ou cancelar assinatura - - - Seu último backup foi feito em %1$s à(s) %2$s. - Limites de histórico de mensagens @@ -7850,5 +7838,101 @@ Ícone de lembrete + + + Eu tenho meu telefone antigo + + Escaneie um QR code da sua conta atual do Signal para começar rapidamente + + + Não tenho meu telefone antigo + + Ou se você está reinstalando o Signal no mesmo dispositivo. + + + Restaurar ou transferir conta + + Baixe sua conta e histórico de mensagens do Signal neste dispositivo. + + Dos backups do Signal + + Seu plano de backup do Signal grátis ou pago + + De uma pasta de backup + + De um arquivo de backup + + Escolha um backup que você salvou + + Do seu telefone antigo + + Transfira diretamente do seu Android antigo + + + Restaurar backup local + + Restaure suas mensagens de um backup salvo no seu dispositivo. Se não restaurar agora, você não poderá fazer isso mais tarde. + + + Digite sua chave de backup + + Sua chave de backup é um código de 64 dígitos que você deve usar para recuperar sua conta e dados. + + Não tem uma chave de backup? + + Chave de backup + + Os backups não podem ser recuperados sem o código de recuperação de 64 dígitos. Se perder sua chave de backup, você não poderá restaurar seu backup pelo Signal. + + Se tiver seu dispositivo antigo com você, poderá ver sua chave de backup em Configurações > Conversas > Backups do Signal. Em seguida, é só tocar em Ver chave de backup. + + Saiba mais + + Ignorar e não restaurar + + + Escaneie esse código com seu telefone antigo + + Abra o Signal no seu dispositivo Android antigo + + Toque no ícone da câmera + + Escaneie esse código com a câmera + + Não foi possível gerar o QR code + + Escaneado no seu dispositivo antigo + + Tente novamente + + + Transferir conta + + Sua conta será transferida para outro dispositivo, onde você poderá ver grupos e contatos, acessar conversas e enviar mensagens em seu nome. %1$s + + Saiba mais + + Transferir conta + + As mensagens e informações das conversas são protegidas por criptografia de ponta a ponta em todos os dispositivos + + Desbloqueie para transferir a conta + + Continuar em outro aparelho + + Continue transferindo sua conta em outro dispositivo. + + + Restauração concluída + + Sua conta e mensagens do Signal começaram a ser transferidas para outro aparelho. O Signal agora está inativo neste dispositivo. + + Transferência concluída + + Sua conta e mensagens do Signal foram transferidas para outro aparelho. O Signal agora está inativo neste dispositivo. + + Ok + + \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 5ababea1b6..832f28bf80 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1325,20 +1325,6 @@ mais Adicionar descrição do grupo… - - - Transferir de um dispositivo Android - - Transfira a sua conta e mensagens do seu dispositivo Android antigo. - - Iniciar sessão sem transferir - - Continuar sem transferir as suas mensagens ou ficheiros - - Restaurar cópia de segurança local - - Restaure as suas mensagens a partir de um ficheiro de cópia de segurança guardado no seu dispositivo. - A descarregar cópia de segurança… @@ -1356,12 +1342,16 @@ Todas as suas mensagens Restaurar a partir da cópia de segurança - + Só estão incluídos ficheiros multimédia enviados ou recebidos nos últimos %1$d. A sua cópia de segurança inclui: Restaurar cópia de segurança + + A sua última cópia de segurança foi feita a %1$s às %2$s. + + A obter detalhes da cópia de segurança… Notificar-me quando for \'Mencionado\' @@ -3463,7 +3453,7 @@ Outro Pagamentos (MobileCoin) Doações e crachás - Signal Android Backup + Cópia de segurança do Signal Android Envio do relatório de depuração Signal Android @@ -4304,6 +4294,8 @@ Eu anotei esta frase-chave. Sem ela, serei incapaz de restaurar uma cópia de segurança. Restaurar cópia de segurança Transferir ou restaurar conta + + Restaurar ou transferir Transferir conta Saltar Cópias de segurança dos chats @@ -5124,7 +5116,7 @@ Grupos - Only messages from group chats + Só mensagens de chats de grupo Adicionar @@ -7426,11 +7418,11 @@ O seu plano de cópia de segurança de ficheiros do Signal foi cancelado porque não foi possível processar o seu pagamento. Esta é a sua última oportunidade de transferir os ficheiros multimédia da sua cópia de segurança antes de serem eliminados. - Free up %1$s on this device + Liberte até %1$s neste dispositivo - To finish downloading your Signal Backup your device needs %1$s of storage space. + Para terminar de descarregar a sua cópia de segurança do Signal, o seu dispositivo precisa de %1$s de espaço de armazenamento. - To free up space offload or delete unused apps or content large in file size. + Para libertar espaço elimine apps não utilizadas ou conteúdos com um tamanho de ficheiro elevado. A renovação da sua subscrição de cópias de segurança falhou @@ -7458,7 +7450,7 @@ Agora não - Try later + Mais tarde Os ficheiros serão eliminados @@ -7470,9 +7462,9 @@ Saltar - Skip restore? + Ignorar restauro? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Se ignorar o restauro, os restantes ficheiros multimédia e anexos da cópia de segurança serão eliminados da próxima vez que o dispositivo efetuar uma nova cópia de segurança. @@ -7529,10 +7521,6 @@ Altere ou cancele a subscrição - - - A sua última cópia de segurança foi feita a %1$s às %2$s. - Limites do chat @@ -7850,5 +7838,101 @@ Ícone de lembrete + + + Tenho o meu telemóvel antigo + + Digitalize um código QR da sua conta Signal atual para começar rapidamente + + + Não tenho o meu telemóvel antigo + + Ou está a reinstalar o Signal no mesmo dispositivo + + + Restaure ou transfira a sua conta + + Coloque a sua conta Signal e o seu histórico de mensagens neste dispositivo. + + A partir de cópias de segurança do Signal + + O seu plano grátis ou pago de cópias de segurança do Signal + + A partir de uma pasta de cópias de segurança + + De um ficheiro de cópia de segurança + + Escolha uma cópia de segurança que tenha guardado + + A partir do seu telemóvel antigo + + Transfira diretamente a partir do seu velho Android + + + Restaurar cópia de segurança local + + Restaure as suas mensagens a partir da cópia de segurança que guardou no seu dispositivo. Se não restaurar agora, não poderá restaurar mais tarde. + + + Introduza a sua chave da cópia de segurança + + A sua chave da cópia de segurança é um código de 64 dígitos necessário para recuperar a sua conta e os seus dados. + + Não tem chave da cópia de segurança? + + Chave da cópia de segurança + + As cópias de segurança não podem ser recuperadas sem o seu código de recuperação de 64 dígitos. Se tiver perdido a sua chave da cópia de segurança, o Signal não pode ajudar a restaurar a sua cópia de segurança. + + Se tiver acesso ao seu dispositivo antigo, pode ver a sua chave da cópia de segurança em Definições > Chats > Cópias de segurança do Signal. Depois, toque em \"Ver chave da cópia de segurança\". + + Saber mais + + Ignorar e não restaurar + + + Digitalize este código com o seu telemóvel antigo + + Abra o Signal no seu dispositivo antigo + + Toque no ícone da câmara + + Digitalize este código com a câmara + + Não é possível gerar o código QR + + Digitalizado no dispositivo antigo + + Tentar novamente + + + Transferir conta + + A sua conta será transferida para um novo dispositivo. Este dispositivo poderá ver os seus grupos e contactos, aceder aos seus chats e enviar mensagens em seu nome. %1$s + + Saber mais + + Transferir conta + + As informações das mensagens e chats estão protegidas por encriptação de ponta a ponta em todos os dispositivos + + Desbloqueie para transferir conta + + Continue no seu outro dispositivo + + Continue a transferir a sua conta no seu outro dispositivo. + + + Restauro completo + + A sua conta e mensagens Signal começaram a ser transferidas para o seu outro dispositivo. O Signal está agora inativo neste dispositivo. + + Transferência completa + + A sua conta e mensagens Signal começaram a ser transferidas para o seu outro dispositivo. O Signal está agora inativo neste dispositivo. + + Ok + + \ No newline at end of file diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 96496e8bc1..88fbb5b491 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -1363,20 +1363,6 @@ mai multe Adaugă descrierea grupului… - - - Transfer de pe un dispozitiv Android - - Transferă-ți contul și mesajele de pe vechiul tău dispozitiv Android. - - Conectează-te fără transfer - - Continuă fără a transfera mesajele și fișierele tale media - - Restaurează backup-ul local - - Restaurează-ți mesajele dintr-un fișier de rezervă salvat pe dispozitiv. - Se descarcă copia de rezervă… @@ -1394,12 +1380,16 @@ Toate mesajele tale Restaurare din backup - + Sunt incluse numai fișierele trimise sau primite în ultimele %1$d de zile. Backup-ul tău include: Restaurare backup + + Ultima copie de rezervă a fost făcută pe %1$s la %2$s. + + Se preiau detaliile backup-ului… Anunță-mă pentru Mențiuni @@ -3562,7 +3552,7 @@ Altul Plăți (MobileCoin) Donații și Insigne - Signal Android Backup + Backup Signal Android Trimiterea jurnalului de depanare Signal Android @@ -4415,6 +4405,8 @@ Am scris parola. Fără ea nu voi putea restaura un backup. Restaurare backup Transfer sau restabilire cont + + Restaurează sau transferă Transfer cont Omite Backup-uri pt. conversații @@ -5244,7 +5236,7 @@ Grupuri - Only messages from group chats + Numai mesajele din conversațiile de grup Adaugă @@ -7587,11 +7579,11 @@ Planul tău de backup Signal media a fost anulat deoarece nu am putut procesa plata. Este ultima ta șansă de a descărca fișierele media din copia de rezervă înainte să fie șterse. - Free up %1$s on this device + Eliberează %1$s pe acest dispozitiv - To finish downloading your Signal Backup your device needs %1$s of storage space. + Ca să termini descărcarea Signal Backup, dispozitivul tău are nevoie de %1$s spațiu de stocare. - To free up space offload or delete unused apps or content large in file size. + Ca să eliberezi spațiu, descarcă sau elimină aplicațiile neutilizate sau conținutul de dimensiuni mari ale fișierului. Abonamentul tău pentru backup nu s-a reînnoit @@ -7619,7 +7611,7 @@ Nu acum - Try later + Încearcă mai târziu Fișierele media vor fi șterse @@ -7631,9 +7623,9 @@ Omite - Skip restore? + Sari peste restaurare? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Dacă omiți restaurarea, fișierele media rămase și atașamentele din backup vor fi eliminate data viitoare când dispozitivul finalizează o nouă copie de rezervă. @@ -7690,10 +7682,6 @@ Modifică sau anulează abonamentul - - - Ultima copie de rezervă a fost făcută pe %1$s la %2$s. - Limite conversații @@ -8017,5 +8005,101 @@ Pictograma de reminder + + + Am vechiul meu telefon + + Scanează un cod QR din contul tău Signal curent pentru a începe rapid + + + Nu am telefonul meu vechi + + Sau reinstalează Signal pe același dispozitiv + + + Restaurează sau transferă contul + + Obține contul Signal și istoricul mesajelor pe acest dispozitiv. + + Din Backup-uri Signal + + Planul tău gratuit sau cu plată pentru Signal Backup + + Dintr-un folder de rezervă + + Dintr-un fișier de rezervă + + Alege un backup pe care l-ai salvat + + De pe telefonul tău vechi + + Transferă direct de pe vechiul tău Android + + + Restaurează backup-ul local + + Restaurează-ți mesajele din backup-ul pe care l-ai salvat pe dispozitiv. Dacă nu restaurezi acum, nu vei putea restabili mai târziu. + + + Introdu codul de rezervă + + Codul de rezervă este un cod format din 64 de cifre necesar pentru a-ți recupera contul și datele. + + Nu ai cod de rezervă? + + Codul de rezervă + + Copiile de rezervă nu pot fi recuperate fără codul lor de recuperare din 64 de cifre. Dacă ți-ai pierdut codul de rezervă, Signal nu te poate ajuta să-ți restabilești backup-ul. + + Dacă ai vechiul dispozitiv, poți vizualiza codul de rezervă în Setări > Conversații > Backup-uri Signal. Apoi atinge Vezi codul de rezervă. + + Află mai multe + + Sari peste și nu restaura + + + Scanează acest cod cu telefonul tău vechi + + Deschide Signal pe vechiul dispozitiv + + Atinge pictograma camerei + + Scanează acest cod cu camera + + Nu se poate genera codul QR + + Scanat pe un dispozitiv vechi + + Încearcă din nou + + + Transfer cont + + Contul tău va fi transferat pe un dispozitiv nou. Acest dispozitiv va putea să-ți vadă grupurile și contactele, să-ți acceseze conversațiile și să trimită mesaje în numele tău. %1$s + + Află mai multe + + Transfer cont + + Informațiile privind conversațiile și mesajele sunt protejate prin criptare end-to-end pe toate dispozitivele + + Deblochează pentru a transfera contul + + Continuă pe celălalt dispozitiv + + Continuă să-ți transferi contul pe celălalt dispozitiv. + + + Restaurarea a fost finalizată + + Contul tău Signal și mesajele au început să se transfere pe celălalt dispozitiv. Signal este acum inactiv pe acest dispozitiv. + + Transfer finalizat + + Contul tău Signal și mesajele au fost transferate pe celălalt dispozitiv. Signal este acum inactiv pe acest dispozitiv. + + Okay + + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 7c3aa17d78..3cc838ccc7 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1401,20 +1401,6 @@ далее Добавить описание группы… - - - Перенести с Android-устройства - - Перенесите свою учётную запись и сообщения с вашего старого устройства на Android. - - Авторизоваться без переноса - - Продолжить без переноса ваших сообщений и медиафайлов - - Восстановить локальную резервную копию - - Восстановите свои сообщения из файла резервной копии, сохранённого на вашем устройстве. - Загружаем резервную копию… @@ -1432,12 +1418,16 @@ Все ваши сообщения Восстановить из резервной копии - + Учитываются только медиафайлы, отправленные или полученные за последние %1$d дней. Резервное копирование включает: Восстановить резервную копию + + Последняя резервная копия была создана %1$s в %2$s. + + Получение сведений о резервном копировании… Уведомлять меня об упоминаниях @@ -3661,7 +3651,7 @@ Другие Платежи (MobileCoin) Пожертвования; Значки - Signal Android Backup + Резервное копирование на Signal Android Отправка журнала отладки Signal Android @@ -4526,6 +4516,8 @@ Я записал(-а) эту парольную фразу. Без неё я не смогу восстановить резервную копию. Восстановить резервную копию Перенести или восстановить учётную запись + + Восстановить или перенести Перенести учётную запись Пропустить Резервные копии чатов @@ -5364,7 +5356,7 @@ Группы - Only messages from group chats + Только сообщения из групповых чатов Добавить @@ -6991,7 +6983,7 @@ Нажмите кнопку «Перейти к настройкам». - Включите в настройках «Разрешить будильники и напоминания». + Включите «Разрешить настройку будильников и напоминаний». Перейти в настройки @@ -7748,11 +7740,11 @@ Ваш план резервного копирования медиафайлов Signal был отменён, так как мы не смогли обработать ваш платёж. Это ваш последний шанс загрузить медиафайлы из резервной копии, прежде чем они будут удалены. - Free up %1$s on this device + Освободите %1$s на этом устройстве - To finish downloading your Signal Backup your device needs %1$s of storage space. + Для завершения загрузки файлов резервного копирования Signal устройству требуется %1$s место на диске. - To free up space offload or delete unused apps or content large in file size. + Чтобы освободить место, выгрузите или удалите неиспользуемые приложения или контент, занимающий большой объём. Не удалось продлить подписку на резервное копирование @@ -7780,7 +7772,7 @@ Не сейчас - Try later + Попробуйте позже Медиафайлы будут удалены @@ -7792,9 +7784,9 @@ Пропустить - Skip restore? + Пропустить восстановление? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Если вы пропустите восстановление, оставшиеся медиафайлы и вложения из резервной копии будут удалены, когда ваше устройство снова выполнит резервное копирование. @@ -7851,10 +7843,6 @@ Изменить или отменить подписку - - - Последняя резервная копия была создана %1$s в %2$s. - Лимит длины чата @@ -8184,5 +8172,101 @@ Значок напоминания + + + У меня есть мой старый телефон + + Отсканируйте QR-код из текущей учётной записи Signal, чтобы быстро начать использование + + + У меня нет старого телефона + + Либо вы повторно устанавливаете Signal на том же устройстве + + + Восстановить или перенести учётную запись + + Перенесите свою учётную запись Signal и историю сообщений на это устройство. + + Из Резервного копирования Signal + + Ваш бесплатный или платный план Резервного копирования Signal + + Из папки резервных копий + + Из файла резервной копии + + Выберите сохранённую резервную копию + + Со старого телефона + + Перенесите прямо со старого телефона Android + + + Восстановить локальную резервную копию + + Восстановите сообщения из резервной копии, сохранённой на этом устройстве. Если вы не сделаете этого сейчас, вы не сможете восстановить их позже. + + + Введите резервный ключ + + Резервный ключ представляет собой 64-значный код, необходимый для восстановления вашей учётной записи и данных. + + Нет резервного ключа? + + Резервный ключ + + Резервные копии не могут быть восстановлены без 64-значного кода восстановления. Если вы потеряли свой резервный ключ, Signal не сможет помочь восстановить резервную копию. + + Если у вас есть старое устройство, вы можете просмотреть свой резервный ключ в Настройках > Чаты > Резервное копирование Signal. Затем нажмите «Просмотреть резервный ключ» + + Узнать больше + + Пропустить и не восстанавливать + + + Отсканируйте этот код с помощью старого телефона + + Откройте Signal на старом устройстве + + Нажмите на значок камеры + + Отсканируйте этот код с помощью камеры + + Не удаётся сгенерировать QR-код + + Отсканировано на старом устройстве + + Повторить попытку + + + Перенести учётную запись + + Ваша учётная запись будет перенесена на новое устройство. Это устройство сможет видеть ваши группы и контакты, получит доступ к вашим чатам и сможет отправлять сообщения от вашего имени. %1$s + + Узнать больше + + Перенести учётную запись + + Сообщения и информация о чате защищены сквозным шифрованием на всех устройствах + + Разблокировать для переноса учётной записи + + Продолжить на другом устройстве + + Продолжите перенос учётной записи на другом устройстве. + + + Восстановление завершено + + Ваша учётная запись Signal и сообщения переносятся на другое устройство. Signal теперь неактивен на этом устройстве. + + Перенос завершён + + Ваша учётная запись Signal и сообщения были перенесены на другое устройство. Signal теперь неактивен на этом устройстве. + + Хорошо + + \ No newline at end of file diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 351583feb5..7998f05ec4 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -1401,20 +1401,6 @@ viac Pridať popis skupiny… - - - Presunúť z Android zariadenia - - Preneste si účet a správy z vášho starého Android zariadenia. - - Prihlásiť sa bez prenosu - - Pokračovať bez prenosu správ a médií - - Obnoviť lokálnu zálohu - - Obnovte svoje správy zo záložného súboru, ktorý ste si uložili do svojho zariadenia. - Sťahuje sa záloha… @@ -1432,12 +1418,16 @@ Všetky vaše správy Obnoviť zo zálohy - + Zahrnuté sú iba médiá odoslané alebo prijaté za posledných %1$d dní. Vaša záloha zahŕňa: Obnoviť zálohu + + Vaša posledná záloha bola vytvorená %1$s o %2$s. + + Načítavajú sa podrobnosti o zálohe… Upozorni ma na Spomenutia @@ -3661,7 +3651,7 @@ Iné Platby (MobileCoin) Príspevky a Odznaky - Signal Android Backup + Záloha Signal Android Odoslanie denníka ladenia Signal Android @@ -4526,6 +4516,8 @@ Heslo som si zapísal/a, bez hesla nebudem schopný/á obnoviť zálohy. Obnoviť zálohu Preniesť alebo obnoviť účet + + Obnoviť alebo preniesť Preniesť účet Preskočiť Zálohy četov @@ -5364,7 +5356,7 @@ Skupiny - Only messages from group chats + Iba správy zo skupinových četov Pridať @@ -6991,7 +6983,7 @@ Ťuknite na tlačidlo „Prejsť na nastavenia“ nižšie - Zapnite možnosť „Povoliť alarmy a pripomenutia.“ + Zapnite možnosť „Povoliť nastavovanie budíkov a pripomenutí.“ Prejsť na nastavenia @@ -7748,11 +7740,11 @@ Váš plán zálohovania médií Signal bol zrušený, pretože sa nám nepodarilo spracovať vašu platbu. Máte poslednú šancu stiahnuť si médiá zo zálohy pred jej vymazaním. - Free up %1$s on this device + Uvoľnite na tomto zariadení %1$s - To finish downloading your Signal Backup your device needs %1$s of storage space. + Na dokončenie sťahovania Zálohy Signal potrebuje vaše zariadenie %1$s úložného priestoru. - To free up space offload or delete unused apps or content large in file size. + Ak chcete uvoľniť miesto, odstráňte nepoužívané aplikácie alebo obsiahle súbory. Vaše predplatné záloh sa nepodarilo obnoviť @@ -7780,7 +7772,7 @@ Teraz nie - Try later + Skúsiť neskôr Médiá budú vymazané @@ -7792,9 +7784,9 @@ Preskočiť - Skip restore? + Preskočiť obnovenie? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Ak preskočíte obnovenie, zostávajúce médiá a prílohy vo vašej zálohe sa vymažú po tom, ako vo vašom zariadení najbližšie prebehne nové zálohovanie. @@ -7851,10 +7843,6 @@ Zmeniť alebo zrušiť predplatné - - - Vaša posledná záloha bola vytvorená %1$s o %2$s. - Obmedzenie počtu správ v čete @@ -8184,5 +8172,101 @@ Ikona pripomienky + + + Mám svoj starý telefón + + Pre rýchle nastavenie naskenujte QR kód zo svojho aktuálneho Signal účtu + + + Nemám svoj starý telefón + + Alebo znova inštalujete Signal na rovnakom zariadení + + + Obnoviť alebo preniesť účet + + Preneste svoj Signal účet a históriu správ do tohto zariadenia. + + Zo Záloh Signal + + Váš bezplatný alebo platený plán Záloh Signal + + Zo záložného priečinka + + Zo záložného súboru + + Vyberte zálohu, ktorú ste si uložili + + Z vášho starého telefónu + + Vykonajte prenos priamo zo svojho starého Android zariadenia + + + Obnoviť lokálnu zálohu + + Obnovte svoje správy zo zálohy, ktorú ste si uložili na zariadení. Ak nevykonáte obnovu teraz, neskôr to nebude možné. + + + Zadajte záložný kľúč + + Váš záložný kľúč je 64-miestny kód potrebný na obnovenie vášho účtu a údajov. + + Nemáte záložný kľúč? + + Záložný kľúč + + Zálohy nie je možné obnoviť bez ich 64-miestneho kódu na obnovenie. V prípade straty záložného kľúča vám Signal nemôže pomôcť obnoviť zálohu. + + Ak máte svoje staré zariadenie, záložný kľúč si môžete pozrieť v časti Nastavenia > Čety > Zálohy Signal. Potom ťuknite na Zobraziť záložný kľúč. + + Zistiť viac + + Preskočiť a neobnoviť + + + Naskenujte tento kód pomocou svojho starého telefónu + + Otvorte Signal na svojom starom zariadení + + Ťuknite na ikonu fotoaparátu + + Naskenujte tento kód pomocou fotoaparátu + + Nepodarilo sa vygenerovať QR kód + + Naskenované na starom zariadení + + Skúsiť znovu + + + Preniesť účet + + Váš účet bude prenesený do nového zariadenia. Toto zariadenie bude vidieť vaše skupiny a kontakty, bude mať prístup k vašim četom a bude môcť odosielať správy vo vašom mene. %1$s + + Zistiť viac + + Preniesť účet + + Správy a informácie o četoch sú na všetkých zariadeniach chránené end-to-end šifrovaním + + Odomknite na prenos účtu + + Pokračujte na druhom zariadení + + Pokračujte v prenose účtu na svojom druhom zariadení. + + + Obnova dokončená + + Váš Signal účet a správy sa začali prenášať do vášho druhého zariadenia. Signal je teraz na tomto zariadení neaktívny. + + Prenos dokončený + + Váš Signal účet a správy boli prenesené do vášho druhého zariadenia. Signal je teraz na tomto zariadení neaktívny. + + OK + + \ No newline at end of file diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 89c7fb9494..6056c2538d 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -1401,20 +1401,6 @@ več Dodaj opis skupine … - - - Prenos iz druge naprave Android - - Prenesite račun in sporočila iz stare naprave Android. - - Prijava brez prenosa - - Nadaljujte brez prenosa sporočil in medijev - - Obnovitev lokalne varnostne kopije - - Obnovite sporočila iz varnostne kopije, ki ste jo shranili v napravo. - Prenos varnostne kopije … @@ -1432,12 +1418,16 @@ Vsa vaša sporočila Obnovi iz varnostne kopije - + Vključeni so samo mediji, poslani ali prejeti v zadnjih %1$d dneh. Vaša varnostna kopija vključuje: Obnovi iz varnostne kopije + + Vaša zadnja varnostna kopija je bila izdelana dne %1$s ob %2$s. + + Pridobivanje podatkov o varnostni kopiji … Obveščaj me o omembah @@ -3655,13 +3645,13 @@ Izberite možnost Nekaj ne deluje - Predlog za novo funkcijo + Prošnja za funkcijo Vprašanje - Odziv + Povratne informacije Drugo Plačila (MobileCoin) Donacije in značke - Signal Android Backup + Varnostna kopija za Signal Android Signalova sistemska zabeležba v sistemu Android @@ -4526,6 +4516,8 @@ Zapisal sem si geslo. Brez njega ne bom mogel obnoviti varnostne kopije. Obnovi iz varnostne kopije Prenesi ali obnovi račun + + Obnovi ali prenesi Prenos računa Preskoči Varnostno kopiranje klepetov @@ -5364,7 +5356,7 @@ Skupine - Only messages from group chats + Samo sporočila iz skupinskih klepetov Dodaj @@ -7748,11 +7740,11 @@ Vaš načrt varnostnega kopiranja medijev Signal je bil preklican, ker nismo mogli obdelati vašega plačila. To je zadnja priložnost, da prenesete medije iz varnostne kopije, preden bodo izbrisani. - Free up %1$s on this device + Sprostite %1$s v tej napravi - To finish downloading your Signal Backup your device needs %1$s of storage space. + Za dokončanje prenosa varnostne kopije Signal naprava potrebuje %1$s prostora za shranjevanje. - To free up space offload or delete unused apps or content large in file size. + Če želite sprostiti prostor, razbremenite ali izbrišite neuporabljene aplikacije ali vsebino z velikimi datotekami. Vaša naročnina na varnostne kopije se ni obnovila @@ -7780,7 +7772,7 @@ Ne zdaj - Try later + Poskusi pozneje Mediji bodo izbrisani @@ -7792,9 +7784,9 @@ Preskoči - Skip restore? + Želite preskočiti obnovitev? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Če obnovitev preskočite, bodo preostali mediji in priponke v varnostni kopiji izbrisani, ko bo naprava naslednjič izdelala novo varnostno kopijo. @@ -7851,10 +7843,6 @@ Sprememba ali preklic naročnine - - - Vaša zadnja varnostna kopija je bila izdelana dne %1$s ob %2$s. - Omejitve klepeta @@ -8184,5 +8172,101 @@ Ikona opomnika + + + Imam svoj stari telefon + + Skenirajte kodo QR iz svojega trenutnega računa Signal in hitro začnite + + + Nimam starega telefona + + Ali pa ponovno nameščate Signal v isto napravo + + + Obnovitev ali prenos računa + + V to napravo prenesite svoj račun Signal in zgodovino sporočil. + + Iz Varnostnih kopij Signal + + Vaš brezplačni ali plačljivi načrt varnostnega kopiranja Signal + + Iz mape z varnostno kopijo + + Iz datoteke z varnostno kopijo + + Izberite shranjeno varnostno kopijo + + Iz starega telefona + + Prenos neposredno iz starega sistema Android + + + Obnovitev lokalne varnostne kopije + + Obnovite sporočila iz varnostne kopije, ki ste jo shranili v napravo. Če ne obnovite zdaj, ne boste mogli obnoviti pozneje. + + + Vnesite varnostni ključ + + Varnostni ključ je 64-mestna koda, ki je potrebna za obnovitev računa in podatkov. + + Nimate varnostnega ključa? + + Varnostni ključ + + Varnostnih kopij ni mogoče obnoviti brez njihove 64-mestne obnovitvene kode. Če ste izgubili varnostni ključ, vam Signal ne more pomagati obnoviti varnostne kopije. + + Če še imate staro napravo, si lahko varnostni ključ ogledate v Nastavitve > Klepeti > Varnostne kopije Signal. Nato tapnite Ogled varnostnega ključa. + + Preberite več + + Preskoči in ne obnovi + + + To kodo skenirajte s starim telefonom + + Odprite aplikacijo Signal v stari napravi + + Tapnite ikono kamere + + To kodo skenirajte s kamero + + Ni mogoče ustvariti kode QR + + Skenirano v stari napravi + + Ponovno + + + Prenos računa + + Vaš račun bo prenesen v novo napravo. Ta naprava bo lahko videla vaše skupine in stike, dostopala do klepetov in pošiljala sporočila v vašem imenu. %1$s + + Preberite več + + Prenos računa + + Sporočila in informacije o klepetu so v vseh napravah zaščitene s šifriranjem od konca do konca + + Odklenite za prenos računa + + Nadaljujte v drugi napravi + + Nadaljujte s prenosom računa v drugo napravo. + + + Obnovitev je bila uspešna + + Vaš račun Signal in sporočila so se začeli prenašati v drugo napravo. Signal je zdaj neaktiven v tej napravi. + + Prenos je končan + + Vaš račun Signal in sporočila so bili preneseni v drugo napravo. Signal je zdaj neaktiven v tej napravi. + + Okej + + \ No newline at end of file diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml index e4899f1a63..eea22a2507 100644 --- a/app/src/main/res/values-sq/strings.xml +++ b/app/src/main/res/values-sq/strings.xml @@ -1325,20 +1325,6 @@ më shumë Shtoni përshkrim grupi… - - - Shpërngulni prej pajisjeje Android - - Transfero llogarinë dhe mesazhet nga pajisja e vjetër Android. - - Identifikohu pa transferim - - Vazhdo pa transferuar mesazhet dhe mediat - - Rikthe kopjeruajtjen lokale - - Rikthe mesazhet nga skedari i kopjeruajtjes që ke ruajtur në pajisje. - Kopjeruajtja po shkarkohet… @@ -1356,12 +1342,16 @@ Të gjitha mesazhet Riktheni prej kopjeruajtje - + Përfshihet vetëm media e dërguar ose marrë në %1$d ditët e fundit. Kopjeruajtja përfshin: Riktheje kopjeruajtjen + + Kopjeruajtja jote e fundit është bërë më %1$s në %2$s. + + Duke marrë detajet e kopjeruajtjes… Njoftomë për Përmendje @@ -3463,7 +3453,7 @@ Tjetër Pagesa (MobileCoin) Dhurime & Distinktive - Signal Android Backup + Kopjeruajtje të Signal Android Paraqitja e regjistrit të korrigjimit në Android Signal @@ -4304,6 +4294,8 @@ E kam shkruar diku këtë frazëkalim. Pa të, s\\’do të jem në gjendje të rikthej një kopjeruajtje. Riktheje kopjeruajtjen Shpërngulni ose riktheni llogari + + Rikthe ose transfero Shpërngulni llogari Anashkaloje Kopjeruajtje bisedash @@ -5124,7 +5116,7 @@ Grupet - Only messages from group chats + Mesazhe vetëm nga bisedat në grup Shtoje @@ -7426,11 +7418,11 @@ Plani yt kopjeruajtës i medias në Signal është anuluar sepse nuk mund ta përpunonim pagesën. Kjo është mundësia jote e fundit për ta shkarkuar median në kopjeruajtje përpara se të fshihet. - Free up %1$s on this device + Liro %1$s në këtë pajisje - To finish downloading your Signal Backup your device needs %1$s of storage space. + Pajisjes i duhet %1$s hapësirë ruajtëse për të përfunduar shkarkimin e kopjeruajtjes së Signal. - To free up space offload or delete unused apps or content large in file size. + Për të liruar hapësirë, shkarko ose fshi aplikacionet apo përmbajtjet që nuk i përdor dhe që kanë përmasa të mëdha skedari. Abonimi i kopjeruajtjeve nuk u rinovua @@ -7458,7 +7450,7 @@ Jo tani - Try later + Provo më vonë Media do të fshihet @@ -7470,9 +7462,9 @@ Anashkaloje - Skip restore? + Të kapërcehet rikthimi? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Nëse kapërcen rikthimin, media dhe bashkëngjitjet e mbetura në kopjeruajtje do të fshihen herën tjetër që pajisja bën kopjeruajtjen e re. @@ -7529,10 +7521,6 @@ Ndrysho ose anulo abonimin - - - Kopjeruajtja jote e fundit është bërë më %1$s në %2$s. - Kufijtë e bisedës @@ -7850,5 +7838,101 @@ Ikona e rikujtesës + + + E kam telefonin e vjetër + + Skano kodin QR nga llogaria aktuale e Signal për të filluar më shpejt + + + Nuk e kam telefonin e vjetër + + Ose po riinstalo Signal në të njëjtën pajisje + + + Rikthe ose transfero llogarinë + + Merr llogarinë e Signal dhe historikun e mesazheve në këtë pajisje. + + Nga kopjeruajtjet e Signal + + Plani yt falas ose me pagesë i kopjeruajtjeve në Signal + + Nga një dosje kopjeruajtjeje + + Nga një skedar kopjeruajtjeje + + Zgjidh kopjeruajtjen që ke ruajtur + + Nga telefoni i vjetër + + Transfero drejtpërdrejt nga Android yt i vjetër + + + Rikthe kopjeruajtjen lokale + + Rikthe mesazhet nga kopjeruajtja që ke ruajtur në pajisje. Nëse nuk i rikthen tani, nuk do të mund t\'i rikthesh më vonë. + + + Vendos kodin e kopjeruajtjes + + Kodi i kopjeruajtjes është një kod 64-shifror që kërkohet për të rikthyer llogarinë dhe të dhënat e tuaja. + + Nuk ke kod kopjeruajtjeje? + + Kodi i kopjeruajtjes + + Kopjeruajtjet nuk mund të rikthehen pa kodin e tyre të rikthimit prej 64 shifrash. Nëse e ke humbur kodin e kopjeruajtjes, Signal nuk mund të ndihmojë në rikthimin e kopjeruajtjes. + + Nëse ke pajisjen e vjetër, mund ta shikosh kodin e kopjeruajtjes te Parametrat > Bisedat > Kopjeruajtjet e Signal. Më pas kliko \"Shiko kodin e kopjeruajtjes\". + + Mëso më shumë + + Kapërce dhe mos rikthe + + + Skano këtë kod me telefonin e vjetër + + Hap Signal në pajisjen e vjetër + + Kliko në ikonën e kamerës + + Skano këtë kod me kamera + + Nuk mund të gjenerohet kodi QR + + Skanuar në pajisjen e vjetër + + Riprovo + + + Shpërngulni llogari + + Llogaria do të transferohet në një pajisje të re. Kjo pajisje do të jetë në gjendje të shikojë grupet dhe kontaktet e tua, të ketë lejehyrje në bisedat e tua dhe të dërgojë mesazhe në emrin tënd. %1$s + + Mëso më shumë + + Shpërngulni llogari + + Mesazhet dhe informacionet e bisedës mbrohen nga kodimi skaji më skaj në të gjitha pajisjet + + Zhblloko për të transferuar llogarinë + + Vazhdo në pajisjen tjetër + + Vazhdo transferimin e llogarisë në pajisjen tjetër. + + + Rikthim i plotësuar + + Llogaria e Signal dhe mesazhet kanë filluar të transferohen në pajisjen tjetër. Signal është tani joaktiv në këtë pajisje. + + Shpërngulje e plotë + + Llogaria e Signal dhe mesazhet janë transferuar në pajisjen tjetër. Signal është tani joaktiv në këtë pajisje. + + OK + + \ No newline at end of file diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 62c8bf44c5..8e2f99d256 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -1325,20 +1325,6 @@ више Унесите опис групе… - - - Пренесите податке са Android уређаја - - Пренесите налог и поруке са старог Android уређаја. - - Пријави се без преноса података - - Наставите без преноса порука и медија - - Врати локалну резервну копију - - Вратите поруке из фајла резервне копије који сте сачували на свом уређају. - Преузимање резервне копије… @@ -1356,12 +1342,16 @@ све ваше поруке Враћање садржаја из резервне копије - + Укључени су само медији послати или примљени у последњих %1$d дана. Резервна копија обухвата: Врати резервну копију + + Последња резервна копија направљена је %1$s у %2$s. + + Преузимамо детаље о резервној копији… Обавести ме за помињања @@ -3463,7 +3453,7 @@ Друго Плаћања (MobileCoin) Донације и значке - Signal Android Backup + Резервне копије – Signal за Android Слање извештаја о грешкама за Signal Android @@ -4304,6 +4294,8 @@ Записао/ла сам ову приступну фразу. Без ње нећу моћи да вратим резервну копију. Врати резервну копију Пренесите или вратите налог + + Врати или пренеси Пренесите налог Прескочи Резерве ћаскања @@ -5124,7 +5116,7 @@ Групе - Only messages from group chats + Само поруке из групних ћаскања Додај @@ -6687,7 +6679,7 @@ Додирните дугме „Иди на подешавања“ у наставку - Активирајте „Дозволи подешавања за обавештења и подсетнике.“ + Активирајте „Дозволи подешавање обавештења и подсетника.“ Idite na podešavanja @@ -7426,11 +7418,11 @@ Ваш резервни пакет за медије у Signal-у је отказан јер нисмо могли да обрадимо вашу уплату. Ово вам је последња прилика да преузмете медије у вашој резервној копији пре него што буду избрисани. - Free up %1$s on this device + Ослободите %1$s на овом уређају - To finish downloading your Signal Backup your device needs %1$s of storage space. + Да би креирање резервних копија на Signal-у могло да се заврши, потребно је %1$s слободног меморијског простора на вашем уређају. - To free up space offload or delete unused apps or content large in file size. + Да бисте ослободили простор, деинсталирајте или избришете апликације које не користите или садржај који заузима пуно простора. Ваша претплата на резервне копије није обновљена @@ -7458,7 +7450,7 @@ Не сада - Try later + Пробајте поново Медији ће бити избрисани @@ -7470,9 +7462,9 @@ Прескочи - Skip restore? + Желите ли да прескочите враћање? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Ако изаберете „Прескочи враћање“, преостали медији и прилози у вашој резервној копији биће избрисани следећи пут када уређај креира нову резервну копију. @@ -7529,10 +7521,6 @@ Промените или откажите претплату - - - Последња резервна копија направљена је %1$s у %2$s. - Ограничења броја ћаскања @@ -7850,5 +7838,101 @@ Икона подсетника + + + Имам свој стари телефон + + Скенирајте QR код на вашем тренутном налогу на Signal-у да бисте брзо започели + + + Немам свој стари телефон + + Или поново инсталирате Signal на истом уређају + + + Враћање или пренос налога + + Пребаците налог и историју порука на овај уређај. + + Из резервних копија Signal-а + + Ваш бесплатни или плаћени пакет за резервне копије Signal-а + + Из фолдера за резервне копије + + Из фајла резервне копије + + Изаберите резервну копију коју сте сачували + + Са старог телефона + + Пренесите директно са старог Android уређаја + + + Врати локалну резервну копију + + Вратите поруке из резервне копије коју сте сачували на свом уређају. Ако их не вратите сада, нећете моћи да их вратите касније. + + + Унесите кључ за резервне копије + + Кључ за резервне копије је 64-цифрена шифра потребна за враћање вашег налога и података. + + Немате кључ за резервне копије? + + Кључ за резервне копије + + Резервне копије се не могу вратити без њихове 64-цифрене шифре за опоравак. Ако сте изгубили кључ за резервне копије, Signal не може да вам помогне да вратите резервну копију. + + Ако имате свој стари уређај, можете да видите резервни кључ ако одете у Подешавања > Ћаскања > Резервне копије Signal-а. Затим додирните „Прикажи кључ за резервне копије“. + + Сазнајте више + + Прескочи и не враћај + + + Скенирајте овај код старим телефоном + + Отворите Signal на старом уређају + + Додирните икону камере + + Скенирајте овај код камером + + Генерисање QR кода није успело + + Скенирано је на старом уређају + + Пробај поново + + + Пренесите налог + + Ваш налог ће бити пренесен на нови уређај. Тај уређај ће имати приступ вашим контактима, групама и ћаскањима и моћи ће да шаље поруке у ваше име. %1$s + + Сазнајте више + + Пренесите налог + + Поруке и информације о ћаскањима заштићене су потпуним шифровањем на свим уређајима + + Откључајте да пренесете налог + + Наставите на другом уређају + + Наставите са преносом налога на другом уређају. + + + Враћање је завршено + + Ваш налог на Signal-у и поруке почели су да се преносе на ваш други уређај. Signal је сада неактиван на овом уређају. + + Пренос је завршен + + Ваш налог на Signal-у и поруке пренети су на ваш други уређај. Signal је сада неактиван на овом уређају. + + У реду + + \ No newline at end of file diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 3a3d33d78d..2f2401e201 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -1325,20 +1325,6 @@ mer Lägg till gruppbeskrivning… - - - Överför från Android-enhet - - Överför ditt konto och dina meddelanden från din gamla Android-enhet. - - Logga in utan att överföra - - Fortsätt utan att överföra dina meddelanden och media - - Återställ lokal säkerhetskopia - - Återställ dina meddelanden från en säkerhetskopia som du har sparat på din enhet. - Laddar ner säkerhetskopia … @@ -1356,12 +1342,16 @@ Alla dina meddelanden Återställ från säkerhetskopia - + Endast media som skickats eller tagits emot under de senaste %1$d dagarna ingår. Din säkerhetskopia innehåller: Återställ säkerhetskopia + + Din senaste säkerhetskopia gjordes den %1$s kl %2$s. + + Hämtar säkerhetskopieringsdetaljer … Meddela mig om omnämnanden @@ -3463,7 +3453,7 @@ Övrigt Betalningar (MobileCoin) Donationer och märken - Signal Android Backup + Säkerhetskopiering av Signal Android Signal Android inskickad felsökningslogg @@ -4304,6 +4294,8 @@ Jag har skrivit ner lösenordet. Utan det kommer jag inte att kunna återställa en säkerhetskopia. Återställ säkerhetskopia Överför eller återställ konto + + Återställ eller överför Överför konto Hoppa över Säkerhetskopior av chattar @@ -5124,7 +5116,7 @@ Grupper - Only messages from group chats + Endast meddelanden från gruppchattar Lägg till @@ -7426,11 +7418,11 @@ Din säkerhetskopieringsplan för media på Signal har annullerats eftersom vi inte kunde behandla din betalning. Detta är din sista chans att ladda ner media i din säkerhetskopia innan den tas bort. - Free up %1$s on this device + Frigör %1$s på den här enheten - To finish downloading your Signal Backup your device needs %1$s of storage space. + För att slutföra nedladdningen av din Signal-säkerhetskopia behöver din enhet %1$s lagringsutrymme. - To free up space offload or delete unused apps or content large in file size. + Avlasta eller ta bort oanvända appar eller innehåll med stor filstorlek för att frigöra utrymme. Ditt abonnemang för säkerhetskopiering kunde inte förnyas @@ -7458,7 +7450,7 @@ Inte nu - Try later + Försök senare Media kommer att tas bort @@ -7470,9 +7462,9 @@ Hoppa över - Skip restore? + Hoppa över återställning? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Om du hoppar över återställningen kommer resterande media och bilagor i din säkerhetskopia att tas bort nästa gång din enhet slutför en ny säkerhetskopiering. @@ -7529,10 +7521,6 @@ Ändra eller avsluta abonnemang - - - Din senaste säkerhetskopia gjordes den %1$s kl %2$s. - Gränser för chattlängd @@ -7850,5 +7838,101 @@ Påminnelseikon + + + Jag har min gamla telefon + + Skanna en QR-kod från ditt nuvarande Signal-konto för att snabbt komma igång + + + Jag har inte min gamla telefon + + Eller så installerar du om Signal på samma enhet + + + Återställ eller överför konto + + Hämta ditt Signal-konto och din meddelandehistorik till den här enheten. + + Från Säkerhetskopiering av Signal + + Din gratis eller betalda plan för Säkerhetskopiering av Signal + + Från en säkerhetskopieringsmapp + + Från en säkerhetskopieringsfil + + Välj en säkerhetskopia som du har sparat + + Från din gamla telefon + + Överför direkt från din gamla Android + + + Återställ lokal säkerhetskopia + + Återställ dina meddelanden från säkerhetskopian som du har sparat på din enhet. Om du inte återställer nu kommer du inte att kunna återställa senare. + + + Ange din säkerhetskopieringsnyckel + + Din säkerhetskopieringsnyckel är en 64-siffrig kod som krävs för att återställa ditt konto och dina data. + + Ingen säkerhetskopieringsnyckel? + + Säkerhetskopieringsnyckel + + Säkerhetskopior kan inte återställas utan deras 64-siffriga återställningskod. Om du har tappat bort din säkerhetskopieringsnyckel kan Signal inte hjälpa till att återställa din säkerhetskopia. + + Om du har din gamla enhet kan du se din säkerhetskopieringsnyckel i Inställningar > Chattar > Säkerhetskopiering av Signal. Tryck sedan på Visa säkerhetskopieringsnyckel. + + Läs mer + + Hoppa över och återställ inte + + + Skanna den här koden med din gamla telefon + + Öppna Signal på din gamla enhet + + Tryck på kameraikonen + + Skanna den här koden med kameran + + Det går inte att generera QR-kod + + Skannad på gammal enhet + + Försök igen + + + Överför konto + + Ditt konto kommer att överföras till en ny enhet. Den här enheten kommer att kunna se dina grupper och kontakter, komma åt dina chattar och skicka meddelanden i ditt namn. %1$s + + Läs mer + + Överför konto + + Meddelanden och chattinformation skyddas av totalsträckskryptering på alla enheter + + Lås upp för att överföra konto + + Fortsätt på din andra enhet + + Fortsätt att överföra ditt konto på din andra enhet. + + + Återställningen är klar + + Ditt Signal-konto och dina meddelanden har börjat överföras till din andra enhet. Signal är nu inaktivt på den här enheten. + + Överföringen slutförd + + Ditt Signal-konto och dina meddelanden har överförts till din andra enhet. Signal är nu inaktivt på den här enheten. + + Okej + + \ No newline at end of file diff --git a/app/src/main/res/values-sw/strings.xml b/app/src/main/res/values-sw/strings.xml index 78f9e824f9..3f9b5dc05a 100644 --- a/app/src/main/res/values-sw/strings.xml +++ b/app/src/main/res/values-sw/strings.xml @@ -1325,20 +1325,6 @@ zaidi Weka maelezo ya kikundi… - - - Hamisha kutoka kifaa cha Android - - Hamisha akaunti na jumbe zako kutoka kwenye kifaa chako cha Android cha zamani. - - Ingia bila kuhamisha - - Endelea bila kuhamisha jumbe na video zako - - Rejesha chelezo ya ndani - - Rejesha jumbe zako kutoka kwenye faili la hifadhi ulilohifadhi kwenye kifaa chako. - Inapakua uhifadhi nakala… @@ -1356,12 +1342,16 @@ Jumbe zako zote Rejesha kutoka nakala hifadhi - + Video na picha zilizotumwa au kupokewa ndani ya siku %1$d zilizopita ndio zitajumuishwa. Hifadhi nakala yako inajumuisha: rejesha upya nakalahifadhi + + Chelezo yako ya mwisho ilifanywa tarehe %1$s saa %2$s. + + Inachukua maelezo ya hifadhi… Niarifu Ninapotajwa @@ -3463,7 +3453,7 @@ ingine Malipo (MobileCoin) Michango & Beji - Signal Android Backup + Hifadhi ya Signal Androidid Kutuma Ombi la Kumbukumbu za Utatuzi za Signal Android @@ -4304,6 +4294,8 @@ Nimeandika Nenosiri hili. Bila yalo, nishindwa kurejesha nakalahifadhi. rejesha upya nakalahifadhi Hamisha au urejeshe akaunti + + Rejesha au hamisha Hamisha akaunti Ruka Nakalahifadhi ya Gumzo @@ -5124,7 +5116,7 @@ Makundi - Only messages from group chats + Ujumbe kutoka kwenye gumzo la moja kwa moja tu Ongeza @@ -6687,7 +6679,7 @@ Gusa kitufe cha \"Nenda kwenye mipangilio\" hapa chini - Washa \"Ruhusu kengele na vikumbusho.\" + Washa \"Ruhusu kuwasha kengele na vikumbusho.\" Nenda kwenye mipangilio @@ -7426,11 +7418,11 @@ Mpango wako wa uhifadhi nakala za video na picha za Signal umeghairishwa kwa sababu tumeshindwa kuchakata malipo yako. Hii ni nafasi yako ya mwisho kupakua video na picha kwenye nakala yako kabla haijafutwa. - Free up %1$s on this device + Futa %1$s ili upate nafasi kwenye kifaa hiki - To finish downloading your Signal Backup your device needs %1$s of storage space. + Ili umalize kupakua Hifadhi Nakala yako ya Signal, kifaa chako kitahitaji nafasi ya uhifadhi ya %1$s. - To free up space offload or delete unused apps or content large in file size. + Ili upate nafasi hamisha au futa programu ambazo huzitumii au maudhui zilizo kubwa kwa saizi. Usajili wako wa uhifadhi nakala umeshindwa kusasisha @@ -7458,7 +7450,7 @@ Sio sasa - Try later + Jaribu baadaye Picha na video zitafutwa @@ -7470,9 +7462,9 @@ Ruka - Skip restore? + Ruka hatua ya kurejesha? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Ikiwa utaruka kurejesha picha na video zilizosalia na viambatisho katika hifadhi yako, zitafutwa mara nyingine kifaa chako kitakapokamilisha uhifadhi nakala mpya. @@ -7529,10 +7521,6 @@ Badili au ghairi usajili - - - Chelezo yako ya mwisho ilifanywa tarehe %1$s saa %2$s. - Kiwango cha gumzo @@ -7850,5 +7838,101 @@ Aikoni ya kumbusho + + + Nina simu yangu ya zamani + + Skani QR code kutoka kwenye akaunti yako ya Signal ya sasa ili uanze haraka + + + Sina simu yangu ya zamani + + Au unasakinisha tena Signal kwenye kifaa kile kile + + + Rejesha au hamisha akaunti + + Pata akaunti yako ya Signal na historia ya jumbe kwenye kifaa hiki. + + Kutoka Hifadhi Nakala ya Signal + + Mpango wako wa bila malipo na wa kulipia wa Hifadhi Nakala ya Signal + + Kutoka kwenye folda la hifadhi + + Kutoka kwenye faili ya hifadhi nakala + + Chagua hifadhi uliyohifadhi + + Kutoka kwenye simu yako ya zamani + + Hamisha moja kwa moja kutoka kwa Android yako ya zamani + + + Rejesha chelezo ya ndani + + Rejesha jumbe zako kutoka kwenye hifadhi nakala uliyohifadhi kwenye kifaa chako. Usiporejesha sasa hivi, hutaweza kurejesha baadaye. + + + Ingiza ufunguo mbadala wako + + Ufunguo mbadala wako ni code ya tarakimu 64 inayohitajika kurejesha akaunti na data yako. + + Hakuna ufunguo mbadala? + + Ufunguo mbadala + + Akaunti na data haiwezi kurejeshwa bila code yake ya urejeshaji yenye tarakimu 64. Iwapo umepoteza ufunguo mbadala wako, Signal haiwezi kukusaidia kurejesha akaunti na data. + + Ikiwa una kifaa chako cha zamani, unaweza kutazama ufunguo mbadala wako kwenye Mipangilio > Gumzo > Hifadhi Nakala ya Signal. Kisha gusa Tazama ufunguo mbadala + + Jifunze zaidi + + Ruka na usirejeshe + + + Skani code hii ukitumia simu yako ya zamani + + Fungua Signal kwenye kifaa chako cha zamani + + Gusa aikoni ya kamera + + Skani code hii ukitumia kamera + + Imeshindwa kutayarisha QR code + + Imeskani kifaa cha zamani + + Jaribu Upya + + + Hamisha akaunti + + Akaunti yako itahamishwa kwenda kwenye kifaa kipya. Kifaa hiki kitaweza kuona vikundi na wawasiliani wako, kufikia gumzo zako na kutuma ujumbe kutumia jina lako. %1$s + + Jifunze zaidi + + Hamisha akaunti + + Taarifa za jumbe na gumzo zinalindwa na usimbaji fiche kwenye vifaa vyote + + Fungua ili kuhamisha akaunti + + Endelea kwenye kifaa chako kingine + + Endelea kuhamisha akaunti yako kwenye kifaa chako kingine. + + + Hatua ya kurejesha imekamilika + + Akaunti yako ya Signal na jumbe zimeanza kuhamishiwa kwenye kifaa chako kingine. Signal sasa haitumiki kwenye kifaa hiki. + + Mchakato wa kuhamisha umekamilika + + Akaunti yako ya Signal na jumbe zimehamishwa kwenye kifaa chako kingine. Signal sasa haitumiki kwenye kifaa hiki. + + Sawa + + \ No newline at end of file diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 7109d480ce..9b8c8ed786 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -1325,20 +1325,6 @@ மேலும் கூட்டு குழு விளக்கம்… - - - இடமாற்றம் Android சாதனத்திலிருந்து - - உங்கள் பழைய ஆண்ட்ராய்டு சாதனத்திலிருந்து உங்கள் கணக்கு மற்றும் செய்திகளை இடமாற்றவும். - - இடமாற்றம் செய்யாமல் உள்நுழைக - - உங்களின் மெசேஜ்கள் மற்றும் மீடியாவை இடமாற்றம் செய்யாமல் தொடர்க - - உள் காப்புப்பிரதியை மீட்டமை - - உங்கள் சாதனத்தில் நீங்கள் சேமித்திருக்கும் காப்புப்பிரதி கோப்பிலிருந்து செய்திகளை மீட்டெடுங்கள். - காப்புப்பிரதியை பதிவிறக்குகிறது… @@ -1356,12 +1342,16 @@ உங்களின் அனைத்து செய்திகள் மீட்டமை இருந்து காப்புப்பிரதி - + கடந்த %1$d நாட்களில் நீங்கள் அனுப்பிய அல்லது பெற்ற மீடியா மட்டுமே சேர்க்கப்பட்டுள்ளது. உங்கள் காப்புப்பிரதியில் உள்ளடங்குவன: காப்புப்பதிவு பயனர் தரவு மீட்டமை + + உங்களின் கடைசி காப்புப் பிரதி %1$sஅன்று %2$s மணிக்கு செய்யப்பட்டது. + + காப்புப்பிரதி விவரங்களைப் பெறுகிறது… என் பெயர் குறிப்புகளை எனக்கு அறிவி @@ -3463,7 +3453,7 @@ மற்றவை கட்டணங்கள் (MobileCoin) நன்கொடைகள் மற்றும் பேட்ஜ்கள் - Signal Android Backup + சிக்னல் ஆண்ட்ராய்டு காப்புப்பிரதி Signal Android பிழைத்திருத்த பதிவு சமர்ப்பிப்பு @@ -4304,6 +4294,8 @@ இந்த கடவுச்சொற்றொடரை நான் எழுதியுள்ளேன். இது இல்லாமல், என்னால் ஒரு காப்புப்பதிவு பயனர் தரவு மீட்டெடுக்க முடியாது. காப்புப்பதிவு பயனர் தரவு மீட்டமை கணக்கை நகர்த்தவும் அல்லது மீட்டெடுக்கவும் + + மீட்டெடு அல்லது இடமாற்று Signal கணக்கை நகர்த்தவும் தவிர் சாட் காப்புப்பிரதிகள் @@ -5124,7 +5116,7 @@ குழுக்கள் - Only messages from group chats + குழு சாட்ஸிலிருந்து மெசேஜ்கள் மட்டும் சேர்க்கவும் @@ -6687,7 +6679,7 @@ கீழே உள்ள \"அமைப்புகளுக்குச் செல்\" பட்டனைத் தட்டவும் - \"அமைப்புகள் அலாரங்கள் மற்றும் நினைவூட்டல்களை அனுமதி\" என்பதை இயக்கவும். + \"அலாரங்கள் மற்றும் நினைவூட்டல்களின் அமைப்பை அனுமதி\" என்பதை ஆன் செய்யவும். அமைப்புகளுக்குச் செல் @@ -7426,11 +7418,11 @@ உங்கள் சிக்னலின் மீடியா பேக்கப் திட்டம் ரத்துசெய்யப்பட்டது, ஏனெனில் உங்கள் கட்டணத்தைச் எங்களால் செயலாக்க முடியவில்லை. உங்கள் பேக்கப்பில் உள்ள மீடியா அழிக்கப்படும் முன் அதைப் பதிவிறக்குவதற்கான கடைசி வாய்ப்பு இதுவாகும். - Free up %1$s on this device + இந்தச் சாதனத்தில் %1$s இடத்தைக் காலியாக்கவும் - To finish downloading your Signal Backup your device needs %1$s of storage space. + உங்கள் சிக்னல் காப்புப்பிரதியைப் பதிவிறக்குவதை முடிக்க, உங்கள் சாதனத்திற்கு %1$s சேமிப்பிடம் தேவை. - To free up space offload or delete unused apps or content large in file size. + இடத்தைக் காலியாக்க, உபயோகக்கப்படாத ஆப்கள் அல்லது கோப்பு அளவு பெரியதாகக் கொண்ட உள்ளடக்கத்தை நீக்கவும். உங்கள் காப்புப் பிரதிகள் சந்தாவைப் புதுப்பிக்க முடியவில்லை @@ -7458,7 +7450,7 @@ இப்போது வேண்டாம் - Try later + பின்னர் முயற்சிக்கவும் மீடியா அழிக்கப்படும் @@ -7470,9 +7462,9 @@ தவிர் - Skip restore? + மீட்டமைப்பதைத் தவிர்ப்பதா? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + மீதி மீடியாவை மீட்டெடுப்பதைத் தவிர்த்தால், அடுத்த முறை உங்கள் சாதனம் புதிய காப்புப்பிரதியை முடிக்கும்போது, உங்கள் காப்புப்பிரதியில் உள்ள இணைப்புகள் நீக்கப்படும். @@ -7529,10 +7521,6 @@ சந்தாவை மாற்றுதல் அல்லது ரத்து செய்தல் - - - உங்களின் கடைசி காப்புப் பிரதி %1$sஅன்று %2$s மணிக்கு செய்யப்பட்டது. - சாட் வரம்புகள் @@ -7850,5 +7838,101 @@ நினைவூட்டல் ஐகான் + + + என் பழைய ஃபோன் என்னிடம் உள்ளது + + விரைவாகத் தொடங்க, உங்கள் தற்போதைய சிக்னல் கணக்கிலிருந்து QR குறியீட்டை ஸ்கேன் செய்யவும் + + + என்னிடம் என் பழைய ஃபோன் இல்லை + + அல்லது அதே சாதனத்தில் நீங்க சிக்னலை மீண்டும் நிறுவுகிறீர்கள் + + + கணக்கை மீட்டெடுக்கவும் அல்லது இடமாற்றவும் + + இந்தச் சாதனத்தில் உங்கள் சிக்னல் கணக்கு மற்றும் மெசேஜ் வரலாற்றைப் பெறவும். + + சிக்னல் காப்புப்பிரதிகளில் இருந்து + + உங்கள் இலவச அல்லது கட்டண சிக்னல் காப்புப்பிரதி திட்டம் + + காப்புப்பிரதி கோப்புறையிலிருந்து + + காப்புப் பிரதி கோப்பிலிருந்து + + நீங்கள் சேமித்த காப்புப்பிரதியைத் தேர்வு செய்யவும் + + உங்களின் பழைய ஃபோனிலிருந்து + + உங்களின் பழைய Android சாதானத்திலிருந்து நேரடியாக இடமாற்றம் செய்யவும் + + + உள் காப்புப்பிரதியை மீட்டமை + + உங்கள் சாதனத்தில் நீங்கள் சேமித்த காப்புப்பிரதியிலிருந்து மெசேஜ்களை மீட்டெடுக்கவும். இப்போது மீட்டெடுக்கப்படவில்லை எனில், பின்னர் மீட்டெடுக்க முடியாது. + + + உங்கக் காப்புப்பிரதி குறியீட்டை உள்ளிடவும் + + உங்கள் காப்புப்பிரதி குறியீடு என்பது உங்கள் கணக்கையும் தரவையும் மீட்டெடுக்க உதவும் 64 இலக்கக் குறியீடாகும். + + காப்புப்பிரதி குறியீடு இல்லையா? + + காப்புப்பிரதி குறியீடு + + 64 இலக்க மீட்புக் குறியீடு இல்லாமல் காப்புப்பிரதிகளை மீட்டெடுக்க முடியாது. உங்கள் காப்புப்பிரதி குறியீட்டை நீங்கள் இழந்திருந்தால், உங்கள் காப்புப்பிரதியை மீட்டெடுக்க சிக்னலால் உதவ முடியாது. + + உங்களிடம் பழைய சாதனம் இருந்தால், அமைப்புகள் > சாட்ஸ் > சிக்னல் காப்புப்பிரதிகளில் உங்கள் காப்புப்பிரதி குறியீட்டை பார்க்கலாம். காப்புப்பிரதி குறியீட்டைக் காண்க என்பதைத் தட்டவும். + + மேலும் அறிக + + தவிர்த்து, மீட்டெடுக்க வேண்டாம் + + + உங்களின் பழைய ஃபோன் கொண்டு இந்தக் குறியீட்டை ஸ்கேன் செய்யவும் + + உங்களின் பழைய சாதனத்தில் சிக்னலைத் திறக்கவும் + + கேமரா ஐகானைத் தட்டவும் + + இந்தக் குறியீட்டைக் கேமரா கொண்டு ஸ்கேன் செய்யவும் + + QR குறியீட்டை உருவாக்க முடியவில்லை + + பழைய சாதனத்தில் ஸ்கேன் செய்யவும் + + மீண்டும் முயற்சி செய் + + + Signal கணக்கை நகர்த்தவும் + + உங்கள் கணக்கு புதிய சாதனத்திற்கு இடமாற்றப்படும். இந்தச் சாதனத்தால் உங்கள் குழுக்களையும் தொடர்புகளையும் பார்க்கவும், உங்கள் சாட்ஸை அணுகவும், உங்கள் பெயரில் மெசேஜ்களை அனுப்பவும் முடியும். %1$s + + மேலும் அறிக + + Signal கணக்கை நகர்த்தவும் + + அனைத்து சாதானங்களிலும் எண்டு-டு-எண்டு குறியாக்கம் மூலம் மெசேஜ்கள் மற்றும் சாட் தகவல்கள் பாதுகாக்கப்பட்டன + + கணக்கை இடமாற்ற, தடைநீக்கவும் + + உங்களின் மற்ற சாதனத்தில் தொடரவும் + + உங்களின் மற்ற சாதனத்தில் கணக்கு இடமாற்றம் செய்வதைத் தொடரவும். + + + மீட்டெடுப்பு முடிந்தது + + மற்ற சாதனத்தில் உங்கள் சிக்னல் கணக்கு மற்றும் மெசேஜ்களின் இடமாற்றம் துவங்கப்பட்டன. இந்தச் சாதனத்தில் சிக்னல் இப்போது செயலற்ற நிலையில் உள்ளது. + + இடமாற்றம் முழுமை + + மற்ற சாதானத்திற்கு உங்கள் சிக்னல் கணக்கு மற்றும் மெசேஜ்கள் இடமாற்றப்பட்டன. இந்தச் சாதனத்தில் சிக்னல் இப்போது செயலற்ற நிலையில் உள்ளது. + + சரி + + \ No newline at end of file diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml index 5bd2116041..1cfd2b3e89 100644 --- a/app/src/main/res/values-te/strings.xml +++ b/app/src/main/res/values-te/strings.xml @@ -1325,20 +1325,6 @@ మరింత సమూహ వివరణను జోడించండి … - - - Android పరికరం నుంచి బదిలీ చేయడం - - మీ పాత Android పరికరం నుండి మీ ఖాతా అలాగే సందేశాలను బదిలీ చేయండి. - - బదిలీ చేయకుండానే లాగిన్ చేయండి - - మీ సందేశాలు అలాగే మీడియాను బదిలీ చేయకుండానే కొనసాగించండి - - లోకల్ బ్యాకప్‌ను పునరుద్ధరించండి - - మీరు మీ పరికరంలో సేవ్ చేసిన బ్యాకప్ ఫైల్ నుండి మీ సందేశాలను రీస్టోర్ చెయ్యండి. - బ్యాకప్ డౌన్‌లోడ్ అవుతోంది… @@ -1356,12 +1342,16 @@ మీ సందేశాలు అన్ని బ్యాకప్ నుంచి పునరుద్ధరించండి - + గత %1$d రోజుల్లో పంపిన లేదా అందుకున్న మీడియా మాత్రమే చేర్చబడింది. మీ బ్యాకప్‌లలో ఇవి ఉంటాయి: ప్రత్యామ్నాయ పునరుద్ధరించండి + + మీ చివరి బ్యాకప్ %1$s న %2$s కు చేయబడింది. + + బ్యాకప్ వివరాలను పొందుతోంది… నాకు ప్రస్తావనల తెలియజేయండి @@ -3463,7 +3453,7 @@ ఇతర చెల్లింపులు (MobileCoin) విరాళాలు & బ్యాడ్జీలు - Signal Android Backup + Signal ఆండ్రాయిడ్ బ్యాకప్ Signal Android డీబగ్ లాగ్ సమర్పణ @@ -4304,6 +4294,8 @@ నేను ఈ పాస్ఫ్రేజ్ని వ్రాశాను. ఇది లేకుండా, నేను ఒక ప్రత్యామ్నాయ పునరుద్ధరించడానికి సాధ్యం కాదు. ప్రత్యామ్నాయ పునరుద్ధరించండి ఖాతాను బదిలీ చేయండి లేదా పునరుద్ధరించండి + + పునరుద్ధరించండి లేదా బదిలీ చేయండి ఖాతాను బదిలీ చేయడం వదిలివేయి చాట్ బ్యాకప్‌లు @@ -5124,7 +5116,7 @@ గ్రూప్స్ - Only messages from group chats + గ్రూప్ చాట్స్ నుంచి సందేశాలు మాత్రమే చేర్చు @@ -6687,7 +6679,7 @@ దిగువన ‘‘సెట్టింగ్‌లకు వెళ్లండి’’ బటన్‌పై తట్టండి - ‘‘సెట్టింగ్స్ అలారమ్‌లు మరియు రిమైండర్లను అనుమతించండి’’ ఆన్ చేయండి. + \"అలారంలు మరియు రిమైండర్‌లను సెట్ చేయడానికి అనుమతించండి\" ఆన్ చేయండి. సెట్టింగ్‌లకు వెళ్లండి @@ -7426,11 +7418,11 @@ మేము మీ చెల్లింపును ప్రాసెస్ చేయలేనందున మీ Signal మీడియా బ్యాకప్ ప్లాన్ రద్దు చేయబడింది. మీ బ్యాకప్‌లోని మీడియా తొలగిపోక ముందే డౌన్‌లోడ్ చేసుకోవడానికి ఇది మీకు చివరి అవకాశం. - Free up %1$s on this device + ఈ పరికరంలో %1$s ను ఖాళీ చేయండి - To finish downloading your Signal Backup your device needs %1$s of storage space. + మీ Signal బ్యాకప్ డౌన్‌లోడ్‌ను పూర్తి చేయడానికి మీ పరికరానికి %1$s నిల్వ స్థలం అవసరం. - To free up space offload or delete unused apps or content large in file size. + స్థలాన్ని ఖాళీ చేయడానికి ఆఫ్‌లోడ్ చేయండి లేదా ఉపయోగించని యాప్‌లు లేదా ఫైల్ పరిమాణంలో పెద్ద కంటెంట్‌ను తొలగించండి. మీ బ్యాకప్‌ల సబ్స్క్రిప్షన్ పునరుద్ధరించబడడంలో విఫలమైంది @@ -7458,7 +7450,7 @@ ఇప్పుడు కాదు - Try later + తర్వాత ప్రయత్నించండి మీడియా తొలగించబడుతుంది @@ -7470,9 +7462,9 @@ దాటవేయి - Skip restore? + పునరుద్ధరించడాన్ని దాటవేసేదా? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + ఒకవేళ మీరు పునరుద్ధరణను దాటవేస్తే మీ బ్యాకప్‌లోని మిగిలిన మీడియా మరియు జోడింపులు తదుపరిసారి మీ పరికరం కొత్త బ్యాకప్‌ను పూర్తి చేసినప్పుడు తొలగించబడతాయి. @@ -7529,10 +7521,6 @@ సబ్‌స్క్రిప్షన్‌ను మార్చండి లేదా రద్దు చెయ్యండి - - - మీ చివరి బ్యాకప్ %1$s న %2$s కు చేయబడింది. - చాట్ పరిమితులు @@ -7850,5 +7838,101 @@ రిమైండర్ ఐకాన్ + + + నా దగ్గర నా పాత ఫోన్ ఉంది + + త్వరగా ప్రారంభించడానికి మీ ప్రస్తుత Signal ఖాతా నుండి QR కోడ్‌ను స్కాన్ చేయండి + + + నా దగ్గర నా పాత ఫోన్ లేదు + + లేదా మీరు అదే పరికరంలో Signal ను మళ్ళీ ఇన్‌స్టాల్ చేస్తున్నారు + + + ఖాతాను పునరుద్ధరించండి లేదా బదిలీ చేయండి + + ఈ పరికరంలో మీ Signal ఖాతా మరియు సందేశ చరిత్రను పొందండి. + + Signal బ్యాకప్‌ల నుండి + + మీ ఉచిత లేదా చెల్లింపు Signal బ్యాకప్ ప్లాన్ + + బ్యాకప్ ఫోల్డర్ నుండి + + బ్యాకప్ ఫైల్ నుండి + + మీరు సేవ్ చేసిన బ్యాకప్‌ను ఎంచుకోండి + + మీ పాత ఫోన్ నుండి + + మీ పాత Android నుండి నేరుగా బదిలీ చేయండి + + + లోకల్ బ్యాకప్‌ను పునరుద్ధరించండి + + మీరు మీ పరికరంలో సేవ్ చేసిన బ్యాకప్ నుండి మీ సందేశాలను పునరుద్ధరించండి. ఒకవేళ మీరు ఇప్పుడు పునరుద్ధరించలేకపోతే తరువాత పునరుద్ధరించలేరు. + + + మీ బ్యాకప్ కీని ఎంటర్ చేయండి + + మీ బ్యాకప్ కీ అనేది 64-అంకెల కోడ్, ఇది మీ ఖాతా మరియు డేటాను పునరుద్ధరించడానికి అవసరం. + + బ్యాకప్ కీ లేదా? + + బ్యాకప్ కీ + + బ్యాకప్‌లు 64-అంకెల పునరుద్ధరణ కోడ్ లేకుండా వాటిని తిరిగి పొందడం సాధ్యం కాదు. ఒకవేళ మీరు మీ బ్యాకప్ కీని పోగొట్టుకున్నట్లయితే, మీ బ్యాకప్‌ని పునరుద్ధరించడంలో Signal సహాయం చేయలేదు. + + ఒకవేళ మీ వద్ద మీ పాత పరికరం ఉంటే, మీరు సెట్టింగ్లు > చాట్లు > Signal బ్యాకప్‌లలో మీ బ్యాకప్ కీని వీక్షించవచ్చు. ఆపై బ్యాకప్ కీని వీక్షించండిని తట్టండి. + + మరింత తెలుసుకోండి + + దాటవేయి మరియు పునరుద్ధరించవద్దు + + + మీ పాత ఫోన్‌తో ఈ కోడ్‌ను స్కాన్ చేయండి + + మీ పాత పరికరంలో Signal ను తెరవండి + + కెమెరా చిహ్నాన్ని తట్టండి + + కెమెరాతో ఈ కోడ్‌ను స్కాన్ చేయండి + + QR కోడ్‌ను ఉత్పత్తి చేయడం సాధ్యం కాలేదు + + పాత పరికరంలో స్కాన్ చేయబడింది + + మళ్ళీ ప్రయత్నించు + + + ఖాతాను బదిలీ చేయడం + + మీ ఖాతా ఒక కొత్త పరికారానికి బదిలీ చేయబడుతుంది. ఈ పరికరం మీ గ్రూప్‌లను మరియు పరిచయాలను చూడగలదు, మీ చాట్లను యాక్సెస్ చేయగలదు, మరియు మీ పేరుతో సందేశాలను పంపగలదు. %1$s + + మరింత తెలుసుకోండి + + ఖాతాను బదిలీ చేయడం + + సందేశాలు మరియు చాట్ సమాచారం అన్ని పరికరాలలో ఎండ్-టు-ఎండ్ గుప్తీకరణ ద్వారా రక్షించబడతాయి + + ఖాతాను బదిలీ చేయడానికి అన్‌లాక్ చేయండి + + మీ మరో పరికరంపై కొనసాగండి + + మీ ఇతర పరికరంలో మీ ఖాతాను బదిలీ చేయడాన్ని కొనసాగించండి. + + + పునరుద్ధరణ పూర్తయింది + + మీ Signal ఖాతా మరియు సందేశాలు మీ ఇతర పరికరానికి బదిలీ కావడం ప్రారంభమయ్యాయి.. ఈ పరికరంపై Signal ఇప్పుడు నిష్క్రియంగా ఉంది. + + బదిలీ చేయడం పూర్తయింది + + మీ Signal ఖాతా మరియు సందేశాలు మీ ఇతర పరికరానికి బదిలీ చేయబడ్డాయి. ఈ పరికరంపై Signal ఇప్పుడు నిష్క్రియంగా ఉంది. + + సరే + + \ No newline at end of file diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 873c6beb00..0df091bebd 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -1287,20 +1287,6 @@ มีอีก เพิ่มคำอธิบายกลุ่ม… - - - ถ่ายโอนจากอุปกรณ์ Android - - ถ่ายโอนบัญชีและข้อความจากอุปกรณ์ Android เครื่องเก่าของคุณ - - เข้าสู่ระบบโดยไม่ถ่ายโอนข้อมูล - - ดำเนินการต่อโดยไม่ถ่ายโอนข้อความและไฟล์สื่อของคุณ - - กู้คืนข้อมูลสำรองจากอุปกรณ์ - - กู้คืนข้อความของคุณจากไฟล์ข้อมูลสำรองที่บันทึกไว้ในอุปกรณ์ - กำลังดาวน์โหลดข้อมูลสำรอง… @@ -1318,12 +1304,16 @@ ข้อความทั้งหมด กู้คืนจากข้อมูลสำรอง - + โดยข้อมูลสำรองจะรวมไฟล์สื่อที่ส่งหรือได้รับภายใน %1$d วันที่ผ่านมาเท่านั้น ข้อมูลสำรองของคุณประกอบด้วย: กู้คืนจากข้อมูลสำรอง + + คุณสำรองข้อมูลครั้งล่าสุดเมื่อวันที่ %1$s เวลา %2$s + + กำลังเรียกดูข้อมูลสำรอง… แจ้งให้ฉันรู้สำหรับการกล่าวถึง @@ -3361,10 +3351,10 @@ คำขอฟีเจอร์ คำถาม ข้อเสนอแนะ - อื่น + อื่นๆ การชำระเงิน (MobileCoin) การบริจาคและโล่ - Signal Android Backup + การสำรองข้อมูลของ Signal Android การส่งรายงานบันทึกดีบักสำหรับ Signal Android @@ -4193,6 +4183,8 @@ ฉันได้จดบันทึกวลีรหัสผ่านนี้แล้ว หากจำไม่ได้ ฉันจะไม่สามารถกู้คืนจากข้อมูลสำรองได้ กู้คืนจากข้อมูลสำรอง ถ่ายโอนหรือกู้คืนบัญชี + + กู้คืนหรือถ่ายโอน ถ่ายโอนบัญชี ข้าม ข้อมูลสำรองของแชท @@ -5004,7 +4996,7 @@ กลุ่ม - Only messages from group chats + ข้อความจากแชทกลุ่มเท่านั้น เพิ่ม @@ -7265,11 +7257,11 @@ แพ็กเกจสำรองข้อมูลสื่อของคุณถูกยกเลิกเนื่องจาก Signal ประมวลผลการชำระเงินของคุณไม่ได้ นี่เป็นโอกาสสุดท้ายที่คุณจะสามารถดาวน์โหลดไฟล์สื่อที่เก็บอยู่ในข้อมูลสำรองก่อนที่ข้อมูลจะถูกลบ - Free up %1$s on this device + เพิ่มพื้นที่ %1$s บนอุปกรณ์เครื่องนี้ - To finish downloading your Signal Backup your device needs %1$s of storage space. + อุปกรณ์ของคุณต้องมีพื้นที่จัดเก็บ %1$s จึงจะสามารถดาวน์โหลดข้อมูลสำรอง Signal ให้เสร็จสมบูรณ์ได้ - To free up space offload or delete unused apps or content large in file size. + โปรดเพิ่มพื้นที่จัดเก็บด้วยการออฟโหลดหรือลบแอปที่ไม่ได้ใช้งาน รวมถึงไฟล์ที่มีขนาดใหญ่ ไม่สามารถต่ออายุแพ็กเกจสำรองข้อมูลของคุณ @@ -7297,7 +7289,7 @@ ไว้ทีหลัง - Try later + ลองอีกครั้งในภายหลัง สื่อจะถูกลบ @@ -7309,9 +7301,9 @@ ข้าม - Skip restore? + ข้ามการกู้คืนใช่หรือไม่ - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + หากข้ามการกู้คืน ไฟล์สื่อและไฟล์แนบที่หลงเหลืออยู่ในข้อมูลสำรองของคุณจะถูกลบเมื่ออุปกรณ์สำรองข้อมูลเสร็จสมบูรณ์ในครั้งต่อไป @@ -7368,10 +7360,6 @@ เปลี่ยนหรือยกเลิกการสมัคร - - - คุณสำรองข้อมูลครั้งล่าสุดเมื่อวันที่ %1$s เวลา %2$s - จำกัดข้อความในแชทสูงสุด @@ -7683,5 +7671,101 @@ ไอคอนการเตือนความจำ + + + ฉันมีโทรศัพท์เครื่องเก่า + + เริ่มใช้งานได้เร็วทันใจด้วยการสแกนคิวอาร์โค้ดจากบัญชี Signal ที่คุณใช้อยู่ + + + ฉันไม่มีโทรศัพท์เครื่องเก่า + + หรือติดตั้ง Signal ใหม่บนอุปกรณ์เครื่องเดียวกันนั้น + + + กู้คืนหรือถ่ายโอนบัญชี + + ส่งข้อมูลบัญชี Signal และประวัติการส่งข้อความของคุณมายังอุปกรณ์เครื่องนี้ + + จากการสำรองข้อมูลของ Signal + + แพ็กเกจสำรองข้อมูลของ Signal แบบฟรีหรือแบบชำระค่าบริการ + + จากโฟลเดอร์ข้อมูลสำรอง + + จากไฟล์ข้อมูลสำรอง + + เลือกข้อมูลสำรองที่คุณเคยบันทึกไว้ + + จากโทรศัพท์เครื่องเก่า + + ถ่ายโอนโดยตรงจากโทรศัพท์ Android เครื่องเก่าของคุณ + + + กู้คืนข้อมูลสำรองจากอุปกรณ์ + + กู้คืนข้อความของคุณจากข้อมูลสำรองที่บันทึกไว้ในอุปกรณ์ หากไม่กู้คืนตอนนี้ คุณจะไม่สามารถกู้คืนได้อีกในภายหลัง + + + ใส่กุญแจสำรองของคุณ + + กุญแจสำรองคือรหัส 64 หลักที่คุณต้องใช้ในการกู้คืนบัญชีและข้อมูล + + ไม่มีกุญแจสำรองใช่หรือไม่ + + กุญแจสำรอง + + ไม่สามารถกู้คืนข้อมูลสำรองในกรณีที่ไม่มีรหัสกู้คืน 64 หลัก Signal จะไม่สามารถช่วยกู้คืนข้อมูลสำรองของคุณหากคุณทำกุญแจสำรองหาย + + โดยหากคุณยังมีอุปกรณ์เครื่องเก่า สามารถเปิดดูกุญแจสำรองได้ในการตั้งค่า > แชท > การสำรองข้อมูลของ Signal จากนั้นแตะที่ดูกุญแจสำรอง + + เรียนรู้เพิ่มเติม + + ข้ามและไม่กู้คืนข้อมูล + + + ใช้โทรศัพท์เครื่องเก่าของคุณสแกนโค้ดนี้ + + เปิดแอป Signal บนอุปกรณ์เครื่องเก่า + + แตะที่ไอคอนกล้อง + + ใช้กล้องสแกนโค้ดนี้ + + ไม่สามารถสร้างคิวอาร์โค้ดได้ + + ใช้อุปกรณ์เครื่องเก่าสแกนแล้ว + + ลองใหม่ + + + ถ่ายโอนบัญชี + + บัญชีของคุณจะถูกถ่ายโอนไปยังอุปกรณ์เครื่องใหม่ ซึ่งอุปกรณ์เครื่องนี้จะสามารถเห็นกลุ่ม รายชื่อผู้ติดต่อ เข้าถึงแชทของคุณ และส่งข้อความด้วยชื่อของคุณ %1$s + + เรียนรู้เพิ่มเติม + + ถ่ายโอนบัญชี + + ปกป้องข้อความและข้อมูลการแชทด้วยการเข้ารหัสตั้งแต่ต้นทางถึงปลายทางบนอุปกรณ์ทุกเครื่อง + + ปลดล็อกเพื่อถ่ายโอนบัญชี + + ดำเนินการต่อบนอุปกรณ์อีกเครื่อง + + ดำเนินการถ่ายโอนบัญชีต่อบนอุปกรณ์อีกเครื่องของคุณ + + + กู้คืนเสร็จสิ้น + + เริ่มการถ่ายโอนบัญชี Signal และข้อความของคุณไปยังอุปกรณ์อีกเครื่องแล้ว ขณะนี้ Signal บนอุปกรณ์เครื่องนี้จะหยุดทำงาน + + การถ่ายโอนสำเร็จ + + ถ่ายโอนบัญชี Signal และข้อความของคุณไปยังอุปกรณ์อีกเครื่องเรียบร้อยแล้ว ขณะนี้ Signal บนอุปกรณ์เครื่องนี้จะหยุดทำงาน + + ตกลง + + \ No newline at end of file diff --git a/app/src/main/res/values-tl/strings.xml b/app/src/main/res/values-tl/strings.xml index 7bd8588403..2efc666b2b 100644 --- a/app/src/main/res/values-tl/strings.xml +++ b/app/src/main/res/values-tl/strings.xml @@ -1325,20 +1325,6 @@ iba pa Mag-add ng group description… - - - I-transfer mula sa Android device - - I-transfer ang account at messages mo mula sa dati mong Android device. - - Mag-log in nang hindi nililipat - - Magpatuloy nang hindi nililipat ang messages at media mo - - I-restore ang local backup - - I-restore ang messages mo mula sa backup file na naka-save sa iyong device. - Dina-download ang backup… @@ -1356,12 +1342,16 @@ Lahat ng iyong messages Mag-restore mula sa backup - + Ang media na ipinadala o natanggap sa nakaraang %1$d araw lang ang kasama dito. Kasama sa backup mo ang: Mag-restore ng backup + + Huli kang nag-backup noong %1$s nang %2$s. + + Kinukuha ang backup details… I-notify ako sa Mentions @@ -4304,6 +4294,8 @@ Naisulat ko na ang passphrase na ito. Kung wala ito, hindi ako makakapag-restore ng backup. Mag-restore ng backup Transfer or restore account + + I-restore o ilipat Transfer account Laktawan Chat backups @@ -5124,7 +5116,7 @@ Groups - Only messages from group chats + Messages lang na mula sa group chats Mag-add @@ -7426,11 +7418,11 @@ Kinansela ang Signal media backup plan mo dahil hindi namin maproseso ang iyong payment. Ito na ang huling pagkakataon mong i-download ang media sa iyong backup bago mabura ang mga ito. - Free up %1$s on this device + Magtanggal ng %1$s sa device na ito - To finish downloading your Signal Backup your device needs %1$s of storage space. + Para matapos ang pag-download sa Signal Backup mo, kailangan ng device mo ng %1$s na storage space. - To free up space offload or delete unused apps or content large in file size. + Para mag-free up ng space, mag-offload o magbura ng unused apps o content na may malaking file size. Failed ang pag-renew ng backups subscription mo @@ -7458,7 +7450,7 @@ Hindi na muna - Try later + Subukan mamaya Mabubura ang media @@ -7470,9 +7462,9 @@ Laktawan - Skip restore? + I-skip ang pag-restore? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Kapag pinili mong i-skip ang pag-restore, mabubura ang natitirang media at attachments sa backup mo sa susunod na mag-back up ang device mo. @@ -7529,10 +7521,6 @@ Palitan o i-cancel ang subscription - - - Huli kang nag-backup noong %1$s nang %2$s. - Chat limits @@ -7850,5 +7838,101 @@ Reminder icon + + + Nasa akin ang lumang phone ko + + I-scan ang QR code mula sa current Signal account mo para makapagsimula agad + + + Wala sa akin ang lumang phone ko + + O nag-iinstall ka ulit ng Signal sa parehong device + + + I-restore o ilipat ang account + + Ilagay ang Signal account at message history mo sa device na ito. + + Mula sa Signal Backups + + Ang free o paid na Signal Backup plan mo + + Mula sa isang backup folder + + Mula sa isang backup file + + Pumili ng backup na na-save mo + + Mula sa lumang phone mo + + Direktang ilipat mula sa lumang Android mo + + + I-restore ang local backup + + I-restore ang messages mo mula sa backup na naka-save sa iyong device. Kung hindi ka mag-restore ngayon, hindi mo na ito magagawa sa susunod. + + + Ilagay ang backup key mo + + Ang backup key mo ay ang 64-digit code na kinakailangan para ma-recover ang iyong account at data. + + Wala kang backup key? + + Backup key + + Hindi mare-recover ang backups kapag wala ang kanilang 64-digit recovery code. Kapag nawala mo ang iyong backup key, hindi ka matutulungan ng Signal na i-restore ang backup mo. + + Kapag nasa \'yo ang lumang device mo, pwede mong tignan ang iyong backup key sa Settings > Chats > Signal Backups. Pagkatapos, i-tap ang View backup key. + + Matuto pa + + I-skip at huwag i-restore + + + I-scan ang code na ito gamit ang lumang phone mo + + Buksan ang Signal sa lumang device mo + + I-tap ang camera icon + + I-scan ang code na ito gamit ang camera + + Hindi ma-generate ang QR code + + Na-scan sa lumang device + + Subukang muli + + + Transfer account + + Ililipat ang account mo sa isang bagong device. Makikita ng device na ito ang groups at contacts mo, ma-a-access ang chats mo, at makakapag-send ng messages gamit ang pangalan mo. %1$s + + Matuto pa + + Transfer account + + Protektado ng end-to-end encryption ang messages at chat info sa lahat ng devices + + I-unlock para ilipat ang account + + Ipagpatuloy sa ibang device mo + + Ipagpatuloy ang paglipat ng account mo sa ibang device mo. + + + Restore complete + + Nagsimula nang ilipat ang Signal account at messages mo sa ibang device mo. Inactive na ang Signal sa device na ito. + + Transfer complete + + Nalipat na ang Signal account at messages mo sa ibang device mo. Inactive na ang Signal sa device na ito. + + Okay + + \ No newline at end of file diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 748c82410c..e89bfbe7c0 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1325,20 +1325,6 @@ dahası Grup tanımı ekle… - - - Android cihazdan aktar - - Eski Android cihazından hesabını ve mesajlarını aktar. - - Aktarmadan oturum aç - - Mesajlarını ve medyanı aktarmadan devam et - - Yerel yedeklemeni geri yükle - - Mesajlarını cihazına kaydettiğin bir yedekleme dosyasından geri yükle. - Yedekleme indiriliyor… @@ -1356,12 +1342,16 @@ Mesajlarının tamamı Yedekten geri yükle - + Yalnızca son %1$d günde gönderilen veya alınan medya dahildir. Yedeklemen şunlardan oluşur: Yedeği geri yükle + + Son yedeklemen %1$s tarihinde %2$s saatinde yapılmıştır. + + Yedekleme ayrıntıları getiriliyor… Bahsedilmeleri bana bildir @@ -3456,14 +3446,14 @@ Lütfen sorunu anlamamıza yardımcı olabilecek şekilde açıklayın. Lütfen bir seçeneği seç - Bir Şeyler Yolunda Gitmiyor - Özellik İsteği + Bir şeyler yolunda gitmiyor + Özellik isteği Soru Geri bildirim Diğer Ödemeler (MobileCoin) - Bağışlar & Rozetler - Signal Android Backup + Bağışlar ve Rozetler + Signal Android Yedeklemesi Signal Android Hata Ayıklama Günlüğü Gönderimi @@ -4304,6 +4294,8 @@ Bu parolayı kaydettim. Parola olmadan yedeği geri yükleyemem. Yedeği geri yükle Hesabı aktar veya geri yükle + + Geri yükle veya aktar Hesabı aktar Atla Sohbet yedekleri @@ -5124,7 +5116,7 @@ Gruplar - Only messages from group chats + Yalnızca grup sohbetlerindeki mesajlar Ekle @@ -6687,7 +6679,7 @@ Aşağıdaki \"Ayarlara git\" butonuna dokun - \"Ayar alarmlarına ve hatırlatıcılarına izin ver\"i aç. + \"Ayar alarmlarına ve hatırlatıcılarına izin ver\" seçeneğini aç. Ayarlara git @@ -7426,11 +7418,11 @@ Signal medya yedekleme planın iptal edildi çünkü ödemeni işleme alamadık. Bu, silinmeden önce yedeklemendeki medyayı indirmek için son şansın. - Free up %1$s on this device + Bu cihazda %1$s yer boşalt - To finish downloading your Signal Backup your device needs %1$s of storage space. + Signal Yedeklemeni indirmeyi tamamlamak için cihazının %1$s depolama alanına ihtiyacı var. - To free up space offload or delete unused apps or content large in file size. + Yer açmak için kullanılmayan uygulamaları veya dosya boyutu büyük içerikleri kaldır veya sil. Yedekleme aboneliğin yenilenemedi @@ -7458,7 +7450,7 @@ Şimdi değil - Try later + Daha sonra dene Medya silinecek @@ -7470,9 +7462,9 @@ Atla - Skip restore? + Geri yükleme atlansın mı? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Geri yüklemeyi atlarsan yedeklemende kalan medya ve eklentiler, cihazın sonraki yeni yedeklemeyi tamamladığında silinir. @@ -7529,10 +7521,6 @@ Aboneliği değiştir veya iptal et - - - Son yedeklemen %1$s tarihinde %2$s saatinde yapılmıştır. - Konuşma uzunluğu limitleri @@ -7850,5 +7838,101 @@ Hatırlatma simgesi + + + Eski telefonum var + + Hızla başlamak için mevcut Signal hesabından bir kare kod tara + + + Eski telefonum yanımda değil + + Ya da Signal\'i aynı cihaza yeniden yüklüyorsun + + + Hesabı geri yükle veya aktar + + Signal hesabını ve mesaj geçmişini bu cihaza getir. + + Signal Yedeklemelerinden + + Ücretsiz veya ücretli Signal Yedekleme planın + + Bir yedekleme klasöründen + + Bir yedekleme dosyasından + + Kaydettiğin bir yedeği seç + + Eski telefonundan + + Doğrudan eski Android\'inden aktar + + + Yerel yedeklemeni geri yükle + + Mesajlarını cihazına kaydettiğin yedekten geri yükle. Şimdi geri yüklemezsen, daha sonra geri yükleyemezsin. + + + Yedekleme anahtarını gir + + Yedekleme anahtarın, hesabını ve verilerini kurtarmak için gereken 64 haneli bir koddur. + + Yedekleme anahtarın yok mu? + + Yedekleme anahtarı + + Yedekleme anahtarları 64 haneli kurtarma kodu olmadan kurtarılamaz. Yedekleme anahtarını kaybettiysen, Signal yedeklemeni geri yüklemene yardımcı olamaz. + + Eski cihazın varsa yedekleme anahtarını Ayarlar > Sohbetler > Signal Yedeklemeleri bölümünden görüntüleyebilirsin. Ardından Yedekleme anahtarını görüntüle tuşuna dokun. + + Daha fazlasını öğren + + Atla ve geri yükleme + + + Bu kodu eski telefonunla tara + + Signal\'i eski cihazında aç + + Kamera simgesine dokun + + Bu kodu kamera ile tara + + Kare kod oluşturulamıyor + + Eski cihazda tarandı + + Tekrar dene + + + Hesabı aktar + + Hesabın yeni bir cihaza aktarılacaktır. Bu cihaz gruplarını ve kişilerini görebilir, sohbetlerine erişebilir ve adına mesaj gönderebilir. %1$s + + Daha fazlasını öğren + + Hesabı aktar + + Mesajlar ve sohbet bilgileri tüm cihazlarda uçtan uca şifreleme ile korunmaktadır + + Hesabı aktarmak için kilidi aç + + Diğer cihazında devam et + + Hesabını diğer cihazına aktarmaya devam et. + + + Geri yükleme tamamlandı + + Signal hesabın ve mesajların diğer cihazına aktarılmaya başlandı. Signal artık bu cihazda etkin değil. + + Aktarım tamamlandı + + Signal hesabın ve mesajların diğer cihazına aktarıldı. Signal artık bu cihazda etkin değil. + + Tamam + + \ No newline at end of file diff --git a/app/src/main/res/values-ug/strings.xml b/app/src/main/res/values-ug/strings.xml index f500dfde9e..b9fd339ad7 100644 --- a/app/src/main/res/values-ug/strings.xml +++ b/app/src/main/res/values-ug/strings.xml @@ -1287,20 +1287,6 @@ تېخىمۇ كۆپ گۇرۇپپا تونۇشتۇرۇشى قوش… - - - Android ئۈسكۈنىسىدىن يۆتكەيدۇ - - ھېساباتىڭىزنى ۋە ئۇچۇرلىرىڭىزنى كونا ئاندىرويىد ئۈسكۈنىسىدىن يۆتكەڭ. - - يۆتكىمەي تۇرۇپلا تىزىملاپ كىرىش - - ئۇچۇر ۋە مېدىيالارنى يۆتكىمەي تۇرۇپلا داۋاملاشتۇرۇش - - يەرلىك زاپاسلاشنى ئەسلىگە كەلتۈرۈش - - ئۈسكۈنىڭىزدە ساقلىۋالغان زاپاس ھۆججەتتىن ئۇچۇرلىرىڭىزنى ئەسلىگە كەلتۈرۈڭ. - زاپاس مەلۇماتنى چۈشۈرۈۋاتىدۇ… @@ -1318,12 +1304,16 @@ بارلىق ئۇچۇرلىرىڭىز زاپاستىن ئەسلىگە كەلتۈرىدۇ - + پەقەت ئالدىنقى %1$d كۈنلەردە ئەۋەتىلگەن ياكى تاپشۇرۇۋېلىنغان مېدىيالار بار. زاپاسلىغان مەلۇماتلار تۆۋەندىكىلەرنى ئۆز ئىچىگە ئالىدۇ: زاپاسنى ئەسلىگە كەلتۈرۈش + + ئاخىرقى قېتىملىق زاپاسلاش %1$s %2$s دە ئېلىپ بېرىلغان. + + زاپاسلاش تەپسىلاتلىرىنى قوبۇللاش… ئاتالسام ئەسكەرتسۇن @@ -3364,7 +3354,7 @@ باشقىلار چىقىم (MobileCoin) ئىئانە ۋە ئىزنەك - Signal Android Backup + Signal Android زاپاسلانمىسى سىگنال ئاندىرويىد خاتالىق خاتىرىسىنى يوللاش @@ -4193,6 +4183,8 @@ بۇ ئىم ئىبارىسىنى يېزىۋالدىم. بۇ بولمىسا، مەن بىر زاپاسنى ئەسلىگە كەلتۈرەلمەيمەن. زاپاسنى ئەسلىگە كەلتۈرۈش ھېساباتنى يۆتكە ياكى ئەسلىگە كەلتۈر + + ئەسلىگە كەلتۈرۈش ياكى يۆتكەش ھېساباتنى يۆتكە ئاتلا پاراڭ زاپاسلانمىسى @@ -5004,7 +4996,7 @@ گۇرۇپپىلار - Only messages from group chats + پەقەت گۇرۇپپا پاراڭلىرىدىن كەلگەن ئۇچۇرلار قوشۇش @@ -6535,7 +6527,7 @@ تۆۋەندىكى «تەڭشەككە كىرىش» كۇنۇپكىسىنى چېكىڭ - «تەڭشەك ئاگاھلاندۇرۇشى ۋە ئەسكەرتىشكە رۇخسەت.» نى ئېچىڭ + «تەڭشەك ئاگاھلاندۇرۇشى ۋە ئەسكەرتىشكە رۇخسەت قىلىش.» نى ئېچىڭ تەڭشەكلەرگە يۆتكەل @@ -7265,11 +7257,11 @@ سىزنىڭ سىگنال مېدىيا زاپاسلاش پىلانىڭىز ئەمەلدىن قالدۇرۇلدى ، چۈنكى بىز تۆلىگەن ھەققىڭىزنى بىرتەرەپ قىلالمىدۇق. بۇ سىزنىڭ مېدىيا ئۆچۈرۈلۈشتىن بۇرۇن زاپاسلاشتىكى ئەڭ ئاخىرقى پۇرسىتىڭىز. - Free up %1$s on this device + بۇ ئۈسكۈنىدىكى %1$s بوشلۇقنى بېكارلاڭ - To finish downloading your Signal Backup your device needs %1$s of storage space. + سىگنال زاپاسلاشنى چۈشۈرۈشنى تاماملاش ئۈچۈن ئۈسكۈنىڭىز %1$s ساقلاش بوشلۇقىغا ئېھتىياجلىق. - To free up space offload or delete unused apps or content large in file size. + بوشلۇق ھازىرلاش ئۈچۈن ئشلىتىلمىگەن ئەپلەرنى ياكى چوڭ ھەجىملىك مەزمۇنلارنى چۈشۈرۈۋېتىڭ ياكى ئۆچۈرۈڭ. زاپاسلاش مۇشتەرىلىكىڭىز يېڭىلىنىشنى تاماملىيالمىدى @@ -7297,7 +7289,7 @@ ھازىر ئەمەس - Try later + سەل تۇرۇپ قايتا سىناڭ مېدىيا ئۆچۈرۈلىدۇ @@ -7309,9 +7301,9 @@ ئاتلا - Skip restore? + ئەسلىگە كەلتۈرۈشتى ئاتلامسىز؟ - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + ئەگەر «ئەسلىگە كەلتۈرۈشتىن ئاتلاش» نى تاللىسىڭىز زاپاسلىغاندا قېلىپ قالغان مېدىيا ۋە قۇشۇمچە ھۆججەتلەر كېيىنكى قېتىم ئۈسكۈنىڭىز يېڭى زاپاسلاشنى تاماملىغاندا ئۆچۈرۈۋېتىلىدۇ. @@ -7368,10 +7360,6 @@ مۇشتەرىلىكنى ئۆزگەرتىش ياكى بىكار قىلىش - - - ئاخىرقى قېتىملىق زاپاسلاش %1$s %2$s دە ئېلىپ بېرىلغان. - پاراڭ چېكى @@ -7683,5 +7671,101 @@ ئەسكەرتىش سىنبەلگىسى + + + مېنىڭ كونا تېلېفونىم بار + + تېز باشلاش ئۈچۈن نۆۋەتتىكى سىگنال ھېساباتىڭىزدىن QR كودىنى سىكانىرلاڭ + + + كونا تېلېفونۇم يېنىمدا يوق + + ياكى ئوخشاش ئۈسكۈنىدە سىگنالنى قايتا قاچىلاۋاتىسىز + + + ھېساباتنى ئەسلىگە كەلتۈرۈش ياكى يۆتكەش + + سىگنال ھېساباتىڭىز ۋە ئۇچۇر تارىخىڭىزنى بۇ ئۈسكۈنىگە قاچىلاڭ. + + سىگنال زاپاسلانمىسىدىن + + ھەقسىز ياكى ھەقلىق سىگنال زاپاسلاش پىلانىڭىز + + بىر تال زاپاسلاش ھۆججەت قىسقۇچىدىن + + بىر تال زاپاس ھۆججەتتىن + + ساقلىۋالغان زاپاسلاشتىن بىرنى تاللاڭ + + كونا تېلېفونىڭىزدىن + + كونا ئاندىرويىد ئۈسكۈنىڭىزدىن بىۋاسىتە يۆتكەڭ + + + يەرلىك زاپاسلاشنى ئەسلىگە كەلتۈرۈش + + ئۈسكۈنىڭىزدە ساقلىغان زاپاسلاشتىن ئۇچۇرلىرىڭىزنى ئەسلىگە كەلتۈرۈڭ. ئەگەر ھازىر ئەسلىگە كەلتۈرمىسىڭىز ، كېيىن ئەسلىگە كەلتۈرەلمەيسىز. + + + زاپاسلاش ئاچقۇچىڭىزنى كىرگۈزۈڭ + + زاپاسلاش ئاچقۇچىڭىز ھېساباتىڭىز ۋە سانلىق مەلۇماتلىرىڭىزنى ئەسلىگە كەلتۈرۈش ئۈچۈن تەلەپ قىلىنىدىغان 64 خانىلىق كود. + + زاپاسلاش ئاچقۇچى يوقمۇ؟ + + زاپاسلاش ئاچقۇچى + + زاپاسلاشنى 64 خانىلىق ئەسلىگە كەلتۈرۈش كودى بولمىسا ئەسلىگە كەلتۈرگىلى بولمايدۇ. ئەگەر زاپاسلاش ئاچقۇچىڭىزنى يوقىتىپ قويغان بولسىڭىز ، سىگنال ياردەملىشىپ زاپاسلاشنى ئەسلىگە كەلتۈرەلمەيدۇ. + + ئەگەر كونا ئۈسكۈنىڭىز بولسا تەڭشەك> پاراڭلار> سىگنال زاپاسلىرى دىن زاپاسلاش ئاچقۇچىنى كۆرەلەيسىز. ئاندىن «زاپاسلاش ئاچقۇچىنى كۆرۈش» نى چېكىڭ. + + تەپسىلاتى + + ئەسلىگە كەلتۈرۈشتى ئاتلاڭ + + + كونا تېلېفونىڭىز بىلەن بۇ كودنى سىكانىرلاڭ + + كونا ئۈسكۈنىڭىزدە سىگنالنى ئېچىڭ + + كامېرا سىنبەلگىسىنى چېكىڭ + + بۇ كودنى كامېرا بىلەن سىكانىرلاڭ + + QR كودنى ھاسىل قىلالمىدى + + كونا ئۈسكۈنىدە سىكانىرلاندى + + قايتا سىنا + + + ھېساباتنى يۆتكە + + ھېساباتىڭىز يېڭى ئۈسكۈنىگە يۆتكىلىدۇ. بۇ ئۈسكۈنە گۇرۇپپا ۋە ئالاقىداشلىرىڭىزنى كۆرەلەيدۇ ، پاراڭلىرىڭىزنى زىيارەت قىلالايدۇ ۋە نامىڭىزدا ئۇچۇر ئەۋەتەلەيدۇ. %1$s + + تەپسىلاتى + + ھېساباتنى يۆتكە + + ئۇچۇر ۋە پاراڭلار بارلىق ئۈسكۈنىلىرىڭىزدە ئاخىرىغىچە مەخپىيلەشتۈرۈلگەن + + ھېساباتنى يۆتكەش ئۈچۈن قۇلۇپنى ئېچىش + + يەنە بىر ئۈسكىنىڭزدە داۋاملاشتۇرڭ + + ھېساباتىڭىزنى باشقا ئۈسكۈنىڭىزگە يۆتكەشنى داۋاملاشتۇرۇڭ. + + + ئەسلىگە كەلتۈرۈش تاماملاندى + + سىگنال ھېساباتىڭىز ۋە ئۇچۇرلىرىڭىز باشقا ئۈسكۈنىڭىزگە يۆتكىلىشكە باشلىدى. سىگنال ئەمدى بۇ ئۈسكۈنىدە ئىشلىمەيدۇ. + + يۆتكەش تاماملاندى + + سىگنال ھېساباتىڭىز ۋە ئۇچۇرلىرىڭىز باشقا ئۈسكۈنىڭىزگە يۆتكەلدى. سىگنال ئەمدى بۇ ئۈسكۈنىدە ئىشلىمەيدۇ. + + جەزملە + + \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index a4f8691ed2..408346cc48 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -782,7 +782,7 @@ Вилучити з папки - Виберіть папку + Оберіть папку Додано в «%1$s» @@ -1401,20 +1401,6 @@ більше Додати опис групи… - - - Перенести з пристрою з ОС Android - - Перенесіть акаунт і повідомлення зі старого пристрою Android. - - Увійти без перенесення даних - - Продовжити без перенесення повідомлень і медіафайлів - - Відновити з локальної резервної копії - - Відновіть повідомлення з резервної копії, збереженої на вашому пристрої. - Завантаження резервної копії… @@ -1432,12 +1418,16 @@ Усі ваші повідомлення Відновлення з резервної копії - + Вона містить лише медіафайли, надіслані та отримані за минулі %1$d днів. Ваша резервна копія містить: Відновити з резервної копії + + Дата і час створення останньої резервної копії: %1$s, %2$s. + + Отримання інформації про резервну копію… Сповіщати про згадки @@ -2168,7 +2158,7 @@ Неправильний пароль! Розблокувати Molly - Molly Android - Екран блокування + Molly для Android: Блокування екрана Мапа @@ -3661,7 +3651,7 @@ Інше Платежі (MobileCoin) Донати і значки - Signal Android Backup + Резервне копіювання від Signal для Android Подання журналу налагодження Signal Android @@ -3827,7 +3817,7 @@ Вимкнути PIN-код Увімкнути PIN-код Якщо ви вимкнете PIN-код, то в разі повторної реєстрації в Signal усі ваші дані буде втрачено. Щоб цього уникнути, необхідно заздалегідь вручну створити їхню резервну копію. З вимкненим PIN-кодом неможливо ввімкнути блокування реєстрації. - PIN-коди забезпечують шифрування інформації, що зберігається в Signal, щоб отримати до неї доступ могли тільки ви. Ваш профіль, налаштування та контакти відновляться, коли ви знову встановите Signal. Щоб відкрити застосунок, PIN-код не потрібен. + PIN-коди забезпечують шифрування інформації, що зберігається в Signal, щоб отримати до неї доступ могли тільки ви. У разі перевстановлення Signal ви зможете відновити свій профіль, налаштування й контакти. Щоб відкрити застосунок, PIN-код не потрібен. Системна Мова Повідомлення і виклики Signal @@ -4011,7 +4001,7 @@ Увімкнути захист платежів для надсилання коштів у майбутньому? - Забезпечте додатковий захист платежів, увімкнувши розблокування екрана Android чи розблокування відбитком пальця. + Додатково убезпечте платежі, зробивши обов\'язковим розблокування екрана для переказу коштів: або шляхом введення пароля від Android, або скануванням відбитка пальця. Увімкнути @@ -4088,59 +4078,59 @@ Адреса вашого гаманця Копіювати Скопійовано в буфер обміну - Щоб додати кошти — надішліть MobileCoin на адресу вашого гаманця. Розпочніть платіж з вашого облікового запису на біржі, що підтримує MobileCoin, зіскануйте QR-код, а потім скопіюйте адресу вашого гаманця. + Щоб додати кошти, надішліть MobileCoin на адресу свого гаманця. Почніть трансакцію у своєму обліковому записі на біржі, що підтримує MobileCoin, і зіскануйте QR-код або скопіюйте адресу свого гаманця. - Деталі + Докладніше Стан - Платіж відправляється… - Обробка платежу… - Платіж завершено - Платіж не вдався + Надсилання платежу… + Опрацювання платежу… + Платіж здійснено + Не вдалося здійснити платіж Комісія мережі Відправник - Надіслано до %1$s - Ви %1$s в %2$s - %1$s%2$s в %3$s + Надіслано користувачеві %1$s + Ви у %1$s о %2$s + %1$s у %2$s о %3$s - Кому + Кому: Відправник Деталі транзакції, зокрема сума платежу і час транзакції, є частиною особового рахунку MobileCoin. - Плата за очищення монет - «Плата за очищення монет» стягується коли ваші монети неможливо сполучити для завершення транзакції. Очищення дозволить надсилати платежі далі. - Деталі цієї транзакції недоступні + Плата за округлення койнів + «Плата за округлення койнів» стягується, коли ваші койни неможливо згрупувати для здійснення трансакції. Округлення дасть вам змогу продовжити надсилати платежі. + Інша інформація про цю трансакцію відсутня Надісланий платіж Отриманий платіж - Платіж завершено %1$s + Платіж здійснено %1$s Заблокувати номер Переказ - Сканувати QR-код - До: відскануйте або введіть адресу гаманця - Ви можете переказувати MobileCoin платежем на адресу гаманця, що його було надано біржею. Адреса гаманця — це рядок цифр та літер, який зазвичай розміщується під QR-кодом. + Зіскануйте QR-код + Кому: зіскануйте або введіть адресу гаманця + Переказ MobileCoin можна здійснити як переказ на адресу гаманця, надану біржею. Адреса гаманця складається з цифр і літер і зазвичай зазначається під QR-кодом. Далі Неправильна адреса - Перевірте адресу гаманця, на який ви намагаєтеся переказати кошти, і спробуйте ще раз. - Ви не можете переказати на власну адресу гаманця Molly. Введіть адресу гаманця зі свого облікового запису на підтримуваній біржі. - Щоб сканувати QR-код, Molly необхідний доступ до камери. + Перевірте адресу гаманця, на який ви переказуєте кошти, і повторіть переказ. + Ви не можете здійснювати перекази на власний гаманець Molly. Введіть адресу гаманця зі свого облікового запису на підтримуваній біржі. + Щоб зісканувати QR-код, надайте Molly доступ до камери. Для сканування QR-кодів Molly потребує доступу до камери. Перейдіть у налаштування, відкрийте «Дозволи» та виберіть камеру. - Щоб сканувати QR-код, Molly необхідний доступ до камери. + Щоб зісканувати QR-код, надайте Molly доступ до камери. Налаштування - Сканувати QR-код адреси - Скануйте QR-код адреси одержувача платежу. + Зіскануйте адресу + Зіскануйте QR-код адреси одержувача платежу - Запит - Переказати + Запит на платіж + Переказ Доступний баланс: %1$s - Переключити + Перемкнути 1 2 3 @@ -4153,7 +4143,7 @@ . 0 < - Повернутись + Видалення останньої цифри Додати примітку Курси обміну валют приблизні та можуть не відображати остаточну суму. @@ -4171,20 +4161,20 @@ Кому Загальна сума Баланс: %1$s - Платіж відправляється… - Обробка платежу… - Платіж завершено - Платіж не вдався - Обробка оплати продовжуватиметься - Неправильний отримувач + Надсилання платежу… + Опрацювання платежу… + Платіж здійснено + Не вдалося здійснити платіж + Опрацювання платежу триватиме + Неналежний отримувач - Не вдалося відобразити захист платежів + Не вдалося показати заблокований екран - Ви увімкнули захист платежів у налаштуваннях, але його не вдалося відобразити. + Ви ввімкнули в налаштуваннях захист платежів, але його не вдалося показати. - Перейдіть до налаштувань - Ця особа не активувала платежі - Не вдалося запросити комісію мережі. Щоб продовжити цей платіж, натисніть \"ОК\", щоб спробувати ще раз. + Відкрити налаштування + Цей користувач не активував платежі + Не вдалося стягнути комісію мережі. Щоб продовжити цей платіж, натисніть «ОК» і здійсніть переказ ще раз. @@ -4220,10 +4210,10 @@ - %1$d обрано - %1$d обрано - %1$d обрано - %1$d обрано + Вибрано %1$d + Вибрано %1$d + Вибрано %1$d + Вибрано %1$d @@ -4264,7 +4254,7 @@ Ярлик налаштувань Пошук - Закріплено + Закріплені Чати Ви можете прикріпити тільки до %1$d чатів @@ -4272,12 +4262,12 @@ Зображення контакту - Архівовано + Архівовані Новий чат - Відкрити Камеру + Відкрити камеру Чатів поки що немає.\n Напишіть щось друзям. Чатів немає @@ -4339,7 +4329,7 @@ Нова група Налаштування Заблокувати - Позначити усі як прочитані + Позначити всі як прочитані Запросити друзів Показати непрочитані чати @@ -4347,11 +4337,11 @@ Скасувати фільтр за непрочитаними - Скопіювати до буфера обміну - Порівняти із буфером обміну + Скопіювати в буфер обміну + Порівняти з буфером обміну - Signal зазнав технічних труднощів. Ми тяжко працюємо щоб відновити сервіс якомога швидше. + У Signal виникли технічні проблеми. Ми робимо все можливе, щоб якнайшвидше відновити роботу. %1$d%% Наразі Signal не може обробити приватні контакти вашого телефону. @@ -4368,7 +4358,7 @@ - Попередній перегляд медіа + Попередній вигляд медіафайлів Оновити @@ -4398,32 +4388,32 @@ Мінімальна довжина PIN-коду — %1$d цифри Створити новий PIN-код - Ви можете змінити свій PIN-код, поки цей пристрій зареєстровано. - Створити PIN-код + Змінити PIN-код можна, якщо цей пристрій зареєстровано. + Створіть PIN-код PIN-код потрібен для відновлення акаунту й шифрування інформації, яку ви зберігаєте в Signal. - Виберіть більш надійний PIN-код + Придумайте надійніший PIN-код PIN-коди не збігаються. Спробуйте ще раз. Введіть створений PIN-код ще раз. - Підтвердьте ваш ПІН. + Підтвердьте PIN-код. Не вдалося створити PIN-код - Ваш PIN-код не був збережений. Ми попросимо вас пізніше створити PIN-код. - PIN створений. + Ваш PIN-код не збережено. Ми попросимо вас створити PIN-код пізніше. + PIN-код створено. Введіть PIN-код ще раз Створення PIN-коду… - Представляємо PIN-коди - PIN-коди зберігають інформацію в Signal зашифрованою, тому тільки ви можете отримати до неї доступ. Ваш профіль, налаштування та контакти буде відновлено, коли ви встановите застосунок знов. PIN-код не потрібен, щоб відкрити застосунок. + Новинка: PIN-коди + PIN-коди забезпечують шифрування інформації, що зберігається в Signal, щоб отримати до неї доступ могли тільки ви. У разі перевстановлення Signal ви зможете відновити свій профіль, налаштування й контакти. Щоб відкрити застосунок, PIN-код не потрібен. Дізнатися більше Блокування реєстрації = PIN-код Блокування реєстрації тепер називається PIN-кодом, і цей код здатний на більше. Оновіть його одразу. Оновити PIN-код - Створити PIN-код - Дізнатися більше про PIN-коди + Створіть PIN-код + Докладніше про PIN-коди Вимкнути PIN-код @@ -4432,7 +4422,7 @@ Пропустити Надіслати Забули PIN-код? - Неправильний PIN-код. Будь ласка спробуйте ще раз. + Неправильний PIN-код. Введіть код ще раз. Акаунт заблоковано @@ -4448,12 +4438,12 @@ Введіть PIN-код, який ви створили для свого акаунту. Перемкнути клавіатуру - Неправильний PIN-код. Будь ласка спробуйте ще раз. + Неправильний PIN-код. Введіть код ще раз. Забули PIN-код? Неправильний PIN-код - Забули свій PIN-код? + Забули PIN-код? Спроб майже не лишилося! - Реєстрація в Signal - Потрібна допомога з PIN-кодом для Android (v2 PIN) + Реєстрація в Signal: Потрібна допомога з PIN-кодом для Android (v2 PIN) З міркувань конфіденційності та безпеки відновлення PIN-коду не передбачено. Якщо ви забули свій PIN-код, то через %1$d день без активності в акаунті зможете зареєструватися заново через SMS. При цьому всю інформацію з вашого акаунту й весь вміст буде видалено. @@ -4463,10 +4453,10 @@ - Невірний PIN-код. Залишилась %1$d спроба. - Невірний PIN-код. Залишилось %1$d спроб. - Невірний PIN-код. Залишилось %1$d спроб. - Невірний PIN-код. Залишилось %1$d спроб. + Неправильний PIN-код. У вас є ще %1$d спроба. + Неправильний PIN-код. У вас є ще %1$d спроби. + Неправильний PIN-код. У вас є ще %1$d спроб. + Неправильний PIN-код. У вас є ще %1$d спроби. @@ -4484,18 +4474,18 @@ - Залишилось %1$d спроба. - Залишилось %1$d спроб. - Залишилось %1$d спроб. - Залишилось %1$d спроб. + У вас є ще %1$d спроба. + У вас є ще %1$d спроби. + У вас є ще %1$d спроб. + У вас є ще %1$d спроби. - %1$s отримає запит з повідомленням від вас. Ви зможете подзвонити коли ваше повідомлення запиту приймуть. + %1$s отримає від вас запит на повідомлення. Ви зможете дзвонити цьому користувачеві, коли ваш запит на повідомлення буде прийнято. Створіть PIN-код - PIN-коди зберігають інформацію в сервісі Signal зашифровано. + PIN-коди забезпечують шифрування інформації, що зберігається в Signal. Створити PIN-код @@ -4515,7 +4505,7 @@ Завантаження… Триває з\'єднання… - Необхідний дозвіл + Необхідно надати дозвіл Продовжити Не зараз Міграція бази даних Signal @@ -4526,22 +4516,24 @@ У мене збережено цей пароль. Без нього відновлення з резервної копії неможливе. Відновити з резервної копії Перенести або відновити акаунт + + Відновлення або перенесення акаунту Перенести акаунт Пропустити Резервні копії чатів Перенести акаунт Перенесіть акаунт на новий пристрій з ОС Android - Введіть фразу-пароль + Введіть пароль Відновити Імпорт резервних копій з нових версій Signal неможливий Резервна копія містить неправильно сформовані дані - Хибна фраза-пароль! + Неправильний пароль Перевірка… - Залишилось %1$d повідомлень… + Загальна кількість повідомлень: %1$d… Відновити з резервної копії? - Відновити ваші повідомлення та медіа-файли із локальної резервної копії. Якщо Ви не бажаєте цього робити зараз, це можна зробити потім. + Відновіть повідомлення й медіафайли з локальної резервної копії. Якщо ви не відновите їх зараз, то не зможете зробити цього пізніше. Розмір резервної копії: %1$s Часова позначка резервної копії: %1$s Увімкнути локальне резервне копіювання? @@ -4553,31 +4545,31 @@ Щоб увімкнути резервне копіювання, оберіть папку. У ній будуть зберігатися нові резервні копії. Обрати папку Скопійовано в буфер обміну - Відсутня програма для вибору файлів. + Файловий менеджер не знайдено. Ваш пароль від резервної копії Перевірити Ваш пароль від резервної копії правильний - Фраза-пароль була неправильна + Пароль неправильний Створення резервної копії Molly… - Перевіряємо резервну копію Molly… - Створення резервної копії не вдалось + Перевірка резервної копії Molly… + Не вдалося створити резервну копію Ваша папка для резервних копій була видалена або переміщена. - Ваш файл резервної копії занадто великий для зберігання на цьому носії. - Недостатньо місця, щоб зберегти вашу резервну копію. + Файл резервної копії завеликий, для його збереження бракує пам\'яті. + Недостатньо місця для збереження резервної копії. - Не вдалося створити і перевірити останню резервну копію. Будь ласка, створіть ще одну. + Не вдалося створити і перевірити останню резервну копію. Вам необхідно створити нову копію. Резервна копія містить великий файл, який неможливо зберегти. Будь ласка, видаліть його і створіть нову резервну копію. - Натисніть, щоб керувати резервним копіюванням. + Торкніться для налаштування резервного копіювання. Неправильний номер? Подзвоніть мені (%1$02d:%2$02d) Надішліть код ще раз (%1$02d:%2$02d) - Зверніться в службу підтримки Molly - Реєстрація в Molly - код підтвердження для Android - Невірний код + Звернутися в службу підтримки Molly + Реєстрація в Molly: Код підтвердження для Android + Неправильний код Ніколи Невідомо @@ -4614,23 +4606,23 @@ Щоб використовувати блокування екрана, встановіть на цьому пристрої PIN-код, графічний ключ або пароль. PIN-код Signal - Створіть PIN-код + Створити PIN-код Змінити PIN-код Нагадувати PIN-код Вимкнути - Підтвердити PIN + Підтвердьте PIN-код Підтвердьте PIN-код Signal PIN-код необхідно запам’ятати або надійно зберегти, оскільки його неможливо відновити. Якщо ви забудете свій PIN-код, то можете втратити дані при повторній реєстрації акаунту в Signal. - Неправильний PIN-код. Будь ласка спробуйте ще раз. + Неправильний PIN-код. Введіть код ще раз. Не вдалося ввімкнути блокування реєстрації. Не вдалося вимкнути блокування реєстрації. Немає Блокувати реєстрацію Необхідно ввести PIN-код для блокування реєстрації - Ваш PIN-код містить не менше %1$d цифр або символів + У вашому PIN-коді щонайменше %1$d цифр / символів Забагато спроб Ви зробили забагато невдалих спроб введення PIN-коду для блокування реєстрації. Ви знову зможете ввести код через день. - Ви зробили надто багато спроб. Будь ласка, спробуйте ще раз пізніше + Ви зробили забагато спроб. Будь ласка, спробуйте ще раз пізніше Помилка з\'єднання із сервісом Резервні копії @@ -4691,12 +4683,12 @@ Перенести або відновити акаунт - Якщо ви вже зареєстрували акаунт Signal, то можете перенести або відновити свій акаунт і повідомлення + Якщо ви вже реєстрували акаунт Signal, то можете перенести або відновити свій акаунт і повідомлення Перенести з пристрою з ОС Android - Перенести акаунт і повідомлення зі старого пристрою Android. Необхідний доступ до старого пристрою. - У вас повинен бути доступ до свого старого пристрою. + Перенесіть акаунт і повідомлення зі старого пристрою Android. Необхідний доступ до старого пристрою. + Ви повинні мати доступ до свого старого пристрою. Відновити з резервної копії - Відновити ваші повідомлення із локальної резервної копії. Якщо ви не бажаєте зробити це зараз, ви не зможете відновити їх пізніше. + Відновіть повідомлення з локальної резервної копії. Якщо ви не відновите їх зараз, то не зможете зробити цього пізніше. Відновити з локальної резервної копії Відновити з резервної копії Signal Відновити всі текстові повідомлення + медіафайли за минулі 30 днів @@ -4708,83 +4700,83 @@ Продовжити без перенесення повідомлень і медіафайлів - Відкрийте Signal на своєму старому Android-телефоні + Відкрийте Signal на старому телефоні з ОС Android Продовжити 1. - Натисніть фото свого профілю в лівому верхньому кутку, щоб відкрити Настройки + Торкніться свого фото профілю в лівому верхньому кутку, щоб відкрити налаштування 2. "Натисніть «Акаунт»" 3. "Натисніть «Перенести акаунт», а потім «Продовжити» на обох пристроях" - Готуємося до підключення до вашого старого Android-пристрою… - Ще трохи, скоро закінчимо - Очікуємо підключення старого Android-пристрою… + Підготовка до з\'єднання з вашим старим пристроєм з ОС Android… + Ще трохи, скоро все буде готово + Очікуйте з\'єднання зі старим пристроєм з ОС Android… Molly потребує доступу до місцеположення, щоб виявити ваш старий пристрій з ОС Android і з\'єднатися з ним. Необхідно ввімкнути геодані, щоб Molly зміг виявити ваш старий пристрій з ОС Android і з\'єднатися з ним. - Wi-Fi повинен бути включений, щоб Molly зміг виявити ваш старий Android-пристрій і з\'єднатися з ним. При цьому підключатися до будь-якої мережі Wi-Fi не обов\'язково. - Вибачте, але схоже, що цей пристрій не підтримує Wi-Fi Direct. Molly використовує Wi-Fi Direct, щоб виявити ваш старий пристрій Android і з\'єднатися з ним. Однак ви можете відновити свій акаунт з резервної копії. - Відновити резервну копію - При спробі підключення до вашого старого Android-пристрою сталась несподівана помилка. + Необхідно ввімкнути Wi-Fi, щоб Molly зміг виявити ваш старий пристрій з ОС Android і з\'єднатися з ним. Wi-Fi має бути ввімкнутий, але підключатися до мережі Wi-Fi не обов\'язково. + На жаль, імовірно, цей пристрій не підтримує Wi-Fi Direct. Molly використовує Wi-Fi Direct, щоб виявити ваш старий пристрій з ОС Android і з\'єднатися з ним. Ви в будь-якому разі можете відновити свій акаунт з резервної копії. + Відновити з резервної копії + Під час з\'єднання з вашим старим пристроєм з ОС Android сталася несподівана помилка. - Пошук нового Android пристрою… + Пошук нового пристрою Android… Molly потребує доступу до місцеположення, щоб виявити ваш новий пристрій з ОС Android і з\'єднатися з ним. Необхідно ввімкнути геодані, щоб Molly зміг виявити ваш новий пристрій з ОС Android і з\'єднатися з ним. - Wi-Fi повинен бути включений, щоб Molly зміг виявити ваш новий Android-пристрій і з\'єднатися з ним. При цьому підключатися до будь-якої мережі Wi-Fi не обов\'язково. - Вибачте, але схоже, що цей пристрій не підтримує Wi-Fi Direct. Molly використовує Wi-Fi Direct, щоб виявити ваш новий пристрій Android і з\'єднатися з ним. Ви все одно можете створити резервну копію, щоб відновити свій акаунт на новому пристрої Android. + Необхідно ввімкнути Wi-Fi, щоб Molly зміг виявити ваш новий пристрій з ОС Android і з\'єднатися з ним. Wi-Fi має бути ввімкнутий, але підключатися до мережі Wi-Fi не обов\'язково. + На жаль, імовірно, цей пристрій не підтримує Wi-Fi Direct. Molly використовує Wi-Fi Direct, щоб виявити ваш новий пристрій з ОС Android і з\'єднатися з ним. Ви в будь-якому разі можете створити резервну копію, щоб відновити з неї акаунт на новому пристрої з ОС Android. Створити резервну копію - При спробі підключення до вашого нового Android-пристрою сталась несподівана помилка. + Під час з\'єднання з вашим новим пристроєм з ОС Android сталася несподівана помилка. - Не вдалося відкрити настройки Wi-Fi. Будь-ласка, включіть Wi-Fi вручну. + Не вдалося відкрити налаштування Wi-Fi. Увімкніть Wi-Fi вручну. Надати доступ до місцеположення Увімкнути геодані Увімкнути Wi-Fi Помилка під час з\'єднання - Повторити + Перенести ще раз Надіслати журнали налагодження - Перевірити код - Перевірте, що вказаний нижче код однаковий на обох ваших пристроях. Потім натисніть \"Продовжити\". - Цифри не збігаються + Перевірте код + Переконайтеся, що наведений нижче код однаковий на обох ваших пристроях. Потім натисніть «Продовжити». + Коди не збігаються Продовжити - Якщо цифри на ваших пристроях не збігаються, можливо, ви підключилися не до того пристрою. Щоб виправити це, зупиніть перенесення і спробуйте ще раз, тримаючи обидва ваших пристрою близько один до одного. + Якщо коди на ваших пристроях не збігаються, можливо, ви з\'єдналися не з тим пристроєм. Щоб це виправити, зупиніть перенесення даних і повторіть з\'єднання, тримаючи обидва пристрої поряд один з одним. Зупинити перенесення - Неможливо знайти старий пристрій - Неможливо знайти новий пристрій - Переконайтеся, що наступні дозволи та сервіси включені: + Не вдалося знайти старий пристрій + Не вдалося знайти новий пристрій + Перевірте, чи надано такі дозволи й увімкнено такі сервіси: Доступ до місцеположення Геодані Wi-Fi На екрані Wi-Fi Direct, видаліть всі групи які запам\'ятались і від\'єднайте будь-які запрошені або з\'єднані пристрої. Екран Wi-Fi Direct Спробуйте вимкнути і ввімкнути Wi-Fi на обох пристроях. - Переконайтеся, що обидва пристрої знаходяться в режимі переносу. + Переконайтеся, що на обох пристроях здійснюється перенос. Перейти на сторінку підтримки - Спробувати знову - Очікування інших пристроїв. - Натисніть \"Продовжити\" на вашому іншому пристрої, щоб почати передачу. - Натисніть \"Продовжити\" на іншому пристрої… + Повторити спробу + Очікування іншого пристрою + Натисніть «Продовжити» на іншому пристрої, щоб почати передачу. + Натисніть «Продовжити» на іншому пристрої… - Неможливо перенести з новіших версій Signal + Неможливо перенести дані з новіших версій Signal Передані дані були сформовані неправильно - Передача даних - Тримайте обидва пристрої поруч один з одним. Не вимикайте ні один з пристроїв і залишайте Molly відкритим. Перекази захищені наскрізним шифруванням. - Залишилось %1$d повідомлень… + Триває передача даних + Пристрої повинні бути поряд один з одним. Не вимикайте жодного з пристроїв і не закривайте на них Molly. Перенесення даних захищено наскрізним шифруванням. + Кількість повідомлень на цю мить: %1$d… - %1$s%% повідомлень на цей час… + Уже %1$s%% повідомлень… Скасувати - Спробувати знову + Повторити спробу Зупинити перенесення - Весь прогрес перенесення буде втрачено. - Помилка перенесення - Не вдалось перенести + Перенесені на цю мить дані не буде збережено. + Не вдалося здійснити перенос + Неможливо перенести дані Перенесення акаунту @@ -4797,28 +4789,28 @@ Продовжити - Перейдіть до вашого нового пристрою - Ваші дані Signal були перенесені на новий пристрій. Щоб завершити процес перенесення, вам необхідно продовжити реєстрацію на своєму новому пристрої. + Скористайтеся новим пристроєм + Ваші дані з Signal перенесено на новий пристрій. Для завершення процесу переносу необхідно продовжити реєстрацію на новому пристрої. Закрити - Перенесення успішне + Дані успішно перенесено Перенесення завершено - Щоб завершити процес перенесення, вам необхідно продовжити реєстрацію. + Щоб завершити процес переносу, необхідно продовжити реєстрацію. Продовжити реєстрацію Перенести акаунт - Підготовка до підключення до вашого іншого Android-пристрою… - Підготовка до підключення до вашого іншого Android-пристрою… - Пошук іншого Android-пристрою… + Підготовка до підключення до іншого пристрою з ОС Android… + Підготовка до підключення до іншого пристрою з ОС Android… + Пошук іншого пристрою з ОС Android… Триває з\'єднання з іншим пристроєм з ОС Android… Необхідна перевірка Переносимо акаунт… - Завершіть реєстрацію на своєму новому пристрої - Ваш акаунт Signal перенесено на новий пристрій, і тепер вам потрібно завершити реєстрацію на ньому. На цьому пристрої Signal буде неактивний. + Завершіть реєстрацію на новому пристрої + Ваш акаунт Signal перенесено на новий пристрій, але вам ще необхідно завершити на ньому реєстрацію. На цьому пристрої Signal буде деактивовано. Готово Скасувати та активувати цей пристрій @@ -4829,7 +4821,7 @@ Розблокувати Додати до контактів - Неможливо знайти застосунок для відкриття контактів. + Не знайдено застосунок, який міг би відкрити контакти. Додати до групи Додати до іншої групи Переглянути код безпеки @@ -4838,7 +4830,7 @@ Вилучити з групи Вилучити %1$s як адміністратора групи? - "«%1$s» зможе змінювати цю групу і її учасників." + "%1$s зможе змінювати інформацію про цю групу і її склад." Вилучити %1$s з цієї групи? @@ -4848,27 +4840,27 @@ Адміністратор - Прийняти + Схвалити Відхилити - Успадковані та Нові групи - Що таке Успадковані групи? - Успадковані групи — це групи, що не є сумісними з можливостями Нових груп, як-от адміністратори та більш детальні оновлення груп. - Чи можу я оновити успадковану групу? - Успадковані групи поки не можуть бути оновлені до Нових груп, але ви можете створити Нову групу з тими ж учасниками, якщо вони використовують найновішу версію Signal. - В майбутньому Signal надасть можливість оновлювати Успадковані групи. + Застарілі і нові групи + Що таке застарілі групи? + Застарілі групи несумісні з такими особливостями нових груп, як наявність адміністраторів і докладніші оновлення груп. + Чи можна оновити застарілу групу? + Застарілі групи поки що неможливо оновлювати до нових, але ви можете створити нову групу з тими ж учасниками, якщо вони використовують найновішу версію Signal. + У майбутньому Signal забезпечить можливість оновлення застарілих груп. - За цим посиланням усі можуть побачити назву й фото профілю групи та подати запит на приєднання. Діліться ним тільки з тими, кому довіряєте. - Будь-хто у кого є це посилання, може переглянути ім\'я та фото цієї групи та приєднатися до неї. Діліться ним з людьми, яким ви довіряєте. + За цим посиланням усі можуть побачити назву й зображення групи й подати запит на приєднання. Діліться ним тільки з тими, кому довіряєте. + За цим посиланням усі можуть побачити назву й зображення групи й подати запит на приєднання. Діліться ним тільки з тими, кому довіряєте. Надіслати в Molly Копіювати QR-код Поділитися Скопійовано в буфер обміну - Це посилання в даний момент не доступне + Це посилання зараз неактивне Не вдалося відтворити голосове повідомлення @@ -4904,7 +4896,7 @@ Якщо ви не впевнені, хто надіслав запит, перегляньте контакти нижче й вчиніть як пропонується. Інших спільних груп немає. - Немає спільних груп. + Спільних груп немає. %1$d спільна група %1$d спільні групи @@ -4934,9 +4926,9 @@ %1$s є в контактах телефона - Приєднався: %1$s - %1$s та %2$s приєднались - %1$s, %2$s і %3$s приєднались + Приєднався користувач %1$s + Приєдналися %1$s і %2$s + Приєдналися %1$s, %2$s і %3$s %1$s, %2$s і ще %3$d користувач приєдналися до виклику @@ -4945,9 +4937,9 @@ %1$s, %2$s і ще %3$d користувача приєдналися до виклику - Залишив розмову: %1$s - %1$s та %2$s покинули розмову - %1$s,%2$s та %3$s покинули розмову + Користувач %1$s покинув виклик + %1$s і %2$s покинули виклик + %1$s, %2$s і %3$s покинули виклик %1$s, %2$s і ще %3$d користувач покинули виклик @@ -4962,7 +4954,7 @@ - Слабкий сигнал Wi-Fi. Увімкнено стільникову мережу. + Слабкий сигнал Wi-Fi. Виклик здійснюється через мобільний інтернет. Видалення акаунту: @@ -4971,27 +4963,27 @@ Видалення ваших облікових даних і фото профілю Видалення всіх ваших повідомлень Видаліть %1$s з вашого платіжного профілю - Код країни не вказано - Номер не вказано - Наданий номер телефону не належить до вашого акаунту. + Не зазначено код країни + Не зазначено номер + Ви ввели номер телефону, не пов\'язаний з вашим акаунтом. Ви точно хочете видалити свій акаунт? Ваш акаунт Signal буде видалено, а налаштування застосунку скинуто. Після завершення цього процесу застосунок закриється. Не вдалося видалити локальні дані. Ви можете видалити їх вручну в системних налаштуваннях програми. - Відкрийте Налаштування програми + Відкрити налаштування застосунку - Залишаємо групи… + Триває вихід з груп… Видаляємо акаунт… Скасовуємо вашу підписку… - В залежності від кількості груп це може зайняти декілька хвилин + Він може зайняти кілька хвилин — час залежить від кількості ваших груп - Видаляємо дані користувача та скидаємо застосунок + Триває видалення даних користувача й скидання застосунку Акаунт не видалено - Сталася помилка під час завершення процесу видалення. Перевірте з\'єднання з мережею та спробуйте ще. + Під час видалення сталася помилка. Перевірте з\'єднання з мережею і повторіть видалення. Пошук країн @@ -5008,13 +5000,13 @@ Поділитися - Відправити + Надсилання , %1$s - Не вдалося поділитися даними. + Не вдалося виконати запит і поділитися даними. - Не вдалося відправити деяким користувачам + Не вдалося надіслати деяким користувачам Ви можете поділитися до %1$d бесідами @@ -5035,13 +5027,13 @@ Скинути всі кольори Скинути стандартні шпалери Скинути всі шпалери - Скинути всі шпалери + Скинути шпалери Скинути шпалери Скинути шпалери? Обрати з фотографій - Шаблони + Готові шпалери Попередній вигляд @@ -5054,28 +5046,28 @@ - Розведіть чи зведіть два пальці для зміни масштабу, потягніть для зміни положення. + Розведіть два пальці, щоб збільшити, зведіть — щоб зменшити, потягніть, щоб зсунути фото. Це будуть шпалери для всіх чатів. Це будуть шпалери для чату «%1$s». - Помилка встановлення шпалери. + Під час установлення шпалер сталася помилка. Розмити фото Про MobileCoin MobileCoin — нова цифрова валюта, націлена на конфіденційність. - Додавання коштів - Ви можете додавати кошти для використання у Molly надсилаючи MobileCoin на адресу вашого гаманця. - Виведення коштів - Ви можете вивести MobileCoin у будь-який час на біржах, які підтримують MobileCoin. Лише здійсніть переказ коштів на свій рахунок на такій біржі. + Як додавати кошти + Ви можете додавати кошти для використання в Molly, надсилаючи MobileCoin на адресу свого гаманця. + Як виводити кошти + Вивести MobileCoin можна на біржі, яка підтримує MobileCoin. Просто здійсніть переказ на свій рахунок на такій біржі. Сховати цю картку? Сховати Зберегти кодову фразу - Кодова фраза надає ще один спосіб відновлення вашого платіжного профілю. + Кодова фраза — це ще один спосіб відновлення платіжного профілю. Збережіть свою фразу - Оновити PIN-код - Якщо ви маєте високий баланс, пропонуємо створити буквено-цифровий PIN-код для більшої безпеки вашого акаунту. + Оновить PIN-код + Якщо у вас велика сума на рахунку, рекомендуємо створити буквено-цифровий PIN-код для надійнішого захисту акаунту. Оновити PIN-код @@ -5083,20 +5075,20 @@ - Вимкнути гаманець + Деактивувати гаманець Ваш баланс Перед вимкненням платежів рекомендуємо вивести кошти в інший гаманець. Якщо ви цього не зробите, то в разі повторної активації платежів кошти залишаться у вашому гаманці, зв\'язаному із Molly. - Перевести кошти, що залишились на балансі - Вимкнути без переводу коштів - Вимкнути - Вимкнути без переводу коштів? + Перевести залишок коштів + Деактивувати без переведення + Деактивувати + Деактивувати без переведення коштів? Баланс залишиться в гаманці, прив\'язаному до Molly, якщо ви вирішите знов увімкнути платежі. - Помилка при вимкненні гаманця. + Під час деактивації гаманця сталася помилка. Кодова фраза - Огляд кодової фрази + Перегляньте кодову фразу Збережіть кодову фразу Введіть кодову фразу @@ -5127,7 +5119,7 @@ Вставити кодову фразу Кодова фраза Далі - Хибна кодова фраза + Неправильна кодова фраза Перевірте, чи ви ввели %1$d слова, і повторіть спробу. @@ -5137,30 +5129,30 @@ Далі Редагувати Ваша кодова фраза - Запишіть наступні %1$d слова одне за одним. Зберігайте записане у надійному місці. - Переконайтесь, що ви правильно ввели фразу. - Не робіть знімків екрану та не відправляйте електронною поштою. + Запишіть ці %1$d слова в такому ж порядку. Збережіть цей список слів у надійному місці. + Перевірте, чи ви правильно ввели кодову фразу. + Не робіть знімків екрана й не пересилайте електронною поштою. Платіжний профіль відновлено. Неправильна кодова фраза - Переконайтесь, що ви ввели правильно фразу і повторіть спробу. - Копіювати до буферу обміну? - Якщо ви оберете цифровий спосіб збереження кодової фрази — упевніться, що вона надійно зберігається у місці, якому ви довіряєте. + Перевірте, чи ви правильно ввели кодову фразу, і повторіть спробу. + Копіювати в буфер обміну? + Якщо ви хочете зберегти кодову фразу в цифровому форматі, оберіть захищений і надійний носій. Копіювати - Підтвердження кодової фрази - Введіть наступні слова з вашої кодової фрази. + Підтвердьте кодову фразу + Введіть зазначені слова зі своєї кодової фрази. Слово %1$d - Подивитись кодову фразу ще раз + Показати кодову фразу ще раз Готово Кодову фразу підтверджено Введіть кодову фразу - Ввести слово %1$d + Введіть слово %1$d Слово %1$d Далі - Хибне слово + Неправильне слово %1$s переказав вам %2$s @@ -5364,7 +5356,7 @@ Групи - Only messages from group chats + Тільки повідомлення з групових чатів Додати @@ -5487,7 +5479,7 @@ Стандартний таймер для нових чатів Установіть стандартний таймер для тимчасових повідомлень у всіх нових чатах, які ви починаєте. - Увімкніть розблокування екрана Android чи розблокування відбитком пальця для переказу коштів + Зробити обов\'язковим розблокування екрана Android або сканування відбитка пальця для переказу коштів Не вдалось увімкнути захист платежів @@ -6991,7 +6983,7 @@ Натисніть «Перейти до налаштувань» нижче - Увімкніть «Дозволити сповіщення і нагадування». + Увімкніть «Дозволити встановлювати будильники й нагадування». Перейдіть до налаштувань @@ -7226,7 +7218,7 @@ - Запроваджуємо групові історії + Новинка: групові історії Діліться оновленнями історій у своєму груповому чаті. @@ -7748,11 +7740,11 @@ Резервне копіювання медіафайлів у Signal було скасовано, тому що ми не змогли опрацювати ваш платіж. Ви маєте останню можливість завантажити медіафайли з резервної копії, поки їх не видалено. - Free up %1$s on this device + Звільніть %1$s на цьому пристрої - To finish downloading your Signal Backup your device needs %1$s of storage space. + Щоб завершити завантаження резервної копії від Signal, на пристрої має бути %1$s вільної пам\'яті. - To free up space offload or delete unused apps or content large in file size. + Звільніть місце, усунувши або видаливши непотрібні застосунки або файли великого розміру. Не вдалося продовжити передплату на резервне копіювання @@ -7764,7 +7756,7 @@ Дізнатися більше - Пропустити відновлення + Не відновлювати Створити копію зараз @@ -7780,7 +7772,7 @@ Не зараз - Try later + Пізніше Видалення медіафайлів @@ -7792,9 +7784,9 @@ Пропустити - Skip restore? + Пропустити відновлення? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Якщо не відновити дані з резервної копії, решту медіафайлів і вкладень з неї буде видалено під час створення нової резервної копії. @@ -7851,10 +7843,6 @@ Змінити або скасувати передплату - - - Дата і час створення останньої резервної копії: %1$s, %2$s. - Обмеження чатів @@ -8184,5 +8172,101 @@ Значок нагадування + + + У мене лишився старий телефон + + Для швидшого початку зіскануйте QR-код пристроєм, на якому зараз використовуєте Signal + + + У мене не лишилося старого телефона + + Або ви перевстановлюєте Signal на цьому ж пристрої + + + Відновлення або перенесення акаунту + + Перенесіть акаунт Signal і історію повідомлень на цей пристрій. + + Зі сховища резервних копій Signal + + Безкоштовне або платне резервне копіювання від Signal + + З папки з резервною копією + + З файлу резервної копії + + Виберіть збережену резервну копію + + Зі старого телефона + + Перенесіть безпосередньо зі старого пристрою з ОС Android + + + Відновлення з локальної резервної копії + + Відновіть повідомлення з резервної копії, збереженої на пристрої. Якщо ви не відновите їх зараз, то не зможете зробити цього пізніше. + + + Введіть ключ від резервної копії + + Ключ від резервної копії — це 64-значний код, який дозволяє відновити акаунт і пов\'язані з ним дані. + + Не зберегли ключа? + + Ключ від резервної копії + + Резервну копію неможливо відновити без відповідного 64-значного ключа. Якщо ви втратили доступ до свого ключа, Signal не допоможе вам відновити резервну копію. + + Якщо у вас лишився старий пристрій, ключ від резервної копії можна переглянути, відкривши налаштування > Чати > Резервне копіювання від Signal. Потім виберіть «Переглянути ключ від резервної копії». + + Докладніше + + Продовжити без відновлення + + + Зіскануйте цей QR-код старим телефоном + + Відкрийте Signal на старому телефоні + + Торкніться значка камери + + Зіскануйте QR-код камерою + + Не вдалося створити QR-код + + Зіскановано старим пристроєм + + Повторити спробу + + + Перенесення акаунту + + Ваш акаунт буде перенесено на новий пристрій. Такий пристрій зможе бачити ваші групи й контакти, отримає доступ до ваших чатів, і з нього надсилатимуться повідомлення від вашого імені. %1$s + + Докладніше + + Перенести акаунт + + Повідомлення й інформацію про чати захищено наскрізним шифруванням на всіх пристроях + + Розблокуйте, щоб перенести акаунт + + Продовжте на новому пристрої + + Щоб продовжити перенесення акаунту, візьміть новий пристрій. + + + Відновлення завершено + + Почалося перенесення вашого акаунту Signal і повідомлень на новий пристрій. На цьому пристрої Signal більше не активний. + + Перенесення завершено + + Ваш акаунт Signal і повідомлення перенесено на новий пристрій. На цьому пристрої Signal більше не активний. + + Зрозуміло + + \ No newline at end of file diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index e3fcacc459..fc3762b5fa 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -1325,20 +1325,6 @@ مزید گروپ کی تفصیل شامل کریں… - - - Android آلہ سے منتقل کریں - - اپنی پرانی Android ڈیوائس سے اپنا اکاؤنٹ اور میسجز منتقل کریں۔ - - منتقل کرنے کے بغیر لاگ ان کریں - - اپنے میسجز اور میڈیا کو منتقل کیے بغیر جاری رکھیں - - لوکل بیک اپ کو ری اسٹور کریں - - وہ بیک اپ فائل جو آپ نے اپنی ڈیوائس میں محفوظ کی تھی سے میسجز کو بحال کریں۔ - بیک اپ ڈاؤن لوڈ کر رہے ہیں… @@ -1356,12 +1342,16 @@ آپ کے تمام میسجز بیک اپ سے بحال کریں - + صرف گزشتہ%1$d دنوں میں بھیجا گیا یا موصول کیا گیا میڈیا شامل ہے۔ آپ کے بیک اپ میں شامل ہوتا ہے: بیک اپ بحال کریں + + آپ کا آخری بیک اپ %2$s بجے %1$s کو کیا گیا۔ + + بیک اپ کی تفصیلات حاصل کی جا رہی ہیں… جب مجھے کوئی ذکر کرتا ہے تو مجھے مطلع کریں @@ -3463,7 +3453,7 @@ دوسرے ادائیگیاں (MobileCoin) عطیات اور بیجز - Signal Android Backup + Signal Android کا بیک اپ Signal Android میں ڈی بَگ لاگ جمع کروانا @@ -4304,6 +4294,8 @@ میں نے یہ پاسفریز نیچے لکھا ہے۔اس کے بغیر، یہ بیک اپ بحال کرنے کے قابل نہیں گا۔ بیک اپ بحال کریں اکاؤنٹ کو منتقل یا بحال کریں + + ری اسٹور کریں یا ٹرانسفر کریں اکاؤنٹ منتقل کریں چھوڑ دو چیٹ کے بیک اپس @@ -5124,7 +5116,7 @@ گروپس - Only messages from group chats + صرف گروپ چیٹس کے میسجز شامل کریں @@ -6687,7 +6679,7 @@ ذیل میں \"سیٹنگز پر جائیں\" بٹن پر ٹیپ کریں - \"الارمز اور یاد دہانیوں کی سیٹنگز کی اجازت دیں\" آن کریں۔ + \"الارمز اور یاد دہانیوں کو سیٹ کرنے کی اجازت دیں\" کو آن کریں۔ سیٹنگز پر جائیں @@ -7426,11 +7418,11 @@ آپ کے Signal کے میڈیا کا بیک اپ پلان منسوخ کر دیا گیا ہے کیونکہ ہم آپ کی پیمنٹ پر کارروائی نہیں کر سکے۔ آپ کے پاس اپنے بیک اپ میں موجود میڈیا کو ڈاؤن لوڈ کرنے کا یہ آخری موقع ہے اس سے پہلے کہ اسے حذف کر دیا جائے۔ - Free up %1$s on this device + اس ڈیوائس پر %1$s خالی کریں - To finish downloading your Signal Backup your device needs %1$s of storage space. + آپ کا Signal بیک اپ ڈاؤن لوڈ مکمل کرنے کے لیے، آپ کی ڈیوائس کو %1$s اسٹوریج درکار ہے۔ - To free up space offload or delete unused apps or content large in file size. + جگہ خالی کرنے کے لیے، غیر استعمال شدہ ایپس یا زیادہ سائز کے مواد والی فائل کو ہٹائیں یا حذف کریں۔ آپ کے بیک اپس کی سبسکرپشن کی تجدید میں ناکامی ہو گئی @@ -7458,7 +7450,7 @@ ابھی نہیں - Try later + تھوڑی دیر بعد کوشش کریں میڈیا حذف کر دیا جائے گا @@ -7470,9 +7462,9 @@ چھوڑ دو - Skip restore? + بحال کرنا چھوڑ دیں؟ - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + اگر آپ بحال کرنا چھوڑ دیتے ہیں، تو اگلی بار جب آپ کی ڈیوائس ایک نیا بیک اپ مکمل کرے گی تو اس وقت آپ کے بیک اپ میں موجود باقی میڈیا اور منسلکات حذف ہو جائیں گی۔ @@ -7529,10 +7521,6 @@ سبسکرپشن کو تبدیل یا منسوخ کریں - - - آپ کا آخری بیک اپ %2$s بجے %1$s کو کیا گیا۔ - چیٹ کی حدود @@ -7850,5 +7838,101 @@ یاد دہانی کا آئیکن + + + میرے پاس اپنا پرانا فون موجود ہے + + فوری آغاز کرنے کے لیے اپنے موجودہ Signal اکاؤنٹ سے QR کوڈ کو اسکین کریں + + + میرے پاس اپنا پرانا فون موجود نہیں ہے + + یا آپ اسی ڈیوائس پر Signal کو دوبارہ انسٹال کر رہے ہیں + + + ری اسٹور کریں یا اکاؤنٹ ٹرانسفر کریں + + اس ڈیوائس پر اپنا Signal اکاؤنٹ اور میسج ہسٹری حاصل کریں۔ + + Signal بیک اپس سے + + آپ کا مفت یا ادائیگی شدہ Signal بیک اپ پلان + + کسی بیک اپ فولڈر سے + + بیک اپ کی فائل سے + + وہ بیک اپ منتخب کریں جو آپ نے محفوظ کیا ہے + + اپنے پرانے فون سے + + اپنے پرانے Android سے براہ راست ٹرانسفر کریں + + + لوکل بیک اپ کو ری اسٹور کریں + + اپنی ڈیوائس پر جو آپ نے بیک اپ محفوظ کیا ہے اس سے اپنے میسجز ری اسٹور کریں۔ اگر آپ ابھی ری اسٹور نہیں کرتے، تو آپ بعد میں ری اسٹور نہیں کر سکیں گے۔ + + + اپنی بیک اپ کیی درج کریں + + آپ کی بیک اپ کیی 64 ہندسوں کا ایک کوڈ ہے جو آپ کے اکاؤنٹ اور ڈیٹا کو بحال کرنے کے لیے درکار ہوتی ہے۔ + + بیک اپ کیی نہیں ہے؟ + + بیک اپ کیی + + بیک اپس کو ان کے 64 ہندسوں کے بحالی کے کوڈ کے بغیر بحال نہیں کیا جا سکتا۔ اگر آپ سے اپنی بیک اپ کیی گم ہو گئی ہے، تو Signal آپ کے بیک اپ کو ری اسٹور کرنے میں مدد نہیں کر سکتا۔ + + اگر آپ کے پاس اپنی پرانی ڈیوائس موجود ہے تو آپ سیٹنگز >چیٹس >Signal بیک اپس میں جا کر اپنی بیک اپ کیی دیکھ سکتے ہیں۔ اس کے بعد بیک اپ کیی دیکھیں پر ٹیپ کریں۔ + + مزید جانیں + + اسکپ کریں اور ری اسٹور نہ کریں + + + اپنے پرانے فون سے اس کوڈ کو اسکین کریں + + اپنی پرانی ڈیوائس پر Signal کھولیں + + کیمرہ آئیکن پر ٹیپ کریں + + اس کوڈ کو کیمرے کے ذریعے اسکین کریں + + QR کوڈ تیار نہیں ہو سکا + + پرانی ڈیوائس پر اسکین کردہ + + دوبارہ کوشش کریں + + + اکاؤنٹ منتقل کریں + + آپ کا اکاؤنٹ ایک نئی ڈیوائس پر ٹرانسفر ہو جائے گا۔ یہ ڈیوائس آپ کے گروپس اور رابطوں کو دیکھ سکے گی، آپ کی چیٹس تک رسائی حاصل کر سکے گی، اور آپ کے نام سے میسجز بھیج سکے گی۔ %1$s + + مزید جانیں + + اکاؤنٹ منتقل کریں + + تمام ڈیوائسز پر میسجز اور چیٹس کی معلومات اینڈ ٹو اینڈ انکرپشن کے ذریعے محفوظ شدہ ہیں + + اکاؤنٹ ٹرانسفر کرنے کے لیے ان لاک کریں + + اپنی دوسری ڈیوائس پر جاری رکھیں + + اپنی دوسری ڈیوائس پر اپنا اکاؤنٹ ٹرانسفر کرنا جاری رکھیں۔ + + + بحالی مکمل + + آپ کا Signal اکاؤنٹ اور میسجز آپ کی دوسری ڈیوائس پر ٹرانسفر ہونا شروع ہو گئے ہیں۔ Signal اب اس ڈیوائس پر غیر فعال ہے۔ + + منتقلی مکمل + + آپ کا Signal اکاؤنٹ اور میسجز آپ کی دوسری ڈیوائس پر ٹرانسفر ہو گئے ہیں۔ Signal اب اس ڈیوائس پر غیر فعال ہے۔ + + ٹھیک ہے + + \ No newline at end of file diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index b840f2a38b..a76065af8b 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -1287,20 +1287,6 @@ thêm Thêm mô tả nhóm… - - - Chuyển từ thiết bị Android - - Chuyển tài khoản và tin nhắn từ thiết bị Android cũ của bạn. - - Đăng nhập và không chuyển dữ liệu - - Tiếp tục và không chuyển tin nhắn cùng tệp đa phương tiện - - Khôi phục bản sao lưu cục bộ - - Khôi phục tin nhắn từ một bản sao lưu bạn đã lưu trên thiết bị của mình. - Đang tải bản sao lưu… @@ -1318,12 +1304,16 @@ Tất cả tin nhắn Khôi phục từ bản sao lưu - + Chỉ tập tin đa phương tiện được gửi và nhận trong %1$d ngày qua được bao gồm. Bản sao lưu bao gồm: Khôi phục từ bản sao lưu + + Bản sao lưu gần nhất của bạn là vào lúc %2$s ngày %1$s. + + Đang thu thập thông tin bản sao lưu… Thông báo khi tôi được nhắc tên @@ -3364,7 +3354,7 @@ Khác Lựa chọn Thanh toán (MobileCoin) Khoản ủng hộ & Huy hiệu - Signal Android Backup + Bản sao lưu Signal Android Gửi Nhật Ký Gỡ Lỗi Signal Android @@ -4193,6 +4183,8 @@ Tôi đã ghi lại mật khẩu này. Không có nó, tôi sẽ không thể khôi phục bản sao lưu. Khôi phục từ bản sao lưu Chuyển hoặc khôi phục tài khoản + + Khôi phục hoặc chuyển Chuyển tài khoản Bỏ qua Sao lưu tin nhắn @@ -5004,7 +4996,7 @@ Nhóm - Only messages from group chats + Chỉ có tin nhắn từ cuộc trò chuyện nhóm Thêm @@ -6535,7 +6527,7 @@ Nhấn nút \"Vào Cài đặt\" bên dưới - Bật \"Cho phép Thông báo và Nhắc nhở.\" + Bật \"Cho phép cài đặt báo thức và lời nhắc\". Đến mục cài đặt @@ -7265,11 +7257,11 @@ Gói đăng ký sao lưu tập tin đa phương tiện Signal của bạn đã bị hủy vì chúng tôi không thể xử lý khoản thanh toán của bạn. Đây là cơ hội cuối cùng để bạn tải tập tin đa phương tiện trong bản sao lưu của mình trước khi những nội dung này bị xóa. - Free up %1$s on this device + Giải phóng %1$s trên thiết bị này - To finish downloading your Signal Backup your device needs %1$s of storage space. + Để hoàn thành tải Bản sao lưu Signal của bạn, thiết bị của bạn cần %1$s dung lượng trống. - To free up space offload or delete unused apps or content large in file size. + Để giải phóng dung lượng, xóa ứng dụng bạn không dùng hoặc nội dung có dung lượng lớn. Gia hạn không thành công gói đăng ký sao lưu của bạn @@ -7297,7 +7289,7 @@ Để sau - Try later + Thử sau Tập tin đa phương tiện sẽ được xóa @@ -7309,9 +7301,9 @@ Bỏ qua - Skip restore? + Bỏ qua khôi phục? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + Nếu bạn bỏ qua việc khôi phục, các tập tin đa phương tiện và tập tin đính kèm còn lại trong bản sao lưu của bạn sẽ được xóa trong lần tiếp theo thiết bị của bạn hoàn tất một bản sao lưu mới. @@ -7368,10 +7360,6 @@ Đổi hoặc hủy gói đăng ký - - - Bản sao lưu gần nhất của bạn là vào lúc %2$s ngày %1$s. - Giới hạn của cuộc trò chuyện @@ -7683,5 +7671,101 @@ Biểu tượng lời nhắc + + + Tôi có điện thoại cũ + + Quét mã QR từ tài khoản Signal điện thoại của bạn để nhanh chóng bắt đầu + + + Tôi không có điện thoại cũ + + Hoặc bạn đang cài lại Signal trên cùng thiết bị + + + Khôi phục hoặc chuyển tài khoản + + Mang tài khoản Signal và lịch sử tin nhắn của bạn lên thiết bị này. + + Bản sao lưu từ Signal + + Gói sao lưu Signal miễn phí hoặc trả phí của bạn + + Từ thư mục sao lưu + + Từ tập tin sao lưu + + Chọn một tập tin sao lưu bạn đã lưu + + Từ điện thoại cũ của bạn + + Chuyển trực tiếp từ điện thoại Android cũ của bạn + + + Khôi phục bản sao lưu cục bộ + + Khôi phục tin nhắn từ một tập tin sao lưu bạn đã lưu trên thiết bị của mình. Nếu bạn không khôi phục bây giờ, bạn sẽ không khôi phục được sau này. + + + Nhập mã khóa sao lưu + + Yêu cầu có mã khóa sao lưu có 64 chữ số của bạn để khôi phục tài khoản và dữ liệu. + + Không có mã khóa sao lưu? + + Mã khóa sao lưu + + Không thể khôi phục bản sao lưu nếu không có mã khóa sao lưu 64 chữ số. Nếu bạn đã để mất mã khóa sao lưu của mình, Signal không thể hỗ trợ bạn khôi phục bản sao lưu. + + Nếu có thiết bị cũ của mình, bạn có thể xem mã khóa sao lưu trong Cài đặt > Trò chuyện > Sao lưu. Sau đó nhấn Xem mã khóa sao lưu. + + Tìm hiểu thêm + + Bỏ qua và không khôi phục + + + Quét mã với điện thoại của bạn + + Mở Signal trên thiết bị cũ của bạn + + Nhấn biểu tượng camera + + Quét mã này với camera + + Không thể tạo mã QR + + Đã quét trên thiết bị cũ + + Thử lại + + + Chuyển tài khoản + + Tài khoản của bạn sẽ dược chuyển đến một thiết bị mới. Thiết bị này sẽ có thể thấy được nhóm và liên hệ của bạn, truy cập các cuộc trò chuyện của bạn, và gửi tin nhắn với danh tính của bạn. %1$s + + Tìm hiểu thêm + + Chuyển tài khoản + + Thông tin tin nhắn và cuộc trò chuyện được bảo vệ bằng hình thức mã hóa đầu cuối trên tất cả thiết bị + + Mở khóa để chuyển tài khoản + + Tiếp tục trên thiết bị khác của bạn + + Tiếp tục chuyển tài khoản trên thiết bị khác của bạn. + + + Hoàn tất khôi phục + + Tài khoản và tin nhắn Signal của bạn đã bắt đầu được chuyển đến thiết bị khác của bạn. Signal hiện không còn hoạt động trên thiết bị này. + + Chuyển hoàn tất + + Tài khoản và tin nhắn Signal của bạn đã được chuyển đến thiết bị khác của bạn. Signal hiện không còn hoạt động trên thiết bị này. + + OK + + \ No newline at end of file diff --git a/app/src/main/res/values-yue/strings.xml b/app/src/main/res/values-yue/strings.xml index 67255f366f..df9f59640c 100644 --- a/app/src/main/res/values-yue/strings.xml +++ b/app/src/main/res/values-yue/strings.xml @@ -1287,20 +1287,6 @@ 更多 加返個谷嘅描述… - - - 由另一部 Android 機搬過嚟 - - 由你部舊嘅 Android 機轉移帳戶同埋訊息過嚟。 - - 淨係登入,唔轉移 - - 唔轉移訊息同媒體就繼續 - - 還原本機備份 - - 由你儲存喺裝置嘅備份檔案還原訊息。 - 下載緊備份… @@ -1318,12 +1304,16 @@ 你嘅所有訊息 用備份還原 - + 只包括過去 %1$d 日內收發嘅媒體。 你嘅備份包括: 還原備份 + + 上次備份時間係 %1$s 嘅 %2$s。 + + 攞緊備份詳細資料… 點名講起我嘅時候通知 @@ -3362,9 +3352,9 @@ 發問 意見 其他 - 付款 (MobileCoin) + 付款(MobileCoin) 課金與襟章 - Signal Android Backup + Signal Android 備份 提交 Signal Android 除錯紀錄 @@ -4193,6 +4183,8 @@ 本人寫低咗呢個密碼㗎嘞。本人明白,無咗呢個密碼,個備份係還原唔到,得物無所用。 還原備份 轉移或還原帳戶 + + 還原或者轉移 轉移帳戶 飛過 傾偈備份 @@ -5004,7 +4996,7 @@ - Only messages from group chats + 只係得群組聊天嘅訊息 加入去 @@ -7265,11 +7257,11 @@ 由於我哋處理唔到你嘅付款,所以 Signal 媒體備份計劃取消咗。呢次係備份刪除之前下載媒體嘅最後機會。 - Free up %1$s on this device + 要喺呢部機度搵多 %1$s 空間 - To finish downloading your Signal Backup your device needs %1$s of storage space. + 要搞掂埋下載 Signal 備份,你部機要 %1$s 嘅儲存空間。 - To free up space offload or delete unused apps or content large in file size. + 要搵多啲儲存空間,請卸載或者刪除已經唔用嘅 app 或者檔案比較大嘅內容。 你嘅備份課金計劃續唔到期 @@ -7297,7 +7289,7 @@ 遲啲先啦 - Try later + 遲啲先試 媒體將會刪除 @@ -7309,9 +7301,9 @@ 跳過 - Skip restore? + 係咪要跳過還原? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + 如果你跳過還原,備份入面剩低嘅媒體同附件會喺你部機下次完成新備份嗰時刪除。 @@ -7368,10 +7360,6 @@ 更改或者取消課金計劃 - - - 上次備份時間係 %1$s 嘅 %2$s。 - 聊天訊息量上限 @@ -7683,5 +7671,101 @@ 提醒圖示 + + + 部舊手機喺我手上 + + 用你而家嘅 Signal 帳戶掃描二維碼,就可以即刻開始用喇 + + + 部舊手機唔喺我手上 + + 或者如果你喺同一部機度重新安裝緊 Signal + + + 還原或者轉移帳戶 + + 將你嘅 Signal 帳戶同訊息紀錄轉入呢部機。 + + 由 Signal 備份 + + 你嘅免費或者課咗金嘅 Signal 備份計劃 + + 由備份資料夾 + + 由備份檔案 + + 揀你儲存咗嘅備份 + + 由你部舊手機 + + 直接由你部舊嘅 Android 機轉移 + + + 還原本機備份 + + 由你儲存咗喺部機嘅備份還原訊息。如果而家唔還原嘅話,遲啲就冇得再搞㗎喇。 + + + 輸入你嘅備份金鑰 + + 你嘅備份金鑰係用嚟恢復你嘅帳戶同資料嘅一組 64 位數字代碼。 + + 冇備份金鑰? + + 備份金鑰 + + 如果你冇 64 位數字嘅恢復代碼,就冇得還原備份㗎喇。如果你唔見咗個備份金鑰,Signal 都冇辦法幫你還原備份。 + + 如果你有部舊機喺手,就可以喺「設定」 > 「聊天」 > 「Signal 備份」入面睇返你嘅備份金鑰。然後㩒一下「睇吓備份金鑰」。 + + 了解詳情 + + 跳過同埋唔還原 + + + 用你部舊手機掃描呢個二維碼 + + 喺你部舊機打開 Signal + + 㩒一吓相機圖示 + + 用相機掃描呢個二維碼 + + 建立唔到二維碼 + + 已經用部舊機掃瞄咗 + + 再試一次 + + + 轉移帳戶 + + 你嘅帳戶會轉移去新機。呢部機會睇到你嘅群組、聯絡人同埋聊天內容,仲可以用你嘅名義傳送訊息。%1$s + + 了解詳情 + + 轉移帳戶 + + 所有裝置上嘅訊息同聊天資料都受到端對端加密保護 + + 解鎖嚟轉移帳戶 + + 喺另一部機繼續 + + 喺另一部機繼續轉移你嘅帳戶。 + + + 還原完成 + + 你嘅 Signal 帳戶同訊息已經開始轉移去另一部機。呢部機嘅 Signal 將會暫停使用。 + + 轉移完成 + + 你嘅 Signal 帳戶同訊息已經轉移咗去另一部機。呢部機嘅 Signal 將會暫停使用。 + + 確定 + + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ca80b01f3f..cae3658f8e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1287,20 +1287,6 @@ 更多 添加群组描述… - - - 从 Android 设备转移 - - 从您的旧 Android 设备上转移您的账户和消息。 - - 登录但不转移 - - 继续但不转移您的消息和媒体 - - 还原本地备份 - - 从您保存在设备上的备份文件中恢复消息。 - 正在下载备份… @@ -1318,12 +1304,16 @@ 您的所有消息 从备份还原 - + 备份中只包含过去 %1$d 天发送或接收的媒体。 您的备份包括: 还原备份 + + 您上一次备份的时间是 %1$s %2$s。 + + 正在获取备份详情… 提到我时通知我 @@ -3364,7 +3354,7 @@ 其他 付款(MobileCoin) 捐款和徽章 - Signal Android Backup + Signal Android 备份 Signal Android 调试日志提交 @@ -4193,6 +4183,8 @@ 我已记下该密码。没有密码,将无法还原备份。 还原备份 转移或还原帐户 + + 恢复或转移 转移帐户 跳过 聊天备份 @@ -5004,7 +4996,7 @@ 群聊 - Only messages from group chats + 仅包含群聊消息 添加 @@ -7265,11 +7257,11 @@ 由于我们无法处理您的付款,您的 Signal 媒体备份计划已取消。这是您在备份被删除之前下载备份媒体的最后机会。 - Free up %1$s on this device + 在此设备上释放 %1$s - To finish downloading your Signal Backup your device needs %1$s of storage space. + 如要完成 Signal 备份的下载,您的设备需要 %1$s 的存储空间。 - To free up space offload or delete unused apps or content large in file size. + 如要释放空间,请卸载或删除不使用的应用或占用空间较大的内容。 您的备份套餐无法续期 @@ -7297,7 +7289,7 @@ 稍后再说 - Try later + 稍后再试 媒体将被删除 @@ -7309,9 +7301,9 @@ 跳过 - Skip restore? + 要跳过恢复吗? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + 如果您跳过恢复备份中剩余的媒体和附件,它们将会在下次设备完成新备份时被删除。 @@ -7368,10 +7360,6 @@ 更改或取消定期捐款 - - - 您上一次备份的时间是 %1$s %2$s。 - 聊天限制 @@ -7683,5 +7671,101 @@ 提醒图标 + + + 我可以用旧手机 + + 从您当前的 Signal 账户扫描二维码以快速开始操作 + + + 我无法用旧手机 + + 您可以在同一个设备上重新安装 Signal + + + 恢复或转移账户 + + 将您的 Signal 账户和消息记录恢复或转移到此设备上。 + + 从 Signal 备份 + + 您的免费或付费备份套餐 + + 从备份文件夹 + + 从备份文件 + + 选择您已保存的备份 + + 从您的旧手机 + + 直接从您的旧 Android 手机转移 + + + 还原本地备份 + + 从您保存在设备上的备份中恢复消息。如果现在不恢复,您以后将无法恢复这些消息。 + + + 输入您的备份密钥 + + 您的备份密钥是一个 64 位代码,它是恢复账户和数据的必要密钥。 + + 没有备份密钥? + + 备份密钥 + + 如果没有 64 位恢复代码,备份将无法恢复。如果您丢失了自己的备份密钥,Signal 将无法帮助您恢复备份。 + + 如果您的旧设备可用,您可以在“设置 > 聊天 > Signal 备份”中查看您的备份密钥。点击“查看备份密钥”即可查看。 + + 了解详情 + + 跳过并且不恢复 + + + 用您的旧手机扫描此二维码 + + 在您的旧设备上打开 Signal + + 点击相机图标 + + 用相机扫描此二维码 + + 无法生成二维码 + + 已在旧设备上扫描 + + 重试 + + + 转移帐户 + + 您的账户将会转移到新设备上。此设备将能够看到您的群组和联系人,访问您的聊天,并以您的名义发送消息。%1$s + + 了解详情 + + 转移帐户 + + 消息和聊天信息在所有设备上均受端对端加密保护 + + 解锁以转移账户 + + 在您的其他设备上继续 + + 在您的其他设备上继续转移您的账户。 + + + 还原完成 + + 您的 Signal 账户及消息已开始转移到您的其他设备上。现在 Signal 在此设备上处于不激活状态。 + + 转移完成 + + 您的 Signal 账户及消息已转移到您的其他设备上。现在 Signal 在此设备上处于不激活状态。 + + 好的 + + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 502baa08aa..13737999c6 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -1287,20 +1287,6 @@ 更多 新增群組描述… - - - 從 Android 裝置轉移 - - 從舊的 Android 裝置轉移你的帳戶和訊息。 - - 登入而不作轉移 - - 不轉移訊息及媒體並繼續 - - 還原本機備份 - - 從你儲存在裝置上的備份檔案還原訊息。 - 正在下載備份… @@ -1318,12 +1304,16 @@ 所有你的訊息 從備份還原 - + 只包含過去 %1$d 天內發送或接收的媒體。 你的備份包括: 備份還原 + + 你上次備份是在 %1$s 的 %2$s 進行的。 + + 正在擷取備份詳細資訊… 通知我有關提及 @@ -3364,7 +3354,7 @@ 其他 付款 (MobileCoin) 捐款及徽章 - Signal Android Backup + Signal Android 備份 Signal Android 除錯日誌提交 @@ -4193,6 +4183,8 @@ 我已寫下此密碼。若然遺失,我將會無法還原備份。 備份還原 轉移或還原帳戶 + + 還原或轉移 轉移帳戶 略過 聊天備份 @@ -5004,7 +4996,7 @@ 群組 - Only messages from group chats + 只有群組聊天的訊息 新增 @@ -6535,7 +6527,7 @@ 點按下方的「前往設定」按鈕 - 開啟「允許設定鬧鐘及提醒」 + 開啟「允許設定鬧鐘及提醒。」 前往設定 @@ -7265,11 +7257,11 @@ 由於我們無法處理你的付款,你的 Signal 媒體備份計畫已被取消。若要下載備份內的媒體,這是刪除備份之前的最後機會。 - Free up %1$s on this device + 在此裝置上釋放 %1$s - To finish downloading your Signal Backup your device needs %1$s of storage space. + 要完成下載 Signal 備份,你的裝置需要 %1$s 的儲存空間。 - To free up space offload or delete unused apps or content large in file size. + 要釋放儲存空間,請卸載或刪除未使用的應用程式,或檔案較大的內容。 你的備份定期贊助無法續訂 @@ -7297,7 +7289,7 @@ 現在不要 - Try later + 請稍後嘗試 媒體將被刪除 @@ -7309,9 +7301,9 @@ 略過 - Skip restore? + 要跳過還原嗎? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + 如果你跳過還原,備份內尚餘的媒體和附件將在你的裝置下次完成新備份時被刪除。 @@ -7368,10 +7360,6 @@ 更改或取消定期贊助 - - - 你上次備份是在 %1$s 的 %2$s 進行的。 - 聊天訊息量上限 @@ -7683,5 +7671,101 @@ 提醒圖示 + + + 我有我的舊手機 + + 從你目前的 Signal 帳戶掃描二維碼以快速開始使用 + + + 我沒有我的舊手機 + + 或者你正在同一裝置上重新安裝 Signal + + + 還原或轉移帳戶 + + 將你的 Signal 帳戶和訊息紀錄載入此裝置。 + + 從 Signal 備份 + + 你的免費或付費 Signal 備份計畫 + + 從備份資料夾 + + 從備份檔案 + + 選擇你已儲存的備份 + + 從你的舊手機 + + 直接從舊的 Android 手機轉移 + + + 還原本機備份 + + 從你儲存在裝置的備份中還原你的訊息。若你現在不還原,之後將無法還原。 + + + 輸入你的備份金鑰 + + 你的備份金鑰是恢復你帳戶和資料所需的一組 64 位數的代碼。 + + 沒有備份金鑰? + + 備份金鑰 + + 如果沒有 64 位數的恢復代碼,則無法還原備份。如果你遺失了備份金鑰,Signal 將會無法幫助還原備份。 + + 如果你有舊裝置,則可以在「設定」 > 「聊天」 > 「Signal 備份」中檢視備份金鑰。然後輕按檢視備份金鑰。 + + 了解更多 + + 跳過並不還原 + + + 使用舊手機掃描此二維碼 + + 在你的舊裝置開啟 Signal + + 輕按相機圖示 + + 使用相機掃描此二維碼 + + 無法產生二維碼 + + 已在舊的裝置上掃瞄 + + 重試 + + + 轉移帳戶 + + 你的帳戶將轉移到新裝置。此裝置可查看你的群組和聯絡人、存取你的聊天,以及以你的名義傳送訊息。%1$s + + 了解更多 + + 轉移帳戶 + + 所有裝置上的訊息和聊天資訊均受端對端加密的保護 + + 解鎖以轉移帳戶 + + 在你的另一部裝置上繼續 + + 在另一部裝置上繼續轉移你的帳戶。 + + + 還原完成 + + 你的 Signal 帳戶和訊息已開始轉移到另一部裝置。此裝置上的 Signal 現在已停用。 + + 轉移完成 + + 你的 Signal 帳戶和訊息已轉移到另一部裝置。此裝置上的 Signal 現在已停用。 + + 確定 + + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 852a62b6d0..1685adbb65 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1287,20 +1287,6 @@ 更多 新增群組描述… - - - 從Android裝置移轉 - - 從舊的 Android 裝置轉移你的帳戶和訊息。 - - 登入而不作轉移 - - 不轉移訊息及媒體並繼續 - - 還原本機備份 - - 從你儲存在裝置上的備份檔案還原訊息。 - 正在下載備份… @@ -1318,12 +1304,16 @@ 所有你的訊息 從備份還原 - + 只包含過去 %1$d 天內發送或接收的媒體。 你的備份包括: 備份還原 + + 你上次備份是在 %1$s 的 %2$s 進行的。 + + 正在擷取備份詳細資訊… 通知我提及。 @@ -3364,7 +3354,7 @@ 其他 付款 (MobileCoin) 捐款及徽章 - Signal Android Backup + Signal Android 備份 Signal Android 除錯日誌提交 @@ -4193,6 +4183,8 @@ 我已經寫下密碼。沒有密碼,我將無法還原此備份。 還原備份 移轉或還原帳號 + + 還原或轉移 轉移帳號 略過 聊天備份 @@ -5004,7 +4996,7 @@ 群組 - Only messages from group chats + 只有群組聊天的訊息 新增 @@ -6535,7 +6527,7 @@ 點按下方的「前往設定」按鈕 - 開啟「允許設定鬧鐘及提醒」 + 開啟「允許設定鬧鐘及提醒。」 前往「設定」 @@ -7265,11 +7257,11 @@ 由於我們無法處理你的付款,你的 Signal 媒體備份計畫已被取消。若要下載備份內的媒體,這是刪除備份之前的最後機會。 - Free up %1$s on this device + 在此裝置上釋放 %1$s - To finish downloading your Signal Backup your device needs %1$s of storage space. + 要完成下載 Signal 備份,你的裝置需要 %1$s 的儲存空間。 - To free up space offload or delete unused apps or content large in file size. + 要釋放儲存空間,請卸載或刪除未使用的應用程式,或檔案較大的內容。 你的備份定期贊助無法續訂 @@ -7297,7 +7289,7 @@ 現在不要 - Try later + 請稍後嘗試 媒體將被刪除 @@ -7309,9 +7301,9 @@ 略過 - Skip restore? + 要跳過還原嗎? - If you skip restore the remaining media and attachments in your backup will be deleted the next time your device completes a new backup. + 如果你跳過還原,備份內尚餘的媒體和附件將在你的裝置下次完成新備份時被刪除。 @@ -7368,10 +7360,6 @@ 更改或取消定期贊助 - - - 你上次備份是在 %1$s 的 %2$s 進行的。 - 聊天訊息量上限 @@ -7683,5 +7671,101 @@ 提醒圖示 + + + 我有我的舊手機 + + 從你目前的 Signal 帳戶掃描二維碼以快速開始使用 + + + 我沒有我的舊手機 + + 或者你正在同一裝置上重新安裝 Signal + + + 還原或轉移帳戶 + + 將你的 Signal 帳戶和訊息紀錄載入此裝置。 + + 從 Signal 備份 + + 你的免費或付費 Signal 備份計畫 + + 從備份資料夾 + + 從備份檔案 + + 選擇你已儲存的備份 + + 從你的舊手機 + + 直接從舊的 Android 手機轉移 + + + 還原本機備份 + + 從你儲存在裝置的備份中還原你的訊息。若你現在不還原,之後將無法還原。 + + + 輸入你的備份金鑰 + + 你的備份金鑰是恢復你帳戶和資料所需的一組 64 位數的代碼。 + + 沒有備份金鑰? + + 備份金鑰 + + 如果沒有 64 位數的恢復代碼,則無法還原備份。如果你遺失了備份金鑰,Signal 將會無法幫助還原備份。 + + 如果你有舊裝置,則可以在「設定」 > 「聊天」 > 「Signal 備份」中檢視備份金鑰。然後輕按檢視備份金鑰。 + + 了解更多 + + 跳過並不還原 + + + 使用舊手機掃描此二維碼 + + 在你的舊裝置開啟 Signal + + 輕按相機圖示 + + 使用相機掃描此二維碼 + + 無法產生二維碼 + + 已在舊的裝置上掃瞄 + + 重試 + + + 轉移帳號 + + 你的帳戶將轉移到新裝置。此裝置可查看你的群組和聯絡人、存取你的聊天,以及以你的名義傳送訊息。%1$s + + 了解更多 + + 轉移帳號 + + 所有裝置上的訊息和聊天資訊均受端對端加密的保護 + + 解鎖以轉移帳戶 + + 在你的另一部裝置上繼續 + + 在另一部裝置上繼續轉移你的帳戶。 + + + 回復完成 + + 你的 Signal 帳戶和訊息已開始轉移到另一部裝置。此裝置上的 Signal 現在已停用。 + + 轉移完成 + + 你的 Signal 帳戶和訊息已轉移到另一部裝置。此裝置上的 Signal 現在已停用。 + + 好的 + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e7a526f16c..9fdc5483f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1326,20 +1326,6 @@ more Add group description… - - - Transfer from Android device - - Transfer your account and messages from your old Android device. - - Log in without transferring - - Continue without transferring your messages and media - - Restore local backup - - Restore your messages from a backup file you saved on your device. - Downloading backup… @@ -1357,12 +1343,16 @@ All of your messages Restore from backup - + Only media sent or received in the past %1$d days is included. Your backup includes: Restore backup + + Your last backup was made on %1$s at %2$s. + + Fetching backup details… Notify me for Mentions @@ -4308,6 +4298,8 @@ I have written down this passphrase. Without it, I will be unable to restore a backup. Restore backup Transfer or restore account + + Restore or transfer Transfer account Skip Chat backups @@ -6691,7 +6683,7 @@ Tap the \"Go to settings\" button below - Turn on \"Allow settings alarms and reminders.\" + Turn on \"Allow setting alarms and reminders.\" Go to settings @@ -7533,10 +7525,6 @@ Change or cancel subscription - - - Your last backup was made on %1$s at %2$s. - Chat limits @@ -7854,5 +7842,101 @@ Reminder icon + + + I have my old phone + + Scan a QR code from your current Signal account to get started quickly + + + I don\'t have my old phone + + Or you’re reinstaling Signal on the same device + + + Restore or transfer account + + Get your Signal account and message history onto this device. + + From Signal Backups + + Your free or paid Signal Backup plan + + From a backup folder + + From a backup file + + Choose a backup you’ve saved + + From your old phone + + Transfer directly from your old Android + + + Restore local backup + + Restore your messages from the backup you saved on your device. If you don\'t restore now, you won\'t be able to restore later. + + + Enter your backup key + + Your backup key is a 64-digit code required to recover your account and data. + + No backup key? + + Backup key + + Backups can\'t be recovered without their 64-digit recovery code. If you\'ve lost your backup key Signal can\'t help restore your backup. + + If you have your old device you can view your backup key in Settings > Chats > Signal Backups. Then tap View backup key. + + Learn more + + Skip and don\'t restore + + + Scan this code with your old phone + + Open Signal on your old device + + Tap the camera icon + + Scan this code with the camera + + Unable to generate QR code + + Scanned on old device + + Retry + + + Transfer account + + Your account will be transferred to a new device.This device will be able to see your groups and contacts, access your chats, and send messages in your name. %1$s + + Learn more + + Transfer account + + Messages and chat info are protected by end-to-end encryption on all devices + + Unlock to transfer account + + Continue on your other device + + Continue transferring your account on your other device. + + + Restore complete + + Your Signal account and messages have started transferring to your other device. Signal is now inactive on this device. + + Transfer complete + + Your Signal account and messages have been transferred to your other device. Signal is now inactive on this device. + + Okay + + diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/StorageServicePlugin.kt b/app/src/spinner/java/org/thoughtcrime/securesms/StorageServicePlugin.kt index 4bfe0dcae4..dca0b8a736 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/StorageServicePlugin.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/StorageServicePlugin.kt @@ -15,7 +15,7 @@ class StorageServicePlugin : Plugin { val rows = mutableListOf>() val manager = AppDependencies.signalServiceAccountManager - val storageServiceKey = SignalStore.storageService.orCreateStorageKey + val storageServiceKey = SignalStore.storageService.storageKey val storageManifestVersion = manager.storageManifestVersion val manifest = manager.getStorageManifestIfDifferentVersion(storageServiceKey, storageManifestVersion - 1).get() val signalStorageRecords = manager.readStorageRecords(storageServiceKey, manifest.storageIds) @@ -23,24 +23,24 @@ class StorageServicePlugin : Plugin { for (record in signalStorageRecords) { val row = mutableListOf() - if (record.account.isPresent) { + if (record.proto.account != null) { row += "Account" - row += record.account.get().toProto().toString() - } else if (record.contact.isPresent) { + row += record.proto.account.toString() + } else if (record.proto.contact != null) { row += "Contact" - row += record.contact.get().toProto().toString() - } else if (record.groupV1.isPresent) { + row += record.proto.toString() + } else if (record.proto.groupV1 != null) { row += "GV1" - row += record.groupV1.get().toProto().toString() - } else if (record.groupV2.isPresent) { + row += record.proto.toString() + } else if (record.proto.groupV2 != null) { row += "GV2" - row += record.groupV2.get().toProto().toString() - } else if (record.storyDistributionList.isPresent) { + row += record.proto.toString() + } else if (record.proto.storyDistributionList != null) { row += "Distribution List" - row += record.storyDistributionList.get().toProto().toString() - } else if (record.callLink.isPresent) { + row += record.proto.toString() + } else if (record.proto.callLink != null) { row += "Call Link" - row += record.callLink.get().toProto().toString() + row += record.proto.callLink.toString() } else { row += "Unknown" row += "" diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index 946023bbfd..ebb4f5115d 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -43,6 +43,7 @@ import org.whispersystems.signalservice.api.attachment.AttachmentApi import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations import org.whispersystems.signalservice.api.keys.KeysApi import org.whispersystems.signalservice.api.link.LinkDeviceApi +import org.whispersystems.signalservice.api.registration.RegistrationApi import org.whispersystems.signalservice.api.services.CallLinksService import org.whispersystems.signalservice.api.services.DonationsService import org.whispersystems.signalservice.api.services.ProfileService @@ -222,4 +223,8 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { override fun provideLinkDeviceApi(pushServiceSocket: PushServiceSocket): LinkDeviceApi { return mockk() } + + override fun provideRegistrationApi(pushServiceSocket: PushServiceSocket): RegistrationApi { + return mockk() + } } diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/ContactRecordProcessorTest.kt b/app/src/test/java/org/thoughtcrime/securesms/storage/ContactRecordProcessorTest.kt index 9556429fb4..ac07974d84 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/storage/ContactRecordProcessorTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/ContactRecordProcessorTest.kt @@ -307,9 +307,9 @@ class ContactRecordProcessorTest { val result = subject.merge(remote, local, TestKeyGenerator(STORAGE_ID_C)) // THEN - assertEquals(local.aci, result.aci) - assertEquals(local.number.get(), result.number.get()) - assertEquals(local.pni.get(), result.pni.get()) + assertEquals(local.proto.aci, result.proto.aci) + assertEquals(local.proto.e164, result.proto.e164) + assertEquals(local.proto.pni, result.proto.pni) } @Test @@ -339,9 +339,9 @@ class ContactRecordProcessorTest { val result = subject.merge(remote, local, TestKeyGenerator(STORAGE_ID_C)) // THEN - assertEquals(local.aci, result.aci) - assertEquals(local.number.get(), result.number.get()) - assertEquals(local.pni.get(), result.pni.get()) + assertEquals(local.proto.aci, result.proto.aci) + assertEquals(local.proto.e164, result.proto.e164) + assertEquals(local.proto.pni, result.proto.pni) } @Test @@ -371,9 +371,9 @@ class ContactRecordProcessorTest { val result = subject.merge(remote, local, TestKeyGenerator(STORAGE_ID_C)) // THEN - assertEquals(remote.aci, result.aci) - assertEquals(remote.number.get(), result.number.get()) - assertEquals(remote.pni.get(), result.pni.get()) + assertEquals(remote.proto.aci, result.proto.aci) + assertEquals(remote.proto.e164, result.proto.e164) + assertEquals(remote.proto.pni, result.proto.pni) } @Test @@ -403,9 +403,9 @@ class ContactRecordProcessorTest { val result = subject.merge(remote, local, TestKeyGenerator(STORAGE_ID_C)) // THEN - assertEquals("Ghost", result.nicknameGivenName.get()) - assertEquals("Spider", result.nicknameFamilyName.get()) - assertEquals("Spidey Friend", result.note.get()) + assertEquals("Ghost", result.proto.nickname?.given) + assertEquals("Spider", result.proto.nickname?.family) + assertEquals("Spidey Friend", result.proto.note) } private fun buildRecord(id: StorageId = STORAGE_ID_A, record: ContactRecord): SignalContactRecord { diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/StorageRecordTest.kt b/app/src/test/java/org/thoughtcrime/securesms/storage/StorageRecordTest.kt new file mode 100644 index 0000000000..4d75100fa4 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/StorageRecordTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.storage + +import junit.framework.TestCase.assertEquals +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.junit.Test +import org.thoughtcrime.securesms.util.Util +import org.whispersystems.signalservice.api.storage.SignalAccountRecord +import org.whispersystems.signalservice.api.storage.SignalContactRecord +import org.whispersystems.signalservice.api.storage.StorageId +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord + +class StorageRecordTest { + + @Test + fun `describeDiff - general test`() { + val a = SignalAccountRecord( + StorageId.forAccount(Util.getSecretBytes(16)), + AccountRecord( + profileKey = ByteString.EMPTY, + givenName = "First", + familyName = "Last" + ) + ) + + val b = SignalAccountRecord( + StorageId.forAccount(Util.getSecretBytes(16)), + AccountRecord( + profileKey = Util.getSecretBytes(16).toByteString(), + givenName = "First", + familyName = "LastB" + ) + ) + + assertEquals("Some fields differ: familyName, id, profileKey", a.describeDiff(b)) + } + + @Test + fun `describeDiff - different class`() { + val a = SignalAccountRecord( + StorageId.forAccount(Util.getSecretBytes(16)), + AccountRecord() + ) + + val b = SignalContactRecord( + StorageId.forAccount(Util.getSecretBytes(16)), + ContactRecord() + ) + + assertEquals("Classes are different!", a.describeDiff(b)) + } +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.java b/app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.java index c6c28199bf..896a28dc66 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.java +++ b/app/src/test/java/org/thoughtcrime/securesms/storage/StorageSyncHelperTest.java @@ -14,13 +14,10 @@ import org.thoughtcrime.securesms.storage.StorageSyncHelper.IdDifferenceResult; import org.thoughtcrime.securesms.util.RemoteConfig; import org.whispersystems.signalservice.api.push.ServiceId.ACI; -import org.whispersystems.signalservice.api.storage.SignalAccountRecord; import org.whispersystems.signalservice.api.storage.SignalContactRecord; -import org.whispersystems.signalservice.api.storage.SignalGroupV1Record; -import org.whispersystems.signalservice.api.storage.SignalGroupV2Record; import org.whispersystems.signalservice.api.storage.SignalRecord; -import org.whispersystems.signalservice.api.storage.SignalStorageRecord; import org.whispersystems.signalservice.api.storage.StorageId; +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord; import java.util.Arrays; import java.util.HashMap; @@ -28,6 +25,8 @@ import java.util.Map; import java.util.Optional; +import okio.ByteString; + import static junit.framework.TestCase.assertTrue; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; @@ -72,25 +71,25 @@ public void setup() { @Test public void findIdDifference_allOverlap() { IdDifferenceResult result = StorageSyncHelper.findIdDifference(keyListOf(1, 2, 3), keyListOf(1, 2, 3)); - assertTrue(result.getLocalOnlyIds().isEmpty()); - assertTrue(result.getRemoteOnlyIds().isEmpty()); - assertFalse(result.hasTypeMismatches()); + assertTrue(result.localOnlyIds.isEmpty()); + assertTrue(result.remoteOnlyIds.isEmpty()); + assertFalse(result.getHasTypeMismatches()); } @Test public void findIdDifference_noOverlap() { IdDifferenceResult result = StorageSyncHelper.findIdDifference(keyListOf(1, 2, 3), keyListOf(4, 5, 6)); - assertContentsEqual(keyListOf(1, 2, 3), result.getRemoteOnlyIds()); - assertContentsEqual(keyListOf(4, 5, 6), result.getLocalOnlyIds()); - assertFalse(result.hasTypeMismatches()); + assertContentsEqual(keyListOf(1, 2, 3), result.remoteOnlyIds); + assertContentsEqual(keyListOf(4, 5, 6), result.localOnlyIds); + assertFalse(result.getHasTypeMismatches()); } @Test public void findIdDifference_someOverlap() { IdDifferenceResult result = StorageSyncHelper.findIdDifference(keyListOf(1, 2, 3), keyListOf(2, 3, 4)); - assertContentsEqual(keyListOf(1), result.getRemoteOnlyIds()); - assertContentsEqual(keyListOf(4), result.getLocalOnlyIds()); - assertFalse(result.hasTypeMismatches()); + assertContentsEqual(keyListOf(1), result.remoteOnlyIds); + assertContentsEqual(keyListOf(4), result.localOnlyIds); + assertFalse(result.getHasTypeMismatches()); } @Test @@ -104,9 +103,9 @@ public void findIdDifference_typeMismatch_allOverlap() { put(200, 1); }})); - assertTrue(result.getLocalOnlyIds().isEmpty()); - assertTrue(result.getRemoteOnlyIds().isEmpty()); - assertTrue(result.hasTypeMismatches()); + assertTrue(result.localOnlyIds.isEmpty()); + assertTrue(result.remoteOnlyIds.isEmpty()); + assertTrue(result.getHasTypeMismatches()); } @Test @@ -122,9 +121,9 @@ public void findIdDifference_typeMismatch_someOverlap() { put(400, 1); }})); - assertContentsEqual(Arrays.asList(StorageId.forType(byteArray(300), 1)), result.getRemoteOnlyIds()); - assertContentsEqual(Arrays.asList(StorageId.forType(byteArray(400), 1)), result.getLocalOnlyIds()); - assertTrue(result.hasTypeMismatches()); + assertContentsEqual(Arrays.asList(StorageId.forType(byteArray(300), 1)), result.remoteOnlyIds); + assertContentsEqual(Arrays.asList(StorageId.forType(byteArray(400), 1)), result.localOnlyIds); + assertTrue(result.getHasTypeMismatches()); } @Test @@ -132,13 +131,16 @@ public void ContactUpdate_equals_sameProfileKeys() { byte[] profileKey = new byte[32]; byte[] profileKeyCopy = profileKey.clone(); - SignalContactRecord a = contactBuilder(1, ACI_A, E164_A, "a").setProfileKey(profileKey).build(); - SignalContactRecord b = contactBuilder(1, ACI_A, E164_A, "a").setProfileKey(profileKeyCopy).build(); + ContactRecord contactA = contactBuilder(ACI_A, E164_A, "a").profileKey(ByteString.of(profileKey)).build(); + ContactRecord contactB = contactBuilder(ACI_A, E164_A, "a").profileKey(ByteString.of(profileKeyCopy)).build(); + + SignalContactRecord signalContactA = new SignalContactRecord(StorageId.forContact(byteArray(1)), contactA); + SignalContactRecord signalContactB = new SignalContactRecord(StorageId.forContact(byteArray(1)), contactB); - assertEquals(a, b); - assertEquals(a.hashCode(), b.hashCode()); + assertEquals(signalContactA, signalContactB); + assertEquals(signalContactA.hashCode(), signalContactB.hashCode()); - assertFalse(StorageSyncHelper.profileKeyChanged(update(a, b))); + assertFalse(StorageSyncHelper.profileKeyChanged(update(signalContactA, signalContactB))); } @Test @@ -147,40 +149,26 @@ public void ContactUpdate_equals_differentProfileKeys() { byte[] profileKeyCopy = profileKey.clone(); profileKeyCopy[0] = 1; - SignalContactRecord a = contactBuilder(1, ACI_A, E164_A, "a").setProfileKey(profileKey).build(); - SignalContactRecord b = contactBuilder(1, ACI_A, E164_A, "a").setProfileKey(profileKeyCopy).build(); + ContactRecord contactA = contactBuilder(ACI_A, E164_A, "a").profileKey(ByteString.of(profileKey)).build(); + ContactRecord contactB = contactBuilder(ACI_A, E164_A, "a").profileKey(ByteString.of(profileKeyCopy)).build(); - assertNotEquals(a, b); - assertNotEquals(a.hashCode(), b.hashCode()); + SignalContactRecord signalContactA = new SignalContactRecord(StorageId.forContact(byteArray(1)), contactA); + SignalContactRecord signalContactB = new SignalContactRecord(StorageId.forContact(byteArray(1)), contactB); - assertTrue(StorageSyncHelper.profileKeyChanged(update(a, b))); - } + assertNotEquals(signalContactA, signalContactB); + assertNotEquals(signalContactA.hashCode(), signalContactB.hashCode()); - private static SignalStorageRecord record(SignalRecord record) { - if (record instanceof SignalContactRecord) { - return SignalStorageRecord.forContact(record.getId(), (SignalContactRecord) record); - } else if (record instanceof SignalGroupV1Record) { - return SignalStorageRecord.forGroupV1(record.getId(), (SignalGroupV1Record) record); - } else if (record instanceof SignalGroupV2Record) { - return SignalStorageRecord.forGroupV2(record.getId(), (SignalGroupV2Record) record); - } else if (record instanceof SignalAccountRecord) { - return SignalStorageRecord.forAccount(record.getId(), (SignalAccountRecord) record); - } else { - return SignalStorageRecord.forUnknown(record.getId()); - } + assertTrue(StorageSyncHelper.profileKeyChanged(update(signalContactA, signalContactB))); } - private static SignalContactRecord.Builder contactBuilder(int key, - ACI aci, - String e164, - String profileName) - { - return new SignalContactRecord.Builder(byteArray(key), aci, null) - .setE164(e164) - .setProfileGivenName(profileName); + private static ContactRecord.Builder contactBuilder(ACI aci, String e164, String profileName) { + return new ContactRecord.Builder() + .aci(aci.toString()) + .e164(e164) + .givenName(profileName); } - private static StorageRecordUpdate update(E oldRecord, E newRecord) { + private static > StorageRecordUpdate update(E oldRecord, E newRecord) { return new StorageRecordUpdate<>(oldRecord, newRecord); } diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/RemoteConfig_StaticValuesTest.kt b/app/src/test/java/org/thoughtcrime/securesms/util/RemoteConfig_StaticValuesTest.kt index 8896f61412..aeda4dfd92 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/util/RemoteConfig_StaticValuesTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/util/RemoteConfig_StaticValuesTest.kt @@ -52,6 +52,7 @@ class RemoteConfig_StaticValuesTest { "libSignalWebSocketEnabled", "restoreAfterRegistration", "libSignalWebSocketShadowingPercentage", + "linkAndSync", "CRASH_PROMPT_CONFIG", "PROMPT_BATTERY_SAVER", "PROMPT_FOR_NOTIFICATION_LOGS", diff --git a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt index ce5ac68cce..f89c829926 100644 --- a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt +++ b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt @@ -36,6 +36,7 @@ import org.signal.core.ui.theme.SignalTheme object Dialogs { + const val NoTitle = "" const val NoDismiss = "" @Composable diff --git a/core-util-jvm/src/main/java/org/signal/core/util/ProtoExtensions.kt b/core-util-jvm/src/main/java/org/signal/core/util/ProtoExtensions.kt index 5f8b6e3bae..2deace46ad 100644 --- a/core-util-jvm/src/main/java/org/signal/core/util/ProtoExtensions.kt +++ b/core-util-jvm/src/main/java/org/signal/core/util/ProtoExtensions.kt @@ -32,6 +32,14 @@ fun ByteString?.isNullOrEmpty(): Boolean { return this == null || this.size == 0 } +fun ByteString.nullIfEmpty(): ByteString? { + return if (this.isEmpty()) { + null + } else { + this + } +} + /** * Performs the common pattern of attempting to decode a serialized proto and returning null if it fails to decode. */ diff --git a/dependencies.gradle.kts b/dependencies.gradle.kts index 9565b95653..099809794d 100644 --- a/dependencies.gradle.kts +++ b/dependencies.gradle.kts @@ -157,7 +157,6 @@ dependencyResolutionManagement { library("mobilecoin", "com.mobilecoin:android-sdk:6.0.1") library("leolin-shortcutbadger", "me.leolin:ShortcutBadger:1.1.22") library("emilsjolander-stickylistheaders", "se.emilsjolander:stickylistheaders:2.7.0") - library("apache-httpclient-android", "org.apache.httpcomponents:httpclient-android:4.3.5") library("glide-glide", "com.github.bumptech.glide", "glide").versionRef("glide") library("glide-ksp", "com.github.bumptech.glide", "ksp").versionRef("glide") library("roundedimageview", "com.makeramen:roundedimageview:2.1.0") diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index e881fef175..f640abcc12 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -6,6 +6,9 @@ Run the following command to update this file after adding or updating a depende (remove the backslashes before running) +If you are updating a dependency, please also clean up the old version so this file does +not grow unboundedly. + For more information, see: https://docs.gradle.org/current/userguide/dependency_verification.html --> diff --git a/image-editor/app/src/main/java/org/signal/imageeditor/app/MainActivity.java b/image-editor/app/src/main/java/org/signal/imageeditor/app/MainActivity.java index ea2e015385..c67f436d3d 100644 --- a/image-editor/app/src/main/java/org/signal/imageeditor/app/MainActivity.java +++ b/image-editor/app/src/main/java/org/signal/imageeditor/app/MainActivity.java @@ -1,7 +1,6 @@ package org.signal.imageeditor.app; import android.Manifest; -import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.graphics.Bitmap; @@ -27,9 +26,7 @@ import org.signal.imageeditor.app.renderers.UriRenderer; import org.signal.imageeditor.app.renderers.UrlRenderer; import org.signal.imageeditor.core.ImageEditorView; -import org.signal.imageeditor.core.Renderer; import org.signal.imageeditor.core.RendererContext; -import org.signal.imageeditor.core.UndoRedoStackListener; import org.signal.imageeditor.core.model.EditorElement; import org.signal.imageeditor.core.model.EditorModel; import org.signal.imageeditor.core.renderers.MultiLineTextRenderer; @@ -176,72 +173,62 @@ public boolean onCreateOptionsMenu(Menu menu) { @Override public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.action_undo: - imageEditorView.getModel().undo(); - Log.d(TAG, String.format("Model is %s", imageEditorView.getModel().isChanged() ? "changed" : "unchanged")); - return true; - - case R.id.action_redo: - imageEditorView.getModel().redo(); - return true; - - case R.id.action_crop: - imageEditorView.setMode(ImageEditorView.Mode.MoveAndResize); - imageEditorView.getModel().startCrop(); - return true; - - case R.id.action_done: - imageEditorView.setMode(ImageEditorView.Mode.MoveAndResize); - imageEditorView.getModel().doneCrop(); - return true; - - case R.id.action_draw: - imageEditorView.setDrawingBrushColor(0xffffff00); - imageEditorView.startDrawing(0.02f, Paint.Cap.ROUND, false); - return true; - - case R.id.action_rotate_left_90: - imageEditorView.getModel().rotate90anticlockwise(); - return true; - - case R.id.action_flip_horizontal: - imageEditorView.getModel().flipHorizontal(); - return true; - - case R.id.action_edit_text: - editText(); - return true; - - case R.id.action_lock_crop_aspect: - imageEditorView.getModel().setCropAspectLock(!imageEditorView.getModel().isCropAspectLocked()); - return true; - - case R.id.action_save: - if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) - != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this, - new String[]{ Manifest.permission.WRITE_EXTERNAL_STORAGE }, - 0); - } else { - Bitmap bitmap = imageEditorView.getModel().render(this, typefaceProvider); - try { - Uri uri = saveBmp(bitmap); - - Intent intent = new Intent(); - intent.setAction(Intent.ACTION_VIEW); - intent.setDataAndType(uri, "image/*"); - startActivity(intent); - - } finally { - bitmap.recycle(); - } + int itemId = item.getItemId(); + if (itemId == R.id.action_undo) { + imageEditorView.getModel().undo(); + Log.d(TAG, String.format("Model is %s", imageEditorView.getModel().isChanged() ? "changed" : "unchanged")); + return true; + } else if (itemId == R.id.action_redo) { + imageEditorView.getModel().redo(); + return true; + } else if (itemId == R.id.action_crop) { + imageEditorView.setMode(ImageEditorView.Mode.MoveAndResize); + imageEditorView.getModel().startCrop(); + return true; + } else if (itemId == R.id.action_done) { + imageEditorView.setMode(ImageEditorView.Mode.MoveAndResize); + imageEditorView.getModel().doneCrop(); + return true; + } else if (itemId == R.id.action_draw) { + imageEditorView.setDrawingBrushColor(0xffffff00); + imageEditorView.startDrawing(0.02f, Paint.Cap.ROUND, false); + return true; + } else if (itemId == R.id.action_rotate_left_90) { + imageEditorView.getModel().rotate90anticlockwise(); + return true; + } else if (itemId == R.id.action_flip_horizontal) { + imageEditorView.getModel().flipHorizontal(); + return true; + } else if (itemId == R.id.action_edit_text) { + editText(); + return true; + } else if (itemId == R.id.action_lock_crop_aspect) { + imageEditorView.getModel().setCropAspectLock(!imageEditorView.getModel().isCropAspectLocked()); + return true; + } else if (itemId == R.id.action_save) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) + { + ActivityCompat.requestPermissions(this, + new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE }, + 0); + } else { + Bitmap bitmap = imageEditorView.getModel().render(this, typefaceProvider); + try { + Uri uri = saveBmp(bitmap); + + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_VIEW); + intent.setDataAndType(uri, "image/*"); + startActivity(intent); + + } finally { + bitmap.recycle(); } - return true; - - default: - return super.onOptionsItemSelected(item); + } + return true; } + return super.onOptionsItemSelected(item); } private void editText() { diff --git a/libsignal-service/build.gradle.kts b/libsignal-service/build.gradle.kts index 3381e266cb..7d59e5e2e0 100644 --- a/libsignal-service/build.gradle.kts +++ b/libsignal-service/build.gradle.kts @@ -30,6 +30,7 @@ java { tasks.withType().configureEach { kotlinOptions { jvmTarget = signalKotlinJvmTarget + freeCompilerArgs = listOf("-Xjvm-default=all") } } @@ -77,6 +78,8 @@ dependencies { api(libs.rxjava3.rxjava) implementation(libs.kotlin.stdlib.jdk8) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.core.jvm) implementation(project(":core-util-jvm")) @@ -85,6 +88,7 @@ dependencies { testImplementation(testLibs.conscrypt.openjdk.uber) testImplementation(testLibs.mockito.core) testImplementation(testLibs.mockk) + testImplementation(testLibs.hamcrest.hamcrest) testFixturesImplementation(libs.libsignal.client) testFixturesImplementation(testLibs.junit.junit) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java index 271a712e42..986b278a7e 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceAccountManager.java @@ -452,13 +452,13 @@ private Optional writeStorageRecords(StorageKey storageKe throws IOException, InvalidKeyException { ManifestRecord.Builder manifestRecordBuilder = new ManifestRecord.Builder() - .sourceDevice(manifest.getSourceDeviceId()) - .version(manifest.getVersion()); + .sourceDevice(manifest.sourceDeviceId) + .version(manifest.version); manifestRecordBuilder.identifiers( - manifest.getStorageIds().stream() - .map(id -> { + manifest.storageIds.stream() + .map(id -> { ManifestRecord.Identifier.Builder builder = new ManifestRecord.Identifier.Builder() .raw(ByteString.of(id.getRaw())); if (!id.isUnknown()) { @@ -469,14 +469,14 @@ private Optional writeStorageRecords(StorageKey storageKe } return builder.build(); }) - .collect(Collectors.toList()) + .collect(Collectors.toList()) ); String authToken = this.pushServiceSocket.getStorageAuth(); - StorageManifestKey manifestKey = storageKey.deriveManifestKey(manifest.getVersion()); + StorageManifestKey manifestKey = storageKey.deriveManifestKey(manifest.version); byte[] encryptedRecord = SignalStorageCipher.encrypt(manifestKey, manifestRecordBuilder.build().encode()); StorageManifest storageManifest = new StorageManifest.Builder() - .version(manifest.getVersion()) + .version(manifest.version) .value_(ByteString.of(encryptedRecord)) .build(); diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalUrlExtensions.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalUrlExtensions.kt new file mode 100644 index 0000000000..6071347467 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalUrlExtensions.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api + +import okhttp3.ConnectionSpec +import okhttp3.OkHttpClient +import org.whispersystems.signalservice.api.push.TrustStore +import org.whispersystems.signalservice.api.util.Tls12SocketFactory +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration +import org.whispersystems.signalservice.internal.configuration.SignalUrl +import org.whispersystems.signalservice.internal.util.BlacklistingTrustManager +import org.whispersystems.signalservice.internal.util.Util +import java.security.KeyManagementException +import java.security.NoSuchAlgorithmException +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.X509TrustManager + +/** + * Select a a URL at random to use. + */ +fun Array.chooseUrl(): T { + return this[(Math.random() * size).toInt()] +} + +/** + * Build and configure an [OkHttpClient] as defined by the target [SignalUrl] and provided [configuration]. + */ +fun T.buildOkHttpClient(configuration: SignalServiceConfiguration): OkHttpClient { + val (socketFactory, trustManager) = createTlsSocketFactory(this.trustStore) + + val builder = OkHttpClient.Builder() + .socketFactory(configuration.socketFactory) + .proxySelector(configuration.proxySelector) + .dns(configuration.dns) + .sslSocketFactory(socketFactory, trustManager) + .connectionSpecs(this.connectionSpecs.orElse(Util.immutableList(ConnectionSpec.RESTRICTED_TLS))) + .retryOnConnectionFailure(false) + .readTimeout(30, TimeUnit.SECONDS) + .connectTimeout(30, TimeUnit.SECONDS) + + for (interceptor in configuration.networkInterceptors) { + builder.addInterceptor(interceptor) + } + + return builder.build() +} + +private fun createTlsSocketFactory(trustStore: TrustStore): Pair { + return try { + val context = SSLContext.getInstance("TLS") + val trustManagers = BlacklistingTrustManager.createFor(trustStore) + context.init(null, trustManagers, null) + Tls12SocketFactory(context.socketFactory) to trustManagers[0] as X509TrustManager + } catch (e: NoSuchAlgorithmException) { + throw AssertionError(e) + } catch (e: KeyManagementException) { + throw AssertionError(e) + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/push/ServiceId.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/push/ServiceId.kt index 5f8588f7ec..6562589e48 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/push/ServiceId.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/push/ServiceId.kt @@ -37,7 +37,7 @@ sealed class ServiceId(val libSignalServiceId: LibSignalServiceId) { @JvmOverloads @JvmStatic fun parseOrNull(raw: String?, logFailures: Boolean = true): ServiceId? { - if (raw == null) { + if (raw.isNullOrBlank()) { return null } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/ProvisioningSocket.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/ProvisioningSocket.kt new file mode 100644 index 0000000000..3256a401c3 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/ProvisioningSocket.kt @@ -0,0 +1,270 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.registration + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.signal.core.util.Base64 +import org.signal.core.util.logging.Log +import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.registration.proto.RegistrationProvisionEnvelope +import org.whispersystems.signalservice.api.buildOkHttpClient +import org.whispersystems.signalservice.api.chooseUrl +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration +import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher +import org.whispersystems.signalservice.internal.push.ProvisioningAddress +import org.whispersystems.signalservice.internal.websocket.WebSocketMessage +import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage +import org.whispersystems.signalservice.internal.websocket.WebSocketResponseMessage +import java.io.Closeable +import java.io.IOException +import java.net.SocketTimeoutException +import java.net.URLEncoder +import kotlin.time.Duration.Companion.seconds + +/** + * A provisional web socket for communicating with a primary device during registration. + */ +class ProvisioningSocket private constructor( + identityKeyPair: IdentityKeyPair, + configuration: SignalServiceConfiguration, + private val scope: CoroutineScope +) { + companion object { + private val TAG = Log.tag(ProvisioningSocket::class) + + fun start( + identityKeyPair: IdentityKeyPair, + configuration: SignalServiceConfiguration, + handler: CoroutineExceptionHandler, + block: suspend CoroutineScope.(ProvisioningSocket) -> Unit + ): Closeable { + val scope = CoroutineScope(Dispatchers.IO) + SupervisorJob() + handler + + scope.launch { + var socket: ProvisioningSocket? = null + try { + socket = ProvisioningSocket(identityKeyPair, configuration, scope) + socket.connect() + block(socket) + } catch (e: CancellationException) { + val rootCause = e.getRootCause() + if (rootCause == null) { + Log.i(TAG, "Scope canceled expectedly, fail silently, ${e.toMinimalString()}") + throw e + } else { + Log.w(TAG, "Unable to maintain web socket, ${rootCause.toMinimalString()}", rootCause) + throw rootCause + } + } finally { + Log.d(TAG, "Closing web socket") + socket?.close() + } + } + + return Closeable { scope.cancel("scope closed") } + } + + /** + * Get non-cancellation exception cause to determine if something legitimately failed. + */ + private fun CancellationException.getRootCause(): Throwable? { + var cause: Throwable? = cause + while (cause != null && cause is CancellationException) { + cause = cause.cause + } + return cause + } + + /** + * Generates a minimal throwable informational string since stack traces aren't always logged. + */ + private fun Throwable.toMinimalString(): String { + return "${javaClass.simpleName}[$message]" + } + } + + private val serviceUrl = configuration.signalServiceUrls.chooseUrl() + private val okhttp = serviceUrl.buildOkHttpClient(configuration) + + private val cipher = SecondaryProvisioningCipher(identityKeyPair) + private var webSocket: WebSocket? = null + + private val provisioningUrlDeferral: CompletableDeferred = CompletableDeferred() + private val provisioningMessageDeferral: CompletableDeferred = CompletableDeferred() + + suspend fun getProvisioningUrl(): String { + return provisioningUrlDeferral.await() + } + + suspend fun getRegistrationProvisioningMessage(): SecondaryProvisioningCipher.RegistrationProvisionResult { + return provisioningMessageDeferral.await() + } + + private fun connect() { + val uri = serviceUrl.url.replace("https://", "wss://").replace("http://", "ws://") + + val openRequest = Request.Builder() + .url("$uri/v1/websocket/provisioning/") + + if (serviceUrl.hostHeader.isPresent) { + openRequest.addHeader("Host", serviceUrl.hostHeader.get()) + Log.w(TAG, "Using alternate host: ${serviceUrl.hostHeader.get()}") + } + + webSocket = okhttp.newWebSocket(openRequest.build(), ProvisioningWebSocketListener()) + } + + private fun close() { + webSocket?.close(1000, "Manual shutdown") + } + + private inner class ProvisioningWebSocketListener : WebSocketListener() { + private var keepAliveJob: Job? = null + + @Volatile + private var lastKeepAliveId: Long = 0 + + override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d(TAG, "[onOpen]") + keepAliveJob = scope.launch { keepAlive(webSocket) } + + val timeoutJob = scope.launch { + delay(10.seconds) + scope.cancel("Did not receive device id within 10 seconds", SocketTimeoutException("No device id received")) + } + + scope.launch { + provisioningUrlDeferral.await() + timeoutJob.cancel() + } + } + + override fun onMessage(webSocket: WebSocket, bytes: ByteString) { + val message: WebSocketMessage = WebSocketMessage.ADAPTER.decode(bytes) + + if (message.response != null && message.response.id == lastKeepAliveId) { + Log.d(TAG, "[onMessage] Keep alive received") + return + } + + if (message.request == null) { + Log.w(TAG, "[onMessage] Received null request") + return + } + + val success = webSocket.send(message.request.toResponse().encode().toByteString()) + + if (!success) { + Log.w(TAG, "[onMessage] Failed to send response") + webSocket.close(1000, "OK") + return + } + + Log.d(TAG, "[onMessage] Processing request") + + if (message.request.verb == "PUT" && message.request.body != null) { + when (message.request.path) { + "/v1/address" -> { + val address = ProvisioningAddress.ADAPTER.decode(message.request.body).address + if (address != null) { + provisioningUrlDeferral.complete(generateProvisioningUrl(address)) + } else { + throw IOException("Device address is null") + } + } + + "/v1/message" -> { + val result = cipher.decrypt(RegistrationProvisionEnvelope.ADAPTER.decode(message.request.body)) + provisioningMessageDeferral.complete(result) + } + + else -> Log.w(TAG, "Unknown path requested") + } + } else { + Log.w(TAG, "Invalid data") + } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + scope.launch { + Log.i(TAG, "[onClosing] code: $code reason: $reason") + + if (code != 1000) { + Log.w(TAG, "Remote side is closing with non-normal code $code") + webSocket.close(1000, "Remote closed with code $code") + } + + scope.cancel() + } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + scope.launch { + Log.w(TAG, "[onFailure] Failed", t) + webSocket.close(1000, "Failed ${t.message}") + + scope.cancel(CancellationException("WebSocket Failure", t)) + } + } + + private fun generateProvisioningUrl(deviceAddress: String): String { + val encodedDeviceId = URLEncoder.encode(deviceAddress, "UTF-8") + val encodedPubKey: String = URLEncoder.encode(Base64.encodeWithoutPadding(cipher.secondaryDevicePublicKey.serialize()), "UTF-8") + return "sgnl://rereg?uuid=$encodedDeviceId&pub_key=$encodedPubKey" + } + + private suspend fun keepAlive(webSocket: WebSocket) { + Log.i(TAG, "[keepAlive] Starting") + while (true) { + delay(30.seconds) + Log.i(TAG, "[keepAlive] Sending...") + + val id = System.currentTimeMillis() + val message = WebSocketMessage( + type = WebSocketMessage.Type.REQUEST, + request = WebSocketRequestMessage( + id = id, + path = "/v1/keepalive", + verb = "GET" + ) + ) + + if (!webSocket.send(message.encodeByteString())) { + Log.w(TAG, "[keepAlive] Send failed") + } else { + lastKeepAliveId = id + } + } + } + + private fun WebSocketRequestMessage.toResponse(): WebSocketMessage { + return WebSocketMessage( + type = WebSocketMessage.Type.RESPONSE, + response = WebSocketResponseMessage( + id = id, + status = 200, + message = "OK" + ) + ) + } + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt index 954174ccc9..10656ee270 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt @@ -5,11 +5,14 @@ package org.whispersystems.signalservice.api.registration +import org.signal.libsignal.protocol.ecc.ECPublicKey +import org.signal.registration.proto.RegistrationProvisionMessage import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.account.AccountAttributes import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest import org.whispersystems.signalservice.api.account.PniKeyDistributionRequest import org.whispersystems.signalservice.api.account.PreKeyCollection +import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher import org.whispersystems.signalservice.internal.push.BackupV2AuthCheckResponse import org.whispersystems.signalservice.internal.push.BackupV3AuthCheckResponse import org.whispersystems.signalservice.internal.push.PushServiceSocket @@ -142,4 +145,38 @@ class RegistrationApi( pushServiceSocket.distributePniKeys(requestBody) } } + + /** + * Encrypts and sends the [RegistrationProvisionMessage] from the current primary (old device) to the new device over + * the provisioning web socket identified by [deviceIdentifier]. + */ + fun sendReRegisterDeviceProvisioningMessage( + deviceIdentifier: String, + deviceKey: ECPublicKey, + registrationProvisionMessage: RegistrationProvisionMessage + ): NetworkResult { + val cipherText = PrimaryProvisioningCipher(deviceKey).encrypt(registrationProvisionMessage) + + return NetworkResult.fromFetch { + pushServiceSocket.sendProvisioningMessage(deviceIdentifier, cipherText) + } + } + + /** + * Set [RestoreMethod] enum on the server for use by the old device to update UX. + */ + fun setRestoreMethod(token: String, method: RestoreMethod): NetworkResult { + return NetworkResult.fromFetch { + pushServiceSocket.setRestoreMethodChosen(token, RestoreMethodBody(method = method)) + } + } + + /** + * Wait for the [RestoreMethod] to be set on the server by the new device. This is a long polling operation. + */ + fun waitForRestoreMethod(token: String, timeout: Int = 30): NetworkResult { + return NetworkResult.fromFetch { + pushServiceSocket.waitForRestoreMethodChosen(token, timeout).method ?: RestoreMethod.DECLINE + } + } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RestoreMethod.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RestoreMethod.kt new file mode 100644 index 0000000000..93dcbd7d61 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RestoreMethod.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.registration + +/** + * Restore method chosen by user on new device after performing a quick-restore. + */ +enum class RestoreMethod { + REMOTE_BACKUP, + LOCAL_BACKUP, + DEVICE_TRANSFER, + DECLINE +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RestoreMethodBody.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RestoreMethodBody.kt new file mode 100644 index 0000000000..46f69041d3 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RestoreMethodBody.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.registration + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Request and response body used to communicate a quick restore method selection during registration. + */ +data class RestoreMethodBody @JsonCreator constructor( + @JsonProperty val method: RestoreMethod? +) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java index 0e4ee458d4..01181596c7 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/services/DonationsService.java @@ -27,6 +27,7 @@ import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Lock; import io.reactivex.rxjava3.annotations.NonNull; @@ -167,27 +168,35 @@ private ServiceResponse getCachedValue(Locale locale, * @param level The new level to subscribe to * @param currencyCode The currencyCode the user is using for payment * @param idempotencyKey url-safe-base64-encoded random 16-byte value (see description) - * @param mutex A mutex to lock on to avoid a situation where this subscription update happens *as* we are trying to get a credential receipt. + * @param lock A lock to lock on to avoid a situation where this subscription update happens *as* we are trying to get a credential receipt. */ public ServiceResponse updateSubscriptionLevel(SubscriberId subscriberId, String level, String currencyCode, String idempotencyKey, - Object mutex + Lock lock ) { return wrapInServiceResponse(() -> { - synchronized (mutex) { + lock.lock(); + + try { pushServiceSocket.updateSubscriptionLevel(subscriberId.serialize(), level, currencyCode, idempotencyKey); + } finally { + lock.unlock(); } + return new Pair<>(EmptyResponse.INSTANCE, 200); }); } - public ServiceResponse linkGooglePlayBillingPurchaseTokenToSubscriberId(SubscriberId subscriberId, String purchaseToken, Object mutex) { + public ServiceResponse linkGooglePlayBillingPurchaseTokenToSubscriberId(SubscriberId subscriberId, String purchaseToken, Lock lock) { return wrapInServiceResponse(() -> { - synchronized (mutex) { + lock.lock(); + try { pushServiceSocket.linkPlayBillingPurchaseToken(subscriberId.serialize(), purchaseToken); + } finally { + lock.unlock(); } return new Pair<>(EmptyResponse.INSTANCE, 200); diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/AccountRecordExtensions.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/AccountRecordExtensions.kt new file mode 100644 index 0000000000..15fbc29af1 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/AccountRecordExtensions.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.storage + +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.signal.core.util.isNotEmpty +import org.whispersystems.signalservice.api.payments.PaymentsConstants +import org.whispersystems.signalservice.api.push.ServiceId +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import org.whispersystems.signalservice.api.storage.StorageRecordProtoUtil.defaultAccountRecord +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord +import org.whispersystems.signalservice.internal.storage.protos.Payments + +fun AccountRecord.Builder.safeSetPayments(enabled: Boolean, entropy: ByteArray?): AccountRecord.Builder { + val paymentsBuilder = Payments.Builder() + val entropyPresent = entropy != null && entropy.size == PaymentsConstants.PAYMENTS_ENTROPY_LENGTH + + paymentsBuilder.enabled(enabled && entropyPresent) + + if (entropyPresent) { + paymentsBuilder.entropy(entropy!!.toByteString()) + } + + this.payments = paymentsBuilder.build() + + return this +} +fun AccountRecord.Builder.safeSetSubscriber(subscriberId: ByteString, subscriberCurrencyCode: String): AccountRecord.Builder { + if (subscriberId.isNotEmpty() && subscriberId.size == 32 && subscriberCurrencyCode.isNotBlank()) { + this.subscriberId = subscriberId + this.subscriberCurrencyCode = subscriberCurrencyCode + } else { + this.subscriberId = defaultAccountRecord.subscriberId + this.subscriberCurrencyCode = defaultAccountRecord.subscriberCurrencyCode + } + + return this +} + +fun AccountRecord.Builder.safeSetBackupsSubscriber(subscriberId: ByteString, subscriberCurrencyCode: String): AccountRecord.Builder { + if (subscriberId.isNotEmpty() && subscriberId.size == 32 && subscriberCurrencyCode.isNotBlank()) { + this.backupsSubscriberId = subscriberId + this.backupsSubscriberCurrencyCode = subscriberCurrencyCode + } else { + this.backupsSubscriberId = defaultAccountRecord.backupsSubscriberId + this.backupsSubscriberCurrencyCode = defaultAccountRecord.backupsSubscriberCurrencyCode + } + + return this +} + +fun AccountRecord.PinnedConversation.Contact.toSignalServiceAddress(): SignalServiceAddress { + val serviceId = ServiceId.parseOrNull(this.serviceId) + return SignalServiceAddress(serviceId, this.e164) +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/ContactRecordExtensions.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/ContactRecordExtensions.kt new file mode 100644 index 0000000000..88329f97d0 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/ContactRecordExtensions.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.storage + +import org.whispersystems.signalservice.api.push.ServiceId +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord + +val ContactRecord.signalAci: ServiceId.ACI? + get() = ServiceId.ACI.parseOrNull(this.aci) + +val ContactRecord.signalPni: ServiceId.PNI? + get() = ServiceId.PNI.parseOrNull(this.pni) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/ManifestRecordIdentifierExtensions.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/ManifestRecordIdentifierExtensions.kt new file mode 100644 index 0000000000..d64d3cabfb --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/ManifestRecordIdentifierExtensions.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.storage + +import com.squareup.wire.FieldEncoding +import okio.ByteString.Companion.toByteString +import org.signal.core.util.getUnknownEnumValue +import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord + +/** + * Wire makes it harder to write specific values to proto enums, since they use actual enums under the hood. + * This method handles creating an identifier from a possibly-unknown enum type, writing an unknown field if + * necessary to preserve the specific value. + */ +fun ManifestRecord.Identifier.Companion.fromPossiblyUnknownType(typeInt: Int, rawId: ByteArray): ManifestRecord.Identifier { + val builder = ManifestRecord.Identifier.Builder() + builder.raw = rawId.toByteString() + + val type = ManifestRecord.Identifier.Type.fromValue(typeInt) + if (type != null) { + builder.type = type + } else { + builder.type = ManifestRecord.Identifier.Type.UNKNOWN + builder.addUnknownField(StorageRecordProtoUtil.STORAGE_ID_TYPE_TAG, FieldEncoding.VARINT, typeInt) + } + + return builder.build() +} + +/** + * Wire makes it harder to read the underlying int value of an unknown enum. + * This value represents the _true_ int value of the enum, even if it is not one of the known values. + */ +val ManifestRecord.Identifier.typeValue: Int + get() { + return if (this.type != ManifestRecord.Identifier.Type.UNKNOWN) { + this.type.value + } else { + this.getUnknownEnumValue(StorageRecordProtoUtil.STORAGE_ID_TYPE_TAG) + } + } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java deleted file mode 100644 index 8ae972e164..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.java +++ /dev/null @@ -1,769 +0,0 @@ -package org.whispersystems.signalservice.api.storage; - -import org.signal.core.util.ProtoUtil; -import org.signal.libsignal.protocol.logging.Log; -import org.whispersystems.signalservice.api.payments.PaymentsConstants; -import org.whispersystems.signalservice.api.push.ServiceId; -import org.whispersystems.signalservice.api.push.SignalServiceAddress; -import org.whispersystems.signalservice.api.util.OptionalUtil; -import org.whispersystems.signalservice.internal.storage.protos.AccountRecord; -import org.whispersystems.signalservice.internal.storage.protos.OptionalBool; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedList; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; - -import javax.annotation.Nullable; - -import okio.ByteString; - -public final class SignalAccountRecord implements SignalRecord { - - private static final String TAG = SignalAccountRecord.class.getSimpleName(); - - private final StorageId id; - private final AccountRecord proto; - private final boolean hasUnknownFields; - - private final Optional givenName; - private final Optional familyName; - private final Optional avatarUrlPath; - private final Optional profileKey; - private final List pinnedConversations; - private final Payments payments; - private final List defaultReactions; - private final Subscriber subscriber; - private final Subscriber backupsSubscriber; - - public SignalAccountRecord(StorageId id, AccountRecord proto) { - this.id = id; - this.proto = proto; - this.hasUnknownFields = ProtoUtil.hasUnknownFields(proto); - - this.givenName = OptionalUtil.absentIfEmpty(proto.givenName); - this.familyName = OptionalUtil.absentIfEmpty(proto.familyName); - this.profileKey = OptionalUtil.absentIfEmpty(proto.profileKey); - this.avatarUrlPath = OptionalUtil.absentIfEmpty(proto.avatarUrlPath); - this.pinnedConversations = new ArrayList<>(proto.pinnedConversations.size()); - this.defaultReactions = new ArrayList<>(proto.preferredReactionEmoji); - this.subscriber = new Subscriber(proto.subscriberCurrencyCode, proto.subscriberId.toByteArray()); - this.backupsSubscriber = new Subscriber(proto.backupsSubscriberCurrencyCode, proto.backupsSubscriberId.toByteArray()); - - if (proto.payments != null) { - this.payments = new Payments(proto.payments.enabled, OptionalUtil.absentIfEmpty(proto.payments.entropy)); - } else { - this.payments = new Payments(false, Optional.empty()); - } - - for (AccountRecord.PinnedConversation conversation : proto.pinnedConversations) { - pinnedConversations.add(PinnedConversation.fromRemote(conversation)); - } - } - - @Override - public StorageId getId() { - return id; - } - - @Override - public SignalStorageRecord asStorageRecord() { - return SignalStorageRecord.forAccount(this); - } - - @Override - public String describeDiff(SignalRecord other) { - if (other instanceof SignalAccountRecord) { - SignalAccountRecord that = (SignalAccountRecord) other; - List diff = new LinkedList<>(); - - if (!Arrays.equals(this.id.getRaw(), that.id.getRaw())) { - diff.add("ID"); - } - - if (!Objects.equals(this.givenName, that.givenName)) { - diff.add("GivenName"); - } - - if (!Objects.equals(this.familyName, that.familyName)) { - diff.add("FamilyName"); - } - - if (!OptionalUtil.byteArrayEquals(this.profileKey, that.profileKey)) { - diff.add("ProfileKey"); - } - - if (!Objects.equals(this.avatarUrlPath, that.avatarUrlPath)) { - diff.add("AvatarUrlPath"); - } - - if (!Objects.equals(this.isNoteToSelfArchived(), that.isNoteToSelfArchived())) { - diff.add("NoteToSelfArchived"); - } - - if (!Objects.equals(this.isNoteToSelfForcedUnread(), that.isNoteToSelfForcedUnread())) { - diff.add("NoteToSelfForcedUnread"); - } - - if (!Objects.equals(this.isReadReceiptsEnabled(), that.isReadReceiptsEnabled())) { - diff.add("ReadReceipts"); - } - - if (!Objects.equals(this.isTypingIndicatorsEnabled(), that.isTypingIndicatorsEnabled())) { - diff.add("TypingIndicators"); - } - - if (!Objects.equals(this.isSealedSenderIndicatorsEnabled(), that.isSealedSenderIndicatorsEnabled())) { - diff.add("SealedSenderIndicators"); - } - - if (!Objects.equals(this.isLinkPreviewsEnabled(), that.isLinkPreviewsEnabled())) { - diff.add("LinkPreviews"); - } - - if (!Objects.equals(this.getPhoneNumberSharingMode(), that.getPhoneNumberSharingMode())) { - diff.add("PhoneNumberSharingMode"); - } - - if (!Objects.equals(this.isPhoneNumberUnlisted(), that.isPhoneNumberUnlisted())) { - diff.add("PhoneNumberUnlisted"); - } - - if (!Objects.equals(this.pinnedConversations, that.pinnedConversations)) { - diff.add("PinnedConversations"); - } - - if (!Objects.equals(this.isPreferContactAvatars(), that.isPreferContactAvatars())) { - diff.add("PreferContactAvatars"); - } - - if (!Objects.equals(this.payments, that.payments)) { - diff.add("Payments"); - } - - if (this.getUniversalExpireTimer() != that.getUniversalExpireTimer()) { - diff.add("UniversalExpireTimer"); - } - - if (!Objects.equals(this.isPrimarySendsSms(), that.isPrimarySendsSms())) { - diff.add("PrimarySendsSms"); - } - - if (!Objects.equals(this.getE164(), that.getE164())) { - diff.add("E164"); - } - - if (!Objects.equals(this.getDefaultReactions(), that.getDefaultReactions())) { - diff.add("DefaultReactions"); - } - - if (!Objects.equals(this.hasUnknownFields(), that.hasUnknownFields())) { - diff.add("UnknownFields"); - } - - if (!Objects.equals(this.getSubscriber(), that.getSubscriber())) { - diff.add("Subscriber"); - } - - if (!Objects.equals(this.isDisplayBadgesOnProfile(), that.isDisplayBadgesOnProfile())) { - diff.add("DisplayBadgesOnProfile"); - } - - if (!Objects.equals(this.isSubscriptionManuallyCancelled(), that.isSubscriptionManuallyCancelled())) { - diff.add("SubscriptionManuallyCancelled"); - } - - if (isKeepMutedChatsArchived() != that.isKeepMutedChatsArchived()) { - diff.add("KeepMutedChatsArchived"); - } - - if (hasSetMyStoriesPrivacy() != that.hasSetMyStoriesPrivacy()) { - diff.add("HasSetMyStoryPrivacy"); - } - - if (hasViewedOnboardingStory() != that.hasViewedOnboardingStory()) { - diff.add("HasViewedOnboardingStory"); - } - - if (isStoriesDisabled() != that.isStoriesDisabled()) { - diff.add("StoriesDisabled"); - } - - if (getStoryViewReceiptsState() != that.getStoryViewReceiptsState()) { - diff.add("StoryViewedReceipts"); - } - - if (hasSeenGroupStoryEducationSheet() != that.hasSeenGroupStoryEducationSheet()) { - diff.add("HasSeenGroupStoryEducationSheet"); - } - - if (!Objects.equals(getUsername(), that.getUsername())) { - diff.add("Username"); - } - - if (hasCompletedUsernameOnboarding() != that.hasCompletedUsernameOnboarding()) { - diff.add("HasCompletedUsernameOnboarding"); - } - - if (!Objects.equals(this.getBackupsSubscriber(), that.getBackupsSubscriber())) { - diff.add("BackupsSubscriber"); - } - - return diff.toString(); - } else { - return "Different class. " + getClass().getSimpleName() + " | " + other.getClass().getSimpleName(); - } - } - - public boolean hasUnknownFields() { - return hasUnknownFields; - } - - public byte[] serializeUnknownFields() { - return hasUnknownFields ? proto.encode() : null; - } - - public Optional getGivenName() { - return givenName; - } - - public Optional getFamilyName() { - return familyName; - } - - public Optional getProfileKey() { - return profileKey; - } - - public Optional getAvatarUrlPath() { - return avatarUrlPath; - } - - public boolean isNoteToSelfArchived() { - return proto.noteToSelfArchived; - } - - public boolean isNoteToSelfForcedUnread() { - return proto.noteToSelfMarkedUnread; - } - - public boolean isReadReceiptsEnabled() { - return proto.readReceipts; - } - - public boolean isTypingIndicatorsEnabled() { - return proto.typingIndicators; - } - - public boolean isSealedSenderIndicatorsEnabled() { - return proto.sealedSenderIndicators; - } - - public boolean isLinkPreviewsEnabled() { - return proto.linkPreviews; - } - - public AccountRecord.PhoneNumberSharingMode getPhoneNumberSharingMode() { - return proto.phoneNumberSharingMode; - } - - public boolean isPhoneNumberUnlisted() { - return proto.unlistedPhoneNumber; - } - - public List getPinnedConversations() { - return pinnedConversations; - } - - public boolean isPreferContactAvatars() { - return proto.preferContactAvatars; - } - - public Payments getPayments() { - return payments; - } - - public int getUniversalExpireTimer() { - return proto.universalExpireTimer; - } - - public boolean isPrimarySendsSms() { - return proto.primarySendsSms; - } - - public String getE164() { - return proto.e164; - } - - public List getDefaultReactions() { - return defaultReactions; - } - - public Subscriber getSubscriber() { - return subscriber; - } - - public Subscriber getBackupsSubscriber() { - return backupsSubscriber; - } - - public boolean isDisplayBadgesOnProfile() { - return proto.displayBadgesOnProfile; - } - - public boolean isSubscriptionManuallyCancelled() { - return proto.subscriptionManuallyCancelled; - } - - public boolean isKeepMutedChatsArchived() { - return proto.keepMutedChatsArchived; - } - - public boolean hasSetMyStoriesPrivacy() { - return proto.hasSetMyStoriesPrivacy; - } - - public boolean hasViewedOnboardingStory() { - return proto.hasViewedOnboardingStory; - } - - public boolean isStoriesDisabled() { - return proto.storiesDisabled; - } - - public OptionalBool getStoryViewReceiptsState() { - return proto.storyViewReceiptsEnabled; - } - - public boolean hasSeenGroupStoryEducationSheet() { - return proto.hasSeenGroupStoryEducationSheet; - } - - public boolean hasCompletedUsernameOnboarding() { - return proto.hasCompletedUsernameOnboarding; - } - - public @Nullable String getUsername() { - return proto.username; - } - - public @Nullable AccountRecord.UsernameLink getUsernameLink() { - return proto.usernameLink; - } - - public AccountRecord toProto() { - return proto; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - SignalAccountRecord that = (SignalAccountRecord) o; - return id.equals(that.id) && - proto.equals(that.proto); - } - - @Override - public int hashCode() { - return Objects.hash(id, proto); - } - - public static class PinnedConversation { - private final Optional contact; - private final Optional groupV1Id; - private final Optional groupV2MasterKey; - - private PinnedConversation(Optional contact, Optional groupV1Id, Optional groupV2MasterKey) { - this.contact = contact; - this.groupV1Id = groupV1Id; - this.groupV2MasterKey = groupV2MasterKey; - } - - public static PinnedConversation forContact(SignalServiceAddress address) { - return new PinnedConversation(Optional.of(address), Optional.empty(), Optional.empty()); - } - - public static PinnedConversation forGroupV1(byte[] groupId) { - return new PinnedConversation(Optional.empty(), Optional.of(groupId), Optional.empty()); - } - - public static PinnedConversation forGroupV2(byte[] masterKey) { - return new PinnedConversation(Optional.empty(), Optional.empty(), Optional.of(masterKey)); - } - - private static PinnedConversation forEmpty() { - return new PinnedConversation(Optional.empty(), Optional.empty(), Optional.empty()); - } - - static PinnedConversation fromRemote(AccountRecord.PinnedConversation remote) { - if (remote.contact != null) { - ServiceId serviceId = ServiceId.parseOrNull(remote.contact.serviceId); - if (serviceId != null) { - return forContact(new SignalServiceAddress(serviceId, remote.contact.e164)); - } else { - Log.w(TAG, "Bad serviceId on pinned contact! Length: " + remote.contact.serviceId); - return PinnedConversation.forEmpty(); - } - } else if (remote.legacyGroupId != null && remote.legacyGroupId.size() > 0) { - return forGroupV1(remote.legacyGroupId.toByteArray()); - } else if (remote.groupMasterKey != null && remote.groupMasterKey.size() > 0) { - return forGroupV2(remote.groupMasterKey.toByteArray()); - } else { - return PinnedConversation.forEmpty(); - } - } - - public Optional getContact() { - return contact; - } - - public Optional getGroupV1Id() { - return groupV1Id; - } - - public Optional getGroupV2MasterKey() { - return groupV2MasterKey; - } - - public boolean isValid() { - return contact.isPresent() || groupV1Id.isPresent() || groupV2MasterKey.isPresent(); - } - - private AccountRecord.PinnedConversation toRemote() { - if (contact.isPresent()) { - AccountRecord.PinnedConversation.Contact.Builder contactBuilder = new AccountRecord.PinnedConversation.Contact.Builder(); - - contactBuilder.serviceId(contact.get().getServiceId().toString()); - - if (contact.get().getNumber().isPresent()) { - contactBuilder.e164(contact.get().getNumber().get()); - } - return new AccountRecord.PinnedConversation.Builder().contact(contactBuilder.build()).build(); - } else if (groupV1Id.isPresent()) { - return new AccountRecord.PinnedConversation.Builder().legacyGroupId(ByteString.of(groupV1Id.get())).build(); - } else if (groupV2MasterKey.isPresent()) { - return new AccountRecord.PinnedConversation.Builder().groupMasterKey(ByteString.of(groupV2MasterKey.get())).build(); - } else { - return new AccountRecord.PinnedConversation.Builder().build(); - } - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - PinnedConversation that = (PinnedConversation) o; - return contact.equals(that.contact) && - groupV1Id.equals(that.groupV1Id) && - groupV2MasterKey.equals(that.groupV2MasterKey); - } - - @Override - public int hashCode() { - return Objects.hash(contact, groupV1Id, groupV2MasterKey); - } - } - - public static class Subscriber { - private final Optional currencyCode; - private final Optional id; - - public Subscriber(String currencyCode, byte[] id) { - if (currencyCode != null && id != null && id.length == 32) { - this.currencyCode = Optional.of(currencyCode); - this.id = Optional.of(id); - } else { - this.currencyCode = Optional.empty(); - this.id = Optional.empty(); - } - } - - public Optional getCurrencyCode() { - return currencyCode; - } - - public Optional getId() { - return id; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - final Subscriber that = (Subscriber) o; - return Objects.equals(currencyCode, that.currencyCode) && OptionalUtil.byteArrayEquals(id, that.id); - } - - @Override - public int hashCode() { - return Objects.hash(currencyCode, id); - } - } - - public static class Payments { - private static final String TAG = Payments.class.getSimpleName(); - - private final boolean enabled; - private final Optional entropy; - - public Payments(boolean enabled, Optional entropy) { - byte[] entropyBytes = entropy.orElse(null); - if (entropyBytes != null && entropyBytes.length != PaymentsConstants.PAYMENTS_ENTROPY_LENGTH) { - Log.w(TAG, "Blocked entropy of length " + entropyBytes.length); - entropyBytes = null; - } - this.entropy = Optional.ofNullable(entropyBytes); - this.enabled = enabled && this.entropy.isPresent(); - } - - public boolean isEnabled() { - return enabled; - } - - public Optional getEntropy() { - return entropy; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - Payments payments = (Payments) o; - return enabled == payments.enabled && - OptionalUtil.byteArrayEquals(entropy, payments.entropy); - } - - @Override - public int hashCode() { - return Objects.hash(enabled, entropy); - } - } - - public static final class Builder { - private final StorageId id; - private final AccountRecord.Builder builder; - - public Builder(byte[] rawId, byte[] serializedUnknowns) { - this.id = StorageId.forAccount(rawId); - - if (serializedUnknowns != null) { - this.builder = parseUnknowns(serializedUnknowns); - } else { - this.builder = new AccountRecord.Builder(); - } - } - - public Builder setGivenName(String givenName) { - builder.givenName(givenName == null ? "" : givenName); - return this; - } - - public Builder setFamilyName(String familyName) { - builder.familyName(familyName == null ? "" : familyName); - return this; - } - - public Builder setProfileKey(byte[] profileKey) { - builder.profileKey(profileKey == null ? ByteString.EMPTY : ByteString.of(profileKey)); - return this; - } - - public Builder setAvatarUrlPath(String urlPath) { - builder.avatarUrlPath(urlPath == null ? "" : urlPath); - return this; - } - - public Builder setNoteToSelfArchived(boolean archived) { - builder.noteToSelfArchived(archived); - return this; - } - - public Builder setNoteToSelfForcedUnread(boolean forcedUnread) { - builder.noteToSelfMarkedUnread(forcedUnread); - return this; - } - - public Builder setReadReceiptsEnabled(boolean enabled) { - builder.readReceipts(enabled); - return this; - } - - public Builder setTypingIndicatorsEnabled(boolean enabled) { - builder.typingIndicators(enabled); - return this; - } - - public Builder setSealedSenderIndicatorsEnabled(boolean enabled) { - builder.sealedSenderIndicators(enabled); - return this; - } - - public Builder setLinkPreviewsEnabled(boolean enabled) { - builder.linkPreviews(enabled); - return this; - } - - public Builder setPhoneNumberSharingMode(AccountRecord.PhoneNumberSharingMode mode) { - builder.phoneNumberSharingMode(mode); - return this; - } - - public Builder setUnlistedPhoneNumber(boolean unlisted) { - builder.unlistedPhoneNumber(unlisted); - return this; - } - - public Builder setPinnedConversations(List pinnedConversations) { - builder.pinnedConversations(pinnedConversations.stream().map(PinnedConversation::toRemote).collect(Collectors.toList())); - return this; - } - - public Builder setPreferContactAvatars(boolean preferContactAvatars) { - builder.preferContactAvatars(preferContactAvatars); - return this; - } - - public Builder setPayments(boolean enabled, byte[] entropy) { - org.whispersystems.signalservice.internal.storage.protos.Payments.Builder paymentsBuilder = new org.whispersystems.signalservice.internal.storage.protos.Payments.Builder(); - - boolean entropyPresent = entropy != null && entropy.length == PaymentsConstants.PAYMENTS_ENTROPY_LENGTH; - - paymentsBuilder.enabled(enabled && entropyPresent); - - if (entropyPresent) { - paymentsBuilder.entropy(ByteString.of(entropy)); - } - - builder.payments(paymentsBuilder.build()); - - return this; - } - - public Builder setUniversalExpireTimer(int timer) { - builder.universalExpireTimer(timer); - return this; - } - - public Builder setPrimarySendsSms(boolean primarySendsSms) { - builder.primarySendsSms(primarySendsSms); - return this; - } - - public Builder setE164(String e164) { - builder.e164(e164); - return this; - } - - public Builder setDefaultReactions(List defaultReactions) { - builder.preferredReactionEmoji(new ArrayList<>(defaultReactions)); - return this; - } - - public Builder setSubscriber(Subscriber subscriber) { - if (subscriber.id.isPresent() && subscriber.currencyCode.isPresent()) { - builder.subscriberId(ByteString.of(subscriber.id.get())); - builder.subscriberCurrencyCode(subscriber.currencyCode.get()); - } else { - builder.subscriberId(StorageRecordProtoUtil.getDefaultAccountRecord().subscriberId); - builder.subscriberCurrencyCode(StorageRecordProtoUtil.getDefaultAccountRecord().subscriberCurrencyCode); - } - - return this; - } - - public Builder setBackupsSubscriber(Subscriber subscriber) { - if (subscriber.id.isPresent() && subscriber.currencyCode.isPresent()) { - builder.backupsSubscriberId(ByteString.of(subscriber.id.get())); - builder.backupsSubscriberCurrencyCode(subscriber.currencyCode.get()); - } else { - builder.backupsSubscriberId(StorageRecordProtoUtil.getDefaultAccountRecord().subscriberId); - builder.backupsSubscriberCurrencyCode(StorageRecordProtoUtil.getDefaultAccountRecord().subscriberCurrencyCode); - } - - return this; - } - - public Builder setDisplayBadgesOnProfile(boolean displayBadgesOnProfile) { - builder.displayBadgesOnProfile(displayBadgesOnProfile); - return this; - } - - public Builder setSubscriptionManuallyCancelled(boolean subscriptionManuallyCancelled) { - builder.subscriptionManuallyCancelled(subscriptionManuallyCancelled); - return this; - } - - public Builder setKeepMutedChatsArchived(boolean keepMutedChatsArchived) { - builder.keepMutedChatsArchived(keepMutedChatsArchived); - return this; - } - - public Builder setHasSetMyStoriesPrivacy(boolean hasSetMyStoriesPrivacy) { - builder.hasSetMyStoriesPrivacy(hasSetMyStoriesPrivacy); - return this; - } - - public Builder setHasViewedOnboardingStory(boolean hasViewedOnboardingStory) { - builder.hasViewedOnboardingStory(hasViewedOnboardingStory); - return this; - } - - public Builder setStoriesDisabled(boolean storiesDisabled) { - builder.storiesDisabled(storiesDisabled); - return this; - } - - public Builder setStoryViewReceiptsState(OptionalBool storyViewedReceiptsEnabled) { - builder.storyViewReceiptsEnabled(storyViewedReceiptsEnabled); - return this; - } - - public Builder setHasSeenGroupStoryEducationSheet(boolean hasSeenGroupStoryEducationSheet) { - builder.hasSeenGroupStoryEducationSheet(hasSeenGroupStoryEducationSheet); - return this; - } - - public Builder setHasCompletedUsernameOnboarding(boolean hasCompletedUsernameOnboarding) { - builder.hasCompletedUsernameOnboarding(hasCompletedUsernameOnboarding); - return this; - } - - public Builder setUsername(@Nullable String username) { - if (username == null || username.isEmpty()) { - builder.username(StorageRecordProtoUtil.getDefaultAccountRecord().username); - } else { - builder.username(username); - } - - return this; - } - - public Builder setUsernameLink(@Nullable AccountRecord.UsernameLink link) { - if (link == null) { - builder.usernameLink(StorageRecordProtoUtil.getDefaultAccountRecord().usernameLink); - } else { - builder.usernameLink(link); - } - - return this; - } - - private static AccountRecord.Builder parseUnknowns(byte[] serializedUnknowns) { - try { - return AccountRecord.ADAPTER.decode(serializedUnknowns).newBuilder(); - } catch (IOException e) { - Log.w(TAG, "Failed to combine unknown fields!", e); - return new AccountRecord.Builder(); - } - } - - public SignalAccountRecord build() { - return new SignalAccountRecord(id, builder.build()); - } - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.kt new file mode 100644 index 0000000000..dd10980091 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalAccountRecord.kt @@ -0,0 +1,27 @@ +package org.whispersystems.signalservice.api.storage + +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord +import java.io.IOException + +/** + * Wrapper around a [AccountRecord] to pair it with a [StorageId]. + */ +data class SignalAccountRecord( + override val id: StorageId, + override val proto: AccountRecord +) : SignalRecord { + + companion object { + fun newBuilder(serializedUnknowns: ByteArray?): AccountRecord.Builder { + return serializedUnknowns?.let { builderFromUnknowns(it) } ?: AccountRecord.Builder() + } + + private fun builderFromUnknowns(serializedUnknowns: ByteArray): AccountRecord.Builder { + return try { + AccountRecord.ADAPTER.decode(serializedUnknowns).newBuilder() + } catch (e: IOException) { + AccountRecord.Builder() + } + } + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalCallLinkRecord.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalCallLinkRecord.kt index 07668e6968..a37ec2857f 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalCallLinkRecord.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalCallLinkRecord.kt @@ -5,103 +5,27 @@ package org.whispersystems.signalservice.api.storage -import okio.ByteString.Companion.toByteString import org.whispersystems.signalservice.internal.storage.protos.CallLinkRecord import java.io.IOException -import java.util.LinkedList /** - * A record in storage service that represents a call link that was already created. + * Wrapper around a [CallLinkRecord] to pair it with a [StorageId]. */ -class SignalCallLinkRecord(private val id: StorageId, private val proto: CallLinkRecord) : SignalRecord { - - val rootKey: ByteArray = proto.rootKey.toByteArray() - val adminPassKey: ByteArray = proto.adminPasskey.toByteArray() - val deletionTimestamp: Long = proto.deletedAtTimestampMs - - fun toProto(): CallLinkRecord { - return proto - } - - override fun getId(): StorageId { - return id - } - - override fun asStorageRecord(): SignalStorageRecord { - return SignalStorageRecord.forCallLink(this) - } - - override fun describeDiff(other: SignalRecord?): String { - return when (other) { - is SignalCallLinkRecord -> { - val diff = LinkedList() - if (!rootKey.contentEquals(other.rootKey)) { - diff.add("RootKey") - } - - if (!adminPassKey.contentEquals(other.adminPassKey)) { - diff.add("AdminPassKey") - } - - if (deletionTimestamp != other.deletionTimestamp) { - diff.add("DeletionTimestamp") - } - - diff.toString() - } - - null -> { - "Other was null!" - } - - else -> { - "Different class. ${this::class.java.getSimpleName()} | ${other::class.java.getSimpleName()}" - } - } - } - - fun isDeleted(): Boolean { - return deletionTimestamp > 0 - } - - class Builder(rawId: ByteArray, serializedUnknowns: ByteArray?) { - private var id: StorageId = StorageId.forCallLink(rawId) - private var builder: CallLinkRecord.Builder - - init { - if (serializedUnknowns != null) { - this.builder = parseUnknowns(serializedUnknowns) - } else { - this.builder = CallLinkRecord.Builder() - } - } - - fun setRootKey(rootKey: ByteArray): Builder { - builder.rootKey = rootKey.toByteString() - return this - } - - fun setAdminPassKey(adminPasskey: ByteArray): Builder { - builder.adminPasskey = adminPasskey.toByteString() - return this - } - - fun setDeletedTimestamp(deletedTimestamp: Long): Builder { - builder.deletedAtTimestampMs = deletedTimestamp - return this - } - - fun build(): SignalCallLinkRecord { - return SignalCallLinkRecord(id, builder.build()) +data class SignalCallLinkRecord( + override val id: StorageId, + override val proto: CallLinkRecord +) : SignalRecord { + + companion object { + fun newBuilder(serializedUnknowns: ByteArray?): CallLinkRecord.Builder { + return serializedUnknowns?.let { builderFromUnknowns(it) } ?: CallLinkRecord.Builder() } - companion object { - fun parseUnknowns(serializedUnknowns: ByteArray): CallLinkRecord.Builder { - return try { - CallLinkRecord.ADAPTER.decode(serializedUnknowns).newBuilder() - } catch (e: IOException) { - CallLinkRecord.Builder() - } + private fun builderFromUnknowns(serializedUnknowns: ByteArray): CallLinkRecord.Builder { + return try { + CallLinkRecord.ADAPTER.decode(serializedUnknowns).newBuilder() + } catch (e: IOException) { + CallLinkRecord.Builder() } } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalContactRecord.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalContactRecord.java deleted file mode 100644 index 90e468a8c8..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalContactRecord.java +++ /dev/null @@ -1,479 +0,0 @@ -package org.whispersystems.signalservice.api.storage; - -import org.signal.core.util.ProtoUtil; -import org.signal.libsignal.protocol.logging.Log; -import org.whispersystems.signalservice.api.push.ServiceId; -import org.whispersystems.signalservice.api.push.ServiceId.ACI; -import org.whispersystems.signalservice.api.push.ServiceId.PNI; -import org.whispersystems.signalservice.api.util.OptionalUtil; -import org.whispersystems.signalservice.internal.storage.protos.ContactRecord; -import org.whispersystems.signalservice.internal.storage.protos.ContactRecord.IdentityState; - -import java.io.IOException; -import java.util.Arrays; -import java.util.LinkedList; -import java.util.List; -import java.util.Objects; -import java.util.Optional; - -import javax.annotation.Nullable; - -import okio.ByteString; - -public final class SignalContactRecord implements SignalRecord { - - private static final String TAG = SignalContactRecord.class.getSimpleName(); - - private final StorageId id; - private final ContactRecord proto; - private final boolean hasUnknownFields; - - private final Optional aci; - private final Optional pni; - private final Optional e164; - private final Optional profileGivenName; - private final Optional profileFamilyName; - private final Optional systemGivenName; - private final Optional systemFamilyName; - private final Optional systemNickname; - private final Optional profileKey; - private final Optional username; - private final Optional identityKey; - private final Optional nicknameGivenName; - private final Optional nicknameFamilyName; - private final Optional note; - - public SignalContactRecord(StorageId id, ContactRecord proto) { - this.id = id; - this.proto = proto; - this.hasUnknownFields = ProtoUtil.hasUnknownFields(proto); - this.aci = OptionalUtil.absentIfEmpty(proto.aci).map(ACI::parseOrNull).map(it -> it.isUnknown() ? null : it); - this.pni = OptionalUtil.absentIfEmpty(proto.pni).map(PNI::parseOrNull).map(it -> it.isUnknown() ? null : it); - this.e164 = OptionalUtil.absentIfEmpty(proto.e164); - this.profileGivenName = OptionalUtil.absentIfEmpty(proto.givenName); - this.profileFamilyName = OptionalUtil.absentIfEmpty(proto.familyName); - this.systemGivenName = OptionalUtil.absentIfEmpty(proto.systemGivenName); - this.systemFamilyName = OptionalUtil.absentIfEmpty(proto.systemFamilyName); - this.systemNickname = OptionalUtil.absentIfEmpty(proto.systemNickname); - this.profileKey = OptionalUtil.absentIfEmpty(proto.profileKey); - this.username = OptionalUtil.absentIfEmpty(proto.username); - this.identityKey = OptionalUtil.absentIfEmpty(proto.identityKey); - this.nicknameGivenName = Optional.ofNullable(proto.nickname).flatMap(n -> OptionalUtil.absentIfEmpty(n.given)); - this.nicknameFamilyName = Optional.ofNullable(proto.nickname).flatMap(n -> OptionalUtil.absentIfEmpty(n.family)); - this.note = OptionalUtil.absentIfEmpty(proto.note); - } - - @Override - public StorageId getId() { - return id; - } - - @Override - public SignalStorageRecord asStorageRecord() { - return SignalStorageRecord.forContact(this); - } - - @Override - public String describeDiff(SignalRecord other) { - if (other instanceof SignalContactRecord) { - SignalContactRecord that = (SignalContactRecord) other; - List diff = new LinkedList<>(); - - if (!Arrays.equals(this.id.getRaw(), that.id.getRaw())) { - diff.add("ID"); - } - - if (!Objects.equals(this.getAci(), that.getAci())) { - diff.add("ACI"); - } - - if (!Objects.equals(this.getPni(), that.getPni())) { - diff.add("PNI"); - } - - if (!Objects.equals(this.getNumber(), that.getNumber())) { - diff.add("E164"); - } - - if (!Objects.equals(this.profileGivenName, that.profileGivenName)) { - diff.add("ProfileGivenName"); - } - - if (!Objects.equals(this.profileFamilyName, that.profileFamilyName)) { - diff.add("ProfileFamilyName"); - } - - if (!Objects.equals(this.systemGivenName, that.systemGivenName)) { - diff.add("SystemGivenName"); - } - - if (!Objects.equals(this.systemFamilyName, that.systemFamilyName)) { - diff.add("SystemFamilyName"); - } - - if (!Objects.equals(this.systemNickname, that.systemNickname)) { - diff.add("SystemNickname"); - } - - if (!OptionalUtil.byteArrayEquals(this.profileKey, that.profileKey)) { - diff.add("ProfileKey"); - } - - if (!Objects.equals(this.username, that.username)) { - diff.add("Username"); - } - - if (!OptionalUtil.byteArrayEquals(this.identityKey, that.identityKey)) { - diff.add("IdentityKey"); - } - - if (!Objects.equals(this.getIdentityState(), that.getIdentityState())) { - diff.add("IdentityState"); - } - - if (!Objects.equals(this.isBlocked(), that.isBlocked())) { - diff.add("Blocked"); - } - - if (!Objects.equals(this.isProfileSharingEnabled(), that.isProfileSharingEnabled())) { - diff.add("ProfileSharing"); - } - - if (!Objects.equals(this.isArchived(), that.isArchived())) { - diff.add("Archived"); - } - - if (!Objects.equals(this.isForcedUnread(), that.isForcedUnread())) { - diff.add("ForcedUnread"); - } - - if (!Objects.equals(this.getMuteUntil(), that.getMuteUntil())) { - diff.add("MuteUntil"); - } - - if (shouldHideStory() != that.shouldHideStory()) { - diff.add("HideStory"); - } - - if (getUnregisteredTimestamp() != that.getUnregisteredTimestamp()) { - diff.add("UnregisteredTimestamp"); - } - - if (isHidden() != that.isHidden()) { - diff.add("Hidden"); - } - - if (isPniSignatureVerified() != that.isPniSignatureVerified()) { - diff.add("PniSignatureVerified"); - } - - if (!Objects.equals(this.hasUnknownFields(), that.hasUnknownFields())) { - diff.add("UnknownFields"); - } - - if (!Objects.equals(this.nicknameGivenName, that.nicknameGivenName)) { - diff.add("NicknameGivenName"); - } - - if (!Objects.equals(this.nicknameFamilyName, that.nicknameFamilyName)) { - diff.add("NicknameFamilyName"); - } - - if (!Objects.equals(this.note, that.note)) { - diff.add("Note"); - } - - return diff.toString(); - } else { - return "Different class. " + getClass().getSimpleName() + " | " + other.getClass().getSimpleName(); - } - } - - public boolean hasUnknownFields() { - return hasUnknownFields; - } - - public byte[] serializeUnknownFields() { - return hasUnknownFields ? proto.encode() : null; - } - - public Optional getAci() { - return aci; - } - - public Optional getPni() { - return pni; - } - - public Optional getServiceId() { - if (aci.isPresent()) { - return aci; - } else if (pni.isPresent()) { - return pni; - } else { - return Optional.empty(); - } - } - - public Optional getNumber() { - return e164; - } - - public Optional getProfileGivenName() { - return profileGivenName; - } - - public Optional getProfileFamilyName() { - return profileFamilyName; - } - - public Optional getSystemGivenName() { - return systemGivenName; - } - - public Optional getSystemFamilyName() { - return systemFamilyName; - } - - public Optional getSystemNickname() { - return systemNickname; - } - - public Optional getNicknameGivenName() { - return nicknameGivenName; - } - - public Optional getNicknameFamilyName() { - return nicknameFamilyName; - } - - public Optional getNote() { - return note; - } - - public Optional getProfileKey() { - return profileKey; - } - - public Optional getUsername() { - return username; - } - - public Optional getIdentityKey() { - return identityKey; - } - - public IdentityState getIdentityState() { - return proto.identityState; - } - - public boolean isBlocked() { - return proto.blocked; - } - - public boolean isProfileSharingEnabled() { - return proto.whitelisted; - } - - public boolean isArchived() { - return proto.archived; - } - - public boolean isForcedUnread() { - return proto.markedUnread; - } - - public long getMuteUntil() { - return proto.mutedUntilTimestamp; - } - - public boolean shouldHideStory() { - return proto.hideStory; - } - - public long getUnregisteredTimestamp() { - return proto.unregisteredAtTimestamp; - } - - public boolean isHidden() { - return proto.hidden; - } - - public boolean isPniSignatureVerified() { - return proto.pniSignatureVerified; - } - - /** - * Returns the same record, but stripped of the PNI field. Only used while PNP is in development. - */ - public SignalContactRecord withoutPni() { - return new SignalContactRecord(id, proto.newBuilder().pni("").build()); - } - - public ContactRecord toProto() { - return proto; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - SignalContactRecord that = (SignalContactRecord) o; - return id.equals(that.id) && - proto.equals(that.proto); - } - - @Override - public int hashCode() { - return Objects.hash(id, proto); - } - - public static final class Builder { - private final StorageId id; - private final ContactRecord.Builder builder; - - public Builder(byte[] rawId, @Nullable ACI aci, byte[] serializedUnknowns) { - this.id = StorageId.forContact(rawId); - - if (serializedUnknowns != null) { - this.builder = parseUnknowns(serializedUnknowns); - } else { - this.builder = new ContactRecord.Builder(); - } - - builder.aci(aci == null ? "" : aci.toString()); - } - - public Builder setE164(String e164) { - builder.e164(e164 == null ? "" : e164); - return this; - } - - public Builder setPni(PNI pni) { - builder.pni(pni == null ? "" : pni.toStringWithoutPrefix()); - return this; - } - - public Builder setProfileGivenName(String givenName) { - builder.givenName(givenName == null ? "" : givenName); - return this; - } - - public Builder setProfileFamilyName(String familyName) { - builder.familyName(familyName == null ? "" : familyName); - return this; - } - - public Builder setSystemGivenName(String givenName) { - builder.systemGivenName(givenName == null ? "" : givenName); - return this; - } - - public Builder setSystemFamilyName(String familyName) { - builder.systemFamilyName(familyName == null ? "" : familyName); - return this; - } - - public Builder setSystemNickname(String nickname) { - builder.systemNickname(nickname == null ? "" : nickname); - return this; - } - - public Builder setProfileKey(byte[] profileKey) { - builder.profileKey(profileKey == null ? ByteString.EMPTY : ByteString.of(profileKey)); - return this; - } - - public Builder setUsername(String username) { - builder.username(username == null ? "" : username); - return this; - } - - public Builder setIdentityKey(byte[] identityKey) { - builder.identityKey(identityKey == null ? ByteString.EMPTY : ByteString.of(identityKey)); - return this; - } - - public Builder setIdentityState(IdentityState identityState) { - builder.identityState(identityState == null ? IdentityState.DEFAULT : identityState); - return this; - } - - public Builder setBlocked(boolean blocked) { - builder.blocked(blocked); - return this; - } - - public Builder setProfileSharingEnabled(boolean profileSharingEnabled) { - builder.whitelisted(profileSharingEnabled); - return this; - } - - public Builder setArchived(boolean archived) { - builder.archived(archived); - return this; - } - - public Builder setForcedUnread(boolean forcedUnread) { - builder.markedUnread(forcedUnread); - return this; - } - - public Builder setMuteUntil(long muteUntil) { - builder.mutedUntilTimestamp(muteUntil); - return this; - } - - public Builder setHideStory(boolean hideStory) { - builder.hideStory(hideStory); - return this; - } - - public Builder setUnregisteredTimestamp(long timestamp) { - builder.unregisteredAtTimestamp(timestamp); - return this; - } - - public Builder setHidden(boolean hidden) { - builder.hidden(hidden); - return this; - } - - public Builder setPniSignatureVerified(boolean verified) { - builder.pniSignatureVerified(verified); - return this; - } - - public Builder setNicknameGivenName(String nicknameGivenName) { - ContactRecord.Name.Builder name = builder.nickname == null ? new ContactRecord.Name.Builder() : builder.nickname.newBuilder(); - name.given(nicknameGivenName); - builder.nickname(name.build()); - return this; - } - - public Builder setNicknameFamilyName(String nicknameFamilyName) { - ContactRecord.Name.Builder name = builder.nickname == null ? new ContactRecord.Name.Builder() : builder.nickname.newBuilder(); - name.family(nicknameFamilyName); - builder.nickname(name.build()); - return this; - } - - public Builder setNote(String note) { - builder.note(note == null ? "" : note); - return this; - } - - private static ContactRecord.Builder parseUnknowns(byte[] serializedUnknowns) { - try { - return ContactRecord.ADAPTER.decode(serializedUnknowns).newBuilder(); - } catch (IOException e) { - Log.w(TAG, "Failed to combine unknown fields!", e); - return new ContactRecord.Builder(); - } - } - - public SignalContactRecord build() { - return new SignalContactRecord(id, builder.build()); - } - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalContactRecord.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalContactRecord.kt new file mode 100644 index 0000000000..a12c262a45 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalContactRecord.kt @@ -0,0 +1,27 @@ +package org.whispersystems.signalservice.api.storage + +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord +import java.io.IOException + +/** + * Wrapper around a [ContactRecord] to pair it with a [StorageId]. + */ +data class SignalContactRecord( + override val id: StorageId, + override val proto: ContactRecord +) : SignalRecord { + + companion object { + fun newBuilder(serializedUnknowns: ByteArray?): ContactRecord.Builder { + return serializedUnknowns?.let { builderFromUnknowns(it) } ?: ContactRecord.Builder() + } + + private fun builderFromUnknowns(serializedUnknowns: ByteArray): ContactRecord.Builder { + return try { + ContactRecord.ADAPTER.decode(serializedUnknowns).newBuilder() + } catch (e: IOException) { + ContactRecord.Builder() + } + } + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV1Record.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV1Record.java deleted file mode 100644 index 3537171cc9..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV1Record.java +++ /dev/null @@ -1,189 +0,0 @@ -package org.whispersystems.signalservice.api.storage; - -import org.signal.core.util.ProtoUtil; -import org.signal.libsignal.protocol.logging.Log; -import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record; - -import java.io.IOException; -import java.util.Arrays; -import java.util.LinkedList; -import java.util.List; -import java.util.Objects; - -import okio.ByteString; - -public final class SignalGroupV1Record implements SignalRecord { - - private static final String TAG = SignalGroupV1Record.class.getSimpleName(); - - private final StorageId id; - private final GroupV1Record proto; - private final byte[] groupId; - private final boolean hasUnknownFields; - - public SignalGroupV1Record(StorageId id, GroupV1Record proto) { - this.id = id; - this.proto = proto; - this.groupId = proto.id.toByteArray(); - this.hasUnknownFields = ProtoUtil.hasUnknownFields(proto); - } - - @Override - public StorageId getId() { - return id; - } - - @Override - public SignalStorageRecord asStorageRecord() { - return SignalStorageRecord.forGroupV1(this); - } - - @Override - public String describeDiff(SignalRecord other) { - if (other instanceof SignalGroupV1Record) { - SignalGroupV1Record that = (SignalGroupV1Record) other; - List diff = new LinkedList<>(); - - if (!Arrays.equals(this.id.getRaw(), that.id.getRaw())) { - diff.add("ID"); - } - - if (!Arrays.equals(this.groupId, that.groupId)) { - diff.add("MasterKey"); - } - - if (!Objects.equals(this.isBlocked(), that.isBlocked())) { - diff.add("Blocked"); - } - - if (!Objects.equals(this.isProfileSharingEnabled(), that.isProfileSharingEnabled())) { - diff.add("ProfileSharing"); - } - - if (!Objects.equals(this.isArchived(), that.isArchived())) { - diff.add("Archived"); - } - - if (!Objects.equals(this.isForcedUnread(), that.isForcedUnread())) { - diff.add("ForcedUnread"); - } - - if (!Objects.equals(this.getMuteUntil(), that.getMuteUntil())) { - diff.add("MuteUntil"); - } - - if (!Objects.equals(this.hasUnknownFields(), that.hasUnknownFields())) { - diff.add("UnknownFields"); - } - - return diff.toString(); - } else { - return "Different class. " + getClass().getSimpleName() + " | " + other.getClass().getSimpleName(); - } - } - - public boolean hasUnknownFields() { - return hasUnknownFields; - } - - public byte[] serializeUnknownFields() { - return hasUnknownFields ? proto.encode() : null; - } - - public byte[] getGroupId() { - return groupId; - } - - public boolean isBlocked() { - return proto.blocked; - } - - public boolean isProfileSharingEnabled() { - return proto.whitelisted; - } - - public boolean isArchived() { - return proto.archived; - } - - public boolean isForcedUnread() { - return proto.markedUnread; - } - - public long getMuteUntil() { - return proto.mutedUntilTimestamp; - } - - public GroupV1Record toProto() { - return proto; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - SignalGroupV1Record that = (SignalGroupV1Record) o; - return id.equals(that.id) && - proto.equals(that.proto); - } - - @Override - public int hashCode() { - return Objects.hash(id, proto); - } - - public static final class Builder { - private final StorageId id; - private final GroupV1Record.Builder builder; - - public Builder(byte[] rawId, byte[] groupId, byte[] serializedUnknowns) { - this.id = StorageId.forGroupV1(rawId); - - if (serializedUnknowns != null) { - this.builder = parseUnknowns(serializedUnknowns); - } else { - this.builder = new GroupV1Record.Builder(); - } - - builder.id(ByteString.of(groupId)); - } - - public Builder setBlocked(boolean blocked) { - builder.blocked(blocked); - return this; - } - - public Builder setProfileSharingEnabled(boolean profileSharingEnabled) { - builder.whitelisted(profileSharingEnabled); - return this; - } - - public Builder setArchived(boolean archived) { - builder.archived(archived); - return this; - } - - public Builder setForcedUnread(boolean forcedUnread) { - builder.markedUnread(forcedUnread); - return this; - } - - public Builder setMuteUntil(long muteUntil) { - builder.mutedUntilTimestamp(muteUntil); - return this; - } - - private static GroupV1Record.Builder parseUnknowns(byte[] serializedUnknowns) { - try { - return GroupV1Record.ADAPTER.decode(serializedUnknowns).newBuilder(); - } catch (IOException e) { - Log.w(TAG, "Failed to combine unknown fields!", e); - return new GroupV1Record.Builder(); - } - } - - public SignalGroupV1Record build() { - return new SignalGroupV1Record(id, builder.build()); - } - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV1Record.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV1Record.kt new file mode 100644 index 0000000000..70873b6296 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV1Record.kt @@ -0,0 +1,27 @@ +package org.whispersystems.signalservice.api.storage + +import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record +import java.io.IOException + +/** + * Wrapper around a [GroupV1Record] to pair it with a [StorageId]. + */ +data class SignalGroupV1Record( + override val id: StorageId, + override val proto: GroupV1Record +) : SignalRecord { + + companion object { + fun newBuilder(serializedUnknowns: ByteArray?): GroupV1Record.Builder { + return serializedUnknowns?.let { builderFromUnknowns(it) } ?: GroupV1Record.Builder() + } + + private fun builderFromUnknowns(serializedUnknowns: ByteArray): GroupV1Record.Builder { + return try { + GroupV1Record.ADAPTER.decode(serializedUnknowns).newBuilder() + } catch (e: IOException) { + GroupV1Record.Builder() + } + } + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV2Record.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV2Record.java deleted file mode 100644 index 059a8979ad..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV2Record.java +++ /dev/null @@ -1,242 +0,0 @@ -package org.whispersystems.signalservice.api.storage; - -import org.signal.core.util.ProtoUtil; -import org.signal.libsignal.protocol.logging.Log; -import org.signal.libsignal.zkgroup.InvalidInputException; -import org.signal.libsignal.zkgroup.groups.GroupMasterKey; -import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record; - -import java.io.IOException; -import java.util.Arrays; -import java.util.LinkedList; -import java.util.List; -import java.util.Objects; - -import okio.ByteString; - -public final class SignalGroupV2Record implements SignalRecord { - - private static final String TAG = SignalGroupV2Record.class.getSimpleName(); - - private final StorageId id; - private final GroupV2Record proto; - private final byte[] masterKey; - private final boolean hasUnknownFields; - - public SignalGroupV2Record(StorageId id, GroupV2Record proto) { - this.id = id; - this.proto = proto; - this.hasUnknownFields = ProtoUtil.hasUnknownFields(proto); - this.masterKey = proto.masterKey.toByteArray(); - } - - @Override - public StorageId getId() { - return id; - } - - @Override - public SignalStorageRecord asStorageRecord() { - return SignalStorageRecord.forGroupV2(this); - } - - @Override - public String describeDiff(SignalRecord other) { - if (other instanceof SignalGroupV2Record) { - SignalGroupV2Record that = (SignalGroupV2Record) other; - List diff = new LinkedList<>(); - - if (!Arrays.equals(this.id.getRaw(), that.id.getRaw())) { - diff.add("ID"); - } - - if (!Arrays.equals(this.getMasterKeyBytes(), that.getMasterKeyBytes())) { - diff.add("MasterKey"); - } - - if (!Objects.equals(this.isBlocked(), that.isBlocked())) { - diff.add("Blocked"); - } - - if (!Objects.equals(this.isProfileSharingEnabled(), that.isProfileSharingEnabled())) { - diff.add("ProfileSharing"); - } - - if (!Objects.equals(this.isArchived(), that.isArchived())) { - diff.add("Archived"); - } - - if (!Objects.equals(this.isForcedUnread(), that.isForcedUnread())) { - diff.add("ForcedUnread"); - } - - if (!Objects.equals(this.getMuteUntil(), that.getMuteUntil())) { - diff.add("MuteUntil"); - } - - if (!Objects.equals(this.notifyForMentionsWhenMuted(), that.notifyForMentionsWhenMuted())) { - diff.add("NotifyForMentionsWhenMuted"); - } - - if (shouldHideStory() != that.shouldHideStory()) { - diff.add("HideStory"); - } - - if (!Objects.equals(this.getStorySendMode(), that.getStorySendMode())) { - diff.add("StorySendMode"); - } - - if (!Objects.equals(this.hasUnknownFields(), that.hasUnknownFields())) { - diff.add("UnknownFields"); - } - - return diff.toString(); - } else { - return "Different class. " + getClass().getSimpleName() + " | " + other.getClass().getSimpleName(); - } - } - - public boolean hasUnknownFields() { - return hasUnknownFields; - } - - public byte[] serializeUnknownFields() { - return hasUnknownFields ? proto.encode() : null; - } - - public byte[] getMasterKeyBytes() { - return masterKey; - } - - public GroupMasterKey getMasterKeyOrThrow() { - try { - return new GroupMasterKey(masterKey); - } catch (InvalidInputException e) { - throw new AssertionError(e); - } - } - - public boolean isBlocked() { - return proto.blocked; - } - - public boolean isProfileSharingEnabled() { - return proto.whitelisted; - } - - public boolean isArchived() { - return proto.archived; - } - - public boolean isForcedUnread() { - return proto.markedUnread; - } - - public long getMuteUntil() { - return proto.mutedUntilTimestamp; - } - - public boolean notifyForMentionsWhenMuted() { - return !proto.dontNotifyForMentionsIfMuted; - } - - public boolean shouldHideStory() { - return proto.hideStory; - } - - public GroupV2Record.StorySendMode getStorySendMode() { - return proto.storySendMode; - } - - public GroupV2Record toProto() { - return proto; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - SignalGroupV2Record that = (SignalGroupV2Record) o; - return id.equals(that.id) && - proto.equals(that.proto); - } - - @Override - public int hashCode() { - return Objects.hash(id, proto); - } - - public static final class Builder { - private final StorageId id; - private final GroupV2Record.Builder builder; - - public Builder(byte[] rawId, GroupMasterKey masterKey, byte[] serializedUnknowns) { - this(rawId, masterKey.serialize(), serializedUnknowns); - } - - public Builder(byte[] rawId, byte[] masterKey, byte[] serializedUnknowns) { - this.id = StorageId.forGroupV2(rawId); - - if (serializedUnknowns != null) { - this.builder = parseUnknowns(serializedUnknowns); - } else { - this.builder = new GroupV2Record.Builder(); - } - - builder.masterKey(ByteString.of(masterKey)); - } - - public Builder setBlocked(boolean blocked) { - builder.blocked(blocked); - return this; - } - - public Builder setProfileSharingEnabled(boolean profileSharingEnabled) { - builder.whitelisted(profileSharingEnabled); - return this; - } - - public Builder setArchived(boolean archived) { - builder.archived(archived); - return this; - } - - public Builder setForcedUnread(boolean forcedUnread) { - builder.markedUnread(forcedUnread); - return this; - } - - public Builder setMuteUntil(long muteUntil) { - builder.mutedUntilTimestamp(muteUntil); - return this; - } - - public Builder setNotifyForMentionsWhenMuted(boolean value) { - builder.dontNotifyForMentionsIfMuted(!value); - return this; - } - - public Builder setHideStory(boolean hideStory) { - builder.hideStory(hideStory); - return this; - } - - public Builder setStorySendMode(GroupV2Record.StorySendMode storySendMode) { - builder.storySendMode(storySendMode); - return this; - } - - private static GroupV2Record.Builder parseUnknowns(byte[] serializedUnknowns) { - try { - return GroupV2Record.ADAPTER.decode(serializedUnknowns).newBuilder(); - } catch (IOException e) { - Log.w(TAG, "Failed to combine unknown fields!", e); - return new GroupV2Record.Builder(); - } - } - - public SignalGroupV2Record build() { - return new SignalGroupV2Record(id, builder.build()); - } - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV2Record.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV2Record.kt new file mode 100644 index 0000000000..1c469d0c7b --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalGroupV2Record.kt @@ -0,0 +1,27 @@ +package org.whispersystems.signalservice.api.storage + +import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record +import java.io.IOException + +/** + * Wrapper around a [GroupV2Record] to pair it with a [StorageId]. + */ +data class SignalGroupV2Record( + override val id: StorageId, + override val proto: GroupV2Record +) : SignalRecord { + + companion object { + fun newBuilder(serializedUnknowns: ByteArray?): GroupV2Record.Builder { + return serializedUnknowns?.let { builderFromUnknowns(it) } ?: GroupV2Record.Builder() + } + + private fun builderFromUnknowns(serializedUnknowns: ByteArray): GroupV2Record.Builder { + return try { + GroupV2Record.ADAPTER.decode(serializedUnknowns).newBuilder() + } catch (e: IOException) { + GroupV2Record.Builder() + } + } + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.java deleted file mode 100644 index ebe060990d..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.whispersystems.signalservice.api.storage; - -public interface SignalRecord { - StorageId getId(); - SignalStorageRecord asStorageRecord(); - String describeDiff(SignalRecord other); -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.kt new file mode 100644 index 0000000000..423c35dd9c --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalRecord.kt @@ -0,0 +1,59 @@ +package org.whispersystems.signalservice.api.storage + +import com.squareup.wire.Message +import org.signal.core.util.hasUnknownFields +import kotlin.reflect.KVisibility +import kotlin.reflect.full.memberProperties + +/** + * Pairs a storage record with its id. Also contains some useful common methods. + */ +interface SignalRecord { + val id: StorageId + val proto: E + + val serializedUnknowns: ByteArray? + get() = (proto as Message<*, *>).takeIf { it.hasUnknownFields() }?.encode() + + fun describeDiff(other: SignalRecord<*>): String { + if (this::class != other::class) { + return "Classes are different!" + } + + if (this.proto!!::class != other.proto!!::class) { + return "Proto classes are different!" + } + + val myFields = this.proto!!::class.memberProperties + val otherFields = other.proto!!::class.memberProperties + + val myFieldsByName = myFields + .filter { it.isFinal && it.visibility == KVisibility.PUBLIC } + .associate { it.name to it.getter.call(this.proto!!) } + + val otherFieldsByName = otherFields + .filter { it.isFinal && it.visibility == KVisibility.PUBLIC } + .associate { it.name to it.getter.call(other.proto!!) } + + val mismatching = mutableListOf() + + if (this.id != other.id) { + mismatching += "id" + } + + for (key in myFieldsByName.keys) { + val myValue = myFieldsByName[key] + val otherValue = otherFieldsByName[key] + + if (myValue != otherValue) { + mismatching += key + } + } + + return if (mismatching.isEmpty()) { + "All fields match." + } else { + mismatching.sorted().joinToString(prefix = "Some fields differ: ", separator = ", ") + } + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageCipher.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageCipher.java deleted file mode 100644 index f716f44d36..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageCipher.java +++ /dev/null @@ -1,52 +0,0 @@ -package org.whispersystems.signalservice.api.storage; - -import org.signal.libsignal.protocol.InvalidKeyException; -import org.whispersystems.signalservice.internal.util.Util; - -import java.security.InvalidAlgorithmParameterException; -import java.security.NoSuchAlgorithmException; - -import javax.crypto.BadPaddingException; -import javax.crypto.Cipher; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; -import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.SecretKeySpec; - -/** - * Encrypts and decrypts data from the storage service. - */ -public class SignalStorageCipher { - - private static final int IV_LENGTH = 12; - - public static byte[] encrypt(StorageCipherKey key, byte[] data) { - try { - Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); - byte[] iv = Util.getSecretBytes(IV_LENGTH); - - cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key.serialize(), "AES"), new GCMParameterSpec(128, iv)); - byte[] ciphertext = cipher.doFinal(data); - - return Util.join(iv, ciphertext); - } catch (NoSuchAlgorithmException | java.security.InvalidKeyException | InvalidAlgorithmParameterException | NoSuchPaddingException | BadPaddingException | IllegalBlockSizeException e) { - throw new AssertionError(e); - } - } - - public static byte[] decrypt(StorageCipherKey key, byte[] data) throws InvalidKeyException { - try { - Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); - byte[][] split = Util.split(data, IV_LENGTH, data.length - IV_LENGTH); - byte[] iv = split[0]; - byte[] cipherText = split[1]; - - cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key.serialize(), "AES"), new GCMParameterSpec(128, iv)); - return cipher.doFinal(cipherText); - } catch (java.security.InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) { - throw new InvalidKeyException(e); - } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidAlgorithmParameterException e) { - throw new AssertionError(e); - } - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageCipher.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageCipher.kt new file mode 100644 index 0000000000..b2880b6dba --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageCipher.kt @@ -0,0 +1,69 @@ +package org.whispersystems.signalservice.api.storage + +import org.whispersystems.signalservice.internal.util.Util +import java.security.InvalidAlgorithmParameterException +import java.security.InvalidKeyException +import java.security.NoSuchAlgorithmException +import javax.crypto.BadPaddingException +import javax.crypto.Cipher +import javax.crypto.IllegalBlockSizeException +import javax.crypto.NoSuchPaddingException +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Encrypts and decrypts data from the storage service. + */ +object SignalStorageCipher { + private const val IV_LENGTH = 12 + + @JvmStatic + fun encrypt(key: StorageCipherKey, data: ByteArray): ByteArray { + try { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val iv = Util.getSecretBytes(IV_LENGTH) + + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key.serialize(), "AES"), GCMParameterSpec(128, iv)) + val ciphertext = cipher.doFinal(data) + + return iv + ciphertext + } catch (e: NoSuchAlgorithmException) { + throw AssertionError(e) + } catch (e: InvalidKeyException) { + throw AssertionError(e) + } catch (e: InvalidAlgorithmParameterException) { + throw AssertionError(e) + } catch (e: NoSuchPaddingException) { + throw AssertionError(e) + } catch (e: BadPaddingException) { + throw AssertionError(e) + } catch (e: IllegalBlockSizeException) { + throw AssertionError(e) + } + } + + @JvmStatic + @Throws(org.signal.libsignal.protocol.InvalidKeyException::class) + fun decrypt(key: StorageCipherKey, data: ByteArray): ByteArray { + try { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val iv = data.copyOfRange(0, IV_LENGTH) + val cipherText = data.copyOfRange(IV_LENGTH, data.size) + + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key.serialize(), "AES"), GCMParameterSpec(128, iv)) + return cipher.doFinal(cipherText) + } catch (e: InvalidKeyException) { + throw org.signal.libsignal.protocol.InvalidKeyException(e) + } catch (e: BadPaddingException) { + throw org.signal.libsignal.protocol.InvalidKeyException(e) + } catch (e: IllegalBlockSizeException) { + throw org.signal.libsignal.protocol.InvalidKeyException(e) + } catch (e: NoSuchAlgorithmException) { + throw AssertionError(e) + } catch (e: NoSuchPaddingException) { + throw AssertionError(e) + } catch (e: InvalidAlgorithmParameterException) { + throw AssertionError(e) + } + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageManifest.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageManifest.java deleted file mode 100644 index bf50e00c83..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageManifest.java +++ /dev/null @@ -1,114 +0,0 @@ -package org.whispersystems.signalservice.api.storage; - -import org.signal.core.util.ProtoUtil; -import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; -import org.whispersystems.signalservice.internal.storage.protos.StorageManifest; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import okio.ByteString; - -public class SignalStorageManifest { - public static final SignalStorageManifest EMPTY = new SignalStorageManifest(0, 1, Collections.emptyList()); - - private final long version; - private final int sourceDeviceId; - private final List storageIds; - private final Map> storageIdsByType; - - public SignalStorageManifest(long version, int sourceDeviceId, List storageIds) { - this.version = version; - this.sourceDeviceId = sourceDeviceId; - this.storageIds = storageIds; - this.storageIdsByType = new HashMap<>(); - - for (StorageId id : storageIds) { - List list = storageIdsByType.get(id.getType()); - if (list == null) { - list = new ArrayList<>(); - } - list.add(id); - storageIdsByType.put(id.getType(), list); - } - } - - public static SignalStorageManifest deserialize(byte[] serialized) { - try { - StorageManifest manifest = StorageManifest.ADAPTER.decode(serialized); - ManifestRecord manifestRecord = ManifestRecord.ADAPTER.decode(manifest.value_); - List ids = new ArrayList<>(manifestRecord.identifiers.size()); - - for (ManifestRecord.Identifier id : manifestRecord.identifiers) { - ids.add(StorageId.forType(id.raw.toByteArray(), id.type.getValue())); - } - - return new SignalStorageManifest(manifest.version, manifestRecord.sourceDevice, ids); - } catch (IOException e) { - throw new AssertionError(e); - } - } - - public long getVersion() { - return version; - } - - public int getSourceDeviceId() { - return sourceDeviceId; - } - - public String getVersionString() { - return version + "." + sourceDeviceId; - } - - public List getStorageIds() { - return storageIds; - } - - public Optional getAccountStorageId() { - List list = storageIdsByType.get(ManifestRecord.Identifier.Type.ACCOUNT.getValue()); - - if (list != null && list.size() > 0) { - return Optional.of(list.get(0)); - } else { - return Optional.empty(); - } - } - - public Map> getStorageIdsByType() { - return storageIdsByType; - } - - public byte[] serialize() { - List ids = new ArrayList<>(storageIds.size()); - - for (StorageId id : storageIds) { - ManifestRecord.Identifier.Type type = ManifestRecord.Identifier.Type.Companion.fromValue(id.getType()); - if (type != null) { - ids.add(new ManifestRecord.Identifier.Builder() - .type(type) - .raw(ByteString.of(id.getRaw())) - .build()); - } else { - ByteString unknownEnum = ProtoUtil.writeUnknownEnumValue(StorageRecordProtoUtil.STORAGE_ID_TYPE_TAG, id.getType()); - ids.add(new ManifestRecord.Identifier(ByteString.of(id.getRaw()), ManifestRecord.Identifier.Type.UNKNOWN, unknownEnum)); - } - } - - ManifestRecord manifestRecord = new ManifestRecord.Builder() - .identifiers(ids) - .sourceDevice(sourceDeviceId) - .build(); - - return new StorageManifest.Builder() - .version(version) - .value_(manifestRecord.encodeByteString()) - .build() - .encode(); - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageManifest.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageManifest.kt new file mode 100644 index 0000000000..aea268928f --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageManifest.kt @@ -0,0 +1,51 @@ +package org.whispersystems.signalservice.api.storage + +import org.signal.core.util.toOptional +import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord +import org.whispersystems.signalservice.internal.storage.protos.StorageManifest +import java.util.Optional + +class SignalStorageManifest( + @JvmField val version: Long, + @JvmField val sourceDeviceId: Int, + @JvmField val storageIds: List +) { + + companion object { + val EMPTY: SignalStorageManifest = SignalStorageManifest(0, 1, emptyList()) + + fun deserialize(serialized: ByteArray): SignalStorageManifest { + val manifest = StorageManifest.ADAPTER.decode(serialized) + val manifestRecord = ManifestRecord.ADAPTER.decode(manifest.value_) + val ids: List = manifestRecord.identifiers.map { id -> + StorageId.forType(id.raw.toByteArray(), id.typeValue) + } + + return SignalStorageManifest(manifest.version, manifestRecord.sourceDevice, ids) + } + } + + val storageIdsByType: Map> = storageIds.groupBy { it.type } + + val versionString: String + get() = "$version.$sourceDeviceId" + + val accountStorageId: Optional + get() = storageIdsByType[ManifestRecord.Identifier.Type.ACCOUNT.value]?.takeIf { it.isNotEmpty() }?.get(0).toOptional() + + fun serialize(): ByteArray { + val ids: List = storageIds.map { id -> + ManifestRecord.Identifier.fromPossiblyUnknownType(id.type, id.raw) + } + + val manifestRecord = ManifestRecord( + identifiers = ids, + sourceDevice = sourceDeviceId + ) + + return StorageManifest( + version = version, + value_ = manifestRecord.encodeByteString() + ).encode() + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java deleted file mode 100644 index fca879f2b5..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.java +++ /dev/null @@ -1,94 +0,0 @@ -package org.whispersystems.signalservice.api.storage; - -import org.signal.core.util.ProtoUtil; -import org.signal.libsignal.protocol.InvalidKeyException; -import org.signal.libsignal.protocol.logging.Log; -import org.signal.libsignal.zkgroup.groups.GroupMasterKey; -import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord; -import org.whispersystems.signalservice.internal.storage.protos.StorageItem; -import org.whispersystems.signalservice.internal.storage.protos.StorageManifest; -import org.whispersystems.signalservice.internal.storage.protos.StorageRecord; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; - -import okio.ByteString; - -public final class SignalStorageModels { - - private static final String TAG = SignalStorageModels.class.getSimpleName(); - - public static SignalStorageManifest remoteToLocalStorageManifest(StorageManifest manifest, StorageKey storageKey) throws IOException, InvalidKeyException { - byte[] rawRecord = SignalStorageCipher.decrypt(storageKey.deriveManifestKey(manifest.version), manifest.value_.toByteArray()); - ManifestRecord manifestRecord = ManifestRecord.ADAPTER.decode(rawRecord); - List ids = new ArrayList<>(manifestRecord.identifiers.size()); - - for (ManifestRecord.Identifier id : manifestRecord.identifiers) { - int typeValue = (id.type != ManifestRecord.Identifier.Type.UNKNOWN) ? id.type.getValue() - : ProtoUtil.getUnknownEnumValue(id, StorageRecordProtoUtil.STORAGE_ID_TYPE_TAG); - - ids.add(StorageId.forType(id.raw.toByteArray(), typeValue)); - } - - return new SignalStorageManifest(manifestRecord.version, manifestRecord.sourceDevice, ids); - } - - public static SignalStorageRecord remoteToLocalStorageRecord(StorageItem item, int type, StorageKey storageKey) throws IOException, InvalidKeyException { - byte[] key = item.key.toByteArray(); - byte[] rawRecord = SignalStorageCipher.decrypt(storageKey.deriveItemKey(key), item.value_.toByteArray()); - StorageRecord record = StorageRecord.ADAPTER.decode(rawRecord); - StorageId id = StorageId.forType(key, type); - - if (record.contact != null && type == ManifestRecord.Identifier.Type.CONTACT.getValue()) { - return SignalStorageRecord.forContact(id, new SignalContactRecord(id, record.contact)); - } else if (record.groupV1 != null && type == ManifestRecord.Identifier.Type.GROUPV1.getValue()) { - return SignalStorageRecord.forGroupV1(id, new SignalGroupV1Record(id, record.groupV1)); - } else if (record.groupV2 != null && type == ManifestRecord.Identifier.Type.GROUPV2.getValue() && record.groupV2.masterKey.size() == GroupMasterKey.SIZE) { - return SignalStorageRecord.forGroupV2(id, new SignalGroupV2Record(id, record.groupV2)); - } else if (record.account != null && type == ManifestRecord.Identifier.Type.ACCOUNT.getValue()) { - return SignalStorageRecord.forAccount(id, new SignalAccountRecord(id, record.account)); - } else if (record.storyDistributionList != null && type == ManifestRecord.Identifier.Type.STORY_DISTRIBUTION_LIST.getValue()) { - return SignalStorageRecord.forStoryDistributionList(id, new SignalStoryDistributionListRecord(id, record.storyDistributionList)); - } else if (record.callLink != null && type == ManifestRecord.Identifier.Type.CALL_LINK.getValue()) { - return SignalStorageRecord.forCallLink(id, new SignalCallLinkRecord(id, record.callLink)); - }else { - if (StorageId.isKnownType(type)) { - Log.w(TAG, "StorageId is of known type (" + type + "), but the data is bad! Falling back to unknown."); - } - return SignalStorageRecord.forUnknown(StorageId.forType(key, type)); - } - } - - public static StorageItem localToRemoteStorageRecord(SignalStorageRecord record, StorageKey storageKey) { - StorageRecord.Builder builder = new StorageRecord.Builder(); - - if (record.getContact().isPresent()) { - builder.contact(record.getContact().get().toProto()); - } else if (record.getGroupV1().isPresent()) { - builder.groupV1(record.getGroupV1().get().toProto()); - } else if (record.getGroupV2().isPresent()) { - builder.groupV2(record.getGroupV2().get().toProto()); - } else if (record.getAccount().isPresent()) { - builder.account(record.getAccount().get().toProto()); - } else if (record.getStoryDistributionList().isPresent()) { - builder.storyDistributionList(record.getStoryDistributionList().get().toProto()); - } else if (record.getCallLink().isPresent()) { - builder.callLink(record.getCallLink().get().toProto()); - } else { - throw new InvalidStorageWriteError(); - } - - StorageRecord remoteRecord = builder.build(); - StorageItemKey itemKey = storageKey.deriveItemKey(record.getId().getRaw()); - byte[] encryptedRecord = SignalStorageCipher.encrypt(itemKey, remoteRecord.encode()); - - return new StorageItem.Builder() - .key(ByteString.of(record.getId().getRaw())) - .value_(ByteString.of(encryptedRecord)) - .build(); - } - - private static class InvalidStorageWriteError extends Error { - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.kt new file mode 100644 index 0000000000..1b9e37a8dd --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageModels.kt @@ -0,0 +1,66 @@ +package org.whispersystems.signalservice.api.storage + +import okio.ByteString.Companion.toByteString +import org.signal.libsignal.protocol.InvalidKeyException +import org.signal.libsignal.protocol.logging.Log +import org.signal.libsignal.zkgroup.groups.GroupMasterKey +import org.whispersystems.signalservice.internal.storage.protos.ManifestRecord +import org.whispersystems.signalservice.internal.storage.protos.StorageItem +import org.whispersystems.signalservice.internal.storage.protos.StorageManifest +import org.whispersystems.signalservice.internal.storage.protos.StorageRecord +import java.io.IOException + +object SignalStorageModels { + private val TAG: String = SignalStorageModels::class.java.simpleName + + @JvmStatic + @Throws(IOException::class, InvalidKeyException::class) + fun remoteToLocalStorageManifest(manifest: StorageManifest, storageKey: StorageKey): SignalStorageManifest { + val rawRecord = SignalStorageCipher.decrypt(storageKey.deriveManifestKey(manifest.version), manifest.value_.toByteArray()) + val manifestRecord = ManifestRecord.ADAPTER.decode(rawRecord) + val ids: List = manifestRecord.identifiers.map { id -> + StorageId.forType(id.raw.toByteArray(), id.typeValue) + } + + return SignalStorageManifest(manifestRecord.version, manifestRecord.sourceDevice, ids) + } + + @JvmStatic + @Throws(IOException::class, InvalidKeyException::class) + fun remoteToLocalStorageRecord(item: StorageItem, type: Int, storageKey: StorageKey): SignalStorageRecord { + val key = item.key.toByteArray() + val rawRecord = SignalStorageCipher.decrypt(storageKey.deriveItemKey(key), item.value_.toByteArray()) + val record = StorageRecord.ADAPTER.decode(rawRecord) + val id = StorageId.forType(key, type) + + if (record.contact != null && type == ManifestRecord.Identifier.Type.CONTACT.value) { + return SignalContactRecord(id, record.contact).toSignalStorageRecord() + } else if (record.groupV1 != null && type == ManifestRecord.Identifier.Type.GROUPV1.value) { + return SignalGroupV1Record(id, record.groupV1).toSignalStorageRecord() + } else if (record.groupV2 != null && type == ManifestRecord.Identifier.Type.GROUPV2.value && record.groupV2.masterKey.size == GroupMasterKey.SIZE) { + return SignalGroupV2Record(id, record.groupV2).toSignalStorageRecord() + } else if (record.account != null && type == ManifestRecord.Identifier.Type.ACCOUNT.value) { + return SignalAccountRecord(id, record.account).toSignalStorageRecord() + } else if (record.storyDistributionList != null && type == ManifestRecord.Identifier.Type.STORY_DISTRIBUTION_LIST.value) { + return SignalStoryDistributionListRecord(id, record.storyDistributionList).toSignalStorageRecord() + } else if (record.callLink != null && type == ManifestRecord.Identifier.Type.CALL_LINK.value) { + return SignalCallLinkRecord(id, record.callLink).toSignalStorageRecord() + } else { + if (StorageId.isKnownType(type)) { + Log.w(TAG, "StorageId is of known type ($type), but the data is bad! Falling back to unknown.") + } + return SignalStorageRecord.forUnknown(StorageId.forType(key, type)) + } + } + + @JvmStatic + fun localToRemoteStorageRecord(record: SignalStorageRecord, storageKey: StorageKey): StorageItem { + val itemKey = storageKey.deriveItemKey(record.id.raw) + val encryptedRecord = SignalStorageCipher.encrypt(itemKey, record.proto.encode()) + + return StorageItem.Builder() + .key(record.id.raw.toByteString()) + .value_(encryptedRecord.toByteString()) + .build() + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.java deleted file mode 100644 index 2e39298e9e..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.java +++ /dev/null @@ -1,156 +0,0 @@ -package org.whispersystems.signalservice.api.storage; - - -import org.jetbrains.annotations.NotNull; -import org.whispersystems.signalservice.internal.storage.protos.CallLinkRecord; - -import java.util.Objects; -import java.util.Optional; - -public class SignalStorageRecord implements SignalRecord { - - private final StorageId id; - private final Optional storyDistributionList; - private final Optional contact; - private final Optional groupV1; - private final Optional groupV2; - private final Optional account; - private final Optional callLink; - - public static SignalStorageRecord forStoryDistributionList(SignalStoryDistributionListRecord storyDistributionList) { - return forStoryDistributionList(storyDistributionList.getId(), storyDistributionList); - } - - public static SignalStorageRecord forStoryDistributionList(StorageId key, SignalStoryDistributionListRecord storyDistributionList) { - return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(storyDistributionList), Optional.empty()); - } - - public static SignalStorageRecord forContact(SignalContactRecord contact) { - return forContact(contact.getId(), contact); - } - - public static SignalStorageRecord forContact(StorageId key, SignalContactRecord contact) { - return new SignalStorageRecord(key, Optional.of(contact), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); - } - - public static SignalStorageRecord forGroupV1(SignalGroupV1Record groupV1) { - return forGroupV1(groupV1.getId(), groupV1); - } - - public static SignalStorageRecord forGroupV1(StorageId key, SignalGroupV1Record groupV1) { - return new SignalStorageRecord(key, Optional.empty(), Optional.of(groupV1), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); - } - - public static SignalStorageRecord forGroupV2(SignalGroupV2Record groupV2) { - return forGroupV2(groupV2.getId(), groupV2); - } - - public static SignalStorageRecord forGroupV2(StorageId key, SignalGroupV2Record groupV2) { - return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.of(groupV2), Optional.empty(), Optional.empty(), Optional.empty()); - } - - public static SignalStorageRecord forAccount(SignalAccountRecord account) { - return forAccount(account.getId(), account); - } - - public static SignalStorageRecord forAccount(StorageId key, SignalAccountRecord account) { - return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(account), Optional.empty(), Optional.empty()); - } - - @NotNull - public static SignalStorageRecord forCallLink(@NotNull SignalCallLinkRecord callLink) { - return forCallLink(callLink.getId(), callLink); - } - - @NotNull - public static SignalStorageRecord forCallLink(StorageId key, @NotNull SignalCallLinkRecord callLink) { - return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.of(callLink)); - } - - public static SignalStorageRecord forUnknown(StorageId key) { - return new SignalStorageRecord(key, Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); - } - - - private SignalStorageRecord(StorageId id, - Optional contact, - Optional groupV1, - Optional groupV2, - Optional account, - Optional storyDistributionList, - Optional callLink) - { - this.id = id; - this.contact = contact; - this.groupV1 = groupV1; - this.groupV2 = groupV2; - this.account = account; - this.storyDistributionList = storyDistributionList; - this.callLink = callLink; - } - - @Override - public StorageId getId() { - return id; - } - - @Override - public SignalStorageRecord asStorageRecord() { - return this; - } - - @Override - public String describeDiff(SignalRecord other) { - return "Diffs not supported."; - } - - public int getType() { - return id.getType(); - } - - public Optional getContact() { - return contact; - } - - public Optional getGroupV1() { - return groupV1; - } - - public Optional getGroupV2() { - return groupV2; - } - - public Optional getAccount() { - return account; - } - - public Optional getStoryDistributionList() { - return storyDistributionList; - } - - public Optional getCallLink() { - return callLink; - } - - public boolean isUnknown() { - return !contact.isPresent() && !groupV1.isPresent() && !groupV2.isPresent() && !account.isPresent() && !storyDistributionList.isPresent() && !callLink.isPresent(); - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - SignalStorageRecord that = (SignalStorageRecord) o; - return Objects.equals(id, that.id) && - Objects.equals(contact, that.contact) && - Objects.equals(groupV1, that.groupV1) && - Objects.equals(groupV2, that.groupV2) && - Objects.equals(storyDistributionList, that.storyDistributionList) && - Objects.equals(callLink, that.callLink); - } - - @Override - public int hashCode() { - return Objects.hash(id, contact, groupV1, groupV2, storyDistributionList, callLink); - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.kt new file mode 100644 index 0000000000..48093a3a00 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStorageRecord.kt @@ -0,0 +1,21 @@ +package org.whispersystems.signalservice.api.storage + +import org.whispersystems.signalservice.internal.storage.protos.StorageRecord + +/** + * A wrapper around [StorageRecord] to pair it with a [StorageId]. + */ +data class SignalStorageRecord( + val id: StorageId, + val proto: StorageRecord +) { + val isUnknown: Boolean + get() = proto.contact == null && proto.groupV1 == null && proto.groupV2 == null && proto.account == null && proto.storyDistributionList == null && proto.callLink == null + + companion object { + @JvmStatic + fun forUnknown(key: StorageId): SignalStorageRecord { + return SignalStorageRecord(key, proto = StorageRecord()) + } + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStoryDistributionListRecord.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStoryDistributionListRecord.java deleted file mode 100644 index e7c497c2ff..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStoryDistributionListRecord.java +++ /dev/null @@ -1,194 +0,0 @@ -package org.whispersystems.signalservice.api.storage; - -import org.signal.core.util.ProtoUtil; -import org.signal.libsignal.protocol.logging.Log; -import org.whispersystems.signalservice.api.push.ServiceId; -import org.whispersystems.signalservice.api.push.SignalServiceAddress; -import org.whispersystems.signalservice.internal.storage.protos.StoryDistributionListRecord; - -import java.io.IOException; -import java.util.Arrays; -import java.util.LinkedList; -import java.util.List; -import java.util.Objects; -import java.util.stream.Collectors; - -import okio.ByteString; - -public class SignalStoryDistributionListRecord implements SignalRecord { - - private static final String TAG = SignalStoryDistributionListRecord.class.getSimpleName(); - - private final StorageId id; - private final StoryDistributionListRecord proto; - private final boolean hasUnknownFields; - private final List recipients; - - public SignalStoryDistributionListRecord(StorageId id, StoryDistributionListRecord proto) { - this.id = id; - this.proto = proto; - this.hasUnknownFields = ProtoUtil.hasUnknownFields(proto); - this.recipients = proto.recipientServiceIds - .stream() - .map(ServiceId::parseOrNull) - .filter(Objects::nonNull) - .map(SignalServiceAddress::new) - .collect(Collectors.toList()); - } - - @Override - public StorageId getId() { - return id; - } - - @Override - public SignalStorageRecord asStorageRecord() { - return SignalStorageRecord.forStoryDistributionList(this); - } - - public StoryDistributionListRecord toProto() { - return proto; - } - - public byte[] serializeUnknownFields() { - return hasUnknownFields ? proto.encode() : null; - } - - public byte[] getIdentifier() { - return proto.identifier.toByteArray(); - } - - public String getName() { - return proto.name; - } - - public List getRecipients() { - return recipients; - } - - public long getDeletedAtTimestamp() { - return proto.deletedAtTimestamp; - } - - public boolean allowsReplies() { - return proto.allowsReplies; - } - - public boolean isBlockList() { - return proto.isBlockList; - } - - @Override - public String describeDiff(SignalRecord other) { - if (other instanceof SignalStoryDistributionListRecord) { - SignalStoryDistributionListRecord that = (SignalStoryDistributionListRecord) other; - List diff = new LinkedList<>(); - - if (!Arrays.equals(this.id.getRaw(), that.id.getRaw())) { - diff.add("ID"); - } - - if (!Arrays.equals(this.getIdentifier(), that.getIdentifier())) { - diff.add("Identifier"); - } - - if (!Objects.equals(this.getName(), that.getName())) { - diff.add("Name"); - } - - if (!Objects.equals(this.recipients, that.recipients)) { - diff.add("RecipientUuids"); - } - - if (this.getDeletedAtTimestamp() != that.getDeletedAtTimestamp()) { - diff.add("DeletedAtTimestamp"); - } - - if (this.allowsReplies() != that.allowsReplies()) { - diff.add("AllowsReplies"); - } - - if (this.isBlockList() != that.isBlockList()) { - diff.add("BlockList"); - } - - return diff.toString(); - } else { - return "Different class. " + getClass().getSimpleName() + " | " + other.getClass().getSimpleName(); - } - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - SignalStoryDistributionListRecord that = (SignalStoryDistributionListRecord) o; - return id.equals(that.id) && - proto.equals(that.proto); - } - - @Override - public int hashCode() { - return Objects.hash(id, proto); - } - - public static final class Builder { - private final StorageId id; - private final StoryDistributionListRecord.Builder builder; - - public Builder(byte[] rawId, byte[] serializedUnknowns) { - this.id = StorageId.forStoryDistributionList(rawId); - - if (serializedUnknowns != null) { - this.builder = parseUnknowns(serializedUnknowns); - } else { - this.builder = new StoryDistributionListRecord.Builder(); - } - } - - public Builder setIdentifier(byte[] identifier) { - builder.identifier(ByteString.of(identifier)); - return this; - } - - public Builder setName(String name) { - builder.name(name); - return this; - } - - public Builder setRecipients(List recipients) { - builder.recipientServiceIds = recipients.stream() - .map(SignalServiceAddress::getIdentifier) - .collect(Collectors.toList()); - return this; - } - - public Builder setDeletedAtTimestamp(long deletedAtTimestamp) { - builder.deletedAtTimestamp(deletedAtTimestamp); - return this; - } - - public Builder setAllowsReplies(boolean allowsReplies) { - builder.allowsReplies(allowsReplies); - return this; - } - - public Builder setIsBlockList(boolean isBlockList) { - builder.isBlockList(isBlockList); - return this; - } - - public SignalStoryDistributionListRecord build() { - return new SignalStoryDistributionListRecord(id, builder.build()); - } - - private static StoryDistributionListRecord.Builder parseUnknowns(byte[] serializedUnknowns) { - try { - return StoryDistributionListRecord.ADAPTER.decode(serializedUnknowns).newBuilder(); - } catch (IOException e) { - Log.w(TAG, "Failed to combine unknown fields!", e); - return new StoryDistributionListRecord.Builder(); - } - } - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStoryDistributionListRecord.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStoryDistributionListRecord.kt new file mode 100644 index 0000000000..2cf4c12d0f --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/SignalStoryDistributionListRecord.kt @@ -0,0 +1,24 @@ +package org.whispersystems.signalservice.api.storage + +import org.whispersystems.signalservice.internal.storage.protos.StoryDistributionListRecord +import java.io.IOException + +data class SignalStoryDistributionListRecord( + override val id: StorageId, + override val proto: StoryDistributionListRecord +) : SignalRecord { + + companion object { + fun newBuilder(serializedUnknowns: ByteArray?): StoryDistributionListRecord.Builder { + return serializedUnknowns?.let { builderFromUnknowns(it) } ?: StoryDistributionListRecord.Builder() + } + + private fun builderFromUnknowns(serializedUnknowns: ByteArray): StoryDistributionListRecord.Builder { + return try { + StoryDistributionListRecord.ADAPTER.decode(serializedUnknowns).newBuilder() + } catch (e: IOException) { + StoryDistributionListRecord.Builder() + } + } + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageCipherKey.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageCipherKey.java deleted file mode 100644 index 474de1e6de..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageCipherKey.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.whispersystems.signalservice.api.storage; - -public interface StorageCipherKey { - byte[] serialize(); -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageCipherKey.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageCipherKey.kt new file mode 100644 index 0000000000..1134716910 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageCipherKey.kt @@ -0,0 +1,5 @@ +package org.whispersystems.signalservice.api.storage + +interface StorageCipherKey { + fun serialize(): ByteArray +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java index 214395fbfd..702533dedf 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageId.java @@ -7,6 +7,9 @@ import java.util.Arrays; import java.util.Objects; +/** + * A copy of {@link ManifestRecord.Identifier} that allows us to more easily store unknown types with their integer constant. + */ public class StorageId { private final int type; private final byte[] raw; diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageItemKey.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageItemKey.java deleted file mode 100644 index 81400b93d4..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageItemKey.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.whispersystems.signalservice.api.storage; - -import java.util.Arrays; - -/** - * Key used to encrypt individual storage items in the storage service. - * - * Created via {@link StorageKey#deriveItemKey(byte[]) }. - */ -public final class StorageItemKey implements StorageCipherKey { - - private static final int LENGTH = 32; - - private final byte[] key; - - StorageItemKey(byte[] key) { - if (key.length != LENGTH) throw new AssertionError(); - - this.key = key; - } - - @Override - public byte[] serialize() { - return key.clone(); - } - - @Override - public boolean equals(Object o) { - if (o == null || o.getClass() != getClass()) return false; - - return Arrays.equals(((StorageItemKey) o).key, key); - } - - @Override - public int hashCode() { - return Arrays.hashCode(key); - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageItemKey.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageItemKey.kt new file mode 100644 index 0000000000..90a58702c0 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageItemKey.kt @@ -0,0 +1,27 @@ +package org.whispersystems.signalservice.api.storage + +/** + * Key used to encrypt individual storage items in the storage service. + * + * Created via [StorageKey.deriveItemKey]. + */ +class StorageItemKey(val key: ByteArray) : StorageCipherKey { + init { + check(key.size == 32) + } + + override fun serialize(): ByteArray = key.clone() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as StorageItemKey + + return key.contentEquals(other.key) + } + + override fun hashCode(): Int { + return key.contentHashCode() + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageKey.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageKey.java deleted file mode 100644 index 12bca1393b..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageKey.java +++ /dev/null @@ -1,56 +0,0 @@ -package org.whispersystems.signalservice.api.storage; - -import org.whispersystems.signalservice.api.kbs.MasterKey; -import org.signal.core.util.Base64; -import org.whispersystems.util.StringUtil; - -import java.util.Arrays; - -import static org.signal.core.util.CryptoUtil.hmacSha256; - -/** - * Key used to encrypt data on the storage service. Not used directly -- instead we used keys that - * are derived for each item we're storing. - * - * Created via {@link MasterKey#deriveStorageServiceKey()}. - */ -public final class StorageKey { - - private static final int LENGTH = 32; - - private final byte[] key; - - public StorageKey(byte[] key) { - if (key.length != LENGTH) throw new AssertionError(); - - this.key = key; - } - - public StorageManifestKey deriveManifestKey(long version) { - return new StorageManifestKey(derive("Manifest_" + version)); - } - - public StorageItemKey deriveItemKey(byte[] key) { - return new StorageItemKey(derive("Item_" + Base64.encodeWithPadding(key))); - } - - private byte[] derive(String keyName) { - return hmacSha256(key, StringUtil.utf8(keyName)); - } - - public byte[] serialize() { - return key.clone(); - } - - @Override - public boolean equals(Object o) { - if (o == null || o.getClass() != getClass()) return false; - - return Arrays.equals(((StorageKey) o).key, key); - } - - @Override - public int hashCode() { - return Arrays.hashCode(key); - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageKey.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageKey.kt new file mode 100644 index 0000000000..5a7ab67393 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageKey.kt @@ -0,0 +1,47 @@ +package org.whispersystems.signalservice.api.storage + +import org.signal.core.util.Base64.encodeWithPadding +import org.signal.core.util.CryptoUtil +import org.whispersystems.signalservice.api.kbs.MasterKey +import org.whispersystems.util.StringUtil + +/** + * Key used to encrypt data on the storage service. Not used directly -- instead we used keys that + * are derived for each item we're storing. + * + * Created via [MasterKey.deriveStorageServiceKey]. + */ +class StorageKey(val key: ByteArray) { + init { + check(key.size == 32) + } + + fun deriveManifestKey(version: Long): StorageManifestKey { + return StorageManifestKey(derive("Manifest_$version")) + } + + fun deriveItemKey(key: ByteArray): StorageItemKey { + return StorageItemKey(derive("Item_" + encodeWithPadding(key))) + } + + private fun derive(keyName: String): ByteArray { + return CryptoUtil.hmacSha256(key, StringUtil.utf8(keyName)) + } + + fun serialize(): ByteArray { + return key.clone() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as StorageKey + + return key.contentEquals(other.key) + } + + override fun hashCode(): Int { + return key.contentHashCode() + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageManifestKey.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageManifestKey.java deleted file mode 100644 index 98977b66fe..0000000000 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageManifestKey.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.whispersystems.signalservice.api.storage; - -import java.util.Arrays; - -/** - * Key used to encrypt a manifest in the storage service. - * - * Created via {@link StorageKey#deriveManifestKey(long)}. - */ -public final class StorageManifestKey implements StorageCipherKey { - - private static final int LENGTH = 32; - - private final byte[] key; - - StorageManifestKey(byte[] key) { - if (key.length != LENGTH) throw new AssertionError(); - - this.key = key; - } - - @Override - public byte[] serialize() { - return key.clone(); - } - - @Override - public boolean equals(Object o) { - if (o == null || o.getClass() != getClass()) return false; - - return Arrays.equals(((StorageManifestKey) o).key, key); - } - - @Override - public int hashCode() { - return Arrays.hashCode(key); - } -} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageManifestKey.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageManifestKey.kt new file mode 100644 index 0000000000..2a5aaa9428 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageManifestKey.kt @@ -0,0 +1,27 @@ +package org.whispersystems.signalservice.api.storage + +/** + * Key used to encrypt a manifest in the storage service. + * + * Created via [StorageKey.deriveManifestKey]. + */ +class StorageManifestKey(val key: ByteArray) : StorageCipherKey { + init { + check(key.size == 32) + } + + override fun serialize(): ByteArray = key.clone() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as StorageManifestKey + + return key.contentEquals(other.key) + } + + override fun hashCode(): Int { + return key.contentHashCode() + } +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageRecordConverters.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageRecordConverters.kt new file mode 100644 index 0000000000..d427d0e2e4 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StorageRecordConverters.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.storage + +import org.whispersystems.signalservice.internal.storage.protos.AccountRecord +import org.whispersystems.signalservice.internal.storage.protos.CallLinkRecord +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord +import org.whispersystems.signalservice.internal.storage.protos.GroupV1Record +import org.whispersystems.signalservice.internal.storage.protos.GroupV2Record +import org.whispersystems.signalservice.internal.storage.protos.StorageRecord +import org.whispersystems.signalservice.internal.storage.protos.StoryDistributionListRecord + +fun ContactRecord.toSignalContactRecord(storageId: StorageId): SignalContactRecord { + return SignalContactRecord(storageId, this) +} + +fun AccountRecord.toSignalAccountRecord(storageId: StorageId): SignalAccountRecord { + return SignalAccountRecord(storageId, this) +} + +fun AccountRecord.Builder.toSignalAccountRecord(storageId: StorageId): SignalAccountRecord { + return SignalAccountRecord(storageId, this.build()) +} + +fun GroupV1Record.toSignalGroupV1Record(storageId: StorageId): SignalGroupV1Record { + return SignalGroupV1Record(storageId, this) +} + +fun GroupV2Record.toSignalGroupV2Record(storageId: StorageId): SignalGroupV2Record { + return SignalGroupV2Record(storageId, this) +} + +fun StoryDistributionListRecord.toSignalStoryDistributionListRecord(storageId: StorageId): SignalStoryDistributionListRecord { + return SignalStoryDistributionListRecord(storageId, this) +} + +fun CallLinkRecord.toSignalCallLinkRecord(storageId: StorageId): SignalCallLinkRecord { + return SignalCallLinkRecord(storageId, this) +} + +fun SignalContactRecord.toSignalStorageRecord(): SignalStorageRecord { + return SignalStorageRecord(id, StorageRecord(contact = this.proto)) +} + +fun SignalGroupV1Record.toSignalStorageRecord(): SignalStorageRecord { + return SignalStorageRecord(id, StorageRecord(groupV1 = this.proto)) +} + +fun SignalGroupV2Record.toSignalStorageRecord(): SignalStorageRecord { + return SignalStorageRecord(id, StorageRecord(groupV2 = this.proto)) +} + +fun SignalAccountRecord.toSignalStorageRecord(): SignalStorageRecord { + return SignalStorageRecord(id, StorageRecord(account = this.proto)) +} + +fun SignalStoryDistributionListRecord.toSignalStorageRecord(): SignalStorageRecord { + return SignalStorageRecord(id, StorageRecord(storyDistributionList = this.proto)) +} + +fun SignalCallLinkRecord.toSignalStorageRecord(): SignalStorageRecord { + return SignalStorageRecord(id, StorageRecord(callLink = this.proto)) +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StoryDistributionListRecordExtensions.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StoryDistributionListRecordExtensions.kt new file mode 100644 index 0000000000..baa1764791 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/storage/StoryDistributionListRecordExtensions.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.storage + +import org.whispersystems.signalservice.api.push.ServiceId +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import org.whispersystems.signalservice.internal.storage.protos.StoryDistributionListRecord + +val StoryDistributionListRecord.recipientServiceAddresses: List + get() { + return this.recipientServiceIds + .mapNotNull { ServiceId.parseOrNull(it) } + .map { SignalServiceAddress(it) } + } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java index 3c042da561..1a7e987755 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscription.java @@ -137,7 +137,7 @@ public boolean isPendingBankTransfer() { } public boolean isInProgress() { - return activeSubscription != null && !isActive() && (!activeSubscription.isFailedPayment() || activeSubscription.isPastDue()); + return activeSubscription != null && !isActive() && (!isFailedPayment() || isPastDue()) && !isCanceled(); } public boolean isPastDue() { @@ -148,6 +148,10 @@ public boolean isFailedPayment() { return chargeFailure != null || (activeSubscription != null && !isActive() && activeSubscription.isFailedPayment()); } + public boolean isCanceled() { + return activeSubscription != null && activeSubscription.isCanceled(); + } + public static final class Subscription { private final int level; private final String currency; diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/svr/Svr2Socket.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/svr/Svr2Socket.kt index e89715d776..e728783bdf 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/svr/Svr2Socket.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/svr/Svr2Socket.kt @@ -1,6 +1,5 @@ package org.whispersystems.signalservice.api.svr -import okhttp3.ConnectionSpec import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.WebSocket @@ -9,29 +8,19 @@ import okio.ByteString import okio.ByteString.Companion.toByteString import org.signal.libsignal.attest.AttestationDataException import org.signal.libsignal.protocol.logging.Log -import org.signal.libsignal.protocol.util.Pair import org.signal.libsignal.sgxsession.SgxCommunicationFailureException import org.signal.libsignal.svr2.Svr2Client -import org.whispersystems.signalservice.api.push.TrustStore +import org.whispersystems.signalservice.api.buildOkHttpClient +import org.whispersystems.signalservice.api.chooseUrl import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException -import org.whispersystems.signalservice.api.util.Tls12SocketFactory import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url import org.whispersystems.signalservice.internal.push.AuthCredentials -import org.whispersystems.signalservice.internal.util.BlacklistingTrustManager import org.whispersystems.signalservice.internal.util.Hex -import org.whispersystems.signalservice.internal.util.Util import java.io.IOException -import java.security.KeyManagementException -import java.security.NoSuchAlgorithmException import java.time.Instant import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference -import javax.net.ssl.SSLContext -import javax.net.ssl.SSLSocketFactory -import javax.net.ssl.X509TrustManager -import kotlin.jvm.Throws import okhttp3.Response as OkHttpResponse import org.signal.svr2.proto.Request as Svr2Request import org.signal.svr2.proto.Response as Svr2Response @@ -43,8 +32,8 @@ internal class Svr2Socket( configuration: SignalServiceConfiguration, private val mrEnclave: String ) { - private val svr2Url: SignalSvr2Url = chooseUrl(configuration.signalSvr2Urls) - private val okhttp: OkHttpClient = buildOkHttpClient(configuration, svr2Url) + private val svr2Url: SignalSvr2Url = configuration.signalSvr2Urls.chooseUrl() + private val okhttp: OkHttpClient = svr2Url.buildOkHttpClient(configuration) @Throws(IOException::class) fun makeRequest(authorization: AuthCredentials, clientRequest: Svr2Request): Svr2Response { @@ -211,41 +200,5 @@ internal class Svr2Socket( companion object { private val TAG = Svr2Socket::class.java.simpleName - - private fun buildOkHttpClient(configuration: SignalServiceConfiguration, svr2Url: SignalSvr2Url): OkHttpClient { - val socketFactory = createTlsSocketFactory(svr2Url.trustStore) - val builder = OkHttpClient.Builder() - .socketFactory(configuration.socketFactory) - .proxySelector(configuration.proxySelector) - .dns(configuration.dns) - .sslSocketFactory(Tls12SocketFactory(socketFactory.first()), socketFactory.second()) - .connectionSpecs(svr2Url.connectionSpecs.orElse(Util.immutableList(ConnectionSpec.RESTRICTED_TLS))) - .retryOnConnectionFailure(false) - .readTimeout(30, TimeUnit.SECONDS) - .connectTimeout(30, TimeUnit.SECONDS) - - for (interceptor in configuration.networkInterceptors) { - builder.addInterceptor(interceptor) - } - - return builder.build() - } - - private fun createTlsSocketFactory(trustStore: TrustStore): Pair { - return try { - val context = SSLContext.getInstance("TLS") - val trustManagers = BlacklistingTrustManager.createFor(trustStore) - context.init(null, trustManagers, null) - Pair(context.socketFactory, trustManagers[0] as X509TrustManager) - } catch (e: NoSuchAlgorithmException) { - throw AssertionError(e) - } catch (e: KeyManagementException) { - throw AssertionError(e) - } - } - - private fun chooseUrl(urls: Array): SignalSvr2Url { - return urls[(Math.random() * urls.size).toInt()] - } } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java index 9d2175435b..562117a9cb 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/util/UuidUtil.java @@ -46,6 +46,10 @@ public static UUID parseOrThrow(byte[] bytes) { return new UUID(high, low); } + public static UUID parseOrThrow(ByteString bytes) { + return parseOrNull(bytes.toByteArray()); + } + public static boolean isUuid(String uuid) { return uuid != null && UUID_PATTERN.matcher(uuid).matches(); } @@ -83,6 +87,10 @@ public static UUID parseOrNull(byte[] byteArray) { return byteArray != null && byteArray.length == 16 ? parseOrThrow(byteArray) : null; } + public static UUID parseOrNull(ByteString byteString) { + return parseOrNull(byteString.toByteArray()); + } + public static List fromByteStrings(Collection byteStringCollection) { ArrayList result = new ArrayList<>(byteStringCollection.size()); diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/PrimaryProvisioningCipher.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/PrimaryProvisioningCipher.java index 19a5427668..78386a9ac0 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/PrimaryProvisioningCipher.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/PrimaryProvisioningCipher.java @@ -12,6 +12,8 @@ import org.signal.libsignal.protocol.ecc.ECKeyPair; import org.signal.libsignal.protocol.ecc.ECPublicKey; import org.signal.libsignal.protocol.kdf.HKDF; +import org.signal.registration.proto.RegistrationProvisionEnvelope; +import org.signal.registration.proto.RegistrationProvisionMessage; import org.whispersystems.signalservice.internal.push.ProvisionEnvelope; import org.whispersystems.signalservice.internal.push.ProvisionMessage; import org.whispersystems.signalservice.internal.util.Util; @@ -59,6 +61,24 @@ public byte[] encrypt(ProvisionMessage message) throws InvalidKeyException { .encode(); } + public byte[] encrypt(RegistrationProvisionMessage message) throws InvalidKeyException { + ECKeyPair ourKeyPair = Curve.generateKeyPair(); + byte[] sharedSecret = Curve.calculateAgreement(theirPublicKey, ourKeyPair.getPrivateKey()); + byte[] derivedSecret = HKDF.deriveSecrets(sharedSecret, PROVISIONING_MESSAGE.getBytes(), 64); + byte[][] parts = Util.split(derivedSecret, 32, 32); + + byte[] version = { 0x00 }; + byte[] ciphertext = getCiphertext(parts[0], message.encode()); + byte[] mac = getMac(parts[1], Util.join(version, ciphertext)); + byte[] body = Util.join(version, ciphertext, mac); + + return new RegistrationProvisionEnvelope.Builder() + .publicKey(ByteString.of(ourKeyPair.getPublicKey().serialize())) + .body(ByteString.of(body)) + .build() + .encode(); + } + private byte[] getCiphertext(byte[] key, byte[] message) { try { Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/secondary/SecondaryProvisioningCipher.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipher.kt similarity index 64% rename from app/src/main/java/org/thoughtcrime/securesms/registration/secondary/SecondaryProvisioningCipher.kt rename to libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipher.kt index 7736703495..bcadec20dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/secondary/SecondaryProvisioningCipher.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipher.kt @@ -1,14 +1,20 @@ -package org.thoughtcrime.securesms.registration.secondary +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.internal.crypto +import org.signal.core.util.logging.Log import org.signal.libsignal.protocol.IdentityKey import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.libsignal.protocol.ecc.Curve import org.signal.libsignal.protocol.ecc.ECPublicKey import org.signal.libsignal.protocol.kdf.HKDF import org.signal.libsignal.zkgroup.profiles.ProfileKey -import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.signal.registration.proto.RegistrationProvisionEnvelope +import org.signal.registration.proto.RegistrationProvisionMessage import org.whispersystems.signalservice.api.util.UuidUtil -import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher import org.whispersystems.signalservice.internal.push.ProvisionEnvelope import org.whispersystems.signalservice.internal.push.ProvisionMessage import java.security.InvalidKeyException @@ -23,23 +29,69 @@ import javax.crypto.spec.SecretKeySpec /** * Used to decrypt a secondary/link device provisioning message from the primary device. */ -class SecondaryProvisioningCipher private constructor(private val secondaryIdentityKeyPair: IdentityKeyPair) { +class SecondaryProvisioningCipher(private val secondaryIdentityKeyPair: IdentityKeyPair) { + + companion object { + private val TAG = Log.tag(SecondaryProvisioningCipher::class) + + private const val VERSION_LENGTH = 1 + private const val IV_LENGTH = 16 + private const val MAC_LENGTH = 32 + + fun generate(identityKeyPair: IdentityKeyPair): SecondaryProvisioningCipher { + return SecondaryProvisioningCipher(identityKeyPair) + } + } val secondaryDevicePublicKey: IdentityKey = secondaryIdentityKeyPair.publicKey fun decrypt(envelope: ProvisionEnvelope): ProvisionDecryptResult { - val primaryEphemeralPublicKey = envelope.publicKey!!.toByteArray() - val body = envelope.body!!.toByteArray() + val plaintext = decrypt(expectedVersion = 1, primaryEphemeralPublicKey = envelope.publicKey!!.toByteArray(), body = envelope.body!!.toByteArray()) + + if (plaintext == null) { + Log.w(TAG, "Plaintext is null") + return ProvisionDecryptResult.Error + } + + val provisioningMessage = ProvisionMessage.ADAPTER.decode(plaintext) + + return ProvisionDecryptResult.Success( + uuid = UuidUtil.parseOrThrow(provisioningMessage.aci), + e164 = provisioningMessage.number!!, + identityKeyPair = IdentityKeyPair(IdentityKey(provisioningMessage.aciIdentityKeyPublic!!.toByteArray()), Curve.decodePrivatePoint(provisioningMessage.aciIdentityKeyPrivate!!.toByteArray())), + profileKey = ProfileKey(provisioningMessage.profileKey!!.toByteArray()), + areReadReceiptsEnabled = provisioningMessage.readReceipts == true, + primaryUserAgent = provisioningMessage.userAgent, + provisioningCode = provisioningMessage.provisioningCode!!, + provisioningVersion = provisioningMessage.provisioningVersion!! + ) + } + + fun decrypt(envelope: RegistrationProvisionEnvelope): RegistrationProvisionResult { + val plaintext = decrypt(expectedVersion = 0, primaryEphemeralPublicKey = envelope.publicKey.toByteArray(), body = envelope.body.toByteArray()) + + if (plaintext == null) { + Log.w(TAG, "Plaintext is null") + return RegistrationProvisionResult.Error + } + val provisioningMessage = RegistrationProvisionMessage.ADAPTER.decode(plaintext) + + return RegistrationProvisionResult.Success(provisioningMessage) + } + + private fun decrypt(expectedVersion: Int, primaryEphemeralPublicKey: ByteArray, body: ByteArray): ByteArray? { val provisionMessageLength = body.size - VERSION_LENGTH - IV_LENGTH - MAC_LENGTH if (provisionMessageLength <= 0) { - return ProvisionDecryptResult.Error + Log.w(TAG, "Provisioning message length invalid") + return null } val version = body[0].toInt() - if (version != 1) { - return ProvisionDecryptResult.Error + if (version != expectedVersion) { + Log.w(TAG, "Version does not match expected, expected $expectedVersion but was $version") + return null } val iv = body.sliceArray(1 until (1 + IV_LENGTH)) @@ -56,27 +108,16 @@ class SecondaryProvisioningCipher private constructor(private val secondaryIdent val ourHmac = getMac(macKey, message) if (!MessageDigest.isEqual(theirMac, ourHmac)) { - return ProvisionDecryptResult.Error + Log.w(TAG, "Macs do not match") + return null } - val plaintext = try { + return try { getPlaintext(cipherKey, iv, cipherText) } catch (e: Exception) { - return ProvisionDecryptResult.Error + Log.w(TAG, "Unable to get plaintext", e) + return null } - - val provisioningMessage = ProvisionMessage.ADAPTER.decode(plaintext) - - return ProvisionDecryptResult.Success( - uuid = UuidUtil.parseOrThrow(provisioningMessage.aci), - e164 = provisioningMessage.number!!, - identityKeyPair = IdentityKeyPair(IdentityKey(provisioningMessage.aciIdentityKeyPublic!!.toByteArray()), Curve.decodePrivatePoint(provisioningMessage.aciIdentityKeyPrivate!!.toByteArray())), - profileKey = ProfileKey(provisioningMessage.profileKey!!.toByteArray()), - areReadReceiptsEnabled = provisioningMessage.readReceipts == true, - primaryUserAgent = provisioningMessage.userAgent, - provisioningCode = provisioningMessage.provisioningCode!!, - provisioningVersion = provisioningMessage.provisioningVersion!! - ) } private fun getMac(key: ByteArray, message: ByteArray): ByteArray? { @@ -97,18 +138,8 @@ class SecondaryProvisioningCipher private constructor(private val secondaryIdent return cipher.doFinal(message) } - companion object { - private const val VERSION_LENGTH = 1 - private const val IV_LENGTH = 16 - private const val MAC_LENGTH = 32 - - fun generate(): SecondaryProvisioningCipher { - return SecondaryProvisioningCipher(IdentityKeyUtil.generateIdentityKeyPair()) - } - } - - sealed class ProvisionDecryptResult { - object Error : ProvisionDecryptResult() + sealed interface ProvisionDecryptResult { + data object Error : ProvisionDecryptResult data class Success( val uuid: UUID, @@ -119,6 +150,11 @@ class SecondaryProvisioningCipher private constructor(private val secondaryIdent val primaryUserAgent: String?, val provisioningCode: String, val provisioningVersion: Int - ) : ProvisionDecryptResult() + ) : ProvisionDecryptResult + } + + sealed interface RegistrationProvisionResult { + data object Error : RegistrationProvisionResult + data class Success(val message: RegistrationProvisionMessage) : RegistrationProvisionResult } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/ProvisioningSocket.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/ProvisioningSocket.java index 138ef4bcda..3d1d23f70f 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/ProvisioningSocket.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/ProvisioningSocket.java @@ -5,8 +5,6 @@ import org.signal.core.util.logging.Log; import org.signal.libsignal.protocol.IdentityKeyPair; import org.signal.libsignal.protocol.InvalidKeyException; -import org.whispersystems.signalservice.api.util.SleepTimer; -import org.whispersystems.signalservice.api.util.UptimeSleepTimer; import org.whispersystems.signalservice.api.websocket.HealthMonitor; import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration; import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher; diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 4f3f611f6b..6d450b8d2b 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -115,6 +115,7 @@ import org.whispersystems.signalservice.api.push.exceptions.UsernameIsNotReservedException; import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException; import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException; +import org.whispersystems.signalservice.api.registration.RestoreMethodBody; import org.whispersystems.signalservice.api.storage.StorageAuthResponse; import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription; import org.whispersystems.signalservice.api.subscriptions.PayPalConfirmPaymentIntentResponse; @@ -257,6 +258,8 @@ public class PushServiceSocket { private static final String DEVICE_LINK_PATH = "/v1/devices/link"; private static final String WAIT_FOR_DEVICES_PATH = "/v1/devices/wait_for_linked_device/%s?timeout=%s"; private static final String TRANSFER_ARCHIVE_PATH = "/v1/devices/transfer_archive"; + private static final String SET_RESTORE_METHOD_PATH = "/v1/devices/restore_account/%s"; + private static final String WAIT_RESTORE_METHOD_PATH = "/v1/devices/restore_account/%s?timeout=%s"; private static final String MESSAGE_PATH = "/v1/messages/%s"; private static final String GROUP_MESSAGE_PATH = "/v1/messages/multi_recipient?ts=%s&online=%s&urgent=%s&story=%s"; @@ -349,6 +352,7 @@ public class PushServiceSocket { private static final Map NO_HEADERS = Collections.emptyMap(); private static final ResponseCodeHandler NO_HANDLER = new EmptyResponseCodeHandler(); private static final ResponseCodeHandler UNOPINIONATED_HANDLER = new UnopinionatedResponseCodeHandler(); + private static final ResponseCodeHandler LONG_POLL_HANDLER = new LongPollingResponseCodeHandler(); public static final long CDN2_RESUMABLE_LINK_LIFETIME_MILLIS = TimeUnit.DAYS.toMillis(7); @@ -730,23 +734,7 @@ public List getDevices() throws IOException { * This is a long-polling endpoint that relies on the fact that our normal connection timeout is already 30s. */ public WaitForLinkedDeviceResponse waitForLinkedDevice(String token, int timeoutSeconds) throws IOException { - // Note: We consider 204 failure, since that means that we timed out before determining if a device was linked. Easier that way. - - String response = makeServiceRequest(String.format(Locale.US, WAIT_FOR_DEVICES_PATH, token, timeoutSeconds), "GET", null, NO_HEADERS, (responseCode, body) -> { - if (responseCode == 204 || responseCode < 200 || responseCode > 299) { - String bodyString = null; - if (body != null) { - try { - bodyString = readBodyString(body); - } catch (MalformedResponseException e) { - Log.w(TAG, "Failed to read body string", e); - } - } - - throw new NonSuccessfulResponseCodeException(responseCode, "Response: " + responseCode, bodyString); - } - }, SealedSenderAccess.NONE); - + String response = makeServiceRequest(String.format(Locale.US, WAIT_FOR_DEVICES_PATH, token, timeoutSeconds), "GET", null, NO_HEADERS, LONG_POLL_HANDLER, SealedSenderAccess.NONE); return JsonUtil.fromJsonResponse(response, WaitForLinkedDeviceResponse.class); } @@ -755,6 +743,19 @@ public void setLinkedDeviceTransferArchive(SetLinkedDeviceTransferArchiveRequest makeServiceRequest(String.format(Locale.US, TRANSFER_ARCHIVE_PATH), "PUT", body, NO_HEADERS, UNOPINIONATED_HANDLER, SealedSenderAccess.NONE); } + public void setRestoreMethodChosen(@Nonnull String token, @Nonnull RestoreMethodBody request) throws IOException { + String body = JsonUtil.toJson(request); + makeServiceRequest(String.format(Locale.US, SET_RESTORE_METHOD_PATH, urlEncode(token)), "PUT", body, NO_HEADERS, UNOPINIONATED_HANDLER, SealedSenderAccess.NONE); + } + + /** + * This is a long-polling endpoint that relies on the fact that our normal connection timeout is already 30s. + */ + public @Nonnull RestoreMethodBody waitForRestoreMethodChosen(@Nonnull String token, int timeoutSeconds) throws IOException { + String response = makeServiceRequest(String.format(Locale.US, WAIT_RESTORE_METHOD_PATH, urlEncode(token), timeoutSeconds), "GET", null, NO_HEADERS, LONG_POLL_HANDLER, SealedSenderAccess.NONE); + return JsonUtil.fromJsonResponse(response, RestoreMethodBody.class); + } + public void removeDevice(long deviceId) throws IOException { makeServiceRequest(String.format(DEVICE_PATH, String.valueOf(deviceId)), "DELETE", null); } @@ -2861,6 +2862,28 @@ public void handle(int responseCode, ResponseBody body) throws NonSuccessfulResp } } + /** + * Like {@link UnopinionatedResponseCodeHandler} but also treats a 204 as a failure, since that means that the server intentionally + * timed out before a valid result for the long poll was returned. Easier that way. + */ + private static class LongPollingResponseCodeHandler implements ResponseCodeHandler { + @Override + public void handle(int responseCode, ResponseBody body) throws NonSuccessfulResponseCodeException, PushNetworkException { + if (responseCode == 204 || responseCode < 200 || responseCode > 299) { + String bodyString = null; + if (body != null) { + try { + bodyString = readBodyString(body); + } catch (MalformedResponseException e) { + Log.w(TAG, "Failed to read body string", e); + } + } + + throw new NonSuccessfulResponseCodeException(responseCode, "Response: " + responseCode, bodyString); + } + } + } + public enum ClientSet { KeyBackup } public CredentialResponse retrieveGroupsV2Credentials(long todaySeconds) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBody.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBody.kt index 85cf552d30..99c957e85c 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBody.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/http/DigestingRequestBody.kt @@ -63,7 +63,7 @@ class DigestingRequestBody( outputStream.flush() - val incrementalDigest: ByteArray = if (isIncremental) { + val incrementalDigest: ByteArray? = if (isIncremental) { if (contentLength != outputStream.totalBytesWritten) { Log.w(TAG, "Content uploaded ${logMessage(outputStream.totalBytesWritten, contentLength)} bytes compared to expected!") } else { @@ -73,10 +73,12 @@ class DigestingRequestBody( digestStream.close() digestStream.toByteArray() } else { - ByteArray(0) + null } - attachmentDigest = AttachmentDigest(outputStream.transmittedDigest, incrementalDigest, sizeChoice.sizeInBytes) + val incrementalDigestChunkSize: Int = if (incrementalDigest?.isNotEmpty() == true) sizeChoice.sizeInBytes else 0 + + attachmentDigest = AttachmentDigest(outputStream.transmittedDigest, incrementalDigest, incrementalDigestChunkSize) } override fun contentLength(): Long { diff --git a/libsignal-service/src/main/protowire/Provisioning.proto b/libsignal-service/src/main/protowire/Provisioning.proto index 93f002a6a3..bf2609750c 100644 --- a/libsignal-service/src/main/protowire/Provisioning.proto +++ b/libsignal-service/src/main/protowire/Provisioning.proto @@ -3,15 +3,19 @@ * * Licensed according to the LICENSE file in this repository. */ -syntax = "proto3"; +syntax = "proto2"; package signalservice; option java_package = "org.whispersystems.signalservice.internal.push"; option java_outer_classname = "ProvisioningProtos"; +message ProvisioningAddress { + optional string address = 1; +} + message ProvisioningUuid { - string uuid = 1; + optional string uuid = 1; } message ProvisionEnvelope { diff --git a/libsignal-service/src/main/protowire/RegistrationProvisioning.proto b/libsignal-service/src/main/protowire/RegistrationProvisioning.proto new file mode 100644 index 0000000000..df52e7b0d2 --- /dev/null +++ b/libsignal-service/src/main/protowire/RegistrationProvisioning.proto @@ -0,0 +1,31 @@ +syntax = "proto3"; + +option java_multiple_files = true; +option java_package = "org.signal.registration.proto"; + +message RegistrationProvisionEnvelope { + bytes publicKey = 1; + bytes body = 2; // Encrypted RegistrationProvisionMessage +} + +message RegistrationProvisionMessage { + enum Platform { + ANDROID = 0; + IOS = 1; + } + + enum Tier { + FREE = 0; + PAID = 1; + } + + string e164 = 1; + bytes aci = 2; + string accountEntropyPool = 3; + string pin = 4; + Platform platform = 5; + uint64 backupTimestampMs = 6; + Tier tier = 7; + string restoreMethodToken = 8; + reserved 9; // iOSDeviceTransferMessage +} diff --git a/libsignal-service/src/test/java/org/whispersystems/signalservice/api/storage/SignalContactRecordTest.java b/libsignal-service/src/test/java/org/whispersystems/signalservice/api/storage/SignalContactRecordTest.java index 23d8dc0349..b81b856c57 100644 --- a/libsignal-service/src/test/java/org/whispersystems/signalservice/api/storage/SignalContactRecordTest.java +++ b/libsignal-service/src/test/java/org/whispersystems/signalservice/api/storage/SignalContactRecordTest.java @@ -1,8 +1,10 @@ package org.whispersystems.signalservice.api.storage; import org.junit.Test; -import org.whispersystems.signalservice.api.push.ServiceId; import org.whispersystems.signalservice.api.push.ServiceId.ACI; +import org.whispersystems.signalservice.internal.storage.protos.ContactRecord; + +import okio.ByteString; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; @@ -14,27 +16,33 @@ public class SignalContactRecordTest { @Test public void contacts_with_same_identity_key_contents_are_equal() { - byte[] profileKey = new byte[32]; - byte[] profileKeyCopy = profileKey.clone(); + byte[] identityKey = new byte[32]; + byte[] identityKeyCopy = identityKey.clone(); + + ContactRecord contactA = contactBuilder(ACI_A, E164_A, "a").identityKey(ByteString.of(identityKey)).build(); + ContactRecord contactB = contactBuilder(ACI_A, E164_A, "a").identityKey(ByteString.of(identityKeyCopy)).build(); - SignalContactRecord a = contactBuilder(1, ACI_A, E164_A, "a").setIdentityKey(profileKey).build(); - SignalContactRecord b = contactBuilder(1, ACI_A, E164_A, "a").setIdentityKey(profileKeyCopy).build(); + SignalContactRecord signalContactA = new SignalContactRecord(StorageId.forContact(byteArray(1)), contactA); + SignalContactRecord signalContactB = new SignalContactRecord(StorageId.forContact(byteArray(1)), contactB); - assertEquals(a, b); - assertEquals(a.hashCode(), b.hashCode()); + assertEquals(signalContactA, signalContactB); + assertEquals(signalContactA.hashCode(), signalContactB.hashCode()); } @Test public void contacts_with_different_identity_key_contents_are_not_equal() { - byte[] profileKey = new byte[32]; - byte[] profileKeyCopy = profileKey.clone(); - profileKeyCopy[0] = 1; + byte[] identityKey = new byte[32]; + byte[] identityKeyCopy = identityKey.clone(); + identityKeyCopy[0] = 1; + + ContactRecord contactA = contactBuilder(ACI_A, E164_A, "a").identityKey(ByteString.of(identityKey)).build(); + ContactRecord contactB = contactBuilder(ACI_A, E164_A, "a").identityKey(ByteString.of(identityKeyCopy)).build(); - SignalContactRecord a = contactBuilder(1, ACI_A, E164_A, "a").setIdentityKey(profileKey).build(); - SignalContactRecord b = contactBuilder(1, ACI_A, E164_A, "a").setIdentityKey(profileKeyCopy).build(); + SignalContactRecord signalContactA = new SignalContactRecord(StorageId.forContact(byteArray(1)), contactA); + SignalContactRecord signalContactB = new SignalContactRecord(StorageId.forContact(byteArray(1)), contactB); - assertNotEquals(a, b); - assertNotEquals(a.hashCode(), b.hashCode()); + assertNotEquals(signalContactA, signalContactB); + assertNotEquals(signalContactA.hashCode(), signalContactB.hashCode()); } private static byte[] byteArray(int a) { @@ -46,13 +54,9 @@ private static byte[] byteArray(int a) { return bytes; } - private static SignalContactRecord.Builder contactBuilder(int key, - ACI serviceId, - String e164, - String givenName) - { - return new SignalContactRecord.Builder(byteArray(key), serviceId, null) - .setE164(e164) - .setProfileGivenName(givenName); + private static ContactRecord.Builder contactBuilder(ACI serviceId, String e164, String givenName) { + return new ContactRecord.Builder() + .e164(e164) + .givenName(givenName); } } diff --git a/libsignal-service/src/test/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscriptionTest.java b/libsignal-service/src/test/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscriptionTest.java index f3b887291c..c1caaf093f 100644 --- a/libsignal-service/src/test/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscriptionTest.java +++ b/libsignal-service/src/test/java/org/whispersystems/signalservice/api/subscriptions/ActiveSubscriptionTest.java @@ -15,4 +15,11 @@ public void givenActiveSubscription_whenIIsPaymentFailure_thenIExpectFalse() thr assertTrue(activeSubscription.isActive()); assertFalse(activeSubscription.isFailedPayment()); } + + @Test + public void givenNoActiveSubscription_whenIIsInProgress_thenIExpectFalse() throws Exception { + ActiveSubscription activeSubscription = new ActiveSubscription(null, null); + + assertFalse(activeSubscription.isInProgress()); + } } \ No newline at end of file diff --git a/app/src/test/java/org/thoughtcrime/securesms/registration/secondary/SecondaryProvisioningCipherTest.kt b/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipherTest.kt similarity index 69% rename from app/src/test/java/org/thoughtcrime/securesms/registration/secondary/SecondaryProvisioningCipherTest.kt rename to libsignal-service/src/test/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipherTest.kt index 22b0e56b13..45ee4bae2c 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/registration/secondary/SecondaryProvisioningCipherTest.kt +++ b/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipherTest.kt @@ -1,26 +1,33 @@ -package org.thoughtcrime.securesms.registration.secondary +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.internal.crypto import okio.ByteString import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.instanceOf import org.hamcrest.Matchers.`is` import org.junit.Test -import org.thoughtcrime.securesms.crypto.IdentityKeyUtil -import org.thoughtcrime.securesms.crypto.ProfileKeyUtil -import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher +import org.signal.libsignal.protocol.IdentityKey +import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.ecc.Curve +import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.whispersystems.signalservice.internal.push.ProvisionEnvelope import org.whispersystems.signalservice.internal.push.ProvisionMessage import org.whispersystems.signalservice.internal.push.ProvisioningVersion import java.util.UUID +import kotlin.random.Random class SecondaryProvisioningCipherTest { @Test fun decrypt() { - val provisioningCipher = SecondaryProvisioningCipher.generate() + val provisioningCipher = SecondaryProvisioningCipher.generate(generateIdentityKeyPair()) - val primaryIdentityKeyPair = IdentityKeyUtil.generateIdentityKeyPair() - val primaryProfileKey = ProfileKeyUtil.createNew() + val primaryIdentityKeyPair = generateIdentityKeyPair() + val primaryProfileKey = generateProfileKey() val primaryProvisioningCipher = PrimaryProvisioningCipher(provisioningCipher.secondaryDevicePublicKey.publicKey) val message = ProvisionMessage( @@ -50,4 +57,18 @@ class SecondaryProvisioningCipherTest { assertThat(success.provisioningCode, `is`(message.provisioningCode)) assertThat(success.provisioningVersion, `is`(message.provisioningVersion)) } + + companion object { + fun generateIdentityKeyPair(): IdentityKeyPair { + val djbKeyPair = Curve.generateKeyPair() + val djbIdentityKey = IdentityKey(djbKeyPair.publicKey) + val djbPrivateKey = djbKeyPair.privateKey + + return IdentityKeyPair(djbIdentityKey, djbPrivateKey) + } + + fun generateProfileKey(): ProfileKey { + return ProfileKey(Random.nextBytes(32)) + } + } }