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 f044d8b92c..46933f55c8 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalActivityRule.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalActivityRule.kt @@ -26,6 +26,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 @@ -104,7 +105,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 28b6d88219..f5fc3df974 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -838,6 +838,11 @@ android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize" android:exported="false"/> + + + + - diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java index f0002217b6..d19cc53b0c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java @@ -191,11 +191,10 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements } 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() { @@ -240,7 +239,7 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements } 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..c331423ecd 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 @@ -13,6 +13,7 @@ import org.greenrobot.eventbus.EventBus import org.signal.core.util.Base64 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 @@ -644,7 +645,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 +1177,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/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/components/settings/app/account/AccountSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/account/AccountSettingsFragment.kt index 96a5965e8c..cb4f898340 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 @@ -69,10 +69,10 @@ class AccountSettingsFragment : DSLSettingsFragment(R.string.AccountSettingsFrag @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) @@ -94,7 +94,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) } @@ -125,7 +125,6 @@ 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(), 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 b7d733ac93..713d743c0c 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 @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.components.settings.app.account data class AccountSettingsState( 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 4be71362bb..94d470a6d2 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 @@ -19,7 +19,8 @@ class AccountSettingsViewModel : ViewModel() { private fun getCurrentState(): AccountSettingsState { return AccountSettingsState( 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 5aa888102a..4868a2a4ff 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 @@ -837,7 +837,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter SignalStore.account.setRegistered(false) SignalStore.registration.clearRegistrationComplete() SignalStore.registration.clearHasUploadedProfile() - SignalStore.registration.clearSkippedTransferOrRestore() + SignalStore.registration.debugClearSkippedTransferOrRestore() Toast.makeText(context, "Unregistered!", Toast.LENGTH_SHORT).show() } 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/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 @@ final class NewDeviceServerTask implements ServerTask { 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 @@ final class NewDeviceServerTask implements ServerTask { 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 @@ final class NewDeviceServerTask implements ServerTask { 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 android.view.View; 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 final class NewDeviceTransferCompleteFragment extends LoggingFragment { @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/NewDeviceTransferSetupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferSetupFragment.java index 880c9d01f8..240866969d 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 @@ public final class NewDeviceTransferSetupFragment extends DeviceTransferSetupFra @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..b745654053 --- /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.clearLocalRegistrationMetadata() + 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.greenrobot.eventbus.ThreadMode; 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 final class OldDeviceTransferFragment extends DeviceTransferFragment { @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/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/RefreshAttributesJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java index c0f5bec68f..e55101f49b 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 class RefreshAttributesJob extends BaseJob { 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()); - Log.i(TAG, "Calling setAccountAttributes() reglockV2? " + !TextUtils.isEmpty(registrationLockV2) + ", pin? " + svrValues.hasPin() + + AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities(svrValues.hasOptedInWithAccess() && !svrValues.hasOptedOut()); + Log.i(TAG, "Calling setAccountAttributes() reglockV2? " + !TextUtils.isEmpty(registrationLockV2) + ", pin? " + svrValues.hasPin() + ", access? " + svrValues.hasOptedInWithAccess() + "\n Recovery password? " + !TextUtils.isEmpty(recoveryPassword) + "\n Phone number discoverable : " + phoneNumberDiscoverable + "\n Device Name : " + (encryptedDeviceName != null) + 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 1ef45f467f..a3b449f42c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java @@ -105,7 +105,7 @@ public class RefreshOwnProfileJob extends BaseJob { 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/StorageSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt index ae1c9e562d..b9d818da32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt @@ -139,7 +139,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()) { + if (!SignalStore.svr.hasOptedInWithAccess() && !SignalStore.svr.hasOptedOut()) { Log.i(TAG, "Doesn't have a PIN. Skipping.") return } 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/PaymentsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt index b341d5ddd7..3205ef4ed3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt @@ -243,7 +243,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 index 7f64139092..22959c07f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java @@ -96,7 +96,7 @@ public final class RegistrationValues extends SignalStoreValues { putBoolean(SKIPPED_TRANSFER_OR_RESTORE, true); } - public void clearSkippedTransferOrRestore() { + public void debugClearSkippedTransferOrRestore() { putBoolean(SKIPPED_TRANSFER_OR_RESTORE, false); } 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 abstract class BaseSvrPinFragment @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 @@ public final class SvrSplashFragment extends Fragment { 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..f97edb623a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionPin.java +++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionPin.java @@ -19,6 +19,7 @@ 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") 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/megaphone/PinsForAllSchedule.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PinsForAllSchedule.java index 2b9a0dc2a7..f8e0836989 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 @@ class PinsForAllSchedule implements MegaphoneSchedule { return false; } - if (SignalStore.svr().hasPin()) { + if (SignalStore.svr().hasOptedInWithAccess()) { return false; } @@ -62,6 +62,6 @@ class PinsForAllSchedule implements MegaphoneSchedule { 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 @@ public final class PinOptOutMigration extends MigrationJob { @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/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 @@ public class PinRestoreEntryFragment extends LoggingFragment { 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 3739f66323..d1105b187b 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,10 +164,13 @@ 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) @@ -264,7 +268,6 @@ object SvrRepository { Log.i(TAG, "[setPin] Success!", true) SignalStore.svr.setMasterKey(masterKey, userPin) - SignalStore.svr.isPinForgottenOrSkipped = false responses .filterIsInstance() .forEach { @@ -321,6 +324,9 @@ object SvrRepository { SignalStore.pin.resetPinReminders() AppDependencies.jobManager.add(ResetSvrGuessCountJob()) + } 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() @@ -342,7 +348,6 @@ object SvrRepository { operationLock.withLock { SignalStore.svr.clearRegistrationLockAndPin() SignalStore.storageService.setNeedsAccountRestore(false) - SignalStore.svr.isPinForgottenOrSkipped = true } } @@ -364,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) @@ -378,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() @@ -408,7 +413,7 @@ object SvrRepository { false } - if (newToken && SignalStore.svr.hasPin()) { + if (newToken && SignalStore.svr.hasOptedInWithAccess()) { BackupManager(AppDependencies.application).dataChanged() } } catch (e: Throwable) { @@ -469,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/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 a2c390ec1f..7f95edd8d6 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 @@ -622,15 +622,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/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/ui/RegistrationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt index 0a9990b561..be21b04c3c 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 @@ -119,7 +119,7 @@ class RegistrationActivity : BaseActivity() { @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) } @@ -127,9 +127,13 @@ class RegistrationActivity : BaseActivity() { @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 7ef39dcc65..c92453be32 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 @@ -33,6 +33,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.LocalRegistrationMetadataUtil import org.thoughtcrime.securesms.registration.data.RegistrationData import org.thoughtcrime.securesms.registration.data.RegistrationRepository @@ -827,7 +828,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) 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 27b0d2886f..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RemoteRestoreActivity.kt +++ /dev/null @@ -1,380 +0,0 @@ -/* - * Copyright 2024 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.registration.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.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 - -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 1775f34a7d..3512214778 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.jobs.StorageSyncJob; 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,7 +30,8 @@ public final class RegistrationUtil { if (!SignalStore.registration().isRegistrationComplete() && SignalStore.account().isRegistered() && !Recipient.self().getProfileName().isEmpty() && - (SignalStore.svr().hasPin() || SignalStore.svr().hasOptedOut())) + (SignalStore.svr().hasOptedInWithAccess() || SignalStore.svr().hasOptedOut()) && + (RemoteConfig.restoreAfterRegistration() && (SignalStore.registration().hasSkippedTransferOrRestore() || SignalStore.registration().hasCompletedRestore()))) { Log.i(TAG, "Marking registration completed.", new Throwable()); SignalStore.registration().setRegistrationComplete(); 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..fa562c14ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/QuickRegistrationRepository.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.registrationv3.data + +import android.net.Uri +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 java.io.IOException + +/** + * 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): 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 + } + ) + ) + .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 + } + + 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..c572883b2a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt @@ -0,0 +1,623 @@ +/* + * 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() + AppDependencies.incomingMessageObserver + 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..72dcde1463 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountActivity.kt @@ -0,0 +1,346 @@ +/* + * 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 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.PassphraseRequiredActivity +import org.thoughtcrime.securesms.R +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 + +/** + * 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 + ) + + setContent { + val state by viewModel.state.collectAsState() + + SignalTheme { + TransferToNewDevice( + state = state, + onTransferAccount = this::authenticate, + clearReRegisterResult = 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 = {}, + clearReRegisterResult: () -> 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 = clearReRegisterResult, + sheetState = sheetState + ) { + ContinueOnOtherDevice() + } + } + + QuickRegistrationRepository.TransferAccountResult.FAILED -> { + Dialogs.SimpleAlertDialog( + title = Dialogs.NoTitle, + body = stringResource(R.string.RegistrationActivity_unable_to_connect_to_service), + confirm = stringResource(android.R.string.ok), + onConfirm = clearReRegisterResult, + onDismiss = clearReRegisterResult + ) + } + + 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..f9e518a7c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountViewModel.kt @@ -0,0 +1,40 @@ +/* + * 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.registrationv3.data.QuickRegistrationRepository + +class TransferAccountViewModel(reRegisterUri: String) : ViewModel() { + + private val store: MutableStateFlow = MutableStateFlow(TransferAccountState(reRegisterUri)) + + val state: StateFlow = store + + fun transferAccount() { + viewModelScope.launch(Dispatchers.IO) { + store.update { it.copy(inProgress = true) } + val result = QuickRegistrationRepository.transferAccount(store.value.reRegisterUri) + store.update { it.copy(reRegisterResult = result, inProgress = false) } + } + } + + fun clearReRegisterResult() { + store.update { it.copy(reRegisterResult = null) } + } + + data class TransferAccountState( + val reRegisterUri: String, + val inProgress: Boolean = false, + val reRegisterResult: QuickRegistrationRepository.TransferAccountResult? = 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..78a882ef8a --- /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..ff051e521e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt @@ -0,0 +1,654 @@ +/* + * 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.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 + } + + null -> { + Log.w(TAG, "Null result received from PlayServicesUtil, marking Play Services as missing.") + fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING) + 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..b600597591 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt @@ -0,0 +1,94 @@ +/* + * 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.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.whispersystems.signalservice.api.registration.ProvisioningSocket +import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher +import java.io.Closeable + +class RestoreViaQrViewModel : ViewModel() { + + 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 { + 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) { + 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 7a4f5cdc9a..6dfb2a2183 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt @@ -8,16 +8,20 @@ package org.thoughtcrime.securesms.restore 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. @@ -27,6 +31,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) @@ -34,16 +40,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() { @@ -51,21 +83,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 } } } @@ -73,26 +122,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/selection/SelectRestoreMethodFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt new file mode 100644 index 0000000000..058116bce6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt @@ -0,0 +1,44 @@ +/* + * 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.navigation.fragment.findNavController +import org.thoughtcrime.securesms.MainActivity +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.keyvalue.SignalStore +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 + +/** + * 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() + startActivity(MainActivity.clearTop(requireContext())) + activity?.finish() + } + ) + } + + private fun startRestoreMethod(method: RestoreMethod) { + 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/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_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"> - - + 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/strings.xml b/app/src/main/res/values/strings.xml index d0b2a7625a..2377a71efd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1329,20 +1329,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… @@ -1360,12 +1346,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 @@ -4311,6 +4301,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 @@ -7536,10 +7528,6 @@ Change or cancel subscription - - - Your last backup was made on %1$s at %2$s. - Chat limits @@ -7857,5 +7845,89 @@ 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. + 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/libsignal-service/build.gradle.kts b/libsignal-service/build.gradle.kts index 98c1c4c7df..5ae5af34e8 100644 --- a/libsignal-service/build.gradle.kts +++ b/libsignal-service/build.gradle.kts @@ -94,6 +94,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")) @@ -102,6 +104,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/SignalUrlExtensions.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalUrlExtensions.kt new file mode 100644 index 0000000000..a6e32c4c11 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalUrlExtensions.kt @@ -0,0 +1,67 @@ +/* + * 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.api.util.TlsProxySocketFactory +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() + .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) + } + + if (configuration.signalProxy.isPresent) { + val proxy = configuration.signalProxy.get() + builder.socketFactory(TlsProxySocketFactory(proxy.host, proxy.port, configuration.dns)) + } + + 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/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..f65bbdf816 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,20 @@ 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) + } + } } 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 6ae409e0a1..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,30 +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.api.util.TlsProxySocketFactory 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 @@ -44,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 { @@ -212,43 +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() - .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) - } - - if (configuration.signalProxy.isPresent) { - val proxy = configuration.signalProxy.get() - builder.socketFactory(TlsProxySocketFactory(proxy.host, proxy.port, configuration.dns)) - } - - 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/internal/crypto/PrimaryProvisioningCipher.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/PrimaryProvisioningCipher.java index fd9acbf063..2ff6e03052 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 @@ -11,6 +11,8 @@ import org.signal.libsignal.protocol.ecc.Curve; 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; @@ -54,6 +56,24 @@ public class PrimaryProvisioningCipher { .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()) - val provisionMessageLength = body.size - VERSION_LENGTH - IV_LENGTH - MAC_LENGTH - - if (provisionMessageLength <= 0) { + 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) { + 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/protowire/Provisioning.proto b/libsignal-service/src/main/protowire/Provisioning.proto index 03fb0085e8..e18829a66e 100644 --- a/libsignal-service/src/main/protowire/Provisioning.proto +++ b/libsignal-service/src/main/protowire/Provisioning.proto @@ -10,8 +10,8 @@ package signalservice; option java_package = "org.whispersystems.signalservice.internal.push"; option java_outer_classname = "ProvisioningProtos"; -message ProvisioningUuid { - optional string uuid = 1; +message ProvisioningAddress { + optional string address = 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..3efcb3ba47 --- /dev/null +++ b/libsignal-service/src/main/protowire/RegistrationProvisioning.proto @@ -0,0 +1,30 @@ +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; + reserved 8; // iOSDeviceTransferMessage +} 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)) + } + } }