diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java index d243f86129..9f8a9565cb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.java @@ -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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/RestoreCompleteBottomSheetDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/RestoreCompleteBottomSheetDialog.kt new file mode 100644 index 0000000000..ff39f24d60 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/RestoreCompleteBottomSheetDialog.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversationlist + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import org.signal.core.ui.BottomSheets +import org.signal.core.ui.Buttons +import org.signal.core.ui.Previews +import org.signal.core.ui.SignalPreview +import org.signal.core.ui.horizontalGutters +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.BottomSheetUtil + +/** + * Bottom sheet dialog shown on an old device after the user has decided to transfer/restore to a new device. + */ +class RestoreCompleteBottomSheetDialog : ComposeBottomSheetDialogFragment() { + + companion object { + @JvmStatic + fun show(fragmentManager: FragmentManager) { + RestoreCompleteBottomSheetDialog().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } + + override val peekHeightPercentage: Float = 1f + + @Composable + override fun SheetContent() { + RestoreCompleteContent( + isAfterDeviceTransfer = SignalStore.misc.isOldDeviceTransferLocked, + onOkayClick = this::dismissAllowingStateLoss + ) + } +} + +@Composable +private fun RestoreCompleteContent( + isAfterDeviceTransfer: Boolean = false, + onOkayClick: () -> Unit = {} +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .horizontalGutters() + .padding(bottom = 54.dp) + ) { + BottomSheets.Handle() + + Spacer(modifier = Modifier.height(20.dp)) + + Icon( + painter = painterResource(R.drawable.symbol_check_circle_40), + tint = MaterialTheme.colorScheme.primary, + contentDescription = null, + modifier = Modifier + .size(64.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + val title = if (isAfterDeviceTransfer) R.string.RestoreCompleteBottomSheet_transfer_complete else R.string.RestoreCompleteBottomSheet_restore_complete + Text( + text = stringResource(id = title), + style = MaterialTheme.typography.titleLarge + ) + + Spacer(modifier = Modifier.height(8.dp)) + + val message = if (isAfterDeviceTransfer) R.string.RestoreCompleteBottomSheet_transfer_complete_message else R.string.RestoreCompleteBottomSheet_restore_complete_message + Text( + text = stringResource(id = message), + style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + ) + + Spacer(modifier = Modifier.height(54.dp)) + + Buttons.LargeTonal( + onClick = onOkayClick, + modifier = Modifier.widthIn(min = 220.dp) + ) { + Text(text = stringResource(R.string.RestoreCompleteBottomSheet_button)) + } + } +} + +@SignalPreview +@Composable +private fun RestoreCompleteContentPreview() { + Previews.Preview { + RestoreCompleteContent(isAfterDeviceTransfer = false) + } +} + +@SignalPreview +@Composable +private fun RestoreCompleteContentAfterDeviceTransferPreview() { + Previews.Preview { + RestoreCompleteContent(isAfterDeviceTransfer = true) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index 555c0d4eb9..fe17673554 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index f19149b833..b07c19cac7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt index 0c742e68b0..946b37b4d8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt @@ -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()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.java deleted file mode 100644 index c4c5098599..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.java +++ /dev/null @@ -1,35 +0,0 @@ -package org.thoughtcrime.securesms.devicetransfer.newdevice; - -import android.os.Bundle; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.navigation.Navigation; - -import org.greenrobot.eventbus.EventBus; -import org.signal.devicetransfer.TransferStatus; -import org.thoughtcrime.securesms.LoggingFragment; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.util.navigation.SafeNavigation; - -/** - * Shows instructions for new device to being transfer. - */ -public final class NewDeviceTransferInstructionsFragment extends LoggingFragment { - public NewDeviceTransferInstructionsFragment() { - super(R.layout.new_device_transfer_instructions_fragment); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - view.findViewById(R.id.new_device_transfer_instructions_fragment_continue) - .setOnClickListener(v -> SafeNavigation.safeNavigate(Navigation.findNavController(v), R.id.action_device_transfer_setup)); - } - - @Override - public void onResume() { - super.onResume(); - EventBus.getDefault().removeStickyEvent(TransferStatus.class); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.kt new file mode 100644 index 0000000000..9ec2cdca6c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferInstructionsFragment.kt @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms.devicetransfer.newdevice + +import android.os.Bundle +import android.view.View +import androidx.navigation.fragment.findNavController +import org.greenrobot.eventbus.EventBus +import org.signal.devicetransfer.TransferStatus +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.navigation.safeNavigate + +/** + * Shows instructions for new device to being transfer. + */ +class NewDeviceTransferInstructionsFragment : LoggingFragment(R.layout.new_device_transfer_instructions_fragment) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view + .findViewById(R.id.new_device_transfer_instructions_fragment_continue) + .setOnClickListener { findNavController().safeNavigate(R.id.action_device_transfer_setup) } + } + + override fun onResume() { + super.onResume() + EventBus.getDefault().removeStickyEvent(TransferStatus::class.java) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt index f2694c620e..f33ea9a642 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.kt @@ -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) 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 530e201dd0..903577ac4e 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 @@ -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."); 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 index fa562c14ea..b7a966bf94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/QuickRegistrationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/QuickRegistrationRepository.kt @@ -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? = null + while (retries-- > 0 && result !is NetworkResult.Success) { + Log.d(TAG, "Setting method, retries remaining: $retries") + result = AppDependencies.registrationApi.setRestoreMethod(restoreMethodToken, restoreMethod) + + if (result !is NetworkResult.Success) { + delay(1.seconds) + } + } + + if (result is NetworkResult.Success) { + Log.i(TAG, "Restore method set successfully") + SignalStore.registration.restoreMethodToken = null + } else { + Log.w(TAG, "Restore method set failed", result?.getCause()) + } + } + } + } + + /** + * Gets the restore method used by the new device to update UI with. This is a long polling operation. + */ + suspend fun waitForRestoreMethodSelectionOnNewDevice(restoreMethodToken: String): RestoreMethod { + var retries = 5 + var result: NetworkResult? = null + + Log.d(TAG, "Waiting for restore method with token: ***${restoreMethodToken.takeLast(4)}") + while (retries-- > 0 && result !is NetworkResult.Success && coroutineContext.isActive) { + Log.d(TAG, "Remaining tries $retries...") + val api = AppDependencies.registrationApi + result = api.waitForRestoreMethod(restoreMethodToken) + Log.d(TAG, "Result: $result") + } + + if (result is NetworkResult.Success) { + Log.i(TAG, "Restore method selected on new device ${result.result}") + return result.result + } else { + Log.w(TAG, "Failed to determine restore method, using default") + return RestoreMethod.DECLINE + } + } + enum class TransferAccountResult { SUCCESS, FAILED diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountActivity.kt index 72dcde1463..eb7a2c3362 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountActivity.kt @@ -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 ) } 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 index f9e518a7c2..ae3273b595 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountViewModel.kt @@ -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 ) } 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 index b600597591..5bdb7a01c0 100644 --- 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 @@ -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 = MutableStateFlow(RestoreViaQrState()) val state: StateFlow = 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) } 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 index 058116bce6..3e1fc2730d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt @@ -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()) diff --git a/app/src/main/res/drawable/symbol_check_circle_40.xml b/app/src/main/res/drawable/symbol_check_circle_40.xml new file mode 100644 index 0000000000..7996eebc07 --- /dev/null +++ b/app/src/main/res/drawable/symbol_check_circle_40.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2377a71efd..4cb6d13cb6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7929,5 +7929,17 @@ Continue transferring your account on your other device. + + Restore complete + + Your Signal account and messages have started transferring to your other device. Signal is now inactive on this device. + + Transfer complete + + Your Signal account and messages have been transferred to your other device. Signal is now inactive on this device. + + Okay + + diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index 510af479e4..e758a983e4 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -43,6 +43,7 @@ import org.whispersystems.signalservice.api.attachment.AttachmentApi import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations import org.whispersystems.signalservice.api.keys.KeysApi import org.whispersystems.signalservice.api.link.LinkDeviceApi +import org.whispersystems.signalservice.api.registration.RegistrationApi import org.whispersystems.signalservice.api.services.CallLinksService import org.whispersystems.signalservice.api.services.DonationsService import org.whispersystems.signalservice.api.services.ProfileService @@ -222,4 +223,8 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { override fun provideLinkDeviceApi(pushServiceSocket: PushServiceSocket): LinkDeviceApi { return mockk() } + + override fun provideRegistrationApi(pushServiceSocket: PushServiceSocket): RegistrationApi { + return mockk() + } } diff --git a/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 f65bbdf816..10656ee270 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt @@ -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 { + return NetworkResult.fromFetch { + pushServiceSocket.setRestoreMethodChosen(token, RestoreMethodBody(method = method)) + } + } + + /** + * Wait for the [RestoreMethod] to be set on the server by the new device. This is a long polling operation. + */ + fun waitForRestoreMethod(token: String, timeout: Int = 30): NetworkResult { + return NetworkResult.fromFetch { + pushServiceSocket.waitForRestoreMethodChosen(token, timeout).method ?: RestoreMethod.DECLINE + } + } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RestoreMethod.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RestoreMethod.kt new file mode 100644 index 0000000000..93dcbd7d61 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RestoreMethod.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.registration + +/** + * Restore method chosen by user on new device after performing a quick-restore. + */ +enum class RestoreMethod { + REMOTE_BACKUP, + LOCAL_BACKUP, + DEVICE_TRANSFER, + DECLINE +} diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RestoreMethodBody.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RestoreMethodBody.kt new file mode 100644 index 0000000000..46f69041d3 --- /dev/null +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RestoreMethodBody.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.signalservice.api.registration + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty + +/** + * Request and response body used to communicate a quick restore method selection during registration. + */ +data class RestoreMethodBody @JsonCreator constructor( + @JsonProperty val method: RestoreMethod? +) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index efb31448a6..fe1a11e1d3 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -115,6 +115,7 @@ import org.whispersystems.signalservice.api.push.exceptions.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 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) diff --git a/libsignal-service/src/main/protowire/RegistrationProvisioning.proto b/libsignal-service/src/main/protowire/RegistrationProvisioning.proto index 3efcb3ba47..df52e7b0d2 100644 --- a/libsignal-service/src/main/protowire/RegistrationProvisioning.proto +++ b/libsignal-service/src/main/protowire/RegistrationProvisioning.proto @@ -26,5 +26,6 @@ message RegistrationProvisionMessage { Platform platform = 5; uint64 backupTimestampMs = 6; Tier tier = 7; - reserved 8; // iOSDeviceTransferMessage + string restoreMethodToken = 8; + reserved 9; // iOSDeviceTransferMessage }