Update local backup v2 support.

This commit is contained in:
Cody Henthorne
2025-12-18 16:33:30 -05:00
committed by jeffrey-signal
parent 71b15d269e
commit d9ecab5240
55 changed files with 2291 additions and 274 deletions

View File

@@ -987,8 +987,8 @@ class RegistrationViewModel : ViewModel() {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Start
}
fun intendToRestore(hasOldDevice: Boolean, fromRemote: Boolean? = null) {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.intendToRestore(hasOldDevice, fromRemote)
fun intendToRestore(hasOldDevice: Boolean, fromRemote: Boolean? = null, fromLocalV2: Boolean? = null) {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.intendToRestore(hasOldDevice, fromRemote, fromLocalV2)
}
fun skipRestore() {

View File

@@ -562,6 +562,7 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
EnterPhoneNumberMode.RESTART_AFTER_COLLECTION -> startNormalRegistration()
EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToEnterBackupKey())
EnterPhoneNumberMode.COLLECT_FOR_LOCAL_V2_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToInternalNewLocalBackupRestore())
}
}

View File

@@ -12,9 +12,12 @@ 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 */
/** User pre-selected restore/transfer flow, collect number to re-register and restore via remote */
COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE,
/** User pre-selected restore/transfer flow, collect number to re-register and restore via local backup v2 */
COLLECT_FOR_LOCAL_V2_SIGNAL_BACKUPS_RESTORE,
/** User reversed decision on restore and needs to resume normal re-register but automatically start verify */
RESTART_AFTER_COLLECTION
}

View File

@@ -20,11 +20,13 @@ import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.compose.Dialogs
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.phonenumber.EnterPhoneNumberMode
import org.thoughtcrime.securesms.restore.RestoreActivity
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
@@ -55,8 +57,16 @@ class SelectManualRestoreMethodFragment : ComposeFragment() {
override fun FragmentContent() {
var showSkipRestoreWarning by remember { mutableStateOf(false) }
val restoreMethods = remember {
if (Environment.IS_NIGHTLY || BuildConfig.DEBUG) {
listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1, RestoreMethod.FROM_LOCAL_BACKUP_V2)
} else {
listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1)
}
}
SelectRestoreMethodScreen(
restoreMethods = listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1),
restoreMethods = restoreMethods,
onRestoreMethodClicked = this::startRestoreMethod,
onSkip = {
showSkipRestoreWarning = true
@@ -92,7 +102,11 @@ class SelectManualRestoreMethodFragment : ComposeFragment() {
localBackupRestore.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")
RestoreMethod.FROM_LOCAL_BACKUP_V2 -> {
sharedViewModel.clearPreviousRegistrationState()
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = false, fromLocalV2 = true)
findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.COLLECT_FOR_LOCAL_V2_SIGNAL_BACKUPS_RESTORE))
}
}
}
}

View File

