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

@@ -104,6 +104,7 @@ class BackupsSettingsFragment : ComposeFragment() {
}
},
onOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_backupsPreferenceFragment) },
onNewOnDeviceBackupsRowClick = { findNavController().safeNavigate(R.id.action_backupsSettingsFragment_to_internalLocalBackupFragment) },
onBackupTierInternalOverrideChanged = { viewModel.onBackupTierInternalOverrideChanged(it) }
)
}
@@ -115,6 +116,7 @@ private fun BackupsSettingsContent(
onNavigationClick: () -> Unit = {},
onBackupsRowClick: () -> Unit = {},
onOnDeviceBackupsRowClick: () -> Unit = {},
onNewOnDeviceBackupsRowClick: () -> Unit = {},
onBackupTierInternalOverrideChanged: (MessageBackupTier?) -> Unit = {}
) {
Scaffolds.Settings(
@@ -232,6 +234,16 @@ private fun BackupsSettingsContent(
onClick = onOnDeviceBackupsRowClick
)
}
if (backupsSettingsState.showNewLocalBackup) {
item {
Rows.TextRow(
text = "INTERNAL ONLY - New Local Backup",
label = "Use new local backup format",
onClick = onNewOnDeviceBackupsRowClick
)
}
}
}
}
}

View File

@@ -17,5 +17,6 @@ data class BackupsSettingsState(
val backupState: BackupState,
val lastBackupAt: Duration = SignalStore.backup.lastBackupTime.milliseconds,
val showBackupTierInternalOverride: Boolean = false,
val backupTierInternalOverride: MessageBackupTier? = null
val backupTierInternalOverride: MessageBackupTier? = null,
val showNewLocalBackup: Boolean = false
)

View File

@@ -21,6 +21,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.storage.StorageSyncHelper
import org.thoughtcrime.securesms.util.Environment
import org.thoughtcrime.securesms.util.RemoteConfig
import kotlin.time.Duration.Companion.milliseconds
class BackupsSettingsViewModel : ViewModel() {
@@ -45,7 +46,8 @@ class BackupsSettingsViewModel : ViewModel() {
backupState = enabledState,
lastBackupAt = SignalStore.backup.lastBackupTime.milliseconds,
showBackupTierInternalOverride = Environment.IS_STAGING,
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride
backupTierInternalOverride = SignalStore.backup.backupTierInternalOverride,
showNewLocalBackup = RemoteConfig.internalUser || Environment.IS_NIGHTLY
)
}
}

View File

@@ -0,0 +1,276 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.app.backups.local
import android.app.Activity
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.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
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.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.vectorResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.map
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.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.LocalBackupV2Event
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.service.LocalBackupListener
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.StorageUtil
import org.thoughtcrime.securesms.util.formatHours
import java.time.LocalTime
import java.util.Locale
/**
* App settings internal screen for enabling and creating new local backups.
*/
class InternalNewLocalBackupCreateFragment : ComposeFragment() {
private val TAG = Log.tag(InternalNewLocalBackupCreateFragment::class)
private lateinit var chooseBackupLocationLauncher: ActivityResultLauncher<Intent>
private var createStatus by mutableStateOf("None")
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")
}
}
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(event: LocalBackupV2Event) {
createStatus = "${event.type}: ${event.count} / ${event.estimatedTotalCount}"
}
@Composable
override fun FragmentContent() {
val context = LocalContext.current
val backupsEnabled by SignalStore.backup.newLocalBackupsEnabledFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsEnabled)
val selectedDirectory by directoryFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsDirectory)
val lastBackupTime by SignalStore.backup.newLocalBackupsLastBackupTimeFlow.collectAsStateWithLifecycle(SignalStore.backup.newLocalBackupsLastBackupTime)
val lastBackupTimeString = remember(lastBackupTime) { calculateLastBackupTimeString(context, lastBackupTime) }
val backupTime = remember { LocalTime.of(SignalStore.settings.backupHour, SignalStore.settings.backupMinute).formatHours(requireContext()) }
InternalLocalBackupScreen(
backupsEnabled = backupsEnabled,
selectedDirectory = selectedDirectory,
lastBackupTimeString = lastBackupTimeString,
backupTime = backupTime,
createStatus = createStatus,
callback = CallbackImpl()
)
}
private fun launchBackupDirectoryPicker() {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
if (Build.VERSION.SDK_INT >= 26) {
val latestDirectory = SignalStore.settings.latestSignalBackupDirectory
if (latestDirectory != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, latestDirectory)
}
}
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 fun calculateLastBackupTimeString(context: Context, lastBackupTimestamp: Long): String {
return if (lastBackupTimestamp > 0) {
val relativeTime = DateUtils.getDatelessRelativeTimeSpanFormattedDate(
context,
Locale.getDefault(),
lastBackupTimestamp
)
if (relativeTime.isRelative) {
relativeTime.value
} else {
val day = DateUtils.getDayPrecisionTimeString(context, Locale.getDefault(), lastBackupTimestamp)
val time = relativeTime.value
context.getString(R.string.RemoteBackupsSettingsFragment__s_at_s, day, time)
}
} else {
context.getString(R.string.RemoteBackupsSettingsFragment__never)
}
}
private inner class CallbackImpl : Callback {
override fun onNavigationClick() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun onToggleBackupsClick(enabled: Boolean) {
SignalStore.backup.newLocalBackupsEnabled = enabled
if (enabled) {
LocalBackupListener.schedule(requireContext())
}
}
override fun onSelectDirectoryClick() {
launchBackupDirectoryPicker()
}
override fun onEnqueueBackupClick() {
createStatus = "Starting..."
LocalBackupJob.enqueueArchive(false)
}
}
}
private interface Callback {
fun onNavigationClick()
fun onToggleBackupsClick(enabled: Boolean)
fun onSelectDirectoryClick()
fun onEnqueueBackupClick()
object Empty : Callback {
override fun onNavigationClick() = Unit
override fun onToggleBackupsClick(enabled: Boolean) = Unit
override fun onSelectDirectoryClick() = Unit
override fun onEnqueueBackupClick() = Unit
}
}
@Composable
private fun InternalLocalBackupScreen(
backupsEnabled: Boolean = false,
selectedDirectory: String? = null,
lastBackupTimeString: String = "Never",
backupTime: String = "Unknown",
createStatus: String = "None",
callback: Callback
) {
Scaffolds.Settings(
title = "New Local Backups",
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_arrow_start_24),
onNavigationClick = callback::onNavigationClick
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
item {
Rows.ToggleRow(
checked = backupsEnabled,
text = "Enable New Local Backups",
label = if (backupsEnabled) "Backups are enabled" else "Backups are disabled",
onCheckChanged = callback::onToggleBackupsClick
)
}
item {
Rows.TextRow(
text = "Last Backup",
label = lastBackupTimeString
)
}
item {
Rows.TextRow(
text = "Backup Schedule Time (same as v1)",
label = backupTime
)
}
item {
Rows.TextRow(
text = "Select Backup Directory",
label = selectedDirectory ?: "No directory selected",
onClick = callback::onSelectDirectoryClick
)
}
item {
Rows.TextRow(
text = "Create Backup Now",
label = "Enqueue LocalArchiveJob",
onClick = callback::onEnqueueBackupClick
)
}
item {
Rows.TextRow(
text = "Create Status",
label = createStatus
)
}
}
}
}
@DayNightPreviews
@Composable
fun InternalLocalBackupScreenPreview() {
Previews.Preview {
InternalLocalBackupScreen(
backupsEnabled = true,
selectedDirectory = "/storage/emulated/0/Signal/Backups",
lastBackupTimeString = "1 hour ago",
callback = Callback.Empty
)
}
}

