diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/ArchivedAttachment.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/ArchivedAttachment.kt index bda906d2a8..f005e3642d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/ArchivedAttachment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/ArchivedAttachment.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.kt index 8d88953e76..784871a778 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.kt index 1c0f4bfc30..0f62be8c04 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.kt index 6b988cf75f..e26b32d95f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/PointerAttachment.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/TombstoneAttachment.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/TombstoneAttachment.kt index efbf88b404..dd2d0364b4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/TombstoneAttachment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/TombstoneAttachment.kt @@ -80,4 +80,5 @@ class TombstoneAttachment : Attachment { override val uri: Uri? = null override val publicUri: Uri? = null + override val thumbnailUri: Uri? = null } diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.kt index 39e3f02a18..2de2f818e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/UriAttachment.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index bab4be3edd..856956270b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -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 { + fun getMediaUploadSpec(secretKey: ByteArray? = null): NetworkResult { 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 { 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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt index c8f3ee2dea..b1116f8dc6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/ChatItemImportInserter.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt index f0401b83c0..49f8bf370c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -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) { @@ -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") diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 591158676b..f45fd79fd1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -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} ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 8dd4929fac..d5f4dbdc1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V231_ArchiveThumbnailColumns.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V231_ArchiveThumbnailColumns.kt new file mode 100644 index 0000000000..dda3d63074 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V231_ArchiveThumbnailColumns.kt @@ -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") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt index 56c11a1808..91b57e91f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt @@ -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 -> { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt index 102cb7b29b..d2d25c2e2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupMessagesJob.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreMediaJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreMediaJob.kt index 039a5ef531..37ea9f3420 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreMediaJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreMediaJob.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt index 21a88b7934..162fbf0197 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt @@ -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): 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): 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, 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 { @@ -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)) ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java b/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java index 74b399786e..db14123ecf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java index 771b0ebcea..3ae762881c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java @@ -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() { diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index 2284d9fa5b..889a9799b8 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -61,4 +61,4 @@ message ArchiveAttachmentBackfillJobData { message ArchiveThumbnailUploadJobData { uint64 attachmentId = 1; -} \ No newline at end of file +} diff --git a/app/src/test/java/org/thoughtcrime/securesms/sms/UploadDependencyGraphTest.kt b/app/src/test/java/org/thoughtcrime/securesms/sms/UploadDependencyGraphTest.kt index 2f78a5272f..a7b6f3df29 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/sms/UploadDependencyGraphTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/sms/UploadDependencyGraphTest.kt @@ -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 ) } diff --git a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt index a3597070c8..91305387bd 100644 --- a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt +++ b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt @@ -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 ) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt index 670f169981..5ca7baa7b1 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/archive/ArchiveApi.kt @@ -156,9 +156,13 @@ class ArchiveApi( } } - fun getResumableUploadSpec(uploadForm: AttachmentUploadForm): NetworkResult { + fun getResumableUploadSpec(uploadForm: AttachmentUploadForm, secretKey: ByteArray?): NetworkResult { return NetworkResult.fromFetch { - pushServiceSocket.getResumableUploadSpec(uploadForm) + if (secretKey == null) { + pushServiceSocket.getResumableUploadSpec(uploadForm) + } else { + pushServiceSocket.getResumableUploadSpecWithKey(uploadForm, secretKey) + } } } diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/BackupKey.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/BackupKey.kt index 8c05a55eea..17f8b876db 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/BackupKey.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/BackupKey.kt @@ -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) diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/MediaName.kt b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/MediaName.kt index e0bc242aaf..3c039634d6 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/MediaName.kt +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/api/backup/MediaName.kt @@ -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 { diff --git a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 598402df91..cc38b0a654 100644 --- a/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -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()) {