Add archive media apis.
This commit is contained in:
parent
ccc9752485
commit
218964cbda
18 changed files with 785 additions and 62 deletions
|
@ -22,6 +22,9 @@ class DatabaseAttachment : Attachment {
|
|||
@JvmField
|
||||
val hasData: Boolean
|
||||
|
||||
@JvmField
|
||||
val dataHash: String?
|
||||
|
||||
private val hasThumbnail: Boolean
|
||||
val displayOrder: Int
|
||||
|
||||
|
@ -53,7 +56,8 @@ class DatabaseAttachment : Attachment {
|
|||
audioHash: AudioHash?,
|
||||
transformProperties: TransformProperties?,
|
||||
displayOrder: Int,
|
||||
uploadTimestamp: Long
|
||||
uploadTimestamp: Long,
|
||||
dataHash: String?
|
||||
) : super(
|
||||
contentType = contentType!!,
|
||||
transferState = transferProgress,
|
||||
|
@ -81,6 +85,7 @@ class DatabaseAttachment : Attachment {
|
|||
this.attachmentId = attachmentId
|
||||
this.mmsId = mmsId
|
||||
this.hasData = hasData
|
||||
this.dataHash = dataHash
|
||||
this.hasThumbnail = hasThumbnail
|
||||
this.displayOrder = displayOrder
|
||||
}
|
||||
|
@ -88,6 +93,7 @@ class DatabaseAttachment : Attachment {
|
|||
constructor(parcel: Parcel) : super(parcel) {
|
||||
attachmentId = ParcelCompat.readParcelable(parcel, AttachmentId::class.java.classLoader, AttachmentId::class.java)!!
|
||||
hasData = ParcelUtil.readBoolean(parcel)
|
||||
dataHash = parcel.readString()
|
||||
hasThumbnail = ParcelUtil.readBoolean(parcel)
|
||||
mmsId = parcel.readLong()
|
||||
displayOrder = parcel.readInt()
|
||||
|
@ -97,6 +103,7 @@ class DatabaseAttachment : Attachment {
|
|||
super.writeToParcel(dest, flags)
|
||||
dest.writeParcelable(attachmentId, 0)
|
||||
ParcelUtil.writeBoolean(dest, hasData)
|
||||
dest.writeString(dataHash)
|
||||
ParcelUtil.writeBoolean(dest, hasThumbnail)
|
||||
dest.writeLong(mmsId)
|
||||
dest.writeInt(displayOrder)
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
package org.thoughtcrime.securesms.backup.v2
|
||||
|
||||
import org.signal.core.util.Base64
|
||||
import org.signal.core.util.EventTimer
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.withinTransaction
|
||||
|
@ -13,6 +14,7 @@ import org.signal.libsignal.messagebackup.MessageBackup.ValidationResult
|
|||
import org.signal.libsignal.messagebackup.MessageBackupKey
|
||||
import org.signal.libsignal.protocol.ServiceId.Aci
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter
|
||||
import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore
|
||||
import org.thoughtcrime.securesms.backup.v2.processor.AccountDataProcessor
|
||||
|
@ -33,9 +35,17 @@ import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob
|
|||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential
|
||||
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse
|
||||
import org.whispersystems.signalservice.api.archive.DeleteArchivedMediaRequest
|
||||
import org.whispersystems.signalservice.api.backup.BackupKey
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.PNI
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
@ -271,6 +281,77 @@ object BackupRepository {
|
|||
.also { Log.i(TAG, "OverallResult: $it") } is NetworkResult.Success
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object with details about the remote backup state.
|
||||
*/
|
||||
fun debugGetArchivedMediaState(): NetworkResult<List<ArchiveGetMediaItemsResponse.StoredMediaObject>> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return api
|
||||
.triggerBackupIdReservation(backupKey)
|
||||
.then { getAuthCredential() }
|
||||
.then { credential ->
|
||||
api.debugGetUploadedMediaItemMetadata(backupKey, credential)
|
||||
}
|
||||
}
|
||||
|
||||
fun archiveMedia(attachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return api
|
||||
.triggerBackupIdReservation(backupKey)
|
||||
.then { getAuthCredential() }
|
||||
.then { credential ->
|
||||
api.archiveAttachmentMedia(
|
||||
backupKey = backupKey,
|
||||
serviceCredential = credential,
|
||||
item = attachment.toArchiveMediaRequest(backupKey)
|
||||
)
|
||||
}
|
||||
.also { Log.i(TAG, "backupMediaResult: $it") }
|
||||
}
|
||||
|
||||
fun archiveMedia(attachments: List<DatabaseAttachment>): NetworkResult<BatchArchiveMediaResponse> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
return api
|
||||
.triggerBackupIdReservation(backupKey)
|
||||
.then { getAuthCredential() }
|
||||
.then { credential ->
|
||||
api.archiveAttachmentMedia(
|
||||
backupKey = backupKey,
|
||||
serviceCredential = credential,
|
||||
items = attachments.map { it.toArchiveMediaRequest(backupKey) }
|
||||
)
|
||||
}
|
||||
.also { Log.i(TAG, "backupMediaResult: $it") }
|
||||
}
|
||||
|
||||
fun deleteArchivedMedia(attachments: List<DatabaseAttachment>): NetworkResult<Unit> {
|
||||
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
|
||||
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
val mediaToDelete = attachments.map {
|
||||
DeleteArchivedMediaRequest.ArchivedMediaObject(
|
||||
cdn = 3, // TODO [cody] store and reuse backup cdn returned from copy/move call
|
||||
mediaId = backupKey.deriveMediaId(Base64.decode(it.dataHash!!)).toString()
|
||||
)
|
||||
}
|
||||
|
||||
return getAuthCredential()
|
||||
.then { credential ->
|
||||
api.deleteArchivedMedia(
|
||||
backupKey = backupKey,
|
||||
serviceCredential = credential,
|
||||
mediaToDelete = mediaToDelete
|
||||
)
|
||||
}
|
||||
.also { Log.i(TAG, "deleteBackupMediaResult: $it") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an auth credential, preferring a cached value if available.
|
||||
*/
|
||||
|
@ -298,6 +379,21 @@ object BackupRepository {
|
|||
val e164: String,
|
||||
val profileKey: ProfileKey
|
||||
)
|
||||
|
||||
private fun DatabaseAttachment.toArchiveMediaRequest(backupKey: BackupKey): ArchiveMediaRequest {
|
||||
val mediaSecrets = backupKey.deriveMediaSecrets(Base64.decode(dataHash!!))
|
||||
return ArchiveMediaRequest(
|
||||
sourceAttachment = ArchiveMediaRequest.SourceAttachment(
|
||||
cdn = cdnNumber,
|
||||
key = remoteLocation!!
|
||||
),
|
||||
objectLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(size)).toInt(),
|
||||
mediaId = mediaSecrets.id.toString(),
|
||||
hmacKey = Base64.encodeWithPadding(mediaSecrets.macKey),
|
||||
encryptionKey = Base64.encodeWithPadding(mediaSecrets.cipherKey),
|
||||
iv = Base64.encodeWithPadding(mediaSecrets.iv)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class ExportState {
|
||||
|
|
|
@ -12,7 +12,11 @@ import android.os.Bundle
|
|||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
|
@ -20,12 +24,26 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
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.text.style.TextAlign
|
||||
|
@ -34,6 +52,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.fragment.app.viewModels
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Snackbars
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.bytes
|
||||
import org.signal.core.util.getLength
|
||||
|
@ -88,47 +107,95 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
|
|||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state
|
||||
val mediaState by viewModel.mediaState
|
||||
|
||||
Screen(
|
||||
state = state,
|
||||
onExportClicked = { viewModel.export() },
|
||||
onImportMemoryClicked = { viewModel.import() },
|
||||
onImportFileClicked = {
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_GET_CONTENT
|
||||
type = "application/octet-stream"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadMedia()
|
||||
}
|
||||
|
||||
importFileLauncher.launch(intent)
|
||||
Tabs(
|
||||
mainContent = {
|
||||
Screen(
|
||||
state = state,
|
||||
onExportClicked = { viewModel.export() },
|
||||
onImportMemoryClicked = { viewModel.import() },
|
||||
onImportFileClicked = {
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_GET_CONTENT
|
||||
type = "application/octet-stream"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
|
||||
importFileLauncher.launch(intent)
|
||||
},
|
||||
onPlaintextClicked = { viewModel.onPlaintextToggled() },
|
||||
onSaveToDiskClicked = {
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_CREATE_DOCUMENT
|
||||
type = "application/octet-stream"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_TITLE, "backup-${if (state.plaintext) "plaintext" else "encrypted"}-${System.currentTimeMillis()}.bin")
|
||||
}
|
||||
|
||||
exportFileLauncher.launch(intent)
|
||||
},
|
||||
onUploadToRemoteClicked = { viewModel.uploadBackupToRemote() },
|
||||
onCheckRemoteBackupStateClicked = { viewModel.checkRemoteBackupState() },
|
||||
onValidateFileClicked = {
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_GET_CONTENT
|
||||
type = "application/octet-stream"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
|
||||
validateFileLauncher.launch(intent)
|
||||
}
|
||||
)
|
||||
},
|
||||
onPlaintextClicked = { viewModel.onPlaintextToggled() },
|
||||
onSaveToDiskClicked = {
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_CREATE_DOCUMENT
|
||||
type = "application/octet-stream"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
putExtra(Intent.EXTRA_TITLE, "backup-${if (state.plaintext) "plaintext" else "encrypted"}-${System.currentTimeMillis()}.bin")
|
||||
}
|
||||
|
||||
exportFileLauncher.launch(intent)
|
||||
},
|
||||
onUploadToRemoteClicked = { viewModel.uploadBackupToRemote() },
|
||||
onCheckRemoteBackupStateClicked = { viewModel.checkRemoteBackupState() },
|
||||
onValidateFileClicked = {
|
||||
val intent = Intent().apply {
|
||||
action = Intent.ACTION_GET_CONTENT
|
||||
type = "application/octet-stream"
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
}
|
||||
|
||||
validateFileLauncher.launch(intent)
|
||||
mediaContent = { snackbarHostState ->
|
||||
MediaList(
|
||||
state = mediaState,
|
||||
snackbarHostState = snackbarHostState,
|
||||
backupAttachmentMedia = { viewModel.backupAttachmentMedia(it) },
|
||||
deleteBackupAttachmentMedia = { viewModel.deleteBackupAttachmentMedia(it) },
|
||||
batchBackupAttachmentMedia = { viewModel.backupAttachmentMedia(it) },
|
||||
batchDeleteBackupAttachmentMedia = { viewModel.deleteBackupAttachmentMedia(it) }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
@Composable
|
||||
fun Tabs(
|
||||
mainContent: @Composable () -> Unit,
|
||||
mediaContent: @Composable (snackbarHostState: SnackbarHostState) -> Unit
|
||||
) {
|
||||
val tabs = listOf("Main", "Media")
|
||||
var tabIndex by remember { mutableIntStateOf(0) }
|
||||
|
||||
val snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { Snackbars.Host(snackbarHostState) },
|
||||
topBar = {
|
||||
TabRow(selectedTabIndex = tabIndex) {
|
||||
tabs.forEachIndexed { index, tab ->
|
||||
Tab(
|
||||
text = { Text(tab) },
|
||||
selected = index == tabIndex,
|
||||
onClick = { tabIndex = index }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Surface(modifier = Modifier.padding(it)) {
|
||||
when (tabIndex) {
|
||||
0 -> mainContent()
|
||||
1 -> mediaContent(snackbarHostState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -198,9 +265,11 @@ fun Screen(
|
|||
BackupState.NONE -> {
|
||||
StateLabel("")
|
||||
}
|
||||
|
||||
BackupState.EXPORT_IN_PROGRESS -> {
|
||||
StateLabel("Export in progress...")
|
||||
}
|
||||
|
||||
BackupState.EXPORT_DONE -> {
|
||||
StateLabel("Export complete. Sitting in memory. You can click 'Import' to import that data, save it to a file, or upload it to remote.")
|
||||
|
||||
|
@ -210,6 +279,7 @@ fun Screen(
|
|||
Text("Save to file")
|
||||
}
|
||||
}
|
||||
|
||||
BackupState.IMPORT_IN_PROGRESS -> {
|
||||
StateLabel("Import in progress...")
|
||||
}
|
||||
|
@ -229,12 +299,15 @@ fun Screen(
|
|||
is InternalBackupPlaygroundViewModel.RemoteBackupState.Available -> {
|
||||
StateLabel("Exists/allocated. ${state.remoteBackupState.response.mediaCount} media items, using ${state.remoteBackupState.response.usedSpace} bytes (${state.remoteBackupState.response.usedSpace.bytes.inMebiBytes.roundedString(3)} MiB)")
|
||||
}
|
||||
|
||||
InternalBackupPlaygroundViewModel.RemoteBackupState.GeneralError -> {
|
||||
StateLabel("Hit an unknown error. Check the logs.")
|
||||
}
|
||||
|
||||
InternalBackupPlaygroundViewModel.RemoteBackupState.NotFound -> {
|
||||
StateLabel("Not found.")
|
||||
}
|
||||
|
||||
InternalBackupPlaygroundViewModel.RemoteBackupState.Unknown -> {
|
||||
StateLabel("Hit the button above to check the state.")
|
||||
}
|
||||
|
@ -255,12 +328,15 @@ fun Screen(
|
|||
BackupUploadState.NONE -> {
|
||||
StateLabel("")
|
||||
}
|
||||
|
||||
BackupUploadState.UPLOAD_IN_PROGRESS -> {
|
||||
StateLabel("Upload in progress...")
|
||||
}
|
||||
|
||||
BackupUploadState.UPLOAD_DONE -> {
|
||||
StateLabel("Upload complete.")
|
||||
}
|
||||
|
||||
BackupUploadState.UPLOAD_FAILED -> {
|
||||
StateLabel("Upload failed.")
|
||||
}
|
||||
|
@ -278,6 +354,124 @@ private fun StateLabel(text: String) {
|
|||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MediaList(
|
||||
state: InternalBackupPlaygroundViewModel.MediaState,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
backupAttachmentMedia: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit,
|
||||
deleteBackupAttachmentMedia: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit,
|
||||
batchBackupAttachmentMedia: (Set<String>) -> Unit,
|
||||
batchDeleteBackupAttachmentMedia: (Set<String>) -> Unit
|
||||
) {
|
||||
LaunchedEffect(state.error?.id) {
|
||||
state.error?.let {
|
||||
snackbarHostState.showSnackbar(it.errorText)
|
||||
}
|
||||
}
|
||||
|
||||
var selectionState by remember { mutableStateOf(MediaMultiSelectState()) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(
|
||||
count = state.attachments.size,
|
||||
key = { index -> state.attachments[index].id }
|
||||
) { index ->
|
||||
val attachment = state.attachments[index]
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
if (selectionState.selecting) {
|
||||
selectionState = selectionState.copy(selected = if (selectionState.selected.contains(attachment.mediaId)) selectionState.selected - attachment.mediaId else selectionState.selected + attachment.mediaId)
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
selectionState = if (selectionState.selecting) MediaMultiSelectState() else MediaMultiSelectState(selecting = true, selected = setOf(attachment.mediaId))
|
||||
}
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
if (selectionState.selecting) {
|
||||
Checkbox(
|
||||
checked = selectionState.selected.contains(attachment.mediaId),
|
||||
onCheckedChange = { selected ->
|
||||
selectionState = selectionState.copy(selected = if (selected) selectionState.selected + attachment.mediaId else selectionState.selected - attachment.mediaId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f, true)) {
|
||||
Text(text = "Attachment ${attachment.title}")
|
||||
Text(text = "State: ${attachment.state}")
|
||||
}
|
||||
|
||||
if (attachment.state == InternalBackupPlaygroundViewModel.BackupAttachment.State.INIT ||
|
||||
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)
|
||||
else -> throw AssertionError("Unsupported state: ${attachment.state}")
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = when (attachment.state) {
|
||||
InternalBackupPlaygroundViewModel.BackupAttachment.State.LOCAL_ONLY -> "Backup"
|
||||
InternalBackupPlaygroundViewModel.BackupAttachment.State.UPLOADED -> "Remote Delete"
|
||||
else -> throw AssertionError("Unsupported state: ${attachment.state}")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectionState.selecting) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 24.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Button(onClick = { selectionState = MediaMultiSelectState() }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
Button(onClick = {
|
||||
batchBackupAttachmentMedia(selectionState.selected)
|
||||
selectionState = MediaMultiSelectState()
|
||||
}) {
|
||||
Text("Backup")
|
||||
}
|
||||
Button(onClick = {
|
||||
batchDeleteBackupAttachmentMedia(selectionState.selected)
|
||||
selectionState = MediaMultiSelectState()
|
||||
}) {
|
||||
Text("Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class MediaMultiSelectState(
|
||||
val selecting: Boolean = false,
|
||||
val selected: Set<String> = emptySet()
|
||||
)
|
||||
|
||||
@Preview(name = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
|
|
|
@ -13,17 +13,27 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
|||
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.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupMetadata
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.backup.BackupKey
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
class InternalBackupPlaygroundViewModel : ViewModel() {
|
||||
|
||||
private val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
|
||||
|
||||
var backupData: ByteArray? = null
|
||||
|
||||
val disposables = CompositeDisposable()
|
||||
|
@ -31,6 +41,9 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
|||
private val _state: MutableState<ScreenState> = mutableStateOf(ScreenState(backupState = BackupState.NONE, uploadState = BackupUploadState.NONE, plaintext = false))
|
||||
val state: State<ScreenState> = _state
|
||||
|
||||
private val _mediaState: MutableState<MediaState> = mutableStateOf(MediaState())
|
||||
val mediaState: State<MediaState> = _mediaState
|
||||
|
||||
fun export() {
|
||||
_state.value = _state.value.copy(backupState = BackupState.EXPORT_IN_PROGRESS)
|
||||
val plaintext = _state.value.plaintext
|
||||
|
@ -117,9 +130,11 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
|||
result is NetworkResult.Success -> {
|
||||
_state.value = _state.value.copy(remoteBackupState = RemoteBackupState.Available(result.result))
|
||||
}
|
||||
|
||||
result is NetworkResult.StatusCodeError && result.code == 404 -> {
|
||||
_state.value = _state.value.copy(remoteBackupState = RemoteBackupState.NotFound)
|
||||
}
|
||||
|
||||
else -> {
|
||||
_state.value = _state.value.copy(remoteBackupState = RemoteBackupState.GeneralError)
|
||||
}
|
||||
|
@ -127,6 +142,98 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
|||
}
|
||||
}
|
||||
|
||||
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) }) }
|
||||
}
|
||||
|
||||
disposables += Single
|
||||
.fromCallable { BackupRepository.debugGetArchivedMediaState() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.single())
|
||||
.subscribeBy { result ->
|
||||
when (result) {
|
||||
is NetworkResult.Success -> _mediaState.set { update(archiveStateLoaded = true, backedUpMediaIds = result.result.map { it.mediaId }.toSet()) }
|
||||
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) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.single())
|
||||
.doOnSubscribe { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds + mediaIds) } }
|
||||
.doOnTerminate { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds - mediaIds) } }
|
||||
.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
|
||||
}
|
||||
}
|
||||
|
||||
else -> _mediaState.set { copy(error = MediaStateError(errorText = "$result")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun backupAttachmentMedia(attachment: BackupAttachment) {
|
||||
disposables += Single.fromCallable { BackupRepository.archiveMedia(attachment.dbAttachment) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(Schedulers.single())
|
||||
.doOnSubscribe { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds + attachment.mediaId) } }
|
||||
.doOnTerminate { _mediaState.set { update(inProgressMediaIds = inProgressMediaIds - attachment.mediaId) } }
|
||||
.subscribeBy {
|
||||
when (it) {
|
||||
is NetworkResult.Success -> {
|
||||
_mediaState.set { update(backedUpMediaIds = backedUpMediaIds + attachment.mediaId) }
|
||||
}
|
||||
|
||||
else -> _mediaState.set { copy(error = MediaStateError(errorText = "$it")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteBackupAttachmentMedia(mediaIds: Set<String>) {
|
||||
deleteBackupAttachmentMedia(mediaIds.mapNotNull { mediaState.value.idToAttachment[it] }.toList())
|
||||
}
|
||||
|
||||
fun deleteBackupAttachmentMedia(attachment: BackupAttachment) {
|
||||
deleteBackupAttachmentMedia(listOf(attachment))
|
||||
}
|
||||
|
||||
private fun deleteBackupAttachmentMedia(attachments: List<BackupAttachment>) {
|
||||
val ids = attachments.map { it.mediaId }.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) } }
|
||||
.subscribeBy {
|
||||
when (it) {
|
||||
is NetworkResult.Success -> {
|
||||
_mediaState.set { update(backedUpMediaIds = backedUpMediaIds - ids) }
|
||||
}
|
||||
|
||||
else -> _mediaState.set { copy(error = MediaStateError(errorText = "$it")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
@ -152,4 +259,77 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
|
|||
object GeneralError : RemoteBackupState()
|
||||
data class Available(val response: BackupMetadata) : RemoteBackupState()
|
||||
}
|
||||
|
||||
data class MediaState(
|
||||
val backupStateLoaded: Boolean = false,
|
||||
val attachments: List<BackupAttachment> = emptyList(),
|
||||
val backedUpMediaIds: Set<String> = emptySet(),
|
||||
val inProgressMediaIds: Set<String> = 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
|
||||
): 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
|
||||
}
|
||||
|
||||
it.copy(state = state)
|
||||
}
|
||||
} else {
|
||||
attachments
|
||||
}
|
||||
|
||||
return copy(
|
||||
backupStateLoaded = archiveStateLoaded,
|
||||
attachments = updatedAttachments,
|
||||
backedUpMediaIds = backedUpMediaIds
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class BackupAttachment(
|
||||
val dbAttachment: DatabaseAttachment,
|
||||
val state: State = State.INIT,
|
||||
val mediaId: String = Base64.encodeUrlSafeWithPadding(Random.nextBytes(15))
|
||||
) {
|
||||
val id: Any = dbAttachment.attachmentId
|
||||
val title: String = dbAttachment.attachmentId.toString()
|
||||
|
||||
enum class State {
|
||||
INIT,
|
||||
LOCAL_ONLY,
|
||||
UPLOADED,
|
||||
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(
|
||||
val id: UUID = UUID.randomUUID(),
|
||||
val errorText: String
|
||||
)
|
||||
|
||||
fun <T> MutableState<T>.set(update: T.() -> T) {
|
||||
this.value = this.value.update()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -949,7 +949,8 @@ class AttachmentTable(
|
|||
audioHash = if (MediaUtil.isAudioType(contentType)) AudioHash.parseOrNull(jsonObject.getString(BLUR_HASH)) else null,
|
||||
transformProperties = TransformProperties.parse(jsonObject.getString(TRANSFORM_PROPERTIES)),
|
||||
displayOrder = jsonObject.getInt(DISPLAY_ORDER),
|
||||
uploadTimestamp = jsonObject.getLong(UPLOAD_TIMESTAMP)
|
||||
uploadTimestamp = jsonObject.getLong(UPLOAD_TIMESTAMP),
|
||||
dataHash = jsonObject.getString(DATA_HASH)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1456,7 +1457,8 @@ class AttachmentTable(
|
|||
audioHash = if (MediaUtil.isAudioType(contentType)) AudioHash.parseOrNull(cursor.requireString(BLUR_HASH)) else null,
|
||||
transformProperties = TransformProperties.parse(cursor.requireString(TRANSFORM_PROPERTIES)),
|
||||
displayOrder = cursor.requireInt(DISPLAY_ORDER),
|
||||
uploadTimestamp = cursor.requireLong(UPLOAD_TIMESTAMP)
|
||||
uploadTimestamp = cursor.requireLong(UPLOAD_TIMESTAMP),
|
||||
dataHash = cursor.requireString(DATA_HASH)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1490,6 +1492,18 @@ class AttachmentTable(
|
|||
}
|
||||
}
|
||||
|
||||
fun debugGetLatestAttachments(): List<DatabaseAttachment> {
|
||||
return readableDatabase
|
||||
.select(*PROJECTION)
|
||||
.from(TABLE_NAME)
|
||||
.where("$TRANSFER_STATE == $TRANSFER_PROGRESS_DONE AND $REMOTE_LOCATION IS NOT NULL AND $DATA_HASH IS NOT NULL")
|
||||
.orderBy("$ID DESC")
|
||||
.limit(30)
|
||||
.run()
|
||||
.readToList { it.readAttachments() }
|
||||
.flatten()
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
class DataInfo(
|
||||
val file: File,
|
||||
|
|
|
@ -48,7 +48,8 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
|
|||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.CAPTION},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.UPLOAD_TIMESTAMP},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST_CHUNK_SIZE},
|
||||
${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_HASH},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.TYPE},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.DATE_SENT},
|
||||
${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED},
|
||||
|
|
|
@ -255,7 +255,8 @@ class UploadDependencyGraphTest {
|
|||
audioHash = attachment.audioHash,
|
||||
transformProperties = attachment.transformProperties,
|
||||
displayOrder = 0,
|
||||
uploadTimestamp = attachment.uploadTimestamp
|
||||
uploadTimestamp = attachment.uploadTimestamp,
|
||||
dataHash = null
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -55,7 +55,8 @@ object FakeMessageRecords {
|
|||
audioHash: AudioHash? = null,
|
||||
transformProperties: AttachmentTable.TransformProperties? = null,
|
||||
displayOrder: Int = 0,
|
||||
uploadTimestamp: Long = 200
|
||||
uploadTimestamp: Long = 200,
|
||||
dataHash: String? = null
|
||||
): DatabaseAttachment {
|
||||
return DatabaseAttachment(
|
||||
attachmentId,
|
||||
|
@ -85,7 +86,8 @@ object FakeMessageRecords {
|
|||
audioHash,
|
||||
transformProperties,
|
||||
displayOrder,
|
||||
uploadTimestamp
|
||||
uploadTimestamp,
|
||||
dataHash
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -125,7 +125,7 @@ class ArchiveApi(
|
|||
* Retrieves all media items in the user's archive. Note that this could be a very large number of items, making this only suitable for debugging.
|
||||
* Use [getArchiveMediaItemsPage] in production.
|
||||
*/
|
||||
fun debugGetUploadedMediaItemMetadata(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): NetworkResult<List<ArchiveGetMediaItemsResponse.StoredMediaObject>> {
|
||||
fun debugGetUploadedMediaItemMetadata(backupKey: BackupKey, serviceCredential: ArchiveServiceCredential): NetworkResult<List<StoredMediaObject>> {
|
||||
return NetworkResult.fromFetch {
|
||||
val zkCredential = getZkCredential(backupKey, serviceCredential)
|
||||
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
|
||||
|
@ -154,7 +154,58 @@ class ArchiveApi(
|
|||
val zkCredential = getZkCredential(backupKey, serviceCredential)
|
||||
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
|
||||
|
||||
pushServiceSocket.getArchiveMediaItemsPage(presentationData.toArchiveCredentialPresentation(), 512, cursor)
|
||||
pushServiceSocket.getArchiveMediaItemsPage(presentationData.toArchiveCredentialPresentation(), limit, cursor)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
|
||||
*/
|
||||
fun archiveAttachmentMedia(
|
||||
backupKey: BackupKey,
|
||||
serviceCredential: ArchiveServiceCredential,
|
||||
item: ArchiveMediaRequest
|
||||
): NetworkResult<ArchiveMediaResponse> {
|
||||
return NetworkResult.fromFetch {
|
||||
val zkCredential = getZkCredential(backupKey, serviceCredential)
|
||||
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
|
||||
|
||||
pushServiceSocket.archiveAttachmentMedia(presentationData.toArchiveCredentialPresentation(), item)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
|
||||
*/
|
||||
fun archiveAttachmentMedia(
|
||||
backupKey: BackupKey,
|
||||
serviceCredential: ArchiveServiceCredential,
|
||||
items: List<ArchiveMediaRequest>
|
||||
): NetworkResult<BatchArchiveMediaResponse> {
|
||||
return NetworkResult.fromFetch {
|
||||
val zkCredential = getZkCredential(backupKey, serviceCredential)
|
||||
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
|
||||
|
||||
val request = BatchArchiveMediaRequest(items = items)
|
||||
|
||||
pushServiceSocket.archiveAttachmentMedia(presentationData.toArchiveCredentialPresentation(), request)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete media from the backup cdn.
|
||||
*/
|
||||
fun deleteArchivedMedia(
|
||||
backupKey: BackupKey,
|
||||
serviceCredential: ArchiveServiceCredential,
|
||||
mediaToDelete: List<DeleteArchivedMediaRequest.ArchivedMediaObject>
|
||||
): NetworkResult<Unit> {
|
||||
return NetworkResult.fromFetch {
|
||||
val zkCredential = getZkCredential(backupKey, serviceCredential)
|
||||
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
|
||||
val request = DeleteArchivedMediaRequest(mediaToDelete = mediaToDelete)
|
||||
|
||||
pushServiceSocket.deleteArchivedMedia(presentationData.toArchiveCredentialPresentation(), request)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,10 +5,19 @@
|
|||
|
||||
package org.whispersystems.signalservice.api.archive
|
||||
|
||||
import org.signal.core.util.Base64
|
||||
|
||||
/**
|
||||
* Acts as credentials for various archive operations.
|
||||
*/
|
||||
class ArchiveCredentialPresentation(
|
||||
val presentation: ByteArray,
|
||||
val signedPresentation: ByteArray
|
||||
)
|
||||
) {
|
||||
fun toHeaders(): MutableMap<String, String> {
|
||||
return mutableMapOf(
|
||||
"X-Signal-ZK-Auth" to Base64.encodeWithPadding(presentation),
|
||||
"X-Signal-ZK-Auth-Signature" to Base64.encodeWithPadding(signedPresentation)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.archive
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Request to copy and re-encrypt media from the attachments cdn into the backup cdn.
|
||||
*/
|
||||
class ArchiveMediaRequest(
|
||||
@JsonProperty val sourceAttachment: SourceAttachment,
|
||||
@JsonProperty val objectLength: Int,
|
||||
@JsonProperty val mediaId: String,
|
||||
@JsonProperty val hmacKey: String,
|
||||
@JsonProperty val encryptionKey: String,
|
||||
@JsonProperty val iv: String
|
||||
) {
|
||||
class SourceAttachment(
|
||||
@JsonProperty val cdn: Int,
|
||||
@JsonProperty val key: String
|
||||
)
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.archive
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Response to archiving media, backup CDN number where media is located.
|
||||
*/
|
||||
class ArchiveMediaResponse(
|
||||
@JsonProperty val cdn: Int
|
||||
)
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.archive
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Request to copy and re-encrypt media from the attachments cdn into the backup cdn.
|
||||
*/
|
||||
class BatchArchiveMediaRequest(
|
||||
@JsonProperty val items: List<ArchiveMediaRequest>
|
||||
)
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.archive
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Multi-response data for a batch archive media operation.
|
||||
*/
|
||||
class BatchArchiveMediaResponse(
|
||||
@JsonProperty val responses: List<BatchArchiveMediaItemResponse>
|
||||
) {
|
||||
class BatchArchiveMediaItemResponse(
|
||||
@JsonProperty val status: Int?,
|
||||
@JsonProperty val failureReason: String?,
|
||||
@JsonProperty val cdn: Int?,
|
||||
@JsonProperty val mediaId: String
|
||||
)
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.archive
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
|
||||
/**
|
||||
* Delete media from the backup cdn.
|
||||
*/
|
||||
class DeleteArchivedMediaRequest(
|
||||
@JsonProperty val mediaToDelete: List<ArchivedMediaObject>
|
||||
) {
|
||||
class ArchivedMediaObject(
|
||||
@JsonProperty val cdn: Int,
|
||||
@JsonProperty val mediaId: String
|
||||
)
|
||||
}
|
|
@ -16,7 +16,7 @@ class BackupKey(val value: ByteArray) {
|
|||
require(value.size == 32) { "Backup key must be 32 bytes!" }
|
||||
}
|
||||
|
||||
fun deriveSecrets(aci: ACI): KeyMaterial {
|
||||
fun deriveSecrets(aci: ACI): KeyMaterial<BackupId> {
|
||||
val backupId = BackupId(
|
||||
HKDF.deriveSecrets(this.value, aci.toByteArray(), "20231003_Signal_Backups_GenerateBackupId".toByteArray(), 16)
|
||||
)
|
||||
|
@ -24,15 +24,32 @@ class BackupKey(val value: ByteArray) {
|
|||
val extendedKey = HKDF.deriveSecrets(this.value, backupId.value, "20231003_Signal_Backups_EncryptMessageBackup".toByteArray(), 80)
|
||||
|
||||
return KeyMaterial(
|
||||
backupId = backupId,
|
||||
id = backupId,
|
||||
macKey = extendedKey.copyOfRange(0, 32),
|
||||
cipherKey = extendedKey.copyOfRange(32, 64),
|
||||
iv = extendedKey.copyOfRange(64, 80)
|
||||
)
|
||||
}
|
||||
|
||||
class KeyMaterial(
|
||||
val backupId: BackupId,
|
||||
fun deriveMediaId(dataHash: ByteArray): MediaId {
|
||||
return MediaId(HKDF.deriveSecrets(value, dataHash, "Media ID".toByteArray(), 15))
|
||||
}
|
||||
|
||||
fun deriveMediaSecrets(dataHash: ByteArray): KeyMaterial<MediaId> {
|
||||
val mediaId = deriveMediaId(dataHash)
|
||||
|
||||
val extendedKey = HKDF.deriveSecrets(this.value, mediaId.value, "20231003_Signal_Backups_EncryptMedia".toByteArray(), 80)
|
||||
|
||||
return KeyMaterial(
|
||||
id = mediaId,
|
||||
macKey = extendedKey.copyOfRange(0, 32),
|
||||
cipherKey = extendedKey.copyOfRange(32, 64),
|
||||
iv = extendedKey.copyOfRange(64, 80)
|
||||
)
|
||||
}
|
||||
|
||||
class KeyMaterial<Id> (
|
||||
val id: Id,
|
||||
val macKey: ByteArray,
|
||||
val cipherKey: ByteArray,
|
||||
val iv: ByteArray
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.backup
|
||||
|
||||
import org.signal.core.util.Base64
|
||||
|
||||
/**
|
||||
* Safe typing around a mediaId, which is a 15-byte array.
|
||||
*/
|
||||
@JvmInline
|
||||
value class MediaId(val value: ByteArray) {
|
||||
|
||||
init {
|
||||
require(value.size == 15) { "MediaId must be 15 bytes!" }
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return Base64.encodeUrlSafeWithPadding(value)
|
||||
}
|
||||
}
|
|
@ -19,7 +19,6 @@ import org.signal.libsignal.protocol.ecc.ECPublicKey;
|
|||
import org.signal.libsignal.protocol.kem.KEMPublicKey;
|
||||
import org.signal.libsignal.protocol.logging.Log;
|
||||
import org.signal.libsignal.protocol.state.PreKeyBundle;
|
||||
import org.signal.libsignal.protocol.state.SignedPreKeyRecord;
|
||||
import org.signal.libsignal.protocol.util.Pair;
|
||||
import org.signal.libsignal.usernames.BaseUsernameException;
|
||||
import org.signal.libsignal.usernames.Username;
|
||||
|
@ -50,10 +49,15 @@ import org.whispersystems.signalservice.api.account.PreKeyUpload;
|
|||
import org.whispersystems.signalservice.api.archive.ArchiveCredentialPresentation;
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetBackupInfoResponse;
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse;
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest;
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse;
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveMessageBackupUploadFormResponse;
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredentialsResponse;
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveSetBackupIdRequest;
|
||||
import org.whispersystems.signalservice.api.archive.ArchiveSetPublicKeyRequest;
|
||||
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaRequest;
|
||||
import org.whispersystems.signalservice.api.archive.BatchArchiveMediaResponse;
|
||||
import org.whispersystems.signalservice.api.archive.DeleteArchivedMediaRequest;
|
||||
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess;
|
||||
import org.whispersystems.signalservice.api.groupsv2.CredentialResponse;
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString;
|
||||
|
@ -305,11 +309,15 @@ public class PushServiceSocket {
|
|||
private static final String BACKUP_AUTH_CHECK = "/v2/backup/auth/check";
|
||||
|
||||
private static final String ARCHIVE_CREDENTIALS = "/v1/archives/auth?redemptionStartSeconds=%d&redemptionEndSeconds=%d";
|
||||
private static final String ARCHIVE_READ_CREDENTIALS = "/v1/archives/auth/read";
|
||||
private static final String ARCHIVE_BACKUP_ID = "/v1/archives/backupid";
|
||||
private static final String ARCHIVE_PUBLIC_KEY = "/v1/archives/keys";
|
||||
private static final String ARCHIVE_INFO = "/v1/archives";
|
||||
private static final String ARCHIVE_MESSAGE_UPLOAD_FORM = "/v1/archives/upload/form";
|
||||
private static final String ARCHIVE_MEDIA = "/v1/archives/media";
|
||||
private static final String ARCHIVE_MEDIA_LIST = "/v1/archives/media?limit=%d";
|
||||
private static final String ARCHIVE_MEDIA_BATCH = "/v1/archives/media/batch";
|
||||
private static final String ARCHIVE_MEDIA_DELETE = "/v1/archives/media/delete";
|
||||
|
||||
private static final String CALL_LINK_CREATION_AUTH = "/v1/call-link/create-auth";
|
||||
private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp";
|
||||
|
@ -494,18 +502,14 @@ public class PushServiceSocket {
|
|||
}
|
||||
|
||||
public void setArchivePublicKey(ECPublicKey publicKey, ArchiveCredentialPresentation credentialPresentation) throws IOException {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
|
||||
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
|
||||
Map<String, String> headers = credentialPresentation.toHeaders();
|
||||
|
||||
String body = JsonUtil.toJson(new ArchiveSetPublicKeyRequest(publicKey));
|
||||
makeServiceRequestWithoutAuthentication(ARCHIVE_PUBLIC_KEY, "PUT", body, headers, NO_HANDLER);
|
||||
}
|
||||
|
||||
public ArchiveGetBackupInfoResponse getArchiveBackupInfo(ArchiveCredentialPresentation credentialPresentation) throws IOException {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
|
||||
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
|
||||
Map<String, String> headers = credentialPresentation.toHeaders();
|
||||
|
||||
String response = makeServiceRequestWithoutAuthentication(ARCHIVE_INFO, "GET", null, headers, NO_HANDLER);
|
||||
return JsonUtil.fromJson(response, ArchiveGetBackupInfoResponse.class);
|
||||
|
@ -529,9 +533,7 @@ public class PushServiceSocket {
|
|||
* @param cursor A token that can be read from your previous response, telling the server where to start the next page.
|
||||
*/
|
||||
public ArchiveGetMediaItemsResponse getArchiveMediaItemsPage(ArchiveCredentialPresentation credentialPresentation, int limit, String cursor) throws IOException {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
|
||||
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
|
||||
Map<String, String> headers = credentialPresentation.toHeaders();
|
||||
|
||||
String url = String.format(Locale.US, ARCHIVE_MEDIA_LIST, limit);
|
||||
|
||||
|
@ -544,10 +546,39 @@ public class PushServiceSocket {
|
|||
return JsonUtil.fromJson(response, ArchiveGetMediaItemsResponse.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
|
||||
*/
|
||||
public ArchiveMediaResponse archiveAttachmentMedia(@Nonnull ArchiveCredentialPresentation credentialPresentation, @Nonnull ArchiveMediaRequest request) throws IOException {
|
||||
Map<String, String> headers = credentialPresentation.toHeaders();
|
||||
|
||||
String response = makeServiceRequestWithoutAuthentication(ARCHIVE_MEDIA, "PUT", JsonUtil.toJson(request), headers, NO_HANDLER);
|
||||
|
||||
return JsonUtil.fromJson(response, ArchiveMediaResponse.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy and re-encrypt media from the attachments cdn into the backup cdn.
|
||||
*/
|
||||
public BatchArchiveMediaResponse archiveAttachmentMedia(@Nonnull ArchiveCredentialPresentation credentialPresentation, @Nonnull BatchArchiveMediaRequest request) throws IOException {
|
||||
Map<String, String> headers = credentialPresentation.toHeaders();
|
||||
|
||||
String response = makeServiceRequestWithoutAuthentication(ARCHIVE_MEDIA_BATCH, "PUT", JsonUtil.toJson(request), headers, NO_HANDLER);
|
||||
|
||||
return JsonUtil.fromJson(response, BatchArchiveMediaResponse.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete media from the backup cdn.
|
||||
*/
|
||||
public void deleteArchivedMedia(@Nonnull ArchiveCredentialPresentation credentialPresentation, @Nonnull DeleteArchivedMediaRequest request) throws IOException {
|
||||
Map<String, String> headers = credentialPresentation.toHeaders();
|
||||
|
||||
makeServiceRequestWithoutAuthentication(ARCHIVE_MEDIA_DELETE, "POST", JsonUtil.toJson(request), headers, NO_HANDLER);
|
||||
}
|
||||
|
||||
public ArchiveMessageBackupUploadFormResponse getArchiveMessageBackupUploadForm(ArchiveCredentialPresentation credentialPresentation) throws IOException {
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
|
||||
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
|
||||
Map<String, String> headers = credentialPresentation.toHeaders();
|
||||
|
||||
String response = makeServiceRequestWithoutAuthentication(ARCHIVE_MESSAGE_UPLOAD_FORM, "GET", null, headers, NO_HANDLER);
|
||||
return JsonUtil.fromJson(response, ArchiveMessageBackupUploadFormResponse.class);
|
||||
|
@ -2126,7 +2157,7 @@ public class PushServiceSocket {
|
|||
throw new ServerRejectedException();
|
||||
}
|
||||
|
||||
if (responseCode != 200 && responseCode != 202 && responseCode != 204) {
|
||||
if (responseCode != 200 && responseCode != 202 && responseCode != 204 && responseCode != 207) {
|
||||
throw new NonSuccessfulResponseCodeException(responseCode, "Bad response: " + responseCode + " " + responseMessage);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue