Add archive media apis.

This commit is contained in:
Cody Henthorne
2024-03-05 11:00:29 -05:00
committed by Alex Hart
parent ccc9752485
commit 218964cbda
18 changed files with 785 additions and 62 deletions

View File

@@ -22,6 +22,9 @@ class DatabaseAttachment : Attachment {
@JvmField @JvmField
val hasData: Boolean val hasData: Boolean
@JvmField
val dataHash: String?
private val hasThumbnail: Boolean private val hasThumbnail: Boolean
val displayOrder: Int val displayOrder: Int
@@ -53,7 +56,8 @@ class DatabaseAttachment : Attachment {
audioHash: AudioHash?, audioHash: AudioHash?,
transformProperties: TransformProperties?, transformProperties: TransformProperties?,
displayOrder: Int, displayOrder: Int,
uploadTimestamp: Long uploadTimestamp: Long,
dataHash: String?
) : super( ) : super(
contentType = contentType!!, contentType = contentType!!,
transferState = transferProgress, transferState = transferProgress,
@@ -81,6 +85,7 @@ class DatabaseAttachment : Attachment {
this.attachmentId = attachmentId this.attachmentId = attachmentId
this.mmsId = mmsId this.mmsId = mmsId
this.hasData = hasData this.hasData = hasData
this.dataHash = dataHash
this.hasThumbnail = hasThumbnail this.hasThumbnail = hasThumbnail
this.displayOrder = displayOrder this.displayOrder = displayOrder
} }
@@ -88,6 +93,7 @@ class DatabaseAttachment : Attachment {
constructor(parcel: Parcel) : super(parcel) { constructor(parcel: Parcel) : super(parcel) {
attachmentId = ParcelCompat.readParcelable(parcel, AttachmentId::class.java.classLoader, AttachmentId::class.java)!! attachmentId = ParcelCompat.readParcelable(parcel, AttachmentId::class.java.classLoader, AttachmentId::class.java)!!
hasData = ParcelUtil.readBoolean(parcel) hasData = ParcelUtil.readBoolean(parcel)
dataHash = parcel.readString()
hasThumbnail = ParcelUtil.readBoolean(parcel) hasThumbnail = ParcelUtil.readBoolean(parcel)
mmsId = parcel.readLong() mmsId = parcel.readLong()
displayOrder = parcel.readInt() displayOrder = parcel.readInt()
@@ -97,6 +103,7 @@ class DatabaseAttachment : Attachment {
super.writeToParcel(dest, flags) super.writeToParcel(dest, flags)
dest.writeParcelable(attachmentId, 0) dest.writeParcelable(attachmentId, 0)
ParcelUtil.writeBoolean(dest, hasData) ParcelUtil.writeBoolean(dest, hasData)
dest.writeString(dataHash)
ParcelUtil.writeBoolean(dest, hasThumbnail) ParcelUtil.writeBoolean(dest, hasThumbnail)
dest.writeLong(mmsId) dest.writeLong(mmsId)
dest.writeInt(displayOrder) dest.writeInt(displayOrder)

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.backup.v2 package org.thoughtcrime.securesms.backup.v2
import org.signal.core.util.Base64
import org.signal.core.util.EventTimer import org.signal.core.util.EventTimer
import org.signal.core.util.logging.Log import org.signal.core.util.logging.Log
import org.signal.core.util.withinTransaction 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.messagebackup.MessageBackupKey
import org.signal.libsignal.protocol.ServiceId.Aci import org.signal.libsignal.protocol.ServiceId.Aci
import org.signal.libsignal.zkgroup.profiles.ProfileKey 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.ChatItemImportInserter
import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore
import org.thoughtcrime.securesms.backup.v2.processor.AccountDataProcessor 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.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.NetworkResult 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.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.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -271,6 +281,77 @@ object BackupRepository {
.also { Log.i(TAG, "OverallResult: $it") } is NetworkResult.Success .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. * Retrieves an auth credential, preferring a cached value if available.
*/ */
@@ -298,6 +379,21 @@ object BackupRepository {
val e164: String, val e164: String,
val profileKey: ProfileKey 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 { class ExportState {

View File

@@ -12,7 +12,11 @@ import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -34,6 +52,7 @@ import androidx.compose.ui.unit.dp
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import org.signal.core.ui.Buttons import org.signal.core.ui.Buttons
import org.signal.core.ui.Dividers import org.signal.core.ui.Dividers
import org.signal.core.ui.Snackbars
import org.signal.core.ui.theme.SignalTheme import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.bytes import org.signal.core.util.bytes
import org.signal.core.util.getLength import org.signal.core.util.getLength
@@ -88,7 +107,14 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
@Composable @Composable
override fun FragmentContent() { override fun FragmentContent() {
val state by viewModel.state val state by viewModel.state
val mediaState by viewModel.mediaState
LaunchedEffect(Unit) {
viewModel.loadMedia()
}
Tabs(
mainContent = {
Screen( Screen(
state = state, state = state,
onExportClicked = { viewModel.export() }, onExportClicked = { viewModel.export() },
@@ -125,10 +151,51 @@ class InternalBackupPlaygroundFragment : ComposeFragment() {
validateFileLauncher.launch(intent) 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?) { @Composable
super.onActivityResult(requestCode, resultCode, data) 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 -> { BackupState.NONE -> {
StateLabel("") StateLabel("")
} }
BackupState.EXPORT_IN_PROGRESS -> { BackupState.EXPORT_IN_PROGRESS -> {
StateLabel("Export in progress...") StateLabel("Export in progress...")
} }
BackupState.EXPORT_DONE -> { 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.") 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") Text("Save to file")
} }
} }
BackupState.IMPORT_IN_PROGRESS -> { BackupState.IMPORT_IN_PROGRESS -> {
StateLabel("Import in progress...") StateLabel("Import in progress...")
} }
@@ -229,12 +299,15 @@ fun Screen(
is InternalBackupPlaygroundViewModel.RemoteBackupState.Available -> { 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)") 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 -> { InternalBackupPlaygroundViewModel.RemoteBackupState.GeneralError -> {
StateLabel("Hit an unknown error. Check the logs.") StateLabel("Hit an unknown error. Check the logs.")
} }
InternalBackupPlaygroundViewModel.RemoteBackupState.NotFound -> { InternalBackupPlaygroundViewModel.RemoteBackupState.NotFound -> {
StateLabel("Not found.") StateLabel("Not found.")
} }
InternalBackupPlaygroundViewModel.RemoteBackupState.Unknown -> { InternalBackupPlaygroundViewModel.RemoteBackupState.Unknown -> {
StateLabel("Hit the button above to check the state.") StateLabel("Hit the button above to check the state.")
} }
@@ -255,12 +328,15 @@ fun Screen(
BackupUploadState.NONE -> { BackupUploadState.NONE -> {
StateLabel("") StateLabel("")
} }
BackupUploadState.UPLOAD_IN_PROGRESS -> { BackupUploadState.UPLOAD_IN_PROGRESS -> {
StateLabel("Upload in progress...") StateLabel("Upload in progress...")
} }
BackupUploadState.UPLOAD_DONE -> { BackupUploadState.UPLOAD_DONE -> {
StateLabel("Upload complete.") StateLabel("Upload complete.")
} }
BackupUploadState.UPLOAD_FAILED -> { BackupUploadState.UPLOAD_FAILED -> {
StateLabel("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 = "Light Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(name = "Dark Theme", group = "screen", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable

View File

@@ -13,17 +13,27 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import io.reactivex.rxjava3.schedulers.Schedulers import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.Base64
import org.signal.libsignal.zkgroup.profiles.ProfileKey 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.BackupMetadata
import org.thoughtcrime.securesms.backup.v2.BackupRepository 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.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.backup.BackupKey
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import java.util.UUID
import kotlin.random.Random
class InternalBackupPlaygroundViewModel : ViewModel() { class InternalBackupPlaygroundViewModel : ViewModel() {
private val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
var backupData: ByteArray? = null var backupData: ByteArray? = null
val disposables = CompositeDisposable() 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)) private val _state: MutableState<ScreenState> = mutableStateOf(ScreenState(backupState = BackupState.NONE, uploadState = BackupUploadState.NONE, plaintext = false))
val state: State<ScreenState> = _state val state: State<ScreenState> = _state
private val _mediaState: MutableState<MediaState> = mutableStateOf(MediaState())
val mediaState: State<MediaState> = _mediaState
fun export() { fun export() {
_state.value = _state.value.copy(backupState = BackupState.EXPORT_IN_PROGRESS) _state.value = _state.value.copy(backupState = BackupState.EXPORT_IN_PROGRESS)
val plaintext = _state.value.plaintext val plaintext = _state.value.plaintext
@@ -117,9 +130,11 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
result is NetworkResult.Success -> { result is NetworkResult.Success -> {
_state.value = _state.value.copy(remoteBackupState = RemoteBackupState.Available(result.result)) _state.value = _state.value.copy(remoteBackupState = RemoteBackupState.Available(result.result))
} }
result is NetworkResult.StatusCodeError && result.code == 404 -> { result is NetworkResult.StatusCodeError && result.code == 404 -> {
_state.value = _state.value.copy(remoteBackupState = RemoteBackupState.NotFound) _state.value = _state.value.copy(remoteBackupState = RemoteBackupState.NotFound)
} }
else -> { else -> {
_state.value = _state.value.copy(remoteBackupState = RemoteBackupState.GeneralError) _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() { override fun onCleared() {
disposables.clear() disposables.clear()
} }
@@ -152,4 +259,77 @@ class InternalBackupPlaygroundViewModel : ViewModel() {
object GeneralError : RemoteBackupState() object GeneralError : RemoteBackupState()
data class Available(val response: BackupMetadata) : 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()
}
} }

View File

@@ -949,7 +949,8 @@ class AttachmentTable(
audioHash = if (MediaUtil.isAudioType(contentType)) AudioHash.parseOrNull(jsonObject.getString(BLUR_HASH)) else null, audioHash = if (MediaUtil.isAudioType(contentType)) AudioHash.parseOrNull(jsonObject.getString(BLUR_HASH)) else null,
transformProperties = TransformProperties.parse(jsonObject.getString(TRANSFORM_PROPERTIES)), transformProperties = TransformProperties.parse(jsonObject.getString(TRANSFORM_PROPERTIES)),
displayOrder = jsonObject.getInt(DISPLAY_ORDER), 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, audioHash = if (MediaUtil.isAudioType(contentType)) AudioHash.parseOrNull(cursor.requireString(BLUR_HASH)) else null,
transformProperties = TransformProperties.parse(cursor.requireString(TRANSFORM_PROPERTIES)), transformProperties = TransformProperties.parse(cursor.requireString(TRANSFORM_PROPERTIES)),
displayOrder = cursor.requireInt(DISPLAY_ORDER), 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 @VisibleForTesting
class DataInfo( class DataInfo(
val file: File, val file: File,

View File

@@ -49,6 +49,7 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD
${AttachmentTable.TABLE_NAME}.${AttachmentTable.UPLOAD_TIMESTAMP}, ${AttachmentTable.TABLE_NAME}.${AttachmentTable.UPLOAD_TIMESTAMP},
${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_INCREMENTAL_DIGEST}, ${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.TYPE},
${MessageTable.TABLE_NAME}.${MessageTable.DATE_SENT}, ${MessageTable.TABLE_NAME}.${MessageTable.DATE_SENT},
${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED}, ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED},

View File

@@ -255,7 +255,8 @@ class UploadDependencyGraphTest {
audioHash = attachment.audioHash, audioHash = attachment.audioHash,
transformProperties = attachment.transformProperties, transformProperties = attachment.transformProperties,
displayOrder = 0, displayOrder = 0,
uploadTimestamp = attachment.uploadTimestamp uploadTimestamp = attachment.uploadTimestamp,
dataHash = null
) )
} }

View File

@@ -55,7 +55,8 @@ object FakeMessageRecords {
audioHash: AudioHash? = null, audioHash: AudioHash? = null,
transformProperties: AttachmentTable.TransformProperties? = null, transformProperties: AttachmentTable.TransformProperties? = null,
displayOrder: Int = 0, displayOrder: Int = 0,
uploadTimestamp: Long = 200 uploadTimestamp: Long = 200,
dataHash: String? = null
): DatabaseAttachment { ): DatabaseAttachment {
return DatabaseAttachment( return DatabaseAttachment(
attachmentId, attachmentId,
@@ -85,7 +86,8 @@ object FakeMessageRecords {
audioHash, audioHash,
transformProperties, transformProperties,
displayOrder, displayOrder,
uploadTimestamp uploadTimestamp,
dataHash
) )
} }

View File

@@ -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. * 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. * 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 { return NetworkResult.fromFetch {
val zkCredential = getZkCredential(backupKey, serviceCredential) val zkCredential = getZkCredential(backupKey, serviceCredential)
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams) val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams)
@@ -154,7 +154,58 @@ class ArchiveApi(
val zkCredential = getZkCredential(backupKey, serviceCredential) val zkCredential = getZkCredential(backupKey, serviceCredential)
val presentationData = CredentialPresentationData.from(backupKey, zkCredential, backupServerPublicParams) 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)
} }
} }

View File

@@ -5,10 +5,19 @@
package org.whispersystems.signalservice.api.archive package org.whispersystems.signalservice.api.archive
import org.signal.core.util.Base64
/** /**
* Acts as credentials for various archive operations. * Acts as credentials for various archive operations.
*/ */
class ArchiveCredentialPresentation( class ArchiveCredentialPresentation(
val presentation: ByteArray, val presentation: ByteArray,
val signedPresentation: 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)
) )
}
}

View File

@@ -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
)
}

View File

@@ -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
)

View File

@@ -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>
)

View File

@@ -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
)
}

View File

@@ -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
)
}

View File

@@ -16,7 +16,7 @@ class BackupKey(val value: ByteArray) {
require(value.size == 32) { "Backup key must be 32 bytes!" } require(value.size == 32) { "Backup key must be 32 bytes!" }
} }
fun deriveSecrets(aci: ACI): KeyMaterial { fun deriveSecrets(aci: ACI): KeyMaterial<BackupId> {
val backupId = BackupId( val backupId = BackupId(
HKDF.deriveSecrets(this.value, aci.toByteArray(), "20231003_Signal_Backups_GenerateBackupId".toByteArray(), 16) 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) val extendedKey = HKDF.deriveSecrets(this.value, backupId.value, "20231003_Signal_Backups_EncryptMessageBackup".toByteArray(), 80)
return KeyMaterial( return KeyMaterial(
backupId = backupId, id = backupId,
macKey = extendedKey.copyOfRange(0, 32), macKey = extendedKey.copyOfRange(0, 32),
cipherKey = extendedKey.copyOfRange(32, 64), cipherKey = extendedKey.copyOfRange(32, 64),
iv = extendedKey.copyOfRange(64, 80) iv = extendedKey.copyOfRange(64, 80)
) )
} }
class KeyMaterial( fun deriveMediaId(dataHash: ByteArray): MediaId {
val backupId: BackupId, 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 macKey: ByteArray,
val cipherKey: ByteArray, val cipherKey: ByteArray,
val iv: ByteArray val iv: ByteArray

View File

@@ -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)
}
}

View File

@@ -19,7 +19,6 @@ import org.signal.libsignal.protocol.ecc.ECPublicKey;
import org.signal.libsignal.protocol.kem.KEMPublicKey; import org.signal.libsignal.protocol.kem.KEMPublicKey;
import org.signal.libsignal.protocol.logging.Log; import org.signal.libsignal.protocol.logging.Log;
import org.signal.libsignal.protocol.state.PreKeyBundle; 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.protocol.util.Pair;
import org.signal.libsignal.usernames.BaseUsernameException; import org.signal.libsignal.usernames.BaseUsernameException;
import org.signal.libsignal.usernames.Username; 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.ArchiveCredentialPresentation;
import org.whispersystems.signalservice.api.archive.ArchiveGetBackupInfoResponse; import org.whispersystems.signalservice.api.archive.ArchiveGetBackupInfoResponse;
import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse; 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.ArchiveMessageBackupUploadFormResponse;
import org.whispersystems.signalservice.api.archive.ArchiveServiceCredentialsResponse; import org.whispersystems.signalservice.api.archive.ArchiveServiceCredentialsResponse;
import org.whispersystems.signalservice.api.archive.ArchiveSetBackupIdRequest; import org.whispersystems.signalservice.api.archive.ArchiveSetBackupIdRequest;
import org.whispersystems.signalservice.api.archive.ArchiveSetPublicKeyRequest; 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.crypto.UnidentifiedAccess;
import org.whispersystems.signalservice.api.groupsv2.CredentialResponse; import org.whispersystems.signalservice.api.groupsv2.CredentialResponse;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2AuthorizationString; 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 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_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_BACKUP_ID = "/v1/archives/backupid";
private static final String ARCHIVE_PUBLIC_KEY = "/v1/archives/keys"; private static final String ARCHIVE_PUBLIC_KEY = "/v1/archives/keys";
private static final String ARCHIVE_INFO = "/v1/archives"; 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_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_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 CALL_LINK_CREATION_AUTH = "/v1/call-link/create-auth";
private static final String SERVER_DELIVERED_TIMESTAMP_HEADER = "X-Signal-Timestamp"; 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 { public void setArchivePublicKey(ECPublicKey publicKey, ArchiveCredentialPresentation credentialPresentation) throws IOException {
Map<String, String> headers = new HashMap<>(); Map<String, String> headers = credentialPresentation.toHeaders();
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
String body = JsonUtil.toJson(new ArchiveSetPublicKeyRequest(publicKey)); String body = JsonUtil.toJson(new ArchiveSetPublicKeyRequest(publicKey));
makeServiceRequestWithoutAuthentication(ARCHIVE_PUBLIC_KEY, "PUT", body, headers, NO_HANDLER); makeServiceRequestWithoutAuthentication(ARCHIVE_PUBLIC_KEY, "PUT", body, headers, NO_HANDLER);
} }
public ArchiveGetBackupInfoResponse getArchiveBackupInfo(ArchiveCredentialPresentation credentialPresentation) throws IOException { public ArchiveGetBackupInfoResponse getArchiveBackupInfo(ArchiveCredentialPresentation credentialPresentation) throws IOException {
Map<String, String> headers = new HashMap<>(); Map<String, String> headers = credentialPresentation.toHeaders();
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
String response = makeServiceRequestWithoutAuthentication(ARCHIVE_INFO, "GET", null, headers, NO_HANDLER); String response = makeServiceRequestWithoutAuthentication(ARCHIVE_INFO, "GET", null, headers, NO_HANDLER);
return JsonUtil.fromJson(response, ArchiveGetBackupInfoResponse.class); 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. * @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 { public ArchiveGetMediaItemsResponse getArchiveMediaItemsPage(ArchiveCredentialPresentation credentialPresentation, int limit, String cursor) throws IOException {
Map<String, String> headers = new HashMap<>(); Map<String, String> headers = credentialPresentation.toHeaders();
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
String url = String.format(Locale.US, ARCHIVE_MEDIA_LIST, limit); String url = String.format(Locale.US, ARCHIVE_MEDIA_LIST, limit);
@@ -544,10 +546,39 @@ public class PushServiceSocket {
return JsonUtil.fromJson(response, ArchiveGetMediaItemsResponse.class); 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 { public ArchiveMessageBackupUploadFormResponse getArchiveMessageBackupUploadForm(ArchiveCredentialPresentation credentialPresentation) throws IOException {
Map<String, String> headers = new HashMap<>(); Map<String, String> headers = credentialPresentation.toHeaders();
headers.put("X-Signal-ZK-Auth", Base64.encodeWithPadding(credentialPresentation.getPresentation()));
headers.put("X-Signal-ZK-Auth-Signature", Base64.encodeWithPadding(credentialPresentation.getSignedPresentation()));
String response = makeServiceRequestWithoutAuthentication(ARCHIVE_MESSAGE_UPLOAD_FORM, "GET", null, headers, NO_HANDLER); String response = makeServiceRequestWithoutAuthentication(ARCHIVE_MESSAGE_UPLOAD_FORM, "GET", null, headers, NO_HANDLER);
return JsonUtil.fromJson(response, ArchiveMessageBackupUploadFormResponse.class); return JsonUtil.fromJson(response, ArchiveMessageBackupUploadFormResponse.class);
@@ -2126,7 +2157,7 @@ public class PushServiceSocket {
throw new ServerRejectedException(); 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); throw new NonSuccessfulResponseCodeException(responseCode, "Bad response: " + responseCode + " " + responseMessage);
} }