mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-22 12:08:34 +00:00
Add archive media apis.
This commit is contained in:
committed by
Alex Hart
parent
ccc9752485
commit
218964cbda
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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!" }
|
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
|
||||||
|
|||||||
@@ -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.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user