mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 18:00:02 +01:00
Add initial support for backup and restore of message and media to staging.
Co-authored-by: Cody Henthorne <cody@signal.org>
This commit is contained in:
@@ -29,6 +29,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
@@ -37,6 +42,8 @@ import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -46,10 +53,12 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Snackbars
|
||||
@@ -57,10 +66,13 @@ import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.getLength
|
||||
import org.signal.core.util.roundedString
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.BackupState
|
||||
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.BackupUploadState
|
||||
import org.thoughtcrime.securesms.components.settings.app.internal.backup.InternalBackupPlaygroundViewModel.ScreenState
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
class InternalBackupPlaygroundFragment : ComposeFragment() {
|
||||
|
||||
@@ -114,6 +126,8 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
|
||||
}
|
||||
|
||||
Tabs(
|
||||
onBack = { findNavController().popBackStack() },
|
||||
onDeleteAllArchivedMedia = { viewModel.deleteAllArchivedMedia() },
|
||||
mainContent = {
|
||||
Screen(
|
||||
state = state,
|
||||
@@ -149,25 +163,32 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
|
||||
}
|
||||
|
||||
validateFileLauncher.launch(intent)
|
||||
}
|
||||
},
|
||||
onTriggerBackupJobClicked = { viewModel.triggerBackupJob() },
|
||||
onRestoreFromRemoteClicked = { viewModel.restoreFromRemote() }
|
||||
)
|
||||
},
|
||||
mediaContent = { snackbarHostState ->
|
||||
MediaList(
|
||||
enabled = SignalStore.backup().canReadWriteToArchiveCdn,
|
||||
state = mediaState,
|
||||
snackbarHostState = snackbarHostState,
|
||||
backupAttachmentMedia = { viewModel.backupAttachmentMedia(it) },
|
||||
deleteBackupAttachmentMedia = { viewModel.deleteBackupAttachmentMedia(it) },
|
||||
batchBackupAttachmentMedia = { viewModel.backupAttachmentMedia(it) },
|
||||
batchDeleteBackupAttachmentMedia = { viewModel.deleteBackupAttachmentMedia(it) }
|
||||
archiveAttachmentMedia = { viewModel.archiveAttachmentMedia(it) },
|
||||
deleteArchivedMedia = { viewModel.deleteArchivedMedia(it) },
|
||||
batchArchiveAttachmentMedia = { viewModel.archiveAttachmentMedia(it) },
|
||||
batchDeleteBackupAttachmentMedia = { viewModel.deleteArchivedMedia(it) },
|
||||
restoreArchivedMedia = { viewModel.restoreArchivedMedia(it) }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun Tabs(
|
||||
onBack: () -> Unit,
|
||||
onDeleteAllArchivedMedia: () -> Unit,
|
||||
mainContent: @Composable () -> Unit,
|
||||
mediaContent: @Composable (snackbarHostState: SnackbarHostState) -> Unit
|
||||
) {
|
||||
@@ -179,13 +200,36 @@ fun Tabs(
|
||||
Scaffold(
|
||||
snackbarHost = { Snackbars.Host(snackbarHostState) },
|
||||
topBar = {
|
||||
TabRow(selectedTabIndex = tabIndex) {
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
Tab(
|
||||
text = { Text(tab) },
|
||||
selected = index == tabIndex,
|
||||
onClick = { tabIndex = index }
|
||||
)
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text("Backup Playground")
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.symbol_arrow_left_24),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (tabIndex == 1 && SignalStore.backup().canReadWriteToArchiveCdn) {
|
||||
TextButton(onClick = onDeleteAllArchivedMedia) {
|
||||
Text(text = "Delete All")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
TabRow(selectedTabIndex = tabIndex) {
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
Tab(
|
||||
text = { Text(tab) },
|
||||
selected = index == tabIndex,
|
||||
onClick = { tabIndex = index }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,7 +253,9 @@ fun Screen(
|
||||
onSaveToDiskClicked: () -> Unit = {},
|
||||
onValidateFileClicked: () -> Unit = {},
|
||||
onUploadToRemoteClicked: () -> Unit = {},
|
||||
onCheckRemoteBackupStateClicked: () -> Unit = {}
|
||||
onCheckRemoteBackupStateClicked: () -> Unit = {},
|
||||
onTriggerBackupJobClicked: () -> Unit = {},
|
||||
onRestoreFromRemoteClicked: () -> Unit = {}
|
||||
) {
|
||||
Surface {
|
||||
Column(
|
||||
@@ -239,6 +285,13 @@ fun Screen(
|
||||
Text("Export")
|
||||
}
|
||||
|
||||
Buttons.LargePrimary(
|
||||
onClick = onTriggerBackupJobClicked,
|
||||
enabled = !state.backupState.inProgress
|
||||
) {
|
||||
Text("Trigger Backup Job")
|
||||
}
|
||||
|
||||
Dividers.Default()
|
||||
|
||||
Buttons.LargeTonal(
|
||||
@@ -280,6 +333,10 @@ fun Screen(
|
||||
}
|
||||
}
|
||||
|
||||
BackupState.BACKUP_JOB_DONE -> {
|
||||
StateLabel("Backup complete and uploaded")
|
||||
}
|
||||
|
||||
BackupState.IMPORT_IN_PROGRESS -> {
|
||||
StateLabel("Import in progress...")
|
||||
}
|
||||
@@ -324,6 +381,10 @@ fun Screen(
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Buttons.LargePrimary(onClick = onRestoreFromRemoteClicked) {
|
||||
Text("Restore from remote")
|
||||
}
|
||||
|
||||
when (state.uploadState) {
|
||||
BackupUploadState.NONE -> {
|
||||
StateLabel("")
|
||||
@@ -357,13 +418,24 @@ private fun StateLabel(text: String) {
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MediaList(
|
||||
enabled: Boolean,
|
||||
state: InternalBackupPlaygroundViewModel.MediaState,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
backupAttachmentMedia: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit,
|
||||
deleteBackupAttachmentMedia: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit,
|
||||
batchBackupAttachmentMedia: (Set<String>) -> Unit,
|
||||
batchDeleteBackupAttachmentMedia: (Set<String>) -> Unit
|
||||
archiveAttachmentMedia: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit,
|
||||
deleteArchivedMedia: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit,
|
||||
batchArchiveAttachmentMedia: (Set<AttachmentId>) -> Unit,
|
||||
batchDeleteBackupAttachmentMedia: (Set<AttachmentId>) -> Unit,
|
||||
restoreArchivedMedia: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit
|
||||
) {
|
||||
if (!enabled) {
|
||||
Text(
|
||||
text = "You do not have read/write to archive cdn enabled via SignalStore.backup()",
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
LaunchedEffect(state.error?.id) {
|
||||
state.error?.let {
|
||||
snackbarHostState.showSnackbar(it.errorText)
|
||||
@@ -384,51 +456,88 @@ fun MediaList(
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (selectionState.selecting) {
|
||||
selectionState = selectionState.copy(selected = if (selectionState.selected.contains(attachment.mediaId)) selectionState.selected - attachment.mediaId else selectionState.selected + attachment.mediaId)
|
||||
selectionState = selectionState.copy(selected = if (selectionState.selected.contains(attachment.id)) selectionState.selected - attachment.id else selectionState.selected + attachment.id)
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
selectionState = if (selectionState.selecting) MediaMultiSelectState() else MediaMultiSelectState(selecting = true, selected = setOf(attachment.mediaId))
|
||||
selectionState = if (selectionState.selecting) MediaMultiSelectState() else MediaMultiSelectState(selecting = true, selected = setOf(attachment.id))
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
if (selectionState.selecting) {
|
||||
Checkbox(
|
||||
checked = selectionState.selected.contains(attachment.mediaId),
|
||||
checked = selectionState.selected.contains(attachment.id),
|
||||
onCheckedChange = { selected ->
|
||||
selectionState = selectionState.copy(selected = if (selected) selectionState.selected + attachment.mediaId else selectionState.selected - attachment.mediaId)
|
||||
selectionState = selectionState.copy(selected = if (selected) selectionState.selected + attachment.id else selectionState.selected - attachment.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f, true)) {
|
||||
Text(text = "Attachment ${attachment.title}")
|
||||
Text(text = attachment.title)
|
||||
Text(text = "State: ${attachment.state}")
|
||||
}
|
||||
|
||||
if (attachment.state == InternalBackupPlaygroundViewModel.BackupAttachment.State.INIT ||
|
||||
attachment.state == InternalBackupPlaygroundViewModel.BackupAttachment.State.IN_PROGRESS
|
||||
) {
|
||||
if (attachment.state == InternalBackupPlaygroundViewModel.BackupAttachment.State.IN_PROGRESS) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
Button(
|
||||
enabled = !selectionState.selecting,
|
||||
onClick = {
|
||||
when (attachment.state) {
|
||||
InternalBackupPlaygroundViewModel.BackupAttachment.State.LOCAL_ONLY -> backupAttachmentMedia(attachment)
|
||||
InternalBackupPlaygroundViewModel.BackupAttachment.State.UPLOADED -> deleteBackupAttachmentMedia(attachment)
|
||||
InternalBackupPlaygroundViewModel.BackupAttachment.State.ATTACHMENT_CDN,
|
||||
InternalBackupPlaygroundViewModel.BackupAttachment.State.LOCAL_ONLY -> archiveAttachmentMedia(attachment)
|
||||
|
||||
InternalBackupPlaygroundViewModel.BackupAttachment.State.UPLOADED_UNDOWNLOADED,
|
||||
InternalBackupPlaygroundViewModel.BackupAttachment.State.UPLOADED_FINAL -> selectionState = selectionState.copy(expandedOption = attachment.dbAttachment.attachmentId)
|
||||
|
||||
else -> throw AssertionError("Unsupported state: ${attachment.state}")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = when (attachment.state) {
|
||||
InternalBackupPlaygroundViewModel.BackupAttachment.State.ATTACHMENT_CDN,
|
||||
InternalBackupPlaygroundViewModel.BackupAttachment.State.LOCAL_ONLY -> "Backup"
|
||||
InternalBackupPlaygroundViewModel.BackupAttachment.State.UPLOADED -> "Remote Delete"
|
||||
|
||||
InternalBackupPlaygroundViewModel.BackupAttachment.State.UPLOADED_UNDOWNLOADED,
|
||||
InternalBackupPlaygroundViewModel.BackupAttachment.State.UPLOADED_FINAL -> "Options..."
|
||||
|
||||
else -> throw AssertionError("Unsupported state: ${attachment.state}")
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenu(
|
||||
expanded = attachment.dbAttachment.attachmentId == selectionState.expandedOption,
|
||||
onDismissRequest = { selectionState = selectionState.copy(expandedOption = null) }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Remote Delete") },
|
||||
onClick = {
|
||||
selectionState = selectionState.copy(expandedOption = null)
|
||||
deleteArchivedMedia(attachment)
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text("Pseudo Restore") },
|
||||
onClick = {
|
||||
selectionState = selectionState.copy(expandedOption = null)
|
||||
restoreArchivedMedia(attachment)
|
||||
}
|
||||
)
|
||||
|
||||
if (attachment.dbAttachment.dataHash != null && attachment.state == InternalBackupPlaygroundViewModel.BackupAttachment.State.UPLOADED_UNDOWNLOADED) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Re-copy with hash") },
|
||||
onClick = {
|
||||
selectionState = selectionState.copy(expandedOption = null)
|
||||
archiveAttachmentMedia(attachment)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -451,7 +560,7 @@ fun MediaList(
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(onClick = {
|
||||
batchBackupAttachmentMedia(selectionState.selected)
|
||||
batchArchiveAttachmentMedia(selectionState.selected)
|
||||
selectionState = MediaMultiSelectState()
|
||||
}) {
|
||||
Text("Backup")
|
||||
@@ -469,7 +578,8 @@ fun MediaList(
|
||||
|
||||
private data class MediaMultiSelectState(
|
||||
val selecting: Boolean = false,
|
||||
val selected: Set<String> = emptySet()
|
||||
val selected: Set<AttachmentId> = emptySet(),
|
||||
val expandedOption: AttachmentId? = null
|
||||
)
|
||||
|
||||
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
|
||||
@@ -10,30 +10,38 @@ import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupMetadata
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.database.MessageType
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.ArchiveAttachmentJob
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob
|
||||
import org.thoughtcrime.securesms.jobs.AttachmentUploadJob
|
||||
import org.thoughtcrime.securesms.jobs.BackupMessagesJob
|
||||
import org.thoughtcrime.securesms.jobs.BackupRestoreJob
|
||||
import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.backup.BackupKey
|
||||
import org.whispersystems.signalservice.api.backup.MediaName
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
|
||||
private val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
var backupData: ByteArray? = null
|
||||
|
||||
val disposables = CompositeDisposable()
|
||||
@@ -57,6 +65,17 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerBackupJob() {
|
||||
_state.value = _state.value.copy(backupState = BackupState.EXPORT_IN_PROGRESS)
|
||||
|
||||
disposables += Single.fromCallable { ApplicationDependencies.getJobManager().runSynchronously(BackupMessagesJob(), 120_000) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy {
|
||||
_state.value = _state.value.copy(backupState = BackupState.BACKUP_JOB_DONE)
|
||||
}
|
||||
}
|
||||
|
||||
fun import() {
|
||||
backupData?.let {
|
||||
_state.value = _state.value.copy(backupState = BackupState.IMPORT_IN_PROGRESS)
|
||||
@@ -68,7 +87,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
disposables += Single.fromCallable { BackupRepository.import(it.size.toLong(), { ByteArrayInputStream(it) }, selfData, plaintext = plaintext) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { nothing ->
|
||||
.subscribeBy {
|
||||
backupData = null
|
||||
_state.value = _state.value.copy(backupState = BackupState.NONE)
|
||||
}
|
||||
@@ -85,7 +104,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
disposables += Single.fromCallable { BackupRepository.import(length, inputStreamFactory, selfData, plaintext = plaintext) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { nothing ->
|
||||
.subscribeBy {
|
||||
backupData = null
|
||||
_state.value = _state.value.copy(backupState = BackupState.NONE)
|
||||
}
|
||||
@@ -98,7 +117,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
disposables += Single.fromCallable { BackupRepository.validate(length, inputStreamFactory, selfData) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { nothing ->
|
||||
.subscribeBy {
|
||||
backupData = null
|
||||
_state.value = _state.value.copy(backupState = BackupState.NONE)
|
||||
}
|
||||
@@ -142,47 +161,77 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreFromRemote() {
|
||||
_state.value = _state.value.copy(backupState = BackupState.IMPORT_IN_PROGRESS)
|
||||
|
||||
disposables += Single.fromCallable {
|
||||
ApplicationDependencies
|
||||
.getJobManager()
|
||||
.startChain(BackupRestoreJob())
|
||||
.then(BackupRestoreMediaJob())
|
||||
.enqueueAndBlockUntilCompletion(120.seconds.inWholeMilliseconds)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy {
|
||||
_state.value = _state.value.copy(backupState = BackupState.NONE)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMedia() {
|
||||
disposables += Single
|
||||
.fromCallable { SignalDatabase.attachments.debugGetLatestAttachments() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.single())
|
||||
.subscribeBy {
|
||||
_mediaState.set { update(attachments = it.map { a -> BackupAttachment.from(backupKey, a) }) }
|
||||
_mediaState.set { update(attachments = it.map { a -> BackupAttachment(dbAttachment = a) }) }
|
||||
}
|
||||
}
|
||||
|
||||
fun archiveAttachmentMedia(attachments: Set<AttachmentId>) {
|
||||
disposables += Single
|
||||
.fromCallable { BackupRepository.debugGetArchivedMediaState() }
|
||||
.fromCallable {
|
||||
val toArchive = mediaState.value
|
||||
.attachments
|
||||
.filter { attachments.contains(it.dbAttachment.attachmentId) }
|
||||
.map { it.dbAttachment }
|
||||
|
||||
BackupRepository.archiveMedia(toArchive)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.single())
|
||||
.doOnSubscribe { _mediaState.set { update(inProgress = inProgressMediaIds + attachments) } }
|
||||
.doOnTerminate { _mediaState.set { update(inProgress = inProgressMediaIds - attachments) } }
|
||||
.subscribeBy { result ->
|
||||
when (result) {
|
||||
is NetworkResult.Success -> _mediaState.set { update(archiveStateLoaded = true, backedUpMediaIds = result.result.map { it.mediaId }.toSet()) }
|
||||
is NetworkResult.Success -> {
|
||||
loadMedia()
|
||||
result
|
||||
.result
|
||||
.sourceNotFoundResponses
|
||||
.forEach {
|
||||
reUploadAndArchiveMedia(result.result.mediaIdToAttachmentId(it.mediaId))
|
||||
}
|
||||
}
|
||||
else -> _mediaState.set { copy(error = MediaStateError(errorText = "$result")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun backupAttachmentMedia(mediaIds: Set<String>) {
|
||||
disposables += Single.fromCallable { mediaIds.mapNotNull { mediaState.value.idToAttachment[it]?.dbAttachment }.toList() }
|
||||
.map { BackupRepository.archiveMedia(it) }
|
||||
fun archiveAttachmentMedia(attachment: BackupAttachment) {
|
||||
disposables += Single.fromCallable { BackupRepository.archiveMedia(attachment.dbAttachment) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.single())
|
||||
.doOnSubscribe { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds + mediaIds) } }
|
||||
.doOnTerminate { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds - mediaIds) } }
|
||||
.doOnSubscribe { _mediaState.set { update(inProgress = inProgressMediaIds + attachment.dbAttachment.attachmentId) } }
|
||||
.doOnTerminate { _mediaState.set { update(inProgress = inProgressMediaIds - attachment.dbAttachment.attachmentId) } }
|
||||
.subscribeBy { result ->
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val response = result.result
|
||||
val successes = response.responses.filter { it.status == 200 }
|
||||
val failures = response.responses - successes.toSet()
|
||||
|
||||
_mediaState.set {
|
||||
var updated = update(backedUpMediaIds = backedUpMediaIds + successes.map { it.mediaId })
|
||||
if (failures.isNotEmpty()) {
|
||||
updated = updated.copy(error = MediaStateError(errorText = failures.toString()))
|
||||
}
|
||||
updated
|
||||
is NetworkResult.Success -> loadMedia()
|
||||
is NetworkResult.StatusCodeError -> {
|
||||
if (result.code == 410) {
|
||||
reUploadAndArchiveMedia(attachment.id)
|
||||
} else {
|
||||
_mediaState.set { copy(error = MediaStateError(errorText = "$result")) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,49 +240,107 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun backupAttachmentMedia(attachment: BackupAttachment) {
|
||||
disposables += Single.fromCallable { BackupRepository.archiveMedia(attachment.dbAttachment) }
|
||||
private fun reUploadAndArchiveMedia(attachmentId: AttachmentId) {
|
||||
disposables += Single
|
||||
.fromCallable {
|
||||
ApplicationDependencies
|
||||
.getJobManager()
|
||||
.startChain(AttachmentUploadJob(attachmentId))
|
||||
.then(ArchiveAttachmentJob(attachmentId))
|
||||
.enqueueAndBlockUntilCompletion(15.seconds.inWholeMilliseconds)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.single())
|
||||
.doOnSubscribe { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds + attachment.mediaId) } }
|
||||
.doOnTerminate { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds - attachment.mediaId) } }
|
||||
.doOnSubscribe { _mediaState.set { update(inProgress = inProgressMediaIds + attachmentId) } }
|
||||
.doOnTerminate { _mediaState.set { update(inProgress = inProgressMediaIds - attachmentId) } }
|
||||
.subscribeBy {
|
||||
when (it) {
|
||||
is NetworkResult.Success -> {
|
||||
_mediaState.set { update(backedUpMediaIds = backedUpMediaIds + attachment.mediaId) }
|
||||
}
|
||||
|
||||
else -> _mediaState.set { copy(error = MediaStateError(errorText = "$it")) }
|
||||
if (it.isPresent && it.get().isComplete) {
|
||||
loadMedia()
|
||||
} else {
|
||||
_mediaState.set { copy(error = MediaStateError(errorText = "Reupload slow or failed, try again")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteBackupAttachmentMedia(mediaIds: Set<String>) {
|
||||
deleteBackupAttachmentMedia(mediaIds.mapNotNull { mediaState.value.idToAttachment[it] }.toList())
|
||||
fun deleteArchivedMedia(attachmentIds: Set<AttachmentId>) {
|
||||
deleteArchivedMedia(mediaState.value.attachments.filter { attachmentIds.contains(it.dbAttachment.attachmentId) })
|
||||
}
|
||||
|
||||
fun deleteBackupAttachmentMedia(attachment: BackupAttachment) {
|
||||
deleteBackupAttachmentMedia(listOf(attachment))
|
||||
fun deleteArchivedMedia(attachment: BackupAttachment) {
|
||||
deleteArchivedMedia(listOf(attachment))
|
||||
}
|
||||
|
||||
private fun deleteBackupAttachmentMedia(attachments: List<BackupAttachment>) {
|
||||
val ids = attachments.map { it.mediaId }.toSet()
|
||||
private fun deleteArchivedMedia(attachments: List<BackupAttachment>) {
|
||||
val ids = attachments.map { it.dbAttachment.attachmentId }.toSet()
|
||||
disposables += Single.fromCallable { BackupRepository.deleteArchivedMedia(attachments.map { it.dbAttachment }) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.single())
|
||||
.doOnSubscribe { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds + ids) } }
|
||||
.doOnTerminate { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds - ids) } }
|
||||
.doOnSubscribe { _mediaState.set { update(inProgress = inProgressMediaIds + ids) } }
|
||||
.doOnTerminate { _mediaState.set { update(inProgress = inProgressMediaIds - ids) } }
|
||||
.subscribeBy {
|
||||
when (it) {
|
||||
is NetworkResult.Success -> {
|
||||
_mediaState.set { update(backedUpMediaIds = backedUpMediaIds - ids) }
|
||||
}
|
||||
|
||||
is NetworkResult.Success -> loadMedia()
|
||||
else -> _mediaState.set { copy(error = MediaStateError(errorText = "$it")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteAllArchivedMedia() {
|
||||
disposables += Single
|
||||
.fromCallable { BackupRepository.debugDeleteAllArchivedMedia() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.single())
|
||||
.subscribeBy { result ->
|
||||
when (result) {
|
||||
is NetworkResult.Success -> loadMedia()
|
||||
else -> _mediaState.set { copy(error = MediaStateError(errorText = "$result")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restoreArchivedMedia(attachment: BackupAttachment) {
|
||||
disposables += Completable
|
||||
.fromCallable {
|
||||
val recipientId = SignalStore.releaseChannelValues().releaseChannelRecipientId!!
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId))
|
||||
|
||||
val message = IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = recipientId,
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
serverTimeMillis = System.currentTimeMillis(),
|
||||
receivedTimeMillis = System.currentTimeMillis(),
|
||||
body = "Restored from Archive!?",
|
||||
serverGuid = UUID.randomUUID().toString()
|
||||
)
|
||||
|
||||
val insertMessage = SignalDatabase.messages.insertMessageInbox(message, threadId).get()
|
||||
|
||||
SignalDatabase.attachments.debugCopyAttachmentForArchiveRestore(
|
||||
insertMessage.messageId,
|
||||
attachment.dbAttachment
|
||||
)
|
||||
|
||||
val archivedAttachment = SignalDatabase.attachments.getAttachmentsForMessage(insertMessage.messageId).first()
|
||||
|
||||
ApplicationDependencies.getJobManager().add(
|
||||
AttachmentDownloadJob(
|
||||
messageId = insertMessage.messageId,
|
||||
attachmentId = archivedAttachment.attachmentId,
|
||||
manual = false,
|
||||
forceArchiveDownload = true
|
||||
)
|
||||
)
|
||||
}
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.single())
|
||||
.subscribeBy(
|
||||
onError = {
|
||||
_mediaState.set { copy(error = MediaStateError(errorText = "$it")) }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
@@ -246,7 +353,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
)
|
||||
|
||||
enum class BackupState(val inProgress: Boolean = false) {
|
||||
NONE, EXPORT_IN_PROGRESS(true), EXPORT_DONE, IMPORT_IN_PROGRESS(true)
|
||||
NONE, EXPORT_IN_PROGRESS(true), EXPORT_DONE, BACKUP_JOB_DONE, IMPORT_IN_PROGRESS(true)
|
||||
}
|
||||
|
||||
enum class BackupUploadState(val inProgress: Boolean = false) {
|
||||
@@ -261,67 +368,59 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
data class MediaState(
|
||||
val backupStateLoaded: Boolean = false,
|
||||
val attachments: List<BackupAttachment> = emptyList(),
|
||||
val backedUpMediaIds: Set<String> = emptySet(),
|
||||
val inProgressMediaIds: Set<String> = emptySet(),
|
||||
val inProgressMediaIds: Set<AttachmentId> = emptySet(),
|
||||
val error: MediaStateError? = null
|
||||
) {
|
||||
val idToAttachment: Map<String, BackupAttachment> = attachments.associateBy { it.mediaId }
|
||||
|
||||
fun update(
|
||||
archiveStateLoaded: Boolean = this.backupStateLoaded,
|
||||
attachments: List<BackupAttachment> = this.attachments,
|
||||
backedUpMediaIds: Set<String> = this.backedUpMediaIds,
|
||||
inProgressMediaIds: Set<String> = this.inProgressMediaIds
|
||||
inProgress: Set<AttachmentId> = this.inProgressMediaIds
|
||||
): MediaState {
|
||||
val updatedAttachments = if (archiveStateLoaded) {
|
||||
attachments.map {
|
||||
val state = if (inProgressMediaIds.contains(it.mediaId)) {
|
||||
BackupAttachment.State.IN_PROGRESS
|
||||
} else if (backedUpMediaIds.contains(it.mediaId)) {
|
||||
BackupAttachment.State.UPLOADED
|
||||
} else {
|
||||
BackupAttachment.State.LOCAL_ONLY
|
||||
}
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
it.copy(state = state)
|
||||
val updatedAttachments = attachments.map {
|
||||
val state = if (inProgress.contains(it.dbAttachment.attachmentId)) {
|
||||
BackupAttachment.State.IN_PROGRESS
|
||||
} else if (it.dbAttachment.archiveMediaName != null) {
|
||||
if (it.dbAttachment.remoteDigest != null) {
|
||||
val mediaId = backupKey.deriveMediaId(MediaName(it.dbAttachment.archiveMediaName)).encode()
|
||||
if (it.dbAttachment.archiveMediaId == mediaId) {
|
||||
BackupAttachment.State.UPLOADED_FINAL
|
||||
} else {
|
||||
BackupAttachment.State.UPLOADED_UNDOWNLOADED
|
||||
}
|
||||
} else {
|
||||
BackupAttachment.State.UPLOADED_UNDOWNLOADED
|
||||
}
|
||||
} else if (it.dbAttachment.dataHash == null) {
|
||||
BackupAttachment.State.ATTACHMENT_CDN
|
||||
} else {
|
||||
BackupAttachment.State.LOCAL_ONLY
|
||||
}
|
||||
} else {
|
||||
attachments
|
||||
|
||||
it.copy(state = state)
|
||||
}
|
||||
|
||||
return copy(
|
||||
backupStateLoaded = archiveStateLoaded,
|
||||
attachments = updatedAttachments,
|
||||
backedUpMediaIds = backedUpMediaIds
|
||||
attachments = updatedAttachments
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class BackupAttachment(
|
||||
val dbAttachment: DatabaseAttachment,
|
||||
val state: State = State.INIT,
|
||||
val mediaId: String = Base64.encodeUrlSafeWithPadding(Random.nextBytes(15))
|
||||
val state: State = State.LOCAL_ONLY
|
||||
) {
|
||||
val id: Any = dbAttachment.attachmentId
|
||||
val id: AttachmentId = dbAttachment.attachmentId
|
||||
val title: String = dbAttachment.attachmentId.toString()
|
||||
|
||||
enum class State {
|
||||
INIT,
|
||||
ATTACHMENT_CDN,
|
||||
LOCAL_ONLY,
|
||||
UPLOADED,
|
||||
UPLOADED_UNDOWNLOADED,
|
||||
UPLOADED_FINAL,
|
||||
IN_PROGRESS
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(backupKey: BackupKey, dbAttachment: DatabaseAttachment): BackupAttachment {
|
||||
return BackupAttachment(
|
||||
dbAttachment = dbAttachment,
|
||||
mediaId = backupKey.deriveMediaId(Base64.decode(dbAttachment.dataHash!!)).toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class MediaStateError(
|
||||
|
||||
Reference in New Issue
Block a user