diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 63b922b710..0f55e9c3a6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -844,6 +844,13 @@
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:exported="false"/>
+
+
protected void closeNavGraphBranch() {
Intent activityIntent = requireActivity().getIntent();
- if (activityIntent != null && activityIntent.hasExtra("next_intent")) {
- startActivity(activityIntent.getParcelableExtra("next_intent"));
+ if (activityIntent != null && activityIntent.hasExtra(PassphraseRequiredActivity.NEXT_INTENT_EXTRA)) {
+ final Intent nextIntent = activityIntent.getParcelableExtra(PassphraseRequiredActivity.NEXT_INTENT_EXTRA);
+ if (nextIntent != null) {
+ startActivity(nextIntent);
+ }
}
requireActivity().finish();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/RegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/RegistrationRepository.kt
index a1f583103e..0307823580 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/RegistrationRepository.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/data/RegistrationRepository.kt
@@ -32,7 +32,7 @@ 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.SvrRepository.onRegistrationComplete
+import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.push.AccountManagerFactory
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -188,7 +188,7 @@ object RegistrationRepository {
TextSecurePreferences.setUnauthorizedReceived(context, false)
NotificationManagerCompat.from(context).cancel(NotificationIds.UNREGISTERED_NOTIFICATION_ID)
- onRegistrationComplete(response.masterKey, response.pin, hasPin, reglockEnabled)
+ SvrRepository.onRegistrationComplete(response.masterKey, response.pin, hasPin, reglockEnabled)
ApplicationDependencies.closeConnections()
ApplicationDependencies.getIncomingMessageObserver()
@@ -335,7 +335,11 @@ object RegistrationRepository {
val eventBus = EventBus.getDefault()
eventBus.register(subscriber)
- val sessionCreationResponse = accountManager.createRegistrationSession(fcmToken, mcc, mnc).successOrThrow() // TODO: error handling
+ 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)
@@ -343,7 +347,7 @@ object RegistrationRepository {
val challenge = subscriber.challenge
if (challenge != null) {
Log.w(TAG, "Push challenge token received.")
- return@withContext accountManager.submitPushChallengeToken(sessionCreationResponse.body.id, challenge)
+ return@withContext accountManager.submitPushChallengeToken(sessionCreationResponse.result.body.id, challenge)
} else {
Log.w(TAG, "Push received but challenge token was null.")
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2Activity.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2Activity.kt
index 1271a2133b..8592149509 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2Activity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/RegistrationV2Activity.kt
@@ -9,15 +9,15 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
-import androidx.appcompat.app.AppCompatActivity
import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.v2.ui.shared.RegistrationV2ViewModel
/**
* Activity to hold the entire registration process.
*/
-class RegistrationV2Activity : AppCompatActivity() {
+class RegistrationV2Activity : BaseActivity() {
private val TAG = Log.tag(RegistrationV2Activity::class.java)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/entercode/EnterCodeV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/entercode/EnterCodeV2Fragment.kt
index 225504ae13..565f0705b7 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/entercode/EnterCodeV2Fragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/ui/entercode/EnterCodeV2Fragment.kt
@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterCodeV2Binding
+import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity
@@ -91,9 +92,12 @@ class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter
Log.i(TAG, "Pin restore flow not required. Profile name: $isProfileNameEmpty | Profile avatar: $isAvatarEmpty | Needs PIN: $needsPin")
+ SignalStore.internalValues().setForceEnterRestoreV2Flow(true)
+
if (!needsProfile && !needsPin) {
sharedViewModel.completeRegistration()
}
+ sharedViewModel.setInProgress(false)
val startIntent = MainActivity.clearTop(activity).apply {
if (needsPin) {
@@ -105,8 +109,8 @@ class EnterCodeV2Fragment : LoggingFragment(R.layout.fragment_registration_enter
}
}
+ Log.d(TAG, "Launching ${startIntent.component}")
activity.startActivity(startIntent)
- sharedViewModel.setInProgress(false)
activity.finish()
ActivityNavigator.applyPopAnimationsToPendingTransition(activity)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt
new file mode 100644
index 0000000000..e28896c2df
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreActivity.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.restore
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.viewModels
+import org.signal.core.util.getParcelableExtraCompat
+import org.thoughtcrime.securesms.BaseActivity
+import org.thoughtcrime.securesms.PassphraseRequiredActivity
+import org.thoughtcrime.securesms.R
+
+/**
+ * Activity to hold the restore from backup flow.
+ */
+class RestoreActivity : BaseActivity() {
+
+ private val sharedViewModel: RestoreViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_restore)
+ intent.getParcelableExtraCompat(PassphraseRequiredActivity.NEXT_INTENT_EXTRA, Intent::class.java)?.let {
+ sharedViewModel.setNextIntent(it)
+ }
+ }
+
+ companion object {
+ @JvmStatic
+ fun getIntentForRestore(context: Context): Intent {
+ return Intent(context, RestoreActivity::class.java)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreRepository.kt
new file mode 100644
index 0000000000..a919d0a279
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreRepository.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.restore
+
+import android.content.Context
+import android.net.Uri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.AppInitialization
+import org.thoughtcrime.securesms.backup.BackupPassphrase
+import org.thoughtcrime.securesms.backup.FullBackupImporter
+import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
+import org.thoughtcrime.securesms.database.SignalDatabase
+import org.thoughtcrime.securesms.jobmanager.impl.DataRestoreConstraint
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.notifications.NotificationChannels
+import org.thoughtcrime.securesms.service.LocalBackupListener
+import org.thoughtcrime.securesms.util.BackupUtil
+import java.io.IOException
+
+/**
+ * Repository to handle restoring a backup of a user's message history.
+ */
+object RestoreRepository {
+ private val TAG = Log.tag(RestoreRepository.javaClass)
+
+ suspend fun getLocalBackupFromUri(context: Context, uri: Uri): BackupUtil.BackupInfo? = withContext(Dispatchers.IO) {
+ BackupUtil.getBackupInfoFromSingleUri(context, uri)
+ }
+
+ suspend fun restoreBackupAsynchronously(context: Context, backupFileUri: Uri, passphrase: String): BackupImportResult = withContext(Dispatchers.IO) {
+ // TODO [regv2]: migrate this to a service
+ try {
+ Log.i(TAG, "Starting backup restore.")
+ DataRestoreConstraint.isRestoringData = true
+
+ val database = SignalDatabase.backupDatabase
+
+ BackupPassphrase.set(context, passphrase)
+
+ if (!FullBackupImporter.validatePassphrase(context, backupFileUri, passphrase)) {
+ // TODO [regv2]: implement a specific, user-visible error for wrong passphrase.
+ return@withContext BackupImportResult.FAILURE_UNKNOWN
+ }
+
+ FullBackupImporter.importFile(
+ context,
+ AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
+ database,
+ backupFileUri,
+ passphrase
+ )
+
+ SignalDatabase.runPostBackupRestoreTasks(database)
+ NotificationChannels.getInstance().restoreContactNotificationChannels()
+
+ if (BackupUtil.canUserAccessBackupDirectory(context)) {
+ LocalBackupListener.setNextBackupTimeToIntervalFromNow(context)
+ SignalStore.settings().isBackupEnabled = true
+ LocalBackupListener.schedule(context)
+ }
+
+ AppInitialization.onPostBackupRestore(context)
+
+ Log.i(TAG, "Backup restore complete.")
+ return@withContext BackupImportResult.SUCCESS
+ } catch (e: FullBackupImporter.DatabaseDowngradeException) {
+ Log.w(TAG, "Failed due to the backup being from a newer version of Signal.", e)
+ return@withContext BackupImportResult.FAILURE_VERSION_DOWNGRADE
+ } catch (e: FullBackupImporter.ForeignKeyViolationException) {
+ Log.w(TAG, "Failed due to foreign key constraint violations.", e)
+ return@withContext BackupImportResult.FAILURE_FOREIGN_KEY
+ } catch (e: IOException) {
+ Log.w(TAG, e)
+ return@withContext BackupImportResult.FAILURE_UNKNOWN
+ } finally {
+ DataRestoreConstraint.isRestoringData = false
+ }
+ }
+
+ enum class BackupImportResult {
+ SUCCESS,
+ FAILURE_VERSION_DOWNGRADE,
+ FAILURE_FOREIGN_KEY,
+ FAILURE_UNKNOWN
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreState.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreState.kt
new file mode 100644
index 0000000000..e2df0cf003
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreState.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.restore
+
+import android.content.Intent
+import android.net.Uri
+import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType
+
+/**
+ * Shared state holder for the restore flow.
+ */
+data class RestoreState(val restorationType: BackupRestorationType = BackupRestorationType.LOCAL_BACKUP, val backupFile: Uri? = null, val nextIntent: Intent? = null)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt
new file mode 100644
index 0000000000..c75adbd296
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/RestoreViewModel.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.restore
+
+import android.content.Intent
+import android.net.Uri
+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
+
+/**
+ * Shared view model for the restore flow.
+ */
+class RestoreViewModel : ViewModel() {
+ private val store = MutableStateFlow(RestoreState())
+ val uiState = store.asLiveData()
+
+ fun setNextIntent(nextIntent: Intent) {
+ store.update {
+ it.copy(nextIntent = nextIntent)
+ }
+ }
+
+ fun onTransferFromAndroidDeviceSelected() {
+ store.update {
+ it.copy(restorationType = BackupRestorationType.DEVICE_TRANSFER)
+ }
+ }
+
+ fun onRestoreFromLocalBackupSelected() {
+ store.update {
+ it.copy(restorationType = BackupRestorationType.LOCAL_BACKUP)
+ }
+ }
+
+ fun onRestoreFromRemoteBackupSelected() {
+ store.update {
+ it.copy(restorationType = BackupRestorationType.REMOTE_BACKUP)
+ }
+ }
+
+ fun getBackupRestorationType(): BackupRestorationType {
+ return store.value.restorationType
+ }
+
+ fun setBackupFileUri(backupFileUri: Uri) {
+ store.update {
+ it.copy(backupFile = backupFileUri)
+ }
+ }
+
+ fun getBackupFileUri(): Uri? = store.value.backupFile
+
+ fun getNextIntent(): Intent? = store.value.nextIntent
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/choosebackup/ChooseBackupV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/choosebackup/ChooseBackupV2Fragment.kt
new file mode 100644
index 0000000000..51a0caa594
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/choosebackup/ChooseBackupV2Fragment.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.restore.choosebackup
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.provider.DocumentsContract
+import android.text.method.LinkMovementMethod
+import android.view.View
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.text.HtmlCompat
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.NavHostFragment
+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.FragmentChooseBackupV2Binding
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
+import org.thoughtcrime.securesms.restore.RestoreViewModel
+import org.thoughtcrime.securesms.util.navigation.safeNavigate
+
+/**
+ * This fragment presents a button to the user to browse their local file system for a legacy backup file.
+ */
+class ChooseBackupV2Fragment : LoggingFragment(R.layout.fragment_choose_backup_v2) {
+ private val sharedViewModel by activityViewModels()
+ private val binding: FragmentChooseBackupV2Binding by ViewBinderDelegate(FragmentChooseBackupV2Binding::bind)
+
+ private val pickMedia = registerForActivityResult(BackupFileContract()) {
+ if (it != null) {
+ onUserChoseBackupFile(it)
+ } else {
+ Log.i(TAG, "Null URI returned for backup file selection.")
+ }
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ RegistrationViewDelegate.setDebugLogSubmitMultiTapView(binding.chooseBackupFragmentTitle)
+ binding.chooseBackupFragmentButton.setOnClickListener { onChooseBackupSelected() }
+
+ binding.chooseBackupFragmentLearnMore.text = HtmlCompat.fromHtml(String.format("%s", getString(R.string.backup_support_url), getString(R.string.ChooseBackupFragment__learn_more)), 0)
+ binding.chooseBackupFragmentLearnMore.movementMethod = LinkMovementMethod.getInstance()
+ }
+
+ private fun onChooseBackupSelected() {
+ pickMedia.launch("application/octet-stream")
+ }
+
+ private fun onUserChoseBackupFile(backupFileUri: Uri) {
+ sharedViewModel.setBackupFileUri(backupFileUri)
+ NavHostFragment.findNavController(this).safeNavigate(ChooseBackupV2FragmentDirections.actionChooseLocalBackupFragmentToRestoreLocalBackupFragment())
+ }
+
+ private class BackupFileContract : ActivityResultContracts.GetContent() {
+ override fun createIntent(context: Context, input: String): Intent {
+ return super.createIntent(context, input).apply {
+ putExtra(Intent.EXTRA_LOCAL_ONLY, true)
+ if (Build.VERSION.SDK_INT >= 26) {
+ putExtra(DocumentsContract.EXTRA_INITIAL_URI, SignalStore.settings().latestSignalBackupDirectory)
+ }
+ }
+ }
+ }
+
+ companion object {
+ private val TAG = Log.tag(ChooseBackupV2Fragment::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
new file mode 100644
index 0000000000..576ff86fcb
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupFragment.kt
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.restore.restorelocalbackup
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.widget.EditText
+import android.widget.Toast
+import androidx.fragment.app.activityViewModels
+import androidx.fragment.app.viewModels
+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.core.util.logging.Log
+import org.thoughtcrime.securesms.LoggingFragment
+import org.thoughtcrime.securesms.R
+import org.thoughtcrime.securesms.backup.BackupEvent
+import org.thoughtcrime.securesms.components.ViewBinderDelegate
+import org.thoughtcrime.securesms.databinding.FragmentRestoreLocalBackupV2Binding
+import org.thoughtcrime.securesms.keyvalue.SignalStore
+import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
+import org.thoughtcrime.securesms.registration.fragments.RestoreBackupFragment.PassphraseAsYouTypeFormatter
+import org.thoughtcrime.securesms.restore.RestoreRepository
+import org.thoughtcrime.securesms.restore.RestoreViewModel
+import org.thoughtcrime.securesms.util.DateUtils
+import org.thoughtcrime.securesms.util.Util
+import org.thoughtcrime.securesms.util.ViewModelFactory
+import org.thoughtcrime.securesms.util.ViewUtil
+import org.thoughtcrime.securesms.util.visible
+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_v2) {
+ private val navigationViewModel: RestoreViewModel by activityViewModels()
+ private val restoreLocalBackupViewModel: RestoreLocalBackupViewModel by viewModels(
+ factoryProducer = ViewModelFactory.factoryProducer {
+ val fileBackupUri = navigationViewModel.getBackupFileUri()!!
+ RestoreLocalBackupViewModel(fileBackupUri)
+ }
+ )
+ private val binding: FragmentRestoreLocalBackupV2Binding by ViewBinderDelegate(FragmentRestoreLocalBackupV2Binding::bind)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ setDebugLogSubmitMultiTapView(binding.verifyHeader)
+ Log.i(TAG, "Backup restore.")
+
+ restoreLocalBackupViewModel.uiState.observe(viewLifecycleOwner) { fragmentState ->
+ fragmentState.backupInfo?.let {
+ presentBackupFileInfo(backupSize = it.size, backupTimestamp = it.timestamp)
+ if (fragmentState.backupPassphrase.isEmpty()) {
+ presentBackupPassPhrasePromptDialog()
+ }
+ }
+
+ if (fragmentState.restoreInProgress) {
+ presentRestoreProgress(fragmentState.backupProgressCount)
+ } else {
+ presentProgressEnded()
+ }
+
+ if (fragmentState.backupRestoreComplete) {
+ val importResult = fragmentState.backupImportResult
+ if (importResult == null) {
+ onBackupCompletedSuccessfully()
+ } else {
+ handleBackupImportResult(importResult)
+ }
+ }
+ }
+
+ restoreLocalBackupViewModel.startRestore(requireContext())
+ }
+
+ private fun onBackupCompletedSuccessfully() {
+ Log.d(TAG, "onBackupCompletedSuccessfully()")
+ SignalStore.internalValues().setForceEnterRestoreV2Flow(false)
+ val activity = requireActivity()
+ navigationViewModel.getNextIntent()?.let {
+ Log.d(TAG, "Launching ${it.component}")
+ activity.startActivity(it)
+ }
+ activity.finish()
+ }
+
+ override fun onStart() {
+ super.onStart()
+ EventBus.getDefault().register(this)
+ }
+
+ override fun onStop() {
+ super.onStop()
+ EventBus.getDefault().unregister(this)
+ }
+
+ @Subscribe(threadMode = ThreadMode.MAIN)
+ fun onEvent(event: BackupEvent) {
+ restoreLocalBackupViewModel.onBackupProgressUpdate(event)
+ }
+
+ private fun handleBackupImportResult(importResult: RestoreRepository.BackupImportResult) {
+ when (importResult) {
+ RestoreRepository.BackupImportResult.FAILURE_VERSION_DOWNGRADE -> Toast.makeText(requireContext(), R.string.RegistrationActivity_backup_failure_downgrade, Toast.LENGTH_LONG).show()
+ RestoreRepository.BackupImportResult.FAILURE_FOREIGN_KEY -> Toast.makeText(requireContext(), R.string.RegistrationActivity_backup_failure_foreign_key, Toast.LENGTH_LONG).show()
+ RestoreRepository.BackupImportResult.FAILURE_UNKNOWN -> Toast.makeText(requireContext(), R.string.RegistrationActivity_incorrect_backup_passphrase, Toast.LENGTH_LONG).show()
+ RestoreRepository.BackupImportResult.SUCCESS -> Log.w(TAG, "Successful backup import should not be handled here.", IllegalStateException())
+ }
+ }
+
+ private fun presentProgressEnded() {
+ binding.restoreButton.cancelSpinning()
+ binding.cancelLocalRestoreButton.visible = true
+ binding.backupProgressText.text = ""
+ }
+
+ private fun presentRestoreProgress(backupProgressCount: Long) {
+ binding.restoreButton.setSpinning()
+ binding.cancelLocalRestoreButton.visibility = View.INVISIBLE
+ if (backupProgressCount > 0L) {
+ binding.backupProgressText.text = getString(R.string.RegistrationActivity_d_messages_so_far, backupProgressCount)
+ } else {
+ binding.backupProgressText.setText(R.string.RegistrationActivity_checking)
+ }
+ }
+
+ private fun presentBackupPassPhrasePromptDialog() {
+ val view = LayoutInflater.from(requireContext()).inflate(R.layout.enter_backup_passphrase_dialog, null)
+ val prompt = view.findViewById(R.id.restore_passphrase_input)
+
+ prompt.addTextChangedListener(PassphraseAsYouTypeFormatter())
+
+ MaterialAlertDialogBuilder(requireContext())
+ .setTitle(R.string.RegistrationActivity_enter_backup_passphrase)
+ .setView(view)
+ .setPositiveButton(R.string.RegistrationActivity_restore) { _, _ ->
+ ViewUtil.hideKeyboard(requireContext(), prompt)
+
+ val passphrase = prompt.getText().toString()
+ restoreLocalBackupViewModel.confirmPassphrase(requireContext(), passphrase)
+ }
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+
+ Log.i(TAG, "Prompt for backup passphrase shown to user.")
+ }
+
+ private fun presentBackupFileInfo(backupSize: Long, backupTimestamp: Long) {
+ if (backupSize > 0) {
+ binding.backupSizeText.text = getString(R.string.RegistrationActivity_backup_size_s, Util.getPrettyFileSize(backupSize))
+ }
+
+ if (backupTimestamp > 0) {
+ binding.backupCreatedText.text = getString(R.string.RegistrationActivity_backup_timestamp_s, DateUtils.getExtendedRelativeTimeSpanString(requireContext(), Locale.getDefault(), backupTimestamp))
+ }
+ }
+
+ companion object {
+ private val TAG = Log.tag(RestoreLocalBackupFragment::class.java)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupState.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupState.kt
new file mode 100644
index 0000000000..fbf2305fe0
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupState.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.restore.restorelocalbackup
+
+import android.net.Uri
+import org.thoughtcrime.securesms.restore.RestoreRepository
+import org.thoughtcrime.securesms.util.BackupUtil.BackupInfo
+
+/**
+ * State holder for a backup restore.
+ */
+data class RestoreLocalBackupState(
+ val uri: Uri,
+ val backupInfo: BackupInfo? = null,
+ val backupPassphrase: String = "",
+ val restoreInProgress: Boolean = false,
+ val backupVerifyingInProgress: Boolean = false,
+ val backupProgressCount: Long = -1,
+ val backupEstimatedTotalCount: Long = -1,
+ val backupRestoreComplete: Boolean = false,
+ val backupImportResult: RestoreRepository.BackupImportResult? = null,
+ val abort: Boolean = false
+)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupViewModel.kt
new file mode 100644
index 0000000000..f8c2fee5d3
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/restorelocalbackup/RestoreLocalBackupViewModel.kt
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2024 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.restore.restorelocalbackup
+
+import android.content.Context
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.asLiveData
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.backup.BackupEvent
+import org.thoughtcrime.securesms.restore.RestoreRepository
+
+/**
+ * ViewModel for [RestoreLocalBackupFragment]
+ */
+class RestoreLocalBackupViewModel(fileBackupUri: Uri) : ViewModel() {
+ private val store = MutableStateFlow(RestoreLocalBackupState(fileBackupUri))
+ val uiState = store.asLiveData()
+
+ fun startRestore(context: Context) {
+ val backupFileUri = store.value.uri
+ viewModelScope.launch {
+ val backupInfo = RestoreRepository.getLocalBackupFromUri(context, backupFileUri)
+
+ if (backupInfo == null) {
+ abort()
+ return@launch
+ }
+
+ store.update {
+ it.copy(
+ backupInfo = backupInfo
+ )
+ }
+ }
+ }
+
+ private fun abort() {
+ store.update {
+ it.copy(abort = true)
+ }
+ }
+
+ fun confirmPassphrase(context: Context, passphrase: String) {
+ store.update {
+ it.copy(
+ backupPassphrase = passphrase,
+ restoreInProgress = true
+ )
+ }
+
+ val backupFileUri = store.value.backupInfo?.uri
+ val backupPassphrase = store.value.backupPassphrase
+ if (backupFileUri == null) {
+ Log.w(TAG, "Could not begin backup import because backup file URI was null!")
+ abort()
+ return
+ }
+
+ if (backupPassphrase.isEmpty()) {
+ Log.w(TAG, "Could not begin backup import because backup passphrase was empty!")
+ abort()
+ return
+ }
+
+ viewModelScope.launch {
+ val importResult = RestoreRepository.restoreBackupAsynchronously(context, backupFileUri, backupPassphrase)
+
+ store.update {
+ it.copy(
+ backupImportResult = if (importResult == RestoreRepository.BackupImportResult.SUCCESS) null else importResult,
+ restoreInProgress = false,
+ backupRestoreComplete = true,
+ backupEstimatedTotalCount = -1L,
+ backupProgressCount = -1L,
+ backupVerifyingInProgress = false
+ )
+ }
+ }
+ }
+
+ fun onBackupProgressUpdate(event: BackupEvent) {
+ store.update {
+ it.copy(
+ backupProgressCount = event.count,
+ backupEstimatedTotalCount = event.estimatedTotalCount,
+ backupVerifyingInProgress = event.type == BackupEvent.Type.PROGRESS_VERIFYING,
+ backupRestoreComplete = event.type == BackupEvent.Type.FINISHED,
+ restoreInProgress = event.type != BackupEvent.Type.FINISHED
+ )
+ }
+ }
+
+ companion object {
+ private val TAG = Log.tag(RestoreLocalBackupViewModel::class.java)
+ }
+}
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
new file mode 100644
index 0000000000..b96b888302
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/restore/transferorrestore/TransferOrRestoreV2Fragment.kt
@@ -0,0 +1,90 @@
+/*
+ * 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.View
+import android.widget.Toast
+import androidx.fragment.app.activityViewModels
+import androidx.navigation.fragment.NavHostFragment
+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.FragmentTransferRestoreV2Binding
+import org.thoughtcrime.securesms.devicetransfer.newdevice.BackupRestorationType
+import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
+import org.thoughtcrime.securesms.restore.RestoreViewModel
+import org.thoughtcrime.securesms.util.FeatureFlags
+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.
+ */
+class TransferOrRestoreV2Fragment : LoggingFragment(R.layout.fragment_transfer_restore_v2) {
+ private val sharedViewModel by activityViewModels()
+ private val binding: FragmentTransferRestoreV2Binding by ViewBinderDelegate(FragmentTransferRestoreV2Binding::bind)
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+
+ 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 {
+ Log.w(TAG, "Not yet implemented!", NotImplementedError()) // TODO [regv2]
+ }
+
+ binding.transferOrRestoreFragmentRestoreRemoteCard.visible = FeatureFlags.messageBackups()
+ binding.transferOrRestoreFragmentMoreOptions.visible = FeatureFlags.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)
+
+ binding.transferOrRestoreFragmentTransferDescription.text = SpanUtil.boldSubstring(description, toBold)
+
+ sharedViewModel.uiState.observe(viewLifecycleOwner) { state ->
+ updateSelection(state.restorationType)
+ }
+
+ // TODO [regv2]: port backup file detection to here
+ }
+
+ 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) {
+ when (restorationType) {
+ BackupRestorationType.DEVICE_TRANSFER -> {
+ // TODO [regv2]
+ Log.w(TAG, "Not yet implemented!", NotImplementedError())
+ Toast.makeText(requireContext(), "Not yet implemented!", Toast.LENGTH_LONG).show()
+ }
+ BackupRestorationType.LOCAL_BACKUP -> {
+ NavHostFragment.findNavController(this).safeNavigate(TransferOrRestoreV2FragmentDirections.actionTransferOrRestoreToRestore())
+ }
+ BackupRestorationType.REMOTE_BACKUP -> {
+ // TODO [regv2]
+ Log.w(TAG, "Not yet implemented!", NotImplementedError())
+ Toast.makeText(requireContext(), "Not yet implemented!", Toast.LENGTH_LONG).show()
+ }
+ else -> {
+ throw IllegalArgumentException()
+ }
+ }
+ }
+
+ companion object {
+ private val TAG = Log.tag(TransferOrRestoreV2Fragment::class.java)
+ }
+}
diff --git a/app/src/main/res/layout/activity_restore.xml b/app/src/main/res/layout/activity_restore.xml
new file mode 100644
index 0000000000..09b7fa8162
--- /dev/null
+++ b/app/src/main/res/layout/activity_restore.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_choose_backup_v2.xml b/app/src/main/res/layout/fragment_choose_backup_v2.xml
new file mode 100644
index 0000000000..123d54be0d
--- /dev/null
+++ b/app/src/main/res/layout/fragment_choose_backup_v2.xml
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_restore_local_backup_v2.xml b/app/src/main/res/layout/fragment_restore_local_backup_v2.xml
new file mode 100644
index 0000000000..e0f7121202
--- /dev/null
+++ b/app/src/main/res/layout/fragment_restore_local_backup_v2.xml
@@ -0,0 +1,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_transfer_restore_v2.xml b/app/src/main/res/layout/fragment_transfer_restore_v2.xml
new file mode 100644
index 0000000000..883b564737
--- /dev/null
+++ b/app/src/main/res/layout/fragment_transfer_restore_v2.xml
@@ -0,0 +1,244 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/navigation/restore.xml b/app/src/main/res/navigation/restore.xml
new file mode 100644
index 0000000000..1446b50e78
--- /dev/null
+++ b/app/src/main/res/navigation/restore.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file