Add initial thumbnail restore for message backup.

This commit is contained in:
Clark 2024-05-15 14:58:29 -04:00 committed by Nicholas Tinsley
parent 757c0fd2ea
commit b72d586748
25 changed files with 397 additions and 50 deletions

View file

@ -22,6 +22,9 @@ class ArchivedAttachment : Attachment {
@JvmField
val archiveMediaId: String
@JvmField
val archiveThumbnailMediaId: String
constructor(
contentType: String?,
size: Long,
@ -31,6 +34,7 @@ class ArchivedAttachment : Attachment {
archiveCdn: Int?,
archiveMediaName: String,
archiveMediaId: String,
archiveThumbnailMediaId: String,
digest: ByteArray,
incrementalMac: ByteArray?,
incrementalMacChunkSize: Int?,
@ -70,12 +74,14 @@ class ArchivedAttachment : Attachment {
this.archiveCdn = archiveCdn ?: Cdn.CDN_3.cdnNumber
this.archiveMediaName = archiveMediaName
this.archiveMediaId = archiveMediaId
this.archiveThumbnailMediaId = archiveThumbnailMediaId
}
constructor(parcel: Parcel) : super(parcel) {
archiveCdn = parcel.readInt()
archiveMediaName = parcel.readString()!!
archiveMediaId = parcel.readString()!!
archiveThumbnailMediaId = parcel.readString()!!
}
override fun writeToParcel(dest: Parcel, flags: Int) {
@ -83,8 +89,10 @@ class ArchivedAttachment : Attachment {
dest.writeInt(archiveCdn)
dest.writeString(archiveMediaName)
dest.writeString(archiveMediaId)
dest.writeString(archiveThumbnailMediaId)
}
override val uri: Uri? = null
override val publicUri: Uri? = null
override val thumbnailUri: Uri? = null
}

View file

@ -70,6 +70,7 @@ abstract class Attachment(
abstract val uri: Uri?
abstract val publicUri: Uri?
abstract val thumbnailUri: Uri?
protected constructor(parcel: Parcel) : this(
contentType = parcel.readString()!!,
@ -129,7 +130,7 @@ abstract class Attachment(
}
val isInProgress: Boolean
get() = transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && transferState != AttachmentTable.TRANSFER_PROGRESS_FAILED && transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE
get() = transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && transferState != AttachmentTable.TRANSFER_PROGRESS_FAILED && transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE && transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED
val isPermanentlyFailed: Boolean
get() = transferState == AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE

View file

@ -28,12 +28,16 @@ class DatabaseAttachment : Attachment {
@JvmField
val archiveCdn: Int
@JvmField
val archiveThumbnailCdn: Int
@JvmField
val archiveMediaName: String?
@JvmField
val archiveMediaId: String?
private val hasArchiveThumbnail: Boolean
private val hasThumbnail: Boolean
val displayOrder: Int
@ -42,6 +46,7 @@ class DatabaseAttachment : Attachment {
mmsId: Long,
hasData: Boolean,
hasThumbnail: Boolean,
hasArchiveThumbnail: Boolean,
contentType: String?,
transferProgress: Int,
size: Long,
@ -68,6 +73,7 @@ class DatabaseAttachment : Attachment {
uploadTimestamp: Long,
dataHash: String?,
archiveCdn: Int,
archiveThumbnailCdn: Int,
archiveMediaName: String?,
archiveMediaId: String?
) : super(
@ -99,8 +105,10 @@ class DatabaseAttachment : Attachment {
this.hasData = hasData
this.dataHash = dataHash
this.hasThumbnail = hasThumbnail
this.hasArchiveThumbnail = hasArchiveThumbnail
this.displayOrder = displayOrder
this.archiveCdn = archiveCdn
this.archiveThumbnailCdn = archiveThumbnailCdn
this.archiveMediaName = archiveMediaName
this.archiveMediaId = archiveMediaId
}
@ -113,8 +121,10 @@ class DatabaseAttachment : Attachment {
mmsId = parcel.readLong()
displayOrder = parcel.readInt()
archiveCdn = parcel.readInt()
archiveThumbnailCdn = parcel.readInt()
archiveMediaName = parcel.readString()
archiveMediaId = parcel.readString()
hasArchiveThumbnail = ParcelUtil.readBoolean(parcel)
}
override fun writeToParcel(dest: Parcel, flags: Int) {
@ -126,8 +136,10 @@ class DatabaseAttachment : Attachment {
dest.writeLong(mmsId)
dest.writeInt(displayOrder)
dest.writeInt(archiveCdn)
dest.writeInt(archiveThumbnailCdn)
dest.writeString(archiveMediaName)
dest.writeString(archiveMediaId)
ParcelUtil.writeBoolean(dest, hasArchiveThumbnail)
}
override val uri: Uri?
@ -144,6 +156,13 @@ class DatabaseAttachment : Attachment {
null
}
override val thumbnailUri: Uri?
get() = if (hasArchiveThumbnail) {
PartAuthority.getAttachmentThumbnailUri(attachmentId)
} else {
null
}
override fun equals(other: Any?): Boolean {
return other != null &&
other is DatabaseAttachment && other.attachmentId == attachmentId

View file

@ -66,6 +66,7 @@ class PointerAttachment : Attachment {
override val uri: Uri? = null
override val publicUri: Uri? = null
override val thumbnailUri: Uri? = null
companion object {
@JvmStatic

View file

@ -80,4 +80,5 @@ class TombstoneAttachment : Attachment {
override val uri: Uri? = null
override val publicUri: Uri? = null
override val thumbnailUri: Uri? = null
}

View file

@ -98,6 +98,7 @@ class UriAttachment : Attachment {
override val uri: Uri
override val publicUri: Uri? = null
override val thumbnailUri: Uri? = null
override fun writeToParcel(dest: Parcel, flags: Int) {
super.writeToParcel(dest, flags)

View file

@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.Cdn
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getThumbnailMediaName
import org.thoughtcrime.securesms.backup.v2.database.ChatItemImportInserter
import org.thoughtcrime.securesms.backup.v2.database.clearAllDataForBackupRestore
import org.thoughtcrime.securesms.backup.v2.processor.AccountDataProcessor
@ -346,7 +347,7 @@ object BackupRepository {
/**
* Retrieves an upload spec that can be used to upload attachment media.
*/
fun getMediaUploadSpec(): NetworkResult<ResumableUploadSpec> {
fun getMediaUploadSpec(secretKey: ByteArray? = null): NetworkResult<ResumableUploadSpec> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
@ -355,20 +356,21 @@ object BackupRepository {
api.getMediaUploadForm(backupKey, credential)
}
.then { form ->
api.getResumableUploadSpec(form)
api.getResumableUploadSpec(form, secretKey)
}
}
fun archiveThumbnail(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult<ArchiveMediaResponse> {
val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val request = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.getThumbnailMediaName(), backupKey)
return initBackupAndFetchAuth(backupKey)
.then { credential ->
api.archiveAttachmentMedia(
backupKey = backupKey,
serviceCredential = credential,
item = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.getThumbnailMediaName(), backupKey)
item = request
)
}
}
@ -390,7 +392,8 @@ object BackupRepository {
.map { Triple(mediaName, request.mediaId, it) }
}
.map { (mediaName, mediaId, response) ->
SignalDatabase.attachments.setArchiveData(attachmentId = attachment.attachmentId, archiveCdn = response.cdn, archiveMediaName = mediaName.name, archiveMediaId = mediaId)
val thumbnailId = backupKey.deriveMediaId(attachment.getThumbnailMediaName()).encode()
SignalDatabase.attachments.setArchiveData(attachmentId = attachment.attachmentId, archiveCdn = response.cdn, archiveMediaName = mediaName.name, archiveMediaId = mediaId, archiveThumbnailMediaId = thumbnailId)
}
.also { Log.i(TAG, "archiveMediaResult: $it") }
}
@ -427,7 +430,8 @@ object BackupRepository {
.forEach {
val attachmentId = result.mediaIdToAttachmentId(it.mediaId)
val mediaName = result.attachmentIdToMediaName(attachmentId)
SignalDatabase.attachments.setArchiveData(attachmentId = attachmentId, archiveCdn = it.cdn!!, archiveMediaName = mediaName, archiveMediaId = it.mediaId)
val thumbnailId = backupKey.deriveMediaId(MediaName.forThumbnailFromMediaName(mediaName = mediaName)).encode()
SignalDatabase.attachments.setArchiveData(attachmentId = attachmentId, archiveCdn = it.cdn!!, archiveMediaName = mediaName, archiveMediaId = it.mediaId, thumbnailId)
}
result
}

View file

@ -658,6 +658,7 @@ class ChatItemImportInserter(
archiveCdn = pointer.backupLocator.cdnNumber,
archiveMediaName = pointer.backupLocator.mediaName,
archiveMediaId = backupState.backupKey.deriveMediaId(MediaName(pointer.backupLocator.mediaName)).encode(),
archiveThumbnailMediaId = backupState.backupKey.deriveMediaId(MediaName.forThumbnailFromMediaName(pointer.backupLocator.mediaName)).encode(),
digest = pointer.backupLocator.digest.toByteArray(),
incrementalMac = pointer.incrementalMac?.toByteArray(),
incrementalMacChunkSize = pointer.incrementalMacChunkSize,

View file

@ -125,6 +125,8 @@ class AttachmentTable(
const val DATA_RANDOM = "data_random"
const val DATA_HASH_START = "data_hash_start"
const val DATA_HASH_END = "data_hash_end"
const val THUMBNAIL_FILE = "thumbnail_file"
const val THUMBNAIL_RANDOM = "thumbnail_random"
const val FILE_NAME = "file_name"
const val FAST_PREFLIGHT_ID = "fast_preflight_id"
const val VOICE_NOTE = "voice_note"
@ -145,6 +147,8 @@ class AttachmentTable(
const val ARCHIVE_CDN = "archive_cdn"
const val ARCHIVE_MEDIA_NAME = "archive_media_name"
const val ARCHIVE_MEDIA_ID = "archive_media_id"
const val ARCHIVE_THUMBNAIL_MEDIA_ID = "archive_thumbnail_media_id"
const val ARCHIVE_THUMBNAIL_CDN = "archive_thumbnail_cdn"
const val ARCHIVE_TRANSFER_FILE = "archive_transfer_file"
const val ARCHIVE_TRANSFER_STATE = "archive_transfer_state"
@ -159,6 +163,7 @@ class AttachmentTable(
const val TRANSFER_PROGRESS_PERMANENT_FAILURE = 4
const val TRANSFER_NEEDS_RESTORE = 5
const val TRANSFER_RESTORE_IN_PROGRESS = 6
const val TRANSFER_RESTORE_OFFLOADED = 7
const val PREUPLOAD_MESSAGE_ID: Long = -8675309
private val PROJECTION = arrayOf(
@ -196,9 +201,11 @@ class AttachmentTable(
DATA_HASH_START,
DATA_HASH_END,
ARCHIVE_CDN,
ARCHIVE_THUMBNAIL_CDN,
ARCHIVE_MEDIA_NAME,
ARCHIVE_MEDIA_ID,
ARCHIVE_TRANSFER_FILE
ARCHIVE_TRANSFER_FILE,
THUMBNAIL_FILE
)
@JvmField
@ -240,8 +247,12 @@ class AttachmentTable(
$ARCHIVE_CDN INTEGER DEFAULT 0,
$ARCHIVE_MEDIA_NAME TEXT DEFAULT NULL,
$ARCHIVE_MEDIA_ID TEXT DEFAULT NULL,
$ARCHIVE_THUMBNAIL_MEDIA_ID TEXT DEFAULT NULL,
$ARCHIVE_THUMBNAIL_CDN INTEGER DEFAULT 0,
$ARCHIVE_TRANSFER_FILE TEXT DEFAULT NULL,
$ARCHIVE_TRANSFER_STATE INTEGER DEFAULT ${ArchiveTransferState.NONE.value}
$ARCHIVE_TRANSFER_STATE INTEGER DEFAULT ${ArchiveTransferState.NONE.value},
$THUMBNAIL_FILE TEXT DEFAULT NULL,
$THUMBNAIL_RANDOM BLOB DEFAULT NULL
)
"""
@ -273,6 +284,15 @@ class AttachmentTable(
} ?: throw IOException("No stream for: $attachmentId")
}
@Throws(IOException::class)
fun getAttachmentThumbnailStream(attachmentId: AttachmentId, offset: Long): InputStream {
return try {
getThumbnailStream(attachmentId, offset)
} catch (e: FileNotFoundException) {
throw IOException("No stream for: $attachmentId", e)
} ?: throw IOException("No stream for: $attachmentId")
}
/**
* Returns a [File] for an attachment that has no [DATA_HASH_END] and is in the [TRANSFER_PROGRESS_DONE] state, if present.
*/
@ -826,6 +846,36 @@ class AttachmentTable(
}
}
@Throws(IOException::class)
fun finalizeAttachmentThumbnailAfterDownload(attachmentId: AttachmentId, archiveMediaId: String, inputStream: InputStream, transferFile: File) {
Log.i(TAG, "[finalizeAttachmentThumbnailAfterDownload] Finalizing downloaded data for $attachmentId.")
val fileWriteResult: DataFileWriteResult = writeToDataFile(newDataFile(context), inputStream, TransformProperties.empty())
writableDatabase.withinTransaction { db ->
val values = contentValuesOf(
THUMBNAIL_FILE to fileWriteResult.file.absolutePath,
THUMBNAIL_RANDOM to fileWriteResult.random
)
db.update(TABLE_NAME)
.values(values)
.where("$ARCHIVE_MEDIA_ID = ?", archiveMediaId)
.run()
db.update(TABLE_NAME)
.values(TRANSFER_STATE to TRANSFER_RESTORE_OFFLOADED)
.where("$ID = ?", attachmentId.id)
.run()
}
notifyConversationListListeners()
notifyAttachmentListeners()
if (!transferFile.delete()) {
Log.w(TAG, "Unable to delete transfer file.")
}
}
/**
* Needs to be called after an attachment is successfully uploaded. Writes metadata around it's final remote location, as well as calculates
* it's ending hash, which is critical for backups.
@ -1158,6 +1208,10 @@ class AttachmentTable(
return transferFile
}
fun createArchiveThumbnailTransferFile(): File {
return newTransferFile()
}
fun getDataFileInfo(attachmentId: AttachmentId): DataFileInfo? {
return readableDatabase
.select(ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP)
@ -1173,6 +1227,21 @@ class AttachmentTable(
}
}
fun getThumbnailFileInfo(attachmentId: AttachmentId): ThumbnailFileInfo? {
return readableDatabase
.select(ID, THUMBNAIL_FILE, THUMBNAIL_RANDOM)
.from(TABLE_NAME)
.where("$ID = ?", attachmentId.id)
.run()
.readToSingleObject { cursor ->
if (cursor.isNull(THUMBNAIL_FILE)) {
null
} else {
cursor.readThumbnailFileInfo()
}
}
}
fun getDataFilePath(attachmentId: AttachmentId): String? {
return readableDatabase
.select(DATA_FILE)
@ -1320,8 +1389,10 @@ class AttachmentTable(
uploadTimestamp = jsonObject.getLong(UPLOAD_TIMESTAMP),
dataHash = jsonObject.getString(DATA_HASH_END),
archiveCdn = jsonObject.getInt(ARCHIVE_CDN),
archiveThumbnailCdn = jsonObject.getInt(ARCHIVE_THUMBNAIL_CDN),
archiveMediaName = jsonObject.getString(ARCHIVE_MEDIA_NAME),
archiveMediaId = jsonObject.getString(ARCHIVE_MEDIA_ID)
archiveMediaId = jsonObject.getString(ARCHIVE_MEDIA_ID),
hasArchiveThumbnail = !TextUtils.isEmpty(jsonObject.getString(THUMBNAIL_FILE))
)
}
}
@ -1361,13 +1432,14 @@ class AttachmentTable(
return readableDatabase.rawQuery(query, null)
}
fun setArchiveData(attachmentId: AttachmentId, archiveCdn: Int, archiveMediaName: String, archiveMediaId: String) {
fun setArchiveData(attachmentId: AttachmentId, archiveCdn: Int, archiveMediaName: String, archiveMediaId: String, archiveThumbnailMediaId: String) {
writableDatabase
.update(TABLE_NAME)
.values(
ARCHIVE_CDN to archiveCdn,
ARCHIVE_MEDIA_ID to archiveMediaId,
ARCHIVE_MEDIA_NAME to archiveMediaName,
ARCHIVE_THUMBNAIL_MEDIA_ID to archiveThumbnailMediaId,
ARCHIVE_TRANSFER_STATE to ArchiveTransferState.FINISHED.value
)
.where("$ID = ?", attachmentId.id)
@ -1375,13 +1447,14 @@ class AttachmentTable(
}
fun updateArchiveCdnByMediaId(archiveMediaId: String, archiveCdn: Int): Int {
return writableDatabase
.update(TABLE_NAME)
.values(
ARCHIVE_CDN to archiveCdn
)
.where("$ARCHIVE_MEDIA_ID = ?", archiveMediaId)
.run()
return writableDatabase.rawQuery(
"UPDATE $TABLE_NAME SET " +
"$ARCHIVE_THUMBNAIL_CDN = CASE WHEN $ARCHIVE_THUMBNAIL_MEDIA_ID = ? THEN ? ELSE $ARCHIVE_THUMBNAIL_CDN END," +
"$ARCHIVE_CDN = CASE WHEN $ARCHIVE_MEDIA_ID = ? THEN ? ELSE $ARCHIVE_CDN END " +
"WHERE $ARCHIVE_MEDIA_ID = ? OR $ARCHIVE_THUMBNAIL_MEDIA_ID = ? " +
"RETURNING $ARCHIVE_CDN, $ARCHIVE_THUMBNAIL_CDN",
SqlUtil.buildArgs(archiveMediaId, archiveCdn, archiveMediaId, archiveCdn, archiveMediaId, archiveMediaId)
).count
}
fun clearArchiveData(attachmentIds: List<AttachmentId>) {
@ -1485,6 +1558,21 @@ class AttachmentTable(
}
}
@Throws(FileNotFoundException::class)
private fun getThumbnailStream(attachmentId: AttachmentId, offset: Long): InputStream? {
val thumbnailInfo = getThumbnailFileInfo(attachmentId) ?: return null
return try {
ModernDecryptingPartInputStream.createFor(attachmentSecret, thumbnailInfo.random, thumbnailInfo.file, offset)
} catch (e: FileNotFoundException) {
Log.w(TAG, e)
throw e
} catch (e: IOException) {
Log.w(TAG, e)
null
}
}
@Throws(IOException::class)
private fun newTransferFile(): File {
val partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE)
@ -1664,6 +1752,7 @@ class AttachmentTable(
put(ARCHIVE_CDN, attachment.archiveCdn)
put(ARCHIVE_MEDIA_NAME, attachment.archiveMediaName)
put(ARCHIVE_MEDIA_ID, attachment.archiveMediaId)
put(ARCHIVE_THUMBNAIL_MEDIA_ID, attachment.archiveThumbnailMediaId)
attachment.stickerLocator?.let { sticker ->
put(STICKER_PACK_ID, sticker.packId)
@ -1874,8 +1963,10 @@ class AttachmentTable(
uploadTimestamp = cursor.requireLong(UPLOAD_TIMESTAMP),
dataHash = cursor.requireString(DATA_HASH_END),
archiveCdn = cursor.requireInt(ARCHIVE_CDN),
archiveThumbnailCdn = cursor.requireInt(ARCHIVE_THUMBNAIL_CDN),
archiveMediaName = cursor.requireString(ARCHIVE_MEDIA_NAME),
archiveMediaId = cursor.requireString(ARCHIVE_MEDIA_ID)
archiveMediaId = cursor.requireString(ARCHIVE_MEDIA_ID),
hasArchiveThumbnail = !cursor.isNull(THUMBNAIL_FILE)
)
}
@ -1900,6 +1991,14 @@ class AttachmentTable(
)
}
private fun Cursor.readThumbnailFileInfo(): ThumbnailFileInfo {
return ThumbnailFileInfo(
id = AttachmentId(this.requireLong(ID)),
file = File(this.requireNonNullString(THUMBNAIL_FILE)),
random = this.requireNonNullBlob(THUMBNAIL_RANDOM)
)
}
private fun Cursor.readStickerLocator(): StickerLocator? {
return if (this.requireInt(STICKER_ID) >= 0) {
StickerLocator(
@ -1954,6 +2053,13 @@ class AttachmentTable(
val uploadTimestamp: Long
)
@VisibleForTesting
class ThumbnailFileInfo(
val id: AttachmentId,
val file: File,
val random: ByteArray
)
@Parcelize
data class TransformProperties(
@JsonProperty("skipTransform")

View file

@ -357,7 +357,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
'${AttachmentTable.MESSAGE_ID}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.MESSAGE_ID},
'${AttachmentTable.DATA_SIZE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_SIZE},
'${AttachmentTable.FILE_NAME}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.FILE_NAME},
'${AttachmentTable.DATA_FILE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_FILE},
'${AttachmentTable.DATA_FILE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_FILE},
'${AttachmentTable.THUMBNAIL_FILE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.THUMBNAIL_FILE},
'${AttachmentTable.CONTENT_TYPE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.CONTENT_TYPE},
'${AttachmentTable.CDN_NUMBER}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.CDN_NUMBER},
'${AttachmentTable.REMOTE_LOCATION}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.REMOTE_LOCATION},
@ -381,6 +382,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
'${AttachmentTable.UPLOAD_TIMESTAMP}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.UPLOAD_TIMESTAMP},
'${AttachmentTable.DATA_HASH_END}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.DATA_HASH_END},
'${AttachmentTable.ARCHIVE_CDN}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_CDN},
'${AttachmentTable.ARCHIVE_THUMBNAIL_CDN}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_THUMBNAIL_CDN},
'${AttachmentTable.ARCHIVE_MEDIA_NAME}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_MEDIA_NAME},
'${AttachmentTable.ARCHIVE_MEDIA_ID}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_MEDIA_ID}
)

View file

@ -88,6 +88,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V227_AddAttachmentA
import org.thoughtcrime.securesms.database.helpers.migration.V228_AddNameCollisionTables
import org.thoughtcrime.securesms.database.helpers.migration.V229_MarkMissedCallEventsNotified
import org.thoughtcrime.securesms.database.helpers.migration.V230_UnreadCountIndices
import org.thoughtcrime.securesms.database.helpers.migration.V231_ArchiveThumbnailColumns
/**
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
@ -178,10 +179,11 @@ object SignalDatabaseMigrations {
227 to V227_AddAttachmentArchiveTransferState,
228 to V228_AddNameCollisionTables,
229 to V229_MarkMissedCallEventsNotified,
230 to V230_UnreadCountIndices
230 to V230_UnreadCountIndices,
231 to V231_ArchiveThumbnailColumns
)
const val DATABASE_VERSION = 230
const val DATABASE_VERSION = 231
@JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {

View file

@ -0,0 +1,18 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import net.zetetic.database.sqlcipher.SQLiteDatabase
object V231_ArchiveThumbnailColumns : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("ALTER TABLE attachment ADD COLUMN thumbnail_file TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE attachment ADD COLUMN thumbnail_random BLOB DEFAULT NULL")
db.execSQL("ALTER TABLE attachment ADD COLUMN archive_thumbnail_cdn INTEGER DEFAULT 0")
db.execSQL("ALTER TABLE attachment ADD COLUMN archive_thumbnail_media_id TEXT DEFAULT NULL")
}
}

View file

@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.attachments.PointerAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getThumbnailMediaName
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.jobmanager.Job
@ -86,8 +87,9 @@ class ArchiveThumbnailUploadJob private constructor(
Log.w(TAG, "Unable to generate a thumbnail result for $attachmentId")
return Result.success()
}
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val resumableUpload = when (val result = BackupRepository.getMediaUploadSpec()) {
val resumableUpload = when (val result = BackupRepository.getMediaUploadSpec(secretKey = backupKey.deriveThumbnailTransitKey(attachment.getThumbnailMediaName()))) {
is NetworkResult.Success -> {
Log.d(TAG, "Got an upload spec!")
result.result.toProto()
@ -116,9 +118,13 @@ class ArchiveThumbnailUploadJob private constructor(
return Result.retry(defaultBackoff())
}
val backupDirectories = BackupRepository.getCdnBackupDirectories().successOrThrow()
val mediaSecrets = backupKey.deriveMediaSecrets(attachment.getThumbnailMediaName())
return when (val result = BackupRepository.archiveThumbnail(attachmentPointer, attachment)) {
is NetworkResult.Success -> {
Log.d(TAG, "Successfully archived thumbnail for $attachmentId")
Log.i(RestoreAttachmentJob.TAG, "Restore: Thumbnail mediaId=${mediaSecrets.id.encode()} backupDir=${backupDirectories.backupDir} mediaDir=${backupDirectories.mediaDir}")
Log.d(TAG, "Successfully archived thumbnail for $attachmentId mediaName=${attachment.getThumbnailMediaName()}")
Result.success()
}
is NetworkResult.NetworkError -> {

View file

@ -72,11 +72,15 @@ class BackupMessagesJob private constructor(parameters: Parameters) : BaseJob(pa
when (val archiveResult = BackupRepository.archiveMedia(attachments)) {
is NetworkResult.Success -> {
Log.i(TAG, "Archive call successful")
for (success in archiveResult.result.sourceNotFoundResponses) {
val attachmentId = archiveResult.result.mediaIdToAttachmentId(success.mediaId)
for (notFound in archiveResult.result.sourceNotFoundResponses) {
val attachmentId = archiveResult.result.mediaIdToAttachmentId(notFound.mediaId)
Log.i(TAG, "Attachment $attachmentId not found on cdn, will need to re-upload")
needToBackfill++
}
for (success in archiveResult.result.successfulResponses) {
val attachmentId = archiveResult.result.mediaIdToAttachmentId(success.mediaId)
ArchiveThumbnailUploadJob.enqueueIfNecessary(attachmentId)
}
progress += attachments.size
}

View file

@ -62,7 +62,11 @@ class BackupRestoreMediaJob private constructor(parameters: Parameters) : BaseJo
attachmentId = attachment.attachmentId,
manual = false,
forceArchiveDownload = true,
fullSize = shouldRestoreFullSize(message, restoreTime, optimizeStorage = SignalStore.backup().optimizeStorage)
restoreMode = if (shouldRestoreFullSize(message, restoreTime, optimizeStorage = SignalStore.backup().optimizeStorage)) {
RestoreAttachmentJob.RestoreMode.ORIGINAL
} else {
RestoreAttachmentJob.RestoreMode.THUMBNAIL
}
)
}
jobManager.addAll(restoreJobBatch)
@ -70,7 +74,7 @@ class BackupRestoreMediaJob private constructor(parameters: Parameters) : BaseJo
}
private fun shouldRestoreFullSize(message: MmsMessageRecord, restoreTime: Long, optimizeStorage: Boolean): Boolean {
return ((restoreTime - message.dateSent) < 30.days.inWholeMilliseconds) || !optimizeStorage
return !optimizeStorage || ((restoreTime - message.dateSent) < 30.days.inWholeMilliseconds)
}
override fun onShouldRetry(e: Exception): Boolean = false

View file

@ -15,6 +15,7 @@ import org.signal.libsignal.protocol.InvalidMessageException
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.BackupRepository.getThumbnailMediaName
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@ -52,18 +53,18 @@ class RestoreAttachmentJob private constructor(
attachmentId: AttachmentId,
private val manual: Boolean,
private var forceArchiveDownload: Boolean,
private val fullSize: Boolean
private val restoreMode: RestoreMode
) : BaseJob(parameters) {
companion object {
const val KEY = "RestoreAttachmentJob"
private val TAG = Log.tag(AttachmentDownloadJob::class.java)
val TAG = Log.tag(AttachmentDownloadJob::class.java)
private const val KEY_MESSAGE_ID = "message_id"
private const val KEY_ATTACHMENT_ID = "part_row_id"
private const val KEY_MANUAL = "part_manual"
private const val KEY_FORCE_ARCHIVE = "force_archive"
private const val KEY_FULL_SIZE = "full_size"
private const val KEY_RESTORE_MODE = "restore_mode"
@JvmStatic
fun constructQueueString(attachmentId: AttachmentId): String {
@ -71,13 +72,19 @@ class RestoreAttachmentJob private constructor(
return "RestoreAttachmentJob"
}
fun jobSpecMatchesAnyAttachmentId(jobSpec: JobSpec, ids: Set<AttachmentId>): Boolean {
private fun getJsonJobData(jobSpec: JobSpec): JsonJobData? {
if (KEY != jobSpec.factoryKey) {
return false
return null
}
val serializedData = jobSpec.serializedData ?: return false
val data = JsonJobData.deserialize(serializedData)
val serializedData = jobSpec.serializedData ?: return null
return JsonJobData.deserialize(serializedData)
}
fun jobSpecMatchesAnyAttachmentId(data: JsonJobData?, ids: Set<AttachmentId>): Boolean {
if (data == null) {
return false
}
val parsed = AttachmentId(data.getLong(KEY_ATTACHMENT_ID))
return ids.contains(parsed)
}
@ -85,8 +92,15 @@ class RestoreAttachmentJob private constructor(
fun modifyPriorities(ids: Set<AttachmentId>, priority: Int) {
val jobManager = ApplicationDependencies.getJobManager()
jobManager.update { spec ->
if (jobSpecMatchesAnyAttachmentId(spec, ids) && spec.priority != priority) {
spec.copy(priority = priority)
val jobData = getJsonJobData(spec)
if (jobSpecMatchesAnyAttachmentId(jobData, ids) && spec.priority != priority) {
val restoreMode = RestoreMode.deserialize(jobData!!.getIntOrDefault(KEY_RESTORE_MODE, RestoreMode.ORIGINAL.value))
val modifiedJobData = if (restoreMode == RestoreMode.ORIGINAL) {
jobData.buildUpon().putInt(KEY_RESTORE_MODE, RestoreMode.BOTH.value).build()
} else {
jobData
}
spec.copy(priority = priority, serializedData = modifiedJobData.serialize())
} else {
spec
}
@ -96,7 +110,7 @@ class RestoreAttachmentJob private constructor(
private val attachmentId: Long
constructor(messageId: Long, attachmentId: AttachmentId, manual: Boolean, forceArchiveDownload: Boolean = false, fullSize: Boolean = true) : this(
constructor(messageId: Long, attachmentId: AttachmentId, manual: Boolean, forceArchiveDownload: Boolean = false, restoreMode: RestoreMode = RestoreMode.ORIGINAL) : this(
Parameters.Builder()
.setQueue(constructQueueString(attachmentId))
.addConstraint(NetworkConstraint.KEY)
@ -107,7 +121,7 @@ class RestoreAttachmentJob private constructor(
attachmentId,
manual,
forceArchiveDownload,
fullSize
restoreMode
)
init {
@ -120,7 +134,7 @@ class RestoreAttachmentJob private constructor(
.putLong(KEY_ATTACHMENT_ID, attachmentId)
.putBoolean(KEY_MANUAL, manual)
.putBoolean(KEY_FORCE_ARCHIVE, forceArchiveDownload)
.putBoolean(KEY_FULL_SIZE, fullSize)
.putInt(KEY_RESTORE_MODE, restoreMode.value)
.serialize()
}
@ -166,12 +180,19 @@ class RestoreAttachmentJob private constructor(
return
}
if (attachment.transferState != AttachmentTable.TRANSFER_NEEDS_RESTORE && attachment.transferState != AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS) {
if (attachment.transferState != AttachmentTable.TRANSFER_NEEDS_RESTORE &&
attachment.transferState != AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS &&
(attachment.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED || restoreMode == RestoreMode.THUMBNAIL)
) {
Log.w(TAG, "Attachment does not need to be restored.")
return
}
retrieveAttachment(messageId, attachmentId, attachment)
if (attachment.thumbnailUri == null && (restoreMode == RestoreMode.THUMBNAIL || restoreMode == RestoreMode.BOTH)) {
downloadThumbnail(attachmentId, attachment)
}
if (restoreMode == RestoreMode.ORIGINAL || restoreMode == RestoreMode.BOTH) {
retrieveAttachment(messageId, attachmentId, attachment)
}
}
override fun onFailure() {
@ -360,6 +381,102 @@ class RestoreAttachmentJob private constructor(
}
}
@Throws(InvalidPartException::class)
private fun createThumbnailPointer(attachment: DatabaseAttachment): SignalServiceAttachmentPointer {
if (TextUtils.isEmpty(attachment.remoteKey)) {
throw InvalidPartException("empty encrypted key")
}
val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey()
val backupDirectories = BackupRepository.getCdnBackupDirectories().successOrThrow()
return try {
val key = backupKey.deriveThumbnailTransitKey(attachment.getThumbnailMediaName())
if (attachment.remoteDigest != null) {
Log.i(TAG, "Downloading attachment with digest: " + Hex.toString(attachment.remoteDigest))
} else {
Log.i(TAG, "Downloading attachment with no digest...")
}
val mediaId = backupKey.deriveMediaId(attachment.getThumbnailMediaName()).encode()
Log.i(TAG, "Restore: Thumbnail mediaId=$mediaId backupDir=${backupDirectories.backupDir} mediaDir=${backupDirectories.mediaDir}")
SignalServiceAttachmentPointer(
attachment.archiveThumbnailCdn,
SignalServiceAttachmentRemoteId.Backup(
backupDir = backupDirectories.backupDir,
mediaDir = backupDirectories.mediaDir,
mediaId = mediaId
),
null,
key,
Optional.empty(),
Optional.empty(),
0,
0,
Optional.ofNullable(attachment.remoteDigest),
Optional.empty(),
attachment.incrementalMacChunkSize,
Optional.empty(),
attachment.voiceNote,
attachment.borderless,
attachment.videoGif,
Optional.empty(),
Optional.ofNullable(attachment.blurHash).map { it.hash },
attachment.uploadTimestamp
)
} catch (e: IOException) {
Log.w(TAG, e)
throw InvalidPartException(e)
} catch (e: ArithmeticException) {
Log.w(TAG, e)
throw InvalidPartException(e)
}
}
private fun downloadThumbnail(attachmentId: AttachmentId, attachment: DatabaseAttachment) {
if (attachment.transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED) {
Log.w(TAG, "$attachmentId already has thumbnail downloaded")
return
}
if (attachment.archiveMediaName == null) {
Log.w(TAG, "$attachmentId was never archived! Cannot proceed.")
return
}
val maxThumbnailSize: Long = FeatureFlags.maxAttachmentReceiveSizeBytes()
val thumbnailTransferFile: File = SignalDatabase.attachments.createArchiveThumbnailTransferFile()
val thumbnailFile: File = SignalDatabase.attachments.createArchiveThumbnailTransferFile()
val progressListener = object : SignalServiceAttachment.ProgressListener {
override fun onAttachmentProgress(total: Long, progress: Long) {
EventBus.getDefault().postSticky(PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress))
}
override fun shouldCancel(): Boolean {
return this@RestoreAttachmentJob.isCanceled
}
}
val cdnCredentials = BackupRepository.getCdnReadCredentials(attachment.archiveCdn).successOrThrow().headers
val messageReceiver = ApplicationDependencies.getSignalServiceMessageReceiver()
val pointer = createThumbnailPointer(attachment)
Log.w(TAG, "Downloading thumbnail for $attachmentId mediaName=${attachment.getThumbnailMediaName()}")
val stream = messageReceiver
.retrieveArchivedAttachment(
SignalStore.svr().getOrCreateMasterKey().deriveBackupKey().deriveMediaSecrets(attachment.getThumbnailMediaName()),
cdnCredentials,
thumbnailTransferFile,
pointer,
thumbnailFile,
maxThumbnailSize,
progressListener
)
SignalDatabase.attachments.finalizeAttachmentThumbnailAfterDownload(attachmentId, attachment.archiveMediaId!!, stream, thumbnailTransferFile)
}
private fun markFailed(messageId: Long, attachmentId: AttachmentId) {
try {
SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId)
@ -382,6 +499,18 @@ class RestoreAttachmentJob private constructor(
constructor(e: Exception?) : super(e)
}
enum class RestoreMode(val value: Int) {
THUMBNAIL(0),
ORIGINAL(1),
BOTH(2);
companion object {
fun deserialize(value: Int): RestoreMode {
return values().firstOrNull { it.value == value } ?: ORIGINAL
}
}
}
private data class RemoteData(val remoteId: SignalServiceAttachmentRemoteId, val cdnNumber: Int)
class Factory : Job.Factory<RestoreAttachmentJob?> {
@ -393,7 +522,7 @@ class RestoreAttachmentJob private constructor(
attachmentId = AttachmentId(data.getLong(KEY_ATTACHMENT_ID)),
manual = data.getBoolean(KEY_MANUAL),
forceArchiveDownload = data.getBooleanOrDefault(KEY_FORCE_ARCHIVE, false),
fullSize = data.getBooleanOrDefault(KEY_FULL_SIZE, true)
restoreMode = RestoreMode.deserialize(data.getIntOrDefault(KEY_RESTORE_MODE, RestoreMode.ORIGINAL.value))
)
}
}

View file

@ -32,11 +32,13 @@ public class PartAuthority {
private static final String AUTHORITY = BuildConfig.APPLICATION_ID;
private static final String PART_URI_STRING = "content://" + AUTHORITY + "/part";
private static final String PART_THUMBNAIL_STRING = "content://" + AUTHORITY + "/thumbnail";
private static final String STICKER_URI_STRING = "content://" + AUTHORITY + "/sticker";
private static final String WALLPAPER_URI_STRING = "content://" + AUTHORITY + "/wallpaper";
private static final String EMOJI_URI_STRING = "content://" + AUTHORITY + "/emoji";
private static final String AVATAR_PICKER_URI_STRING = "content://" + AUTHORITY + "/avatar_picker";
private static final Uri PART_CONTENT_URI = Uri.parse(PART_URI_STRING);
private static final Uri PART_THUMBNAIL_URI = Uri.parse(PART_THUMBNAIL_STRING);
private static final Uri STICKER_CONTENT_URI = Uri.parse(STICKER_URI_STRING);
private static final Uri WALLPAPER_CONTENT_URI = Uri.parse(WALLPAPER_URI_STRING);
private static final Uri EMOJI_CONTENT_URI = Uri.parse(EMOJI_URI_STRING);
@ -49,12 +51,14 @@ public class PartAuthority {
private static final int WALLPAPER_ROW = 5;
private static final int EMOJI_ROW = 6;
private static final int AVATAR_PICKER_ROW = 7;
private static final int THUMBNAIL_ROW = 8;
private static final UriMatcher uriMatcher;
static {
uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
uriMatcher.addURI(AUTHORITY, "part/#", PART_ROW);
uriMatcher.addURI(AUTHORITY, "thumbnail/#", THUMBNAIL_ROW);
uriMatcher.addURI(AUTHORITY, "sticker/#", STICKER_ROW);
uriMatcher.addURI(AUTHORITY, "wallpaper/*", WALLPAPER_ROW);
uriMatcher.addURI(AUTHORITY, "emoji/*", EMOJI_ROW);
@ -83,6 +87,7 @@ public class PartAuthority {
case WALLPAPER_ROW: return WallpaperStorage.read(context, getWallpaperFilename(uri));
case EMOJI_ROW: return EmojiFiles.openForReading(context, getEmojiFilename(uri));
case AVATAR_PICKER_ROW: return AvatarPickerStorage.read(context, getAvatarPickerFilename(uri));
case THUMBNAIL_ROW: return SignalDatabase.attachments().getAttachmentThumbnailStream(new PartUriParser(uri).getPartId(), 0);
default: return openExternalFileStream(context, uri);
}
} catch (SecurityException se) {
@ -178,7 +183,7 @@ public class PartAuthority {
}
public static Uri getAttachmentThumbnailUri(AttachmentId attachmentId) {
return getAttachmentDataUri(attachmentId);
return ContentUris.withAppendedId(PART_THUMBNAIL_URI, attachmentId.id);
}
public static Uri getStickerUri(long id) {

View file

@ -49,9 +49,18 @@ public abstract class Slide {
return attachment.contentType;
}
@Nullable
public Uri getThumbnailUri() {
return attachment.getThumbnailUri();
}
@Nullable
public Uri getUri() {
return attachment.getUri();
Uri attachmentUri = attachment.getUri();
if (attachmentUri != null) {
return attachmentUri;
}
return attachment.getThumbnailUri();
}
public @Nullable Uri getPublicUri() {

View file

@ -61,4 +61,4 @@ message ArchiveAttachmentBackfillJobData {
message ArchiveThumbnailUploadJobData {
uint64 attachmentId = 1;
}
}

View file

@ -232,6 +232,7 @@ class UploadDependencyGraphTest {
mmsId = AttachmentTable.PREUPLOAD_MESSAGE_ID,
hasData = false,
hasThumbnail = false,
hasArchiveThumbnail = false,
contentType = attachment.contentType,
transferProgress = AttachmentTable.TRANSFER_PROGRESS_PENDING,
size = attachment.size,
@ -259,7 +260,8 @@ class UploadDependencyGraphTest {
dataHash = null,
archiveMediaId = null,
archiveMediaName = null,
archiveCdn = 0
archiveCdn = 0,
archiveThumbnailCdn = 0
)
}

View file

@ -32,6 +32,7 @@ object FakeMessageRecords {
mmsId: Long = 1,
hasData: Boolean = true,
hasThumbnail: Boolean = true,
hasArchiveThumbnail: Boolean = false,
contentType: String = MediaUtil.IMAGE_JPEG,
transferProgress: Int = AttachmentTable.TRANSFER_PROGRESS_DONE,
size: Long = 0L,
@ -59,14 +60,17 @@ object FakeMessageRecords {
uploadTimestamp: Long = 200,
dataHash: String? = null,
archiveCdn: Int = 0,
archiveThumbnailCdn: Int = 0,
archiveMediaName: String? = null,
archiveMediaId: String? = null
archiveMediaId: String? = null,
archiveThumbnailId: String? = null
): DatabaseAttachment {
return DatabaseAttachment(
attachmentId,
mmsId,
hasData,
hasThumbnail,
hasArchiveThumbnail,
contentType,
transferProgress,
size,
@ -93,6 +97,7 @@ object FakeMessageRecords {
uploadTimestamp,
dataHash,
archiveCdn,
archiveThumbnailCdn,
archiveMediaId,
archiveMediaName
)

View file

@ -156,9 +156,13 @@ class ArchiveApi(
}
}
fun getResumableUploadSpec(uploadForm: AttachmentUploadForm): NetworkResult<ResumableUploadSpec> {
fun getResumableUploadSpec(uploadForm: AttachmentUploadForm, secretKey: ByteArray?): NetworkResult<ResumableUploadSpec> {
return NetworkResult.fromFetch {
pushServiceSocket.getResumableUploadSpec(uploadForm)
if (secretKey == null) {
pushServiceSocket.getResumableUploadSpec(uploadForm)
} else {
pushServiceSocket.getResumableUploadSpecWithKey(uploadForm, secretKey)
}
}
}

View file

@ -42,6 +42,10 @@ class BackupKey(val value: ByteArray) {
return deriveMediaSecrets(deriveMediaId(mediaName))
}
fun deriveThumbnailTransitKey(thumbnailMediaName: MediaName): ByteArray {
return HKDF.deriveSecrets(value, deriveMediaId(thumbnailMediaName).value, "20240513_Signal_Backups_EncryptThumbnail".toByteArray(), 64)
}
private fun deriveMediaSecrets(mediaId: MediaId): MediaKeyMaterial {
val extendedKey = HKDF.deriveSecrets(this.value, mediaId.value, "20231003_Signal_Backups_EncryptMedia".toByteArray(), 80)

View file

@ -16,6 +16,7 @@ value class MediaName(val name: String) {
companion object {
fun fromDigest(digest: ByteArray) = MediaName(Base64.encodeWithoutPadding(digest))
fun fromDigestForThumbnail(digest: ByteArray) = MediaName("${Base64.encodeWithoutPadding(digest)}_thumbnail")
fun forThumbnailFromMediaName(mediaName: String) = MediaName("${mediaName}_thumbnail")
}
fun toByteArray(): ByteArray {

View file

@ -1586,6 +1586,16 @@ public class PushServiceSocket {
uploadForm.headers);
}
public ResumableUploadSpec getResumableUploadSpecWithKey(AttachmentUploadForm uploadForm, byte[] secretKey) throws IOException {
return new ResumableUploadSpec(secretKey,
Util.getSecretBytes(16),
uploadForm.key,
uploadForm.cdn,
getResumableUploadUrl(uploadForm),
System.currentTimeMillis() + CDN2_RESUMABLE_LINK_LIFETIME_MILLIS,
uploadForm.headers);
}
public AttachmentDigest uploadAttachment(PushAttachmentData attachment) throws IOException {
if (attachment.getResumableUploadSpec() == null || attachment.getResumableUploadSpec().getExpirationTimestamp() < System.currentTimeMillis()) {