View File

@@ -84,6 +84,7 @@ import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
import org.thoughtcrime.securesms.jobs.LocalBackupJob
import org.thoughtcrime.securesms.keyvalue.BackupValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ui.restore.local.InternalNewLocalRestoreActivity
import org.thoughtcrime.securesms.util.Util
class InternalBackupPlaygroundFragment : ComposeFragment() {
@@ -192,7 +193,7 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
savePlaintextCopyLauncher.launch(intent)
},
onExportNewStyleLocalBackupClicked = { LocalBackupJob.enqueueArchive() },
onExportNewStyleLocalBackupClicked = { LocalBackupJob.enqueueArchive(false) },
onWipeDataAndRestoreFromRemoteClicked = {
MaterialAlertDialogBuilder(context)
.setTitle("Are you sure?")
@@ -229,7 +230,9 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
MaterialAlertDialogBuilder(context)
.setTitle("Are you sure?")
.setMessage("After you choose a file to import, this will delete all of your chats, then restore them from the file! Only do this on a test device!")
.setPositiveButton("Wipe and restore") { _, _ -> viewModel.import(SignalStore.settings.signalBackupDirectory!!) }
.setPositiveButton("Wipe and restore") { _, _ ->
startActivity(InternalNewLocalRestoreActivity.getIntent(context, finish = false))
}
.show()
},
onDeleteRemoteBackup = {

View File

@@ -5,7 +5,6 @@
package org.thoughtcrime.securesms.components.settings.app.internal.backup
import android.net.Uri
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
@@ -43,11 +42,6 @@ import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.DebugBackupMetadata
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.RemoteRestoreResult
import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem
import org.thoughtcrime.securesms.backup.v2.local.ArchiveResult
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver.FailureCause
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader
import org.thoughtcrime.securesms.backup.v2.stream.EncryptedBackupReader.Companion.MAC_SIZE
import org.thoughtcrime.securesms.database.AttachmentTable
@@ -55,7 +49,6 @@ import org.thoughtcrime.securesms.database.AttachmentTable.DebugAttachmentStats
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.providers.BlobProvider
@@ -193,29 +186,6 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
}
}
fun import(uri: Uri) {
_state.value = _state.value.copy(statusMessage = "Importing new-style local backup...")
val self = Recipient.self()
val selfData = BackupRepository.SelfData(self.aci.get(), self.pni.get(), self.e164.get(), ProfileKey(self.profileKey))
disposables += Single.fromCallable {
val archiveFileSystem = ArchiveFileSystem.fromUri(AppDependencies.application, uri)!!
val snapshotInfo = archiveFileSystem.listSnapshots().firstOrNull() ?: return@fromCallable ArchiveResult.failure(FailureCause.MAIN_STREAM)
val snapshotFileSystem = SnapshotFileSystem(AppDependencies.application, snapshotInfo.file)
LocalArchiver.import(snapshotFileSystem, selfData)
val mediaNameToFileInfo = archiveFileSystem.filesFileSystem.allFiles()
RestoreLocalAttachmentJob.enqueueRestoreLocalAttachmentsJobs(mediaNameToFileInfo)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
_state.value = _state.value.copy(statusMessage = "New-style local backup import complete!")
}
}
fun haltAllJobs() {
ArchiveUploadProgress.cancel()