@@ -0,0 +1,312 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
import org.signal.core.models.AccountEntropyPool
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.restore.AccountEntropyPoolVerification
import org.thoughtcrime.securesms.registration.ui.restore.BackupKeyVisualTransformation
import org.thoughtcrime.securesms.registration.ui.restore.attachBackupKeyAutoFillHelper
import org.thoughtcrime.securesms.registration.ui.restore.backupKeyAutoFillHelper
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
import org.thoughtcrime.securesms.util.StorageUtil
/**
* Internal only registration screen to collect backup folder and AEP. Actual restore will happen
* post-registration when the app re-routes to [org.thoughtcrime.securesms.restore.RestoreActivity] and then
* [InternalNewLocalRestoreActivity]. Yay implicit navigation!
*/
class InternalNewLocalBackupRestore : ComposeFragment() {
private val TAG = Log.tag(InternalNewLocalBackupRestore::class)
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private lateinit var chooseBackupLocationLauncher: ActivityResultLauncher<Intent>
private val directoryFlow = SignalStore.backup.newLocalBackupsDirectoryFlow.map { if (Build.VERSION.SDK_INT >= 24 && it != null) StorageUtil.getDisplayPath(requireContext(), Uri.parse(it)) else it }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
chooseBackupLocationLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data?.data != null) {
handleBackupLocationSelected(result.data!!.data!!)
} else {
Log.w(TAG, "Backup location selection cancelled or failed")
}
}
}
@Composable
override fun FragmentContent() {
val selectedDirectory: String? by directoryFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsDirectory)
InternalNewLocalBackupRestoreScreen(
selectedDirectory = selectedDirectory,
callback = CallbackImpl()
)
}
private fun launchBackupDirectoryPicker() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Build.VERSION.SDK_INT >= 26) {
val currentDirectory = SignalStore.backup.newLocalBackupsDirectory
if (currentDirectory != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, Uri.parse(currentDirectory))
}
}
intent.addFlags(
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_READ_URI_PERMISSION
)
try {
Log.d(TAG, "Launching backup directory picker")
chooseBackupLocationLauncher.launch(intent)
} catch (e: Exception) {
Log.w(TAG, "Failed to launch backup directory picker", e)
Toast.makeText(requireContext(), R.string.BackupDialog_no_file_picker_available, Toast.LENGTH_LONG).show()
}
}
private fun handleBackupLocationSelected(uri: Uri) {
Log.i(TAG, "Backup location selected: $uri")
val takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
requireContext().contentResolver.takePersistableUriPermission(uri, takeFlags)
SignalStore.backup.newLocalBackupsDirectory = uri.toString()
Toast.makeText(requireContext(), "Directory selected: $uri", Toast.LENGTH_SHORT).show()
}
private inner class CallbackImpl : Callback {
override fun onSelectDirectoryClick() {
launchBackupDirectoryPicker()
}
override fun onRestoreClick(backupKey: String) {
sharedViewModel.registerWithBackupKey(
context = requireContext(),
backupKey = backupKey,
e164 = null,
pin = null,
aciIdentityKeyPair = null,
pniIdentityKeyPair = null
)
}
}
}
private interface Callback {
fun onSelectDirectoryClick()
fun onRestoreClick(backupKey: String)
object Empty : Callback {
override fun onSelectDirectoryClick() = Unit
override fun onRestoreClick(backupKey: String) = Unit
}
}
@Composable
private fun InternalNewLocalBackupRestoreScreen(
selectedDirectory: String? = null,
callback: Callback
) {
var backupKey by remember { mutableStateOf("") }
var isBackupKeyValid by remember { mutableStateOf(false) }
var aepValidationError by remember { mutableStateOf<AccountEntropyPoolVerification.AEPValidationError?>(null) }
val visualTransform = remember { BackupKeyVisualTransformation(chunkSize = 4) }
val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
var requestFocus by remember { mutableStateOf(true) }
val autoFillHelper = backupKeyAutoFillHelper { newValue ->
backupKey = newValue
val (valid, error) = AccountEntropyPoolVerification.verifyAEP(
backupKey = newValue,
changed = true,
previousAEPValidationError = aepValidationError
)
isBackupKeyValid = valid
aepValidationError = error
}
RegistrationScreen(
title = "Local Backup V2 Restore",
subtitle = null,
bottomContent = {
Buttons.LargeTonal(
onClick = { callback.onRestoreClick(backupKey) },
enabled = isBackupKeyValid && aepValidationError == null && selectedDirectory != null,
modifier = Modifier.align(Alignment.CenterEnd)
) {
Text(text = "Restore")
}
}
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
DirectorySelectionRow(
selectedDirectory = selectedDirectory,
onClick = callback::onSelectDirectoryClick
)
Spacer(modifier = Modifier.height(24.dp))
TextField(
value = backupKey,
onValueChange = { value ->
val newKey = AccountEntropyPool.removeIllegalCharacters(value).take(AccountEntropyPool.LENGTH + 16).lowercase()
val (valid, error) = AccountEntropyPoolVerification.verifyAEP(
backupKey = newKey,
changed = backupKey != newKey,
previousAEPValidationError = aepValidationError
)
backupKey = newKey
isBackupKeyValid = valid
aepValidationError = error
autoFillHelper.onValueChanged(newKey)
},
label = {
Text(text = stringResource(id = R.string.EnterBackupKey_backup_key))
},
textStyle = LocalTextStyle.current.copy(
fontFamily = MonoTypeface.fontFamily(),
lineHeight = 36.sp
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onDone = {
if (isBackupKeyValid && aepValidationError == null && selectedDirectory != null) {
keyboardController?.hide()
callback.onRestoreClick(backupKey)
}
}
),
supportingText = { aepValidationError?.ValidationErrorMessage() },
isError = aepValidationError != null,
minLines = 4,
visualTransformation = visualTransform,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.attachBackupKeyAutoFillHelper(autoFillHelper)
.onGloballyPositioned {
if (requestFocus) {
focusRequester.requestFocus()
requestFocus = false
}
}
)
}
}
}
@Composable
private fun DirectorySelectionRow(
selectedDirectory: String?,
onClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Text(
text = "Select Backup Directory",
style = MaterialTheme.typography.bodyLarge
)
Text(
text = selectedDirectory ?: "No directory selected",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun AccountEntropyPoolVerification.AEPValidationError.ValidationErrorMessage() {
when (this) {
is AccountEntropyPoolVerification.AEPValidationError.TooLong -> Text(text = stringResource(R.string.EnterBackupKey_too_long_error, this.count, this.max))
AccountEntropyPoolVerification.AEPValidationError.Invalid -> Text(text = stringResource(R.string.EnterBackupKey_invalid_backup_key_error))
AccountEntropyPoolVerification.AEPValidationError.Incorrect -> Text(text = stringResource(R.string.EnterBackupKey_incorrect_backup_key_error))
}
}
@DayNightPreviews
@Composable
private fun InternalNewLocalBackupRestoreScreenPreview() {
Previews.Preview {
InternalNewLocalBackupRestoreScreen(
selectedDirectory = "/storage/emulated/0/Signal/Backups",
callback = Callback.Empty
)
}
}

View File

@@ -0,0 +1,151 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore.local
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.util.Result
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.compose.SignalTheme
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.registration.ui.restore.StorageServiceRestore
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
/**
* Internal only. On launch, attempt to import the most recent backup located in [SignalStore.backup].newLocalBackupsDirectory.
*/
class InternalNewLocalRestoreActivity : BaseActivity() {
companion object {
fun getIntent(context: Context, finish: Boolean = true): Intent = Intent(context, InternalNewLocalRestoreActivity::class.java).apply { putExtra("finish", finish) }
}
private var restoreStatus by mutableStateOf<String>("Unknown")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch(Dispatchers.IO) {
restoreStatus = "Starting..."
val self = Recipient.self()
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
val archiveFileSystem = ArchiveFileSystem.fromUri(AppDependencies.application, Uri.parse(SignalStore.backup.newLocalBackupsDirectory!!))!!
val snapshotInfo = archiveFileSystem.listSnapshots().first()
val snapshotFileSystem = SnapshotFileSystem(AppDependencies.application, snapshotInfo.file)
val result = LocalArchiver.import(snapshotFileSystem, selfData)
if (result is Result.Success) {
restoreStatus = "Success! Finishing"
val mediaNameToFileInfo = archiveFileSystem.filesFileSystem.allFiles()
RestoreLocalAttachmentJob.enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo)
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Completed
SignalStore.backup.backupSecretRestoreRequired = false
StorageServiceRestore.restore()
withContext(Dispatchers.Main) {
Toast.makeText(this@InternalNewLocalRestoreActivity, "Local backup restored!", Toast.LENGTH_SHORT).show()
RegistrationUtil.maybeMarkRegistrationComplete()
startActivity(MainActivity.clearTop(this@InternalNewLocalRestoreActivity))
if (intent.getBooleanExtra("finish", false)) {
finishAffinity()
}
}
} else {
restoreStatus = "Backup failed"
Toast.makeText(this@InternalNewLocalRestoreActivity, "Local backup failed", Toast.LENGTH_SHORT).show()
}
}
setContent {
SignalTheme {
Surface {
InternalNewLocalRestoreScreen(
status = restoreStatus
)
}
}
}
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(restoreEvent: RestoreV2Event) {
this.restoreStatus = "${restoreEvent.type}: ${restoreEvent.count} / ${restoreEvent.estimatedTotalCount}"
}
}
@Composable
private fun InternalNewLocalRestoreScreen(
status: String = ""
) {
RegistrationScreen(
title = "Internal - Local Restore",
subtitle = null,
bottomContent = { }
) {
Text(
text = status,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 16.dp)
)
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally)
)
}
}
@DayNightPreviews
@Composable
private fun InternalNewLocalRestorePreview() {
Previews.Preview {
InternalNewLocalRestoreScreen(status = "Importing...")
}
}