Add user restore method selection plumbing to old device.

This commit is contained in:
Cody Henthorne 2024-11-13 15:01:04 -05:00 committed by Greyson Parrelli
parent b6bb3928e7
commit 75f0d3363b
22 changed files with 457 additions and 72 deletions

View file

@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment;
import org.thoughtcrime.securesms.conversationlist.RestoreCompleteBottomSheetDialog;
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor;
@ -191,7 +192,16 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
protected void onResume() {
super.onResume();
dynamicTheme.onResume(this);
if (SignalStore.misc().isOldDeviceTransferLocked()) {
if (SignalStore.misc().getShouldShowLinkedDevicesReminder()) {
SignalStore.misc().setShouldShowLinkedDevicesReminder(false);
RelinkDevicesReminderBottomSheetFragment.show(getSupportFragmentManager());
}
if (SignalStore.registration().isRestoringOnNewDevice()) {
SignalStore.registration().setRestoringOnNewDevice(false);
RestoreCompleteBottomSheetDialog.show(getSupportFragmentManager());
} else if (SignalStore.misc().isOldDeviceTransferLocked()) {
new MaterialAlertDialogBuilder(this)
.setTitle(R.string.OldDeviceTransferLockedDialog__complete_registration_on_your_new_device)
.setMessage(R.string.OldDeviceTransferLockedDialog__your_signal_account_has_been_transferred_to_your_new_device)
@ -204,11 +214,6 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
.show();
}
if (SignalStore.misc().getShouldShowLinkedDevicesReminder()) {
SignalStore.misc().setShouldShowLinkedDevicesReminder(false);
RelinkDevicesReminderBottomSheetFragment.show(getSupportFragmentManager());
}
updateTabVisibility();
vitalsViewModel.checkSlowNotificationHeuristics();

View file

@ -0,0 +1,124 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversationlist
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Previews
import org.signal.core.ui.SignalPreview
import org.signal.core.ui.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
/**
* Bottom sheet dialog shown on an old device after the user has decided to transfer/restore to a new device.
*/
class RestoreCompleteBottomSheetDialog : ComposeBottomSheetDialogFragment() {
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
RestoreCompleteBottomSheetDialog().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
override val peekHeightPercentage: Float = 1f
@Composable
override fun SheetContent() {
RestoreCompleteContent(
isAfterDeviceTransfer = SignalStore.misc.isOldDeviceTransferLocked,
onOkayClick = this::dismissAllowingStateLoss
)
}
}
@Composable
private fun RestoreCompleteContent(
isAfterDeviceTransfer: Boolean = false,
onOkayClick: () -> Unit = {}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
.padding(bottom = 54.dp)
) {
BottomSheets.Handle()
Spacer(modifier = Modifier.height(20.dp))
Icon(
painter = painterResource(R.drawable.symbol_check_circle_40),
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
modifier = Modifier
.size(64.dp)
)
Spacer(modifier = Modifier.height(16.dp))
val title = if (isAfterDeviceTransfer) R.string.RestoreCompleteBottomSheet_transfer_complete else R.string.RestoreCompleteBottomSheet_restore_complete
Text(
text = stringResource(id = title),
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(8.dp))
val message = if (isAfterDeviceTransfer) R.string.RestoreCompleteBottomSheet_transfer_complete_message else R.string.RestoreCompleteBottomSheet_restore_complete_message
Text(
text = stringResource(id = message),
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
)
Spacer(modifier = Modifier.height(54.dp))
Buttons.LargeTonal(
onClick = onOkayClick,
modifier = Modifier.widthIn(min = 220.dp)
) {
Text(text = stringResource(R.string.RestoreCompleteBottomSheet_button))
}
}
}
@SignalPreview
@Composable
private fun RestoreCompleteContentPreview() {
Previews.Preview {
RestoreCompleteContent(isAfterDeviceTransfer = false)
}
}
@SignalPreview
@Composable
private fun RestoreCompleteContentAfterDeviceTransferPreview() {
Previews.Preview {
RestoreCompleteContent(isAfterDeviceTransfer = true)
}
}

View file

@ -48,6 +48,7 @@ import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations
import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.link.LinkDeviceApi
import org.whispersystems.signalservice.api.registration.RegistrationApi
import org.whispersystems.signalservice.api.services.CallLinksService
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.services.ProfileService
@ -298,6 +299,10 @@ object AppDependencies {
val linkDeviceApi: LinkDeviceApi
get() = networkModule.linkDeviceApi
@JvmStatic
val registrationApi: RegistrationApi
get() = networkModule.registrationApi
@JvmStatic
val okHttpClient: OkHttpClient
get() = networkModule.okHttpClient
@ -361,5 +366,6 @@ object AppDependencies {
fun provideKeysApi(pushServiceSocket: PushServiceSocket): KeysApi
fun provideAttachmentApi(signalWebSocket: SignalWebSocket, pushServiceSocket: PushServiceSocket): AttachmentApi
fun provideLinkDeviceApi(pushServiceSocket: PushServiceSocket): LinkDeviceApi
fun provideRegistrationApi(pushServiceSocket: PushServiceSocket): RegistrationApi
}
}

View file

@ -90,6 +90,7 @@ import org.whispersystems.signalservice.api.keys.KeysApi;
import org.whispersystems.signalservice.api.link.LinkDeviceApi;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI;
import org.whispersystems.signalservice.api.registration.RegistrationApi;
import org.whispersystems.signalservice.api.services.CallLinksService;
import org.whispersystems.signalservice.api.services.DonationsService;
import org.whispersystems.signalservice.api.services.ProfileService;
@ -473,6 +474,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
return new LinkDeviceApi(pushServiceSocket);
}
@Override
public @NonNull RegistrationApi provideRegistrationApi(@NonNull PushServiceSocket pushServiceSocket) {
return new RegistrationApi(pushServiceSocket);
}
@VisibleForTesting
static class DynamicCredentialsProvider implements CredentialsProvider {

View file

@ -33,6 +33,7 @@ import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations
import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.link.LinkDeviceApi
import org.whispersystems.signalservice.api.push.TrustStore
import org.whispersystems.signalservice.api.registration.RegistrationApi
import org.whispersystems.signalservice.api.services.CallLinksService
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.services.ProfileService
@ -143,6 +144,10 @@ class NetworkDependenciesModule(
provider.provideLinkDeviceApi(pushServiceSocket)
}
val registrationApi: RegistrationApi by lazy {
provider.provideRegistrationApi(pushServiceSocket)
}
val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor(StandardUserAgentInterceptor())

View file

@ -1,35 +0,0 @@
package org.thoughtcrime.securesms.devicetransfer.newdevice;
import android.os.Bundle;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.navigation.Navigation;
import org.greenrobot.eventbus.EventBus;
import org.signal.devicetransfer.TransferStatus;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
/**
* Shows instructions for new device to being transfer.
*/
public final class NewDeviceTransferInstructionsFragment extends LoggingFragment {
public NewDeviceTransferInstructionsFragment() {
super(R.layout.new_device_transfer_instructions_fragment);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
view.findViewById(R.id.new_device_transfer_instructions_fragment_continue)
.setOnClickListener(v -> SafeNavigation.safeNavigate(Navigation.findNavController(v), R.id.action_device_transfer_setup));
}
@Override
public void onResume() {
super.onResume();
EventBus.getDefault().removeStickyEvent(TransferStatus.class);
}
}

View file

@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.devicetransfer.newdevice
import android.os.Bundle
import android.view.View
import androidx.navigation.fragment.findNavController
import org.greenrobot.eventbus.EventBus
import org.signal.devicetransfer.TransferStatus
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Shows instructions for new device to being transfer.
*/
class NewDeviceTransferInstructionsFragment : LoggingFragment(R.layout.new_device_transfer_instructions_fragment) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view
.findViewById<View>(R.id.new_device_transfer_instructions_fragment_continue)
.setOnClickListener { findNavController().safeNavigate(R.id.action_device_transfer_setup) }
}
override fun onResume() {
super.onResume()
EventBus.getDefault().removeStickyEvent(TransferStatus::class.java)
}
}

