> {
return Single.fromCallable {
- url.map { QrCodeData.forData(it, 64) }
+ url.map { QrCodeData.forData(it) }
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt
index 8122d57235..a988d1bb32 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameLinkShareScreen.kt
@@ -334,7 +334,7 @@ private fun previewState(): UsernameLinkSettingsState {
activeTab = ActiveTab.Code,
username = "parker.42",
usernameLinkState = UsernameLinkState.Present("https://signal.me/#eu/asdfasdfasdfasdfasdfasdfasdfasdfasdfasdf"),
- qrCodeState = QrCodeState.Present(QrCodeData.forData(link, 64)),
+ qrCodeState = QrCodeState.Present(QrCodeData.forData(link)),
qrCodeColorScheme = UsernameQrCodeColorScheme.Blue
)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferFragment.java
deleted file mode 100644
index e165e36fb0..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/DeviceTransferFragment.java
+++ /dev/null
@@ -1,170 +0,0 @@
-package org.thoughtcrime.securesms.devicetransfer;
-
-import android.os.Bundle;
-import android.view.View;
-import android.widget.Button;
-import android.widget.TextView;
-
-import androidx.activity.OnBackPressedCallback;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.StringRes;
-
-import com.google.android.material.dialog.MaterialAlertDialogBuilder;
-
-import org.greenrobot.eventbus.EventBus;
-import org.greenrobot.eventbus.Subscribe;
-import org.greenrobot.eventbus.ThreadMode;
-import org.signal.devicetransfer.DeviceToDeviceTransferService;
-import org.signal.devicetransfer.TransferStatus;
-import org.thoughtcrime.securesms.LoggingFragment;
-import org.thoughtcrime.securesms.R;
-
-/**
- * Drives the UI for the actual device transfer progress. Shown after setup is complete
- * and the two devices are transferring.
- *
- * Handles show progress and error state.
- */
-public abstract class DeviceTransferFragment extends LoggingFragment {
-
- private static final String TRANSFER_FINISHED_KEY = "transfer_finished";
-
- private final OnBackPressed onBackPressed = new OnBackPressed();
- private final TransferModeListener transferModeListener = new TransferModeListener();
-
- protected TextView title;
- protected View tryAgain;
- protected Button cancel;
- protected View progress;
- protected View alert;
- protected TextView status;
- protected boolean transferFinished;
-
- public DeviceTransferFragment() {
- super(R.layout.fragment_device_transfer);
- }
-
- @Override
- public void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- if (savedInstanceState != null) {
- transferFinished = savedInstanceState.getBoolean(TRANSFER_FINISHED_KEY);
- }
- }
-
- @Override
- public void onStart() {
- super.onStart();
- if (transferFinished) {
- navigateToTransferComplete();
- }
- }
-
- @Override
- public void onSaveInstanceState(@NonNull Bundle outState) {
- super.onSaveInstanceState(outState);
- outState.putBoolean(TRANSFER_FINISHED_KEY, transferFinished);
- }
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- title = view.findViewById(R.id.device_transfer_fragment_title);
- tryAgain = view.findViewById(R.id.device_transfer_fragment_try_again);
- cancel = view.findViewById(R.id.device_transfer_fragment_cancel);
- progress = view.findViewById(R.id.device_transfer_fragment_progress);
- alert = view.findViewById(R.id.device_transfer_fragment_alert);
- status = view.findViewById(R.id.device_transfer_fragment_status);
-
- cancel.setOnClickListener(v -> cancelActiveTransfer());
- tryAgain.setOnClickListener(v -> {
- EventBus.getDefault().unregister(transferModeListener);
- EventBus.getDefault().removeStickyEvent(TransferStatus.class);
- navigateToRestartTransfer();
- });
-
- EventBus.getDefault().register(transferModeListener);
- }
-
- @Override
- public void onActivityCreated(@Nullable Bundle savedInstanceState) {
- super.onActivityCreated(savedInstanceState);
- requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), onBackPressed);
- }
-
- @Override
- public void onDestroyView() {
- EventBus.getDefault().unregister(transferModeListener);
- super.onDestroyView();
- }
-
- private void cancelActiveTransfer() {
- new MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.DeviceTransfer__stop_transfer)
- .setMessage(R.string.DeviceTransfer__all_transfer_progress_will_be_lost)
- .setPositiveButton(R.string.DeviceTransfer__stop_transfer, (d, w) -> {
- EventBus.getDefault().unregister(transferModeListener);
- DeviceToDeviceTransferService.stop(requireContext());
- EventBus.getDefault().removeStickyEvent(TransferStatus.class);
- navigateAwayFromTransfer();
- })
- .setNegativeButton(android.R.string.cancel, null)
- .show();
- }
-
- protected void ignoreTransferStatusEvents() {
- EventBus.getDefault().unregister(transferModeListener);
- }
-
- protected abstract void navigateToRestartTransfer();
-
- protected abstract void navigateAwayFromTransfer();
-
- protected abstract void navigateToTransferComplete();
-
- private class TransferModeListener {
- @Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
- public void onEventMainThread(@NonNull TransferStatus event) {
- if (event.getTransferMode() != TransferStatus.TransferMode.SERVICE_CONNECTED) {
- abort();
- }
- }
- }
-
- protected void abort() {
- abort(R.string.DeviceTransfer__transfer_failed);
- }
-
- protected void abort(@StringRes int errorMessage) {
- EventBus.getDefault().unregister(transferModeListener);
- DeviceToDeviceTransferService.stop(requireContext());
-
- progress.setVisibility(View.GONE);
- alert.setVisibility(View.VISIBLE);
- tryAgain.setVisibility(View.VISIBLE);
-
- title.setText(R.string.DeviceTransfer__unable_to_transfer);
- status.setText(errorMessage);
- cancel.setText(R.string.DeviceTransfer__cancel);
- cancel.setOnClickListener(v -> navigateAwayFromTransfer());
-
- onBackPressed.isActiveTransfer = false;
- }
-
- protected class OnBackPressed extends OnBackPressedCallback {
-
- private boolean isActiveTransfer = true;
-
- public OnBackPressed() {
- super(true);
- }
-
- @Override
- public void handleOnBackPressed() {
- if (isActiveTransfer) {
- cancelActiveTransfer();
- } else {
- navigateAwayFromTransfer();
- }
- }
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsMode.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsMode.kt
deleted file mode 100644
index 4619dd41b7..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsMode.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-/*
- * Copyright 2024 Signal Messenger, LLC
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-package org.thoughtcrime.securesms.devicetransfer.moreoptions
-
-/**
- * Allows component opening sheet to specify mode
- */
-enum class MoreTransferOrRestoreOptionsMode {
- /**
- * Only display the option to log in without transferring. Selection
- * will be disabled.
- */
- SKIP_ONLY,
-
- /**
- * Display transfer/restore local/skip as well as a next and cancel button
- */
- SELECTION
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsSheet.kt
deleted file mode 100644
index db26bd0644..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/moreoptions/MoreTransferOrRestoreOptionsSheet.kt
+++ /dev/null
@@ -1,339 +0,0 @@
-/*
- * Copyright 2024 Signal Messenger, LLC
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-package org.thoughtcrime.securesms.devicetransfer.moreoptions
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.dimensionResource
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.navigation.fragment.findNavController
-import androidx.navigation.fragment.navArgs
-import org.signal.core.ui.BottomSheets
-import org.signal.core.ui.Buttons
-import org.signal.core.ui.Previews
-import org.thoughtcrime.securesms.R
-import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
-import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType
-import org.signal.core.ui.R as CoreUiR
-
-/**
- * Lists a set of options the user can choose from for restoring backup or skipping restoration
- */
-class MoreTransferOrRestoreOptionsSheet : ComposeBottomSheetDialogFragment() {
-
- private val args by navArgs()
-
- @Composable
- override fun SheetContent() {
- var selectedOption by remember {
- mutableStateOf(null)
- }
-
- MoreOptionsSheetContent(
- mode = args.mode,
- selectedOption = selectedOption,
- onOptionSelected = { selectedOption = it },
- onCancelClick = { findNavController().popBackStack() },
- onNextClick = {
- this.onNextClicked(selectedOption ?: BackupRestorationType.NONE)
- }
- )
- }
-
- private fun onNextClicked(selectedOption: BackupRestorationType) {
- // TODO [message-requests] -- Launch next screen based off user choice
- }
-}
-
-@Preview
-@Composable
-private fun MoreOptionsSheetContentPreview() {
- Previews.BottomSheetPreview {
- MoreOptionsSheetContent(
- mode = MoreTransferOrRestoreOptionsMode.SKIP_ONLY,
- selectedOption = null,
- onOptionSelected = {},
- onCancelClick = {},
- onNextClick = {}
- )
- }
-}
-
-@Preview
-@Composable
-private fun MoreOptionsSheetSelectableContentPreview() {
- Previews.BottomSheetPreview {
- MoreOptionsSheetContent(
- mode = MoreTransferOrRestoreOptionsMode.SELECTION,
- selectedOption = null,
- onOptionSelected = {},
- onCancelClick = {},
- onNextClick = {}
- )
- }
-}
-
-@Composable
-private fun MoreOptionsSheetContent(
- mode: MoreTransferOrRestoreOptionsMode,
- selectedOption: BackupRestorationType?,
- onOptionSelected: (BackupRestorationType) -> Unit,
- onCancelClick: () -> Unit,
- onNextClick: () -> Unit
-) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
- ) {
- BottomSheets.Handle()
-
- Spacer(modifier = Modifier.size(42.dp))
-
- if (mode == MoreTransferOrRestoreOptionsMode.SELECTION) {
- TransferFromAndroidDeviceOption(
- selectedOption = selectedOption,
- onOptionSelected = onOptionSelected
- )
- Spacer(modifier = Modifier.size(16.dp))
- RestoreLocalBackupOption(
- selectedOption = selectedOption,
- onOptionSelected = onOptionSelected
- )
- Spacer(modifier = Modifier.size(16.dp))
- }
-
- LogInWithoutTransferringOption(
- selectedOption = selectedOption,
- onOptionSelected = when (mode) {
- MoreTransferOrRestoreOptionsMode.SKIP_ONLY -> { _ -> onNextClick() }
- MoreTransferOrRestoreOptionsMode.SELECTION -> onOptionSelected
- }
- )
-
- if (mode == MoreTransferOrRestoreOptionsMode.SELECTION) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 30.dp, bottom = 24.dp)
- ) {
- TextButton(
- onClick = onCancelClick
- ) {
- Text(text = stringResource(id = android.R.string.cancel))
- }
-
- Spacer(modifier = Modifier.weight(1f))
-
- Buttons.LargeTonal(
- enabled = selectedOption != null,
- onClick = onNextClick
- ) {
- Text(text = stringResource(id = R.string.RegistrationActivity_next))
- }
- }
- } else {
- Spacer(modifier = Modifier.size(45.dp))
- }
- }
-}
-
-@Preview
-@Composable
-private fun LogInWithoutTransferringOptionPreview() {
- Previews.BottomSheetPreview {
- LogInWithoutTransferringOption(
- selectedOption = null,
- onOptionSelected = {}
- )
- }
-}
-
-@Composable
-private fun LogInWithoutTransferringOption(
- selectedOption: BackupRestorationType?,
- onOptionSelected: (BackupRestorationType) -> Unit
-) {
- Option(
- icon = {
- Box(
- modifier = Modifier.padding(horizontal = 18.dp)
- ) {
- Icon(
- painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [backups] Finalized asset.
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(36.dp)
- )
- }
- },
- isSelected = selectedOption == BackupRestorationType.NONE,
- title = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__log_in_without_transferring),
- subtitle = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__continue_without_transferring),
- onClick = { onOptionSelected(BackupRestorationType.NONE) }
- )
-}
-
-@Preview
-@Composable
-private fun TransferFromAndroidDeviceOptionPreview() {
- Previews.BottomSheetPreview {
- TransferFromAndroidDeviceOption(
- selectedOption = null,
- onOptionSelected = {}
- )
- }
-}
-
-@Composable
-private fun TransferFromAndroidDeviceOption(
- selectedOption: BackupRestorationType?,
- onOptionSelected: (BackupRestorationType) -> Unit
-) {
- Option(
- icon = {
- Box(
- modifier = Modifier.padding(horizontal = 18.dp)
- ) {
- Icon(
- painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [backups] Finalized asset.
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(36.dp)
- )
- }
- },
- isSelected = selectedOption == BackupRestorationType.DEVICE_TRANSFER,
- title = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__transfer_from_android_device),
- subtitle = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__transfer_your_account_and_messages),
- onClick = { onOptionSelected(BackupRestorationType.DEVICE_TRANSFER) }
- )
-}
-
-@Preview
-@Composable
-private fun RestoreLocalBackupOptionPreview() {
- Previews.BottomSheetPreview {
- RestoreLocalBackupOption(
- selectedOption = null,
- onOptionSelected = {}
- )
- }
-}
-
-@Composable
-private fun RestoreLocalBackupOption(
- selectedOption: BackupRestorationType?,
- onOptionSelected: (BackupRestorationType) -> Unit
-) {
- Option(
- icon = {
- Box(
- modifier = Modifier.padding(horizontal = 18.dp)
- ) {
- Icon(
- painter = painterResource(id = R.drawable.symbol_backup_light), // TODO [backups] Finalized asset.
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(36.dp)
- )
- }
- },
- isSelected = selectedOption == BackupRestorationType.LOCAL_BACKUP,
- title = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__restore_local_backup),
- subtitle = stringResource(id = R.string.MoreTransferOrRestoreOptionsSheet__restore_your_messages),
- onClick = { onOptionSelected(BackupRestorationType.LOCAL_BACKUP) }
- )
-}
-
-@Preview
-@Composable
-private fun OptionPreview() {
- Previews.BottomSheetPreview {
- Option(
- icon = {
- Box(
- modifier = Modifier.padding(horizontal = 18.dp)
- ) {
- Icon(
- painter = painterResource(id = R.drawable.symbol_backup_light),
- contentDescription = null,
- tint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.size(36.dp)
- )
- }
- },
- isSelected = false,
- title = "Option Preview Title",
- subtitle = "Option Preview Subtitle",
- onClick = {}
- )
- }
-}
-
-@Composable
-private fun Option(
- icon: @Composable () -> Unit,
- isSelected: Boolean,
- title: String,
- subtitle: String,
- onClick: () -> Unit
-) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .background(
- color = MaterialTheme.colorScheme.surface,
- shape = RoundedCornerShape(12.dp)
- )
- .border(
- width = if (isSelected) 2.dp else 0.dp,
- color = if (isSelected) MaterialTheme.colorScheme.primary else Color.Transparent
- )
- .clip(RoundedCornerShape(12.dp))
- .clickable { onClick() }
- .padding(vertical = 21.dp)
- ) {
- icon()
- Column {
- Text(
- text = title,
- style = MaterialTheme.typography.bodyLarge
- )
- Text(
- text = subtitle,
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant
- )
- }
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java
index 80d8f9dcae..e1affd13c6 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java
@@ -73,6 +73,8 @@ final class NewDeviceServerTask implements ServerTask {
long end = System.currentTimeMillis();
Log.i(TAG, "Receive took: " + (end - start));
+
+ EventBus.getDefault().post(new Status(0, Status.State.RESTORE_COMPLETE));
}
@Subscribe(threadMode = ThreadMode.POSTING)
@@ -80,7 +82,7 @@ final class NewDeviceServerTask implements ServerTask {
if (event.getType() == BackupEvent.Type.PROGRESS) {
EventBus.getDefault().post(new Status(event.getCount(), Status.State.IN_PROGRESS));
} else if (event.getType() == BackupEvent.Type.FINISHED) {
- EventBus.getDefault().post(new Status(event.getCount(), Status.State.SUCCESS));
+ EventBus.getDefault().post(new Status(event.getCount(), Status.State.TRANSFER_COMPLETE));
}
}
@@ -103,7 +105,8 @@ final class NewDeviceServerTask implements ServerTask {
public enum State {
IN_PROGRESS,
- SUCCESS,
+ TRANSFER_COMPLETE,
+ RESTORE_COMPLETE,
FAILURE_VERSION_DOWNGRADE,
FAILURE_FOREIGN_KEY,
FAILURE_UNKNOWN
diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferCompleteFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferCompleteFragment.java
index 013cd93461..c6f0d093b5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferCompleteFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferCompleteFragment.java
@@ -6,11 +6,10 @@ import android.view.View;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import androidx.navigation.fragment.NavHostFragment;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
-import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
+import org.thoughtcrime.securesms.restore.RestoreActivity;
/**
* Shown after the new device successfully completes receiving a backup from the old device.
@@ -23,8 +22,7 @@ public final class NewDeviceTransferCompleteFragment extends LoggingFragment {
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
view.findViewById(R.id.new_device_transfer_complete_fragment_continue_registration)
- .setOnClickListener(v -> SafeNavigation.safeNavigate(NavHostFragment.findNavController(this),
- R.id.action_newDeviceTransferComplete_to_enterPhoneNumberFragment));
+ .setOnClickListener(v -> ((RestoreActivity) requireActivity()).onBackupCompletedSuccessfully());
}
@Override
diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.java
deleted file mode 100644
index 6feb785390..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.java
+++ /dev/null
@@ -1,80 +0,0 @@
-package org.thoughtcrime.securesms.devicetransfer.newdevice;
-
-import android.os.Bundle;
-import android.view.View;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.navigation.fragment.NavHostFragment;
-
-import org.greenrobot.eventbus.EventBus;
-import org.greenrobot.eventbus.Subscribe;
-import org.greenrobot.eventbus.ThreadMode;
-import org.signal.devicetransfer.DeviceToDeviceTransferService;
-import org.thoughtcrime.securesms.R;
-import org.thoughtcrime.securesms.devicetransfer.DeviceTransferFragment;
-import org.thoughtcrime.securesms.keyvalue.SignalStore;
-import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
-
-/**
- * Shows transfer progress on the new device. Most logic is in {@link DeviceTransferFragment}
- * and it delegates to this class for strings, navigation, and updating progress.
- */
-public final class NewDeviceTransferFragment extends DeviceTransferFragment {
-
- private final ServerTaskListener serverTaskListener = new ServerTaskListener();
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- super.onViewCreated(view, savedInstanceState);
- EventBus.getDefault().register(serverTaskListener);
- }
-
- @Override
- public void onDestroyView() {
- EventBus.getDefault().unregister(serverTaskListener);
- super.onDestroyView();
- }
-
- @Override
- protected void navigateToRestartTransfer() {
- SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferInstructions());
- }
-
- @Override
- protected void navigateAwayFromTransfer() {
- EventBus.getDefault().unregister(serverTaskListener);
- requireActivity().finish();
- }
-
- @Override
- protected void navigateToTransferComplete() {
- SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferComplete());
- }
-
- private class ServerTaskListener {
- @Subscribe(threadMode = ThreadMode.MAIN)
- public void onEventMainThread(@NonNull NewDeviceServerTask.Status event) {
- status.setText(getString(R.string.DeviceTransfer__d_messages_so_far, event.getMessageCount()));
- switch (event.getState()) {
- case IN_PROGRESS:
- break;
- case SUCCESS:
- transferFinished = true;
- DeviceToDeviceTransferService.stop(requireContext());
- SignalStore.registration().markRestoreCompleted();
- navigateToTransferComplete();
- break;
- case FAILURE_VERSION_DOWNGRADE:
- abort(R.string.NewDeviceTransfer__cannot_transfer_from_a_newer_version_of_signal);
- break;
- case FAILURE_FOREIGN_KEY:
- abort(R.string.NewDeviceTransfer__failure_foreign_key);
- break;
- case FAILURE_UNKNOWN:
- abort();
- break;
- }
- }
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.kt
new file mode 100644
index 0000000000..95a8ee4ff3
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferFragment.kt
@@ -0,0 +1,79 @@
+package org.thoughtcrime.securesms.devicetransfer.newdevice
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import org.greenrobot.eventbus.EventBus
+import org.greenrobot.eventbus.Subscribe
+import org.greenrobot.eventbus.ThreadMode
+import org.signal.devicetransfer.DeviceToDeviceTransferService
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.restore.RestoreActivity
+import org.thoughtcrime.securesms.restore.devicetransfer.DeviceTransferFragment
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+
+/**
+ * Shows transfer progress on the new device. Most logic is in [DeviceTransferFragment]
+ * and it delegates to this class for strings, navigation, and updating progress.
+ */
+class NewDeviceTransferFragment : DeviceTransferFragment() {
+
+ private val viewModel: NewDeviceTransferViewModel by viewModels()
+ private val serverTaskListener = ServerTaskListener()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ EventBus.getDefault().register(serverTaskListener)
+ }
+
+ override fun onDestroyView() {
+ EventBus.getDefault().unregister(serverTaskListener)
+ super.onDestroyView()
+ }
+
+ override fun navigateToRestartTransfer() {
+ findNavController().safeNavigate(NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferInstructions())
+ }
+
+ override fun navigateAwayFromTransfer() {
+ EventBus.getDefault().unregister(serverTaskListener)
+ requireActivity().finish()
+ }
+
+ override fun navigateToTransferComplete() {
+ if (SignalStore.account.isRegistered) {
+ (requireActivity() as RestoreActivity).onBackupCompletedSuccessfully()
+ } else {
+ findNavController().safeNavigate(NewDeviceTransferFragmentDirections.actionNewDeviceTransferToNewDeviceTransferComplete())
+ }
+ }
+
+ private fun onRestoreComplete() {
+ ignoreTransferStatusEvents()
+ DeviceToDeviceTransferService.stop(requireContext())
+
+ viewModel.onRestoreComplete(requireContext()) {
+ transferFinished = true
+ navigateToTransferComplete()
+ }
+ }
+
+ private inner class ServerTaskListener {
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ fun onEventMainThread(event: NewDeviceServerTask.Status) {
+ status.text = getString(R.string.DeviceTransfer__d_messages_so_far, event.messageCount)
+
+ when (event.state) {
+ NewDeviceServerTask.Status.State.IN_PROGRESS,
+ NewDeviceServerTask.Status.State.TRANSFER_COMPLETE -> Unit
+
+ NewDeviceServerTask.Status.State.RESTORE_COMPLETE -> onRestoreComplete()
+ NewDeviceServerTask.Status.State.FAILURE_VERSION_DOWNGRADE -> abort(R.string.NewDeviceTransfer__cannot_transfer_from_a_newer_version_of_signal)
+ NewDeviceServerTask.Status.State.FAILURE_FOREIGN_KEY -> abort(R.string.NewDeviceTransfer__failure_foreign_key)
+ NewDeviceServerTask.Status.State.FAILURE_UNKNOWN -> abort()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferSetupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferSetupFragment.java
index 880c9d01f8..240866969d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferSetupFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferSetupFragment.java
@@ -27,7 +27,7 @@ public final class NewDeviceTransferSetupFragment extends DeviceTransferSetupFra
@Override
protected void navigateAwayFromTransfer() {
- SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), R.id.action_deviceTransferSetup_to_transferOrRestore);
+ requireActivity().onNavigateUp();
}
@Override
@@ -78,7 +78,7 @@ public final class NewDeviceTransferSetupFragment extends DeviceTransferSetupFra
@Override
protected void navigateWhenWifiDirectUnavailable() {
- SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), R.id.action_deviceTransferSetup_to_transferOrRestore);
+ requireActivity().onNavigateUp();
}
@Override
diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferViewModel.kt
new file mode 100644
index 0000000000..b745654053
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceTransferViewModel.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.devicetransfer.newdevice
+
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.registration.util.RegistrationUtil
+import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
+
+class NewDeviceTransferViewModel : ViewModel() {
+ fun onRestoreComplete(context: Context, onComplete: () -> Unit) {
+ viewModelScope.launch {
+ SignalStore.registration.localRegistrationMetadata?.let { metadata ->
+ RegistrationRepository.registerAccountLocally(context, metadata)
+ SignalStore.registration.clearLocalRegistrationMetadata()
+ RegistrationUtil.maybeMarkRegistrationComplete()
+ }
+
+ SignalStore.registration.markRestoreCompleted()
+
+ withContext(Dispatchers.Main) {
+ onComplete()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreFragment.java
deleted file mode 100644
index 686cb9cc8b..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreFragment.java
+++ /dev/null
@@ -1,71 +0,0 @@
-package org.thoughtcrime.securesms.devicetransfer.newdevice;
-
-import android.os.Bundle;
-import android.view.View;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.lifecycle.ViewModelProvider;
-import androidx.navigation.Navigation;
-
-import org.signal.core.util.concurrent.LifecycleDisposable;
-import org.thoughtcrime.securesms.LoggingFragment;
-import org.thoughtcrime.securesms.R;
-import org.thoughtcrime.securesms.databinding.FragmentTransferRestoreBinding;
-import org.thoughtcrime.securesms.util.RemoteConfig;
-import org.thoughtcrime.securesms.util.SpanUtil;
-import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
-
-/**
- * Simple jumping off menu to starts a device-to-device transfer or restore a backup.
- */
-public final class TransferOrRestoreFragment extends LoggingFragment {
-
- private final LifecycleDisposable lifecycleDisposable = new LifecycleDisposable();
-
- private FragmentTransferRestoreBinding binding;
-
- public TransferOrRestoreFragment() {
- super(R.layout.fragment_transfer_restore);
- }
-
- @Override
- public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- binding = FragmentTransferRestoreBinding.bind(view);
-
- TransferOrRestoreViewModel viewModel = new ViewModelProvider(this).get(TransferOrRestoreViewModel.class);
-
- binding.transferOrRestoreFragmentTransfer.setOnClickListener(v -> viewModel.onTransferFromAndroidDeviceSelected());
- binding.transferOrRestoreFragmentRestore.setOnClickListener(v -> viewModel.onRestoreFromLocalBackupSelected());
- binding.transferOrRestoreFragmentRestoreRemote.setOnClickListener(v -> viewModel.onRestoreFromRemoteBackupSelected());
- binding.transferOrRestoreFragmentNext.setOnClickListener(v -> launchSelection(viewModel.getStateSnapshot()));
- binding.transferOrRestoreFragmentMoreOptions.setOnClickListener(v -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_transferOrRestore_to_moreOptions));
-
- int visibility = RemoteConfig.messageBackups() ? View.VISIBLE : View.GONE;
- binding.transferOrRestoreFragmentRestoreRemoteCard.setVisibility(visibility);
- binding.transferOrRestoreFragmentMoreOptions.setVisibility(visibility);
-
- String description = getString(R.string.TransferOrRestoreFragment__transfer_your_account_and_messages_from_your_old_android_device);
- String toBold = getString(R.string.TransferOrRestoreFragment__you_need_access_to_your_old_device);
-
- binding.transferOrRestoreFragmentTransferDescription.setText(SpanUtil.boldSubstring(description, toBold));
-
- lifecycleDisposable.bindTo(getViewLifecycleOwner());
- lifecycleDisposable.add(viewModel.getState().subscribe(this::updateSelection));
- }
-
- private void updateSelection(BackupRestorationType restorationType) {
- binding.transferOrRestoreFragmentTransferCard.setSelected(restorationType == BackupRestorationType.DEVICE_TRANSFER);
- binding.transferOrRestoreFragmentRestoreCard.setSelected(restorationType == BackupRestorationType.LOCAL_BACKUP);
- binding.transferOrRestoreFragmentRestoreRemoteCard.setSelected(restorationType == BackupRestorationType.REMOTE_BACKUP);
- }
-
- private void launchSelection(BackupRestorationType restorationType) {
- switch (restorationType) {
- case DEVICE_TRANSFER -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_new_device_transfer_instructions);
- case LOCAL_BACKUP -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_transfer_or_restore_to_local_restore);
- case REMOTE_BACKUP -> {}
- default -> throw new IllegalArgumentException();
- }
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreViewModel.kt
deleted file mode 100644
index 7848ff2304..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/TransferOrRestoreViewModel.kt
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
- * Copyright 2024 Signal Messenger, LLC
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-package org.thoughtcrime.securesms.devicetransfer.newdevice
-
-import androidx.lifecycle.ViewModel
-import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
-import io.reactivex.rxjava3.core.Flowable
-import io.reactivex.rxjava3.processors.BehaviorProcessor
-
-/**
- * Maintains state of the TransferOrRestoreFragment
- */
-class TransferOrRestoreViewModel : ViewModel() {
-
- private val internalState = BehaviorProcessor.createDefault(BackupRestorationType.DEVICE_TRANSFER)
-
- val state: Flowable = internalState.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread())
- val stateSnapshot: BackupRestorationType get() = internalState.value!!
-
- fun onTransferFromAndroidDeviceSelected() {
- internalState.onNext(BackupRestorationType.DEVICE_TRANSFER)
- }
-
- fun onRestoreFromLocalBackupSelected() {
- internalState.onNext(BackupRestorationType.LOCAL_BACKUP)
- }
-
- fun onRestoreFromRemoteBackupSelected() {
- internalState.onNext(BackupRestorationType.REMOTE_BACKUP)
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferFragment.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferFragment.java
index e51a5a6a6e..06d5e65560 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceTransferFragment.java
@@ -13,7 +13,7 @@ import org.greenrobot.eventbus.ThreadMode;
import org.signal.devicetransfer.DeviceToDeviceTransferService;
import org.signal.devicetransfer.TransferStatus;
import org.thoughtcrime.securesms.R;
-import org.thoughtcrime.securesms.devicetransfer.DeviceTransferFragment;
+import org.thoughtcrime.securesms.restore.devicetransfer.DeviceTransferFragment;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
import java.text.NumberFormat;
@@ -66,16 +66,16 @@ public final class OldDeviceTransferFragment extends DeviceTransferFragment {
@Subscribe(threadMode = ThreadMode.MAIN)
public void onEventMainThread(@NonNull OldDeviceClientTask.Status event) {
if (event.isDone()) {
- transferFinished = true;
+ setTransferFinished(true);
ignoreTransferStatusEvents();
EventBus.getDefault().removeStickyEvent(TransferStatus.class);
DeviceToDeviceTransferService.stop(requireContext());
SafeNavigation.safeNavigate(NavHostFragment.findNavController(OldDeviceTransferFragment.this), R.id.action_oldDeviceTransfer_to_oldDeviceTransferComplete);
} else {
if (event.getEstimatedMessageCount() == 0) {
- status.setText(getString(R.string.DeviceTransfer__d_messages_so_far, event.getMessageCount()));
+ getStatus().setText(getString(R.string.DeviceTransfer__d_messages_so_far, event.getMessageCount()));
} else {
- status.setText(getString(R.string.DeviceTransfer__s_of_messages_so_far, formatter.format(event.getCompletionPercentage())));
+ getStatus().setText(getString(R.string.DeviceTransfer__s_of_messages_so_far, formatter.format(event.getCompletionPercentage())));
}
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt b/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt
index fd2246bd5d..9ba4dcee3c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/fonts/SignalSymbols.kt
@@ -25,7 +25,8 @@ object SignalSymbols {
enum class Glyph(val unicode: Char) {
CHECKMARK('\u2713'),
CHEVRON_RIGHT('\uE025'),
- PERSON_CIRCLE('\uE05E')
+ PERSON_CIRCLE('\uE05E'),
+ LOCK('\uE041')
}
enum class Weight {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreJob.kt
index 00cf7e2027..0c552bf3ae 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreJob.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreJob.kt
@@ -6,6 +6,7 @@
package org.thoughtcrime.securesms.jobs
import org.greenrobot.eventbus.EventBus
+import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.R
@@ -75,7 +76,7 @@ class BackupRestoreJob private constructor(parameters: Parameters) : BaseJob(par
progress = progress.toFloat() / total.toFloat(),
indeterminate = false
)
- EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_DOWNLOAD, progress, total))
+ EventBus.getDefault().post(RestoreV2Event(RestoreV2Event.Type.PROGRESS_DOWNLOAD, progress.bytes, total.bytes))
}
override fun shouldCancel() = isCanceled
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java
index c0f5bec68f..e55101f49b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshAttributesJob.java
@@ -103,8 +103,8 @@ public class RefreshAttributesJob extends BaseJob {
String deviceName = SignalStore.account().getDeviceName();
byte[] encryptedDeviceName = (deviceName == null) ? null : DeviceNameCipher.encryptDeviceName(deviceName.getBytes(StandardCharsets.UTF_8), SignalStore.account().getAciIdentityKey());
- AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities(svrValues.hasPin() && !svrValues.hasOptedOut());
- Log.i(TAG, "Calling setAccountAttributes() reglockV2? " + !TextUtils.isEmpty(registrationLockV2) + ", pin? " + svrValues.hasPin() +
+ AccountAttributes.Capabilities capabilities = AppCapabilities.getCapabilities(svrValues.hasOptedInWithAccess() && !svrValues.hasOptedOut());
+ Log.i(TAG, "Calling setAccountAttributes() reglockV2? " + !TextUtils.isEmpty(registrationLockV2) + ", pin? " + svrValues.hasPin() + ", access? " + svrValues.hasOptedInWithAccess() +
"\n Recovery password? " + !TextUtils.isEmpty(recoveryPassword) +
"\n Phone number discoverable : " + phoneNumberDiscoverable +
"\n Device Name : " + (encryptedDeviceName != null) +
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java
index 1ef45f467f..a3b449f42c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshOwnProfileJob.java
@@ -105,7 +105,7 @@ public class RefreshOwnProfileJob extends BaseJob {
return;
}
- if (SignalStore.svr().hasPin() && !SignalStore.svr().hasOptedOut() && SignalStore.storageService().getLastSyncTime() == 0) {
+ if (SignalStore.svr().hasOptedInWithAccess() && !SignalStore.svr().hasOptedOut() && SignalStore.storageService().getLastSyncTime() == 0) {
Log.i(TAG, "Registered with PIN but haven't completed storage sync yet.");
return;
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshSvrCredentialsJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshSvrCredentialsJob.kt
index 40175201a7..5f8454a6fb 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshSvrCredentialsJob.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RefreshSvrCredentialsJob.kt
@@ -24,7 +24,7 @@ class RefreshSvrCredentialsJob private constructor(parameters: Parameters) : Bas
@JvmStatic
fun enqueueIfNecessary() {
- if (SignalStore.svr.hasPin() && SignalStore.account.isRegistered) {
+ if (SignalStore.svr.hasOptedInWithAccess() && SignalStore.account.isRegistered) {
val lastTimestamp = SignalStore.svr.lastRefreshAuthTimestamp
if (lastTimestamp + FREQUENCY.inWholeMilliseconds < System.currentTimeMillis() || lastTimestamp > System.currentTimeMillis()) {
AppDependencies.jobManager.add(RefreshSvrCredentialsJob())
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt
index ae1c9e562d..b9d818da32 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/StorageSyncJob.kt
@@ -139,7 +139,7 @@ class StorageSyncJob private constructor(parameters: Parameters) : BaseJob(param
@Throws(IOException::class, RetryLaterException::class, UntrustedIdentityException::class)
override fun onRun() {
- if (!SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) {
+ if (!SignalStore.svr.hasOptedInWithAccess() && !SignalStore.svr.hasOptedOut()) {
Log.i(TAG, "Doesn't have a PIN. Skipping.")
return
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt
index b21ccfd312..c42edcb9d3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/BackupValues.kt
@@ -154,7 +154,7 @@ class BackupValues(store: KeyValueStore) : SignalStoreValues(store) {
}
/**
- * When uploading a backup, we store the progress state here so that I can remain across app restarts.
+ * When uploading a backup, we store the progress state here so that it can remain across app restarts.
*/
var archiveUploadState: ArchiveUploadProgressState? by protoValue(KEY_ARCHIVE_UPLOAD_STATE, ArchiveUploadProgressState.ADAPTER)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt
index b341d5ddd7..3205ef4ed3 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/PaymentsValues.kt
@@ -243,7 +243,8 @@ class PaymentsValues internal constructor(store: KeyValueStore) : SignalStoreVal
fun showUpdatePinInfoCard(): Boolean {
return if (userHasLargeBalance() &&
SignalStore.svr.hasPin() &&
- !SignalStore.svr.hasOptedOut() && SignalStore.pin.keyboardType == PinKeyboardType.NUMERIC
+ !SignalStore.svr.hasOptedOut() &&
+ SignalStore.pin.keyboardType == PinKeyboardType.NUMERIC
) {
store.getBoolean(SHOW_CASHING_OUT_INFO_CARD, true)
} else {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java
index 7f64139092..22959c07f6 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/RegistrationValues.java
@@ -96,7 +96,7 @@ public final class RegistrationValues extends SignalStoreValues {
putBoolean(SKIPPED_TRANSFER_OR_RESTORE, true);
}
- public void clearSkippedTransferOrRestore() {
+ public void debugClearSkippedTransferOrRestore() {
putBoolean(SKIPPED_TRANSFER_OR_RESTORE, false);
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SvrValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SvrValues.kt
index 53dd94191b..788a26dd8d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SvrValues.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SvrValues.kt
@@ -25,6 +25,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
private const val SVR2_AUTH_TOKENS = "kbs.kbs_auth_tokens"
private const val SVR_LAST_AUTH_REFRESH_TIMESTAMP = "kbs.kbs_auth_tokens.last_refresh_timestamp"
private const val SVR3_AUTH_TOKENS = "kbs.svr3_auth_tokens"
+ private const val RESTORED_VIA_ACCOUNT_ENTROPY_KEY = "kbs.restore_via_account_entropy_pool"
}
public override fun onFirstEverAppLaunch() = Unit
@@ -52,14 +53,22 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
}
@Synchronized
- fun setMasterKey(masterKey: MasterKey, pin: String) {
- store.beginWrite()
- .putBlob(MASTER_KEY, masterKey.serialize())
- .putString(LOCK_LOCAL_PIN_HASH, localPinHash(pin))
- .putString(PIN, pin)
- .putLong(LAST_CREATE_FAILED_TIMESTAMP, -1)
- .putBoolean(OPTED_OUT, false)
- .commit()
+ fun setMasterKey(masterKey: MasterKey, pin: String?) {
+ store.beginWrite().apply {
+ putBlob(MASTER_KEY, masterKey.serialize())
+ putLong(LAST_CREATE_FAILED_TIMESTAMP, -1)
+ putBoolean(OPTED_OUT, false)
+
+ if (pin != null) {
+ putString(LOCK_LOCAL_PIN_HASH, localPinHash(pin))
+ putString(PIN, pin)
+ remove(RESTORED_VIA_ACCOUNT_ENTROPY_KEY)
+ } else {
+ putBoolean(RESTORED_VIA_ACCOUNT_ENTROPY_KEY, true)
+ remove(LOCK_LOCAL_PIN_HASH)
+ remove(PIN)
+ }
+ }.commit()
}
@Synchronized
@@ -85,9 +94,9 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
return getLong(LAST_CREATE_FAILED_TIMESTAMP, -1) > 0
}
+ /** Returns the Master Key, lazily creating one if needed. */
@get:Synchronized
val masterKey: MasterKey
- /** Returns the Master Key, lazily creating one if needed. */
get() {
val blob = store.getBlob(MASTER_KEY, null)
if (blob != null) {
@@ -123,7 +132,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
val recoveryPassword: String?
get() {
val masterKey = rawMasterKey
- return if (masterKey != null && hasPin()) {
+ return if (masterKey != null && hasOptedInWithAccess()) {
masterKey.deriveRegistrationRecoveryPassword()
} else {
null
@@ -136,11 +145,19 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
@get:Synchronized
val localPinHash: String? by stringValue(LOCK_LOCAL_PIN_HASH, null)
+ @Synchronized
+ fun hasOptedInWithAccess(): Boolean {
+ return hasPin() || restoredViaAccountEntropyPool
+ }
+
@Synchronized
fun hasPin(): Boolean {
return localPinHash != null
}
+ @get:Synchronized
+ val restoredViaAccountEntropyPool by booleanValue(RESTORED_VIA_ACCOUNT_ENTROPY_KEY, false)
+
@get:Synchronized
@set:Synchronized
var isPinForgottenOrSkipped: Boolean by booleanValue(PIN_FORGOTTEN_OR_SKIPPED, false)
@@ -229,6 +246,7 @@ class SvrValues internal constructor(store: KeyValueStore) : SignalStoreValues(s
.putBlob(MASTER_KEY, MasterKey.createNew(SecureRandom()).serialize())
.remove(LOCK_LOCAL_PIN_HASH)
.remove(PIN)
+ .remove(RESTORED_VIA_ACCOUNT_ENTROPY_KEY)
.putLong(LAST_CREATE_FAILED_TIMESTAMP, -1)
.commit()
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/BaseSvrPinFragment.java b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/BaseSvrPinFragment.java
index 158df96247..67a09d01a0 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/BaseSvrPinFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/BaseSvrPinFragment.java
@@ -101,7 +101,7 @@ public abstract class BaseSvrPinFragment
@Override
public void onPrepareOptionsMenu(@NonNull Menu menu) {
if (SignalStore.svr().isRegistrationLockEnabled() ||
- SignalStore.svr().hasPin() ||
+ SignalStore.svr().hasOptedInWithAccess() ||
SignalStore.svr().hasOptedOut())
{
menu.clear();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/SvrSplashFragment.java b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/SvrSplashFragment.java
index 87a3038518..88b0317c3d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/lock/v2/SvrSplashFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/lock/v2/SvrSplashFragment.java
@@ -115,7 +115,7 @@ public final class SvrSplashFragment extends Fragment {
private void onCreatePin() {
SvrSplashFragmentDirections.ActionCreateKbsPin action = SvrSplashFragmentDirections.actionCreateKbsPin();
- action.setIsPinChange(SignalStore.svr().hasPin());
+ action.setIsPinChange(SignalStore.svr().hasOptedInWithAccess());
SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), action);
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionPin.java b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionPin.java
index 2949ee3fb3..f97edb623a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionPin.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/logsubmit/LogSectionPin.java
@@ -19,6 +19,7 @@ public class LogSectionPin implements LogSection {
.append("Next Reminder Interval: ").append(SignalStore.pin().getCurrentInterval()).append("\n")
.append("Reglock: ").append(SignalStore.svr().isRegistrationLockEnabled()).append("\n")
.append("Signal PIN: ").append(SignalStore.svr().hasPin()).append("\n")
+ .append("Restored via AEP: ").append(SignalStore.svr().getRestoredViaAccountEntropyPool()).append("\n")
.append("Opted Out: ").append(SignalStore.svr().hasOptedOut()).append("\n")
.append("Last Creation Failed: ").append(SignalStore.svr().lastPinCreateFailed()).append("\n")
.append("Needs Account Restore: ").append(SignalStore.storageService().needsAccountRestore()).append("\n")
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureEvent.kt
index 13a93919ef..3fd272b8b1 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureEvent.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureEvent.kt
@@ -3,9 +3,10 @@ package org.thoughtcrime.securesms.mediasend.v2.capture
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.recipients.Recipient
-sealed class MediaCaptureEvent {
- data class MediaCaptureRendered(val media: Media) : MediaCaptureEvent()
- data class UsernameScannedFromQrCode(val recipient: Recipient, val username: String) : MediaCaptureEvent()
- object DeviceLinkScannedFromQrCode : MediaCaptureEvent()
- object MediaCaptureRenderFailed : MediaCaptureEvent()
+sealed interface MediaCaptureEvent {
+ data class MediaCaptureRendered(val media: Media) : MediaCaptureEvent
+ data class UsernameScannedFromQrCode(val recipient: Recipient, val username: String) : MediaCaptureEvent
+ data object DeviceLinkScannedFromQrCode : MediaCaptureEvent
+ data object MediaCaptureRenderFailed : MediaCaptureEvent
+ data class ReregistrationScannedFromQrCode(val data: String) : MediaCaptureEvent
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt
index 1d5fec556f..ec9690a67d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureFragment.kt
@@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.permissions.Permissions
+import org.thoughtcrime.securesms.registrationv3.olddevice.TransferAccountActivity
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
@@ -67,6 +68,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
Log.w(TAG, "Failed to render captured media.")
Toast.makeText(requireContext(), R.string.MediaSendActivity_camera_unavailable, Toast.LENGTH_SHORT).show()
}
+
is MediaCaptureEvent.MediaCaptureRendered -> {
if (isFirst()) {
sharedViewModel.addCameraFirstCapture(event.media)
@@ -76,6 +78,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
navigator.goToReview(findNavController())
}
+
is MediaCaptureEvent.UsernameScannedFromQrCode -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(getString(R.string.MediaCaptureFragment_username_dialog_title, event.username))
@@ -87,6 +90,7 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
.setNegativeButton(android.R.string.cancel, null)
.show()
}
+
is MediaCaptureEvent.DeviceLinkScannedFromQrCode -> {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.MediaCaptureFragment_device_link_dialog_title)
@@ -98,6 +102,11 @@ class MediaCaptureFragment : Fragment(R.layout.fragment_container), CameraFragme
.setNegativeButton(android.R.string.cancel, null)
.show()
}
+
+ is MediaCaptureEvent.ReregistrationScannedFromQrCode -> {
+ startActivity(TransferAccountActivity.intent(requireContext(), event.data))
+ requireActivity().finish()
+ }
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt
index 3db6595108..bba66f93e5 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureViewModel.kt
@@ -11,10 +11,11 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import org.signal.core.util.logging.Log
-import org.thoughtcrime.securesms.components.settings.app.usernamelinks.main.QrScanResult
+import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
import org.thoughtcrime.securesms.util.rx.RxStore
import java.io.FileDescriptor
import java.util.Optional
@@ -71,6 +72,15 @@ class MediaCaptureViewModel(private val repository: MediaCaptureRepository) : Vi
.subscribe { data ->
internalEvents.onNext(MediaCaptureEvent.DeviceLinkScannedFromQrCode)
}
+
+ if (SignalStore.account.isRegistered) {
+ disposables += qrData
+ .throttleFirst(5, TimeUnit.SECONDS)
+ .filter { it.startsWith("sgnl://rereg") && QuickRegistrationRepository.isValidReRegistrationQr(it) }
+ .subscribe { data ->
+ internalEvents.onNext(MediaCaptureEvent.ReregistrationScannedFromQrCode(data))
+ }
+ }
}
override fun onCleared() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/megaphone/PinsForAllSchedule.java b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PinsForAllSchedule.java
index 2b9a0dc2a7..f8e0836989 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/megaphone/PinsForAllSchedule.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/megaphone/PinsForAllSchedule.java
@@ -45,7 +45,7 @@ class PinsForAllSchedule implements MegaphoneSchedule {
return false;
}
- if (SignalStore.svr().hasPin()) {
+ if (SignalStore.svr().hasOptedInWithAccess()) {
return false;
}
@@ -62,6 +62,6 @@ class PinsForAllSchedule implements MegaphoneSchedule {
private static boolean pinCreationFailedDuringRegistration() {
return SignalStore.registration().pinWasRequiredAtRegistration() &&
- !SignalStore.svr().hasPin();
+ !SignalStore.svr().hasOptedInWithAccess();
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/PinOptOutMigration.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/PinOptOutMigration.java
index 4506ed0939..ed3f9b3abc 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/migrations/PinOptOutMigration.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/PinOptOutMigration.java
@@ -37,7 +37,7 @@ public final class PinOptOutMigration extends MigrationJob {
@Override
void performMigration() {
- if (SignalStore.svr().hasOptedOut() && SignalStore.svr().hasPin()) {
+ if (SignalStore.svr().hasOptedOut() && SignalStore.svr().hasOptedInWithAccess()) {
Log.w(TAG, "Discovered a legacy opt-out user! Resetting the state.");
SignalStore.svr().optOut();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java
index 3f561a6ba6..e15293c6d6 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/pin/PinRestoreEntryFragment.java
@@ -240,7 +240,7 @@ public class PinRestoreEntryFragment extends LoggingFragment {
Activity activity = requireActivity();
if (RemoteConfig.messageBackups() && !SignalStore.registration().hasCompletedRestore()) {
- final Intent transferOrRestore = RestoreActivity.getIntentForTransferOrRestore(activity);
+ final Intent transferOrRestore = RestoreActivity.getRestoreIntent(activity);
transferOrRestore.putExtra(PassphraseRequiredActivity.NEXT_INTENT_EXTRA, MainActivity.clearTop(requireContext()));
startActivity(transferOrRestore);
} else if (Recipient.self().getProfileName().isEmpty() || !AvatarHelper.hasAvatar(activity, Recipient.self().getId())) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt
index 3739f66323..d1105b187b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/pin/SvrRepository.kt
@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.pin
import android.app.backup.BackupManager
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
+import okio.ByteString.Companion.toByteString
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
@@ -163,10 +164,13 @@ object SvrRepository {
Log.i(TAG, "[restoreMasterKeyPostRegistration] Successfully restored master key. $implementation", true)
stopwatch.split("restore")
+ SignalStore.registration.localRegistrationMetadata?.let { metadata ->
+ SignalStore.registration.localRegistrationMetadata = metadata.copy(masterKey = response.masterKey.serialize().toByteString(), pin = userPin)
+ }
+
SignalStore.svr.setMasterKey(response.masterKey, userPin)
SignalStore.svr.isRegistrationLockEnabled = false
SignalStore.pin.resetPinReminders()
- SignalStore.svr.isPinForgottenOrSkipped = false
SignalStore.pin.keyboardType = pinKeyboardType
SignalStore.storageService.setNeedsAccountRestore(false)
@@ -264,7 +268,6 @@ object SvrRepository {
Log.i(TAG, "[setPin] Success!", true)
SignalStore.svr.setMasterKey(masterKey, userPin)
- SignalStore.svr.isPinForgottenOrSkipped = false
responses
.filterIsInstance()
.forEach {
@@ -321,6 +324,9 @@ object SvrRepository {
SignalStore.pin.resetPinReminders()
AppDependencies.jobManager.add(ResetSvrGuessCountJob())
+ } else if (masterKey != null) {
+ Log.i(TAG, "[onRegistrationComplete] ReRegistered with key without pin")
+ SignalStore.svr.setMasterKey(masterKey, null)
} else if (hasPinToRestore) {
Log.i(TAG, "[onRegistrationComplete] Has a PIN to restore.", true)
SignalStore.svr.clearRegistrationLockAndPin()
@@ -342,7 +348,6 @@ object SvrRepository {
operationLock.withLock {
SignalStore.svr.clearRegistrationLockAndPin()
SignalStore.storageService.setNeedsAccountRestore(false)
- SignalStore.svr.isPinForgottenOrSkipped = true
}
}
@@ -364,7 +369,7 @@ object SvrRepository {
@Throws(IOException::class)
fun enableRegistrationLockForUserWithPin() {
operationLock.withLock {
- check(SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to set a registration lock!" }
+ check(SignalStore.svr.hasOptedInWithAccess() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to set a registration lock!" }
Log.i(TAG, "[enableRegistrationLockForUserWithPin] Enabling registration lock.", true)
AppDependencies.signalServiceAccountManager.enableRegistrationLock(SignalStore.svr.masterKey)
@@ -378,7 +383,7 @@ object SvrRepository {
@Throws(IOException::class)
fun disableRegistrationLockForUserWithPin() {
operationLock.withLock {
- check(SignalStore.svr.hasPin() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to disable registration lock!" }
+ check(SignalStore.svr.hasOptedInWithAccess() && !SignalStore.svr.hasOptedOut()) { "Must have a PIN to disable registration lock!" }
Log.i(TAG, "[disableRegistrationLockForUserWithPin] Disabling registration lock.", true)
AppDependencies.signalServiceAccountManager.disableRegistrationLock()
@@ -408,7 +413,7 @@ object SvrRepository {
false
}
- if (newToken && SignalStore.svr.hasPin()) {
+ if (newToken && SignalStore.svr.hasOptedInWithAccess()) {
BackupManager(AppDependencies.application).dataChanged()
}
} catch (e: Throwable) {
@@ -469,7 +474,7 @@ object SvrRepository {
private val hasNoRegistrationLock: Boolean
get() {
return !SignalStore.svr.isRegistrationLockEnabled &&
- !SignalStore.svr.hasPin() &&
+ !SignalStore.svr.hasOptedInWithAccess() &&
!SignalStore.svr.hasOptedOut()
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/AccountRegistrationResult.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/AccountRegistrationResult.kt
new file mode 100644
index 0000000000..6d2f1d15fc
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/AccountRegistrationResult.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registration.data
+
+import org.whispersystems.signalservice.api.account.PreKeyCollection
+import org.whispersystems.signalservice.api.kbs.MasterKey
+
+data class AccountRegistrationResult(
+ val uuid: String,
+ val pni: String,
+ val storageCapable: Boolean,
+ val number: String,
+ val masterKey: MasterKey?,
+ val pin: String?,
+ val aciPreKeyCollection: PreKeyCollection,
+ val pniPreKeyCollection: PreKeyCollection
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/LocalRegistrationMetadataUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/LocalRegistrationMetadataUtil.kt
index b005bf239a..e3c665316f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/LocalRegistrationMetadataUtil.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/LocalRegistrationMetadataUtil.kt
@@ -17,7 +17,7 @@ import org.whispersystems.signalservice.api.account.PreKeyCollection
* and combines them into a proto-backed class [LocalRegistrationMetadata] so they can be serialized & stored.
*/
object LocalRegistrationMetadataUtil {
- fun createLocalRegistrationMetadata(localAciIdentityKeyPair: IdentityKeyPair, localPniIdentityKeyPair: IdentityKeyPair, registrationData: RegistrationData, remoteResult: RegistrationRepository.AccountRegistrationResult, reglockEnabled: Boolean): LocalRegistrationMetadata {
+ fun createLocalRegistrationMetadata(localAciIdentityKeyPair: IdentityKeyPair, localPniIdentityKeyPair: IdentityKeyPair, registrationData: RegistrationData, remoteResult: AccountRegistrationResult, reglockEnabled: Boolean): LocalRegistrationMetadata {
return LocalRegistrationMetadata.Builder().apply {
aciIdentityKeyPair = localAciIdentityKeyPair.serialize().toByteString()
aciSignedPreKey = remoteResult.aciPreKeyCollection.signedPreKey.serialize().toByteString()
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt
index a2c390ec1f..7f95edd8d6 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt
@@ -622,15 +622,4 @@ object RegistrationRepository {
latch.countDown()
}
}
-
- data class AccountRegistrationResult(
- val uuid: String,
- val pni: String,
- val storageCapable: Boolean,
- val number: String,
- val masterKey: MasterKey?,
- val pin: String?,
- val aciPreKeyCollection: PreKeyCollection,
- val pniPreKeyCollection: PreKeyCollection
- )
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/network/RegisterAccountResult.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/network/RegisterAccountResult.kt
index 858be7c09d..af8b7bc3d2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/network/RegisterAccountResult.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/network/RegisterAccountResult.kt
@@ -6,7 +6,7 @@
package org.thoughtcrime.securesms.registration.data.network
import org.thoughtcrime.securesms.pin.SvrWrongPinException
-import org.thoughtcrime.securesms.registration.data.RegistrationRepository
+import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.push.exceptions.AuthorizationFailedException
@@ -23,7 +23,7 @@ import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
*/
sealed class RegisterAccountResult(cause: Throwable?) : RegistrationResult(cause) {
companion object {
- fun from(networkResult: NetworkResult): RegisterAccountResult {
+ fun from(networkResult: NetworkResult): RegisterAccountResult {
return when (networkResult) {
is NetworkResult.Success -> Success(networkResult.result)
is NetworkResult.ApplicationError -> UnknownError(networkResult.throwable)
@@ -55,7 +55,7 @@ sealed class RegisterAccountResult(cause: Throwable?) : RegistrationResult(cause
}
}
}
- class Success(val accountRegistrationResult: RegistrationRepository.AccountRegistrationResult) : RegisterAccountResult(null)
+ class Success(val accountRegistrationResult: AccountRegistrationResult) : RegisterAccountResult(null)
class IncorrectRecoveryPassword(cause: Throwable) : RegisterAccountResult(cause)
class AuthorizationFailed(cause: Throwable) : RegisterAccountResult(cause)
class MalformedRequest(cause: Throwable) : RegisterAccountResult(cause)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt
index 0a9990b561..be21b04c3c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationActivity.kt
@@ -23,7 +23,7 @@ import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.sms.SmsRetrieverReceiver
-import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity
+import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.RemoteConfig
@@ -119,7 +119,7 @@ class RegistrationActivity : BaseActivity() {
@JvmStatic
fun newIntentForNewRegistration(context: Context, originalIntent: Intent): Intent {
- return Intent(context, RegistrationActivity::class.java).apply {
+ return Intent(context, getRegistrationClass()).apply {
putExtra(RE_REGISTRATION_EXTRA, false)
setData(originalIntent.data)
}
@@ -127,9 +127,13 @@ class RegistrationActivity : BaseActivity() {
@JvmStatic
fun newIntentForReRegistration(context: Context): Intent {
- return Intent(context, RegistrationActivity::class.java).apply {
+ return Intent(context, getRegistrationClass()).apply {
putExtra(RE_REGISTRATION_EXTRA, true)
}
}
+
+ private fun getRegistrationClass(): Class<*> {
+ return if (RemoteConfig.restoreAfterRegistration) org.thoughtcrime.securesms.registrationv3.ui.RegistrationActivity::class.java else RegistrationActivity::class.java
+ }
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt
index 7ef39dcc65..c92453be32 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt
@@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.pin.SvrWrongPinException
+import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil
import org.thoughtcrime.securesms.registration.data.RegistrationData
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
@@ -827,7 +828,7 @@ class RegistrationViewModel : ViewModel() {
handleRegistrationResult(context, registrationData, registrationResponse, false)
}
- private suspend fun onSuccessfulRegistration(context: Context, registrationData: RegistrationData, remoteResult: RegistrationRepository.AccountRegistrationResult, reglockEnabled: Boolean) {
+ private suspend fun onSuccessfulRegistration(context: Context, registrationData: RegistrationData, remoteResult: AccountRegistrationResult, reglockEnabled: Boolean) {
Log.v(TAG, "onSuccessfulRegistration()")
val metadata = LocalRegistrationMetadataUtil.createLocalRegistrationMetadata(SignalStore.account.aciIdentityKey, SignalStore.account.pniIdentityKey, registrationData, remoteResult, reglockEnabled)
RegistrationRepository.registerAccountLocally(context, metadata)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/grantpermissions/GrantPermissionsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/grantpermissions/GrantPermissionsFragment.kt
index e2b3dc61a6..91a3e84e78 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/grantpermissions/GrantPermissionsFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/grantpermissions/GrantPermissionsFragment.kt
@@ -104,7 +104,7 @@ class GrantPermissionsFragment : ComposeFragment() {
when (welcomeAction) {
WelcomeAction.CONTINUE -> findNavController().safeNavigate(GrantPermissionsFragmentDirections.actionEnterPhoneNumber())
WelcomeAction.RESTORE_BACKUP -> {
- val restoreIntent = RestoreActivity.getIntentForTransferOrRestore(requireActivity())
+ val restoreIntent = RestoreActivity.getRestoreIntent(requireActivity())
launchRestoreActivity.launch(restoreIntent)
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RemoteRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RemoteRestoreActivity.kt
deleted file mode 100644
index 27b0d2886f..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/restore/RemoteRestoreActivity.kt
+++ /dev/null
@@ -1,380 +0,0 @@
-/*
- * Copyright 2024 Signal Messenger, LLC
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-package org.thoughtcrime.securesms.registration.ui.restore
-
-import android.content.Context
-import android.content.Intent
-import android.os.Bundle
-import androidx.activity.compose.setContent
-import androidx.activity.viewModels
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.layout.wrapContentSize
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.SideEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.dimensionResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.withStyle
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.persistentListOf
-import org.greenrobot.eventbus.EventBus
-import org.greenrobot.eventbus.Subscribe
-import org.greenrobot.eventbus.ThreadMode
-import org.signal.core.ui.Buttons
-import org.signal.core.ui.Previews
-import org.signal.core.ui.theme.SignalTheme
-import org.thoughtcrime.securesms.BaseActivity
-import org.thoughtcrime.securesms.MainActivity
-import org.thoughtcrime.securesms.R
-import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
-import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
-import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature
-import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow
-import org.thoughtcrime.securesms.backup.v2.ui.subscription.RemoteRestoreViewModel
-import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
-import org.thoughtcrime.securesms.dependencies.AppDependencies
-import org.thoughtcrime.securesms.jobs.ProfileUploadJob
-import org.thoughtcrime.securesms.keyvalue.SignalStore
-import org.thoughtcrime.securesms.profiles.AvatarHelper
-import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
-import org.thoughtcrime.securesms.recipients.Recipient
-import org.thoughtcrime.securesms.registration.util.RegistrationUtil
-import org.thoughtcrime.securesms.restore.transferorrestore.TransferOrRestoreMoreOptionsDialog
-import org.thoughtcrime.securesms.util.DateUtils
-import org.thoughtcrime.securesms.util.Util
-import java.util.Locale
-import org.signal.core.ui.R as CoreUiR
-
-class RemoteRestoreActivity : BaseActivity() {
- companion object {
- fun getIntent(context: Context): Intent {
- return Intent(context, RemoteRestoreActivity::class.java)
- }
- }
-
- private val viewModel: RemoteRestoreViewModel by viewModels()
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- setContent {
- val state by viewModel.state
- SignalTheme {
- Surface {
- RestoreFromBackupContent(
- features = getFeatureList(state.backupTier),
- onRestoreBackupClick = {
- viewModel.restore()
- },
- onCancelClick = {
- finish()
- },
- onMoreOptionsClick = {
- TransferOrRestoreMoreOptionsDialog.show(fragmentManager = supportFragmentManager, skipOnly = false)
- },
- state.backupTier,
- state.backupTime,
- state.backupTier != MessageBackupTier.PAID
- )
- if (state.importState == RemoteRestoreViewModel.ImportState.RESTORED) {
- SideEffect {
- SignalStore.registration.markRestoreCompleted()
- RegistrationUtil.maybeMarkRegistrationComplete()
- AppDependencies.jobManager.add(ProfileUploadJob())
- startActivity(MainActivity.clearTop(this))
- }
- } else if (state.importState == RemoteRestoreViewModel.ImportState.IN_PROGRESS) {
- ProgressDialog(state.restoreProgress)
- }
- }
- }
- }
- EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
- }
-
- @Subscribe(threadMode = ThreadMode.MAIN)
- fun onEvent(restoreEvent: RestoreV2Event) {
- viewModel.updateRestoreProgress(restoreEvent)
- }
-
- @Composable
- private fun getFeatureList(tier: MessageBackupTier?): ImmutableList {
- return when (tier) {
- null -> persistentListOf()
- MessageBackupTier.PAID -> {
- persistentListOf(
- MessageBackupsTypeFeature(
- iconResourceId = R.drawable.symbol_thread_compact_bold_16,
- label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_media)
- ),
- MessageBackupsTypeFeature(
- iconResourceId = R.drawable.symbol_recent_compact_bold_16,
- label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages)
- )
- )
- }
- MessageBackupTier.FREE -> {
- persistentListOf(
- MessageBackupsTypeFeature(
- iconResourceId = R.drawable.symbol_thread_compact_bold_16,
- label = stringResource(id = R.string.RemoteRestoreActivity__your_last_d_days_of_media, 30)
- ),
- MessageBackupsTypeFeature(
- iconResourceId = R.drawable.symbol_recent_compact_bold_16,
- label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages)
- )
- )
- }
- }
- }
-
- /**
- * A dialog that *just* shows a spinner. Useful for short actions where you need to
- * let the user know that some action is completing.
- */
- @Composable
- fun ProgressDialog(restoreProgress: RestoreV2Event?) {
- androidx.compose.material3.AlertDialog(
- onDismissRequest = {},
- confirmButton = {},
- dismissButton = {},
- text = {
- Box(
- contentAlignment = Alignment.Center,
- modifier = Modifier
- .fillMaxWidth()
- ) {
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- modifier = Modifier.wrapContentSize()
- ) {
- if (restoreProgress == null) {
- CircularProgressIndicator(
- modifier = Modifier
- .padding(top = 55.dp, bottom = 16.dp)
- .width(48.dp)
- .height(48.dp)
- )
- } else {
- CircularProgressIndicator(
- progress = restoreProgress.getProgress(),
- modifier = Modifier
- .padding(top = 55.dp, bottom = 16.dp)
- .width(48.dp)
- .height(48.dp)
- )
- }
-
- val progressText = when (restoreProgress?.type) {
- RestoreV2Event.Type.PROGRESS_DOWNLOAD -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup)
- RestoreV2Event.Type.PROGRESS_RESTORE -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup)
- else -> stringResource(id = R.string.RemoteRestoreActivity__restoring)
- }
-
- Text(
- text = progressText,
- style = MaterialTheme.typography.bodyMedium,
- modifier = Modifier.padding(bottom = 12.dp)
- )
-
- if (restoreProgress != null) {
- val progressBytes = Util.getPrettyFileSize(restoreProgress.count)
- val totalBytes = Util.getPrettyFileSize(restoreProgress.estimatedTotalCount)
- Text(
- text = stringResource(id = R.string.RemoteRestoreActivity__s_of_s_s, progressBytes, totalBytes, "%.2f%%".format(restoreProgress.getProgress())),
- style = MaterialTheme.typography.bodySmall,
- modifier = Modifier.padding(bottom = 12.dp)
- )
- }
- }
- }
- },
- modifier = Modifier.width(212.dp)
- )
- }
-
- @Preview
- @Composable
- private fun ProgressDialogPreview() {
- Previews.Preview {
- ProgressDialog(RestoreV2Event(RestoreV2Event.Type.PROGRESS_RESTORE, 10, 1000))
- }
- }
-
- @Preview
- @Composable
- private fun RestoreFromBackupContentPreview() {
- Previews.Preview {
- RestoreFromBackupContent(
- features = persistentListOf(
- MessageBackupsTypeFeature(
- iconResourceId = R.drawable.symbol_thread_compact_bold_16,
- label = "Your last 30 days of media"
- ),
- MessageBackupsTypeFeature(
- iconResourceId = R.drawable.symbol_recent_compact_bold_16,
- label = "All of your text messages"
- )
- ),
- onRestoreBackupClick = {},
- onCancelClick = {},
- onMoreOptionsClick = {},
- MessageBackupTier.PAID,
- System.currentTimeMillis(),
- true
- )
- }
- }
-
- @Composable
- private fun RestoreFromBackupContent(
- features: ImmutableList,
- onRestoreBackupClick: () -> Unit,
- onCancelClick: () -> Unit,
- onMoreOptionsClick: () -> Unit,
- tier: MessageBackupTier?,
- lastBackupTime: Long,
- cancelable: Boolean
- ) {
- Column(
- modifier = Modifier
- .padding(horizontal = dimensionResource(id = CoreUiR.dimen.gutter))
- .padding(top = 40.dp, bottom = 24.dp)
- ) {
- Text(
- text = stringResource(id = R.string.RemoteRestoreActivity__restore_from_backup),
- style = MaterialTheme.typography.headlineMedium,
- modifier = Modifier.padding(bottom = 12.dp)
- )
-
- val yourLastBackupText = buildAnnotatedString {
- append(
- stringResource(
- id = R.string.RemoteRestoreActivity__backup_created_at,
- DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), lastBackupTime),
- DateUtils.getOnlyTimeString(LocalContext.current, lastBackupTime)
- )
-
- )
- append(" ")
- if (tier != MessageBackupTier.PAID) {
- withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
- append(stringResource(id = R.string.RemoteRestoreActivity__only_media_sent_or_received))
- }
- }
- }
-
- Text(
- text = yourLastBackupText,
- style = MaterialTheme.typography.bodyLarge,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(bottom = 28.dp)
- )
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp))
- .padding(horizontal = 20.dp)
- .padding(top = 20.dp, bottom = 18.dp)
- ) {
- Text(
- text = stringResource(id = R.string.RemoteRestoreActivity__your_backup_includes),
- style = MaterialTheme.typography.titleMedium,
- modifier = Modifier.padding(bottom = 6.dp)
- )
-
- features.forEach {
- MessageBackupsTypeFeatureRow(
- messageBackupsTypeFeature = it,
- iconTint = MaterialTheme.colorScheme.primary,
- modifier = Modifier.padding(start = 16.dp, top = 6.dp)
- )
- }
- }
-
- Spacer(modifier = Modifier.weight(1f))
-
- Buttons.LargeTonal(
- onClick = onRestoreBackupClick,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(
- text = stringResource(id = R.string.RemoteRestoreActivity__restore_backup)
- )
- }
-
- if (cancelable) {
- TextButton(
- onClick = onCancelClick,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(
- text = stringResource(id = android.R.string.cancel)
- )
- }
- } else {
- TextButton(
- onClick = onMoreOptionsClick,
- modifier = Modifier.fillMaxWidth()
- ) {
- Text(
- text = stringResource(id = R.string.TransferOrRestoreFragment__more_options)
- )
- }
- }
- }
- }
-
- private fun restoreFromServer() {
- viewModel.restore()
- }
-
- private fun continueRegistration() {
- if (Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(this, Recipient.self().id)) {
- val main = MainActivity.clearTop(this)
- val profile = CreateProfileActivity.getIntentForUserProfile(this)
- profile.putExtra("next_intent", main)
- startActivity(profile)
- } else {
- RegistrationUtil.maybeMarkRegistrationComplete()
- AppDependencies.jobManager.add(ProfileUploadJob())
- startActivity(MainActivity.clearTop(this))
- }
- finish()
- }
-
- @Composable
- private fun StateLabel(text: String) {
- Text(
- text = text,
- style = MaterialTheme.typography.labelSmall,
- textAlign = TextAlign.Center
- )
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/welcome/WelcomeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/welcome/WelcomeFragment.kt
index b56855cfbc..555f8ee5cd 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/welcome/WelcomeFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/welcome/WelcomeFragment.kt
@@ -83,7 +83,7 @@ class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome)
} else {
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
- val restoreIntent = RestoreActivity.getIntentForTransferOrRestore(requireActivity())
+ val restoreIntent = RestoreActivity.getRestoreIntent(requireActivity())
launchRestoreActivity.launch(restoreIntent)
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java
index 1775f34a7d..3512214778 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/util/RegistrationUtil.java
@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.jobs.StorageSyncJob;
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
+import org.thoughtcrime.securesms.util.RemoteConfig;
public final class RegistrationUtil {
@@ -29,7 +30,8 @@ public final class RegistrationUtil {
if (!SignalStore.registration().isRegistrationComplete() &&
SignalStore.account().isRegistered() &&
!Recipient.self().getProfileName().isEmpty() &&
- (SignalStore.svr().hasPin() || SignalStore.svr().hasOptedOut()))
+ (SignalStore.svr().hasOptedInWithAccess() || SignalStore.svr().hasOptedOut()) &&
+ (RemoteConfig.restoreAfterRegistration() && (SignalStore.registration().hasSkippedTransferOrRestore() || SignalStore.registration().hasCompletedRestore())))
{
Log.i(TAG, "Marking registration completed.", new Throwable());
SignalStore.registration().setRegistrationComplete();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/QuickRegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/QuickRegistrationRepository.kt
new file mode 100644
index 0000000000..fa562c14ea
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/QuickRegistrationRepository.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.data
+
+import android.net.Uri
+import org.signal.core.util.Base64.decode
+import org.signal.core.util.Hex
+import org.signal.core.util.isNotNullOrBlank
+import org.signal.core.util.logging.Log
+import org.signal.libsignal.protocol.InvalidKeyException
+import org.signal.libsignal.protocol.ecc.Curve
+import org.signal.registration.proto.RegistrationProvisionMessage
+import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
+import org.thoughtcrime.securesms.dependencies.AppDependencies
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import java.io.IOException
+
+/**
+ * Helpers for quickly re-registering on a new device with the old device.
+ */
+object QuickRegistrationRepository {
+ private val TAG = Log.tag(QuickRegistrationRepository::class)
+
+ private const val REREG_URI_HOST = "rereg"
+
+ fun isValidReRegistrationQr(data: String): Boolean {
+ val uri = Uri.parse(data)
+
+ if (!uri.isHierarchical) {
+ return false
+ }
+
+ val ephemeralId: String? = uri.getQueryParameter("uuid")
+ val publicKeyEncoded: String? = uri.getQueryParameter("pub_key")
+ return uri.host == REREG_URI_HOST && ephemeralId.isNotNullOrBlank() && publicKeyEncoded.isNotNullOrBlank()
+ }
+
+ /**
+ * Send registration provisioning message to new device.
+ */
+ fun transferAccount(reRegisterUri: String): TransferAccountResult {
+ if (!isValidReRegistrationQr(reRegisterUri)) {
+ Log.w(TAG, "Invalid quick re-register qr data")
+ return TransferAccountResult.FAILED
+ }
+
+ val uri = Uri.parse(reRegisterUri)
+
+ try {
+ val ephemeralId: String? = uri.getQueryParameter("uuid")
+ val publicKeyEncoded: String? = uri.getQueryParameter("pub_key")
+ val publicKey = Curve.decodePoint(publicKeyEncoded?.let { decode(it) }, 0)
+
+ if (ephemeralId == null || publicKeyEncoded == null) {
+ Log.w(TAG, "Invalid link data hasId: ${ephemeralId != null} hasKey: ${publicKeyEncoded != null}")
+ return TransferAccountResult.FAILED
+ }
+
+ val pin = SignalStore.svr.pin ?: run {
+ Log.w(TAG, "No pin")
+ return TransferAccountResult.FAILED
+ }
+
+ AppDependencies
+ .signalServiceAccountManager
+ .registrationApi
+ .sendReRegisterDeviceProvisioningMessage(
+ ephemeralId,
+ publicKey,
+ RegistrationProvisionMessage(
+ e164 = SignalStore.account.requireE164(),
+ aci = SignalStore.account.requireAci().toByteString(),
+ accountEntropyPool = Hex.toStringCondensed(SignalStore.svr.masterKey.serialize()),
+ pin = pin,
+ platform = RegistrationProvisionMessage.Platform.ANDROID,
+ backupTimestampMs = SignalStore.backup.lastBackupTime.coerceAtLeast(0L),
+ tier = when (SignalStore.backup.backupTier) {
+ MessageBackupTier.PAID -> RegistrationProvisionMessage.Tier.PAID
+ MessageBackupTier.FREE,
+ null -> RegistrationProvisionMessage.Tier.FREE
+ }
+ )
+ )
+ .successOrThrow()
+
+ Log.i(TAG, "Re-registration provisioning message sent")
+ } catch (e: IOException) {
+ Log.w(TAG, "Exception re-registering new device", e)
+ return TransferAccountResult.FAILED
+ } catch (e: InvalidKeyException) {
+ Log.w(TAG, "Exception re-registering new device", e)
+ return TransferAccountResult.FAILED
+ }
+
+ return TransferAccountResult.SUCCESS
+ }
+
+ enum class TransferAccountResult {
+ SUCCESS,
+ FAILED
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt
new file mode 100644
index 0000000000..c572883b2a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/data/RegistrationRepository.kt
@@ -0,0 +1,623 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.data
+
+import android.app.backup.BackupManager
+import android.content.Context
+import androidx.annotation.VisibleForTesting
+import androidx.core.app.NotificationManagerCompat
+import com.google.android.gms.auth.api.phone.SmsRetriever
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.tasks.await
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import org.greenrobot.eventbus.EventBus
+import org.greenrobot.eventbus.Subscribe
+import org.signal.core.util.Base64
+import org.signal.core.util.logging.Log
+import org.signal.libsignal.protocol.IdentityKeyPair
+import org.signal.libsignal.protocol.util.KeyHelper
+import org.signal.libsignal.zkgroup.profiles.ProfileKey
+import org.thoughtcrime.securesms.AppCapabilities
+import org.thoughtcrime.securesms.crypto.PreKeyUtil
+import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
+import org.thoughtcrime.securesms.crypto.SenderKeyUtil
+import org.thoughtcrime.securesms.crypto.storage.PreKeyMetadataStore
+import org.thoughtcrime.securesms.crypto.storage.SignalServiceAccountDataStoreImpl
+import org.thoughtcrime.securesms.database.IdentityTable
+import org.thoughtcrime.securesms.database.SignalDatabase
+import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata
+import org.thoughtcrime.securesms.dependencies.AppDependencies
+import org.thoughtcrime.securesms.gcm.FcmUtil
+import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
+import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
+import org.thoughtcrime.securesms.jobs.RotateCertificateJob
+import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.notifications.NotificationIds
+import org.thoughtcrime.securesms.pin.Svr3Migration
+import org.thoughtcrime.securesms.pin.SvrRepository
+import org.thoughtcrime.securesms.pin.SvrWrongPinException
+import org.thoughtcrime.securesms.profiles.AvatarHelper
+import org.thoughtcrime.securesms.push.AccountManagerFactory
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult
+import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getAciIdentityKeyPair
+import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getAciPreKeyCollection
+import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getPniIdentityKeyPair
+import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getPniPreKeyCollection
+import org.thoughtcrime.securesms.registration.data.RegistrationData
+import org.thoughtcrime.securesms.registration.data.network.BackupAuthCheckResult
+import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
+import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult
+import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
+import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
+import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest
+import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
+import org.thoughtcrime.securesms.service.DirectoryRefreshListener
+import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener
+import org.thoughtcrime.securesms.util.TextSecurePreferences
+import org.whispersystems.signalservice.api.NetworkResult
+import org.whispersystems.signalservice.api.SvrNoDataException
+import org.whispersystems.signalservice.api.account.AccountAttributes
+import org.whispersystems.signalservice.api.account.PreKeyCollection
+import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
+import org.whispersystems.signalservice.api.kbs.MasterKey
+import org.whispersystems.signalservice.api.kbs.PinHashUtil
+import org.whispersystems.signalservice.api.push.ServiceId
+import org.whispersystems.signalservice.api.push.ServiceId.ACI
+import org.whispersystems.signalservice.api.push.ServiceId.PNI
+import org.whispersystems.signalservice.api.push.SignalServiceAddress
+import org.whispersystems.signalservice.api.registration.RegistrationApi
+import org.whispersystems.signalservice.api.svr.Svr3Credentials
+import org.whispersystems.signalservice.internal.push.AuthCredentials
+import org.whispersystems.signalservice.internal.push.PushServiceSocket
+import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataHeaders
+import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
+import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
+import java.io.IOException
+import java.nio.charset.StandardCharsets
+import java.util.Locale
+import java.util.Optional
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * A repository that deals with disk I/O during account registration.
+ */
+object RegistrationRepository {
+
+ private val TAG = Log.tag(RegistrationRepository::class.java)
+
+ private val PUSH_REQUEST_TIMEOUT = 5.seconds.inWholeMilliseconds
+
+ /**
+ * Retrieve the FCM token from the Firebase service.
+ */
+ suspend fun getFcmToken(context: Context): String? =
+ withContext(Dispatchers.Default) {
+ FcmUtil.getToken(context).orElse(null)
+ }
+
+ /**
+ * Queries, and creates if needed, the local registration ID.
+ */
+ @JvmStatic
+ fun getRegistrationId(): Int {
+ // TODO [regv2]: make creation more explicit instead of hiding it in this getter
+ var registrationId = SignalStore.account.registrationId
+ if (registrationId == 0) {
+ registrationId = KeyHelper.generateRegistrationId(false)
+ SignalStore.account.registrationId = registrationId
+ }
+ return registrationId
+ }
+
+ /**
+ * Queries, and creates if needed, the local PNI registration ID.
+ */
+ @JvmStatic
+ fun getPniRegistrationId(): Int {
+ // TODO [regv2]: make creation more explicit instead of hiding it in this getter
+ var pniRegistrationId = SignalStore.account.pniRegistrationId
+ if (pniRegistrationId == 0) {
+ pniRegistrationId = KeyHelper.generateRegistrationId(false)
+ SignalStore.account.pniRegistrationId = pniRegistrationId
+ }
+ return pniRegistrationId
+ }
+
+ /**
+ * Queries, and creates if needed, the local profile key.
+ */
+ @JvmStatic
+ suspend fun getProfileKey(e164: String): ProfileKey =
+ withContext(Dispatchers.IO) {
+ // TODO [regv2]: make creation more explicit instead of hiding it in this getter
+ val recipientTable = SignalDatabase.recipients
+ val recipient = recipientTable.getByE164(e164)
+ var profileKey = if (recipient.isPresent) {
+ ProfileKeyUtil.profileKeyOrNull(Recipient.resolved(recipient.get()).profileKey)
+ } else {
+ null
+ }
+ if (profileKey == null) {
+ profileKey = ProfileKeyUtil.createNew()
+ Log.i(TAG, "No profile key found, created a new one")
+ }
+ profileKey
+ }
+
+ /**
+ * Takes a server response from a successful registration and persists the relevant data.
+ */
+ @JvmStatic
+ suspend fun registerAccountLocally(context: Context, data: LocalRegistrationMetadata) =
+ withContext(Dispatchers.IO) {
+ Log.v(TAG, "registerAccountLocally()")
+ val aciIdentityKeyPair = data.getAciIdentityKeyPair()
+ val pniIdentityKeyPair = data.getPniIdentityKeyPair()
+ SignalStore.account.restoreAciIdentityKeyFromBackup(aciIdentityKeyPair.publicKey.serialize(), aciIdentityKeyPair.privateKey.serialize())
+ SignalStore.account.restorePniIdentityKeyFromBackup(pniIdentityKeyPair.publicKey.serialize(), pniIdentityKeyPair.privateKey.serialize())
+
+ val aciPreKeyCollection = data.getAciPreKeyCollection()
+ val pniPreKeyCollection = data.getPniPreKeyCollection()
+ val aci: ACI = ACI.parseOrThrow(data.aci)
+ val pni: PNI = PNI.parseOrThrow(data.pni)
+ val hasPin: Boolean = data.hasPin
+
+ SignalStore.account.setAci(aci)
+ SignalStore.account.setPni(pni)
+
+ AppDependencies.resetProtocolStores()
+
+ AppDependencies.protocolStore.aci().sessions().archiveAllSessions()
+ AppDependencies.protocolStore.pni().sessions().archiveAllSessions()
+ SenderKeyUtil.clearAllState()
+
+ val aciProtocolStore = AppDependencies.protocolStore.aci()
+ val aciMetadataStore = SignalStore.account.aciPreKeys
+
+ val pniProtocolStore = AppDependencies.protocolStore.pni()
+ val pniMetadataStore = SignalStore.account.pniPreKeys
+
+ storeSignedAndLastResortPreKeys(aciProtocolStore, aciMetadataStore, aciPreKeyCollection)
+ storeSignedAndLastResortPreKeys(pniProtocolStore, pniMetadataStore, pniPreKeyCollection)
+
+ val recipientTable = SignalDatabase.recipients
+ val selfId = Recipient.trustedPush(aci, pni, data.e164).id
+
+ recipientTable.setProfileSharing(selfId, true)
+ recipientTable.markRegisteredOrThrow(selfId, aci)
+ recipientTable.linkIdsForSelf(aci, pni, data.e164)
+ recipientTable.setProfileKey(selfId, ProfileKey(data.profileKey.toByteArray()))
+
+ AppDependencies.recipientCache.clearSelf()
+
+ SignalStore.account.setE164(data.e164)
+ SignalStore.account.fcmToken = data.fcmToken
+ SignalStore.account.fcmEnabled = data.fcmEnabled
+
+ val now = System.currentTimeMillis()
+ saveOwnIdentityKey(selfId, aci, aciProtocolStore, now)
+ saveOwnIdentityKey(selfId, pni, pniProtocolStore, now)
+
+ SignalStore.account.setServicePassword(data.servicePassword)
+ SignalStore.account.setRegistered(true)
+ TextSecurePreferences.setPromptedPushRegistration(context, true)
+ TextSecurePreferences.setUnauthorizedReceived(context, false)
+ NotificationManagerCompat.from(context).cancel(NotificationIds.UNREGISTERED_NOTIFICATION_ID)
+
+ val masterKey = if (data.masterKey != null) MasterKey(data.masterKey.toByteArray()) else null
+ SvrRepository.onRegistrationComplete(masterKey, data.pin, hasPin, data.reglockEnabled)
+
+ AppDependencies.resetNetwork()
+ AppDependencies.incomingMessageObserver
+ PreKeysSyncJob.enqueue()
+
+ val jobManager = AppDependencies.jobManager
+ jobManager.add(DirectoryRefreshJob(false))
+ jobManager.add(RotateCertificateJob())
+
+ DirectoryRefreshListener.schedule(context)
+ RotateSignedPreKeyListener.schedule(context)
+ }
+
+ @JvmStatic
+ private fun saveOwnIdentityKey(selfId: RecipientId, serviceId: ServiceId, protocolStore: SignalServiceAccountDataStoreImpl, now: Long) {
+ protocolStore.identities().saveIdentityWithoutSideEffects(
+ selfId,
+ serviceId,
+ protocolStore.identityKeyPair.publicKey,
+ IdentityTable.VerifiedStatus.VERIFIED,
+ true,
+ now,
+ true
+ )
+ }
+
+ @JvmStatic
+ private fun storeSignedAndLastResortPreKeys(protocolStore: SignalServiceAccountDataStoreImpl, metadataStore: PreKeyMetadataStore, preKeyCollection: PreKeyCollection) {
+ PreKeyUtil.storeSignedPreKey(protocolStore, metadataStore, preKeyCollection.signedPreKey)
+ metadataStore.isSignedPreKeyRegistered = true
+ metadataStore.activeSignedPreKeyId = preKeyCollection.signedPreKey.id
+ metadataStore.lastSignedPreKeyRotationTime = System.currentTimeMillis()
+
+ PreKeyUtil.storeLastResortKyberPreKey(protocolStore, metadataStore, preKeyCollection.lastResortKyberPreKey)
+ metadataStore.lastResortKyberPreKeyId = preKeyCollection.lastResortKyberPreKey.id
+ metadataStore.lastResortKyberPreKeyRotationTime = System.currentTimeMillis()
+ }
+
+ fun canUseLocalRecoveryPassword(): Boolean {
+ val recoveryPassword = SignalStore.svr.recoveryPassword
+ val pinHash = SignalStore.svr.localPinHash
+ return recoveryPassword != null && pinHash != null
+ }
+
+ fun doesPinMatchLocalHash(pin: String): Boolean {
+ val pinHash = SignalStore.svr.localPinHash ?: throw IllegalStateException("Local PIN hash is not present!")
+ return PinHashUtil.verifyLocalPinHash(pinHash, pin)
+ }
+
+ suspend fun fetchMasterKeyFromSvrRemote(pin: String, svr2Credentials: AuthCredentials?, svr3Credentials: Svr3Credentials?): MasterKey =
+ withContext(Dispatchers.IO) {
+ val credentialSet = SvrAuthCredentialSet(svr2Credentials = svr2Credentials, svr3Credentials = svr3Credentials)
+ val masterKey = SvrRepository.restoreMasterKeyPreRegistration(credentialSet, pin)
+ return@withContext masterKey
+ }
+
+ /**
+ * Validates a session ID.
+ */
+ private suspend fun validateSession(context: Context, sessionId: String, e164: String, password: String): RegistrationSessionCheckResult =
+ withContext(Dispatchers.IO) {
+ val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
+ Log.d(TAG, "Validating registration session with service.")
+ val registrationSessionResult = api.getRegistrationSessionStatus(sessionId)
+ return@withContext RegistrationSessionCheckResult.from(registrationSessionResult)
+ }
+
+ /**
+ * Initiates a new registration session on the service.
+ */
+ suspend fun createSession(context: Context, e164: String, password: String, mcc: String?, mnc: String?): RegistrationSessionCreationResult =
+ withContext(Dispatchers.IO) {
+ Log.d(TAG, "About to create a registration session…")
+ val fcmToken: String? = FcmUtil.getToken(context).orElse(null)
+ val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
+
+ val registrationSessionResult = if (fcmToken == null) {
+ Log.d(TAG, "Creating registration session without FCM token.")
+ api.createRegistrationSession(null, mcc, mnc)
+ } else {
+ Log.d(TAG, "Creating registration session with FCM token.")
+ createSessionAndBlockForPushChallenge(api, fcmToken, mcc, mnc)
+ }
+ val result = RegistrationSessionCreationResult.from(registrationSessionResult)
+ if (result is RegistrationSessionCreationResult.Success) {
+ Log.d(TAG, "Updating registration session and E164 in value store.")
+ SignalStore.registration.sessionId = result.getMetadata().body.id
+ SignalStore.registration.sessionE164 = e164
+ }
+
+ return@withContext result
+ }
+
+ /**
+ * Validates an existing session, if its ID is provided. If the session is expired/invalid, or none is provided, it will attempt to initiate a new session.
+ */
+ suspend fun createOrValidateSession(context: Context, sessionId: String?, e164: String, password: String, mcc: String?, mnc: String?): RegistrationSessionResult {
+ val savedSessionId = if (sessionId == null && e164 == SignalStore.registration.sessionE164) {
+ SignalStore.registration.sessionId
+ } else {
+ sessionId
+ }
+
+ if (savedSessionId != null) {
+ Log.d(TAG, "Validating existing registration session.")
+ val sessionValidationResult = validateSession(context, savedSessionId, e164, password)
+ when (sessionValidationResult) {
+ is RegistrationSessionCheckResult.Success -> {
+ Log.d(TAG, "Existing registration session is valid.")
+ return sessionValidationResult
+ }
+
+ is RegistrationSessionCheckResult.UnknownError -> {
+ Log.w(TAG, "Encountered error when validating existing session.", sessionValidationResult.getCause())
+ return sessionValidationResult
+ }
+
+ is RegistrationSessionCheckResult.SessionNotFound -> {
+ Log.i(TAG, "Current session is invalid or has expired. Must create new one.")
+ // fall through to creation
+ }
+ }
+ }
+ return createSession(context, e164, password, mcc, mnc)
+ }
+
+ /**
+ * Asks the service to send a verification code through one of our supported channels (SMS, phone call).
+ */
+ suspend fun requestSmsCode(context: Context, sessionId: String, e164: String, password: String, mode: E164VerificationMode): VerificationCodeRequestResult =
+ withContext(Dispatchers.IO) {
+ val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
+
+ val codeRequestResult = api.requestSmsVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported, mode.transport)
+
+ return@withContext VerificationCodeRequestResult.from(codeRequestResult)
+ }
+
+ /**
+ * Submits the user-entered verification code to the service.
+ */
+ suspend fun submitVerificationCode(context: Context, sessionId: String, registrationData: RegistrationData): VerificationCodeRequestResult =
+ withContext(Dispatchers.IO) {
+ val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, registrationData.e164, SignalServiceAddress.DEFAULT_DEVICE_ID, registrationData.password).registrationApi
+ val result = api.verifyAccount(sessionId = sessionId, verificationCode = registrationData.code)
+ return@withContext VerificationCodeRequestResult.from(result)
+ }
+
+ /**
+ * Submits the solved captcha token to the service.
+ */
+ suspend fun submitCaptchaToken(context: Context, e164: String, password: String, sessionId: String, captchaToken: String): VerificationCodeRequestResult =
+ withContext(Dispatchers.IO) {
+ val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
+ val captchaSubmissionResult = api.submitCaptchaToken(sessionId = sessionId, captchaToken = captchaToken)
+ return@withContext VerificationCodeRequestResult.from(captchaSubmissionResult)
+ }
+
+ suspend fun requestAndVerifyPushToken(context: Context, sessionId: String, e164: String, password: String) =
+ withContext(Dispatchers.IO) {
+ val fcmToken = getFcmToken(context)
+ val accountManager = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password)
+ val pushChallenge = PushChallengeRequest.getPushChallengeBlocking(accountManager, sessionId, Optional.ofNullable(fcmToken), PUSH_REQUEST_TIMEOUT).orElse(null)
+ val pushSubmissionResult = accountManager.registrationApi.submitPushChallengeToken(sessionId = sessionId, pushChallengeToken = pushChallenge)
+ return@withContext VerificationCodeRequestResult.from(pushSubmissionResult)
+ }
+
+ /**
+ * Submit the necessary assets as a verified account so that the user can actually use the service.
+ */
+ suspend fun registerAccount(context: Context, sessionId: String?, registrationData: RegistrationData, pin: String? = null, masterKeyProducer: MasterKeyProducer? = null): RegisterAccountResult =
+ withContext(Dispatchers.IO) {
+ Log.v(TAG, "registerAccount()")
+ val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, registrationData.e164, SignalServiceAddress.DEFAULT_DEVICE_ID, registrationData.password).registrationApi
+
+ val universalUnidentifiedAccess: Boolean = TextSecurePreferences.isUniversalUnidentifiedAccess(context)
+ val unidentifiedAccessKey: ByteArray = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey)
+
+ val masterKey: MasterKey?
+ try {
+ masterKey = masterKeyProducer?.produceMasterKey()
+ } catch (e: SvrNoDataException) {
+ return@withContext RegisterAccountResult.SvrNoData(e)
+ } catch (e: SvrWrongPinException) {
+ return@withContext RegisterAccountResult.SvrWrongPin(e)
+ } catch (e: IOException) {
+ return@withContext RegisterAccountResult.UnknownError(e)
+ }
+
+ val registrationLock: String? = masterKey?.deriveRegistrationLock()
+
+ val accountAttributes = AccountAttributes(
+ signalingKey = null,
+ registrationId = registrationData.registrationId,
+ fetchesMessages = registrationData.isNotFcm,
+ registrationLock = registrationLock,
+ unidentifiedAccessKey = unidentifiedAccessKey,
+ unrestrictedUnidentifiedAccess = universalUnidentifiedAccess,
+ capabilities = AppCapabilities.getCapabilities(true),
+ discoverableByPhoneNumber = SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode == PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE,
+ name = null,
+ pniRegistrationId = registrationData.pniRegistrationId,
+ recoveryPassword = registrationData.recoveryPassword
+ )
+
+ SignalStore.account.generateAciIdentityKeyIfNecessary()
+ val aciIdentity: IdentityKeyPair = SignalStore.account.aciIdentityKey
+
+ SignalStore.account.generatePniIdentityKeyIfNecessary()
+ val pniIdentity: IdentityKeyPair = SignalStore.account.pniIdentityKey
+
+ val aciPreKeyCollection = generateSignedAndLastResortPreKeys(aciIdentity, SignalStore.account.aciPreKeys)
+ val pniPreKeyCollection = generateSignedAndLastResortPreKeys(pniIdentity, SignalStore.account.pniPreKeys)
+
+ val result: NetworkResult = api.registerAccount(sessionId, registrationData.recoveryPassword, accountAttributes, aciPreKeyCollection, pniPreKeyCollection, registrationData.fcmToken, true)
+ .map { accountRegistrationResponse: VerifyAccountResponse ->
+ AccountRegistrationResult(
+ uuid = accountRegistrationResponse.uuid,
+ pni = accountRegistrationResponse.pni,
+ storageCapable = accountRegistrationResponse.storageCapable,
+ number = accountRegistrationResponse.number,
+ masterKey = masterKey,
+ pin = pin,
+ aciPreKeyCollection = aciPreKeyCollection,
+ pniPreKeyCollection = pniPreKeyCollection
+ )
+ }
+
+ return@withContext RegisterAccountResult.from(result)
+ }
+
+ private suspend fun createSessionAndBlockForPushChallenge(accountManager: RegistrationApi, fcmToken: String, mcc: String?, mnc: String?): NetworkResult =
+ withContext(Dispatchers.IO) {
+ // TODO [regv2]: do not use event bus nor latch
+ val subscriber = PushTokenChallengeSubscriber()
+ val eventBus = EventBus.getDefault()
+ eventBus.register(subscriber)
+
+ try {
+ Log.d(TAG, "Requesting a registration session with FCM token…")
+ val sessionCreationResponse = accountManager.createRegistrationSession(fcmToken, mcc, mnc)
+ if (sessionCreationResponse !is NetworkResult.Success) {
+ return@withContext sessionCreationResponse
+ }
+
+ val receivedPush = subscriber.latch.await(PUSH_REQUEST_TIMEOUT, TimeUnit.MILLISECONDS)
+ eventBus.unregister(subscriber)
+
+ if (receivedPush) {
+ val challenge = subscriber.challenge
+ if (challenge != null) {
+ Log.w(TAG, "Push challenge token received.")
+ return@withContext accountManager.submitPushChallengeToken(sessionCreationResponse.result.body.id, challenge)
+ } else {
+ Log.w(TAG, "Push received but challenge token was null.")
+ }
+ } else {
+ Log.i(TAG, "Push challenge timed out.")
+ }
+ Log.i(TAG, "Push challenge unsuccessful. Continuing with session created without one.")
+ return@withContext sessionCreationResponse
+ } catch (ex: Exception) {
+ Log.w(TAG, "Exception caught, but the earlier try block should have caught it?", ex)
+ return@withContext NetworkResult.ApplicationError(ex)
+ }
+ }
+
+ @JvmStatic
+ fun deriveTimestamp(headers: RegistrationSessionMetadataHeaders, deltaSeconds: Int?): Long {
+ if (deltaSeconds == null) {
+ return 0L
+ }
+
+ val timestamp: Long = headers.timestamp
+ return timestamp + deltaSeconds.seconds.inWholeMilliseconds
+ }
+
+ suspend fun hasValidSvrAuthCredentials(context: Context, e164: String, password: String): BackupAuthCheckResult =
+ withContext(Dispatchers.IO) {
+ val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
+
+ val svr3Result = SignalStore.svr.svr3AuthTokens
+ ?.takeIf { Svr3Migration.shouldReadFromSvr3 }
+ ?.takeIf { it.isNotEmpty() }
+ ?.toSvrCredentials()
+ ?.let { authTokens ->
+ api
+ .validateSvr3AuthCredential(e164, authTokens)
+ .runIfSuccessful {
+ val removedInvalidTokens = SignalStore.svr.removeSvr3AuthTokens(it.invalid)
+ if (removedInvalidTokens) {
+ BackupManager(context).dataChanged()
+ }
+ }
+ .let { BackupAuthCheckResult.fromV3(it) }
+ }
+
+ if (svr3Result is BackupAuthCheckResult.SuccessWithCredentials) {
+ Log.d(TAG, "Found valid SVR3 credentials.")
+ return@withContext svr3Result
+ }
+
+ Log.d(TAG, "No valid SVR3 credentials, looking for SVR2.")
+
+ return@withContext SignalStore.svr.svr2AuthTokens
+ ?.takeIf { it.isNotEmpty() }
+ ?.toSvrCredentials()
+ ?.let { authTokens ->
+ api
+ .validateSvr2AuthCredential(e164, authTokens)
+ .runIfSuccessful {
+ val removedInvalidTokens = SignalStore.svr.removeSvr2AuthTokens(it.invalid)
+ if (removedInvalidTokens) {
+ BackupManager(context).dataChanged()
+ }
+ }
+ .let { BackupAuthCheckResult.fromV2(it) }
+ } ?: BackupAuthCheckResult.SuccessWithoutCredentials()
+ }
+
+ /** Converts the basic-auth creds we have locally into username:password pairs that are suitable for handing off to the service. */
+ private fun List.toSvrCredentials(): List {
+ return this
+ .asSequence()
+ .filterNotNull()
+ .take(10)
+ .map { it.replace("Basic ", "").trim() }
+ .mapNotNull {
+ try {
+ Base64.decode(it)
+ } catch (e: IOException) {
+ Log.w(TAG, "Encountered error trying to decode a token!", e)
+ null
+ }
+ }
+ .map { String(it, StandardCharsets.ISO_8859_1) }
+ .toList()
+ }
+
+ /**
+ * Starts an SMS listener to auto-enter a verification code.
+ *
+ * The listener [lives for 5 minutes](https://developers.google.com/android/reference/com/google/android/gms/auth/api/phone/SmsRetrieverApi).
+ *
+ * @return whether or not the Play Services SMS Listener was successfully registered.
+ */
+ suspend fun registerSmsListener(context: Context): Boolean {
+ Log.d(TAG, "Attempting to start verification code SMS retriever.")
+ val started = withTimeoutOrNull(5.seconds.inWholeMilliseconds) {
+ try {
+ SmsRetriever.getClient(context).startSmsRetriever().await()
+ Log.d(TAG, "Successfully started verification code SMS retriever.")
+ return@withTimeoutOrNull true
+ } catch (ex: Exception) {
+ Log.w(TAG, "Could not start verification code SMS retriever due to exception.", ex)
+ return@withTimeoutOrNull false
+ }
+ }
+
+ if (started == null) {
+ Log.w(TAG, "Could not start verification code SMS retriever due to timeout.")
+ }
+
+ return started == true
+ }
+
+ @VisibleForTesting
+ fun generateSignedAndLastResortPreKeys(identity: IdentityKeyPair, metadataStore: PreKeyMetadataStore): PreKeyCollection {
+ val signedPreKey = PreKeyUtil.generateSignedPreKey(metadataStore.nextSignedPreKeyId, identity.privateKey)
+ val lastResortKyberPreKey = PreKeyUtil.generateLastResortKyberPreKey(metadataStore.nextKyberPreKeyId, identity.privateKey)
+
+ return PreKeyCollection(
+ identity.publicKey,
+ signedPreKey,
+ lastResortKyberPreKey
+ )
+ }
+
+ fun isMissingProfileData(): Boolean {
+ return Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(AppDependencies.application, Recipient.self().id)
+ }
+
+ fun interface MasterKeyProducer {
+ @Throws(IOException::class, SvrWrongPinException::class, SvrNoDataException::class)
+ fun produceMasterKey(): MasterKey
+ }
+
+ enum class E164VerificationMode(val isSmsRetrieverSupported: Boolean, val transport: PushServiceSocket.VerificationCodeTransport) {
+ SMS_WITH_LISTENER(true, PushServiceSocket.VerificationCodeTransport.SMS),
+ SMS_WITHOUT_LISTENER(false, PushServiceSocket.VerificationCodeTransport.SMS),
+ PHONE_CALL(false, PushServiceSocket.VerificationCodeTransport.VOICE)
+ }
+
+ private class PushTokenChallengeSubscriber {
+ var challenge: String? = null
+ val latch = CountDownLatch(1)
+
+ @Subscribe
+ fun onChallengeEvent(pushChallengeEvent: PushChallengeRequest.PushChallengeEvent) {
+ Log.d(TAG, "Push challenge received!")
+ challenge = pushChallengeEvent.challenge
+ latch.countDown()
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountActivity.kt
new file mode 100644
index 0000000000..72dcde1463
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountActivity.kt
@@ -0,0 +1,346 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.olddevice
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.result.ActivityResultLauncher
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import org.signal.core.ui.BottomSheets
+import org.signal.core.ui.Buttons
+import org.signal.core.ui.Dialogs
+import org.signal.core.ui.Previews
+import org.signal.core.ui.SignalPreview
+import org.signal.core.ui.Texts
+import org.signal.core.ui.horizontalGutters
+import org.signal.core.ui.theme.SignalTheme
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.BiometricDeviceAuthentication
+import org.thoughtcrime.securesms.BiometricDeviceLockContract
+import org.thoughtcrime.securesms.PassphraseRequiredActivity
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.fonts.SignalSymbols
+import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
+import org.thoughtcrime.securesms.util.CommunicationActions
+import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
+import org.thoughtcrime.securesms.util.DynamicTheme
+import org.thoughtcrime.securesms.util.SpanUtil
+import org.thoughtcrime.securesms.util.viewModel
+
+/**
+ * Launched after scanning QR code from new device to start the transfer/reregistration process from
+ * old phone to new phone.
+ */
+class TransferAccountActivity : PassphraseRequiredActivity() {
+
+ companion object {
+ private val TAG = Log.tag(TransferAccountActivity::class)
+
+ private const val KEY_URI = "URI"
+
+ // TODO [backups] Put actual learn more url
+ const val LEARN_MORE_URL = "https://signal.org#"
+
+ fun intent(context: Context, uri: String): Intent {
+ return Intent(context, TransferAccountActivity::class.java).apply {
+ putExtra(KEY_URI, uri)
+ }
+ }
+ }
+
+ private val theme: DynamicTheme = DynamicNoActionBarTheme()
+
+ private val viewModel: TransferAccountViewModel by viewModel {
+ TransferAccountViewModel(intent.getStringExtra(KEY_URI)!!)
+ }
+
+ private lateinit var biometricAuth: BiometricDeviceAuthentication
+ private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher
+
+ override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
+ super.onCreate(savedInstanceState, ready)
+ theme.onCreate(this)
+
+ if (!SignalStore.account.isRegistered) {
+ finish()
+ }
+
+ biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int ->
+ if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
+ Log.i(TAG, "Device authentication succeeded via contract")
+ transferAccount()
+ }
+ }
+
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
+ .setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
+ .setTitle(getString(R.string.TransferAccount_unlock_to_transfer))
+ .setConfirmationRequired(true)
+ .build()
+
+ biometricAuth = BiometricDeviceAuthentication(
+ BiometricManager.from(this),
+ BiometricPrompt(this, BiometricAuthenticationListener()),
+ promptInfo
+ )
+
+ setContent {
+ val state by viewModel.state.collectAsState()
+
+ SignalTheme {
+ TransferToNewDevice(
+ state = state,
+ onTransferAccount = this::authenticate,
+ clearReRegisterResult = viewModel::clearReRegisterResult,
+ onBackClicked = { finish() }
+ )
+ }
+ }
+ }
+
+ override fun onPause() {
+ super.onPause()
+ biometricAuth.cancelAuthentication()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ theme.onResume(this)
+ }
+
+ private fun authenticate() {
+ val canAuthenticate = biometricAuth.authenticate(this, true) {
+ biometricDeviceLockLauncher.launch(getString(R.string.TransferAccount_unlock_to_transfer))
+ }
+
+ if (!canAuthenticate) {
+ Log.w(TAG, "Device authentication not available")
+ transferAccount()
+ }
+ }
+
+ private fun transferAccount() {
+ Log.d(TAG, "transferAccount()")
+
+ viewModel.transferAccount()
+ }
+
+ private inner class BiometricAuthenticationListener : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
+ Log.w(TAG, "Device authentication error: $errorCode")
+ onAuthenticationFailed()
+ }
+
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ Log.i(TAG, "Device authentication succeeded")
+ transferAccount()
+ }
+
+ override fun onAuthenticationFailed() {
+ Log.w(TAG, "Device authentication failed")
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TransferToNewDevice(
+ state: TransferAccountViewModel.TransferAccountState,
+ onTransferAccount: () -> Unit = {},
+ clearReRegisterResult: () -> Unit = {},
+ onBackClicked: () -> Unit = {}
+) {
+ Scaffold(
+ topBar = { TopAppBarContent(onBackClicked = onBackClicked) }
+ ) { contentPadding ->
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .padding(contentPadding)
+ .horizontalGutters()
+ ) {
+ Image(
+ painter = painterResource(R.drawable.image_transfer_phones),
+ contentDescription = null,
+ modifier = Modifier.padding(top = 20.dp, bottom = 28.dp)
+ )
+
+ val context = LocalContext.current
+ val learnMore = stringResource(id = R.string.TransferAccount_learn_more)
+ val fullString = stringResource(id = R.string.TransferAccount_body, learnMore)
+ val spanned = SpanUtil.urlSubsequence(fullString, learnMore, TransferAccountActivity.LEARN_MORE_URL)
+ Texts.LinkifiedText(
+ textWithUrlSpans = spanned,
+ onUrlClick = { CommunicationActions.openBrowserLink(context, it) },
+ style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center)
+ )
+
+ Spacer(modifier = Modifier.height(28.dp))
+
+ AnimatedContent(
+ targetState = state.inProgress,
+ contentAlignment = Alignment.Center
+ ) { inProgress ->
+ if (inProgress) {
+ CircularProgressIndicator()
+ } else {
+ Buttons.LargeTonal(
+ onClick = onTransferAccount,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(id = R.string.TransferAccount_button))
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = buildAnnotatedString {
+ SignalSymbol(SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.LOCK)
+ append(" ")
+ append(stringResource(id = R.string.TransferAccount_messages_e2e))
+ },
+ style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
+ )
+ }
+
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ when (state.reRegisterResult) {
+ QuickRegistrationRepository.TransferAccountResult.SUCCESS -> {
+ ModalBottomSheet(
+ dragHandle = null,
+ onDismissRequest = clearReRegisterResult,
+ sheetState = sheetState
+ ) {
+ ContinueOnOtherDevice()
+ }
+ }
+
+ QuickRegistrationRepository.TransferAccountResult.FAILED -> {
+ Dialogs.SimpleAlertDialog(
+ title = Dialogs.NoTitle,
+ body = stringResource(R.string.RegistrationActivity_unable_to_connect_to_service),
+ confirm = stringResource(android.R.string.ok),
+ onConfirm = clearReRegisterResult,
+ onDismiss = clearReRegisterResult
+ )
+ }
+
+ null -> Unit
+ }
+ }
+}
+
+@SignalPreview
+@Composable
+private fun TransferToNewDevicePreview() {
+ Previews.Preview {
+ TransferToNewDevice(state = TransferAccountViewModel.TransferAccountState("sgnl://rereg"))
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun TopAppBarContent(onBackClicked: () -> Unit) {
+ TopAppBar(
+ title = {
+ Text(text = stringResource(R.string.TransferAccount_title))
+ },
+ navigationIcon = {
+ IconButton(onClick = onBackClicked) {
+ Icon(
+ painter = painterResource(R.drawable.symbol_x_24),
+ tint = MaterialTheme.colorScheme.onSurface,
+ contentDescription = null
+ )
+ }
+ }
+ )
+}
+
+/**
+ * Shown after successfully sending provisioning message to new device.
+ */
+@Composable
+fun ContinueOnOtherDevice() {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalGutters()
+ .padding(bottom = 54.dp)
+ ) {
+ BottomSheets.Handle()
+
+ Spacer(modifier = Modifier.height(26.dp))
+
+ Image(
+ painter = painterResource(R.drawable.image_other_device),
+ contentDescription = null,
+ modifier = Modifier.padding(bottom = 20.dp)
+ )
+
+ Text(
+ text = stringResource(id = R.string.TransferAccount_continue_on_your_other_device),
+ style = MaterialTheme.typography.titleLarge
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = stringResource(id = R.string.TransferAccount_continue_on_your_other_device_details),
+ style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
+ )
+
+ Spacer(modifier = Modifier.height(36.dp))
+
+ CircularProgressIndicator(modifier = Modifier.size(44.dp))
+ }
+}
+
+@SignalPreview
+@Composable
+private fun ContinueOnOtherDevicePreview() {
+ Previews.Preview {
+ ContinueOnOtherDevice()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountViewModel.kt
new file mode 100644
index 0000000000..f9e518a7c2
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/olddevice/TransferAccountViewModel.kt
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.olddevice
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.thoughtcrime.securesms.registrationv3.data.QuickRegistrationRepository
+
+class TransferAccountViewModel(reRegisterUri: String) : ViewModel() {
+
+ private val store: MutableStateFlow = MutableStateFlow(TransferAccountState(reRegisterUri))
+
+ val state: StateFlow = store
+
+ fun transferAccount() {
+ viewModelScope.launch(Dispatchers.IO) {
+ store.update { it.copy(inProgress = true) }
+ val result = QuickRegistrationRepository.transferAccount(store.value.reRegisterUri)
+ store.update { it.copy(reRegisterResult = result, inProgress = false) }
+ }
+ }
+
+ fun clearReRegisterResult() {
+ store.update { it.copy(reRegisterResult = null) }
+ }
+
+ data class TransferAccountState(
+ val reRegisterUri: String,
+ val inProgress: Boolean = false,
+ val reRegisterResult: QuickRegistrationRepository.TransferAccountResult? = null
+ )
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationActivity.kt
new file mode 100644
index 0000000000..78a882ef8a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationActivity.kt
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.viewModels
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.navigation.ActivityNavigator
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.BaseActivity
+import org.thoughtcrime.securesms.MainActivity
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
+import org.thoughtcrime.securesms.pin.PinRestoreActivity
+import org.thoughtcrime.securesms.profiles.AvatarHelper
+import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.registration.sms.SmsRetrieverReceiver
+import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity
+import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
+import org.thoughtcrime.securesms.util.RemoteConfig
+
+/**
+ * Activity to hold the entire registration process.
+ */
+class RegistrationActivity : BaseActivity() {
+
+ private val TAG = Log.tag(RegistrationActivity::class.java)
+
+ private val dynamicTheme = DynamicNoActionBarTheme()
+ val sharedViewModel: RegistrationViewModel by viewModels()
+
+ private var smsRetrieverReceiver: SmsRetrieverReceiver? = null
+
+ init {
+ lifecycle.addObserver(SmsRetrieverObserver())
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ dynamicTheme.onCreate(this)
+
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_registration_navigation_v3)
+
+ sharedViewModel.isReregister = intent.getBooleanExtra(RE_REGISTRATION_EXTRA, false)
+
+ sharedViewModel.checkpoint.observe(this) {
+ if (it >= RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE) {
+ handleSuccessfulVerify()
+ }
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ dynamicTheme.onResume(this)
+ }
+
+ private fun handleSuccessfulVerify() {
+ if (SignalStore.misc.hasLinkedDevices) {
+ SignalStore.misc.shouldShowLinkedDevicesReminder = sharedViewModel.isReregister
+ }
+
+ if (SignalStore.storageService.needsAccountRestore()) {
+ Log.i(TAG, "Performing pin restore.")
+ startActivity(Intent(this, PinRestoreActivity::class.java))
+ finish()
+ } else {
+ val isProfileNameEmpty = Recipient.self().profileName.isEmpty
+ val isAvatarEmpty = !AvatarHelper.hasAvatar(this, Recipient.self().id)
+ val needsProfile = isProfileNameEmpty || isAvatarEmpty
+ val needsPin = !SignalStore.svr.hasOptedInWithAccess()
+
+ Log.i(TAG, "Pin restore flow not required. Profile name empty: $isProfileNameEmpty | Profile avatar empty: $isAvatarEmpty | Needs PIN: $needsPin")
+
+ if (!needsProfile && !needsPin) {
+ sharedViewModel.completeRegistration()
+ }
+ sharedViewModel.setInProgress(false)
+
+ val startIntent = MainActivity.clearTop(this)
+
+ val nextIntent: Intent? = when {
+ needsPin -> CreateSvrPinActivity.getIntentForPinCreate(this@RegistrationActivity)
+ !SignalStore.registration.hasSkippedTransferOrRestore() && RemoteConfig.messageBackups -> RemoteRestoreActivity.getIntent(this@RegistrationActivity)
+ needsProfile -> CreateProfileActivity.getIntentForUserProfile(this@RegistrationActivity)
+ else -> null
+ }
+
+ if (nextIntent != null) {
+ startIntent.putExtra("next_intent", nextIntent)
+ }
+
+ Log.d(TAG, "Launching ${startIntent.component} with next_intent: ${nextIntent?.component}")
+ startActivity(startIntent)
+ finish()
+ ActivityNavigator.applyPopAnimationsToPendingTransition(this)
+ }
+ }
+
+ private inner class SmsRetrieverObserver : DefaultLifecycleObserver {
+ override fun onCreate(owner: LifecycleOwner) {
+ smsRetrieverReceiver = SmsRetrieverReceiver(application)
+ smsRetrieverReceiver?.registerReceiver()
+ }
+
+ override fun onDestroy(owner: LifecycleOwner) {
+ smsRetrieverReceiver?.unregisterReceiver()
+ smsRetrieverReceiver = null
+ }
+ }
+
+ companion object {
+ const val RE_REGISTRATION_EXTRA: String = "re_registration"
+
+ @JvmStatic
+ fun newIntentForNewRegistration(context: Context, originalIntent: Intent): Intent {
+ return Intent(context, RegistrationActivity::class.java).apply {
+ putExtra(RE_REGISTRATION_EXTRA, false)
+ setData(originalIntent.data)
+ }
+ }
+
+ @JvmStatic
+ fun newIntentForReRegistration(context: Context): Intent {
+ return Intent(context, RegistrationActivity::class.java).apply {
+ putExtra(RE_REGISTRATION_EXTRA, true)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationCheckpoint.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationCheckpoint.kt
new file mode 100644
index 0000000000..82e04d1eeb
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationCheckpoint.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui
+
+/**
+ * An ordered list of checkpoints of the registration process.
+ * This is used for screens to know when to advance, as well as restoring state after process death.
+ */
+enum class RegistrationCheckpoint {
+ INITIALIZATION,
+ PERMISSIONS_GRANTED,
+ BACKUP_RESTORED_OR_SKIPPED,
+ PUSH_NETWORK_AUDITED,
+ PHONE_NUMBER_CONFIRMED,
+ PIN_CONFIRMED,
+ CHALLENGE_RECEIVED,
+ CHALLENGE_COMPLETED,
+ VERIFICATION_CODE_REQUESTED,
+ VERIFICATION_CODE_ENTERED,
+ PIN_ENTERED,
+ VERIFICATION_CODE_VALIDATED,
+ SERVICE_REGISTRATION_COMPLETED,
+ LOCAL_REGISTRATION_COMPLETE
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationState.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationState.kt
new file mode 100644
index 0000000000..4bcffe037d
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationState.kt
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui
+
+import com.google.i18n.phonenumbers.NumberParseException
+import com.google.i18n.phonenumbers.PhoneNumberUtil
+import com.google.i18n.phonenumbers.Phonenumber
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.registration.data.network.Challenge
+import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
+import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
+import org.whispersystems.signalservice.api.svr.Svr3Credentials
+import org.whispersystems.signalservice.internal.push.AuthCredentials
+
+/**
+ * State holder shared across all of registration.
+ */
+data class RegistrationState(
+ val sessionId: String? = null,
+ val enteredCode: String = "",
+ val phoneNumber: Phonenumber.PhoneNumber? = fetchExistingE164FromValues(),
+ val inProgress: Boolean = false,
+ val isReRegister: Boolean = false,
+ val recoveryPassword: String? = null,
+ val canSkipSms: Boolean = false,
+ val svr2AuthCredentials: AuthCredentials? = null,
+ val svr3AuthCredentials: Svr3Credentials? = null,
+ val svrTriesRemaining: Int = 10,
+ val incorrectCodeAttempts: Int = 0,
+ val isRegistrationLockEnabled: Boolean = false,
+ val lockedTimeRemaining: Long = 0L,
+ val userSkippedReregistration: Boolean = false,
+ val isFcmSupported: Boolean = false,
+ val isAllowedToRequestCode: Boolean = false,
+ val fcmToken: String? = null,
+ val challengesRequested: List = emptyList(),
+ val challengesPresented: Set = emptySet(),
+ val captchaToken: String? = null,
+ val allowedToRequestCode: Boolean = false,
+ val nextSmsTimestamp: Long = 0L,
+ val nextCallTimestamp: Long = 0L,
+ val nextVerificationAttempt: Long = 0L,
+ val verified: Boolean = false,
+ val smsListenerTimeout: Long = 0L,
+ val registrationCheckpoint: RegistrationCheckpoint = RegistrationCheckpoint.INITIALIZATION,
+ val networkError: Throwable? = null,
+ val sessionCreationError: RegistrationSessionResult? = null,
+ val sessionStateError: VerificationCodeRequestResult? = null,
+ val registerAccountError: RegisterAccountResult? = null
+) {
+ val challengesRemaining: List = challengesRequested.filterNot { it in challengesPresented }
+
+ companion object {
+ private val TAG = Log.tag(RegistrationState::class)
+
+ private fun fetchExistingE164FromValues(): Phonenumber.PhoneNumber? {
+ val existingE164 = SignalStore.registration.sessionE164
+ if (existingE164 != null) {
+ try {
+ return PhoneNumberUtil.getInstance().parse(existingE164, null)
+ } catch (ex: NumberParseException) {
+ Log.w(TAG, "Could not parse stored E164.", ex)
+ return null
+ }
+ } else {
+ return null
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt
new file mode 100644
index 0000000000..ff8c911de7
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt
@@ -0,0 +1,996 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui
+
+import android.Manifest
+import android.content.Context
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.asLiveData
+import androidx.lifecycle.viewModelScope
+import com.google.i18n.phonenumbers.PhoneNumberUtil
+import com.google.i18n.phonenumbers.Phonenumber
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.flow.updateAndGet
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.signal.core.util.Hex
+import org.signal.core.util.Stopwatch
+import org.signal.core.util.isNotNullOrBlank
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.backup.v2.BackupRepository
+import org.thoughtcrime.securesms.dependencies.AppDependencies
+import org.thoughtcrime.securesms.jobs.MultiDeviceProfileContentUpdateJob
+import org.thoughtcrime.securesms.jobs.MultiDeviceProfileKeyUpdateJob
+import org.thoughtcrime.securesms.jobs.ProfileUploadJob
+import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob
+import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob
+import org.thoughtcrime.securesms.jobs.StorageSyncJob
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.permissions.Permissions
+import org.thoughtcrime.securesms.pin.SvrRepository
+import org.thoughtcrime.securesms.pin.SvrWrongPinException
+import org.thoughtcrime.securesms.registration.data.AccountRegistrationResult
+import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil
+import org.thoughtcrime.securesms.registration.data.RegistrationData
+import org.thoughtcrime.securesms.registration.data.network.BackupAuthCheckResult
+import org.thoughtcrime.securesms.registration.data.network.Challenge
+import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
+import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult
+import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
+import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.AlreadyVerified
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.AttemptsExhausted
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ChallengeRequired
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ExternalServiceFailure
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.ImpossibleNumber
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.InvalidTransportModeFailure
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.MalformedRequest
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.MustRetry
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.NoSuchSession
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.NonNormalizedNumber
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.RateLimited
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.RegistrationLocked
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.Success
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.TokenNotAccepted
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.UnknownError
+import org.thoughtcrime.securesms.registration.ui.toE164
+import org.thoughtcrime.securesms.registration.util.RegistrationUtil
+import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
+import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
+import org.thoughtcrime.securesms.util.RemoteConfig
+import org.thoughtcrime.securesms.util.Util
+import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
+import org.whispersystems.signalservice.api.SvrNoDataException
+import org.whispersystems.signalservice.api.kbs.MasterKey
+import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+import kotlin.jvm.optionals.getOrNull
+import kotlin.time.Duration.Companion.minutes
+
+/**
+ * ViewModel shared across all of registration.
+ */
+class RegistrationViewModel : ViewModel() {
+
+ private val store = MutableStateFlow(RegistrationState())
+ private val password = Util.getSecret(18)
+
+ private val coroutineExceptionHandler = CoroutineExceptionHandler { _, exception ->
+ Log.w(TAG, "CoroutineExceptionHandler invoked.", exception)
+ store.update {
+ it.copy(
+ networkError = exception,
+ inProgress = false
+ )
+ }
+ }
+
+ val state: StateFlow = store
+
+ val uiState = store.asLiveData()
+
+ val checkpoint = store.map { it.registrationCheckpoint }.asLiveData()
+
+ val lockedTimeRemaining = store.map { it.lockedTimeRemaining }.asLiveData()
+
+ val incorrectCodeAttempts = store.map { it.incorrectCodeAttempts }.asLiveData()
+
+ val svrTriesRemaining: Int
+ get() = store.value.svrTriesRemaining
+
+ var isReregister: Boolean
+ get() = store.value.isReRegister
+ set(value) {
+ store.update {
+ it.copy(isReRegister = value)
+ }
+ }
+
+ val phoneNumber: Phonenumber.PhoneNumber?
+ get() = store.value.phoneNumber
+
+ fun maybePrefillE164(context: Context) {
+ Log.v(TAG, "maybePrefillE164()")
+ if (Permissions.hasAll(context, Manifest.permission.READ_PHONE_STATE, Manifest.permission.READ_PHONE_NUMBERS)) {
+ val localNumber = Util.getDeviceNumber(context).getOrNull()
+
+ if (localNumber != null) {
+ Log.v(TAG, "Phone number detected.")
+ setPhoneNumber(localNumber)
+ } else {
+ Log.i(TAG, "Could not read phone number.")
+ }
+ } else {
+ Log.i(TAG, "No phone permission.")
+ }
+ }
+
+ fun setInProgress(inProgress: Boolean) {
+ store.update {
+ it.copy(inProgress = inProgress)
+ }
+ }
+
+ fun setRegistrationCheckpoint(checkpoint: RegistrationCheckpoint) {
+ store.update {
+ it.copy(registrationCheckpoint = checkpoint)
+ }
+ }
+
+ fun setPhoneNumber(phoneNumber: Phonenumber.PhoneNumber?) {
+ store.update {
+ it.copy(
+ phoneNumber = phoneNumber,
+ sessionId = null
+ )
+ }
+ }
+
+ fun setCaptchaResponse(token: String) {
+ store.update {
+ it.copy(
+ registrationCheckpoint = RegistrationCheckpoint.CHALLENGE_COMPLETED,
+ captchaToken = token
+ )
+ }
+ }
+
+ fun sessionCreationErrorShown() {
+ store.update {
+ it.copy(sessionCreationError = null)
+ }
+ }
+
+ fun sessionStateErrorShown() {
+ store.update {
+ it.copy(sessionStateError = null)
+ }
+ }
+
+ fun registerAccountErrorShown() {
+ store.update {
+ it.copy(registerAccountError = null)
+ }
+ }
+
+ fun incrementIncorrectCodeAttempts() {
+ store.update {
+ it.copy(incorrectCodeAttempts = it.incorrectCodeAttempts + 1)
+ }
+ }
+
+ fun addPresentedChallenge(challenge: Challenge) {
+ store.update {
+ it.copy(challengesPresented = it.challengesPresented.plus(challenge))
+ }
+ }
+
+ fun removePresentedChallenge(challenge: Challenge) {
+ store.update {
+ it.copy(challengesPresented = it.challengesPresented.minus(challenge))
+ }
+ }
+
+ fun fetchFcmToken(context: Context) {
+ viewModelScope.launch(context = coroutineExceptionHandler) {
+ val fcmToken = RegistrationRepository.getFcmToken(context)
+ store.update {
+ it.copy(registrationCheckpoint = RegistrationCheckpoint.PUSH_NETWORK_AUDITED, isFcmSupported = true, fcmToken = fcmToken)
+ }
+ }
+ }
+
+ private suspend fun updateFcmToken(context: Context): String? {
+ Log.d(TAG, "Fetching FCM token…")
+ val fcmToken = RegistrationRepository.getFcmToken(context)
+ store.update {
+ it.copy(fcmToken = fcmToken)
+ }
+ Log.d(TAG, "FCM token fetched.")
+ return fcmToken
+ }
+
+ fun onBackupSuccessfullyRestored() {
+ val recoveryPassword = SignalStore.svr.recoveryPassword
+ store.update {
+ it.copy(registrationCheckpoint = RegistrationCheckpoint.BACKUP_RESTORED_OR_SKIPPED, recoveryPassword = SignalStore.svr.recoveryPassword, canSkipSms = recoveryPassword != null, isReRegister = true)
+ }
+ }
+
+ fun onUserConfirmedPhoneNumber(context: Context) {
+ setRegistrationCheckpoint(RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED)
+ val state = store.value
+
+ val e164 = state.phoneNumber?.toE164() ?: return bail { Log.i(TAG, "Phone number was null after confirmation.") }
+
+ if (!state.userSkippedReregistration) {
+ if (hasRecoveryPassword() && matchesSavedE164(e164)) {
+ // Re-registration when the local database is intact.
+ Log.d(TAG, "Has recovery password, and therefore can skip SMS verification.")
+ store.update {
+ it.copy(
+ canSkipSms = true,
+ isReRegister = true,
+ inProgress = false
+ )
+ }
+ return
+ }
+ }
+
+ viewModelScope.launch {
+ if (!state.userSkippedReregistration) {
+ val svrCredentialsResult: BackupAuthCheckResult = RegistrationRepository.hasValidSvrAuthCredentials(context, e164, password)
+
+ when (svrCredentialsResult) {
+ is BackupAuthCheckResult.UnknownError -> {
+ handleGenericError(svrCredentialsResult.getCause())
+ return@launch
+ }
+
+ is BackupAuthCheckResult.SuccessWithCredentials -> {
+ Log.d(TAG, "Found local valid SVR auth credentials.")
+ store.update {
+ it.copy(
+ isReRegister = true,
+ canSkipSms = true,
+ svr2AuthCredentials = svrCredentialsResult.svr2Credentials,
+ svr3AuthCredentials = svrCredentialsResult.svr3Credentials,
+ inProgress = false
+ )
+ }
+ return@launch
+ }
+
+ is BackupAuthCheckResult.SuccessWithoutCredentials -> {
+ Log.d(TAG, "No local SVR auth credentials could be found and/or validated.")
+ }
+ }
+ }
+
+ val validSession = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for confirming the entered E164.") }
+
+ if (validSession.body.verified) {
+ Log.i(TAG, "Session is already verified, registering account.")
+ registerVerifiedSession(context, validSession.body.id)
+ return@launch
+ }
+
+ if (!validSession.body.allowedToRequestCode) {
+ if (System.currentTimeMillis() > (validSession.body.nextVerificationAttempt ?: Int.MAX_VALUE)) {
+ store.update {
+ it.copy(registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED)
+ }
+ } else {
+ val challenges = validSession.body.requestedInformation
+ Log.i(TAG, "Not allowed to request code! Remaining challenges: ${challenges.joinToString()}")
+ handleSessionStateResult(context, ChallengeRequired(Challenge.parse(validSession.body.requestedInformation)))
+ }
+ return@launch
+ }
+
+ requestSmsCodeInternal(context, validSession.body.id, e164)
+ }
+ }
+
+ fun requestSmsCode(context: Context) {
+ val e164 = getCurrentE164() ?: return bail { Log.i(TAG, "Phone number was null after confirmation.") }
+
+ viewModelScope.launch {
+ val validSession = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for requesting an SMS code.") }
+ requestSmsCodeInternal(context, validSession.body.id, e164)
+ }
+ }
+
+ fun requestVerificationCall(context: Context) {
+ val e164 = getCurrentE164()
+
+ if (e164 == null) {
+ Log.w(TAG, "Phone number was null after confirmation.")
+ onErrorOccurred()
+ return
+ }
+
+ viewModelScope.launch {
+ val validSession = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for requesting a verification call.") }
+ Log.d(TAG, "Requesting voice call code…")
+ val codeRequestResponse = RegistrationRepository.requestSmsCode(
+ context = context,
+ sessionId = validSession.body.id,
+ e164 = e164,
+ password = password,
+ mode = RegistrationRepository.E164VerificationMode.PHONE_CALL
+ )
+ Log.d(TAG, "Voice code request network call completed.")
+
+ handleSessionStateResult(context, codeRequestResponse)
+ if (codeRequestResponse is Success) {
+ Log.d(TAG, "Voice code request was successful.")
+ }
+ }
+ }
+
+ private suspend fun requestSmsCodeInternal(context: Context, sessionId: String, e164: String) {
+ var smsListenerReady = false
+ Log.d(TAG, "Initializing SMS listener.")
+ if (store.value.smsListenerTimeout < System.currentTimeMillis()) {
+ smsListenerReady = store.value.isFcmSupported && RegistrationRepository.registerSmsListener(context)
+
+ if (smsListenerReady) {
+ val smsRetrieverTimeout = System.currentTimeMillis() + 5.minutes.inWholeMilliseconds
+ Log.d(TAG, "Successfully started verification code SMS retriever, which will last until $smsRetrieverTimeout.")
+ store.update { it.copy(smsListenerTimeout = smsRetrieverTimeout) }
+ } else {
+ Log.d(TAG, "Could not start verification code SMS retriever.")
+ }
+ }
+
+ Log.d(TAG, "Requesting SMS code…")
+ val transportMode = if (smsListenerReady) RegistrationRepository.E164VerificationMode.SMS_WITH_LISTENER else RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER
+ val codeRequestResponse = RegistrationRepository.requestSmsCode(
+ context = context,
+ sessionId = sessionId,
+ e164 = e164,
+ password = password,
+ mode = transportMode
+ )
+ Log.d(TAG, "SMS code request network call completed.")
+
+ if (codeRequestResponse is AlreadyVerified) {
+ Log.d(TAG, "Got session was already verified when requesting SMS code.")
+ registerVerifiedSession(context, sessionId)
+ return
+ }
+
+ handleSessionStateResult(context, codeRequestResponse)
+
+ if (codeRequestResponse is Success) {
+ Log.d(TAG, "SMS code request was successful.")
+ store.update {
+ it.copy(
+ registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED
+ )
+ }
+ }
+ }
+
+ private suspend fun getOrCreateValidSession(context: Context): RegistrationSessionMetadataResponse? {
+ Log.v(TAG, "getOrCreateValidSession()")
+ val e164 = getCurrentE164() ?: throw IllegalStateException("E164 required to create session!")
+ val mccMncProducer = MccMncProducer(context)
+
+ val existingSessionId = store.value.sessionId
+ return getOrCreateValidSession(
+ context = context,
+ existingSessionId = existingSessionId,
+ e164 = e164,
+ password = password,
+ mcc = mccMncProducer.mcc,
+ mnc = mccMncProducer.mnc,
+ successListener = { networkResult ->
+ store.update {
+ it.copy(
+ sessionId = networkResult.body.id,
+ nextSmsTimestamp = RegistrationRepository.deriveTimestamp(networkResult.headers, networkResult.body.nextSms),
+ nextCallTimestamp = RegistrationRepository.deriveTimestamp(networkResult.headers, networkResult.body.nextCall),
+ nextVerificationAttempt = RegistrationRepository.deriveTimestamp(networkResult.headers, networkResult.body.nextVerificationAttempt),
+ allowedToRequestCode = networkResult.body.allowedToRequestCode,
+ challengesRequested = Challenge.parse(networkResult.body.requestedInformation),
+ verified = networkResult.body.verified,
+ inProgress = false
+ )
+ }
+ },
+ errorHandler = { error ->
+ Log.d(TAG, "Setting ${error::class.simpleName} as session creation error.")
+ store.update {
+ it.copy(
+ sessionCreationError = error,
+ inProgress = false
+ )
+ }
+ }
+ )
+ }
+
+ fun submitCaptchaToken(context: Context) {
+ val e164 = getCurrentE164() ?: throw IllegalStateException("Can't submit captcha token if no phone number is set!")
+ val captchaToken = store.value.captchaToken ?: throw IllegalStateException("Can't submit captcha token if no captcha token is set!")
+
+ store.update {
+ it.copy(captchaToken = null)
+ }
+
+ viewModelScope.launch {
+ val session = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for submitting a captcha token.") }
+ Log.d(TAG, "Submitting captcha token…")
+ val captchaSubmissionResult = RegistrationRepository.submitCaptchaToken(context, e164, password, session.body.id, captchaToken)
+ Log.d(TAG, "Captcha token submitted.")
+
+ handleSessionStateResult(context, captchaSubmissionResult)
+ }
+ }
+
+ fun requestAndSubmitPushToken(context: Context) {
+ Log.v(TAG, "validatePushToken()")
+
+ addPresentedChallenge(Challenge.PUSH)
+
+ val e164 = getCurrentE164() ?: throw IllegalStateException("Can't submit captcha token if no phone number is set!")
+
+ viewModelScope.launch {
+ Log.d(TAG, "Getting session in order to perform push token verification…")
+ val session = getOrCreateValidSession(context) ?: return@launch bail { Log.i(TAG, "Could not create valid session for submitting a push challenge token.") }
+
+ if (!Challenge.parse(session.body.requestedInformation).contains(Challenge.PUSH)) {
+ Log.d(TAG, "Push submission no longer necessary, bailing.")
+ store.update {
+ it.copy(
+ inProgress = false
+ )
+ }
+ return@launch bail { Log.i(TAG, "Push challenge token no longer needed, bailing.") }
+ }
+
+ Log.d(TAG, "Requesting push challenge token…")
+ val pushSubmissionResult = RegistrationRepository.requestAndVerifyPushToken(context, session.body.id, e164, password)
+ Log.d(TAG, "Push challenge token submitted.")
+ handleSessionStateResult(context, pushSubmissionResult)
+ }
+ }
+
+ /**
+ * @return whether the request was successful and execution should continue
+ */
+ private suspend fun handleSessionStateResult(context: Context, sessionResult: VerificationCodeRequestResult): Boolean {
+ Log.v(TAG, "handleSessionStateResult()")
+ when (sessionResult) {
+ is UnknownError -> {
+ handleGenericError(sessionResult.getCause())
+ }
+
+ is Success -> {
+ Log.d(TAG, "New registration session status received.")
+ updateFcmToken(context)
+ store.update {
+ it.copy(
+ sessionId = sessionResult.sessionId,
+ nextSmsTimestamp = sessionResult.nextSmsTimestamp,
+ nextCallTimestamp = sessionResult.nextCallTimestamp,
+ isAllowedToRequestCode = sessionResult.allowedToRequestCode,
+ challengesRequested = emptyList(),
+ inProgress = false
+ )
+ }
+ return true
+ }
+
+ is ChallengeRequired -> {
+ Log.d(TAG, "[${sessionResult.challenges.joinToString()}] registration challenges received.")
+ store.update {
+ it.copy(
+ registrationCheckpoint = RegistrationCheckpoint.CHALLENGE_RECEIVED,
+ challengesRequested = sessionResult.challenges,
+ inProgress = false
+ )
+ }
+ return false
+ }
+
+ is AttemptsExhausted -> Log.i(TAG, "Received AttemptsExhausted.", sessionResult.getCause())
+
+ is ImpossibleNumber -> Log.i(TAG, "Received ImpossibleNumber.", sessionResult.getCause())
+
+ is NonNormalizedNumber -> Log.i(TAG, "Received NonNormalizedNumber.", sessionResult.getCause())
+
+ is RateLimited -> Log.i(TAG, "Received RateLimited.", sessionResult.getCause())
+
+ is ExternalServiceFailure -> Log.i(TAG, "Received ExternalServiceFailure.", sessionResult.getCause())
+
+ is InvalidTransportModeFailure -> Log.i(TAG, "Received InvalidTransportModeFailure.", sessionResult.getCause())
+
+ is MalformedRequest -> Log.i(TAG, "Received MalformedRequest.", sessionResult.getCause())
+
+ is MustRetry -> Log.i(TAG, "Received MustRetry.", sessionResult.getCause())
+
+ is TokenNotAccepted -> Log.i(TAG, "Received TokenNotAccepted.", sessionResult.getCause())
+
+ is RegistrationLocked -> {
+ store.update {
+ it.copy(lockedTimeRemaining = sessionResult.timeRemaining)
+ }
+ Log.i(TAG, "Received RegistrationLocked.", sessionResult.getCause())
+ }
+
+ is NoSuchSession -> Log.i(TAG, "Received NoSuchSession.", sessionResult.getCause())
+
+ is AlreadyVerified -> Log.i(TAG, "Received AlreadyVerified", sessionResult.getCause())
+ }
+ setInProgress(false)
+ store.update {
+ it.copy(
+ sessionStateError = sessionResult
+ )
+ }
+ return false
+ }
+
+ /**
+ * @return whether the request was successful and execution should continue
+ */
+ private suspend fun handleRegistrationResult(context: Context, registrationData: RegistrationData, registrationResult: RegisterAccountResult, reglockEnabled: Boolean): Boolean {
+ Log.v(TAG, "handleRegistrationResult()")
+ when (registrationResult) {
+ is RegisterAccountResult.Success -> {
+ Log.i(TAG, "Register account result: Success! Registration lock: $reglockEnabled")
+ store.update {
+ it.copy(
+ registrationCheckpoint = RegistrationCheckpoint.SERVICE_REGISTRATION_COMPLETED
+ )
+ }
+ onSuccessfulRegistration(context, registrationData, registrationResult.accountRegistrationResult, reglockEnabled)
+ return true
+ }
+
+ is RegisterAccountResult.IncorrectRecoveryPassword -> {
+ Log.i(TAG, "Registration recovery password was incorrect, falling back to SMS verification.", registrationResult.getCause())
+ setUserSkippedReRegisterFlow(true)
+ }
+
+ is RegisterAccountResult.RegistrationLocked -> {
+ Log.i(TAG, "Account is registration locked!", registrationResult.getCause())
+ }
+
+ is RegisterAccountResult.SvrWrongPin -> {
+ Log.i(TAG, "Received wrong SVR PIN response! ${registrationResult.triesRemaining} tries remaining.")
+ updateSvrTriesRemaining(registrationResult.triesRemaining)
+ }
+
+ is RegisterAccountResult.SvrNoData,
+ is RegisterAccountResult.AttemptsExhausted,
+ is RegisterAccountResult.RateLimited,
+ is RegisterAccountResult.AuthorizationFailed,
+ is RegisterAccountResult.MalformedRequest,
+ is RegisterAccountResult.ValidationError,
+ is RegisterAccountResult.UnknownError -> Log.i(TAG, "Received error when trying to register!", registrationResult.getCause())
+ }
+ setInProgress(false)
+ store.update {
+ it.copy(
+ registerAccountError = registrationResult
+ )
+ }
+ return false
+ }
+
+ private fun handleGenericError(cause: Throwable) {
+ Log.w(TAG, "Encountered unknown error!", cause)
+ store.update {
+ it.copy(inProgress = false, networkError = cause)
+ }
+ }
+
+ private fun setRecoveryPassword(recoveryPassword: String?) {
+ store.update {
+ it.copy(recoveryPassword = recoveryPassword)
+ }
+ }
+
+ private fun updateSvrTriesRemaining(remainingTries: Int) {
+ store.update {
+ it.copy(svrTriesRemaining = remainingTries)
+ }
+ }
+
+ fun setUserSkippedReRegisterFlow(value: Boolean) {
+ store.update {
+ it.copy(userSkippedReregistration = value, canSkipSms = !value)
+ }
+ }
+
+ fun verifyReRegisterWithPin(context: Context, pin: String, wrongPinHandler: () -> Unit) {
+ setInProgress(true)
+
+ // Local recovery password
+ if (RegistrationRepository.canUseLocalRecoveryPassword()) {
+ if (RegistrationRepository.doesPinMatchLocalHash(pin)) {
+ Log.d(TAG, "Found recovery password, attempting to re-register.")
+ viewModelScope.launch(context = coroutineExceptionHandler) {
+ val masterKey = SignalStore.svr.masterKey
+ setRecoveryPassword(masterKey.deriveRegistrationRecoveryPassword())
+ verifyReRegisterInternal(context, pin, masterKey)
+ setInProgress(false)
+ }
+ } else {
+ Log.d(TAG, "Entered PIN did not match local PIN hash.")
+ wrongPinHandler()
+ setInProgress(false)
+ }
+ return
+ }
+
+ // remote recovery password
+ val svr2Credentials = store.value.svr2AuthCredentials
+ val svr3Credentials = store.value.svr3AuthCredentials
+
+ if (svr2Credentials != null || svr3Credentials != null) {
+ Log.d(TAG, "Found SVR auth credentials, fetching recovery password from SVR (svr2: ${svr2Credentials != null}, svr3: ${svr3Credentials != null}).")
+ viewModelScope.launch(context = coroutineExceptionHandler) {
+ try {
+ val masterKey = RegistrationRepository.fetchMasterKeyFromSvrRemote(pin, svr2Credentials, svr3Credentials)
+ setRecoveryPassword(masterKey.deriveRegistrationRecoveryPassword())
+ updateSvrTriesRemaining(10)
+ verifyReRegisterInternal(context, pin, masterKey)
+ } catch (rejectedPin: SvrWrongPinException) {
+ Log.w(TAG, "Submitted PIN was rejected by SVR.", rejectedPin)
+ updateSvrTriesRemaining(rejectedPin.triesRemaining)
+ wrongPinHandler()
+ } catch (noData: SvrNoDataException) {
+ Log.w(TAG, "SVR has no data for these credentials. Aborting skip SMS flow.", noData)
+ updateSvrTriesRemaining(0)
+ setUserSkippedReRegisterFlow(true)
+ }
+ setInProgress(false)
+ }
+ return
+ }
+
+ Log.w(TAG, "Could not get credentials to skip SMS registration, aborting!")
+ store.update {
+ it.copy(canSkipSms = false, inProgress = false)
+ }
+ }
+
+ private suspend fun verifyReRegisterInternal(context: Context, pin: String?, masterKey: MasterKey) {
+ Log.v(TAG, "verifyReRegisterInternal(hasPin=${pin != null})")
+ updateFcmToken(context)
+
+ val registrationData = getRegistrationData()
+
+ val resultAndRegLockStatus = registerAccountInternal(context, null, registrationData, pin, masterKey)
+ val result = resultAndRegLockStatus.first
+ val reglockEnabled = resultAndRegLockStatus.second
+
+ handleRegistrationResult(context, registrationData, result, reglockEnabled)
+ }
+
+ /**
+ * @return a [Pair] containing the server response and a boolean signifying whether the current account is registration locked.
+ */
+ private suspend fun registerAccountInternal(context: Context, sessionId: String?, registrationData: RegistrationData, pin: String?, masterKey: MasterKey): Pair {
+ Log.v(TAG, "registerAccountInternal()")
+ var registrationResult: RegisterAccountResult = RegistrationRepository.registerAccount(context = context, sessionId = sessionId, registrationData = registrationData, pin = pin)
+
+ // Check if reg lock is enabled
+ if (registrationResult !is RegisterAccountResult.RegistrationLocked) {
+ if (registrationResult is RegisterAccountResult.Success) {
+ registrationResult = RegisterAccountResult.Success(registrationResult.accountRegistrationResult.copy(masterKey = masterKey))
+ }
+
+ Log.i(TAG, "Received a non-registration lock response to registration. Assuming registration lock as DISABLED")
+ return Pair(registrationResult, false)
+ }
+
+ Log.i(TAG, "Received a registration lock response when trying to register an account. Retrying with master key.")
+ store.update {
+ it.copy(
+ svr2AuthCredentials = registrationResult.svr2Credentials,
+ svr3AuthCredentials = registrationResult.svr3Credentials
+ )
+ }
+
+ return Pair(RegistrationRepository.registerAccount(context = context, sessionId = sessionId, registrationData = registrationData, pin = pin) { masterKey }, true)
+ }
+
+ fun verifyCodeWithoutRegistrationLock(context: Context, code: String) {
+ Log.v(TAG, "verifyCodeWithoutRegistrationLock()")
+ store.update {
+ it.copy(
+ inProgress = true,
+ enteredCode = code,
+ registrationCheckpoint = RegistrationCheckpoint.VERIFICATION_CODE_ENTERED
+ )
+ }
+
+ viewModelScope.launch(context = coroutineExceptionHandler) {
+ verifyCodeInternal(
+ context = context,
+ registrationLocked = false,
+ pin = null
+ )
+ }
+ }
+
+ fun verifyCodeAndRegisterAccountWithRegistrationLock(context: Context, pin: String) {
+ Log.v(TAG, "verifyCodeAndRegisterAccountWithRegistrationLock()")
+ store.update {
+ it.copy(
+ inProgress = true,
+ registrationCheckpoint = RegistrationCheckpoint.PIN_ENTERED
+ )
+ }
+ viewModelScope.launch {
+ verifyCodeInternal(
+ context = context,
+ registrationLocked = true,
+ pin = pin
+ )
+ }
+ }
+
+ private suspend fun verifyCodeInternal(context: Context, registrationLocked: Boolean, pin: String?) {
+ Log.d(TAG, "Getting valid session in order to submit verification code.")
+
+ if (registrationLocked && pin.isNullOrBlank()) {
+ throw IllegalStateException("Must have PIN to register with registration lock!")
+ }
+
+ var reglock = registrationLocked
+
+ val sessionId = getOrCreateValidSession(context)?.body?.id ?: return
+ val registrationData = getRegistrationData()
+
+ Log.d(TAG, "Submitting verification code…")
+
+ val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
+
+ val submissionSuccessful = verificationResponse is Success
+ val alreadyVerified = verificationResponse is AlreadyVerified
+
+ Log.d(TAG, "Verification code submission network call completed. Submission successful? $submissionSuccessful Account already verified? $alreadyVerified")
+
+ if (!submissionSuccessful && !alreadyVerified) {
+ handleSessionStateResult(context, verificationResponse)
+ return
+ }
+
+ Log.d(TAG, "Submitting registration…")
+
+ var result: RegisterAccountResult? = null
+ var state = store.value
+
+ if (!reglock) {
+ Log.d(TAG, "Registration lock not enabled, attempting to register account without master key producer.")
+ result = RegistrationRepository.registerAccount(context, sessionId, registrationData, pin)
+ }
+
+ if (result is RegisterAccountResult.RegistrationLocked) {
+ Log.d(TAG, "Registration lock response received.")
+ val timeRemaining = result.timeRemaining
+ store.update {
+ it.copy(lockedTimeRemaining = timeRemaining)
+ }
+ reglock = true
+ if (pin == null && SignalStore.svr.registrationLockToken != null) {
+ Log.d(TAG, "Retrying registration with stored credentials.")
+ result = RegistrationRepository.registerAccount(context, sessionId, registrationData, SignalStore.svr.pin) { SignalStore.svr.masterKey }
+ } else if (result.svr2Credentials != null || result.svr3Credentials != null) {
+ Log.d(TAG, "Retrying registration with received credentials (svr2: ${result.svr2Credentials != null}, svr3: ${result.svr3Credentials != null}).")
+ val svr2Credentials = result.svr2Credentials
+ val svr3Credentials = result.svr3Credentials
+ state = store.updateAndGet {
+ it.copy(svr2AuthCredentials = svr2Credentials, svr3AuthCredentials = svr3Credentials)
+ }
+ }
+ }
+
+ if (reglock && pin.isNotNullOrBlank()) {
+ Log.d(TAG, "Registration lock enabled, attempting to register account restore master key from SVR (svr2: ${state.svr2AuthCredentials != null}, svr3: ${state.svr3AuthCredentials != null})")
+ result = RegistrationRepository.registerAccount(context, sessionId, registrationData, pin) {
+ SvrRepository.restoreMasterKeyPreRegistration(
+ credentials = SvrAuthCredentialSet(
+ svr2Credentials = state.svr2AuthCredentials,
+ svr3Credentials = state.svr3AuthCredentials
+ ),
+ userPin = pin
+ )
+ }
+ }
+
+ if (result != null) {
+ handleRegistrationResult(context, registrationData, result, reglock)
+ } else {
+ Log.w(TAG, "No registration response received!")
+ }
+ }
+
+ private suspend fun registerVerifiedSession(context: Context, sessionId: String) {
+ Log.v(TAG, "registerVerifiedSession()")
+ val registrationData = getRegistrationData()
+ val registrationResponse: RegisterAccountResult = RegistrationRepository.registerAccount(context, sessionId, registrationData)
+ handleRegistrationResult(context, registrationData, registrationResponse, false)
+ }
+
+ private suspend fun onSuccessfulRegistration(context: Context, registrationData: RegistrationData, remoteResult: AccountRegistrationResult, reglockEnabled: Boolean) = withContext(Dispatchers.IO) {
+ Log.v(TAG, "onSuccessfulRegistration()")
+ val metadata = LocalRegistrationMetadataUtil.createLocalRegistrationMetadata(SignalStore.account.aciIdentityKey, SignalStore.account.pniIdentityKey, registrationData, remoteResult, reglockEnabled)
+ SignalStore.registration.localRegistrationMetadata = metadata
+ RegistrationRepository.registerAccountLocally(context, metadata)
+
+ if (!remoteResult.storageCapable && !SignalStore.registration.hasCompletedRestore()) {
+ // Not being storage capable is a high signal that account is new and there's no data to restore
+ SignalStore.registration.markSkippedTransferOrRestore()
+ }
+
+ if (reglockEnabled || SignalStore.svr.hasOptedInWithAccess()) {
+ SignalStore.onboarding.clearAll()
+ }
+
+ if (reglockEnabled || SignalStore.svr.hasOptedInWithAccess()) {
+ val stopwatch = Stopwatch("post-reg-storage-service")
+
+ AppDependencies.jobManager.runSynchronously(StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN)
+ stopwatch.split("account-restore")
+
+ AppDependencies.jobManager
+ .startChain(StorageSyncJob())
+ .then(ReclaimUsernameAndLinkJob())
+ .enqueueAndBlockUntilCompletion(TimeUnit.SECONDS.toMillis(10))
+ stopwatch.split("storage-sync")
+
+ BackupRepository.restoreBackupTier(SignalStore.account.requireAci())
+ stopwatch.split("backup-tier")
+
+ stopwatch.stop(TAG)
+ }
+
+ refreshRemoteConfig()
+
+ store.update {
+ it.copy(
+ registrationCheckpoint = RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE,
+ inProgress = false
+ )
+ }
+ }
+
+ fun completeRegistration() {
+ AppDependencies.jobManager.startChain(ProfileUploadJob()).then(listOf(MultiDeviceProfileKeyUpdateJob(), MultiDeviceProfileContentUpdateJob())).enqueue()
+ RegistrationUtil.maybeMarkRegistrationComplete()
+ }
+
+ fun networkErrorShown() {
+ store.update {
+ it.copy(networkError = null)
+ }
+ }
+
+ private fun matchesSavedE164(e164: String?): Boolean {
+ return if (e164 == null) {
+ false
+ } else {
+ e164 == SignalStore.account.e164
+ }
+ }
+
+ private fun hasRecoveryPassword(): Boolean {
+ return store.value.recoveryPassword != null
+ }
+
+ private fun getCurrentE164(): String? {
+ return store.value.phoneNumber?.toE164()
+ }
+
+ private suspend fun getRegistrationData(): RegistrationData {
+ val currentState = store.value
+ val code = currentState.enteredCode
+ val e164: String = currentState.phoneNumber?.toE164() ?: throw IllegalStateException("Can't construct registration data without E164!")
+ val recoveryPassword = if (currentState.sessionId == null && hasRecoveryPassword()) store.value.recoveryPassword!! else null
+ return RegistrationData(code, e164, password, RegistrationRepository.getRegistrationId(), RegistrationRepository.getProfileKey(e164), currentState.fcmToken, RegistrationRepository.getPniRegistrationId(), recoveryPassword)
+ }
+
+ /**
+ * This is a generic error UI handler that re-enables the UI so that the user can recover from errors.
+ * Do not forget to log any errors when calling this method!
+ */
+ private fun onErrorOccurred() {
+ setInProgress(false)
+ }
+
+ /**
+ * Used for early returns in order to end the in-progress visual state, as well as print a log message explaining what happened.
+ *
+ * @param logMessage Logging code is wrapped in lambda so that our automated tools detect the various [Log] calls with their accompanying messages.
+ */
+ private fun bail(logMessage: () -> Unit) {
+ logMessage()
+ setInProgress(false)
+ }
+
+ fun registerWithBackupKey(context: Context, backupKey: String, e164: String?, pin: String?) {
+ setInProgress(true)
+
+ viewModelScope.launch(context = coroutineExceptionHandler) {
+ if (e164 != null) {
+ setPhoneNumber(PhoneNumberUtil.getInstance().parse(e164, null))
+ }
+
+ // TODO [backups] use new data and not master key
+ val masterKey = MasterKey(Hex.fromStringCondensed(backupKey))
+ SignalStore.svr.setMasterKey(masterKey, pin)
+ setRecoveryPassword(masterKey.deriveRegistrationRecoveryPassword())
+ verifyReRegisterInternal(context = context, pin = pin, masterKey = masterKey)
+
+ setInProgress(false)
+ }
+ }
+
+ companion object {
+ private val TAG = Log.tag(RegistrationViewModel::class.java)
+
+ private suspend fun refreshRemoteConfig() = withContext(Dispatchers.IO) {
+ val startTime = System.currentTimeMillis()
+ try {
+ RemoteConfig.refreshSync()
+ Log.i(TAG, "Took " + (System.currentTimeMillis() - startTime) + " ms to get feature flags.")
+ } catch (e: IOException) {
+ Log.w(TAG, "Failed to refresh flags after " + (System.currentTimeMillis() - startTime) + " ms.", e)
+ }
+ }
+
+ suspend fun getOrCreateValidSession(
+ context: Context,
+ existingSessionId: String?,
+ e164: String,
+ password: String,
+ mcc: String?,
+ mnc: String?,
+ successListener: (RegistrationSessionMetadataResponse) -> Unit,
+ errorHandler: (RegistrationSessionResult) -> Unit
+ ): RegistrationSessionMetadataResponse? {
+ Log.d(TAG, "Validating/creating a registration session.")
+ val sessionResult: RegistrationSessionResult = RegistrationRepository.createOrValidateSession(context, existingSessionId, e164, password, mcc, mnc)
+ when (sessionResult) {
+ is RegistrationSessionCheckResult.Success -> {
+ val metadata = sessionResult.getMetadata()
+ successListener(metadata)
+ Log.d(TAG, "Registration session validated.")
+ return metadata
+ }
+
+ is RegistrationSessionCreationResult.Success -> {
+ val metadata = sessionResult.getMetadata()
+ successListener(metadata)
+ Log.d(TAG, "Registration session created.")
+ return metadata
+ }
+
+ else -> {
+ Log.d(TAG, "Handling error during session creation.")
+ errorHandler(sessionResult)
+ }
+ }
+ return null
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/accountlocked/AccountLockedFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/accountlocked/AccountLockedFragment.kt
new file mode 100644
index 0000000000..c2099c3a75
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/accountlocked/AccountLockedFragment.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.accountlocked
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import android.widget.TextView
+import androidx.activity.OnBackPressedCallback
+import androidx.fragment.app.activityViewModels
+import org.thoughtcrime.securesms.LoggingFragment
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Screen educating the user that they need to wait some number of days to register.
+ */
+class AccountLockedFragment : LoggingFragment(R.layout.account_locked_fragment) {
+ private val viewModel by activityViewModels()
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ setDebugLogSubmitMultiTapView(view.findViewById(R.id.account_locked_title))
+
+ val description = view.findViewById(R.id.account_locked_description)
+
+ viewModel.lockedTimeRemaining.observe(
+ viewLifecycleOwner
+ ) { t: Long? -> description.text = getString(R.string.AccountLockedFragment__your_account_has_been_locked_to_protect_your_privacy, durationToDays(t!!)) }
+
+ view.findViewById(R.id.account_locked_next).setOnClickListener { v: View? -> onNext() }
+ view.findViewById(R.id.account_locked_learn_more).setOnClickListener { v: View? -> learnMore() }
+
+ requireActivity().onBackPressedDispatcher.addCallback(
+ viewLifecycleOwner,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ onNext()
+ }
+ }
+ )
+ }
+
+ private fun learnMore() {
+ val intent = Intent(Intent.ACTION_VIEW)
+ intent.setData(Uri.parse(getString(R.string.AccountLockedFragment__learn_more_url)))
+ startActivity(intent)
+ }
+
+ fun onNext() {
+ requireActivity().finish()
+ }
+
+ private fun durationToDays(duration: Long): Long {
+ return if (duration != 0L) getLockoutDays(duration).toLong() else 7
+ }
+
+ private fun getLockoutDays(timeRemainingMs: Long): Int {
+ return timeRemainingMs.milliseconds.inWholeDays.toInt() + 1
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/captcha/CaptchaFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/captcha/CaptchaFragment.kt
new file mode 100644
index 0000000000..034e6761cc
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/captcha/CaptchaFragment.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.captcha
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.view.View
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.OnBackPressedCallback
+import androidx.activity.addCallback
+import androidx.navigation.fragment.findNavController
+import org.thoughtcrime.securesms.BuildConfig
+import org.thoughtcrime.securesms.LoggingFragment
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.ViewBinderDelegate
+import org.thoughtcrime.securesms.databinding.FragmentRegistrationCaptchaBinding
+import org.thoughtcrime.securesms.registration.fragments.RegistrationConstants
+
+abstract class CaptchaFragment : LoggingFragment(R.layout.fragment_registration_captcha) {
+
+ private val binding: FragmentRegistrationCaptchaBinding by ViewBinderDelegate(FragmentRegistrationCaptchaBinding::bind)
+
+ private val backListener = object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ handleUserExit()
+ }
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ binding.registrationCaptchaWebView.settings.javaScriptEnabled = true
+ binding.registrationCaptchaWebView.clearCache(true)
+
+ binding.registrationCaptchaWebView.webViewClient = object : WebViewClient() {
+ @Deprecated("Deprecated in Java")
+ override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
+ if (url.startsWith(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME)) {
+ val token = url.substring(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME.length)
+ handleCaptchaToken(token)
+ backListener.isEnabled = false
+ findNavController().navigateUp()
+ return true
+ }
+ return false
+ }
+ }
+ requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) {
+ handleUserExit()
+ }
+ binding.registrationCaptchaWebView.loadUrl(BuildConfig.SIGNAL_CAPTCHA_URL)
+ }
+
+ abstract fun handleCaptchaToken(token: String)
+
+ abstract fun handleUserExit()
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/captcha/RegistrationCaptchaFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/captcha/RegistrationCaptchaFragment.kt
new file mode 100644
index 0000000000..da9fc97d6b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/captcha/RegistrationCaptchaFragment.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.captcha
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.activityViewModels
+import org.thoughtcrime.securesms.registration.data.network.Challenge
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
+
+/**
+ * Screen that displays a captcha as part of the registration flow.
+ * This subclass plugs in [RegistrationViewModel] to the shared super class.
+ *
+ * @see CaptchaFragment
+ */
+class RegistrationCaptchaFragment : CaptchaFragment() {
+ private val sharedViewModel by activityViewModels()
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ sharedViewModel.addPresentedChallenge(Challenge.CAPTCHA)
+ }
+
+ override fun handleCaptchaToken(token: String) {
+ sharedViewModel.setCaptchaResponse(token)
+ }
+
+ override fun handleUserExit() {
+ sharedViewModel.removePresentedChallenge(Challenge.CAPTCHA)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeFragment.kt
new file mode 100644
index 0000000000..c2d1075feb
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeFragment.kt
@@ -0,0 +1,300 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.entercode
+
+import android.content.DialogInterface
+import android.os.Bundle
+import android.view.View
+import android.widget.Toast
+import androidx.activity.OnBackPressedCallback
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.NavHostFragment
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.i18n.phonenumbers.PhoneNumberUtil
+import org.greenrobot.eventbus.EventBus
+import org.greenrobot.eventbus.Subscribe
+import org.greenrobot.eventbus.ThreadMode
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.LoggingFragment
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.ViewBinderDelegate
+import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
+import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterCodeBinding
+import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
+import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
+import org.thoughtcrime.securesms.registration.fragments.ContactSupportBottomSheetFragment
+import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
+import org.thoughtcrime.securesms.registration.fragments.SignalStrengthPhoneStateListener
+import org.thoughtcrime.securesms.registration.sms.ReceivedSmsEvent
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
+import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+import org.thoughtcrime.securesms.util.visible
+
+/**
+ * The final screen of account registration, where the user enters their verification code.
+ */
+class EnterCodeFragment : LoggingFragment(R.layout.fragment_registration_enter_code) {
+
+ companion object {
+ private const val BOTTOM_SHEET_TAG = "support_bottom_sheet"
+ }
+
+ private val TAG = Log.tag(EnterCodeFragment::class.java)
+
+ private val sharedViewModel by activityViewModels()
+ private val fragmentViewModel by viewModels()
+ private val bottomSheet = ContactSupportBottomSheetFragment()
+ private val binding: FragmentRegistrationEnterCodeBinding by ViewBinderDelegate(FragmentRegistrationEnterCodeBinding::bind)
+
+ private lateinit var phoneStateListener: SignalStrengthPhoneStateListener
+
+ private var autopilotCodeEntryActive = false
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ setDebugLogSubmitMultiTapView(binding.verifyHeader)
+
+ phoneStateListener = SignalStrengthPhoneStateListener(this, PhoneStateCallback())
+
+ requireActivity().onBackPressedDispatcher.addCallback(
+ viewLifecycleOwner,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ popBackStack()
+ }
+ }
+ )
+
+ binding.wrongNumber.setOnClickListener {
+ popBackStack()
+ }
+
+ binding.code.setOnCompleteListener {
+ sharedViewModel.verifyCodeWithoutRegistrationLock(requireContext(), it)
+ }
+
+ binding.havingTroubleButton.setOnClickListener {
+ bottomSheet.showSafely(childFragmentManager, BOTTOM_SHEET_TAG)
+ }
+
+ binding.callMeCountDown.apply {
+ setTextResources(R.string.RegistrationActivity_call, R.string.RegistrationActivity_call_me_instead_available_in)
+ setOnClickListener {
+ sharedViewModel.requestVerificationCall(requireContext())
+ }
+ }
+
+ binding.resendSmsCountDown.apply {
+ setTextResources(R.string.RegistrationActivity_resend_code, R.string.RegistrationActivity_resend_sms_available_in)
+ setOnClickListener {
+ sharedViewModel.requestSmsCode(requireContext())
+ }
+ }
+
+ binding.keyboard.setOnKeyPressListener { key ->
+ if (!autopilotCodeEntryActive) {
+ if (key >= 0) {
+ binding.code.append(key)
+ } else {
+ binding.code.delete()
+ }
+ }
+ }
+
+ sharedViewModel.incorrectCodeAttempts.observe(viewLifecycleOwner) { attempts: Int ->
+ if (attempts >= 3) {
+ binding.havingTroubleButton.visible = true
+ }
+ }
+
+ sharedViewModel.uiState.observe(viewLifecycleOwner) {
+ it.sessionStateError?.let { error ->
+ handleSessionErrorResponse(error)
+ sharedViewModel.sessionStateErrorShown()
+ }
+
+ it.registerAccountError?.let { error ->
+ handleRegistrationErrorResponse(error)
+ sharedViewModel.registerAccountErrorShown()
+ }
+
+ binding.resendSmsCountDown.startCountDownTo(it.nextSmsTimestamp)
+ binding.callMeCountDown.startCountDownTo(it.nextCallTimestamp)
+ if (it.inProgress) {
+ binding.keyboard.displayProgress()
+ } else {
+ binding.keyboard.displayKeyboard()
+ }
+ }
+
+ fragmentViewModel.uiState.observe(viewLifecycleOwner) {
+ if (it.resetRequiredAfterFailure) {
+ binding.callMeCountDown.visibility = View.VISIBLE
+ binding.resendSmsCountDown.visibility = View.VISIBLE
+ binding.wrongNumber.visibility = View.VISIBLE
+ binding.code.clear()
+ binding.keyboard.displayKeyboard()
+ fragmentViewModel.allViewsResetCompleted()
+ } else if (it.showKeyboard) {
+ binding.keyboard.displayKeyboard()
+ fragmentViewModel.keyboardShown()
+ }
+ }
+
+ EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = viewLifecycleOwner)
+ }
+
+ override fun onResume() {
+ super.onResume()
+ sharedViewModel.phoneNumber?.let {
+ val formatted = PhoneNumberUtil.getInstance().format(it, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)
+ binding.verificationSubheader.text = requireContext().getString(R.string.RegistrationActivity_enter_the_code_we_sent_to_s, formatted)
+ }
+ }
+
+ private fun handleSessionErrorResponse(result: VerificationCodeRequestResult) {
+ when (result) {
+ is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
+ is VerificationCodeRequestResult.RateLimited -> presentRateLimitedDialog()
+ is VerificationCodeRequestResult.AttemptsExhausted -> presentAccountLocked()
+ is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
+ else -> presentGenericError(result)
+ }
+ }
+
+ private fun handleRegistrationErrorResponse(result: RegisterAccountResult) {
+ when (result) {
+ is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
+ is RegisterAccountResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
+ is RegisterAccountResult.AuthorizationFailed -> presentIncorrectCodeDialog()
+ is RegisterAccountResult.AttemptsExhausted -> presentAccountLocked()
+ is RegisterAccountResult.RateLimited -> presentRateLimitedDialog()
+
+ else -> presentGenericError(result)
+ }
+ }
+
+ private fun presentAccountLocked() {
+ binding.keyboard.displayLocked().addListener(
+ object : AssertedSuccessListener() {
+ override fun onSuccess(result: Boolean?) {
+ findNavController().safeNavigate(EnterCodeFragmentDirections.actionAccountLocked())
+ }
+ }
+ )
+ }
+
+ private fun presentRegistrationLocked(timeRemaining: Long) {
+ binding.keyboard.displayLocked().addListener(
+ object : AssertedSuccessListener() {
+ override fun onSuccess(result: Boolean?) {
+ findNavController().safeNavigate(EnterCodeFragmentDirections.actionRequireKbsLockPin(timeRemaining))
+ }
+ }
+ )
+ }
+
+ private fun presentRateLimitedDialog() {
+ binding.keyboard.displayFailure().addListener(
+ object : AssertedSuccessListener() {
+ override fun onSuccess(result: Boolean?) {
+ MaterialAlertDialogBuilder(requireContext()).apply {
+ setTitle(R.string.RegistrationActivity_too_many_attempts)
+ setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
+ setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
+ fragmentViewModel.resetAllViews()
+ }
+ show()
+ }
+ }
+ }
+ )
+ }
+
+ private fun presentIncorrectCodeDialog() {
+ sharedViewModel.incrementIncorrectCodeAttempts()
+
+ Toast.makeText(requireContext(), R.string.RegistrationActivity_incorrect_code, Toast.LENGTH_LONG).show()
+
+ binding.keyboard.displayFailure().addListener(object : AssertedSuccessListener() {
+ override fun onSuccess(result: Boolean?) {
+ fragmentViewModel.resetAllViews()
+ }
+ })
+ }
+
+ private fun presentGenericError(requestResult: RegistrationResult) {
+ binding.keyboard.displayFailure().addListener(
+ object : AssertedSuccessListener() {
+ override fun onSuccess(result: Boolean?) {
+ Log.w(TAG, "Encountered unexpected error!", requestResult.getCause())
+ MaterialAlertDialogBuilder(requireContext()).apply {
+ null?.let {
+ setTitle(it)
+ }
+ setMessage(getString(R.string.RegistrationActivity_error_connecting_to_service))
+ setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.showKeyboard() }
+ show()
+ }
+ }
+ }
+ )
+ }
+
+ private fun popBackStack() {
+ sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PUSH_NETWORK_AUDITED)
+ NavHostFragment.findNavController(this).popBackStack()
+ }
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ fun onVerificationCodeReceived(event: ReceivedSmsEvent) {
+ Log.i(TAG, "Received verification code via EventBus.")
+ binding.code.clear()
+
+ if (event.code.isBlank() || event.code.length != ReceivedSmsEvent.CODE_LENGTH) {
+ Log.i(TAG, "Received invalid code of length ${event.code.length}. Ignoring.")
+ return
+ }
+
+ val finalIndex = ReceivedSmsEvent.CODE_LENGTH - 1
+ autopilotCodeEntryActive = true
+ try {
+ event.code
+ .map { it.digitToInt() }
+ .forEachIndexed { i, digit ->
+ binding.code.postDelayed({
+ binding.code.append(digit)
+ if (i == finalIndex) {
+ autopilotCodeEntryActive = false
+ }
+ }, i * 200L)
+ }
+ Log.i(TAG, "Finished auto-filling code.")
+ } catch (notADigit: IllegalArgumentException) {
+ Log.w(TAG, "Failed to convert code into digits.", notADigit)
+ autopilotCodeEntryActive = false
+ }
+ }
+
+ private inner class PhoneStateCallback : SignalStrengthPhoneStateListener.Callback {
+ override fun onNoCellSignalPresent() {
+ if (isAdded) {
+ bottomSheet.showSafely(childFragmentManager, BOTTOM_SHEET_TAG)
+ }
+ }
+
+ override fun onCellSignalPresent() {
+ if (bottomSheet.isResumed) {
+ bottomSheet.dismiss()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeState.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeState.kt
new file mode 100644
index 0000000000..f1b19819d8
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeState.kt
@@ -0,0 +1,8 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.entercode
+
+data class EnterCodeState(val resetRequiredAfterFailure: Boolean = false, val showKeyboard: Boolean = false)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeViewModel.kt
new file mode 100644
index 0000000000..9074a4f534
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/entercode/EnterCodeViewModel.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.entercode
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.asLiveData
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+
+class EnterCodeViewModel : ViewModel() {
+ private val store = MutableStateFlow(EnterCodeState())
+ val uiState = store.asLiveData()
+
+ fun resetAllViews() {
+ store.update { it.copy(resetRequiredAfterFailure = true) }
+ }
+
+ fun allViewsResetCompleted() {
+ store.update {
+ it.copy(
+ resetRequiredAfterFailure = false,
+ showKeyboard = false
+ )
+ }
+ }
+
+ fun showKeyboard() {
+ store.update { it.copy(showKeyboard = true) }
+ }
+
+ fun keyboardShown() {
+ store.update { it.copy(showKeyboard = false) }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/permissions/GrantPermissionsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/permissions/GrantPermissionsFragment.kt
new file mode 100644
index 0000000000..416792d633
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/permissions/GrantPermissionsFragment.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.permissions
+
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.annotation.RequiresApi
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.content.ContextCompat
+import androidx.core.os.bundleOf
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.setFragmentResult
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.compose.ComposeFragment
+import org.thoughtcrime.securesms.registration.fragments.WelcomePermissions
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
+import org.thoughtcrime.securesms.registrationv3.ui.welcome.WelcomeUserSelection
+import org.thoughtcrime.securesms.util.BackupUtil
+
+/**
+ * Screen in account registration that provides rationales for the suggested runtime permissions.
+ */
+@RequiresApi(23)
+class GrantPermissionsFragment : ComposeFragment() {
+
+ companion object {
+ private val TAG = Log.tag(GrantPermissionsFragment::class.java)
+
+ const val REQUEST_KEY = "GrantPermissionsFragment"
+ }
+
+ private val sharedViewModel by activityViewModels()
+ private val args by navArgs()
+
+ private val requestPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions(),
+ ::onPermissionsGranted
+ )
+
+ private val welcomeUserSelection: WelcomeUserSelection by lazy { args.welcomeUserSelection }
+
+ @Composable
+ override fun FragmentContent() {
+ GrantPermissionsScreen(
+ deviceBuildVersion = Build.VERSION.SDK_INT,
+ isBackupSelectionRequired = BackupUtil.isUserSelectionRequired(LocalContext.current),
+ onNextClicked = this::launchPermissionRequests,
+ onNotNowClicked = this::proceedToNextScreen
+ )
+ }
+
+ private fun launchPermissionRequests() {
+ val isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext())
+
+ val neededPermissions = WelcomePermissions.getWelcomePermissions(isUserSelectionRequired).filterNot {
+ ContextCompat.checkSelfPermission(requireContext(), it) == PackageManager.PERMISSION_GRANTED
+ }
+
+ if (neededPermissions.isEmpty()) {
+ proceedToNextScreen()
+ } else {
+ requestPermissionLauncher.launch(neededPermissions.toTypedArray())
+ }
+ }
+
+ private fun onPermissionsGranted(permissions: Map) {
+ permissions.forEach {
+ Log.d(TAG, "${it.key} = ${it.value}")
+ }
+ sharedViewModel.maybePrefillE164(requireContext())
+ sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
+ proceedToNextScreen()
+ }
+
+ private fun proceedToNextScreen() {
+ setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to welcomeUserSelection))
+ findNavController().popBackStack()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/permissions/GrantPermissionsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/permissions/GrantPermissionsScreen.kt
new file mode 100644
index 0000000000..a8c19bc34c
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/permissions/GrantPermissionsScreen.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.permissions
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.unit.dp
+import org.signal.core.ui.Buttons
+import org.signal.core.ui.Previews
+import org.signal.core.ui.SignalPreview
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
+
+/**
+ * Layout that explains permissions rationale to the user.
+ */
+@Composable
+fun GrantPermissionsScreen(
+ deviceBuildVersion: Int,
+ isBackupSelectionRequired: Boolean,
+ onNextClicked: () -> Unit = {},
+ onNotNowClicked: () -> Unit = {}
+) {
+ RegistrationScreen(
+ title = stringResource(id = R.string.GrantPermissionsFragment__allow_permissions),
+ subtitle = stringResource(id = R.string.GrantPermissionsFragment__to_help_you_message_people_you_know),
+ bottomContent = {
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ TextButton(onClick = onNotNowClicked) {
+ Text(
+ text = stringResource(id = R.string.GrantPermissionsFragment__not_now)
+ )
+ }
+
+ Buttons.LargeTonal(
+ onClick = onNextClicked
+ ) {
+ Text(
+ text = stringResource(id = R.string.GrantPermissionsFragment__next)
+ )
+ }
+ }
+ }
+ ) {
+ if (deviceBuildVersion >= 33) {
+ PermissionRow(
+ imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification),
+ title = stringResource(id = R.string.GrantPermissionsFragment__notifications),
+ subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when)
+ )
+ }
+
+ PermissionRow(
+ imageVector = ImageVector.vectorResource(id = R.drawable.permission_contact),
+ title = stringResource(id = R.string.GrantPermissionsFragment__contacts),
+ subtitle = stringResource(id = R.string.GrantPermissionsFragment__find_people_you_know)
+ )
+
+ if (deviceBuildVersion < 29 || !isBackupSelectionRequired) {
+ PermissionRow(
+ imageVector = ImageVector.vectorResource(id = R.drawable.permission_file),
+ title = stringResource(id = R.string.GrantPermissionsFragment__storage),
+ subtitle = stringResource(id = R.string.GrantPermissionsFragment__send_photos_videos_and_files)
+ )
+ }
+
+ PermissionRow(
+ imageVector = ImageVector.vectorResource(id = R.drawable.permission_phone),
+ title = stringResource(id = R.string.GrantPermissionsFragment__phone_calls),
+ subtitle = stringResource(id = R.string.GrantPermissionsFragment__make_registering_easier)
+ )
+ }
+}
+
+@SignalPreview
+@Composable
+fun GrantPermissionsScreenPreview() {
+ Previews.Preview {
+ GrantPermissionsScreen(
+ deviceBuildVersion = 33,
+ isBackupSelectionRequired = true
+ )
+ }
+}
+
+@Composable
+fun PermissionRow(
+ imageVector: ImageVector,
+ title: String,
+ subtitle: String
+) {
+ Row(modifier = Modifier.padding(bottom = 32.dp)) {
+ Image(
+ imageVector = imageVector,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp)
+ )
+
+ Spacer(modifier = Modifier.size(16.dp))
+
+ Column {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleSmall
+ )
+
+ Text(
+ text = subtitle,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Spacer(modifier = Modifier.size(32.dp))
+ }
+}
+
+@SignalPreview
+@Composable
+fun PermissionRowPreview() {
+ Previews.Preview {
+ PermissionRow(
+ imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification),
+ title = stringResource(id = R.string.GrantPermissionsFragment__notifications),
+ subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when)
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt
new file mode 100644
index 0000000000..ff051e521e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt
@@ -0,0 +1,654 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
+
+import android.content.Context
+import android.content.DialogInterface
+import android.os.Bundle
+import android.text.Editable
+import android.text.SpannableStringBuilder
+import android.text.TextWatcher
+import android.view.KeyEvent
+import android.view.Menu
+import android.view.MenuInflater
+import android.view.MenuItem
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.widget.ArrayAdapter
+import android.widget.TextView
+import androidx.activity.OnBackPressedCallback
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.MenuProvider
+import androidx.core.widget.addTextChangedListener
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.NavHostFragment
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import com.google.android.gms.common.ConnectionResult
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.textfield.MaterialAutoCompleteTextView
+import com.google.android.material.textfield.TextInputEditText
+import com.google.i18n.phonenumbers.NumberParseException
+import com.google.i18n.phonenumbers.PhoneNumberUtil
+import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber
+import org.signal.core.util.isNotNullOrBlank
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.LoggingFragment
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.ViewBinderDelegate
+import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterPhoneNumberBinding
+import org.thoughtcrime.securesms.dependencies.AppDependencies
+import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter
+import org.thoughtcrime.securesms.registration.data.network.Challenge
+import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
+import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
+import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult
+import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
+import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
+import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
+import org.thoughtcrime.securesms.registration.ui.toE164
+import org.thoughtcrime.securesms.registration.util.CountryPrefix
+import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
+import org.thoughtcrime.securesms.util.CommunicationActions
+import org.thoughtcrime.securesms.util.Dialogs
+import org.thoughtcrime.securesms.util.PlayServicesUtil
+import org.thoughtcrime.securesms.util.SpanUtil
+import org.thoughtcrime.securesms.util.SupportEmailUtil
+import org.thoughtcrime.securesms.util.ViewUtil
+import org.thoughtcrime.securesms.util.livedata.LiveDataObserverCallback
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+import org.thoughtcrime.securesms.util.visible
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Screen in registration where the user enters their phone number.
+ */
+class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_enter_phone_number) {
+
+ private val TAG = Log.tag(EnterPhoneNumberFragment::class.java)
+ private val sharedViewModel by activityViewModels()
+ private val fragmentViewModel by viewModels()
+ private val args by navArgs()
+ private val binding: FragmentRegistrationEnterPhoneNumberBinding by ViewBinderDelegate(FragmentRegistrationEnterPhoneNumberBinding::bind)
+
+ private val enterPhoneNumberMode: EnterPhoneNumberMode by lazy { args.enterPhoneNumberMode }
+ private var processedResumeMode: Boolean = false
+
+ private val skipToNextScreen: DialogInterface.OnClickListener = DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> moveToVerificationEntryScreen() }
+
+ private lateinit var spinnerAdapter: ArrayAdapter
+ private lateinit var phoneNumberInputLayout: TextInputEditText
+ private lateinit var spinnerView: MaterialAutoCompleteTextView
+
+ private var currentPhoneNumberFormatter: TextWatcher? = null
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ setDebugLogSubmitMultiTapView(binding.verifyHeader)
+ requireActivity().onBackPressedDispatcher.addCallback(
+ viewLifecycleOwner,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ popBackStack()
+ }
+ }
+ )
+ phoneNumberInputLayout = binding.number.editText as TextInputEditText
+ spinnerView = binding.countryCode.editText as MaterialAutoCompleteTextView
+ spinnerAdapter = ArrayAdapter(
+ requireContext(),
+ R.layout.registration_country_code_dropdown_item,
+ fragmentViewModel.supportedCountryPrefixes
+ )
+ binding.registerButton.setOnClickListener { onRegistrationButtonClicked() }
+
+ binding.toolbar.title = ""
+ val activity = requireActivity() as AppCompatActivity
+ activity.setSupportActionBar(binding.toolbar)
+
+ requireActivity().addMenuProvider(UseProxyMenuProvider(), viewLifecycleOwner)
+
+ sharedViewModel.uiState.observe(viewLifecycleOwner) { sharedState ->
+ presentRegisterButton(sharedState)
+ presentProgressBar(sharedState.inProgress, sharedState.isReRegister)
+
+ sharedState.networkError?.let {
+ presentNetworkError(it)
+ sharedViewModel.networkErrorShown()
+ }
+
+ sharedState.sessionCreationError?.let {
+ handleSessionCreationError(it)
+ sharedViewModel.sessionCreationErrorShown()
+ }
+
+ sharedState.sessionStateError?.let {
+ handleSessionStateError(it)
+ sharedViewModel.sessionStateErrorShown()
+ }
+
+ sharedState.registerAccountError?.let {
+ handleRegistrationErrorResponse(it)
+ sharedViewModel.registerAccountErrorShown()
+ }
+
+ if (sharedState.challengesRequested.contains(Challenge.CAPTCHA) && sharedState.captchaToken.isNotNullOrBlank()) {
+ sharedViewModel.submitCaptchaToken(requireContext())
+ } else if (sharedState.challengesRemaining.isNotEmpty()) {
+ handleChallenges(sharedState.challengesRemaining)
+ } else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED && sharedState.canSkipSms) {
+ moveToEnterPinScreen()
+ } else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED) {
+ moveToVerificationEntryScreen()
+ }
+ }
+
+ fragmentViewModel.uiState.observe(viewLifecycleOwner) { fragmentState ->
+
+ fragmentState.phoneNumberFormatter?.let {
+ bindPhoneNumberFormatter(it)
+ phoneNumberInputLayout.requestFocus()
+ }
+
+ if (fragmentViewModel.isEnteredNumberPossible(fragmentState)) {
+ sharedViewModel.setPhoneNumber(fragmentViewModel.parsePhoneNumber(fragmentState))
+ } else {
+ sharedViewModel.setPhoneNumber(null)
+ }
+
+ if (fragmentState.error != EnterPhoneNumberState.Error.NONE) {
+ presentLocalError(fragmentState)
+ }
+ }
+
+ initializeInputFields()
+
+ val existingPhoneNumber = sharedViewModel.phoneNumber
+ if (existingPhoneNumber != null) {
+ fragmentViewModel.restoreState(existingPhoneNumber)
+ spinnerView.setText(existingPhoneNumber.countryCode.toString())
+ fragmentViewModel.formatter?.let {
+ bindPhoneNumberFormatter(it)
+ }
+ phoneNumberInputLayout.setText(existingPhoneNumber.nationalNumber.toString())
+ } else {
+ spinnerView.setText(fragmentViewModel.countryPrefix().toString())
+ }
+
+ if (enterPhoneNumberMode == EnterPhoneNumberMode.RESTART_AFTER_COLLECTION && (savedInstanceState == null && !processedResumeMode)) {
+ processedResumeMode = true
+ startNormalRegistration()
+ } else {
+ ViewUtil.focusAndShowKeyboard(phoneNumberInputLayout)
+ }
+ }
+
+ private fun bindPhoneNumberFormatter(formatter: TextWatcher) {
+ if (formatter != currentPhoneNumberFormatter) {
+ currentPhoneNumberFormatter?.let { oldWatcher ->
+ Log.d(TAG, "Removing current phone number formatter in fragment")
+ phoneNumberInputLayout.removeTextChangedListener(oldWatcher)
+ }
+ phoneNumberInputLayout.addTextChangedListener(formatter)
+ currentPhoneNumberFormatter = formatter
+ Log.d(TAG, "Updated phone number formatter in fragment")
+ }
+ }
+
+ private fun handleChallenges(remainingChallenges: List) {
+ when (remainingChallenges.first()) {
+ Challenge.CAPTCHA -> moveToCaptcha()
+ Challenge.PUSH -> performPushChallenge()
+ }
+ }
+
+ private fun performPushChallenge() {
+ sharedViewModel.requestAndSubmitPushToken(requireContext())
+ }
+
+ private fun initializeInputFields() {
+ binding.countryCode.editText?.addTextChangedListener { s ->
+ val sanitized = s.toString().filter { c -> c.isDigit() }
+ if (sanitized.isNotNullOrBlank()) {
+ val countryCode: Int = sanitized.toInt()
+ fragmentViewModel.setCountry(countryCode)
+ }
+ }
+
+ phoneNumberInputLayout.addTextChangedListener {
+ fragmentViewModel.setPhoneNumber(it?.toString())
+ }
+
+ val scrollView = binding.scrollView
+ val registerButton = binding.registerButton
+ phoneNumberInputLayout.onFocusChangeListener = View.OnFocusChangeListener { _: View?, hasFocus: Boolean ->
+ if (hasFocus) {
+ scrollView.postDelayed({
+ scrollView.smoothScrollTo(0, registerButton.bottom)
+ }, 250)
+ }
+ }
+
+ phoneNumberInputLayout.imeOptions = EditorInfo.IME_ACTION_DONE
+ phoneNumberInputLayout.setOnEditorActionListener { v: TextView?, actionId: Int, _: KeyEvent? ->
+ if (actionId == EditorInfo.IME_ACTION_DONE && v != null) {
+ onRegistrationButtonClicked()
+ return@setOnEditorActionListener true
+ }
+ false
+ }
+
+ spinnerView.threshold = 100
+ spinnerView.setAdapter(spinnerAdapter)
+ spinnerView.addTextChangedListener(afterTextChanged = ::onCountryDropDownChanged)
+ }
+
+ private fun onCountryDropDownChanged(s: Editable?) {
+ if (s.isNullOrEmpty()) {
+ return
+ }
+
+ if (s[0] != '+') {
+ s.insert(0, "+")
+ }
+
+ fragmentViewModel.supportedCountryPrefixes.firstOrNull { it.toString() == s.toString() }?.let {
+ fragmentViewModel.setCountry(it.digits)
+ val numberLength: Int = phoneNumberInputLayout.text?.length ?: 0
+ phoneNumberInputLayout.setSelection(numberLength, numberLength)
+ }
+ }
+
+ private fun presentRegisterButton(sharedState: RegistrationState) {
+ binding.registerButton.isEnabled = sharedState.phoneNumber != null && PhoneNumberUtil.getInstance().isPossibleNumber(sharedState.phoneNumber)
+ if (sharedState.inProgress) {
+ binding.registerButton.setSpinning()
+ } else {
+ binding.registerButton.cancelSpinning()
+ }
+ }
+
+ private fun presentLocalError(state: EnterPhoneNumberState) {
+ when (state.error) {
+ EnterPhoneNumberState.Error.NONE -> Unit
+
+ EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER -> {
+ MaterialAlertDialogBuilder(requireContext()).apply {
+ setTitle(R.string.RegistrationActivity_invalid_number)
+ setMessage(
+ String.format(
+ getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid),
+ state.phoneNumber
+ )
+ )
+ setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.clearError() }
+ setOnCancelListener { fragmentViewModel.clearError() }
+ setOnDismissListener { fragmentViewModel.clearError() }
+ show()
+ }
+ }
+
+ EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING -> {
+ handlePromptForNoPlayServices()
+ }
+
+ EnterPhoneNumberState.Error.PLAY_SERVICES_NEEDS_UPDATE -> {
+ GoogleApiAvailability.getInstance().getErrorDialog(requireActivity(), ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED, 0)?.show()
+ }
+
+ EnterPhoneNumberState.Error.PLAY_SERVICES_TRANSIENT -> {
+ MaterialAlertDialogBuilder(requireContext()).apply {
+ setTitle(R.string.RegistrationActivity_play_services_error)
+ setMessage(R.string.RegistrationActivity_google_play_services_is_updating_or_unavailable)
+ setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.clearError() }
+ setOnCancelListener { fragmentViewModel.clearError() }
+ setOnDismissListener { fragmentViewModel.clearError() }
+ show()
+ }
+ }
+ }
+ }
+
+ private fun presentNetworkError(networkError: Throwable) {
+ Log.i(TAG, "Unknown error during verification code request", networkError)
+ MaterialAlertDialogBuilder(requireContext()).apply {
+ setMessage(R.string.RegistrationActivity_unable_to_connect_to_service)
+ setPositiveButton(android.R.string.ok, null)
+ show()
+ }
+ }
+
+ private fun handleSessionCreationError(result: RegistrationSessionResult) {
+ if (!result.isSuccess()) {
+ Log.i(TAG, "Handling error response of ${result.javaClass.name}", result.getCause())
+ }
+ when (result) {
+ is RegistrationSessionCheckResult.Success,
+ is RegistrationSessionCreationResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
+
+ is RegistrationSessionCreationResult.AttemptsExhausted -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_service))
+ is RegistrationSessionCreationResult.MalformedRequest -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen)
+
+ is RegistrationSessionCreationResult.RateLimited -> {
+ Log.i(TAG, "Session creation rate limited! Next attempt: ${result.timeRemaining.milliseconds}")
+ presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_try_again, result.timeRemaining.milliseconds.toString()))
+ }
+
+ is RegistrationSessionCreationResult.ServerUnableToParse -> presentGenericError(result)
+ is RegistrationSessionCheckResult.SessionNotFound -> presentGenericError(result)
+ is RegistrationSessionCheckResult.UnknownError,
+ is RegistrationSessionCreationResult.UnknownError -> presentGenericError(result)
+ }
+ }
+
+ private fun handleSessionStateError(result: VerificationCodeRequestResult) {
+ if (!result.isSuccess()) {
+ Log.i(TAG, "Handling error response.", result.getCause())
+ }
+ when (result) {
+ is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
+ is VerificationCodeRequestResult.AttemptsExhausted -> presentRateLimitedDialog()
+ is VerificationCodeRequestResult.ChallengeRequired -> handleChallenges(result.challenges)
+ is VerificationCodeRequestResult.ExternalServiceFailure -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen)
+ is VerificationCodeRequestResult.ImpossibleNumber -> {
+ MaterialAlertDialogBuilder(requireContext()).apply {
+ setMessage(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid, fragmentViewModel.phoneNumber?.toE164()))
+ setPositiveButton(android.R.string.ok, null)
+ show()
+ }
+ }
+
+ is VerificationCodeRequestResult.InvalidTransportModeFailure -> {
+ MaterialAlertDialogBuilder(requireContext()).apply {
+ setMessage(R.string.RegistrationActivity_we_couldnt_send_you_a_verification_code)
+ setPositiveButton(R.string.RegistrationActivity_voice_call) { _, _ ->
+ sharedViewModel.requestVerificationCall(requireContext())
+ }
+ setNegativeButton(R.string.RegistrationActivity_cancel, null)
+ show()
+ }
+ }
+
+ is VerificationCodeRequestResult.MalformedRequest -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen)
+ is VerificationCodeRequestResult.MustRetry -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen)
+ is VerificationCodeRequestResult.NonNormalizedNumber -> handleNonNormalizedNumberError(result.originalNumber, result.normalizedNumber, fragmentViewModel.e164VerificationMode)
+ is VerificationCodeRequestResult.RateLimited -> {
+ Log.i(TAG, "Code request rate limited! Next attempt: ${result.timeRemaining.milliseconds}")
+ presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_try_again, result.timeRemaining.milliseconds.toString()))
+ }
+
+ is VerificationCodeRequestResult.TokenNotAccepted -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_we_need_to_verify_that_youre_human)) { _, _ -> moveToCaptcha() }
+ is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
+ is VerificationCodeRequestResult.AlreadyVerified -> presentGenericError(result)
+ is VerificationCodeRequestResult.NoSuchSession -> presentGenericError(result)
+ is VerificationCodeRequestResult.UnknownError -> presentGenericError(result)
+ }
+ }
+
+ private fun presentGenericError(result: RegistrationResult) {
+ Log.i(TAG, "Received unhandled response: ${result.javaClass.name}", result.getCause())
+ presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service))
+ }
+
+ private fun handleRegistrationErrorResponse(result: RegisterAccountResult) {
+ when (result) {
+ is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
+ is RegisterAccountResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
+ is RegisterAccountResult.AttemptsExhausted -> presentAccountLocked()
+ is RegisterAccountResult.RateLimited -> presentRateLimitedDialog()
+ is RegisterAccountResult.SvrNoData -> presentAccountLocked()
+ else -> presentGenericError(result)
+ }
+ }
+
+ private fun presentRegistrationLocked(timeRemaining: Long) {
+ findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionPhoneNumberRegistrationLock(timeRemaining))
+ }
+
+ private fun presentRateLimitedDialog() {
+ presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_service))
+ }
+
+ private fun presentAccountLocked() {
+ findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionPhoneNumberAccountLocked())
+ }
+
+ private fun moveToCaptcha() {
+ findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionRequestCaptcha())
+ }
+
+ private fun presentRemoteErrorDialog(message: String, positiveButtonListener: DialogInterface.OnClickListener? = null) {
+ MaterialAlertDialogBuilder(requireContext()).apply {
+ setMessage(message)
+ setPositiveButton(android.R.string.ok, positiveButtonListener)
+ show()
+ }
+ }
+
+ private fun handleNonNormalizedNumberError(originalNumber: String, normalizedNumber: String, mode: RegistrationRepository.E164VerificationMode) {
+ try {
+ val phoneNumber = PhoneNumberUtil.getInstance().parse(normalizedNumber, null)
+
+ MaterialAlertDialogBuilder(requireContext()).apply {
+ setTitle(R.string.RegistrationActivity_non_standard_number_format)
+ setMessage(getString(R.string.RegistrationActivity_the_number_you_entered_appears_to_be_a_non_standard, originalNumber, normalizedNumber))
+ setNegativeButton(android.R.string.no) { d: DialogInterface, i: Int -> d.dismiss() }
+ setNeutralButton(R.string.RegistrationActivity_contact_signal_support) { dialogInterface, _ ->
+ val subject = getString(R.string.RegistrationActivity_signal_android_phone_number_format)
+ val body = SupportEmailUtil.generateSupportEmailBody(requireContext(), R.string.RegistrationActivity_signal_android_phone_number_format, null, null)
+
+ CommunicationActions.openEmail(requireContext(), SupportEmailUtil.getSupportEmailAddress(requireContext()), subject, body)
+ dialogInterface.dismiss()
+ }
+ setPositiveButton(R.string.yes) { dialogInterface, _ ->
+ spinnerView.setText(phoneNumber.countryCode.toString())
+ phoneNumberInputLayout.setText(phoneNumber.nationalNumber.toString())
+ when (mode) {
+ RegistrationRepository.E164VerificationMode.SMS_WITH_LISTENER,
+ RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER -> sharedViewModel.requestSmsCode(requireContext())
+
+ RegistrationRepository.E164VerificationMode.PHONE_CALL -> sharedViewModel.requestVerificationCall(requireContext())
+ }
+ dialogInterface.dismiss()
+ }
+ show()
+ }
+ } catch (e: NumberParseException) {
+ Log.w(TAG, "Failed to parse number!", e)
+
+ Dialogs.showAlertDialog(
+ requireContext(),
+ getString(R.string.RegistrationActivity_invalid_number),
+ getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid, fragmentViewModel.phoneNumber?.toE164())
+ )
+ }
+ }
+
+ private fun onRegistrationButtonClicked() {
+ when (enterPhoneNumberMode) {
+ EnterPhoneNumberMode.NORMAL,
+ EnterPhoneNumberMode.RESTART_AFTER_COLLECTION -> startNormalRegistration()
+
+ EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToEnterBackupKey())
+ }
+ }
+
+ private fun startNormalRegistration() {
+ ViewUtil.hideKeyboard(requireContext(), phoneNumberInputLayout)
+ sharedViewModel.setInProgress(true)
+ val hasFcm = validateFcmStatus(requireContext())
+ if (hasFcm) {
+ sharedViewModel.uiState.observe(viewLifecycleOwner, FcmTokenRetrievedObserver())
+ sharedViewModel.fetchFcmToken(requireContext())
+ } else {
+ sharedViewModel.uiState.value?.let { value ->
+ val now = System.currentTimeMillis()
+ if (value.phoneNumber == null) {
+ fragmentViewModel.setError(EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER)
+ sharedViewModel.setInProgress(false)
+ } else if (now < value.nextSmsTimestamp) {
+ moveToVerificationEntryScreen()
+ } else {
+ presentConfirmNumberDialog(value.phoneNumber, value.isReRegister, value.canSkipSms, missingFcmConsentRequired = true)
+ }
+ }
+ }
+ }
+
+ private fun onFcmTokenRetrieved(value: RegistrationState) {
+ if (value.phoneNumber == null) {
+ fragmentViewModel.setError(EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER)
+ sharedViewModel.setInProgress(false)
+ } else {
+ presentConfirmNumberDialog(value.phoneNumber, value.isReRegister, value.canSkipSms, missingFcmConsentRequired = false)
+ }
+ }
+
+ private fun presentProgressBar(showProgress: Boolean, isReRegister: Boolean) {
+ if (showProgress) {
+ binding.registerButton.setSpinning()
+ } else {
+ binding.registerButton.cancelSpinning()
+ }
+ binding.countryCode.isEnabled = !showProgress
+ binding.number.isEnabled = !showProgress
+ binding.cancelButton.visible = !showProgress && isReRegister
+ }
+
+ private fun validateFcmStatus(context: Context): Boolean {
+ val fcmStatus = PlayServicesUtil.getPlayServicesStatus(context)
+ Log.d(TAG, "Got $fcmStatus for Play Services status.")
+ when (fcmStatus) {
+ PlayServicesUtil.PlayServicesStatus.SUCCESS -> {
+ return true
+ }
+
+ PlayServicesUtil.PlayServicesStatus.MISSING -> {
+ fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING)
+ return false
+ }
+
+ PlayServicesUtil.PlayServicesStatus.NEEDS_UPDATE -> {
+ fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_NEEDS_UPDATE)
+ return false
+ }
+
+ PlayServicesUtil.PlayServicesStatus.TRANSIENT_ERROR -> {
+ fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_TRANSIENT)
+ return false
+ }
+
+ null -> {
+ Log.w(TAG, "Null result received from PlayServicesUtil, marking Play Services as missing.")
+ fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING)
+ return false
+ }
+ }
+ }
+
+ private fun handleConfirmNumberDialogCanceled() {
+ Log.d(TAG, "User canceled confirm number, returning to edit number.")
+ sharedViewModel.setInProgress(false)
+ ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(phoneNumberInputLayout)
+ }
+
+ private fun presentConfirmNumberDialog(phoneNumber: PhoneNumber, isReRegister: Boolean, canSkipSms: Boolean, missingFcmConsentRequired: Boolean) {
+ val title = if (isReRegister) {
+ R.string.RegistrationActivity_additional_verification_required
+ } else {
+ R.string.RegistrationActivity_phone_number_verification_dialog_title
+ }
+
+ val message: CharSequence = SpannableStringBuilder().apply {
+ append(SpanUtil.bold(PhoneNumberFormatter.prettyPrint(phoneNumber.toE164())))
+ if (!canSkipSms) {
+ append("\n\n")
+ append(getString(R.string.RegistrationActivity_a_verification_code_will_be_sent_to_this_number))
+ }
+ }
+
+ MaterialAlertDialogBuilder(requireContext()).apply {
+ setTitle(title)
+ setMessage(message)
+ setPositiveButton(android.R.string.ok) { _, _ ->
+ Log.d(TAG, "User confirmed number.")
+ if (missingFcmConsentRequired) {
+ handlePromptForNoPlayServices()
+ } else {
+ sharedViewModel.onUserConfirmedPhoneNumber(requireContext())
+ }
+ }
+ setNegativeButton(R.string.RegistrationActivity_edit_number) { _, _ -> handleConfirmNumberDialogCanceled() }
+ setOnCancelListener { _ -> handleConfirmNumberDialogCanceled() }
+ }.show()
+ }
+
+ private fun handlePromptForNoPlayServices() {
+ val context = activity
+
+ if (context != null) {
+ Log.d(TAG, "Device does not have Play Services, showing consent dialog.")
+ MaterialAlertDialogBuilder(context).apply {
+ setTitle(R.string.RegistrationActivity_missing_google_play_services)
+ setMessage(R.string.RegistrationActivity_this_device_is_missing_google_play_services)
+ setPositiveButton(R.string.RegistrationActivity_i_understand) { _, _ ->
+ Log.d(TAG, "User confirmed number.")
+ sharedViewModel.onUserConfirmedPhoneNumber(AppDependencies.application)
+ }
+ setNegativeButton(android.R.string.cancel, null)
+ setOnCancelListener { fragmentViewModel.clearError() }
+ setOnDismissListener { fragmentViewModel.clearError() }
+ show()
+ }
+ }
+ }
+
+ private fun moveToEnterPinScreen() {
+ findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionReRegisterWithPinFragment())
+ sharedViewModel.setInProgress(false)
+ }
+
+ private fun moveToVerificationEntryScreen() {
+ findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionEnterVerificationCode())
+ sharedViewModel.setInProgress(false)
+ }
+
+ private fun popBackStack() {
+ sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.INITIALIZATION)
+ findNavController().popBackStack()
+ }
+
+ private inner class FcmTokenRetrievedObserver : LiveDataObserverCallback(sharedViewModel.uiState) {
+ override fun onValue(value: RegistrationState): Boolean {
+ val fcmRetrieved = value.isFcmSupported
+ if (fcmRetrieved) {
+ onFcmTokenRetrieved(value)
+ }
+ return fcmRetrieved
+ }
+ }
+
+ private inner class UseProxyMenuProvider : MenuProvider {
+ override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
+ menuInflater.inflate(R.menu.enter_phone_number, menu)
+ }
+
+ override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
+ return if (menuItem.itemId == R.id.phone_menu_use_proxy) {
+ NavHostFragment.findNavController(this@EnterPhoneNumberFragment).safeNavigate(EnterPhoneNumberFragmentDirections.actionEditProxy())
+ true
+ } else {
+ false
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberMode.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberMode.kt
new file mode 100644
index 0000000000..d6f3bf4e75
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberMode.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
+
+/**
+ * Enter phone number mode to determine if verification is needed or just e164 input is necessary.
+ */
+enum class EnterPhoneNumberMode {
+ /** Normal registration start, collect number to verify */
+ NORMAL,
+
+ /** User pre-selected restore/transfer flow, collect number to re-register and restore with */
+ COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE,
+
+ /** User reversed decision on restore and needs to resume normal re-register but automatically start verify */
+ RESTART_AFTER_COLLECTION
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberState.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberState.kt
new file mode 100644
index 0000000000..eb4f0f0ca6
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberState.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
+
+import android.text.TextWatcher
+import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
+
+/**
+ * State holder for the phone number entry screen, including phone number and Play Services errors.
+ */
+data class EnterPhoneNumberState(
+ val countryPrefixIndex: Int = 0,
+ val phoneNumber: String = "",
+ val phoneNumberFormatter: TextWatcher? = null,
+ val mode: RegistrationRepository.E164VerificationMode = RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER,
+ val error: Error = Error.NONE
+) {
+ enum class Error {
+ NONE, INVALID_PHONE_NUMBER, PLAY_SERVICES_MISSING, PLAY_SERVICES_NEEDS_UPDATE, PLAY_SERVICES_TRANSIENT
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberViewModel.kt
new file mode 100644
index 0000000000..0d10f620fc
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberViewModel.kt
@@ -0,0 +1,125 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
+
+import android.telephony.PhoneNumberFormattingTextWatcher
+import android.text.TextWatcher
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.asLiveData
+import androidx.lifecycle.viewModelScope
+import com.google.i18n.phonenumbers.NumberParseException
+import com.google.i18n.phonenumbers.PhoneNumberUtil
+import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.registration.util.CountryPrefix
+import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
+
+/**
+ * ViewModel for the phone number entry screen.
+ */
+class EnterPhoneNumberViewModel : ViewModel() {
+
+ private val TAG = Log.tag(EnterPhoneNumberViewModel::class.java)
+
+ private val store = MutableStateFlow(EnterPhoneNumberState())
+ val uiState = store.asLiveData()
+
+ val formatter: TextWatcher?
+ get() = store.value.phoneNumberFormatter
+
+ val phoneNumber: PhoneNumber?
+ get() = try {
+ parsePhoneNumber(store.value)
+ } catch (ex: NumberParseException) {
+ Log.w(TAG, "Could not parse phone number in current state.", ex)
+ null
+ }
+
+ val supportedCountryPrefixes: List = PhoneNumberUtil.getInstance().supportedCallingCodes
+ .map { CountryPrefix(it, PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(it)) }
+ .sortedBy { it.digits }
+
+ var e164VerificationMode: RegistrationRepository.E164VerificationMode
+ get() = store.value.mode
+ set(value) = store.update {
+ it.copy(mode = value)
+ }
+
+ fun countryPrefix(): CountryPrefix {
+ return supportedCountryPrefixes[store.value.countryPrefixIndex]
+ }
+
+ fun setPhoneNumber(phoneNumber: String?) {
+ store.update { it.copy(phoneNumber = phoneNumber ?: "") }
+ }
+
+ fun setCountry(digits: Int) {
+ val matchingIndex = countryCodeToAdapterIndex(digits)
+ if (matchingIndex == -1) {
+ Log.d(TAG, "Invalid country code specified $digits")
+ return
+ }
+
+ store.update {
+ it.copy(countryPrefixIndex = matchingIndex)
+ }
+
+ viewModelScope.launch {
+ withContext(Dispatchers.Default) {
+ val regionCode = PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(digits)
+ val textWatcher = PhoneNumberFormattingTextWatcher(regionCode)
+
+ store.update {
+ Log.d(TAG, "Updating phone number formatter in state")
+ it.copy(phoneNumberFormatter = textWatcher)
+ }
+ }
+ }
+ }
+
+ fun parsePhoneNumber(state: EnterPhoneNumberState): PhoneNumber {
+ return PhoneNumberUtil.getInstance().parse(state.phoneNumber, supportedCountryPrefixes[state.countryPrefixIndex].regionCode)
+ }
+
+ fun isEnteredNumberPossible(state: EnterPhoneNumberState): Boolean {
+ return try {
+ PhoneNumberUtil.getInstance().isPossibleNumber(parsePhoneNumber(state))
+ } catch (ex: NumberParseException) {
+ false
+ }
+ }
+
+ fun restoreState(value: PhoneNumber) {
+ val prefixIndex = countryCodeToAdapterIndex(value.countryCode)
+ if (prefixIndex != -1) {
+ store.update {
+ it.copy(
+ countryPrefixIndex = prefixIndex,
+ phoneNumber = value.nationalNumber.toString()
+ )
+ }
+ }
+ }
+
+ private fun countryCodeToAdapterIndex(countryCode: Int): Int {
+ return supportedCountryPrefixes.indexOfFirst { prefix -> prefix.digits == countryCode }
+ }
+
+ fun clearError() {
+ setError(EnterPhoneNumberState.Error.NONE)
+ }
+
+ fun setError(error: EnterPhoneNumberState.Error) {
+ store.update {
+ it.copy(error = error)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/registrationlock/RegistrationLockFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/registrationlock/RegistrationLockFragment.kt
new file mode 100644
index 0000000000..9abccff461
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/registrationlock/RegistrationLockFragment.kt
@@ -0,0 +1,304 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.registrationlock
+
+import android.os.Bundle
+import android.text.InputType
+import android.view.KeyEvent
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.widget.TextView
+import android.widget.Toast
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.LoggingFragment
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.ViewBinderDelegate
+import org.thoughtcrime.securesms.databinding.FragmentRegistrationLockBinding
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
+import org.thoughtcrime.securesms.lock.v2.SvrConstants
+import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
+import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
+import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
+import org.thoughtcrime.securesms.util.CommunicationActions
+import org.thoughtcrime.securesms.util.SupportEmailUtil
+import org.thoughtcrime.securesms.util.ViewUtil
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+import java.util.concurrent.TimeUnit
+
+class RegistrationLockFragment : LoggingFragment(R.layout.fragment_registration_lock) {
+ companion object {
+ private val TAG = Log.tag(RegistrationLockFragment::class.java)
+ }
+
+ private val binding: FragmentRegistrationLockBinding by ViewBinderDelegate(FragmentRegistrationLockBinding::bind)
+
+ private val viewModel by activityViewModels()
+
+ private var timeRemaining: Long = 0
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title))
+
+ val args: RegistrationLockFragmentArgs = RegistrationLockFragmentArgs.fromBundle(requireArguments())
+
+ timeRemaining = args.getTimeRemaining()
+
+ binding.kbsLockForgotPin.visibility = View.GONE
+ binding.kbsLockForgotPin.setOnClickListener { handleForgottenPin(timeRemaining) }
+
+ binding.kbsLockPinInput.setImeOptions(EditorInfo.IME_ACTION_DONE)
+ binding.kbsLockPinInput.setOnEditorActionListener { v: TextView?, actionId: Int, _: KeyEvent? ->
+ if (actionId == EditorInfo.IME_ACTION_DONE) {
+ ViewUtil.hideKeyboard(requireContext(), v!!)
+ handlePinEntry()
+ return@setOnEditorActionListener true
+ }
+ false
+ }
+
+ enableAndFocusPinEntry()
+
+ binding.kbsLockPinConfirm.setOnClickListener {
+ ViewUtil.hideKeyboard(requireContext(), binding.kbsLockPinInput)
+ handlePinEntry()
+ }
+
+ binding.kbsLockKeyboardToggle.setOnClickListener {
+ val keyboardType: PinKeyboardType = getPinEntryKeyboardType()
+ updateKeyboard(keyboardType.other)
+ binding.kbsLockKeyboardToggle.setIconResource(keyboardType.iconResource)
+ }
+
+ val keyboardType: PinKeyboardType = getPinEntryKeyboardType().getOther()
+ binding.kbsLockKeyboardToggle.setIconResource(keyboardType.iconResource)
+
+ viewModel.lockedTimeRemaining.observe(viewLifecycleOwner) { t: Long -> timeRemaining = t }
+
+ val triesRemaining: Int = viewModel.svrTriesRemaining
+
+ if (triesRemaining <= 3) {
+ val daysRemaining = getLockoutDays(timeRemaining)
+
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.RegistrationLockFragment__not_many_tries_left)
+ .setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining))
+ .setPositiveButton(android.R.string.ok, null)
+ .setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ -> sendEmailToSupport() }
+ .show()
+ }
+
+ if (triesRemaining < 5) {
+ binding.kbsLockPinInputLabel.text = requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__d_attempts_remaining, triesRemaining, triesRemaining)
+ }
+
+ viewModel.uiState.observe(viewLifecycleOwner) {
+ if (it.inProgress) {
+ binding.kbsLockPinConfirm.setSpinning()
+ } else {
+ binding.kbsLockPinConfirm.cancelSpinning()
+ }
+
+ it.sessionStateError?.let { error ->
+ handleSessionErrorResponse(error)
+ viewModel.sessionStateErrorShown()
+ }
+
+ it.registerAccountError?.let { error ->
+ handleRegistrationErrorResponse(error)
+ viewModel.registerAccountErrorShown()
+ }
+ }
+ }
+
+ private fun handlePinEntry() {
+ binding.kbsLockPinInput.setEnabled(false)
+
+ val pin: String = binding.kbsLockPinInput.getText().toString()
+
+ val trimmedLength = pin.replace(" ", "").length
+ if (trimmedLength == 0) {
+ Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show()
+ enableAndFocusPinEntry()
+ return
+ }
+
+ if (trimmedLength < SvrConstants.MINIMUM_PIN_LENGTH) {
+ Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, SvrConstants.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show()
+ enableAndFocusPinEntry()
+ return
+ }
+
+ SignalStore.pin.keyboardType = getPinEntryKeyboardType()
+
+ binding.kbsLockPinConfirm.setSpinning()
+
+ viewModel.verifyCodeAndRegisterAccountWithRegistrationLock(requireContext(), pin)
+ }
+
+ private fun handleSessionErrorResponse(requestResult: VerificationCodeRequestResult) {
+ when (requestResult) {
+ is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
+ is VerificationCodeRequestResult.RateLimited -> onRateLimited()
+ is VerificationCodeRequestResult.AttemptsExhausted -> {
+ findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
+ }
+
+ is VerificationCodeRequestResult.RegistrationLocked -> {
+ Log.i(TAG, "Registration locked response to verify account!")
+ binding.kbsLockPinConfirm.cancelSpinning()
+ enableAndFocusPinEntry()
+ Toast.makeText(requireContext(), "Reg lock!", Toast.LENGTH_LONG).show()
+ }
+
+ else -> {
+ Log.w(TAG, "Unable to verify code with registration lock", requestResult.getCause())
+ onError()
+ }
+ }
+ }
+
+ private fun handleRegistrationErrorResponse(result: RegisterAccountResult) {
+ when (result) {
+ is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
+ is RegisterAccountResult.RateLimited -> onRateLimited()
+ is RegisterAccountResult.AttemptsExhausted -> {
+ findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
+ }
+
+ is RegisterAccountResult.RegistrationLocked -> {
+ Log.i(TAG, "Registration locked response to register account!")
+ binding.kbsLockPinConfirm.cancelSpinning()
+ enableAndFocusPinEntry()
+ Toast.makeText(requireContext(), "Reg lock!", Toast.LENGTH_LONG).show()
+ }
+
+ is RegisterAccountResult.SvrWrongPin -> onIncorrectKbsRegistrationLockPin(result.triesRemaining)
+ is RegisterAccountResult.SvrNoData -> {
+ findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
+ }
+
+ else -> {
+ Log.w(TAG, "Unable to register account with registration lock", result.getCause())
+ onError()
+ }
+ }
+ }
+
+ private fun onIncorrectKbsRegistrationLockPin(svrTriesRemaining: Int) {
+ binding.kbsLockPinConfirm.cancelSpinning()
+ binding.kbsLockPinInput.getText().clear()
+ enableAndFocusPinEntry()
+
+ if (svrTriesRemaining == 0) {
+ Log.w(TAG, "Account locked. User out of attempts on KBS.")
+ findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
+ return
+ }
+
+ if (svrTriesRemaining == 3) {
+ val daysRemaining = getLockoutDays(timeRemaining)
+
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.RegistrationLockFragment__incorrect_pin)
+ .setMessage(getTriesRemainingDialogMessage(svrTriesRemaining, daysRemaining))
+ .setPositiveButton(android.R.string.ok, null)
+ .show()
+ }
+
+ if (svrTriesRemaining > 5) {
+ binding.kbsLockPinInputLabel.setText(R.string.RegistrationLockFragment__incorrect_pin_try_again)
+ } else {
+ binding.kbsLockPinInputLabel.text = requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, svrTriesRemaining, svrTriesRemaining)
+ binding.kbsLockForgotPin.visibility = View.VISIBLE
+ }
+ }
+
+ private fun onRateLimited() {
+ binding.kbsLockPinConfirm.cancelSpinning()
+ enableAndFocusPinEntry()
+
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.RegistrationActivity_too_many_attempts)
+ .setMessage(R.string.RegistrationActivity_you_have_made_too_many_incorrect_registration_lock_pin_attempts_please_try_again_in_a_day)
+ .setPositiveButton(android.R.string.ok, null)
+ .show()
+ }
+
+ fun onError() {
+ binding.kbsLockPinConfirm.cancelSpinning()
+ enableAndFocusPinEntry()
+
+ Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show()
+ }
+
+ private fun handleForgottenPin(timeRemainingMs: Long) {
+ val lockoutDays = getLockoutDays(timeRemainingMs)
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.RegistrationLockFragment__forgot_your_pin)
+ .setMessage(requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__for_your_privacy_and_security_there_is_no_way_to_recover, lockoutDays, lockoutDays))
+ .setPositiveButton(android.R.string.ok, null)
+ .setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ -> sendEmailToSupport() }
+ .show()
+ }
+
+ private fun getLockoutDays(timeRemainingMs: Long): Int {
+ return TimeUnit.MILLISECONDS.toDays(timeRemainingMs).toInt() + 1
+ }
+
+ private fun getTriesRemainingDialogMessage(triesRemaining: Int, daysRemaining: Int): String {
+ val resources = requireContext().resources
+ val tries = resources.getQuantityString(R.plurals.RegistrationLockFragment__you_have_d_attempts_remaining, triesRemaining, triesRemaining)
+ val days = resources.getQuantityString(R.plurals.RegistrationLockFragment__if_you_run_out_of_attempts_your_account_will_be_locked_for_d_days, daysRemaining, daysRemaining)
+
+ return "$tries $days"
+ }
+
+ private fun enableAndFocusPinEntry() {
+ binding.kbsLockPinInput.setEnabled(true)
+ binding.kbsLockPinInput.setFocusable(true)
+ ViewUtil.focusAndShowKeyboard(binding.kbsLockPinInput)
+ }
+
+ private fun getPinEntryKeyboardType(): PinKeyboardType {
+ val isNumeric = (binding.kbsLockPinInput.inputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_NUMBER
+
+ return if (isNumeric) PinKeyboardType.NUMERIC else PinKeyboardType.ALPHA_NUMERIC
+ }
+
+ private fun updateKeyboard(keyboard: PinKeyboardType) {
+ val isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC
+
+ binding.kbsLockPinInput.setInputType(
+ if (isAlphaNumeric) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
+ else InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
+ )
+
+ binding.kbsLockPinInput.getText().clear()
+ }
+
+ private fun sendEmailToSupport() {
+ val subject = R.string.RegistrationLockFragment__signal_registration_need_help_with_pin_for_android_v2_pin
+
+ val body = SupportEmailUtil.generateSupportEmailBody(
+ requireContext(),
+ subject,
+ null,
+ null
+ )
+ CommunicationActions.openEmail(
+ requireContext(),
+ SupportEmailUtil.getSupportEmailAddress(requireContext()),
+ getString(subject),
+ body
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinFragment.kt
new file mode 100644
index 0000000000..11290207ca
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinFragment.kt
@@ -0,0 +1,287 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.reregisterwithpin
+
+import android.os.Bundle
+import android.text.InputType
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.widget.Toast
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.LoggingFragment
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.ViewBinderDelegate
+import org.thoughtcrime.securesms.databinding.FragmentRegistrationPinRestoreEntryV2Binding
+import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
+import org.thoughtcrime.securesms.lock.v2.SvrConstants
+import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
+import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
+import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
+import org.thoughtcrime.securesms.util.CommunicationActions
+import org.thoughtcrime.securesms.util.SupportEmailUtil
+import org.thoughtcrime.securesms.util.ViewUtil
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+
+class ReRegisterWithPinFragment : LoggingFragment(R.layout.fragment_registration_pin_restore_entry_v2) {
+ companion object {
+ private val TAG = Log.tag(ReRegisterWithPinFragment::class.java)
+ }
+
+ private val registrationViewModel by activityViewModels()
+ private val reRegisterViewModel by viewModels()
+
+ private val binding: FragmentRegistrationPinRestoreEntryV2Binding by ViewBinderDelegate(FragmentRegistrationPinRestoreEntryV2Binding::bind)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ RegistrationViewDelegate.setDebugLogSubmitMultiTapView(binding.pinRestorePinTitle)
+ binding.pinRestorePinDescription.setText(R.string.RegistrationLockFragment__enter_the_pin_you_created_for_your_account)
+
+ binding.pinRestoreForgotPin.visibility = View.GONE
+ binding.pinRestoreForgotPin.setOnClickListener { onNeedHelpClicked() }
+
+ binding.pinRestoreSkipButton.setOnClickListener { onSkipClicked() }
+
+ binding.pinRestorePinInput.imeOptions = EditorInfo.IME_ACTION_DONE
+ binding.pinRestorePinInput.setOnEditorActionListener { v, actionId, _ ->
+ if (actionId == EditorInfo.IME_ACTION_DONE) {
+ ViewUtil.hideKeyboard(requireContext(), v!!)
+ handlePinEntry()
+ return@setOnEditorActionListener true
+ }
+ false
+ }
+
+ enableAndFocusPinEntry()
+
+ binding.pinRestorePinContinue.setOnClickListener {
+ handlePinEntry()
+ }
+
+ binding.pinRestoreKeyboardToggle.setOnClickListener {
+ val currentKeyboardType: PinKeyboardType = getPinEntryKeyboardType()
+ updateKeyboard(currentKeyboardType.other)
+ binding.pinRestoreKeyboardToggle.setIconResource(currentKeyboardType.iconResource)
+ }
+
+ binding.pinRestoreKeyboardToggle.setIconResource(getPinEntryKeyboardType().other.iconResource)
+
+ registrationViewModel.uiState.observe(viewLifecycleOwner, ::updateViewState)
+ }
+
+ private fun updateViewState(state: RegistrationState) {
+ if (state.networkError != null) {
+ genericErrorDialog()
+ registrationViewModel.networkErrorShown()
+ } else if (!state.canSkipSms) {
+ findNavController().safeNavigate(ReRegisterWithPinFragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberFragment(EnterPhoneNumberMode.NORMAL))
+ } else if (state.isRegistrationLockEnabled && state.svrTriesRemaining == 0) {
+ Log.w(TAG, "Unable to continue skip flow, KBS is locked")
+ onAccountLocked()
+ } else {
+ presentProgress(state.inProgress)
+ presentTriesRemaining(state.svrTriesRemaining)
+ }
+
+ state.registerAccountError?.let { error ->
+ registrationErrorHandler(error)
+ registrationViewModel.registerAccountErrorShown()
+ }
+ }
+
+ private fun presentProgress(inProgress: Boolean) {
+ if (inProgress) {
+ ViewUtil.hideKeyboard(requireContext(), binding.pinRestorePinInput)
+ binding.pinRestorePinInput.isEnabled = false
+ binding.pinRestorePinContinue.setSpinning()
+ } else {
+ binding.pinRestorePinInput.isEnabled = true
+ binding.pinRestorePinContinue.cancelSpinning()
+ }
+ }
+
+ private fun handlePinEntry() {
+ val pin: String? = binding.pinRestorePinInput.text?.toString()
+
+ if (pin.isNullOrBlank()) {
+ Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show()
+ enableAndFocusPinEntry()
+ return
+ }
+
+ if (pin.trim().length < SvrConstants.MINIMUM_PIN_LENGTH) {
+ Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, SvrConstants.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show()
+ enableAndFocusPinEntry()
+ return
+ }
+
+ registrationViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PIN_CONFIRMED)
+
+ registrationViewModel.verifyReRegisterWithPin(
+ context = requireContext(),
+ pin = pin,
+ wrongPinHandler = {
+ reRegisterViewModel.markIncorrectGuess()
+ }
+ )
+ }
+
+ private fun presentTriesRemaining(triesRemaining: Int) {
+ if (reRegisterViewModel.hasIncorrectGuess) {
+ if (triesRemaining == 1 && !reRegisterViewModel.isLocalVerification) {
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.PinRestoreEntryFragment_incorrect_pin)
+ .setMessage(resources.getQuantityString(R.plurals.PinRestoreEntryFragment_you_have_d_attempt_remaining, triesRemaining, triesRemaining))
+ .setPositiveButton(android.R.string.ok, null)
+ .show()
+ }
+
+ if (triesRemaining > 5) {
+ binding.pinRestorePinInputLabel.setText(R.string.PinRestoreEntryFragment_incorrect_pin)
+ } else {
+ binding.pinRestorePinInputLabel.text = resources.getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, triesRemaining, triesRemaining)
+ }
+ binding.pinRestoreForgotPin.visibility = View.VISIBLE
+ } else {
+ if (triesRemaining == 1) {
+ binding.pinRestoreForgotPin.visibility = View.VISIBLE
+ if (!reRegisterViewModel.isLocalVerification) {
+ MaterialAlertDialogBuilder(requireContext())
+ .setMessage(resources.getQuantityString(R.plurals.PinRestoreEntryFragment_you_have_d_attempt_remaining, triesRemaining, triesRemaining))
+ .setPositiveButton(android.R.string.ok, null)
+ .show()
+ }
+ }
+ }
+
+ if (triesRemaining == 0) {
+ Log.w(TAG, "Account locked. User out of attempts on KBS.")
+ onAccountLocked()
+ }
+ }
+
+ private fun onAccountLocked() {
+ Log.d(TAG, "Showing Incorrect PIN dialog. Is local verification: ${reRegisterViewModel.isLocalVerification}")
+ val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_out_of_guesses_local else R.string.PinRestoreLockedFragment_youve_run_out_of_pin_guesses
+
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.PinRestoreEntryFragment_incorrect_pin)
+ .setMessage(message)
+ .setCancelable(false)
+ .setPositiveButton(R.string.ReRegisterWithPinFragment_send_sms_code) { _, _ -> onSkipPinEntry() }
+ .setNegativeButton(R.string.AccountLockedFragment__learn_more) { _, _ -> CommunicationActions.openBrowserLink(requireContext(), getString(R.string.PinRestoreLockedFragment_learn_more_url)) }
+ .show()
+ }
+
+ private fun enableAndFocusPinEntry() {
+ binding.pinRestorePinInput.isEnabled = true
+ binding.pinRestorePinInput.isFocusable = true
+ ViewUtil.focusAndShowKeyboard(binding.pinRestorePinInput)
+ }
+
+ private fun getPinEntryKeyboardType(): PinKeyboardType {
+ val isNumeric = binding.pinRestorePinInput.inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_CLASS_NUMBER
+ return if (isNumeric) PinKeyboardType.NUMERIC else PinKeyboardType.ALPHA_NUMERIC
+ }
+
+ private fun updateKeyboard(keyboard: PinKeyboardType) {
+ val isAlphaNumeric = keyboard == PinKeyboardType.ALPHA_NUMERIC
+ binding.pinRestorePinInput.inputType = if (isAlphaNumeric) InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD else InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
+ binding.pinRestorePinInput.text?.clear()
+ }
+
+ private fun onNeedHelpClicked() {
+ Log.i(TAG, "User clicked need help dialog.")
+ val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_need_help_local else R.string.PinRestoreEntryFragment_your_pin_is_a_d_digit_code
+
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.PinRestoreEntryFragment_need_help)
+ .setMessage(getString(message, SvrConstants.MINIMUM_PIN_LENGTH))
+ .setPositiveButton(R.string.PinRestoreEntryFragment_skip) { _, _ -> onSkipPinEntry() }
+ .setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ ->
+ val body = SupportEmailUtil.generateSupportEmailBody(requireContext(), R.string.ReRegisterWithPinFragment_support_email_subject, null, null)
+
+ CommunicationActions.openEmail(
+ requireContext(),
+ SupportEmailUtil.getSupportEmailAddress(requireContext()),
+ getString(R.string.ReRegisterWithPinFragment_support_email_subject),
+ body
+ )
+ }
+ .setNegativeButton(R.string.PinRestoreEntryFragment_cancel, null)
+ .show()
+ }
+
+ private fun onSkipClicked() {
+ Log.i(TAG, "User clicked the skip PIN button.")
+ val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_skip_local else R.string.PinRestoreEntryFragment_if_you_cant_remember_your_pin
+
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.PinRestoreEntryFragment_skip_pin_entry)
+ .setMessage(message)
+ .setPositiveButton(R.string.PinRestoreEntryFragment_skip) { _, _ -> onSkipPinEntry() }
+ .setNegativeButton(R.string.PinRestoreEntryFragment_cancel, null)
+ .show()
+ }
+
+ private fun onSkipPinEntry() {
+ Log.d(TAG, "User skipping PIN entry.")
+ registrationViewModel.setUserSkippedReRegisterFlow(true)
+ }
+
+ private fun presentRateLimitedDialog() {
+ MaterialAlertDialogBuilder(requireContext()).apply {
+ setTitle(R.string.RegistrationActivity_too_many_attempts)
+ setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
+ setPositiveButton(android.R.string.ok, null)
+ show()
+ }
+ }
+
+ private fun genericErrorDialog() {
+ MaterialAlertDialogBuilder(requireContext())
+ .setMessage(R.string.RegistrationActivity_error_connecting_to_service)
+ .setPositiveButton(android.R.string.ok, null)
+ .create()
+ .show()
+ }
+
+ private fun registrationErrorHandler(result: RegisterAccountResult) {
+ when (result) {
+ is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
+ is RegisterAccountResult.AuthorizationFailed,
+ is RegisterAccountResult.MalformedRequest,
+ is RegisterAccountResult.UnknownError,
+ is RegisterAccountResult.ValidationError,
+ is RegisterAccountResult.RegistrationLocked -> {
+ Log.i(TAG, "Registration failed.", result.getCause())
+ genericErrorDialog()
+ }
+
+ is RegisterAccountResult.IncorrectRecoveryPassword -> {
+ registrationViewModel.setUserSkippedReRegisterFlow(true)
+ findNavController().safeNavigate(ReRegisterWithPinFragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberFragment(EnterPhoneNumberMode.NORMAL))
+ }
+
+ is RegisterAccountResult.AttemptsExhausted,
+ is RegisterAccountResult.RateLimited -> presentRateLimitedDialog()
+
+ is RegisterAccountResult.SvrNoData -> onAccountLocked()
+ is RegisterAccountResult.SvrWrongPin -> {
+ reRegisterViewModel.markIncorrectGuess()
+ reRegisterViewModel.markAsRemoteVerification()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinState.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinState.kt
new file mode 100644
index 0000000000..2557fc3273
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinState.kt
@@ -0,0 +1,12 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.reregisterwithpin
+
+data class ReRegisterWithPinState(
+ val isLocalVerification: Boolean = false,
+ val hasIncorrectGuess: Boolean = false,
+ val localPinMatches: Boolean = false
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinViewModel.kt
new file mode 100644
index 0000000000..89bb555a1f
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/reregisterwithpin/ReRegisterWithPinViewModel.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.reregisterwithpin
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import org.signal.core.util.logging.Log
+
+class ReRegisterWithPinViewModel : ViewModel() {
+ companion object {
+ private val TAG = Log.tag(ReRegisterWithPinViewModel::class.java)
+ }
+
+ private val store = MutableStateFlow(ReRegisterWithPinState())
+
+ val isLocalVerification: Boolean
+ get() = store.value.isLocalVerification
+ val hasIncorrectGuess: Boolean
+ get() = store.value.hasIncorrectGuess
+
+ fun markAsRemoteVerification() {
+ store.update {
+ it.copy(isLocalVerification = false)
+ }
+ }
+
+ fun markIncorrectGuess() {
+ store.update {
+ it.copy(hasIncorrectGuess = true)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/BackupKeyVisualTransformation.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/BackupKeyVisualTransformation.kt
new file mode 100644
index 0000000000..4f1f225310
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/BackupKeyVisualTransformation.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.restore
+
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.input.OffsetMapping
+import androidx.compose.ui.text.input.TransformedText
+import androidx.compose.ui.text.input.VisualTransformation
+
+/**
+ * Visual formatter for backup keys.
+ *
+ * @param length max length of key
+ * @param chunkSize character count per group
+ */
+class BackupKeyVisualTransformation(private val length: Int, private val chunkSize: Int) : VisualTransformation {
+ override fun filter(text: AnnotatedString): TransformedText {
+ var output = ""
+ for (i in text.take(length).indices) {
+ output += text[i]
+ if (i % chunkSize == chunkSize - 1) {
+ output += " "
+ }
+ }
+
+ return TransformedText(
+ text = AnnotatedString(output),
+ offsetMapping = BackupKeyVisualTransformation(chunkSize)
+ )
+ }
+
+ private class BackupKeyVisualTransformation(private val chunkSize: Int) : OffsetMapping {
+ override fun originalToTransformed(offset: Int): Int {
+ return offset + (offset / chunkSize)
+ }
+
+ override fun transformedToOriginal(offset: Int): Int {
+ return offset - (offset / chunkSize)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyFragment.kt
new file mode 100644
index 0000000000..b0aa6de96a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyFragment.kt
@@ -0,0 +1,299 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.restore
+
+import android.graphics.Typeface
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import androidx.navigation.fragment.findNavController
+import kotlinx.coroutines.launch
+import org.signal.core.ui.BottomSheets
+import org.signal.core.ui.Buttons
+import org.signal.core.ui.Previews
+import org.signal.core.ui.SignalPreview
+import org.signal.core.ui.horizontalGutters
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
+import org.thoughtcrime.securesms.compose.ComposeFragment
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
+import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
+import org.thoughtcrime.securesms.registrationv3.ui.restore.EnterBackupKeyViewModel.EnterBackupKeyState
+import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
+import org.thoughtcrime.securesms.util.CommunicationActions
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+
+/**
+ * Enter backup key screen for manual Signal Backups restore flow.
+ */
+class EnterBackupKeyFragment : ComposeFragment() {
+
+ companion object {
+ private const val LEARN_MORE_URL = "https://signal.org" // TODO [backups] but really
+ }
+
+ private val sharedViewModel by activityViewModels()
+ private val viewModel by viewModels()
+
+ @Composable
+ override fun FragmentContent() {
+ val state by viewModel.state
+ val sharedState by sharedViewModel.state.collectAsState()
+
+ EnterBackupKeyScreen(
+ state = state,
+ sharedState = sharedState,
+ onBackupKeyChanged = viewModel::updateBackupKey,
+ onNextClicked = {
+ sharedViewModel.registerWithBackupKey(
+ context = requireContext(),
+ backupKey = state.backupKey,
+ e164 = null,
+ pin = null
+ )
+ },
+ onLearnMore = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
+ onSkip = { findNavController().safeNavigate(EnterBackupKeyFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION)) }
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun EnterBackupKeyScreen(
+ state: EnterBackupKeyState,
+ sharedState: RegistrationState,
+ onBackupKeyChanged: (String) -> Unit = {},
+ onNextClicked: () -> Unit = {},
+ onLearnMore: () -> Unit = {},
+ onSkip: () -> Unit = {}
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val sheetState = rememberModalBottomSheetState(
+ skipPartiallyExpanded = true
+ )
+
+ RegistrationScreen(
+ title = stringResource(R.string.EnterBackupKey_title),
+ subtitle = stringResource(R.string.EnterBackupKey_subtitle),
+ bottomContent = {
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ TextButton(
+ enabled = !sharedState.inProgress,
+ onClick = {
+ coroutineScope.launch {
+ sheetState.show()
+ }
+ }
+ ) {
+ Text(
+ text = stringResource(id = R.string.EnterBackupKey_no_backup_key)
+ )
+ }
+
+ Buttons.LargeTonal(
+ enabled = state.backupKeyValid && !sharedState.inProgress,
+ onClick = onNextClicked
+ ) {
+ Text(
+ text = stringResource(id = R.string.RegistrationActivity_next)
+ )
+ }
+ }
+ }
+ ) {
+ val focusRequester = remember { FocusRequester() }
+ val visualTransform = remember(state.length, state.chunkLength) { BackupKeyVisualTransformation(length = state.length, chunkSize = state.chunkLength) }
+
+ TextField(
+ value = state.backupKey,
+ label = {
+ Text(text = stringResource(id = R.string.EnterBackupKey_backup_key))
+ },
+ onValueChange = onBackupKeyChanged,
+ textStyle = LocalTextStyle.current.copy(
+ fontFamily = FontFamily(typeface = Typeface.MONOSPACE),
+ lineHeight = 36.sp
+ ),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ capitalization = KeyboardCapitalization.None,
+ imeAction = ImeAction.Next,
+ autoCorrectEnabled = false
+ ),
+ keyboardActions = KeyboardActions(
+ onNext = { if (state.backupKeyValid) onNextClicked() }
+ ),
+ minLines = 4,
+ visualTransformation = visualTransform,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ )
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+
+ if (sheetState.isVisible) {
+ ModalBottomSheet(
+ dragHandle = null,
+ onDismissRequest = {
+ coroutineScope.launch {
+ sheetState.hide()
+ }
+ }
+ ) {
+ NoBackupKeyBottomSheet(
+ onLearnMore = {
+ coroutineScope.launch {
+ sheetState.hide()
+ }
+ onLearnMore()
+ },
+ onSkip = onSkip
+ )
+ }
+ }
+ }
+}
+
+@SignalPreview
+@Composable
+private fun EnterBackupKeyScreenPreview() {
+ Previews.Preview {
+ EnterBackupKeyScreen(
+ state = EnterBackupKeyState(backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t", length = 64, chunkLength = 4),
+ sharedState = RegistrationState(phoneNumber = null, recoveryPassword = null)
+ )
+ }
+}
+
+@Composable
+private fun NoBackupKeyBottomSheet(
+ onLearnMore: () -> Unit = {},
+ onSkip: () -> Unit = {}
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalGutters()
+ ) {
+ BottomSheets.Handle()
+
+ Icon(
+ painter = painterResource(id = R.drawable.symbol_key_24),
+ tint = BackupsIconColors.Success.foreground,
+ contentDescription = null,
+ modifier = Modifier
+ .padding(top = 18.dp, bottom = 16.dp)
+ .size(88.dp)
+ .background(
+ color = BackupsIconColors.Success.background,
+ shape = CircleShape
+ )
+ .padding(20.dp)
+ )
+
+ Text(
+ text = stringResource(R.string.EnterBackupKey_no_backup_key),
+ style = MaterialTheme.typography.titleLarge
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Text(
+ text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
+ style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
+ style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(36.dp))
+
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 24.dp)
+ ) {
+ TextButton(
+ onClick = onLearnMore
+ ) {
+ Text(
+ text = stringResource(id = R.string.EnterBackupKey_learn_more)
+ )
+ }
+
+ TextButton(
+ onClick = onSkip
+ ) {
+ Text(
+ text = stringResource(id = R.string.EnterBackupKey_skip_and_dont_restore)
+ )
+ }
+ }
+ }
+}
+
+@SignalPreview
+@Composable
+private fun NoBackupKeyBottomSheetPreview() {
+ Previews.BottomSheetPreview {
+ NoBackupKeyBottomSheet()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyViewModel.kt
new file mode 100644
index 0000000000..155d0001c9
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/EnterBackupKeyViewModel.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.restore
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.lifecycle.ViewModel
+import org.signal.core.util.Hex
+import java.io.IOException
+
+class EnterBackupKeyViewModel : ViewModel() {
+
+ companion object {
+ // TODO [backups] Set actual valid characters for key input
+ private val VALID_CHARACTERS = setOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
+ }
+
+ private val _state = mutableStateOf(
+ EnterBackupKeyState(
+ backupKey = "",
+ length = 64,
+ chunkLength = 4
+ )
+ )
+
+ val state: State = _state
+
+ fun updateBackupKey(key: String) {
+ _state.update {
+ val newKey = key.removeIllegalCharacters().take(length)
+ copy(backupKey = newKey, backupKeyValid = validate(length, newKey))
+ }
+ }
+
+ private fun validate(length: Int, backupKey: String): Boolean {
+ if (backupKey.length != length) {
+ return false
+ }
+
+ try {
+ // TODO [backups] Actually validate key with requirements instead of just hex
+ Hex.fromStringCondensed(backupKey)
+ } catch (e: IOException) {
+ return false
+ }
+
+ return true
+ }
+
+ private fun String.removeIllegalCharacters(): String {
+ return filter { VALID_CHARACTERS.contains(it) }
+ }
+
+ private inline fun MutableState.update(update: T.() -> T) {
+ this.value = this.value.update()
+ }
+
+ data class EnterBackupKeyState(
+ val backupKey: String = "",
+ val backupKeyValid: Boolean = false,
+ val length: Int,
+ val chunkLength: Int
+ )
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreActivity.kt
new file mode 100644
index 0000000000..ccbc11376a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreActivity.kt
@@ -0,0 +1,382 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.restore
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.lifecycleScope
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import org.greenrobot.eventbus.EventBus
+import org.greenrobot.eventbus.Subscribe
+import org.greenrobot.eventbus.ThreadMode
+import org.signal.core.ui.Buttons
+import org.signal.core.ui.Dialogs
+import org.signal.core.ui.Previews
+import org.signal.core.ui.SignalPreview
+import org.signal.core.ui.theme.SignalTheme
+import org.signal.core.util.bytes
+import org.thoughtcrime.securesms.BaseActivity
+import org.thoughtcrime.securesms.MainActivity
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
+import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
+import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature
+import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow
+import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
+import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
+import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
+import org.thoughtcrime.securesms.util.DateUtils
+import java.util.Locale
+
+/**
+ * Restore backup from remote source.
+ */
+class RemoteRestoreActivity : BaseActivity() {
+ companion object {
+ fun getIntent(context: Context): Intent {
+ return Intent(context, RemoteRestoreActivity::class.java)
+ }
+ }
+
+ private val viewModel: RemoteRestoreViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ lifecycleScope.launch {
+ val restored = viewModel
+ .state
+ .map { it.importState }
+ .filterIsInstance()
+ .firstOrNull()
+
+ if (restored != null) {
+ continueRegistration(restored.missingProfileData)
+ }
+ }
+
+ setContent {
+ val state: RemoteRestoreViewModel.ScreenState by viewModel.state.collectAsStateWithLifecycle()
+
+ SignalTheme {
+ Surface {
+ RestoreFromBackupContent(
+ state = state,
+ onRestoreBackupClick = { viewModel.restore() },
+ onCancelClick = { finish() },
+ onErrorDialogDismiss = { viewModel.clearError() }
+ )
+ }
+ }
+ }
+
+ EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
+ }
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ fun onEvent(restoreEvent: RestoreV2Event) {
+ viewModel.updateRestoreProgress(restoreEvent)
+ }
+
+ private fun continueRegistration(missingProfileData: Boolean) {
+ val main = MainActivity.clearTop(this)
+
+ if (missingProfileData) {
+ val profile = CreateProfileActivity.getIntentForUserProfile(this)
+ profile.putExtra("next_intent", main)
+ startActivity(profile)
+ } else {
+ startActivity(main)
+ }
+
+ finish()
+ }
+}
+
+@Composable
+private fun RestoreFromBackupContent(
+ state: RemoteRestoreViewModel.ScreenState,
+ onRestoreBackupClick: () -> Unit = {},
+ onCancelClick: () -> Unit = {},
+ onErrorDialogDismiss: () -> Unit = {}
+) {
+ val subtitle = buildAnnotatedString {
+ append(
+ stringResource(
+ id = R.string.RemoteRestoreActivity__backup_created_at,
+ DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), state.backupTime),
+ DateUtils.getOnlyTimeString(LocalContext.current, state.backupTime)
+ )
+ )
+ append(" ")
+ if (state.backupTier != MessageBackupTier.PAID) {
+ withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
+ append(stringResource(id = R.string.RemoteRestoreActivity__only_media_sent_or_received))
+ }
+ }
+ }
+
+ RegistrationScreen(
+ title = stringResource(id = R.string.RemoteRestoreActivity__restore_from_backup),
+ subtitle = if (state.isLoaded()) subtitle else null,
+ bottomContent = {
+ Column {
+ if (state.isLoaded()) {
+ Buttons.LargeTonal(
+ onClick = onRestoreBackupClick,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(id = R.string.RemoteRestoreActivity__restore_backup))
+ }
+ }
+
+ TextButton(
+ onClick = onCancelClick,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(text = stringResource(id = android.R.string.cancel))
+ }
+ }
+ }
+ ) {
+ when (state.loadState) {
+ RemoteRestoreViewModel.ScreenState.LoadState.LOADING -> {
+ Dialogs.IndeterminateProgressDialog(
+ message = stringResource(R.string.RemoteRestoreActivity__fetching_backup_details)
+ )
+ }
+
+ RemoteRestoreViewModel.ScreenState.LoadState.LOADED -> {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp))
+ .padding(horizontal = 20.dp)
+ .padding(top = 20.dp, bottom = 18.dp)
+ ) {
+ Text(
+ text = stringResource(id = R.string.RemoteRestoreActivity__your_backup_includes),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(bottom = 6.dp)
+ )
+
+ getFeatures(state.backupTier).forEach {
+ MessageBackupsTypeFeatureRow(
+ messageBackupsTypeFeature = it,
+ iconTint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(start = 16.dp, top = 6.dp)
+ )
+ }
+ }
+ }
+
+ RemoteRestoreViewModel.ScreenState.LoadState.FAILURE -> {
+ RestoreFailedDialog(onDismiss = onCancelClick)
+ }
+ }
+
+ when (state.importState) {
+ RemoteRestoreViewModel.ImportState.None -> Unit
+ RemoteRestoreViewModel.ImportState.InProgress -> RestoreProgressDialog(state.restoreProgress)
+ is RemoteRestoreViewModel.ImportState.Restored -> Unit
+ RemoteRestoreViewModel.ImportState.Failed -> RestoreFailedDialog(onDismiss = onErrorDialogDismiss)
+ }
+ }
+}
+
+@SignalPreview
+@Composable
+private fun RestoreFromBackupContentPreview() {
+ Previews.Preview {
+ RestoreFromBackupContent(
+ state = RemoteRestoreViewModel.ScreenState(
+ backupTier = MessageBackupTier.PAID,
+ backupTime = System.currentTimeMillis(),
+ importState = RemoteRestoreViewModel.ImportState.None,
+ restoreProgress = null
+ )
+ )
+ }
+}
+
+@SignalPreview
+@Composable
+private fun RestoreFromBackupContentLoadingPreview() {
+ Previews.Preview {
+ RestoreFromBackupContent(
+ state = RemoteRestoreViewModel.ScreenState(
+ importState = RemoteRestoreViewModel.ImportState.None,
+ restoreProgress = null
+ )
+ )
+ }
+}
+
+@Composable
+private fun getFeatures(tier: MessageBackupTier?): ImmutableList {
+ return when (tier) {
+ null -> persistentListOf()
+ MessageBackupTier.PAID -> {
+ persistentListOf(
+ MessageBackupsTypeFeature(
+ iconResourceId = R.drawable.symbol_thread_compact_bold_16,
+ label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_media)
+ ),
+ MessageBackupsTypeFeature(
+ iconResourceId = R.drawable.symbol_recent_compact_bold_16,
+ label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages)
+ )
+ )
+ }
+
+ MessageBackupTier.FREE -> {
+ persistentListOf(
+ MessageBackupsTypeFeature(
+ iconResourceId = R.drawable.symbol_thread_compact_bold_16,
+ label = stringResource(id = R.string.RemoteRestoreActivity__your_last_d_days_of_media, 30)
+ ),
+ MessageBackupsTypeFeature(
+ iconResourceId = R.drawable.symbol_recent_compact_bold_16,
+ label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages)
+ )
+ )
+ }
+ }
+}
+
+/**
+ * A dialog that *just* shows a spinner. Useful for short actions where you need to
+ * let the user know that some action is completing.
+ */
+@Composable
+private fun RestoreProgressDialog(restoreProgress: RestoreV2Event?) {
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = {},
+ confirmButton = {},
+ dismissButton = {},
+ text = {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.wrapContentSize()
+ ) {
+ if (restoreProgress == null) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .padding(top = 55.dp, bottom = 16.dp)
+ .width(48.dp)
+ .height(48.dp)
+ )
+ } else {
+ CircularProgressIndicator(
+ progress = { restoreProgress.getProgress() },
+ modifier = Modifier
+ .padding(top = 55.dp, bottom = 16.dp)
+ .width(48.dp)
+ .height(48.dp)
+ )
+ }
+
+ val progressText = when (restoreProgress?.type) {
+ RestoreV2Event.Type.PROGRESS_DOWNLOAD -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup)
+ RestoreV2Event.Type.PROGRESS_RESTORE -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup)
+ else -> stringResource(id = R.string.RemoteRestoreActivity__restoring)
+ }
+
+ Text(
+ text = progressText,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(bottom = 12.dp)
+ )
+
+ if (restoreProgress != null) {
+ val progressBytes = restoreProgress.count.toUnitString(maxPlaces = 2)
+ val totalBytes = restoreProgress.estimatedTotalCount.toUnitString(maxPlaces = 2)
+ Text(
+ text = stringResource(id = R.string.RemoteRestoreActivity__s_of_s_s, progressBytes, totalBytes, "%.2f%%".format(restoreProgress.getProgress())),
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(bottom = 12.dp)
+ )
+ }
+ }
+ }
+ },
+ modifier = Modifier.width(212.dp)
+ )
+}
+
+@SignalPreview
+@Composable
+private fun ProgressDialogPreview() {
+ Previews.Preview {
+ RestoreProgressDialog(
+ RestoreV2Event(
+ type = RestoreV2Event.Type.PROGRESS_RESTORE,
+ count = 1234.bytes,
+ estimatedTotalCount = 10240.bytes
+ )
+ )
+ }
+}
+
+@Composable
+fun RestoreFailedDialog(
+ onDismiss: () -> Unit = {}
+) {
+ Dialogs.SimpleAlertDialog(
+ title = "Restore Failed", // TODO [backups] Remote restore error placeholder copy
+ body = "Unable to restore from backup. Please try again.", // TODO [backups] Placeholder copy
+ confirm = stringResource(android.R.string.ok),
+ onConfirm = onDismiss,
+ onDismiss = onDismiss
+ )
+}
+
+@SignalPreview
+@Composable
+private fun RestoreFailedDialogPreview() {
+ Previews.Preview {
+ RestoreFailedDialog()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt
new file mode 100644
index 0000000000..3271834407
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RemoteRestoreViewModel.kt
@@ -0,0 +1,159 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.restore
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.backup.v2.BackupRepository
+import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
+import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
+import org.thoughtcrime.securesms.dependencies.AppDependencies
+import org.thoughtcrime.securesms.jobmanager.JobTracker
+import org.thoughtcrime.securesms.jobs.BackupRestoreJob
+import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
+import org.thoughtcrime.securesms.jobs.ProfileUploadJob
+import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.registration.util.RegistrationUtil
+import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
+
+class RemoteRestoreViewModel : ViewModel() {
+
+ companion object {
+ private val TAG = Log.tag(RemoteRestoreViewModel::class)
+ }
+
+ private val store: MutableStateFlow = MutableStateFlow(
+ ScreenState(
+ backupTier = SignalStore.backup.backupTier,
+ backupTime = SignalStore.backup.lastBackupTime
+ )
+ )
+
+ val state: StateFlow = store.asStateFlow()
+
+ init {
+ viewModelScope.launch(Dispatchers.IO) {
+ val restored = BackupRepository.restoreBackupTier(SignalStore.account.requireAci()) != null
+ store.update {
+ if (restored) {
+ it.copy(
+ loadState = ScreenState.LoadState.LOADED,
+ backupTier = SignalStore.backup.backupTier,
+ backupTime = SignalStore.backup.lastBackupTime
+ )
+ } else {
+ it.copy(
+ loadState = ScreenState.LoadState.FAILURE
+ )
+ }
+ }
+ }
+ }
+
+ fun restore() {
+ viewModelScope.launch {
+ store.update { it.copy(importState = ImportState.InProgress) }
+
+ withContext(Dispatchers.IO) {
+ val jobStateFlow = callbackFlow {
+ val listener = JobTracker.JobListener { _, jobState ->
+ trySend(jobState)
+ }
+
+ AppDependencies
+ .jobManager
+ .startChain(BackupRestoreJob())
+ .then(SyncArchivedMediaJob())
+ .then(BackupRestoreMediaJob())
+ .enqueue(listener)
+
+ awaitClose {
+ AppDependencies.jobManager.removeListener(listener)
+ }
+ }
+
+ jobStateFlow.collect { state ->
+ when (state) {
+ JobTracker.JobState.SUCCESS -> {
+ Log.i(TAG, "Restore successful")
+ SignalStore.registration.markRestoreCompleted()
+
+ if (!RegistrationRepository.isMissingProfileData()) {
+ RegistrationUtil.maybeMarkRegistrationComplete()
+ AppDependencies.jobManager.add(ProfileUploadJob())
+ }
+
+ store.update { it.copy(importState = ImportState.Restored(RegistrationRepository.isMissingProfileData())) }
+ }
+
+ JobTracker.JobState.PENDING,
+ JobTracker.JobState.RUNNING -> {
+ Log.i(TAG, "Restore job states updated: $state")
+ }
+
+ JobTracker.JobState.FAILURE,
+ JobTracker.JobState.IGNORED -> {
+ Log.w(TAG, "Restore failed with $state")
+
+ store.update { it.copy(importState = ImportState.Failed) }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ fun updateRestoreProgress(restoreEvent: RestoreV2Event) {
+ store.update { it.copy(restoreProgress = restoreEvent) }
+ }
+
+ fun cancel() {
+ SignalStore.registration.markSkippedTransferOrRestore()
+ }
+
+ fun clearError() {
+ store.update { it.copy(importState = ImportState.None, restoreProgress = null) }
+ }
+
+ data class ScreenState(
+ val backupTier: MessageBackupTier? = null,
+ val backupTime: Long = -1,
+ val importState: ImportState = ImportState.None,
+ val restoreProgress: RestoreV2Event? = null,
+ val loadState: LoadState = if (backupTier != null) LoadState.LOADED else LoadState.LOADING
+ ) {
+
+ fun isLoaded(): Boolean {
+ return loadState == LoadState.LOADED
+ }
+
+ fun isLoading(): Boolean {
+ return loadState == LoadState.LOADING
+ }
+
+ enum class LoadState {
+ LOADING, LOADED, FAILURE
+ }
+ }
+
+ sealed interface ImportState {
+ data object None : ImportState
+ data object InProgress : ImportState
+ data class Restored(val missingProfileData: Boolean) : ImportState
+ data object Failed : ImportState
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreMethod.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreMethod.kt
new file mode 100644
index 0000000000..787ff8c4ab
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreMethod.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.restore
+
+import org.thoughtcrime.securesms.R
+
+/**
+ * Restore methods for various spots in restore flow.
+ */
+enum class RestoreMethod(val iconRes: Int, val titleRes: Int, val subtitleRes: Int) {
+ FROM_SIGNAL_BACKUPS(
+ iconRes = R.drawable.symbol_signal_backups_24,
+ titleRes = R.string.SelectRestoreMethodFragment__from_signal_backups,
+ subtitleRes = R.string.SelectRestoreMethodFragment__your_free_or_paid_signal_backup_plan
+ ),
+ FROM_LOCAL_BACKUP_V1(
+ iconRes = R.drawable.symbol_file_24,
+ titleRes = R.string.SelectRestoreMethodFragment__from_a_backup_file,
+ subtitleRes = R.string.SelectRestoreMethodFragment__choose_a_backup_youve_saved
+ ),
+ FROM_LOCAL_BACKUP_V2(
+ iconRes = R.drawable.symbol_folder_24,
+ titleRes = R.string.SelectRestoreMethodFragment__from_a_backup_folder,
+ subtitleRes = R.string.SelectRestoreMethodFragment__choose_a_backup_youve_saved
+ ),
+ FROM_OLD_DEVICE(
+ iconRes = R.drawable.symbol_transfer_24,
+ titleRes = R.string.SelectRestoreMethodFragment__from_your_old_phone,
+ subtitleRes = R.string.SelectRestoreMethodFragment__transfer_directly_from_old
+ )
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreRow.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreRow.kt
new file mode 100644
index 0000000000..3c6e982df9
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreRow.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.restore
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import org.signal.core.ui.Previews
+import org.signal.core.ui.SignalPreview
+import org.signal.core.ui.theme.SignalTheme
+import org.thoughtcrime.securesms.R
+
+/**
+ * Renders row-ux used commonly through the restore flows.
+ */
+@Composable
+fun RestoreRow(
+ icon: Painter,
+ title: String,
+ subtitle: String,
+ onRowClick: () -> Unit = {}
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .padding(bottom = 16.dp)
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(18.dp))
+ .background(SignalTheme.colors.colorSurface2)
+ .clickable(enabled = true, onClick = onRowClick)
+ .padding(horizontal = 20.dp, vertical = 22.dp)
+ ) {
+ Icon(
+ painter = icon,
+ tint = MaterialTheme.colorScheme.primary,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp)
+ )
+
+ Column(
+ modifier = Modifier.padding(start = 16.dp)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge
+ )
+
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+@SignalPreview
+@Composable
+private fun RestoreMethodRowPreview() {
+ Previews.Preview {
+ RestoreRow(
+ icon = painterResource(R.drawable.symbol_backup_24),
+ title = stringResource(R.string.SelectRestoreMethodFragment__from_signal_backups),
+ subtitle = stringResource(R.string.SelectRestoreMethodFragment__your_free_or_paid_signal_backup_plan)
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrFragment.kt
new file mode 100644
index 0000000000..59b64bcf00
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrFragment.kt
@@ -0,0 +1,350 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.restore
+
+import android.os.Bundle
+import android.view.View
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import androidx.navigation.fragment.findNavController
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.launch
+import org.signal.core.ui.Buttons
+import org.signal.core.ui.Dialogs
+import org.signal.core.ui.Previews
+import org.signal.core.ui.SignalPreview
+import org.signal.core.ui.horizontalGutters
+import org.signal.core.ui.theme.SignalTheme
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCode
+import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
+import org.thoughtcrime.securesms.compose.ComposeFragment
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
+import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
+
+/**
+ * Show QR code on new device to allow registration and restore via old device.
+ */
+class RestoreViaQrFragment : ComposeFragment() {
+
+ private val sharedViewModel by activityViewModels()
+ private val viewModel: RestoreViaQrViewModel by viewModels()
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
+ viewModel
+ .state
+ .mapNotNull { it.provisioningMessage }
+ .distinctUntilChanged()
+ .collect { message ->
+ sharedViewModel.registerWithBackupKey(requireContext(), message.accountEntropyPool, message.e164, message.pin)
+ }
+ }
+ }
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
+ sharedViewModel
+ .state
+ .map { it.registerAccountError }
+ .filterNotNull()
+ .collect {
+ sharedViewModel.registerAccountErrorShown()
+ viewModel.handleRegistrationFailure()
+ }
+ }
+ }
+ }
+
+ @Composable
+ override fun FragmentContent() {
+ val state by viewModel.state.collectAsState()
+
+ RestoreViaQrScreen(
+ state = state,
+ onRetryQrCode = viewModel::restart,
+ onRegistrationErrorDismiss = viewModel::clearRegistrationError,
+ onCancel = { findNavController().popBackStack() }
+ )
+ }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun RestoreViaQrScreen(
+ state: RestoreViaQrViewModel.RestoreViaQrState,
+ onRetryQrCode: () -> Unit = {},
+ onRegistrationErrorDismiss: () -> Unit = {},
+ onCancel: () -> Unit = {}
+) {
+ RegistrationScreen(
+ title = stringResource(R.string.RestoreViaQr_title),
+ subtitle = null,
+ bottomContent = {
+ TextButton(
+ onClick = onCancel,
+ modifier = Modifier.align(Alignment.Center)
+ ) {
+ Text(text = stringResource(android.R.string.cancel))
+ }
+ }
+ ) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(space = 48.dp, alignment = Alignment.CenterHorizontally),
+ verticalArrangement = Arrangement.spacedBy(space = 48.dp),
+ modifier = Modifier
+ .fillMaxWidth()
+ .horizontalGutters()
+ ) {
+ Box(
+ modifier = Modifier
+ .widthIn(160.dp, 320.dp)
+ .aspectRatio(1f)
+ .clip(RoundedCornerShape(24.dp))
+ .background(SignalTheme.colors.colorSurface5)
+ .padding(40.dp)
+ ) {
+ SignalTheme(isDarkMode = false) {
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.surface)
+ .fillMaxWidth()
+ .fillMaxHeight()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ AnimatedContent(
+ targetState = state.qrState,
+ contentAlignment = Alignment.Center,
+ label = "qr-code-progress"
+ ) { qrState ->
+ when (qrState) {
+ is RestoreViaQrViewModel.QrState.Loaded -> {
+ QrCode(
+ data = qrState.qrData,
+ foregroundColor = Color(0xFF2449C0),
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight()
+ )
+ }
+
+ RestoreViaQrViewModel.QrState.Loading -> {
+ CircularProgressIndicator(modifier = Modifier.size(48.dp))
+ }
+
+ is RestoreViaQrViewModel.QrState.Scanned,
+ RestoreViaQrViewModel.QrState.Failed -> {
+ Column(
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ val text = if (state.qrState is RestoreViaQrViewModel.QrState.Scanned) {
+ stringResource(R.string.RestoreViaQr_qr_code_scanned)
+ } else {
+ stringResource(R.string.RestoreViaQr_qr_code_error)
+ }
+
+ Text(
+ text = text,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Buttons.Small(
+ onClick = onRetryQrCode
+ ) {
+ Text(text = stringResource(R.string.RestoreViaQr_retry))
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Column(
+ modifier = Modifier
+ .align(alignment = Alignment.CenterVertically)
+ .widthIn(160.dp, 320.dp)
+ ) {
+ InstructionRow(
+ icon = painterResource(R.drawable.symbol_phone_24),
+ instruction = stringResource(R.string.RestoreViaQr_instruction_1)
+ )
+
+ InstructionRow(
+ icon = painterResource(R.drawable.symbol_camera_24),
+ instruction = stringResource(R.string.RestoreViaQr_instruction_2)
+ )
+
+ InstructionRow(
+ icon = painterResource(R.drawable.symbol_qrcode_24),
+ instruction = stringResource(R.string.RestoreViaQr_instruction_3)
+ )
+ }
+ }
+
+ if (state.isRegistering) {
+ Dialogs.IndeterminateProgressDialog()
+ } else if (state.showRegistrationError) {
+ Dialogs.SimpleMessageDialog(
+ message = stringResource(R.string.RegistrationActivity_error_connecting_to_service),
+ onDismiss = onRegistrationErrorDismiss,
+ dismiss = stringResource(android.R.string.ok)
+ )
+ }
+ }
+}
+
+@SignalPreview
+@Composable
+private fun RestoreViaQrScreenPreview() {
+ Previews.Preview {
+ RestoreViaQrScreen(
+ state = RestoreViaQrViewModel.RestoreViaQrState(
+ qrState = RestoreViaQrViewModel.QrState.Loaded(
+ QrCodeData.forData("sgnl://rereg?uuid=asdfasdfasdfasdfasdfasdf&pub_key=asdfasdfasdfSDFSsdfsdfSDFSDffd", false)
+ )
+ )
+ )
+ }
+}
+
+@SignalPreview
+@Composable
+private fun RestoreViaQrScreenLoadingPreview() {
+ Previews.Preview {
+ RestoreViaQrScreen(
+ state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Loading)
+ )
+ }
+}
+
+@SignalPreview
+@Composable
+private fun RestoreViaQrScreenFailurePreview() {
+ Previews.Preview {
+ RestoreViaQrScreen(
+ state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Failed)
+ )
+ }
+}
+
+@SignalPreview
+@Composable
+private fun RestoreViaQrScreenScannedPreview() {
+ Previews.Preview {
+ RestoreViaQrScreen(
+ state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Scanned)
+ )
+ }
+}
+
+@SignalPreview
+@Composable
+private fun RestoreViaQrScreenRegisteringPreview() {
+ Previews.Preview {
+ RestoreViaQrScreen(
+ state = RestoreViaQrViewModel.RestoreViaQrState(isRegistering = true, qrState = RestoreViaQrViewModel.QrState.Scanned)
+ )
+ }
+}
+
+@SignalPreview
+@Composable
+private fun RestoreViaQrScreenRegistrationFailedPreview() {
+ Previews.Preview {
+ RestoreViaQrScreen(
+ state = RestoreViaQrViewModel.RestoreViaQrState(isRegistering = false, showRegistrationError = true, qrState = RestoreViaQrViewModel.QrState.Scanned)
+ )
+ }
+}
+
+@Composable
+private fun InstructionRow(
+ icon: Painter,
+ instruction: String
+) {
+ Row(
+ modifier = Modifier
+ .padding(vertical = 12.dp)
+ ) {
+ Icon(
+ painter = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Text(
+ text = instruction,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@SignalPreview
+@Composable
+private fun InstructionRowPreview() {
+ Previews.Preview {
+ InstructionRow(
+ icon = painterResource(R.drawable.symbol_phone_24),
+ instruction = "Instruction!"
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt
new file mode 100644
index 0000000000..b600597591
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/RestoreViaQrViewModel.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.restore
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import org.signal.registration.proto.RegistrationProvisionMessage
+import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
+import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
+import org.thoughtcrime.securesms.dependencies.AppDependencies
+import org.whispersystems.signalservice.api.registration.ProvisioningSocket
+import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
+import java.io.Closeable
+
+class RestoreViaQrViewModel : ViewModel() {
+
+ private val store: MutableStateFlow = MutableStateFlow(RestoreViaQrState())
+
+ val state: StateFlow = store
+
+ private var socketHandle: Closeable
+
+ init {
+ socketHandle = start()
+ }
+
+ fun restart() {
+ socketHandle.close()
+ socketHandle = start()
+ }
+
+ fun handleRegistrationFailure() {
+ store.update {
+ if (it.isRegistering) {
+ it.copy(
+ isRegistering = false,
+ provisioningMessage = null,
+ showRegistrationError = true
+ )
+ } else {
+ it
+ }
+ }
+ }
+
+ fun clearRegistrationError() {
+ store.update { it.copy(showRegistrationError = false) }
+ }
+
+ override fun onCleared() {
+ socketHandle.close()
+ }
+
+ private fun start(): Closeable {
+ store.update { it.copy(qrState = QrState.Loading) }
+
+ return ProvisioningSocket.start(
+ identityKeyPair = IdentityKeyUtil.generateIdentityKeyPair(),
+ configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration(),
+ handler = CoroutineExceptionHandler { _, _ -> store.update { it.copy(qrState = QrState.Failed) } }
+ ) { socket ->
+ val url = socket.getProvisioningUrl()
+ store.update { it.copy(qrState = QrState.Loaded(qrData = QrCodeData.forData(data = url, supportIconOverlay = false))) }
+
+ val result = socket.getRegistrationProvisioningMessage()
+ if (result is SecondaryProvisioningCipher.RegistrationProvisionResult.Success) {
+ store.update { it.copy(isRegistering = true, provisioningMessage = result.message, qrState = QrState.Scanned) }
+ } else {
+ store.update { it.copy(showProvisioningError = true, qrState = QrState.Scanned) }
+ }
+ }
+ }
+
+ data class RestoreViaQrState(
+ val isRegistering: Boolean = false,
+ val qrState: QrState = QrState.Loading,
+ val provisioningMessage: RegistrationProvisionMessage? = null,
+ val showProvisioningError: Boolean = false,
+ val showRegistrationError: Boolean = false
+ )
+
+ sealed interface QrState {
+ data object Loading : QrState
+ data class Loaded(val qrData: QrCodeData) : QrState
+ data object Failed : QrState
+ data object Scanned : QrState
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectManualRestoreMethodFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectManualRestoreMethodFragment.kt
new file mode 100644
index 0000000000..aa3afca9d8
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectManualRestoreMethodFragment.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.restore
+
+import android.app.Activity
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.runtime.Composable
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.findNavController
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.compose.ComposeFragment
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
+import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
+import org.thoughtcrime.securesms.restore.RestoreActivity
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+
+/**
+ * Provide options to select restore/transfer operation and flow during manual registration.
+ */
+class SelectManualRestoreMethodFragment : ComposeFragment() {
+
+ companion object {
+ private val TAG = Log.tag(SelectManualRestoreMethodFragment::class)
+ }
+
+ private val sharedViewModel by activityViewModels()
+
+ private val launchRestoreActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
+ when (val resultCode = result.resultCode) {
+ Activity.RESULT_OK -> {
+ sharedViewModel.onBackupSuccessfullyRestored()
+ findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL))
+ }
+ Activity.RESULT_CANCELED -> {
+ Log.w(TAG, "Backup restoration canceled.")
+ }
+ else -> Log.w(TAG, "Backup restoration activity ended with unknown result code: $resultCode")
+ }
+ }
+
+ @Composable
+ override fun FragmentContent() {
+ SelectRestoreMethodScreen(
+ restoreMethods = listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1),
+ onRestoreMethodClicked = this::startRestoreMethod,
+ onSkip = { findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL)) }
+ )
+ }
+
+ private fun startRestoreMethod(method: RestoreMethod) {
+ when (method) {
+ RestoreMethod.FROM_SIGNAL_BACKUPS -> findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE))
+ RestoreMethod.FROM_LOCAL_BACKUP_V1 -> launchRestoreActivity.launch(RestoreActivity.getLocalRestoreIntent(requireContext()))
+ RestoreMethod.FROM_OLD_DEVICE -> error("Device transfer not supported in manual restore flow")
+ RestoreMethod.FROM_LOCAL_BACKUP_V2 -> error("Not currently supported")
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectRestoreMethodScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectRestoreMethodScreen.kt
new file mode 100644
index 0000000000..2815c9b383
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/restore/SelectRestoreMethodScreen.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.restore
+
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import org.signal.core.ui.SignalPreview
+import org.signal.core.ui.theme.SignalTheme
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.registrationv3.ui.shared.RegistrationScreen
+
+/**
+ * Screen showing various restore methods available during quick and manual re-registration.
+ */
+@Composable
+fun SelectRestoreMethodScreen(
+ restoreMethods: List,
+ onRestoreMethodClicked: (RestoreMethod) -> Unit = {},
+ onSkip: () -> Unit = {}
+) {
+ RegistrationScreen(
+ title = stringResource(id = R.string.SelectRestoreMethodFragment__restore_or_transfer_account),
+ subtitle = stringResource(id = R.string.SelectRestoreMethodFragment__get_your_signal_account),
+ bottomContent = {
+ TextButton(
+ onClick = onSkip,
+ modifier = Modifier.align(Alignment.Center)
+ ) {
+ Text(text = stringResource(R.string.registration_activity__skip))
+ }
+ }
+ ) {
+ for (method in restoreMethods) {
+ RestoreRow(
+ icon = painterResource(method.iconRes),
+ title = stringResource(method.titleRes),
+ subtitle = stringResource(method.subtitleRes),
+ onRowClick = { onRestoreMethodClicked(method) }
+ )
+ }
+ }
+}
+
+@SignalPreview
+@Composable
+private fun SelectRestoreMethodScreenPreview() {
+ SignalTheme {
+ SelectRestoreMethodScreen(listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_OLD_DEVICE, RestoreMethod.FROM_LOCAL_BACKUP_V1))
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/shared/RegistrationScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/shared/RegistrationScreen.kt
new file mode 100644
index 0000000000..6ecdf070ed
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/shared/RegistrationScreen.kt
@@ -0,0 +1,122 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.shared
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.unit.dp
+import org.signal.core.ui.Previews
+import org.signal.core.ui.SignalPreview
+import org.signal.core.ui.horizontalGutters
+
+/**
+ * A base framework for rendering the various v3 registration screens.
+ */
+@Composable
+fun RegistrationScreen(
+ title: String,
+ subtitle: String,
+ bottomContent: @Composable (BoxScope.() -> Unit),
+ mainContent: @Composable () -> Unit
+) {
+ RegistrationScreen(title, AnnotatedString(subtitle), bottomContent, mainContent)
+}
+
+/**
+ * A base framework for rendering the various v3 registration screens.
+ */
+@Composable
+fun RegistrationScreen(
+ title: String,
+ subtitle: AnnotatedString?,
+ bottomContent: @Composable (BoxScope.() -> Unit),
+ mainContent: @Composable () -> Unit
+) {
+ Surface {
+ Column(
+ verticalArrangement = Arrangement.SpaceBetween,
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight()
+ ) {
+ val scrollState = rememberScrollState()
+
+ Column(
+ modifier = Modifier
+ .verticalScroll(scrollState)
+ .weight(weight = 1f, fill = false)
+ .padding(top = 40.dp, bottom = 16.dp)
+ .horizontalGutters()
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineMedium,
+ modifier = Modifier
+ )
+
+ if (subtitle != null) {
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 16.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(40.dp))
+
+ mainContent()
+ }
+
+ Surface(
+ shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Box(
+ modifier = Modifier
+ .padding(top = 8.dp, bottom = 24.dp)
+ .horizontalGutters()
+ ) {
+ bottomContent()
+ }
+ }
+ }
+ }
+}
+
+@SignalPreview
+@Composable
+private fun RegistrationScreenPreview() {
+ Previews.Preview {
+ RegistrationScreen(
+ title = "Title",
+ subtitle = "Subtitle",
+ bottomContent = {
+ TextButton(onClick = {}) {
+ Text("Bottom Button")
+ }
+ }
+ ) {
+ Text("Main content")
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/RestoreWelcomeBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/RestoreWelcomeBottomSheet.kt
new file mode 100644
index 0000000000..c76ebdb173
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/RestoreWelcomeBottomSheet.kt
@@ -0,0 +1,164 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.welcome
+
+import android.content.DialogInterface
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.os.bundleOf
+import androidx.fragment.app.setFragmentResult
+import org.signal.core.ui.BottomSheets
+import org.signal.core.ui.Previews
+import org.signal.core.ui.SignalPreview
+import org.signal.core.ui.horizontalGutters
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
+
+/**
+ * Restore flow starting bottom sheet that allows user to progress through quick restore or manual restore flows
+ * from the Welcome screen.
+ */
+class RestoreWelcomeBottomSheet : ComposeBottomSheetDialogFragment() {
+
+ private var result: WelcomeUserSelection = WelcomeUserSelection.CONTINUE
+
+ companion object {
+ const val REQUEST_KEY = "RestoreWelcomeBottomSheet"
+ }
+
+ @Composable
+ override fun SheetContent() {
+ Sheet(
+ onHasOldPhone = {
+ result = WelcomeUserSelection.RESTORE_WITH_OLD_PHONE
+ dismissAllowingStateLoss()
+ },
+ onNoPhone = {
+ result = WelcomeUserSelection.RESTORE_WITH_NO_PHONE
+ dismissAllowingStateLoss()
+ }
+ )
+ }
+
+ override fun onDismiss(dialog: DialogInterface) {
+ setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to result))
+
+ super.onDismiss(dialog)
+ }
+}
+
+@Composable
+private fun Sheet(
+ onHasOldPhone: () -> Unit = {},
+ onNoPhone: () -> Unit = {}
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.fillMaxWidth()
+ .padding(bottom = 54.dp)
+ ) {
+ BottomSheets.Handle()
+
+ val context = LocalContext.current
+
+ Spacer(modifier = Modifier.size(26.dp))
+
+ RestoreActionRow(
+ icon = painterResource(R.drawable.symbol_qrcode_24),
+ title = stringResource(R.string.WelcomeFragment_restore_action_i_have_my_old_phone),
+ subtitle = stringResource(R.string.WelcomeFragment_restore_action_scan_qr),
+ onRowClick = onHasOldPhone
+ )
+
+ RestoreActionRow(
+ icon = painterResource(R.drawable.symbol_no_phone_44),
+ title = stringResource(R.string.WelcomeFragment_restore_action_i_dont_have_my_old_phone),
+ subtitle = stringResource(R.string.WelcomeFragment_restore_action_reinstalling),
+ onRowClick = onNoPhone
+ )
+ }
+}
+
+@Composable
+@SignalPreview
+private fun SheetPreview() {
+ Previews.BottomSheetPreview {
+ Sheet()
+ }
+}
+
+@Composable
+fun RestoreActionRow(
+ icon: Painter,
+ title: String,
+ subtitle: String,
+ onRowClick: () -> Unit = {}
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .horizontalGutters()
+ .padding(vertical = 8.dp)
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(18.dp))
+ .background(MaterialTheme.colorScheme.background)
+ .clickable(enabled = true, onClick = onRowClick)
+ .padding(horizontal = 24.dp, vertical = 16.dp)
+ ) {
+ Icon(
+ painter = icon,
+ tint = MaterialTheme.colorScheme.primary,
+ contentDescription = null,
+ modifier = Modifier.size(44.dp)
+ )
+
+ Column(
+ modifier = Modifier.padding(start = 16.dp)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyLarge
+ )
+
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+}
+
+@SignalPreview
+@Composable
+private fun RestoreActionRowPreview() {
+ Previews.Preview {
+ RestoreActionRow(
+ icon = painterResource(R.drawable.symbol_qrcode_24),
+ title = stringResource(R.string.WelcomeFragment_restore_action_i_have_my_old_phone),
+ subtitle = stringResource(R.string.WelcomeFragment_restore_action_scan_qr)
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeFragment.kt
new file mode 100644
index 0000000000..d3b8ee79a9
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeFragment.kt
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.welcome
+
+import android.content.pm.PackageManager
+import android.os.Bundle
+import android.view.View
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.findNavController
+import org.signal.core.util.getSerializableCompat
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.LoggingFragment
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.components.ViewBinderDelegate
+import org.thoughtcrime.securesms.databinding.FragmentRegistrationWelcomeV3Binding
+import org.thoughtcrime.securesms.permissions.Permissions
+import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
+import org.thoughtcrime.securesms.registration.fragments.WelcomePermissions
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
+import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
+import org.thoughtcrime.securesms.registrationv3.ui.permissions.GrantPermissionsFragment
+import org.thoughtcrime.securesms.registrationv3.ui.phonenumber.EnterPhoneNumberMode
+import org.thoughtcrime.securesms.util.BackupUtil
+import org.thoughtcrime.securesms.util.CommunicationActions
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+
+/**
+ * First screen that is displayed on the very first app launch.
+ */
+class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v3) {
+ companion object {
+ private val TAG = Log.tag(WelcomeFragment::class.java)
+ private const val TERMS_AND_CONDITIONS_URL = "https://signal.org/legal"
+ }
+
+ private val sharedViewModel by activityViewModels()
+ private val binding: FragmentRegistrationWelcomeV3Binding by ViewBinderDelegate(FragmentRegistrationWelcomeV3Binding::bind)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ setDebugLogSubmitMultiTapView(binding.image)
+ setDebugLogSubmitMultiTapView(binding.title)
+
+ binding.welcomeContinueButton.setOnClickListener { onContinueClicked() }
+ binding.welcomeTermsButton.setOnClickListener { onTermsClicked() }
+ binding.welcomeTransferOrRestore.setOnClickListener { onRestoreOrTransferClicked() }
+
+ childFragmentManager.setFragmentResultListener(RestoreWelcomeBottomSheet.REQUEST_KEY, viewLifecycleOwner) { requestKey, bundle ->
+ if (requestKey == RestoreWelcomeBottomSheet.REQUEST_KEY) {
+ when (val userSelection = bundle.getSerializableCompat(RestoreWelcomeBottomSheet.REQUEST_KEY, WelcomeUserSelection::class.java)) {
+ WelcomeUserSelection.RESTORE_WITH_OLD_PHONE,
+ WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> afterRestoreOrTransferClicked(userSelection)
+ else -> Unit
+ }
+ }
+ }
+
+ if (Permissions.isRuntimePermissionsRequired()) {
+ parentFragmentManager.setFragmentResultListener(GrantPermissionsFragment.REQUEST_KEY, viewLifecycleOwner) { requestKey, bundle ->
+ if (requestKey == GrantPermissionsFragment.REQUEST_KEY) {
+ when (val userSelection = bundle.getSerializableCompat(GrantPermissionsFragment.REQUEST_KEY, WelcomeUserSelection::class.java)) {
+ WelcomeUserSelection.RESTORE_WITH_OLD_PHONE,
+ WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> navigateToNextScreenViaRestore(userSelection)
+ WelcomeUserSelection.CONTINUE -> navigateToNextScreenViaContinue()
+ null -> Unit
+ }
+ }
+ }
+ }
+ }
+
+ private fun onContinueClicked() {
+ if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) {
+ findNavController().safeNavigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(WelcomeUserSelection.CONTINUE))
+ } else {
+ navigateToNextScreenViaContinue()
+ }
+ }
+
+ private fun navigateToNextScreenViaContinue() {
+ sharedViewModel.maybePrefillE164(requireContext())
+ findNavController().safeNavigate(WelcomeFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL))
+ }
+
+ private fun onTermsClicked() {
+ CommunicationActions.openBrowserLink(requireContext(), TERMS_AND_CONDITIONS_URL)
+ }
+
+ private fun onRestoreOrTransferClicked() {
+ RestoreWelcomeBottomSheet().show(childFragmentManager, null)
+ }
+
+ private fun afterRestoreOrTransferClicked(userSelection: WelcomeUserSelection) {
+ if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) {
+ findNavController().safeNavigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(userSelection))
+ } else {
+ navigateToNextScreenViaRestore(userSelection)
+ }
+ }
+
+ private fun navigateToNextScreenViaRestore(userSelection: WelcomeUserSelection) {
+ sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
+
+ when (userSelection) {
+ WelcomeUserSelection.CONTINUE -> throw IllegalArgumentException()
+ WelcomeUserSelection.RESTORE_WITH_OLD_PHONE -> findNavController().safeNavigate(WelcomeFragmentDirections.goToRestoreViaQr())
+ WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> findNavController().safeNavigate(WelcomeFragmentDirections.goToSelectRestoreMethod(userSelection))
+ }
+ }
+
+ private fun hasAllPermissions(): Boolean {
+ val isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext())
+ return WelcomePermissions.getWelcomePermissions(isUserSelectionRequired).all { ContextCompat.checkSelfPermission(requireContext(), it) == PackageManager.PERMISSION_GRANTED }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeUserSelection.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeUserSelection.kt
new file mode 100644
index 0000000000..b20e310cc7
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/welcome/WelcomeUserSelection.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.registrationv3.ui.welcome
+
+/**
+ * User options available to start registration flow.
+ */
+enum class WelcomeUserSelection {
+ CONTINUE, RESTORE_WITH_OLD_PHONE, RESTORE_WITH_NO_PHONE
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt
index 7a4f5cdc9a..6dfb2a2183 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt
@@ -8,16 +8,20 @@ package org.thoughtcrime.securesms.restore
import android.content.Context
import android.content.Intent
import android.os.Bundle
+import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
-import androidx.navigation.findNavController
+import androidx.navigation.NavController
+import androidx.navigation.Navigation
+import androidx.navigation.fragment.NavHostFragment
import org.signal.core.util.getParcelableExtraCompat
+import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
-import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
-import org.thoughtcrime.securesms.keyvalue.SignalStore
-import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity
+import org.thoughtcrime.securesms.RestoreDirections
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
+import org.thoughtcrime.securesms.util.RemoteConfig
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Activity to hold the restore from backup flow.
@@ -27,6 +31,8 @@ class RestoreActivity : BaseActivity() {
private val dynamicTheme = DynamicNoActionBarTheme()
private val sharedViewModel: RestoreViewModel by viewModels()
+ private lateinit var navController: NavController
+
override fun onCreate(savedInstanceState: Bundle?) {
dynamicTheme.onCreate(this)
super.onCreate(savedInstanceState)
@@ -34,16 +40,42 @@ class RestoreActivity : BaseActivity() {
setResult(RESULT_CANCELED)
setContentView(R.layout.activity_restore)
+
+ if (savedInstanceState == null) {
+ val fragment: NavHostFragment = NavHostFragment.create(R.navigation.restore)
+
+ supportFragmentManager
+ .beginTransaction()
+ .replace(R.id.nav_host_fragment, fragment)
+ .commitNow()
+
+ navController = fragment.navController
+ } else {
+ val fragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
+ navController = fragment.navController
+ }
+
intent.getParcelableExtraCompat(PassphraseRequiredActivity.NEXT_INTENT_EXTRA, Intent::class.java)?.let {
sharedViewModel.setNextIntent(it)
}
- val navTarget = NavTarget.deserialize(intent.getIntExtra(EXTRA_NAV_TARGET, NavTarget.NONE.value))
+ val navTarget = NavTarget.deserialize(intent.getIntExtra(EXTRA_NAV_TARGET, NavTarget.LEGACY_LANDING.value))
+
when (navTarget) {
- NavTarget.LOCAL_RESTORE -> findNavController(R.id.nav_host_fragment).navigate(R.id.choose_local_backup_fragment)
- NavTarget.TRANSFER -> findNavController(R.id.nav_host_fragment).navigate(R.id.newDeviceTransferInstructions)
+ NavTarget.NEW_LANDING -> navController.safeNavigate(RestoreDirections.goDirectlyToNewLanding())
+ NavTarget.LOCAL_RESTORE -> navController.safeNavigate(RestoreDirections.goDirectlyToChooseLocalBackup())
+ NavTarget.TRANSFER -> navController.safeNavigate(RestoreDirections.goDirectlyToDeviceTransfer())
else -> Unit
}
+
+ onBackPressedDispatcher.addCallback(
+ this,
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ onNavigateUp()
+ }
+ }
+ )
}
override fun onResume() {
@@ -51,21 +83,38 @@ class RestoreActivity : BaseActivity() {
dynamicTheme.onResume(this)
}
- fun finishActivitySuccessfully() {
+ override fun onNavigateUp(): Boolean {
+ return if (!Navigation.findNavController(this, R.id.nav_host_fragment).popBackStack()) {
+ finish()
+ true
+ } else {
+ false
+ }
+ }
+
+ fun onBackupCompletedSuccessfully() {
+ sharedViewModel.getNextIntent()?.let {
+ Log.d(TAG, "Launching ${it.component}", Throwable())
+ startActivity(it)
+ }
+
setResult(RESULT_OK)
finish()
}
companion object {
+ private val TAG = Log.tag(RestoreActivity::class)
+
enum class NavTarget(val value: Int) {
- NONE(0),
- TRANSFER(1),
- LOCAL_RESTORE(2);
+ LEGACY_LANDING(0),
+ NEW_LANDING(1),
+ TRANSFER(2),
+ LOCAL_RESTORE(3);
companion object {
fun deserialize(value: Int): NavTarget {
- return entries.firstOrNull { it.value == value } ?: NONE
+ return entries.firstOrNull { it.value == value } ?: LEGACY_LANDING
}
}
}
@@ -73,26 +122,26 @@ class RestoreActivity : BaseActivity() {
private const val EXTRA_NAV_TARGET = "nav_target"
@JvmStatic
- fun getIntentForTransfer(context: Context): Intent {
+ fun getDeviceTransferIntent(context: Context): Intent {
return Intent(context, RestoreActivity::class.java).apply {
putExtra(EXTRA_NAV_TARGET, NavTarget.TRANSFER.value)
}
}
@JvmStatic
- fun getIntentForLocalRestore(context: Context): Intent {
+ fun getLocalRestoreIntent(context: Context): Intent {
return Intent(context, RestoreActivity::class.java).apply {
putExtra(EXTRA_NAV_TARGET, NavTarget.LOCAL_RESTORE.value)
}
}
@JvmStatic
- fun getIntentForTransferOrRestore(context: Context): Intent {
- val tier = SignalStore.backup.backupTier
- if (tier == MessageBackupTier.PAID) {
- return Intent(context, RemoteRestoreActivity::class.java)
+ fun getRestoreIntent(context: Context): Intent {
+ return Intent(context, RestoreActivity::class.java).apply {
+ if (RemoteConfig.restoreAfterRegistration) {
+ putExtra(EXTRA_NAV_TARGET, NavTarget.NEW_LANDING.value)
+ }
}
- return Intent(context, RestoreActivity::class.java)
}
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreState.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreState.kt
index e2df0cf003..bf66b88581 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreState.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreState.kt
@@ -7,7 +7,7 @@ package org.thoughtcrime.securesms.restore
import android.content.Intent
import android.net.Uri
-import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType
+import org.thoughtcrime.securesms.restore.transferorrestore.BackupRestorationType
/**
* Shared state holder for the restore flow.
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt
index c75adbd296..87ec134b11 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt
@@ -11,7 +11,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
-import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType
+import org.thoughtcrime.securesms.restore.transferorrestore.BackupRestorationType
/**
* Shared view model for the restore flow.
@@ -38,12 +38,6 @@ class RestoreViewModel : ViewModel() {
}
}
- fun onRestoreFromRemoteBackupSelected() {
- store.update {
- it.copy(restorationType = BackupRestorationType.REMOTE_BACKUP)
- }
- }
-
fun getBackupRestorationType(): BackupRestorationType {
return store.value.restorationType
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/devicetransfer/DeviceTransferFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/devicetransfer/DeviceTransferFragment.kt
index 4e511edf63..efed2c8d53 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/restore/devicetransfer/DeviceTransferFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/devicetransfer/DeviceTransferFragment.kt
@@ -7,9 +7,9 @@ package org.thoughtcrime.securesms.restore.devicetransfer
import android.os.Bundle
import android.view.View
+import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.annotation.StringRes
-import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
@@ -20,14 +20,24 @@ import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentDeviceTransferBinding
-import org.thoughtcrime.securesms.restore.RestoreViewModel
import org.thoughtcrime.securesms.util.visible
-sealed class DeviceTransferFragment : LoggingFragment(R.layout.fragment_device_transfer) {
+/**
+ * Drives the UI for the actual device transfer progress. Shown after setup is complete
+ * and the two devices are transferring.
+ *
+ * Handles show progress and error state.
+ */
+abstract class DeviceTransferFragment : LoggingFragment(R.layout.fragment_device_transfer) {
+
+ companion object {
+ private const val TRANSFER_FINISHED_KEY = "transfer_finished"
+ }
+
private val onBackPressed = OnBackPressed()
private val transferModeListener = TransferModeListener()
- protected val navigationViewModel: RestoreViewModel by activityViewModels()
protected val binding: FragmentDeviceTransferBinding by ViewBinderDelegate(FragmentDeviceTransferBinding::bind)
+ protected val status: TextView by lazy { binding.deviceTransferFragmentStatus }
protected var transferFinished: Boolean = false
@@ -38,6 +48,13 @@ sealed class DeviceTransferFragment : LoggingFragment(R.layout.fragment_device_t
}
}
+ override fun onStart() {
+ super.onStart()
+ if (transferFinished) {
+ navigateToTransferComplete()
+ }
+ }
+
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putBoolean(TRANSFER_FINISHED_KEY, transferFinished)
@@ -132,8 +149,4 @@ sealed class DeviceTransferFragment : LoggingFragment(R.layout.fragment_device_t
}
}
}
-
- companion object {
- private const val TRANSFER_FINISHED_KEY = "transfer_finished"
- }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/restorecomplete/RestoreCompleteFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/restorecomplete/RestoreCompleteFragment.kt
deleted file mode 100644
index 44b1979a77..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/restore/restorecomplete/RestoreCompleteFragment.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-/*
- * Copyright 2024 Signal Messenger, LLC
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-package org.thoughtcrime.securesms.restore.restorecomplete
-
-import android.os.Bundle
-import android.view.View
-import org.signal.core.util.logging.Log
-import org.thoughtcrime.securesms.LoggingFragment
-import org.thoughtcrime.securesms.R
-import org.thoughtcrime.securesms.restore.RestoreActivity
-
-/**
- * This is a hack placeholder fragment so we can reuse the existing V1 device transfer fragments without changing their navigation calls.
- * The original calls expect to be navigating from the [NewDeviceTransferCompleteFragment] to [EnterPhoneNumberFragment]
- * This approximates that by taking the place of [EnterPhoneNumberFragment],
- * then bridging us back to [RegistrationV2Activity] by immediately closing the [RestoreActivity].
- */
-class RestoreCompleteFragment : LoggingFragment(R.layout.fragment_registration_blank) {
-
- override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
- super.onViewCreated(view, savedInstanceState)
- Log.d(TAG, "Finishing activity…")
- onBackupCompletedSuccessfully()
- }
-
- private fun onBackupCompletedSuccessfully() {
- Log.d(TAG, "onBackupCompletedSuccessfully()")
- val activity = requireActivity() as RestoreActivity
- activity.finishActivitySuccessfully()
- }
-
- companion object {
- private val TAG = Log.tag(RestoreCompleteFragment::class.java)
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupFragment.kt
index d7f5bb5626..3e96ce9d4f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupFragment.kt
@@ -41,10 +41,10 @@ import java.util.Locale
* This fragment is used to monitor and manage an in-progress backup restore.
*/
class RestoreLocalBackupFragment : LoggingFragment(R.layout.fragment_restore_local_backup) {
- private val navigationViewModel: RestoreViewModel by activityViewModels()
+ private val sharedViewModel: RestoreViewModel by activityViewModels()
private val restoreLocalBackupViewModel: RestoreLocalBackupViewModel by viewModels(
factoryProducer = ViewModelFactory.factoryProducer {
- val fileBackupUri = navigationViewModel.getBackupFileUri()!!
+ val fileBackupUri = sharedViewModel.getBackupFileUri()!!
RestoreLocalBackupViewModel(fileBackupUri)
}
)
@@ -55,7 +55,7 @@ class RestoreLocalBackupFragment : LoggingFragment(R.layout.fragment_restore_loc
setDebugLogSubmitMultiTapView(binding.verifyHeader)
Log.i(TAG, "Backup restore.")
- if (navigationViewModel.getBackupFileUri() == null) {
+ if (sharedViewModel.getBackupFileUri() == null) {
Log.i(TAG, "No backup URI found, must navigate back to choose one.")
findNavController().navigateUp()
return
@@ -110,11 +110,7 @@ class RestoreLocalBackupFragment : LoggingFragment(R.layout.fragment_restore_loc
private fun onBackupCompletedSuccessfully() {
Log.d(TAG, "onBackupCompletedSuccessfully()")
val activity = requireActivity() as RestoreActivity
- navigationViewModel.getNextIntent()?.let {
- Log.d(TAG, "Launching ${it.component}")
- activity.startActivity(it)
- }
- activity.finishActivitySuccessfully()
+ activity.onBackupCompletedSuccessfully()
}
override fun onStart() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt
new file mode 100644
index 0000000000..058116bce6
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/selection/SelectRestoreMethodFragment.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.restore.selection
+
+import android.content.Intent
+import androidx.compose.runtime.Composable
+import androidx.navigation.fragment.findNavController
+import org.thoughtcrime.securesms.MainActivity
+import org.thoughtcrime.securesms.compose.ComposeFragment
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.registrationv3.ui.restore.RemoteRestoreActivity
+import org.thoughtcrime.securesms.registrationv3.ui.restore.RestoreMethod
+import org.thoughtcrime.securesms.registrationv3.ui.restore.SelectRestoreMethodScreen
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+
+/**
+ * Provide options to select restore/transfer operation and flow during quick registration.
+ */
+class SelectRestoreMethodFragment : ComposeFragment() {
+ @Composable
+ override fun FragmentContent() {
+ SelectRestoreMethodScreen(
+ restoreMethods = listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_OLD_DEVICE, RestoreMethod.FROM_LOCAL_BACKUP_V1), // TODO [backups] make dynamic
+ onRestoreMethodClicked = this::startRestoreMethod,
+ onSkip = {
+ SignalStore.registration.markSkippedTransferOrRestore()
+ startActivity(MainActivity.clearTop(requireContext()))
+ activity?.finish()
+ }
+ )
+ }
+
+ private fun startRestoreMethod(method: RestoreMethod) {
+ when (method) {
+ RestoreMethod.FROM_SIGNAL_BACKUPS -> startActivity(Intent(requireContext(), RemoteRestoreActivity::class.java))
+ RestoreMethod.FROM_OLD_DEVICE -> findNavController().safeNavigate(SelectRestoreMethodFragmentDirections.goToDeviceTransfer())
+ RestoreMethod.FROM_LOCAL_BACKUP_V1 -> findNavController().safeNavigate(SelectRestoreMethodFragmentDirections.goToLocalBackupRestore())
+ RestoreMethod.FROM_LOCAL_BACKUP_V2 -> error("Not currently supported")
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/BackupRestorationType.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/BackupRestorationType.kt
similarity index 75%
rename from app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/BackupRestorationType.kt
rename to app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/BackupRestorationType.kt
index ea34c70d1a..22de9070e4 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/BackupRestorationType.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/BackupRestorationType.kt
@@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-package org.thoughtcrime.securesms.devicetransfer.newdevice
+package org.thoughtcrime.securesms.restore.transferorrestore
/**
* What kind of backup restore the user wishes to perform.
@@ -11,6 +11,5 @@ package org.thoughtcrime.securesms.devicetransfer.newdevice
enum class BackupRestorationType {
DEVICE_TRANSFER,
LOCAL_BACKUP,
- REMOTE_BACKUP,
NONE
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreMoreOptionsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreMoreOptionsDialog.kt
deleted file mode 100644
index 3eae9cc613..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreMoreOptionsDialog.kt
+++ /dev/null
@@ -1,95 +0,0 @@
-/*
- * Copyright 2024 Signal Messenger, LLC
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-package org.thoughtcrime.securesms.restore.transferorrestore
-
-import android.os.Bundle
-import android.view.ContextThemeWrapper
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.core.os.bundleOf
-import androidx.fragment.app.FragmentManager
-import androidx.fragment.app.viewModels
-import org.thoughtcrime.securesms.MainActivity
-import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
-import org.thoughtcrime.securesms.databinding.TransferOrRestoreOptionsBottomSheetDialogFragmentBinding
-import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType
-import org.thoughtcrime.securesms.keyvalue.SignalStore
-import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
-import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity
-import org.thoughtcrime.securesms.restore.RestoreActivity
-import org.thoughtcrime.securesms.util.visible
-
-class TransferOrRestoreMoreOptionsDialog : FixedRoundedCornerBottomSheetDialogFragment() {
-
- override val peekHeightPercentage: Float = 1f
-
- private val viewModel by viewModels()
- private lateinit var binding: TransferOrRestoreOptionsBottomSheetDialogFragmentBinding
-
- companion object {
-
- const val TAG = "TRANSFER_OR_RESTORE_OPTIONS_DIALOG_FRAGMENT"
- const val ARG_SKIP_ONLY = "skip_only"
-
- fun show(fragmentManager: FragmentManager, skipOnly: Boolean) {
- TransferOrRestoreMoreOptionsDialog().apply {
- arguments = bundleOf(ARG_SKIP_ONLY to skipOnly)
- }.show(fragmentManager, TAG)
- }
- }
-
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
- binding = TransferOrRestoreOptionsBottomSheetDialogFragmentBinding.inflate(inflater.cloneInContext(ContextThemeWrapper(inflater.context, themeResId)), container, false)
- if (arguments?.getBoolean(ARG_SKIP_ONLY, false) ?: false) {
- binding.transferCard.visible = false
- binding.localRestoreCard.visible = false
- }
- binding.transferOrRestoreFragmentNext.setOnClickListener { launchSelection(viewModel.getBackupRestorationType()) }
- binding.transferCard.setOnClickListener { viewModel.onTransferFromAndroidDeviceSelected() }
- binding.localRestoreCard.setOnClickListener { viewModel.onRestoreFromLocalBackupSelected() }
- binding.skipCard.setOnClickListener { viewModel.onSkipRestoreOrTransferSelected() }
- binding.cancel.setOnClickListener { dismiss() }
-
- viewModel.uiState.observe(viewLifecycleOwner) { state ->
- updateSelection(state.restorationType)
- }
-
- return binding.root
- }
-
- private fun launchSelection(restorationType: BackupRestorationType?) {
- when (restorationType) {
- BackupRestorationType.DEVICE_TRANSFER -> {
- startActivity(RestoreActivity.getIntentForTransfer(requireContext()))
- }
- BackupRestorationType.LOCAL_BACKUP -> {
- startActivity(RestoreActivity.getIntentForLocalRestore(requireContext()))
- }
- BackupRestorationType.REMOTE_BACKUP -> {
- startActivity(RemoteRestoreActivity.getIntent(requireContext()))
- }
- BackupRestorationType.NONE -> {
- SignalStore.registration.markSkippedTransferOrRestore()
- val startIntent = MainActivity.clearTop(requireContext()).apply {
- putExtra("next_intent", CreateProfileActivity.getIntentForUserProfile(requireContext()))
- }
- startActivity(startIntent)
- }
- else -> {
- return
- }
- }
- dismiss()
- }
-
- private fun updateSelection(restorationType: BackupRestorationType?) {
- binding.transferCard.isSelected = restorationType == BackupRestorationType.DEVICE_TRANSFER
- binding.localRestoreCard.isSelected = restorationType == BackupRestorationType.LOCAL_BACKUP
- binding.skipCard.isSelected = restorationType == BackupRestorationType.NONE
- binding.transferOrRestoreFragmentNext.isEnabled = restorationType != null
- }
-}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreV2Fragment.kt
index a2818976a9..a4739655fd 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreV2Fragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreV2Fragment.kt
@@ -14,15 +14,10 @@ import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentTransferRestoreV2Binding
-import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType
-import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
-import org.thoughtcrime.securesms.registration.ui.restore.RemoteRestoreActivity
import org.thoughtcrime.securesms.restore.RestoreViewModel
-import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
-import org.thoughtcrime.securesms.util.visible
/**
* This presents a list of options for the user to restore (or skip) a backup.
@@ -37,18 +32,7 @@ class TransferOrRestoreV2Fragment : LoggingFragment(R.layout.fragment_transfer_r
RegistrationViewDelegate.setDebugLogSubmitMultiTapView(binding.transferOrRestoreTitle)
binding.transferOrRestoreFragmentTransfer.setOnClickListener { sharedViewModel.onTransferFromAndroidDeviceSelected() }
binding.transferOrRestoreFragmentRestore.setOnClickListener { sharedViewModel.onRestoreFromLocalBackupSelected() }
- binding.transferOrRestoreFragmentRestoreRemote.setOnClickListener { sharedViewModel.onRestoreFromRemoteBackupSelected() }
binding.transferOrRestoreFragmentNext.setOnClickListener { launchSelection(sharedViewModel.getBackupRestorationType()) }
- binding.transferOrRestoreFragmentMoreOptions.setOnClickListener {
- TransferOrRestoreMoreOptionsDialog.show(fragmentManager = childFragmentManager, skipOnly = true)
- }
-
- if (SignalStore.backup.backupTier == null) {
- binding.transferOrRestoreFragmentRestoreRemoteCard.visible = false
- }
-
- binding.transferOrRestoreFragmentRestoreRemoteCard.visible = RemoteConfig.messageBackups
- binding.transferOrRestoreFragmentMoreOptions.visible = RemoteConfig.messageBackups
val description = getString(R.string.TransferOrRestoreFragment__transfer_your_account_and_messages_from_your_old_android_device)
val toBold = getString(R.string.TransferOrRestoreFragment__you_need_access_to_your_old_device)
@@ -65,7 +49,6 @@ class TransferOrRestoreV2Fragment : LoggingFragment(R.layout.fragment_transfer_r
private fun updateSelection(restorationType: BackupRestorationType) {
binding.transferOrRestoreFragmentTransferCard.isSelected = restorationType == BackupRestorationType.DEVICE_TRANSFER
binding.transferOrRestoreFragmentRestoreCard.isSelected = restorationType == BackupRestorationType.LOCAL_BACKUP
- binding.transferOrRestoreFragmentRestoreRemoteCard.isSelected = restorationType == BackupRestorationType.REMOTE_BACKUP
}
private fun launchSelection(restorationType: BackupRestorationType) {
@@ -76,9 +59,6 @@ class TransferOrRestoreV2Fragment : LoggingFragment(R.layout.fragment_transfer_r
BackupRestorationType.LOCAL_BACKUP -> {
NavHostFragment.findNavController(this).safeNavigate(TransferOrRestoreV2FragmentDirections.actionTransferOrRestoreToLocalRestore())
}
- BackupRestorationType.REMOTE_BACKUP -> {
- startActivity(RemoteRestoreActivity.getIntent(requireContext()))
- }
else -> {
throw IllegalArgumentException()
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreViewModel.kt
deleted file mode 100644
index d309c35da1..0000000000
--- a/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreViewModel.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * Copyright 2024 Signal Messenger, LLC
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-package org.thoughtcrime.securesms.restore.transferorrestore
-
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.asLiveData
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.update
-import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType
-
-class TransferOrRestoreViewModel : ViewModel() {
-
- private val store = MutableStateFlow(State())
- val uiState = store.asLiveData()
-
- fun onSkipRestoreOrTransferSelected() {
- store.update {
- it.copy(restorationType = BackupRestorationType.NONE)
- }
- }
-
- fun onTransferFromAndroidDeviceSelected() {
- store.update {
- it.copy(restorationType = BackupRestorationType.DEVICE_TRANSFER)
- }
- }
-
- fun onRestoreFromLocalBackupSelected() {
- store.update {
- it.copy(restorationType = BackupRestorationType.LOCAL_BACKUP)
- }
- }
-
- fun getBackupRestorationType(): BackupRestorationType? {
- return store.value.restorationType
- }
-}
-
-data class State(val restorationType: BackupRestorationType? = null)
diff --git a/app/src/main/res/drawable/image_other_device.xml b/app/src/main/res/drawable/image_other_device.xml
new file mode 100644
index 0000000000..fef0674912
--- /dev/null
+++ b/app/src/main/res/drawable/image_other_device.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/image_transfer_phones.xml b/app/src/main/res/drawable/image_transfer_phones.xml
new file mode 100644
index 0000000000..942203ca09
--- /dev/null
+++ b/app/src/main/res/drawable/image_transfer_phones.xml
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/symbol_backup_40.xml b/app/src/main/res/drawable/symbol_backup_40.xml
new file mode 100644
index 0000000000..3db52a49da
--- /dev/null
+++ b/app/src/main/res/drawable/symbol_backup_40.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/symbol_folder_24.xml b/app/src/main/res/drawable/symbol_folder_24.xml
index 31a6fa6131..9afc7adcad 100644
--- a/app/src/main/res/drawable/symbol_folder_24.xml
+++ b/app/src/main/res/drawable/symbol_folder_24.xml
@@ -3,7 +3,7 @@
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
-
+
diff --git a/app/src/main/res/drawable/symbol_no_phone_44.xml b/app/src/main/res/drawable/symbol_no_phone_44.xml
new file mode 100644
index 0000000000..92f3477421
--- /dev/null
+++ b/app/src/main/res/drawable/symbol_no_phone_44.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/app/src/main/res/drawable/symbol_signal_backups_24.xml b/app/src/main/res/drawable/symbol_signal_backups_24.xml
new file mode 100644
index 0000000000..54d7013afe
--- /dev/null
+++ b/app/src/main/res/drawable/symbol_signal_backups_24.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/symbol_transfer_24.xml b/app/src/main/res/drawable/symbol_transfer_24.xml
new file mode 100644
index 0000000000..2c7b67c1aa
--- /dev/null
+++ b/app/src/main/res/drawable/symbol_transfer_24.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/layout/activity_registration_navigation_v3.xml b/app/src/main/res/layout/activity_registration_navigation_v3.xml
new file mode 100644
index 0000000000..571a69947f
--- /dev/null
+++ b/app/src/main/res/layout/activity_registration_navigation_v3.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/activity_remote_restore.xml b/app/src/main/res/layout/activity_remote_restore.xml
deleted file mode 100644
index 883b564737..0000000000
--- a/app/src/main/res/layout/activity_remote_restore.xml
+++ /dev/null
@@ -1,244 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/activity_restore.xml b/app/src/main/res/layout/activity_restore.xml
index 09b7fa8162..4b4112779a 100644
--- a/app/src/main/res/layout/activity_restore.xml
+++ b/app/src/main/res/layout/activity_restore.xml
@@ -3,23 +3,11 @@
~ SPDX-License-Identifier: AGPL-3.0-only
-->
-
-
-
-
-
\ No newline at end of file
+ android:background="@color/signal_background_primary"
+ android:transitionName="window_content" />
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_registration_restore_backup.xml b/app/src/main/res/layout/fragment_registration_restore_backup.xml
deleted file mode 100644
index 00fb3c6e28..0000000000
--- a/app/src/main/res/layout/fragment_registration_restore_backup.xml
+++ /dev/null
@@ -1,108 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_registration_welcome_v3.xml b/app/src/main/res/layout/fragment_registration_welcome_v3.xml
new file mode 100644
index 0000000000..ee4ccca2b7
--- /dev/null
+++ b/app/src/main/res/layout/fragment_registration_welcome_v3.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_restore_local_backup.xml b/app/src/main/res/layout/fragment_restore_local_backup.xml
index e0f7121202..32101aad0c 100644
--- a/app/src/main/res/layout/fragment_restore_local_backup.xml
+++ b/app/src/main/res/layout/fragment_restore_local_backup.xml
@@ -4,8 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:fillViewport="true"
- tools:context=".registration.fragments.RestoreBackupFragment">
+ android:fillViewport="true">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_transfer_restore_v2.xml b/app/src/main/res/layout/fragment_transfer_restore_v2.xml
index 9d2cd64ebd..13d7b99c2f 100644
--- a/app/src/main/res/layout/fragment_transfer_restore_v2.xml
+++ b/app/src/main/res/layout/fragment_transfer_restore_v2.xml
@@ -93,67 +93,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/app/src/main/res/navigation/registration_v3.xml b/app/src/main/res/navigation/registration_v3.xml
new file mode 100644
index 0000000000..cb42553bbd
--- /dev/null
+++ b/app/src/main/res/navigation/registration_v3.xml
@@ -0,0 +1,279 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/restore.xml b/app/src/main/res/navigation/restore.xml
index 53e60d012e..4b0b520697 100644
--- a/app/src/main/res/navigation/restore.xml
+++ b/app/src/main/res/navigation/restore.xml
@@ -1,5 +1,4 @@
-
-
@@ -7,13 +6,41 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/restore"
- app:startDestination="@id/transferOrRestore">
+ app:startDestination="@id/transferOrRestoreV2">
+
+
+
+
+
+
+ android:id="@+id/transferOrRestoreV2"
+ android:name="org.thoughtcrime.securesms.restore.transferorrestore.TransferOrRestoreV2Fragment">
-
-
+ app:popExitAnim="@anim/nav_default_pop_exit_anim" />
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+ tools:layout="@layout/fragment_restore_local_backup" />
-
-
+ app:popUpTo="@id/restore"
+ app:popUpToInclusive="true" />
-
-
-
-
-
-
+ tools:layout="@layout/new_device_transfer_complete_fragment" />
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d0b2a7625a..2377a71efd 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1329,20 +1329,6 @@
more
Add group description…
-
-
- Transfer from Android device
-
- Transfer your account and messages from your old Android device.
-
- Log in without transferring
-
- Continue without transferring your messages and media
-
- Restore local backup
-
- Restore your messages from a backup file you saved on your device.
-
Downloading backup…
@@ -1360,12 +1346,16 @@
All of your messages
Restore from backup
-
+
Only media sent or received in the past %1$d days is included.
Your backup includes:
Restore backup
+
+ Your last backup was made on %1$s at %2$s.
+
+ Fetching backup details…
Notify me for Mentions
@@ -4311,6 +4301,8 @@
I have written down this passphrase. Without it, I will be unable to restore a backup.
Restore backup
Transfer or restore account
+
+ Restore or transfer
Transfer account
Skip
Chat backups
@@ -7536,10 +7528,6 @@
Change or cancel subscription
-
-
- Your last backup was made on %1$s at %2$s.
-
Chat limits
@@ -7857,5 +7845,89 @@
Reminder icon
+
+
+ I have my old phone
+
+ Scan a QR code from your current Signal account to get started quickly
+
+
+ I don\'t have my old phone
+
+ Or you’re reinstaling Signal on the same device
+
+
+ Restore or transfer account
+
+ Get your Signal account and message history onto this device.
+
+ From Signal Backups
+
+ Your free or paid Signal Backup plan
+
+ From a backup folder
+
+ From a backup file
+
+ Choose a backup you’ve saved
+
+ From your old phone
+
+ Transfer directly from your old Android
+
+
+ Restore local backup
+
+ Restore your messages from the backup you saved on your device. If you don\'t restore now, you won\'t be able to restore later.
+
+
+ Enter your backup key
+
+ Your backup key is a 64-digit code required to recover your account and data.
+
+ No backup key?
+
+ Backup key
+
+ Backups can\'t be recovered without their 64-digit recovery code. If you\'ve lost your backup key Signal can\'t help restore your backup.
+
+ If you have your old device you can view your backup key in Settings > Chats > Signal Backups. Then tap View backup key.
+
+ Learn more
+
+ Skip and don\'t restore
+
+
+ Scan this code with your old phone
+
+ Open Signal on your old device
+
+ Tap the camera icon
+
+ Scan this code with the camera
+
+ Unable to generate QR code
+
+ Scanned on old device
+
+ Retry
+
+
+ Transfer account
+
+ Your account will be transferred to a new device.This device will be able to see your groups and contacts, access your chats, and send messages in your name. %1$s
+
+ Learn more
+
+ Transfer account
+
+ Messages and chat info are protected by end-to-end encryption on all devices
+
+ Unlock to transfer account
+
+ Continue on your other device
+
+ Continue transferring your account on your other device.
+
diff --git a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt
index ce5ac68cce..f89c829926 100644
--- a/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt
+++ b/core-ui/src/main/java/org/signal/core/ui/Dialogs.kt
@@ -36,6 +36,7 @@ import org.signal.core.ui.theme.SignalTheme
object Dialogs {
+ const val NoTitle = ""
const val NoDismiss = ""
@Composable
diff --git a/libsignal-service/build.gradle.kts b/libsignal-service/build.gradle.kts
index 98c1c4c7df..5ae5af34e8 100644
--- a/libsignal-service/build.gradle.kts
+++ b/libsignal-service/build.gradle.kts
@@ -94,6 +94,8 @@ dependencies {
api(libs.rxjava3.rxjava)
implementation(libs.kotlin.stdlib.jdk8)
+ implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.coroutines.core.jvm)
implementation(project(":core-util-jvm"))
@@ -102,6 +104,7 @@ dependencies {
testImplementation(testLibs.conscrypt.openjdk.uber)
testImplementation(testLibs.mockito.core)
testImplementation(testLibs.mockk)
+ testImplementation(testLibs.hamcrest.hamcrest)
testFixturesImplementation(libs.libsignal.client)
testFixturesImplementation(testLibs.junit.junit)
diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalUrlExtensions.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalUrlExtensions.kt
new file mode 100644
index 0000000000..a6e32c4c11
--- /dev/null
+++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalUrlExtensions.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.signalservice.api
+
+import okhttp3.ConnectionSpec
+import okhttp3.OkHttpClient
+import org.whispersystems.signalservice.api.push.TrustStore
+import org.whispersystems.signalservice.api.util.Tls12SocketFactory
+import org.whispersystems.signalservice.api.util.TlsProxySocketFactory
+import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
+import org.whispersystems.signalservice.internal.configuration.SignalUrl
+import org.whispersystems.signalservice.internal.util.BlacklistingTrustManager
+import org.whispersystems.signalservice.internal.util.Util
+import java.security.KeyManagementException
+import java.security.NoSuchAlgorithmException
+import java.util.concurrent.TimeUnit
+import javax.net.ssl.SSLContext
+import javax.net.ssl.SSLSocketFactory
+import javax.net.ssl.X509TrustManager
+
+/**
+ * Select a a URL at random to use.
+ */
+fun Array.chooseUrl(): T {
+ return this[(Math.random() * size).toInt()]
+}
+
+/**
+ * Build and configure an [OkHttpClient] as defined by the target [SignalUrl] and provided [configuration].
+ */
+fun T.buildOkHttpClient(configuration: SignalServiceConfiguration): OkHttpClient {
+ val (socketFactory, trustManager) = createTlsSocketFactory(this.trustStore)
+
+ val builder = OkHttpClient.Builder()
+ .sslSocketFactory(socketFactory, trustManager)
+ .connectionSpecs(this.connectionSpecs.orElse(Util.immutableList(ConnectionSpec.RESTRICTED_TLS)))
+ .retryOnConnectionFailure(false)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .connectTimeout(30, TimeUnit.SECONDS)
+
+ for (interceptor in configuration.networkInterceptors) {
+ builder.addInterceptor(interceptor)
+ }
+
+ if (configuration.signalProxy.isPresent) {
+ val proxy = configuration.signalProxy.get()
+ builder.socketFactory(TlsProxySocketFactory(proxy.host, proxy.port, configuration.dns))
+ }
+
+ return builder.build()
+}
+
+private fun createTlsSocketFactory(trustStore: TrustStore): Pair {
+ return try {
+ val context = SSLContext.getInstance("TLS")
+ val trustManagers = BlacklistingTrustManager.createFor(trustStore)
+ context.init(null, trustManagers, null)
+ Tls12SocketFactory(context.socketFactory) to trustManagers[0] as X509TrustManager
+ } catch (e: NoSuchAlgorithmException) {
+ throw AssertionError(e)
+ } catch (e: KeyManagementException) {
+ throw AssertionError(e)
+ }
+}
diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/ProvisioningSocket.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/ProvisioningSocket.kt
new file mode 100644
index 0000000000..3256a401c3
--- /dev/null
+++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/ProvisioningSocket.kt
@@ -0,0 +1,270 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.signalservice.api.registration
+
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.plus
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+import okio.ByteString
+import okio.ByteString.Companion.toByteString
+import org.signal.core.util.Base64
+import org.signal.core.util.logging.Log
+import org.signal.libsignal.protocol.IdentityKeyPair
+import org.signal.registration.proto.RegistrationProvisionEnvelope
+import org.whispersystems.signalservice.api.buildOkHttpClient
+import org.whispersystems.signalservice.api.chooseUrl
+import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
+import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
+import org.whispersystems.signalservice.internal.push.ProvisioningAddress
+import org.whispersystems.signalservice.internal.websocket.WebSocketMessage
+import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
+import org.whispersystems.signalservice.internal.websocket.WebSocketResponseMessage
+import java.io.Closeable
+import java.io.IOException
+import java.net.SocketTimeoutException
+import java.net.URLEncoder
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * A provisional web socket for communicating with a primary device during registration.
+ */
+class ProvisioningSocket private constructor(
+ identityKeyPair: IdentityKeyPair,
+ configuration: SignalServiceConfiguration,
+ private val scope: CoroutineScope
+) {
+ companion object {
+ private val TAG = Log.tag(ProvisioningSocket::class)
+
+ fun start(
+ identityKeyPair: IdentityKeyPair,
+ configuration: SignalServiceConfiguration,
+ handler: CoroutineExceptionHandler,
+ block: suspend CoroutineScope.(ProvisioningSocket) -> Unit
+ ): Closeable {
+ val scope = CoroutineScope(Dispatchers.IO) + SupervisorJob() + handler
+
+ scope.launch {
+ var socket: ProvisioningSocket? = null
+ try {
+ socket = ProvisioningSocket(identityKeyPair, configuration, scope)
+ socket.connect()
+ block(socket)
+ } catch (e: CancellationException) {
+ val rootCause = e.getRootCause()
+ if (rootCause == null) {
+ Log.i(TAG, "Scope canceled expectedly, fail silently, ${e.toMinimalString()}")
+ throw e
+ } else {
+ Log.w(TAG, "Unable to maintain web socket, ${rootCause.toMinimalString()}", rootCause)
+ throw rootCause
+ }
+ } finally {
+ Log.d(TAG, "Closing web socket")
+ socket?.close()
+ }
+ }
+
+ return Closeable { scope.cancel("scope closed") }
+ }
+
+ /**
+ * Get non-cancellation exception cause to determine if something legitimately failed.
+ */
+ private fun CancellationException.getRootCause(): Throwable? {
+ var cause: Throwable? = cause
+ while (cause != null && cause is CancellationException) {
+ cause = cause.cause
+ }
+ return cause
+ }
+
+ /**
+ * Generates a minimal throwable informational string since stack traces aren't always logged.
+ */
+ private fun Throwable.toMinimalString(): String {
+ return "${javaClass.simpleName}[$message]"
+ }
+ }
+
+ private val serviceUrl = configuration.signalServiceUrls.chooseUrl()
+ private val okhttp = serviceUrl.buildOkHttpClient(configuration)
+
+ private val cipher = SecondaryProvisioningCipher(identityKeyPair)
+ private var webSocket: WebSocket? = null
+
+ private val provisioningUrlDeferral: CompletableDeferred = CompletableDeferred()
+ private val provisioningMessageDeferral: CompletableDeferred = CompletableDeferred()
+
+ suspend fun getProvisioningUrl(): String {
+ return provisioningUrlDeferral.await()
+ }
+
+ suspend fun getRegistrationProvisioningMessage(): SecondaryProvisioningCipher.RegistrationProvisionResult {
+ return provisioningMessageDeferral.await()
+ }
+
+ private fun connect() {
+ val uri = serviceUrl.url.replace("https://", "wss://").replace("http://", "ws://")
+
+ val openRequest = Request.Builder()
+ .url("$uri/v1/websocket/provisioning/")
+
+ if (serviceUrl.hostHeader.isPresent) {
+ openRequest.addHeader("Host", serviceUrl.hostHeader.get())
+ Log.w(TAG, "Using alternate host: ${serviceUrl.hostHeader.get()}")
+ }
+
+ webSocket = okhttp.newWebSocket(openRequest.build(), ProvisioningWebSocketListener())
+ }
+
+ private fun close() {
+ webSocket?.close(1000, "Manual shutdown")
+ }
+
+ private inner class ProvisioningWebSocketListener : WebSocketListener() {
+ private var keepAliveJob: Job? = null
+
+ @Volatile
+ private var lastKeepAliveId: Long = 0
+
+ override fun onOpen(webSocket: WebSocket, response: Response) {
+ Log.d(TAG, "[onOpen]")
+ keepAliveJob = scope.launch { keepAlive(webSocket) }
+
+ val timeoutJob = scope.launch {
+ delay(10.seconds)
+ scope.cancel("Did not receive device id within 10 seconds", SocketTimeoutException("No device id received"))
+ }
+
+ scope.launch {
+ provisioningUrlDeferral.await()
+ timeoutJob.cancel()
+ }
+ }
+
+ override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
+ val message: WebSocketMessage = WebSocketMessage.ADAPTER.decode(bytes)
+
+ if (message.response != null && message.response.id == lastKeepAliveId) {
+ Log.d(TAG, "[onMessage] Keep alive received")
+ return
+ }
+
+ if (message.request == null) {
+ Log.w(TAG, "[onMessage] Received null request")
+ return
+ }
+
+ val success = webSocket.send(message.request.toResponse().encode().toByteString())
+
+ if (!success) {
+ Log.w(TAG, "[onMessage] Failed to send response")
+ webSocket.close(1000, "OK")
+ return
+ }
+
+ Log.d(TAG, "[onMessage] Processing request")
+
+ if (message.request.verb == "PUT" && message.request.body != null) {
+ when (message.request.path) {
+ "/v1/address" -> {
+ val address = ProvisioningAddress.ADAPTER.decode(message.request.body).address
+ if (address != null) {
+ provisioningUrlDeferral.complete(generateProvisioningUrl(address))
+ } else {
+ throw IOException("Device address is null")
+ }
+ }
+
+ "/v1/message" -> {
+ val result = cipher.decrypt(RegistrationProvisionEnvelope.ADAPTER.decode(message.request.body))
+ provisioningMessageDeferral.complete(result)
+ }
+
+ else -> Log.w(TAG, "Unknown path requested")
+ }
+ } else {
+ Log.w(TAG, "Invalid data")
+ }
+ }
+
+ override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
+ scope.launch {
+ Log.i(TAG, "[onClosing] code: $code reason: $reason")
+
+ if (code != 1000) {
+ Log.w(TAG, "Remote side is closing with non-normal code $code")
+ webSocket.close(1000, "Remote closed with code $code")
+ }
+
+ scope.cancel()
+ }
+ }
+
+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
+ scope.launch {
+ Log.w(TAG, "[onFailure] Failed", t)
+ webSocket.close(1000, "Failed ${t.message}")
+
+ scope.cancel(CancellationException("WebSocket Failure", t))
+ }
+ }
+
+ private fun generateProvisioningUrl(deviceAddress: String): String {
+ val encodedDeviceId = URLEncoder.encode(deviceAddress, "UTF-8")
+ val encodedPubKey: String = URLEncoder.encode(Base64.encodeWithoutPadding(cipher.secondaryDevicePublicKey.serialize()), "UTF-8")
+ return "sgnl://rereg?uuid=$encodedDeviceId&pub_key=$encodedPubKey"
+ }
+
+ private suspend fun keepAlive(webSocket: WebSocket) {
+ Log.i(TAG, "[keepAlive] Starting")
+ while (true) {
+ delay(30.seconds)
+ Log.i(TAG, "[keepAlive] Sending...")
+
+ val id = System.currentTimeMillis()
+ val message = WebSocketMessage(
+ type = WebSocketMessage.Type.REQUEST,
+ request = WebSocketRequestMessage(
+ id = id,
+ path = "/v1/keepalive",
+ verb = "GET"
+ )
+ )
+
+ if (!webSocket.send(message.encodeByteString())) {
+ Log.w(TAG, "[keepAlive] Send failed")
+ } else {
+ lastKeepAliveId = id
+ }
+ }
+ }
+
+ private fun WebSocketRequestMessage.toResponse(): WebSocketMessage {
+ return WebSocketMessage(
+ type = WebSocketMessage.Type.RESPONSE,
+ response = WebSocketResponseMessage(
+ id = id,
+ status = 200,
+ message = "OK"
+ )
+ )
+ }
+ }
+}
diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt
index 954174ccc9..f65bbdf816 100644
--- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt
+++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/registration/RegistrationApi.kt
@@ -5,11 +5,14 @@
package org.whispersystems.signalservice.api.registration
+import org.signal.libsignal.protocol.ecc.ECPublicKey
+import org.signal.registration.proto.RegistrationProvisionMessage
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.account.AccountAttributes
import org.whispersystems.signalservice.api.account.ChangePhoneNumberRequest
import org.whispersystems.signalservice.api.account.PniKeyDistributionRequest
import org.whispersystems.signalservice.api.account.PreKeyCollection
+import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher
import org.whispersystems.signalservice.internal.push.BackupV2AuthCheckResponse
import org.whispersystems.signalservice.internal.push.BackupV3AuthCheckResponse
import org.whispersystems.signalservice.internal.push.PushServiceSocket
@@ -142,4 +145,20 @@ class RegistrationApi(
pushServiceSocket.distributePniKeys(requestBody)
}
}
+
+ /**
+ * Encrypts and sends the [RegistrationProvisionMessage] from the current primary (old device) to the new device over
+ * the provisioning web socket identified by [deviceIdentifier].
+ */
+ fun sendReRegisterDeviceProvisioningMessage(
+ deviceIdentifier: String,
+ deviceKey: ECPublicKey,
+ registrationProvisionMessage: RegistrationProvisionMessage
+ ): NetworkResult {
+ val cipherText = PrimaryProvisioningCipher(deviceKey).encrypt(registrationProvisionMessage)
+
+ return NetworkResult.fromFetch {
+ pushServiceSocket.sendProvisioningMessage(deviceIdentifier, cipherText)
+ }
+ }
}
diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/svr/Svr2Socket.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/svr/Svr2Socket.kt
index 6ae409e0a1..e728783bdf 100644
--- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/svr/Svr2Socket.kt
+++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/svr/Svr2Socket.kt
@@ -1,6 +1,5 @@
package org.whispersystems.signalservice.api.svr
-import okhttp3.ConnectionSpec
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
@@ -9,30 +8,19 @@ import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.signal.libsignal.attest.AttestationDataException
import org.signal.libsignal.protocol.logging.Log
-import org.signal.libsignal.protocol.util.Pair
import org.signal.libsignal.sgxsession.SgxCommunicationFailureException
import org.signal.libsignal.svr2.Svr2Client
-import org.whispersystems.signalservice.api.push.TrustStore
+import org.whispersystems.signalservice.api.buildOkHttpClient
+import org.whispersystems.signalservice.api.chooseUrl
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
-import org.whispersystems.signalservice.api.util.Tls12SocketFactory
-import org.whispersystems.signalservice.api.util.TlsProxySocketFactory
import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration
import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url
import org.whispersystems.signalservice.internal.push.AuthCredentials
-import org.whispersystems.signalservice.internal.util.BlacklistingTrustManager
import org.whispersystems.signalservice.internal.util.Hex
-import org.whispersystems.signalservice.internal.util.Util
import java.io.IOException
-import java.security.KeyManagementException
-import java.security.NoSuchAlgorithmException
import java.time.Instant
import java.util.concurrent.CountDownLatch
-import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
-import javax.net.ssl.SSLContext
-import javax.net.ssl.SSLSocketFactory
-import javax.net.ssl.X509TrustManager
-import kotlin.jvm.Throws
import okhttp3.Response as OkHttpResponse
import org.signal.svr2.proto.Request as Svr2Request
import org.signal.svr2.proto.Response as Svr2Response
@@ -44,8 +32,8 @@ internal class Svr2Socket(
configuration: SignalServiceConfiguration,
private val mrEnclave: String
) {
- private val svr2Url: SignalSvr2Url = chooseUrl(configuration.signalSvr2Urls)
- private val okhttp: OkHttpClient = buildOkHttpClient(configuration, svr2Url)
+ private val svr2Url: SignalSvr2Url = configuration.signalSvr2Urls.chooseUrl()
+ private val okhttp: OkHttpClient = svr2Url.buildOkHttpClient(configuration)
@Throws(IOException::class)
fun makeRequest(authorization: AuthCredentials, clientRequest: Svr2Request): Svr2Response {
@@ -212,43 +200,5 @@ internal class Svr2Socket(
companion object {
private val TAG = Svr2Socket::class.java.simpleName
-
- private fun buildOkHttpClient(configuration: SignalServiceConfiguration, svr2Url: SignalSvr2Url): OkHttpClient {
- val socketFactory = createTlsSocketFactory(svr2Url.trustStore)
- val builder = OkHttpClient.Builder()
- .sslSocketFactory(Tls12SocketFactory(socketFactory.first()), socketFactory.second())
- .connectionSpecs(svr2Url.connectionSpecs.orElse(Util.immutableList(ConnectionSpec.RESTRICTED_TLS)))
- .retryOnConnectionFailure(false)
- .readTimeout(30, TimeUnit.SECONDS)
- .connectTimeout(30, TimeUnit.SECONDS)
-
- for (interceptor in configuration.networkInterceptors) {
- builder.addInterceptor(interceptor)
- }
-
- if (configuration.signalProxy.isPresent) {
- val proxy = configuration.signalProxy.get()
- builder.socketFactory(TlsProxySocketFactory(proxy.host, proxy.port, configuration.dns))
- }
-
- return builder.build()
- }
-
- private fun createTlsSocketFactory(trustStore: TrustStore): Pair {
- return try {
- val context = SSLContext.getInstance("TLS")
- val trustManagers = BlacklistingTrustManager.createFor(trustStore)
- context.init(null, trustManagers, null)
- Pair(context.socketFactory, trustManagers[0] as X509TrustManager)
- } catch (e: NoSuchAlgorithmException) {
- throw AssertionError(e)
- } catch (e: KeyManagementException) {
- throw AssertionError(e)
- }
- }
-
- private fun chooseUrl(urls: Array): SignalSvr2Url {
- return urls[(Math.random() * urls.size).toInt()]
- }
}
}
diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/PrimaryProvisioningCipher.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/PrimaryProvisioningCipher.java
index fd9acbf063..2ff6e03052 100644
--- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/PrimaryProvisioningCipher.java
+++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/PrimaryProvisioningCipher.java
@@ -11,6 +11,8 @@ import org.signal.libsignal.protocol.ecc.Curve;
import org.signal.libsignal.protocol.ecc.ECKeyPair;
import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.signal.libsignal.protocol.kdf.HKDF;
+import org.signal.registration.proto.RegistrationProvisionEnvelope;
+import org.signal.registration.proto.RegistrationProvisionMessage;
import org.whispersystems.signalservice.internal.push.ProvisionEnvelope;
import org.whispersystems.signalservice.internal.push.ProvisionMessage;
import org.whispersystems.signalservice.internal.util.Util;
@@ -54,6 +56,24 @@ public class PrimaryProvisioningCipher {
.encode();
}
+ public byte[] encrypt(RegistrationProvisionMessage message) throws InvalidKeyException {
+ ECKeyPair ourKeyPair = Curve.generateKeyPair();
+ byte[] sharedSecret = Curve.calculateAgreement(theirPublicKey, ourKeyPair.getPrivateKey());
+ byte[] derivedSecret = HKDF.deriveSecrets(sharedSecret, PROVISIONING_MESSAGE.getBytes(), 64);
+ byte[][] parts = Util.split(derivedSecret, 32, 32);
+
+ byte[] version = { 0x00 };
+ byte[] ciphertext = getCiphertext(parts[0], message.encode());
+ byte[] mac = getMac(parts[1], Util.join(version, ciphertext));
+ byte[] body = Util.join(version, ciphertext, mac);
+
+ return new RegistrationProvisionEnvelope.Builder()
+ .publicKey(ByteString.of(ourKeyPair.getPublicKey().serialize()))
+ .body(ByteString.of(body))
+ .build()
+ .encode();
+ }
+
private byte[] getCiphertext(byte[] key, byte[] message) {
try {
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/secondary/SecondaryProvisioningCipher.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipher.kt
similarity index 64%
rename from app/src/main/java/org/thoughtcrime/securesms/registration/secondary/SecondaryProvisioningCipher.kt
rename to libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipher.kt
index 7736703495..bcadec20dd 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/secondary/SecondaryProvisioningCipher.kt
+++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipher.kt
@@ -1,14 +1,20 @@
-package org.thoughtcrime.securesms.registration.secondary
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+package org.whispersystems.signalservice.internal.crypto
+
+import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ecc.Curve
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.libsignal.protocol.kdf.HKDF
import org.signal.libsignal.zkgroup.profiles.ProfileKey
-import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
+import org.signal.registration.proto.RegistrationProvisionEnvelope
+import org.signal.registration.proto.RegistrationProvisionMessage
import org.whispersystems.signalservice.api.util.UuidUtil
-import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher
import org.whispersystems.signalservice.internal.push.ProvisionEnvelope
import org.whispersystems.signalservice.internal.push.ProvisionMessage
import java.security.InvalidKeyException
@@ -23,23 +29,69 @@ import javax.crypto.spec.SecretKeySpec
/**
* Used to decrypt a secondary/link device provisioning message from the primary device.
*/
-class SecondaryProvisioningCipher private constructor(private val secondaryIdentityKeyPair: IdentityKeyPair) {
+class SecondaryProvisioningCipher(private val secondaryIdentityKeyPair: IdentityKeyPair) {
+
+ companion object {
+ private val TAG = Log.tag(SecondaryProvisioningCipher::class)
+
+ private const val VERSION_LENGTH = 1
+ private const val IV_LENGTH = 16
+ private const val MAC_LENGTH = 32
+
+ fun generate(identityKeyPair: IdentityKeyPair): SecondaryProvisioningCipher {
+ return SecondaryProvisioningCipher(identityKeyPair)
+ }
+ }
val secondaryDevicePublicKey: IdentityKey = secondaryIdentityKeyPair.publicKey
fun decrypt(envelope: ProvisionEnvelope): ProvisionDecryptResult {
- val primaryEphemeralPublicKey = envelope.publicKey!!.toByteArray()
- val body = envelope.body!!.toByteArray()
+ val plaintext = decrypt(expectedVersion = 1, primaryEphemeralPublicKey = envelope.publicKey!!.toByteArray(), body = envelope.body!!.toByteArray())
- val provisionMessageLength = body.size - VERSION_LENGTH - IV_LENGTH - MAC_LENGTH
-
- if (provisionMessageLength <= 0) {
+ if (plaintext == null) {
+ Log.w(TAG, "Plaintext is null")
return ProvisionDecryptResult.Error
}
+ val provisioningMessage = ProvisionMessage.ADAPTER.decode(plaintext)
+
+ return ProvisionDecryptResult.Success(
+ uuid = UuidUtil.parseOrThrow(provisioningMessage.aci),
+ e164 = provisioningMessage.number!!,
+ identityKeyPair = IdentityKeyPair(IdentityKey(provisioningMessage.aciIdentityKeyPublic!!.toByteArray()), Curve.decodePrivatePoint(provisioningMessage.aciIdentityKeyPrivate!!.toByteArray())),
+ profileKey = ProfileKey(provisioningMessage.profileKey!!.toByteArray()),
+ areReadReceiptsEnabled = provisioningMessage.readReceipts == true,
+ primaryUserAgent = provisioningMessage.userAgent,
+ provisioningCode = provisioningMessage.provisioningCode!!,
+ provisioningVersion = provisioningMessage.provisioningVersion!!
+ )
+ }
+
+ fun decrypt(envelope: RegistrationProvisionEnvelope): RegistrationProvisionResult {
+ val plaintext = decrypt(expectedVersion = 0, primaryEphemeralPublicKey = envelope.publicKey.toByteArray(), body = envelope.body.toByteArray())
+
+ if (plaintext == null) {
+ Log.w(TAG, "Plaintext is null")
+ return RegistrationProvisionResult.Error
+ }
+
+ val provisioningMessage = RegistrationProvisionMessage.ADAPTER.decode(plaintext)
+
+ return RegistrationProvisionResult.Success(provisioningMessage)
+ }
+
+ private fun decrypt(expectedVersion: Int, primaryEphemeralPublicKey: ByteArray, body: ByteArray): ByteArray? {
+ val provisionMessageLength = body.size - VERSION_LENGTH - IV_LENGTH - MAC_LENGTH
+
+ if (provisionMessageLength <= 0) {
+ Log.w(TAG, "Provisioning message length invalid")
+ return null
+ }
+
val version = body[0].toInt()
- if (version != 1) {
- return ProvisionDecryptResult.Error
+ if (version != expectedVersion) {
+ Log.w(TAG, "Version does not match expected, expected $expectedVersion but was $version")
+ return null
}
val iv = body.sliceArray(1 until (1 + IV_LENGTH))
@@ -56,27 +108,16 @@ class SecondaryProvisioningCipher private constructor(private val secondaryIdent
val ourHmac = getMac(macKey, message)
if (!MessageDigest.isEqual(theirMac, ourHmac)) {
- return ProvisionDecryptResult.Error
+ Log.w(TAG, "Macs do not match")
+ return null
}
- val plaintext = try {
+ return try {
getPlaintext(cipherKey, iv, cipherText)
} catch (e: Exception) {
- return ProvisionDecryptResult.Error
+ Log.w(TAG, "Unable to get plaintext", e)
+ return null
}
-
- val provisioningMessage = ProvisionMessage.ADAPTER.decode(plaintext)
-
- return ProvisionDecryptResult.Success(
- uuid = UuidUtil.parseOrThrow(provisioningMessage.aci),
- e164 = provisioningMessage.number!!,
- identityKeyPair = IdentityKeyPair(IdentityKey(provisioningMessage.aciIdentityKeyPublic!!.toByteArray()), Curve.decodePrivatePoint(provisioningMessage.aciIdentityKeyPrivate!!.toByteArray())),
- profileKey = ProfileKey(provisioningMessage.profileKey!!.toByteArray()),
- areReadReceiptsEnabled = provisioningMessage.readReceipts == true,
- primaryUserAgent = provisioningMessage.userAgent,
- provisioningCode = provisioningMessage.provisioningCode!!,
- provisioningVersion = provisioningMessage.provisioningVersion!!
- )
}
private fun getMac(key: ByteArray, message: ByteArray): ByteArray? {
@@ -97,18 +138,8 @@ class SecondaryProvisioningCipher private constructor(private val secondaryIdent
return cipher.doFinal(message)
}
- companion object {
- private const val VERSION_LENGTH = 1
- private const val IV_LENGTH = 16
- private const val MAC_LENGTH = 32
-
- fun generate(): SecondaryProvisioningCipher {
- return SecondaryProvisioningCipher(IdentityKeyUtil.generateIdentityKeyPair())
- }
- }
-
- sealed class ProvisionDecryptResult {
- object Error : ProvisionDecryptResult()
+ sealed interface ProvisionDecryptResult {
+ data object Error : ProvisionDecryptResult
data class Success(
val uuid: UUID,
@@ -119,6 +150,11 @@ class SecondaryProvisioningCipher private constructor(private val secondaryIdent
val primaryUserAgent: String?,
val provisioningCode: String,
val provisioningVersion: Int
- ) : ProvisionDecryptResult()
+ ) : ProvisionDecryptResult
+ }
+
+ sealed interface RegistrationProvisionResult {
+ data object Error : RegistrationProvisionResult
+ data class Success(val message: RegistrationProvisionMessage) : RegistrationProvisionResult
}
}
diff --git a/libsignal-service/src/main/protowire/Provisioning.proto b/libsignal-service/src/main/protowire/Provisioning.proto
index 03fb0085e8..e18829a66e 100644
--- a/libsignal-service/src/main/protowire/Provisioning.proto
+++ b/libsignal-service/src/main/protowire/Provisioning.proto
@@ -10,8 +10,8 @@ package signalservice;
option java_package = "org.whispersystems.signalservice.internal.push";
option java_outer_classname = "ProvisioningProtos";
-message ProvisioningUuid {
- optional string uuid = 1;
+message ProvisioningAddress {
+ optional string address = 1;
}
message ProvisionEnvelope {
diff --git a/libsignal-service/src/main/protowire/RegistrationProvisioning.proto b/libsignal-service/src/main/protowire/RegistrationProvisioning.proto
new file mode 100644
index 0000000000..3efcb3ba47
--- /dev/null
+++ b/libsignal-service/src/main/protowire/RegistrationProvisioning.proto
@@ -0,0 +1,30 @@
+syntax = "proto3";
+
+option java_multiple_files = true;
+option java_package = "org.signal.registration.proto";
+
+message RegistrationProvisionEnvelope {
+ bytes publicKey = 1;
+ bytes body = 2; // Encrypted RegistrationProvisionMessage
+}
+
+message RegistrationProvisionMessage {
+ enum Platform {
+ ANDROID = 0;
+ IOS = 1;
+ }
+
+ enum Tier {
+ FREE = 0;
+ PAID = 1;
+ }
+
+ string e164 = 1;
+ bytes aci = 2;
+ string accountEntropyPool = 3;
+ string pin = 4;
+ Platform platform = 5;
+ uint64 backupTimestampMs = 6;
+ Tier tier = 7;
+ reserved 8; // iOSDeviceTransferMessage
+}
diff --git a/app/src/test/java/org/thoughtcrime/securesms/registration/secondary/SecondaryProvisioningCipherTest.kt b/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipherTest.kt
similarity index 69%
rename from app/src/test/java/org/thoughtcrime/securesms/registration/secondary/SecondaryProvisioningCipherTest.kt
rename to libsignal-service/src/test/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipherTest.kt
index 22b0e56b13..45ee4bae2c 100644
--- a/app/src/test/java/org/thoughtcrime/securesms/registration/secondary/SecondaryProvisioningCipherTest.kt
+++ b/libsignal-service/src/test/java/org/whispersystems/signalservice/internal/crypto/SecondaryProvisioningCipherTest.kt
@@ -1,26 +1,33 @@
-package org.thoughtcrime.securesms.registration.secondary
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.whispersystems.signalservice.internal.crypto
import okio.ByteString
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.instanceOf
import org.hamcrest.Matchers.`is`
import org.junit.Test
-import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
-import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
-import org.whispersystems.signalservice.internal.crypto.PrimaryProvisioningCipher
+import org.signal.libsignal.protocol.IdentityKey
+import org.signal.libsignal.protocol.IdentityKeyPair
+import org.signal.libsignal.protocol.ecc.Curve
+import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.whispersystems.signalservice.internal.push.ProvisionEnvelope
import org.whispersystems.signalservice.internal.push.ProvisionMessage
import org.whispersystems.signalservice.internal.push.ProvisioningVersion
import java.util.UUID
+import kotlin.random.Random
class SecondaryProvisioningCipherTest {
@Test
fun decrypt() {
- val provisioningCipher = SecondaryProvisioningCipher.generate()
+ val provisioningCipher = SecondaryProvisioningCipher.generate(generateIdentityKeyPair())
- val primaryIdentityKeyPair = IdentityKeyUtil.generateIdentityKeyPair()
- val primaryProfileKey = ProfileKeyUtil.createNew()
+ val primaryIdentityKeyPair = generateIdentityKeyPair()
+ val primaryProfileKey = generateProfileKey()
val primaryProvisioningCipher = PrimaryProvisioningCipher(provisioningCipher.secondaryDevicePublicKey.publicKey)
val message = ProvisionMessage(
@@ -50,4 +57,18 @@ class SecondaryProvisioningCipherTest {
assertThat(success.provisioningCode, `is`(message.provisioningCode))
assertThat(success.provisioningVersion, `is`(message.provisioningVersion))
}
+
+ companion object {
+ fun generateIdentityKeyPair(): IdentityKeyPair {
+ val djbKeyPair = Curve.generateKeyPair()
+ val djbIdentityKey = IdentityKey(djbKeyPair.publicKey)
+ val djbPrivateKey = djbKeyPair.privateKey
+
+ return IdentityKeyPair(djbIdentityKey, djbPrivateKey)
+ }
+
+ fun generateProfileKey(): ProfileKey {
+ return ProfileKey(Random.nextBytes(32))
+ }
+ }
}