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