View file

@ -14,6 +14,8 @@ class RegistrationValues internal constructor(store: KeyValueStore) : SignalStor
private const val SKIPPED_TRANSFER_OR_RESTORE = "registration.has_skipped_transfer_or_restore"
private const val LOCAL_REGISTRATION_DATA = "registration.local_registration_data"
private const val RESTORE_COMPLETED = "registration.backup_restore_completed"
private const val RESTORE_METHOD_TOKEN = "registration.restore_method_token"
private const val RESTORING_ON_NEW_DEVICE = "registration.restoring_on_new_device"
}
@Synchronized
@ -58,6 +60,10 @@ class RegistrationValues internal constructor(store: KeyValueStore) : SignalStor
var hasUploadedProfile: Boolean by booleanValue(HAS_UPLOADED_PROFILE, true)
var sessionId: String? by stringValue(SESSION_ID, null)
var sessionE164: String? by stringValue(SESSION_E164, null)
var restoreMethodToken: String? by stringValue(RESTORE_METHOD_TOKEN, null)
@get:JvmName("isRestoringOnNewDevice")
var restoringOnNewDevice: Boolean by booleanValue(RESTORING_ON_NEW_DEVICE, false)
fun hasSkippedTransferOrRestore(): Boolean {
return getBoolean(SKIPPED_TRANSFER_OR_RESTORE, false)

View file

@ -36,6 +36,7 @@ public final class RegistrationUtil {
Log.i(TAG, "Marking registration completed.", new Throwable());
SignalStore.registration().markRegistrationComplete();
SignalStore.registration().setLocalRegistrationMetadata(null);
SignalStore.registration().setRestoreMethodToken(null);
if (SignalStore.phoneNumberPrivacy().getPhoneNumberDiscoverabilityMode() == PhoneNumberDiscoverabilityMode.UNDECIDED) {
Log.w(TAG, "Phone number discoverability mode is still UNDECIDED. Setting to DISCOVERABLE.");

View file

@ -6,6 +6,10 @@
package org.thoughtcrime.securesms.registrationv3.data
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import org.signal.core.util.Base64.decode
import org.signal.core.util.Hex
import org.signal.core.util.isNotNullOrBlank
@ -16,7 +20,11 @@ import org.signal.registration.proto.RegistrationProvisionMessage
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.registration.RestoreMethod
import java.io.IOException
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration.Companion.seconds
/**
* Helpers for quickly re-registering on a new device with the old device.
@ -41,7 +49,7 @@ object QuickRegistrationRepository {
/**
* Send registration provisioning message to new device.
*/
fun transferAccount(reRegisterUri: String): TransferAccountResult {
fun transferAccount(reRegisterUri: String, restoreMethodToken: String): TransferAccountResult {
if (!isValidReRegistrationQr(reRegisterUri)) {
Log.w(TAG, "Invalid quick re-register qr data")
return TransferAccountResult.FAILED
@ -81,7 +89,8 @@ object QuickRegistrationRepository {
MessageBackupTier.PAID -> RegistrationProvisionMessage.Tier.PAID
MessageBackupTier.FREE,
null -> RegistrationProvisionMessage.Tier.FREE
}
},
restoreMethodToken = restoreMethodToken
)
)
.successOrThrow()
@ -98,6 +107,60 @@ object QuickRegistrationRepository {
return TransferAccountResult.SUCCESS
}
/**
* Sets the restore method enum for the old device to retrieve and update their UI with.
*/
suspend fun setRestoreMethodForOldDevice(restoreMethod: RestoreMethod) {
val restoreMethodToken = SignalStore.registration.restoreMethodToken
if (restoreMethodToken != null) {
withContext(Dispatchers.IO) {
Log.d(TAG, "Setting restore method ***${restoreMethodToken.takeLast(4)}: $restoreMethod")
var retries = 3
var result: NetworkResult<Unit>? = null
while (retries-- > 0 && result !is NetworkResult.Success) {
Log.d(TAG, "Setting method, retries remaining: $retries")
result = AppDependencies.registrationApi.setRestoreMethod(restoreMethodToken, restoreMethod)
if (result !is NetworkResult.Success) {
delay(1.seconds)
}
}
if (result is NetworkResult.Success) {
Log.i(TAG, "Restore method set successfully")
SignalStore.registration.restoreMethodToken = null
} else {
Log.w(TAG, "Restore method set failed", result?.getCause())
}
}
}
}
/**
* Gets the restore method used by the new device to update UI with. This is a long polling operation.
*/
suspend fun waitForRestoreMethodSelectionOnNewDevice(restoreMethodToken: String): RestoreMethod {
var retries = 5
var result: NetworkResult<RestoreMethod>? = null
Log.d(TAG, "Waiting for restore method with token: ***${restoreMethodToken.takeLast(4)}")
while (retries-- > 0 && result !is NetworkResult.Success && coroutineContext.isActive) {
Log.d(TAG, "Remaining tries $retries...")
val api = AppDependencies.registrationApi
result = api.waitForRestoreMethod(restoreMethodToken)
Log.d(TAG, "Result: $result")
}
if (result is NetworkResult.Success) {
Log.i(TAG, "Restore method selected on new device ${result.result}")
return result.result
} else {
Log.w(TAG, "Failed to determine restore method, using default")
return RestoreMethod.DECLINE
}
}
enum class TransferAccountResult {
SUCCESS,
FAILED

View file

@ -41,6 +41,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs
@ -52,8 +56,10 @@ import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
import org.thoughtcrime.securesms.BiometricDeviceLockContract
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity
import org.thoughtcrime.securesms.fonts.SignalSymbols
import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol
import org.thoughtcrime.securesms.keyvalue.SignalStore
@ -63,6 +69,7 @@ import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.viewModel
import org.whispersystems.signalservice.api.registration.RestoreMethod
/**
* Launched after scanning QR code from new device to start the transfer/reregistration process from
@ -121,6 +128,29 @@ class TransferAccountActivity : PassphraseRequiredActivity() {
promptInfo
)
lifecycleScope.launch {
val restoreMethodSelected = viewModel
.state
.mapNotNull { it.restoreMethodSelected }
.firstOrNull()
when (restoreMethodSelected) {
RestoreMethod.DEVICE_TRANSFER -> {
startActivities(
arrayOf(
MainActivity.clearTop(this@TransferAccountActivity),
Intent(this@TransferAccountActivity, OldDeviceTransferActivity::class.java)
)
)
}
RestoreMethod.REMOTE_BACKUP,
RestoreMethod.LOCAL_BACKUP,
RestoreMethod.DECLINE,
null -> startActivity(MainActivity.clearTop(this@TransferAccountActivity))
}
}
setContent {
val state by viewModel.state.collectAsState()
@ -128,7 +158,11 @@ class TransferAccountActivity : PassphraseRequiredActivity() {
TransferToNewDevice(
state = state,
onTransferAccount = this::authenticate,
clearReRegisterResult = viewModel::clearReRegisterResult,
onContinueOnOtherDeviceDismiss = {
finish()
viewModel.clearReRegisterResult()
},
onErrorDismiss = viewModel::clearReRegisterResult,
onBackClicked = { finish() }
)
}
@ -184,7 +218,8 @@ class TransferAccountActivity : PassphraseRequiredActivity() {
fun TransferToNewDevice(
state: TransferAccountViewModel.TransferAccountState,
onTransferAccount: () -> Unit = {},
clearReRegisterResult: () -> Unit = {},
onContinueOnOtherDeviceDismiss: () -> Unit = {},
onErrorDismiss: () -> Unit = {},
onBackClicked: () -> Unit = {}
) {
Scaffold(
@ -248,7 +283,7 @@ fun TransferToNewDevice(
QuickRegistrationRepository.TransferAccountResult.SUCCESS -> {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = clearReRegisterResult,
onDismissRequest = onContinueOnOtherDeviceDismiss,
sheetState = sheetState
) {
ContinueOnOtherDevice()
@ -256,12 +291,10 @@ fun TransferToNewDevice(
}
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
Dialogs.SimpleMessageDialog(
message = stringResource(R.string.RegistrationActivity_unable_to_connect_to_service),
dismiss = stringResource(android.R.string.ok),
onDismiss = onErrorDismiss
)
}

View file

@ -12,7 +12,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
import org.whispersystems.signalservice.api.registration.RestoreMethod
import java.util.UUID
class TransferAccountViewModel(reRegisterUri: String) : ViewModel() {
@ -22,9 +25,18 @@ class TransferAccountViewModel(reRegisterUri: String) : ViewModel() {
fun transferAccount() {
viewModelScope.launch(Dispatchers.IO) {
val restoreMethodToken = UUID.randomUUID().toString()
store.update { it.copy(inProgress = true) }
val result = QuickRegistrationRepository.transferAccount(store.value.reRegisterUri)
val result = QuickRegistrationRepository.transferAccount(store.value.reRegisterUri, restoreMethodToken)
store.update { it.copy(reRegisterResult = result, inProgress = false) }
val restoreMethod = QuickRegistrationRepository.waitForRestoreMethodSelectionOnNewDevice(restoreMethodToken)
if (restoreMethod != RestoreMethod.DECLINE) {
SignalStore.registration.restoringOnNewDevice = true
}
store.update { it.copy(restoreMethodSelected = restoreMethod) }
}
}
@ -35,6 +47,7 @@ class TransferAccountViewModel(reRegisterUri: String) : ViewModel() {
data class TransferAccountState(
val reRegisterUri: String,
val inProgress: Boolean = false,
val reRegisterResult: QuickRegistrationRepository.TransferAccountResult? = null
val reRegisterResult: QuickRegistrationRepository.TransferAccountResult? = null,
val restoreMethodSelected: RestoreMethod? = null
)
}

View file

@ -10,16 +10,22 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.logging.Log
import org.signal.registration.proto.RegistrationProvisionMessage
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.registration.ProvisioningSocket
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
import java.io.Closeable
class RestoreViaQrViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(RestoreViaQrViewModel::class)
}
private val store: MutableStateFlow<RestoreViaQrState> = MutableStateFlow(RestoreViaQrState())
val state: StateFlow<RestoreViaQrState> = store
@ -58,6 +64,7 @@ class RestoreViaQrViewModel : ViewModel() {
}
private fun start(): Closeable {
SignalStore.registration.restoreMethodToken = null
store.update { it.copy(qrState = QrState.Loading) }
return ProvisioningSocket.start(
@ -69,7 +76,10 @@ class RestoreViaQrViewModel : ViewModel() {
store.update { it.copy(qrState = QrState.Loaded(qrData = QrCodeData.forData(data = url, supportIconOverlay = false))) }
val result = socket.getRegistrationProvisioningMessage()
if (result is SecondaryProvisioningCipher.RegistrationProvisionResult.Success) {
Log.i(TAG, "Saving restore method token: ***${result.message.restoreMethodToken.takeLast(4)}")
SignalStore.registration.restoreMethodToken = result.message.restoreMethodToken
store.update { it.copy(isRegistering = true, provisioningMessage = result.message, qrState = QrState.Scanned) }
} else {
store.update { it.copy(showProvisioningError = true, qrState = QrState.Scanned) }

View file

@ -7,14 +7,18 @@ package org.thoughtcrime.securesms.restore.selection
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.registrationv3.ui.restore.RestoreMethod
import org.thoughtcrime.securesms.registrationv3.ui.restore.SelectRestoreMethodScreen
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.whispersystems.signalservice.api.registration.RestoreMethod as ApiRestoreMethod
/**
* Provide options to select restore/transfer operation and flow during quick registration.
@ -27,6 +31,11 @@ class SelectRestoreMethodFragment : ComposeFragment() {
onRestoreMethodClicked = this::startRestoreMethod,
onSkip = {
SignalStore.registration.markSkippedTransferOrRestore()
lifecycleScope.launch {
QuickRegistrationRepository.setRestoreMethodForOldDevice(ApiRestoreMethod.DECLINE)
}
startActivity(MainActivity.clearTop(requireContext()))
activity?.finish()
}
@ -34,6 +43,16 @@ class SelectRestoreMethodFragment : ComposeFragment() {
}
private fun startRestoreMethod(method: RestoreMethod) {
val apiRestoreMethod = when (method) {
RestoreMethod.FROM_SIGNAL_BACKUPS -> ApiRestoreMethod.REMOTE_BACKUP
RestoreMethod.FROM_LOCAL_BACKUP_V1, RestoreMethod.FROM_LOCAL_BACKUP_V2 -> ApiRestoreMethod.LOCAL_BACKUP
RestoreMethod.FROM_OLD_DEVICE -> ApiRestoreMethod.DEVICE_TRANSFER
}
lifecycleScope.launch {
QuickRegistrationRepository.setRestoreMethodForOldDevice(apiRestoreMethod)
}
when (method) {
RestoreMethod.FROM_SIGNAL_BACKUPS -> startActivity(Intent(requireContext(), RemoteRestoreActivity::class.java))
RestoreMethod.FROM_OLD_DEVICE -> findNavController().safeNavigate(SelectRestoreMethodFragmentDirections.goToDeviceTransfer())

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:fillColor="#FF000000"
android:pathData="M27.6 14.03c0.29-0.46 0.15-1.08-0.32-1.38-0.46-0.29-1.08-0.15-1.38 0.32l-7.75 12.3-4.12-5.14c-0.34-0.44-0.97-0.5-1.4-0.16-0.44 0.34-0.5 0.97-0.16 1.4l5 6.25c0.2 0.26 0.51 0.4 0.84 0.38 0.32-0.02 0.61-0.2 0.79-0.47l8.5-13.5Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M20 2C10.06 2 2 10.06 2 20s8.06 18 18 18 18-8.06 18-18S29.94 2 20 2ZM4 20c0-8.84 7.16-16 16-16s16 7.16 16 16-7.16 16-16 16S4 28.84 4 20Z"/>
</vector>

View file

@ -7929,5 +7929,17 @@
<!-- Old device Transfer account bottom sheet dialog body -->
<string name="TransferAccount_continue_on_your_other_device_details">Continue transferring your account on your other device.</string>
<!-- Restore Complete bottom sheet dialog title -->
<string name="RestoreCompleteBottomSheet_restore_complete">Restore complete</string>
<!-- Restore Complete bottom sheet dialog message -->
<string name="RestoreCompleteBottomSheet_restore_complete_message">Your Signal account and messages have started transferring to your other device. Signal is now inactive on this device.</string>
<!-- Restore Complete bottom sheet dialog title after device transfer -->
<string name="RestoreCompleteBottomSheet_transfer_complete">Transfer complete</string>
<!-- Restore Complete bottom sheet dialog message after device transfer -->
<string name="RestoreCompleteBottomSheet_transfer_complete_message">Your Signal account and messages have been transferred to your other device. Signal is now inactive on this device.</string>
<!-- Restore Complete bottom sheet dialog button text to dismiss sheet -->
<string name="RestoreCompleteBottomSheet_button">Okay</string>
<!-- EOF -->
</resources>

View file

@ -43,6 +43,7 @@ import org.whispersystems.signalservice.api.attachment.AttachmentApi
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations
import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.link.LinkDeviceApi
import org.whispersystems.signalservice.api.registration.RegistrationApi
import org.whispersystems.signalservice.api.services.CallLinksService
import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.services.ProfileService
@ -222,4 +223,8 @@ class MockApplicationDependencyProvider : AppDependencies.Provider {
override fun provideLinkDeviceApi(pushServiceSocket: PushServiceSocket): LinkDeviceApi {
return mockk()
}
override fun provideRegistrationApi(pushServiceSocket: PushServiceSocket): RegistrationApi {
return mockk()
}
}

View file

@ -161,4 +161,22 @@ class RegistrationApi(
pushServiceSocket.sendProvisioningMessage(deviceIdentifier, cipherText)
}
}
/**
* Set [RestoreMethod] enum on the server for use by the old device to update UX.
*/
fun setRestoreMethod(token: String, method: RestoreMethod): NetworkResult<Unit> {
return NetworkResult.fromFetch {
pushServiceSocket.setRestoreMethodChosen(token, RestoreMethodBody(method = method))
}
}
/**
* Wait for the [RestoreMethod] to be set on the server by the new device. This is a long polling operation.
*/
fun waitForRestoreMethod(token: String, timeout: Int = 30): NetworkResult<RestoreMethod> {
return NetworkResult.fromFetch {
pushServiceSocket.waitForRestoreMethodChosen(token, timeout).method ?: RestoreMethod.DECLINE
}
}
}

View file

@ -0,0 +1,16 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.registration
/**
* Restore method chosen by user on new device after performing a quick-restore.
*/
enum class RestoreMethod {
REMOTE_BACKUP,
LOCAL_BACKUP,
DEVICE_TRANSFER,
DECLINE
}

View file

@ -0,0 +1,16 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.signalservice.api.registration
import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonProperty
/**
* Request and response body used to communicate a quick restore method selection during registration.
*/
data class RestoreMethodBody @JsonCreator constructor(
@JsonProperty val method: RestoreMethod?
)

View file

@ -115,6 +115,7 @@ import org.whispersystems.signalservice.api.push.exceptions.UsernameIsNotAssocia
import org.whispersystems.signalservice.api.push.exceptions.UsernameIsNotReservedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
import org.whispersystems.signalservice.api.registration.RestoreMethodBody;
import org.whispersystems.signalservice.api.storage.StorageAuthResponse;
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
import org.whispersystems.signalservice.api.subscriptions.PayPalConfirmPaymentIntentResponse;
@ -255,6 +256,8 @@ public class PushServiceSocket {
private static final String DEVICE_PATH = "/v1/devices/%s";
private static final String WAIT_FOR_DEVICES_PATH = "/v1/devices/wait_for_linked_device/%s?timeout=%s";
private static final String TRANSFER_ARCHIVE_PATH = "/v1/devices/transfer_archive";
private static final String SET_RESTORE_METHOD_PATH = "/v1/devices/restore_account/%s";
private static final String WAIT_RESTORE_METHOD_PATH = "/v1/devices/restore_account/%s?timeout=%s";
private static final String MESSAGE_PATH = "/v1/messages/%s";
private static final String GROUP_MESSAGE_PATH = "/v1/messages/multi_recipient?ts=%s&online=%s&urgent=%s&story=%s";
@ -347,6 +350,7 @@ public class PushServiceSocket {
private static final Map<String, String> NO_HEADERS = Collections.emptyMap();
private static final ResponseCodeHandler NO_HANDLER = new EmptyResponseCodeHandler();
private static final ResponseCodeHandler UNOPINIONATED_HANDLER = new UnopinionatedResponseCodeHandler();
private static final ResponseCodeHandler LONG_POLL_HANDLER = new LongPollingResponseCodeHandler();
public static final long CDN2_RESUMABLE_LINK_LIFETIME_MILLIS = TimeUnit.DAYS.toMillis(7);
@ -687,23 +691,7 @@ public class PushServiceSocket {
* This is a long-polling endpoint that relies on the fact that our normal connection timeout is already 30s.
*/
public WaitForLinkedDeviceResponse waitForLinkedDevice(String token, int timeoutSeconds) throws IOException {
// Note: We consider 204 failure, since that means that we timed out before determining if a device was linked. Easier that way.
String response = makeServiceRequest(String.format(Locale.US, WAIT_FOR_DEVICES_PATH, token, timeoutSeconds), "GET", null, NO_HEADERS, (responseCode, body) -> {
if (responseCode == 204 || responseCode < 200 || responseCode > 299) {
String bodyString = null;
if (body != null) {
try {
bodyString = readBodyString(body);
} catch (MalformedResponseException e) {
Log.w(TAG, "Failed to read body string", e);
}
}
throw new NonSuccessfulResponseCodeException(responseCode, "Response: " + responseCode, bodyString);
}
}, SealedSenderAccess.NONE);
String response = makeServiceRequest(String.format(Locale.US, WAIT_FOR_DEVICES_PATH, token, timeoutSeconds), "GET", null, NO_HEADERS, LONG_POLL_HANDLER, SealedSenderAccess.NONE);
return JsonUtil.fromJsonResponse(response, WaitForLinkedDeviceResponse.class);
}
@ -712,6 +700,19 @@ public class PushServiceSocket {
makeServiceRequest(String.format(Locale.US, TRANSFER_ARCHIVE_PATH), "PUT", body, NO_HEADERS, UNOPINIONATED_HANDLER, SealedSenderAccess.NONE);
}
public void setRestoreMethodChosen(@Nonnull String token, @Nonnull RestoreMethodBody request) throws IOException {
String body = JsonUtil.toJson(request);
makeServiceRequest(String.format(Locale.US, SET_RESTORE_METHOD_PATH, urlEncode(token)), "PUT", body, NO_HEADERS, UNOPINIONATED_HANDLER, SealedSenderAccess.NONE);
}
/**
* This is a long-polling endpoint that relies on the fact that our normal connection timeout is already 30s.
*/
public @Nonnull RestoreMethodBody waitForRestoreMethodChosen(@Nonnull String token, int timeoutSeconds) throws IOException {
String response = makeServiceRequest(String.format(Locale.US, WAIT_RESTORE_METHOD_PATH, urlEncode(token), timeoutSeconds), "GET", null, NO_HEADERS, LONG_POLL_HANDLER, SealedSenderAccess.NONE);
return JsonUtil.fromJsonResponse(response, RestoreMethodBody.class);
}
public void removeDevice(long deviceId) throws IOException {
makeServiceRequest(String.format(DEVICE_PATH, String.valueOf(deviceId)), "DELETE", null);
}
@ -2818,6 +2819,28 @@ public class PushServiceSocket {
}
}
/**
* Like {@link UnopinionatedResponseCodeHandler} but also treats a 204 as a failure, since that means that the server intentionally
* timed out before a valid result for the long poll was returned. Easier that way.
*/
private static class LongPollingResponseCodeHandler implements ResponseCodeHandler {
@Override
public void handle(int responseCode, ResponseBody body) throws NonSuccessfulResponseCodeException, PushNetworkException {
if (responseCode == 204 || responseCode < 200 || responseCode > 299) {
String bodyString = null;
if (body != null) {
try {
bodyString = readBodyString(body);
} catch (MalformedResponseException e) {
Log.w(TAG, "Failed to read body string", e);
}
}
throw new NonSuccessfulResponseCodeException(responseCode, "Response: " + responseCode, bodyString);
}
}
}
public enum ClientSet { KeyBackup }
public CredentialResponse retrieveGroupsV2Credentials(long todaySeconds)

View file

@ -26,5 +26,6 @@ message RegistrationProvisionMessage {
Platform platform = 5;
uint64 backupTimestampMs = 6;
Tier tier = 7;
reserved 8; // iOSDeviceTransferMessage
string restoreMethodToken = 8;
reserved 9; // iOSDeviceTransferMessage
}