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.VoiceNoteMediaController;
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner; import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner;
import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment; import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment;
import org.thoughtcrime.securesms.conversationlist.RestoreCompleteBottomSheetDialog;
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity; import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity;
import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor; import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor;
@ -191,7 +192,16 @@ public class MainActivity extends PassphraseRequiredActivity implements VoiceNot
protected void onResume() { protected void onResume() {
super.onResume(); super.onResume();
dynamicTheme.onResume(this); 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) new MaterialAlertDialogBuilder(this)
.setTitle(R.string.OldDeviceTransferLockedDialog__complete_registration_on_your_new_device) .setTitle(R.string.OldDeviceTransferLockedDialog__complete_registration_on_your_new_device)
.setMessage(R.string.OldDeviceTransferLockedDialog__your_signal_account_has_been_transferred_to_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(); .show();
} }
if (SignalStore.misc().getShouldShowLinkedDevicesReminder()) {
SignalStore.misc().setShouldShowLinkedDevicesReminder(false);
RelinkDevicesReminderBottomSheetFragment.show(getSupportFragmentManager());
}
updateTabVisibility(); updateTabVisibility();
vitalsViewModel.checkSlowNotificationHeuristics(); 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.groupsv2.GroupsV2Operations
import org.whispersystems.signalservice.api.keys.KeysApi import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.link.LinkDeviceApi 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.CallLinksService
import org.whispersystems.signalservice.api.services.DonationsService import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.services.ProfileService import org.whispersystems.signalservice.api.services.ProfileService
@ -298,6 +299,10 @@ object AppDependencies {
val linkDeviceApi: LinkDeviceApi val linkDeviceApi: LinkDeviceApi
get() = networkModule.linkDeviceApi get() = networkModule.linkDeviceApi
@JvmStatic
val registrationApi: RegistrationApi
get() = networkModule.registrationApi
@JvmStatic @JvmStatic
val okHttpClient: OkHttpClient val okHttpClient: OkHttpClient
get() = networkModule.okHttpClient get() = networkModule.okHttpClient
@ -361,5 +366,6 @@ object AppDependencies {
fun provideKeysApi(pushServiceSocket: PushServiceSocket): KeysApi fun provideKeysApi(pushServiceSocket: PushServiceSocket): KeysApi
fun provideAttachmentApi(signalWebSocket: SignalWebSocket, pushServiceSocket: PushServiceSocket): AttachmentApi fun provideAttachmentApi(signalWebSocket: SignalWebSocket, pushServiceSocket: PushServiceSocket): AttachmentApi
fun provideLinkDeviceApi(pushServiceSocket: PushServiceSocket): LinkDeviceApi 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.link.LinkDeviceApi;
import org.whispersystems.signalservice.api.push.ServiceId.ACI; import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import org.whispersystems.signalservice.api.push.ServiceId.PNI; 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.CallLinksService;
import org.whispersystems.signalservice.api.services.DonationsService; import org.whispersystems.signalservice.api.services.DonationsService;
import org.whispersystems.signalservice.api.services.ProfileService; import org.whispersystems.signalservice.api.services.ProfileService;
@ -473,6 +474,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
return new LinkDeviceApi(pushServiceSocket); return new LinkDeviceApi(pushServiceSocket);
} }
@Override
public @NonNull RegistrationApi provideRegistrationApi(@NonNull PushServiceSocket pushServiceSocket) {
return new RegistrationApi(pushServiceSocket);
}
@VisibleForTesting @VisibleForTesting
static class DynamicCredentialsProvider implements CredentialsProvider { 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.keys.KeysApi
import org.whispersystems.signalservice.api.link.LinkDeviceApi import org.whispersystems.signalservice.api.link.LinkDeviceApi
import org.whispersystems.signalservice.api.push.TrustStore 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.CallLinksService
import org.whispersystems.signalservice.api.services.DonationsService import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.services.ProfileService import org.whispersystems.signalservice.api.services.ProfileService
@ -143,6 +144,10 @@ class NetworkDependenciesModule(
provider.provideLinkDeviceApi(pushServiceSocket) provider.provideLinkDeviceApi(pushServiceSocket)
} }
val registrationApi: RegistrationApi by lazy {
provider.provideRegistrationApi(pushServiceSocket)
}
val okHttpClient: OkHttpClient by lazy { val okHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder() OkHttpClient.Builder()
.addInterceptor(StandardUserAgentInterceptor()) .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 SKIPPED_TRANSFER_OR_RESTORE = "registration.has_skipped_transfer_or_restore"
private const val LOCAL_REGISTRATION_DATA = "registration.local_registration_data" private const val LOCAL_REGISTRATION_DATA = "registration.local_registration_data"
private const val RESTORE_COMPLETED = "registration.backup_restore_completed" 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 @Synchronized
@ -58,6 +60,10 @@ class RegistrationValues internal constructor(store: KeyValueStore) : SignalStor
var hasUploadedProfile: Boolean by booleanValue(HAS_UPLOADED_PROFILE, true) var hasUploadedProfile: Boolean by booleanValue(HAS_UPLOADED_PROFILE, true)
var sessionId: String? by stringValue(SESSION_ID, null) var sessionId: String? by stringValue(SESSION_ID, null)
var sessionE164: String? by stringValue(SESSION_E164, 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 { fun hasSkippedTransferOrRestore(): Boolean {
return getBoolean(SKIPPED_TRANSFER_OR_RESTORE, false) 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()); Log.i(TAG, "Marking registration completed.", new Throwable());
SignalStore.registration().markRegistrationComplete(); SignalStore.registration().markRegistrationComplete();
SignalStore.registration().setLocalRegistrationMetadata(null); SignalStore.registration().setLocalRegistrationMetadata(null);
SignalStore.registration().setRestoreMethodToken(null);
if (SignalStore.phoneNumberPrivacy().getPhoneNumberDiscoverabilityMode() == PhoneNumberDiscoverabilityMode.UNDECIDED) { if (SignalStore.phoneNumberPrivacy().getPhoneNumberDiscoverabilityMode() == PhoneNumberDiscoverabilityMode.UNDECIDED) {
Log.w(TAG, "Phone number discoverability mode is still UNDECIDED. Setting to DISCOVERABLE."); 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 package org.thoughtcrime.securesms.registrationv3.data
import android.net.Uri 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.Base64.decode
import org.signal.core.util.Hex import org.signal.core.util.Hex
import org.signal.core.util.isNotNullOrBlank 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.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.registration.RestoreMethod
import java.io.IOException 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. * 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. * Send registration provisioning message to new device.
*/ */
fun transferAccount(reRegisterUri: String): TransferAccountResult { fun transferAccount(reRegisterUri: String, restoreMethodToken: String): TransferAccountResult {
if (!isValidReRegistrationQr(reRegisterUri)) { if (!isValidReRegistrationQr(reRegisterUri)) {
Log.w(TAG, "Invalid quick re-register qr data") Log.w(TAG, "Invalid quick re-register qr data")
return TransferAccountResult.FAILED return TransferAccountResult.FAILED
@ -81,7 +89,8 @@ object QuickRegistrationRepository {
MessageBackupTier.PAID -> RegistrationProvisionMessage.Tier.PAID MessageBackupTier.PAID -> RegistrationProvisionMessage.Tier.PAID
MessageBackupTier.FREE, MessageBackupTier.FREE,
null -> RegistrationProvisionMessage.Tier.FREE null -> RegistrationProvisionMessage.Tier.FREE
} },
restoreMethodToken = restoreMethodToken
) )
) )
.successOrThrow() .successOrThrow()
@ -98,6 +107,60 @@ object QuickRegistrationRepository {
return TransferAccountResult.SUCCESS 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 { enum class TransferAccountResult {
SUCCESS, SUCCESS,
FAILED FAILED

View file

@ -41,6 +41,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp 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.BottomSheets
import org.signal.core.ui.Buttons import org.signal.core.ui.Buttons
import org.signal.core.ui.Dialogs 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.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BiometricDeviceAuthentication import org.thoughtcrime.securesms.BiometricDeviceAuthentication
import org.thoughtcrime.securesms.BiometricDeviceLockContract import org.thoughtcrime.securesms.BiometricDeviceLockContract
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.PassphraseRequiredActivity import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity
import org.thoughtcrime.securesms.fonts.SignalSymbols import org.thoughtcrime.securesms.fonts.SignalSymbols
import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol
import org.thoughtcrime.securesms.keyvalue.SignalStore 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.DynamicTheme
import org.thoughtcrime.securesms.util.SpanUtil import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.viewModel 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 * Launched after scanning QR code from new device to start the transfer/reregistration process from
@ -121,6 +128,29 @@ class TransferAccountActivity : PassphraseRequiredActivity() {
promptInfo 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 { setContent {
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
@ -128,7 +158,11 @@ class TransferAccountActivity : PassphraseRequiredActivity() {
TransferToNewDevice( TransferToNewDevice(
state = state, state = state,
onTransferAccount = this::authenticate, onTransferAccount = this::authenticate,
clearReRegisterResult = viewModel::clearReRegisterResult, onContinueOnOtherDeviceDismiss = {
finish()
viewModel.clearReRegisterResult()
},
onErrorDismiss = viewModel::clearReRegisterResult,
onBackClicked = { finish() } onBackClicked = { finish() }
) )
} }
@ -184,7 +218,8 @@ class TransferAccountActivity : PassphraseRequiredActivity() {
fun TransferToNewDevice( fun TransferToNewDevice(
state: TransferAccountViewModel.TransferAccountState, state: TransferAccountViewModel.TransferAccountState,
onTransferAccount: () -> Unit = {}, onTransferAccount: () -> Unit = {},
clearReRegisterResult: () -> Unit = {}, onContinueOnOtherDeviceDismiss: () -> Unit = {},
onErrorDismiss: () -> Unit = {},
onBackClicked: () -> Unit = {} onBackClicked: () -> Unit = {}
) { ) {
Scaffold( Scaffold(
@ -248,7 +283,7 @@ fun TransferToNewDevice(
QuickRegistrationRepository.TransferAccountResult.SUCCESS -> { QuickRegistrationRepository.TransferAccountResult.SUCCESS -> {
ModalBottomSheet( ModalBottomSheet(
dragHandle = null, dragHandle = null,
onDismissRequest = clearReRegisterResult, onDismissRequest = onContinueOnOtherDeviceDismiss,
sheetState = sheetState sheetState = sheetState
) { ) {
ContinueOnOtherDevice() ContinueOnOtherDevice()
@ -256,12 +291,10 @@ fun TransferToNewDevice(
} }
QuickRegistrationRepository.TransferAccountResult.FAILED -> { QuickRegistrationRepository.TransferAccountResult.FAILED -> {
Dialogs.SimpleAlertDialog( Dialogs.SimpleMessageDialog(
title = Dialogs.NoTitle, message = stringResource(R.string.RegistrationActivity_unable_to_connect_to_service),
body = stringResource(R.string.RegistrationActivity_unable_to_connect_to_service), dismiss = stringResource(android.R.string.ok),
confirm = stringResource(android.R.string.ok), onDismiss = onErrorDismiss
onConfirm = clearReRegisterResult,
onDismiss = clearReRegisterResult
) )
} }

View file

@ -12,7 +12,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
import org.whispersystems.signalservice.api.registration.RestoreMethod
import java.util.UUID
class TransferAccountViewModel(reRegisterUri: String) : ViewModel() { class TransferAccountViewModel(reRegisterUri: String) : ViewModel() {
@ -22,9 +25,18 @@ class TransferAccountViewModel(reRegisterUri: String) : ViewModel() {
fun transferAccount() { fun transferAccount() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val restoreMethodToken = UUID.randomUUID().toString()
store.update { it.copy(inProgress = true) } 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) } 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( data class TransferAccountState(
val reRegisterUri: String, val reRegisterUri: String,
val inProgress: Boolean = false, 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import org.signal.core.util.logging.Log
import org.signal.registration.proto.RegistrationProvisionMessage import org.signal.registration.proto.RegistrationProvisionMessage
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.whispersystems.signalservice.api.registration.ProvisioningSocket import org.whispersystems.signalservice.api.registration.ProvisioningSocket
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
import java.io.Closeable import java.io.Closeable
class RestoreViaQrViewModel : ViewModel() { class RestoreViaQrViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(RestoreViaQrViewModel::class)
}
private val store: MutableStateFlow<RestoreViaQrState> = MutableStateFlow(RestoreViaQrState()) private val store: MutableStateFlow<RestoreViaQrState> = MutableStateFlow(RestoreViaQrState())
val state: StateFlow<RestoreViaQrState> = store val state: StateFlow<RestoreViaQrState> = store
@ -58,6 +64,7 @@ class RestoreViaQrViewModel : ViewModel() {
} }
private fun start(): Closeable { private fun start(): Closeable {
SignalStore.registration.restoreMethodToken = null
store.update { it.copy(qrState = QrState.Loading) } store.update { it.copy(qrState = QrState.Loading) }
return ProvisioningSocket.start( return ProvisioningSocket.start(
@ -69,7 +76,10 @@ class RestoreViaQrViewModel : ViewModel() {
store.update { it.copy(qrState = QrState.Loaded(qrData = QrCodeData.forData(data = url, supportIconOverlay = false))) } store.update { it.copy(qrState = QrState.Loaded(qrData = QrCodeData.forData(data = url, supportIconOverlay = false))) }
val result = socket.getRegistrationProvisioningMessage() val result = socket.getRegistrationProvisioningMessage()
if (result is SecondaryProvisioningCipher.RegistrationProvisionResult.Success) { 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) } store.update { it.copy(isRegistering = true, provisioningMessage = result.message, qrState = QrState.Scanned) }
} else { } else {
store.update { it.copy(showProvisioningError = true, qrState = QrState.Scanned) } 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 android.content.Intent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.MainActivity import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.compose.ComposeFragment import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.keyvalue.SignalStore 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.RemoteRestoreActivity
import org.thoughtcrime.securesms.registrationv3.ui.restore.RestoreMethod import org.thoughtcrime.securesms.registrationv3.ui.restore.RestoreMethod
import org.thoughtcrime.securesms.registrationv3.ui.restore.SelectRestoreMethodScreen import org.thoughtcrime.securesms.registrationv3.ui.restore.SelectRestoreMethodScreen
import org.thoughtcrime.securesms.util.navigation.safeNavigate 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. * Provide options to select restore/transfer operation and flow during quick registration.
@ -27,6 +31,11 @@ class SelectRestoreMethodFragment : ComposeFragment() {
onRestoreMethodClicked = this::startRestoreMethod, onRestoreMethodClicked = this::startRestoreMethod,
onSkip = { onSkip = {
SignalStore.registration.markSkippedTransferOrRestore() SignalStore.registration.markSkippedTransferOrRestore()
lifecycleScope.launch {
QuickRegistrationRepository.setRestoreMethodForOldDevice(ApiRestoreMethod.DECLINE)
}
startActivity(MainActivity.clearTop(requireContext())) startActivity(MainActivity.clearTop(requireContext()))
activity?.finish() activity?.finish()
} }
@ -34,6 +43,16 @@ class SelectRestoreMethodFragment : ComposeFragment() {
} }
private fun startRestoreMethod(method: RestoreMethod) { 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) { when (method) {
RestoreMethod.FROM_SIGNAL_BACKUPS -> startActivity(Intent(requireContext(), RemoteRestoreActivity::class.java)) RestoreMethod.FROM_SIGNAL_BACKUPS -> startActivity(Intent(requireContext(), RemoteRestoreActivity::class.java))
RestoreMethod.FROM_OLD_DEVICE -> findNavController().safeNavigate(SelectRestoreMethodFragmentDirections.goToDeviceTransfer()) 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 --> <!-- 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> <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 --> <!-- EOF -->
</resources> </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.groupsv2.GroupsV2Operations
import org.whispersystems.signalservice.api.keys.KeysApi import org.whispersystems.signalservice.api.keys.KeysApi
import org.whispersystems.signalservice.api.link.LinkDeviceApi 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.CallLinksService
import org.whispersystems.signalservice.api.services.DonationsService import org.whispersystems.signalservice.api.services.DonationsService
import org.whispersystems.signalservice.api.services.ProfileService import org.whispersystems.signalservice.api.services.ProfileService
@ -222,4 +223,8 @@ class MockApplicationDependencyProvider : AppDependencies.Provider {
override fun provideLinkDeviceApi(pushServiceSocket: PushServiceSocket): LinkDeviceApi { override fun provideLinkDeviceApi(pushServiceSocket: PushServiceSocket): LinkDeviceApi {
return mockk() return mockk()
} }
override fun provideRegistrationApi(pushServiceSocket: PushServiceSocket): RegistrationApi {
return mockk()
}
} }

View file

@ -161,4 +161,22 @@ class RegistrationApi(
pushServiceSocket.sendProvisioningMessage(deviceIdentifier, cipherText) 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.UsernameIsNotReservedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException; import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException; 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.storage.StorageAuthResponse;
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription; import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription;
import org.whispersystems.signalservice.api.subscriptions.PayPalConfirmPaymentIntentResponse; 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 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 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 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 MESSAGE_PATH = "/v1/messages/%s";
private static final String GROUP_MESSAGE_PATH = "/v1/messages/multi_recipient?ts=%s&online=%s&urgent=%s&story=%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 Map<String, String> NO_HEADERS = Collections.emptyMap();
private static final ResponseCodeHandler NO_HANDLER = new EmptyResponseCodeHandler(); private static final ResponseCodeHandler NO_HANDLER = new EmptyResponseCodeHandler();
private static final ResponseCodeHandler UNOPINIONATED_HANDLER = new UnopinionatedResponseCodeHandler(); 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); 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. * 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 { 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, LONG_POLL_HANDLER, SealedSenderAccess.NONE);
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);
return JsonUtil.fromJsonResponse(response, WaitForLinkedDeviceResponse.class); 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); 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 { public void removeDevice(long deviceId) throws IOException {
makeServiceRequest(String.format(DEVICE_PATH, String.valueOf(deviceId)), "DELETE", null); 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 enum ClientSet { KeyBackup }
public CredentialResponse retrieveGroupsV2Credentials(long todaySeconds) public CredentialResponse retrieveGroupsV2Credentials(long todaySeconds)

View file

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