diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt index 5231d50b2e..acaf56c94c 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt @@ -703,7 +703,6 @@ class SyncMessageProcessorTest_synchronizeDeleteForMe { uploadTimestamp = this.uploadTimestamp, dataHash = this.dataHash, archiveCdn = this.archiveCdn, - archiveThumbnailCdn = this.archiveThumbnailCdn, archiveMediaName = this.archiveMediaName, archiveMediaId = this.archiveMediaId, thumbnailRestoreState = this.thumbnailRestoreState, 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 e3c54c82c5..246b784399 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.kt @@ -29,9 +29,6 @@ class DatabaseAttachment : Attachment { @JvmField val archiveCdn: Int - @JvmField - val archiveThumbnailCdn: Int - @JvmField val archiveMediaName: String? @@ -78,7 +75,6 @@ class DatabaseAttachment : Attachment { uploadTimestamp: Long, dataHash: String?, archiveCdn: Int, - archiveThumbnailCdn: Int, archiveMediaName: String?, archiveMediaId: String?, thumbnailRestoreState: AttachmentTable.ThumbnailRestoreState, @@ -117,7 +113,6 @@ class DatabaseAttachment : Attachment { this.hasArchiveThumbnail = hasArchiveThumbnail this.displayOrder = displayOrder this.archiveCdn = archiveCdn - this.archiveThumbnailCdn = archiveThumbnailCdn this.archiveMediaName = archiveMediaName this.archiveMediaId = archiveMediaId this.thumbnailRestoreState = thumbnailRestoreState @@ -131,7 +126,6 @@ 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) @@ -147,7 +141,6 @@ 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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/InvalidAttachmentException.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/InvalidAttachmentException.kt new file mode 100644 index 0000000000..41bf54962d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/InvalidAttachmentException.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.attachments + +/** + * Thrown by jobs unable to rehydrate enough attachment information to download it. + */ +class InvalidAttachmentException : Exception { + constructor(s: String?) : super(s) + constructor(e: Exception?) : super(e) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRestoreManager.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRestoreManager.kt index 132a978a73..ae117f9df1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRestoreManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRestoreManager.kt @@ -30,6 +30,7 @@ object BackupRestoreManager { SignalExecutors.BOUNDED.execute { synchronized(this) { val restoringAttachments = messageRecords + .asSequence() .mapNotNull { (it as? MmsMessageRecord?)?.slideDeck?.slides } .flatten() .mapNotNull { it.asAttachment() as? DatabaseAttachment } @@ -41,10 +42,11 @@ object BackupRestoreManager { .toSet() reprioritizedAttachments += restoringAttachments.map { it.first } - val thumbnailJobs = restoringAttachments.map { - val (attachmentId, mmsId) = it + + val thumbnailJobs = restoringAttachments.map { (attachmentId, mmsId) -> RestoreAttachmentThumbnailJob(attachmentId = attachmentId, messageId = mmsId, highPriority = true) } + if (thumbnailJobs.isNotEmpty()) { AppDependencies.jobManager.addAll(thumbnailJobs) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/DatabaseAttachmentBackupExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/DatabaseAttachmentBackupExtensions.kt new file mode 100644 index 0000000000..7b528e0544 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/database/DatabaseAttachmentBackupExtensions.kt @@ -0,0 +1,129 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.backup.v2.database + +import android.text.TextUtils +import org.signal.core.util.Base64 +import org.thoughtcrime.securesms.attachments.DatabaseAttachment +import org.thoughtcrime.securesms.attachments.InvalidAttachmentException +import org.thoughtcrime.securesms.backup.v2.BackupRepository +import org.thoughtcrime.securesms.backup.v2.BackupRepository.getThumbnailMediaName +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.Util +import org.whispersystems.signalservice.api.backup.MediaName +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId +import java.io.IOException +import java.util.Optional + +/** + * Creates a [SignalServiceAttachmentPointer] for the archived attachment of the given [DatabaseAttachment]. + */ +@Throws(InvalidAttachmentException::class) +fun DatabaseAttachment.createArchiveAttachmentPointer(useArchiveCdn: Boolean): SignalServiceAttachmentPointer { + if (remoteKey.isNullOrBlank()) { + throw InvalidAttachmentException("empty encrypted key") + } + + if (remoteDigest == null) { + throw InvalidAttachmentException("no digest") + } + + return try { + val (remoteId, cdnNumber) = if (useArchiveCdn) { + val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey() + val backupDirectories = BackupRepository.getCdnBackupDirectories().successOrThrow() + + val id = SignalServiceAttachmentRemoteId.Backup( + backupDir = backupDirectories.backupDir, + mediaDir = backupDirectories.mediaDir, + mediaId = backupKey.deriveMediaId(MediaName(archiveMediaName!!)).encode() + ) + + id to archiveCdn + } else { + if (remoteLocation.isNullOrEmpty()) { + throw InvalidAttachmentException("empty content id") + } + + SignalServiceAttachmentRemoteId.from(remoteLocation) to cdn.cdnNumber + } + + val key = Base64.decode(remoteKey) + + SignalServiceAttachmentPointer( + cdnNumber = cdnNumber, + remoteId = remoteId, + contentType = null, + key = key, + size = Optional.of(Util.toIntExact(size)), + preview = Optional.empty(), + width = 0, + height = 0, + digest = Optional.ofNullable(remoteDigest), + incrementalDigest = Optional.ofNullable(getIncrementalDigest()), + incrementalMacChunkSize = incrementalMacChunkSize, + fileName = Optional.ofNullable(fileName), + voiceNote = voiceNote, + isBorderless = borderless, + isGif = videoGif, + caption = Optional.empty(), + blurHash = Optional.ofNullable(blurHash).map { it.hash }, + uploadTimestamp = uploadTimestamp, + uuid = uuid + ) + } catch (e: IOException) { + throw InvalidAttachmentException(e) + } catch (e: ArithmeticException) { + throw InvalidAttachmentException(e) + } +} + +/** + * Creates a [SignalServiceAttachmentPointer] for an archived thumbnail of the given [DatabaseAttachment]. + */ +@Throws(InvalidAttachmentException::class) +fun DatabaseAttachment.createArchiveThumbnailPointer(): SignalServiceAttachmentPointer { + if (TextUtils.isEmpty(remoteKey)) { + throw InvalidAttachmentException("empty encrypted key") + } + + val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey() + val backupDirectories = BackupRepository.getCdnBackupDirectories().successOrThrow() + return try { + val key = backupKey.deriveThumbnailTransitKey(getThumbnailMediaName()) + val mediaId = backupKey.deriveMediaId(getThumbnailMediaName()).encode() + SignalServiceAttachmentPointer( + cdnNumber = archiveCdn, + remoteId = SignalServiceAttachmentRemoteId.Backup( + backupDir = backupDirectories.backupDir, + mediaDir = backupDirectories.mediaDir, + mediaId = mediaId + ), + contentType = null, + key = key, + size = Optional.empty(), + preview = Optional.empty(), + width = 0, + height = 0, + digest = Optional.empty(), + incrementalDigest = Optional.empty(), + incrementalMacChunkSize = incrementalMacChunkSize, + fileName = Optional.empty(), + voiceNote = voiceNote, + isBorderless = borderless, + isGif = videoGif, + caption = Optional.empty(), + blurHash = Optional.ofNullable(blurHash).map { it.hash }, + uploadTimestamp = uploadTimestamp, + uuid = uuid + ) + } catch (e: IOException) { + throw InvalidAttachmentException(e) + } catch (e: ArithmeticException) { + throw InvalidAttachmentException(e) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt index ac3cac2a9b..506edd4e8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundFragment.kt @@ -184,7 +184,8 @@ class InternalBackupPlaygroundFragment : ComposeFragment() { deleteArchivedMedia = { viewModel.deleteArchivedMedia(it) }, batchArchiveAttachmentMedia = { viewModel.archiveAttachmentMedia(it) }, batchDeleteBackupAttachmentMedia = { viewModel.deleteArchivedMedia(it) }, - restoreArchivedMedia = { viewModel.restoreArchivedMedia(it) } + restoreArchivedMedia = { viewModel.restoreArchivedMedia(it, asThumbnail = false) }, + restoreArchivedMediaThumbnail = { viewModel.restoreArchivedMedia(it, asThumbnail = true) } ) } ) @@ -450,7 +451,8 @@ fun MediaList( deleteArchivedMedia: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit, batchArchiveAttachmentMedia: (Set) -> Unit, batchDeleteBackupAttachmentMedia: (Set) -> Unit, - restoreArchivedMedia: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit + restoreArchivedMedia: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit, + restoreArchivedMediaThumbnail: (InternalBackupPlaygroundViewModel.BackupAttachment) -> Unit ) { if (!enabled) { Text( @@ -553,6 +555,14 @@ fun MediaList( } ) + DropdownMenuItem( + text = { Text("Pseudo Restore Thumbnail") }, + onClick = { + selectionState = selectionState.copy(expandedOption = null) + restoreArchivedMediaThumbnail(attachment) + } + ) + if (attachment.dbAttachment.dataHash != null && attachment.state == InternalBackupPlaygroundViewModel.BackupAttachment.State.UPLOADED_UNDOWNLOADED) { DropdownMenuItem( text = { Text("Re-copy with hash") }, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt index 73622649db..49dc564d95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/backup/InternalBackupPlaygroundViewModel.kt @@ -32,11 +32,12 @@ import org.thoughtcrime.securesms.database.MessageType import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobs.ArchiveAttachmentJob -import org.thoughtcrime.securesms.jobs.AttachmentDownloadJob import org.thoughtcrime.securesms.jobs.AttachmentUploadJob import org.thoughtcrime.securesms.jobs.BackupMessagesJob import org.thoughtcrime.securesms.jobs.BackupRestoreJob import org.thoughtcrime.securesms.jobs.BackupRestoreMediaJob +import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob +import org.thoughtcrime.securesms.jobs.RestoreAttachmentThumbnailJob import org.thoughtcrime.securesms.jobs.RestoreLocalAttachmentJob import org.thoughtcrime.securesms.jobs.SyncArchivedMediaJob import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -346,7 +347,7 @@ class InternalBackupPlaygroundViewModel : ViewModel() { } } - fun restoreArchivedMedia(attachment: BackupAttachment) { + fun restoreArchivedMedia(attachment: BackupAttachment, asThumbnail: Boolean) { disposables += Completable .fromCallable { val recipientId = SignalStore.releaseChannel.releaseChannelRecipientId!! @@ -365,20 +366,29 @@ class InternalBackupPlaygroundViewModel : ViewModel() { val insertMessage = SignalDatabase.messages.insertMessageInbox(message, threadId).get() SignalDatabase.attachments.debugCopyAttachmentForArchiveRestore( - insertMessage.messageId, - attachment.dbAttachment + mmsId = insertMessage.messageId, + attachment = attachment.dbAttachment, + forThumbnail = asThumbnail ) val archivedAttachment = SignalDatabase.attachments.getAttachmentsForMessage(insertMessage.messageId).first() - AppDependencies.jobManager.add( - AttachmentDownloadJob( - messageId = insertMessage.messageId, - attachmentId = archivedAttachment.attachmentId, - manual = false, - forceArchiveDownload = true + if (asThumbnail) { + AppDependencies.jobManager.add( + RestoreAttachmentThumbnailJob( + messageId = insertMessage.messageId, + attachmentId = archivedAttachment.attachmentId, + highPriority = false + ) ) - ) + } else { + AppDependencies.jobManager.add( + RestoreAttachmentJob( + messageId = insertMessage.messageId, + attachmentId = archivedAttachment.attachmentId + ) + ) + } } .subscribeOn(Schedulers.io()) .observeOn(Schedulers.single()) 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 e6056694f5..ce14547910 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -55,7 +55,6 @@ import org.signal.core.util.requireBlob import org.signal.core.util.requireBoolean import org.signal.core.util.requireInt import org.signal.core.util.requireLong -import org.signal.core.util.requireLongOrNull import org.signal.core.util.requireNonNullBlob import org.signal.core.util.requireNonNullString import org.signal.core.util.requireObject @@ -98,10 +97,12 @@ import org.thoughtcrime.securesms.util.StorageUtil import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.video.EncryptedMediaDataSource import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult +import org.whispersystems.signalservice.api.backup.MediaId import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream import org.whispersystems.signalservice.api.util.UuidUtil import org.whispersystems.signalservice.internal.crypto.PaddingInputStream import org.whispersystems.signalservice.internal.util.JsonUtil +import java.io.ByteArrayInputStream import java.io.File import java.io.FileNotFoundException import java.io.IOException @@ -163,7 +164,6 @@ class AttachmentTable( 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" const val THUMBNAIL_RESTORE_STATE = "thumbnail_restore_state" @@ -219,7 +219,6 @@ class AttachmentTable( DATA_HASH_START, DATA_HASH_END, ARCHIVE_CDN, - ARCHIVE_THUMBNAIL_CDN, ARCHIVE_MEDIA_NAME, ARCHIVE_MEDIA_ID, ARCHIVE_TRANSFER_FILE, @@ -269,7 +268,6 @@ class AttachmentTable( $ARCHIVE_MEDIA_ID TEXT DEFAULT NULL, $ARCHIVE_TRANSFER_FILE TEXT DEFAULT NULL, $ARCHIVE_TRANSFER_STATE INTEGER DEFAULT ${ArchiveTransferState.NONE.value}, - $ARCHIVE_THUMBNAIL_CDN INTEGER DEFAULT 0, $ARCHIVE_THUMBNAIL_MEDIA_ID TEXT DEFAULT NULL, $THUMBNAIL_FILE TEXT DEFAULT NULL, $THUMBNAIL_RANDOM BLOB DEFAULT NULL, @@ -499,29 +497,16 @@ class AttachmentTable( } } - fun getRestorableAttachments(batchSize: Int): List { + fun getRestorableAttachments(batchSize: Int): List { return readableDatabase - .select(*PROJECTION) + .select(ID, MESSAGE_ID, DATA_SIZE, REMOTE_DIGEST, REMOTE_KEY) .from(TABLE_NAME) .where("$TRANSFER_STATE = ?", TRANSFER_NEEDS_RESTORE) .limit(batchSize) .orderBy("$ID DESC") .run() .readToList { - it.readAttachment() - } - } - - fun getLocalRestorableAttachments(batchSize: Int): List { - return readableDatabase - .select(*PROJECTION) - .from(TABLE_NAME) - .where("$TRANSFER_STATE = ?", TRANSFER_NEEDS_RESTORE) - .limit(batchSize) - .orderBy("$ID DESC") - .run() - .readToList { - LocalRestorableAttachment( + RestorableAttachment( attachmentId = AttachmentId(it.requireLong(ID)), mmsId = it.requireLong(MESSAGE_ID), size = it.requireLong(DATA_SIZE), @@ -871,17 +856,6 @@ class AttachmentTable( notifyConversationListeners(threadId) } - fun setThumbnailTransferState(messageId: Long, attachmentId: AttachmentId, thumbnailRestoreState: ThumbnailRestoreState) { - writableDatabase - .update(TABLE_NAME) - .values(THUMBNAIL_RESTORE_STATE to thumbnailRestoreState.value) - .where("$ID = ?", attachmentId.id) - .run() - - val threadId = messages.getThreadIdForMessage(messageId) - notifyConversationListeners(threadId) - } - fun setTransferProgressFailed(attachmentId: AttachmentId, mmsId: Long) { writableDatabase .update(TABLE_NAME) @@ -912,30 +886,46 @@ class AttachmentTable( notifyConversationListeners(messages.getThreadIdForMessage(mmsId)) } - fun setRestoreInProgressTransferState(restorableAttachments: List) { - setRestoreTransferState( - restorableAttachments = restorableAttachments, - prefix = "$TRANSFER_STATE = $TRANSFER_NEEDS_RESTORE", - state = TRANSFER_RESTORE_IN_PROGRESS - ) - } + fun setThumbnailRestoreState(thumbnailAttachmentIds: List, thumbnailRestoreState: ThumbnailRestoreState) { + val prefix: String = when (thumbnailRestoreState) { + ThumbnailRestoreState.IN_PROGRESS -> { + "($THUMBNAIL_RESTORE_STATE = ${ThumbnailRestoreState.NEEDS_RESTORE.value} OR $THUMBNAIL_RESTORE_STATE = ${ThumbnailRestoreState.IN_PROGRESS.value}) AND" + } + else -> "" + } - fun setRestoreFailedTransferState(notRestorableAttachments: List) { - setRestoreTransferState( - restorableAttachments = notRestorableAttachments, - prefix = "$TRANSFER_STATE != $TRANSFER_PROGRESS_PERMANENT_FAILURE", - state = TRANSFER_PROGRESS_FAILED + val setQueries = SqlUtil.buildCollectionQuery( + column = ID, + values = thumbnailAttachmentIds.map { it.id }, + prefix = prefix ) - } - private fun setRestoreTransferState(restorableAttachments: List, prefix: String, state: Int) { writableDatabase.withinTransaction { - val setQueries = SqlUtil.buildCollectionQuery( - column = ID, - values = restorableAttachments.map { it.attachmentId.id }, - prefix = "$prefix AND" - ) + setQueries.forEach { query -> + writableDatabase + .update(TABLE_NAME) + .values(THUMBNAIL_RESTORE_STATE to thumbnailRestoreState.value) + .where(query.where, query.whereArgs) + .run() + } + } + } + fun setRestoreTransferState(restorableAttachments: Collection, state: Int) { + val prefix = when (state) { + TRANSFER_RESTORE_OFFLOADED -> "$TRANSFER_STATE != $TRANSFER_PROGRESS_PERMANENT_FAILURE AND" + TRANSFER_RESTORE_IN_PROGRESS -> "($TRANSFER_STATE = $TRANSFER_NEEDS_RESTORE OR $TRANSFER_STATE = $TRANSFER_RESTORE_OFFLOADED) AND" + TRANSFER_PROGRESS_FAILED -> "$TRANSFER_STATE != $TRANSFER_PROGRESS_PERMANENT_FAILURE AND" + else -> "" + } + + val setQueries = SqlUtil.buildCollectionQuery( + column = ID, + values = restorableAttachments.map { it.attachmentId.id }, + prefix = prefix + ) + + writableDatabase.withinTransaction { setQueries.forEach { query -> writableDatabase .update(TABLE_NAME) @@ -943,23 +933,6 @@ class AttachmentTable( .where(query.where, query.whereArgs) .run() } - - val threadQueries = SqlUtil.buildCollectionQuery( - column = MessageTable.ID, - values = restorableAttachments.map { it.mmsId } - ) - - val threads = mutableSetOf() - threadQueries.forEach { query -> - threads += readableDatabase - .select("DISTINCT ${MessageTable.THREAD_ID}") - .from(MessageTable.TABLE_NAME) - .where(query.where, query.whereArgs) - .run() - .readToList { it.requireLongOrNull(MessageTable.THREAD_ID) ?: -1 } - } - - notifyConversationListeners(threads) } } @@ -1103,6 +1076,30 @@ class AttachmentTable( } } + fun finalizeAttachmentThumbnailAfterUpload( + attachmentId: AttachmentId, + archiveMediaId: String, + archiveThumbnailMediaId: MediaId, + data: ByteArray + ) { + Log.i(TAG, "[finalizeAttachmentThumbnailAfterUpload] Finalizing archive data for $attachmentId thumbnail.") + val fileWriteResult: DataFileWriteResult = writeToDataFile(newDataFile(context), ByteArrayInputStream(data), TransformProperties.empty()) + + writableDatabase.withinTransaction { db -> + val values = contentValuesOf( + THUMBNAIL_FILE to fileWriteResult.file.absolutePath, + THUMBNAIL_RANDOM to fileWriteResult.random, + THUMBNAIL_RESTORE_STATE to ThumbnailRestoreState.FINISHED.value, + ARCHIVE_THUMBNAIL_MEDIA_ID to archiveThumbnailMediaId.encode() + ) + + db.update(TABLE_NAME) + .values(values) + .where("$ARCHIVE_MEDIA_ID = ? OR $ID = ?", archiveMediaId, attachmentId) + .run() + } + } + /** * 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. @@ -1334,7 +1331,8 @@ class AttachmentTable( fun debugCopyAttachmentForArchiveRestore( mmsId: Long, - attachment: DatabaseAttachment + attachment: DatabaseAttachment, + forThumbnail: Boolean ) { val copy = """ @@ -1365,7 +1363,9 @@ class AttachmentTable( $DATA_HASH_END, $ARCHIVE_MEDIA_ID, $ARCHIVE_MEDIA_NAME, - $ARCHIVE_CDN + $ARCHIVE_CDN, + $ARCHIVE_THUMBNAIL_MEDIA_ID, + $THUMBNAIL_RESTORE_STATE ) SELECT $mmsId, @@ -1393,7 +1393,9 @@ class AttachmentTable( $DATA_HASH_END, "${attachment.archiveMediaId}", "${attachment.archiveMediaName}", - ${attachment.archiveCdn} + ${attachment.archiveCdn}, + $ARCHIVE_THUMBNAIL_MEDIA_ID, + ${if (forThumbnail) ThumbnailRestoreState.NEEDS_RESTORE.value else ThumbnailRestoreState.NONE.value} FROM $TABLE_NAME WHERE $ID = ${attachment.attachmentId.id} """ @@ -1667,7 +1669,6 @@ 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), hasArchiveThumbnail = !TextUtils.isEmpty(jsonObject.getString(THUMBNAIL_FILE)), @@ -1745,11 +1746,10 @@ class AttachmentTable( fun updateArchiveCdnByMediaId(archiveMediaId: String, archiveCdn: Int): Int { 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) + "RETURNING $ARCHIVE_CDN", + SqlUtil.buildArgs(archiveMediaId, archiveCdn, archiveMediaId, archiveMediaId) ).count } @@ -2269,7 +2269,6 @@ 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), hasArchiveThumbnail = !cursor.isNull(THUMBNAIL_FILE), @@ -2562,11 +2561,19 @@ class AttachmentTable( val remoteIv: ByteArray ) - class LocalRestorableAttachment( + class RestorableAttachment( val attachmentId: AttachmentId, val mmsId: Long, val size: Long, val remoteDigest: ByteArray?, val remoteKey: ByteArray? - ) + ) { + override fun equals(other: Any?): Boolean { + return this === other || attachmentId == (other as? RestorableAttachment)?.attachmentId + } + + override fun hashCode(): Int { + return attachmentId.hashCode() + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt index ff8c367c48..380f4e6c0f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt @@ -56,7 +56,6 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_CDN}, ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_MEDIA_NAME}, ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_MEDIA_ID}, - ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ARCHIVE_THUMBNAIL_CDN}, ${AttachmentTable.TABLE_NAME}.${AttachmentTable.THUMBNAIL_RESTORE_STATE}, ${AttachmentTable.TABLE_NAME}.${AttachmentTable.ATTACHMENT_UUID}, ${MessageTable.TABLE_NAME}.${MessageTable.TYPE}, 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 51420bee4d..81c278f9b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -388,7 +388,6 @@ 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}, '${AttachmentTable.THUMBNAIL_RESTORE_STATE}', ${AttachmentTable.TABLE_NAME}.${AttachmentTable.THUMBNAIL_RESTORE_STATE}, 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 99ffcad3ae..a832f496cd 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 @@ -103,6 +103,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V242_MessageFullTex import org.thoughtcrime.securesms.database.helpers.migration.V243_MessageFullTextSearchDisableSecureDelete import org.thoughtcrime.securesms.database.helpers.migration.V244_AttachmentRemoteIv import org.thoughtcrime.securesms.database.helpers.migration.V245_DeletionTimestampOnCallLinks +import org.thoughtcrime.securesms.database.helpers.migration.V246_DropThumbnailCdnFromAttachments /** * Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness. @@ -208,10 +209,11 @@ object SignalDatabaseMigrations { 242 to V242_MessageFullTextSearchEmojiSupportV2, 243 to V243_MessageFullTextSearchDisableSecureDelete, 244 to V244_AttachmentRemoteIv, - 245 to V245_DeletionTimestampOnCallLinks + 245 to V245_DeletionTimestampOnCallLinks, + 246 to V246_DropThumbnailCdnFromAttachments ) - const val DATABASE_VERSION = 245 + const val DATABASE_VERSION = 246 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V246_DropThumbnailCdnFromAttachments.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V246_DropThumbnailCdnFromAttachments.kt new file mode 100644 index 0000000000..20d5f9451c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V246_DropThumbnailCdnFromAttachments.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2023 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 + +/** + * Thumbnails are best effort and assumed to have the same CDN as the full attachment, there is no need to store it in the database. + */ +@Suppress("ClassName") +object V246_DropThumbnailCdnFromAttachments : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("ALTER TABLE attachment DROP COLUMN archive_thumbnail_cdn") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java index 199bdf21f8..fd87e14684 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobController.java @@ -164,7 +164,7 @@ class JobController { } @WorkerThread - void submitJobs(@NonNull List jobs) { + void submitJobs(@NonNull List jobs) { List canRun = new ArrayList<>(jobs.size()); synchronized (this) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java index 7d5d7b3044..035842ae7c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobmanager/JobManager.java @@ -198,7 +198,7 @@ public class JobManager implements ConstraintObserver.Notifier { }); } - public void addAll(@NonNull List jobs) { + public void addAll(@NonNull List jobs) { if (jobs.isEmpty()) { return; } 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 92f416ddb1..bb180b8434 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.getMediaName import org.thoughtcrime.securesms.backup.v2.BackupRepository.getThumbnailMediaName import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -29,7 +30,6 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStre import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec import java.io.ByteArrayInputStream import java.io.IOException -import java.lang.RuntimeException import java.util.Optional import kotlin.time.Duration.Companion.days @@ -52,7 +52,7 @@ class ArchiveThumbnailUploadJob private constructor( } } - constructor(attachmentId: AttachmentId) : this( + private constructor(attachmentId: AttachmentId) : this( Parameters.Builder() .setQueue("ArchiveThumbnailUploadJob") .addConstraint(NetworkConstraint.KEY) @@ -82,11 +82,15 @@ class ArchiveThumbnailUploadJob private constructor( return Result.success() } + // TODO [backups] Decide if we fail a job when associated attachment not already backed up + // TODO [backups] Determine if we actually need to upload or are reusing a thumbnail from another attachment + val thumbnailResult = generateThumbnailIfPossible(attachment) if (thumbnailResult == null) { 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(secretKey = backupKey.deriveThumbnailTransitKey(attachment.getThumbnailMediaName()))) { @@ -123,6 +127,10 @@ class ArchiveThumbnailUploadJob private constructor( return when (val result = BackupRepository.archiveThumbnail(attachmentPointer, attachment)) { is NetworkResult.Success -> { + // save attachment thumbnail + val archiveMediaId = attachment.archiveMediaId ?: backupKey.deriveMediaId(attachment.getMediaName()).encode() + SignalDatabase.attachments.finalizeAttachmentThumbnailAfterUpload(attachmentId, archiveMediaId, mediaSecrets.id, thumbnailResult.data) + 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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.kt index ba72699652..3ef57eb49f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.kt @@ -4,8 +4,6 @@ */ package org.thoughtcrime.securesms.jobs -import android.text.TextUtils -import androidx.annotation.VisibleForTesting import okio.Source import okio.buffer import org.greenrobot.eventbus.EventBus @@ -19,7 +17,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 +import org.thoughtcrime.securesms.attachments.InvalidAttachmentException import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -37,7 +35,6 @@ import org.thoughtcrime.securesms.transport.RetryLaterException import org.thoughtcrime.securesms.util.AttachmentUtil import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.Util -import org.whispersystems.signalservice.api.backup.MediaName import org.whispersystems.signalservice.api.messages.SignalServiceAttachment import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId @@ -56,9 +53,8 @@ import java.util.concurrent.TimeUnit class AttachmentDownloadJob private constructor( parameters: Parameters, private val messageId: Long, - attachmentId: AttachmentId, - private val manual: Boolean, - private var forceArchiveDownload: Boolean + private val attachmentId: AttachmentId, + private val manual: Boolean ) : BaseJob(parameters) { companion object { @@ -68,7 +64,6 @@ class AttachmentDownloadJob private constructor( 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" @JvmStatic fun constructQueueString(attachmentId: AttachmentId): String { @@ -89,25 +84,37 @@ class AttachmentDownloadJob private constructor( @JvmStatic fun downloadAttachmentIfNeeded(databaseAttachment: DatabaseAttachment): String? { return when (val transferState = databaseAttachment.transferState) { - AttachmentTable.TRANSFER_RESTORE_OFFLOADED -> RestoreAttachmentJob.restoreOffloadedAttachment(databaseAttachment) + AttachmentTable.TRANSFER_RESTORE_OFFLOADED, + AttachmentTable.TRANSFER_NEEDS_RESTORE -> RestoreAttachmentJob.restoreAttachment(databaseAttachment) AttachmentTable.TRANSFER_PROGRESS_PENDING, - AttachmentTable.TRANSFER_PROGRESS_FAILED, - AttachmentTable.TRANSFER_NEEDS_RESTORE -> { - val downloadJob = AttachmentDownloadJob( - messageId = databaseAttachment.mmsId, - attachmentId = databaseAttachment.attachmentId, - manual = true, - forceArchiveDownload = false - ) - AppDependencies.jobManager.add(downloadJob) - downloadJob.id + AttachmentTable.TRANSFER_PROGRESS_FAILED -> { + if (SignalStore.backup.backsUpMedia && databaseAttachment.remoteLocation == null) { + if (databaseAttachment.archiveMediaName.isNullOrEmpty()) { + Log.w(TAG, "No remote location or archive media name, can't download") + null + } else { + Log.i(TAG, "Trying to restore attachment from archive cdn") + RestoreAttachmentJob.restoreAttachment(databaseAttachment) + } + } else { + val downloadJob = AttachmentDownloadJob( + messageId = databaseAttachment.mmsId, + attachmentId = databaseAttachment.attachmentId, + manual = true + ) + AppDependencies.jobManager.add(downloadJob) + downloadJob.id + } } AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS, AttachmentTable.TRANSFER_PROGRESS_DONE, AttachmentTable.TRANSFER_PROGRESS_STARTED, - AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE -> null + AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE -> { + Log.d(TAG, "$databaseAttachment is downloading, downloaded already or permanently failed, transferState: $transferState") + null + } else -> { Log.w(TAG, "Attempted to download attachment with unknown transfer state: $transferState") @@ -117,9 +124,7 @@ class AttachmentDownloadJob private constructor( } } - private val attachmentId: Long - - constructor(messageId: Long, attachmentId: AttachmentId, manual: Boolean, forceArchiveDownload: Boolean = false) : this( + constructor(messageId: Long, attachmentId: AttachmentId, manual: Boolean) : this( Parameters.Builder() .setQueue(constructQueueString(attachmentId)) .addConstraint(NetworkConstraint.KEY) @@ -128,20 +133,14 @@ class AttachmentDownloadJob private constructor( .build(), messageId, attachmentId, - manual, - forceArchiveDownload + manual ) - init { - this.attachmentId = attachmentId.id - } - override fun serialize(): ByteArray? { return JsonJobData.Builder() .putLong(KEY_MESSAGE_ID, messageId) - .putLong(KEY_ATTACHMENT_ID, attachmentId) + .putLong(KEY_ATTACHMENT_ID, attachmentId.id) .putBoolean(KEY_MANUAL, manual) - .putBoolean(KEY_FORCE_ARCHIVE, forceArchiveDownload) .serialize() } @@ -152,7 +151,6 @@ class AttachmentDownloadJob private constructor( override fun onAdded() { Log.i(TAG, "onAdded() messageId: $messageId attachmentId: $attachmentId manual: $manual") - val attachmentId = AttachmentId(attachmentId) val attachment = SignalDatabase.attachments.getAttachment(attachmentId) val pending = attachment != null && attachment.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && attachment.transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE @@ -175,7 +173,6 @@ class AttachmentDownloadJob private constructor( fun doWork() { Log.i(TAG, "onRun() messageId: $messageId attachmentId: $attachmentId manual: $manual") - val attachmentId = AttachmentId(attachmentId) val attachment = SignalDatabase.attachments.getAttachment(attachmentId) if (attachment == null) { @@ -199,6 +196,17 @@ class AttachmentDownloadJob private constructor( return } + if (SignalStore.backup.backsUpMedia && attachment.remoteLocation == null) { + if (attachment.archiveMediaName.isNullOrEmpty()) { + throw InvalidAttachmentException("No remote location or archive media name") + } + + Log.i(TAG, "Trying to restore attachment from archive cdn instead") + RestoreAttachmentJob.restoreAttachment(attachment) + + return + } + Log.i(TAG, "Downloading push part $attachmentId") SignalDatabase.attachments.setTransferState(messageId, attachmentId, AttachmentTable.TRANSFER_PROGRESS_STARTED) @@ -218,7 +226,6 @@ class AttachmentDownloadJob private constructor( override fun onFailure() { Log.w(TAG, format(this, "onFailure() messageId: $messageId attachmentId: $attachmentId manual: $manual")) - val attachmentId = AttachmentId(attachmentId) markFailed(messageId, attachmentId) } @@ -235,25 +242,13 @@ class AttachmentDownloadJob private constructor( ) { val maxReceiveSize: Long = RemoteConfig.maxAttachmentReceiveSizeBytes val attachmentFile: File = SignalDatabase.attachments.getOrCreateTransferFile(attachmentId) - var archiveFile: File? = null - var useArchiveCdn = false try { if (attachment.size > maxReceiveSize) { throw MmsException("Attachment too large, failing download") } - useArchiveCdn = if (SignalStore.backup.backsUpMedia && (forceArchiveDownload || attachment.remoteLocation == null)) { - if (attachment.archiveMediaName.isNullOrEmpty()) { - throw InvalidPartException("Invalid attachment configuration") - } - true - } else { - false - } - - val messageReceiver = AppDependencies.signalServiceMessageReceiver - val pointer = createAttachmentPointer(attachment, useArchiveCdn) + val pointer = createAttachmentPointer(attachment) val progressListener = object : SignalServiceAttachment.ProgressListener { override fun onAttachmentProgress(total: Long, progress: Long) { @@ -265,55 +260,32 @@ class AttachmentDownloadJob private constructor( } } - val downloadResult = if (useArchiveCdn) { - archiveFile = SignalDatabase.attachments.getOrCreateArchiveTransferFile(attachmentId) - val cdnCredentials = BackupRepository.getCdnReadCredentials(attachment.archiveCdn).successOrThrow().headers - - messageReceiver - .retrieveArchivedAttachment( - SignalStore.svr.getOrCreateMasterKey().deriveBackupKey().deriveMediaSecrets(MediaName(attachment.archiveMediaName!!)), - cdnCredentials, - archiveFile, - pointer, - attachmentFile, - maxReceiveSize, - false, - progressListener - ) - } else { - messageReceiver - .retrieveAttachment( - pointer, - attachmentFile, - maxReceiveSize, - progressListener - ) - } + val downloadResult = AppDependencies + .signalServiceMessageReceiver + .retrieveAttachment( + pointer, + attachmentFile, + maxReceiveSize, + progressListener + ) SignalDatabase.attachments.finalizeAttachmentAfterDownload(messageId, attachmentId, downloadResult.dataStream, downloadResult.iv) } catch (e: RangeException) { - val transferFile = archiveFile ?: attachmentFile - Log.w(TAG, "Range exception, file size " + transferFile.length(), e) - if (transferFile.delete()) { + Log.w(TAG, "Range exception, file size " + attachmentFile.length(), e) + if (attachmentFile.delete()) { Log.i(TAG, "Deleted temp download file to recover") throw RetryLaterException(e) } else { throw IOException("Failed to delete temp download file following range exception") } - } catch (e: InvalidPartException) { + } catch (e: InvalidAttachmentException) { Log.w(TAG, "Experienced exception while trying to download an attachment.", e) markFailed(messageId, attachmentId) } catch (e: NonSuccessfulResponseCodeException) { - if (SignalStore.backup.backsUpMedia) { - if (e.code == 404 && !useArchiveCdn && attachment.archiveMediaName?.isNotEmpty() == true) { - Log.i(TAG, "Retrying download from archive CDN") - forceArchiveDownload = true - retrieveAttachment(messageId, attachmentId, attachment) - return - } else if (e.code == 401 && useArchiveCdn) { - SignalStore.backup.cdnReadCredentials = null - throw RetryLaterException(e) - } + if (SignalStore.backup.backsUpMedia && e.code == 404 && attachment.archiveMediaName?.isNotEmpty() == true) { + Log.i(TAG, "Retrying download from archive CDN") + RestoreAttachmentJob.restoreAttachment(attachment) + return } Log.w(TAG, "Experienced exception while trying to download an attachment.", e) @@ -335,47 +307,31 @@ class AttachmentDownloadJob private constructor( } } - @Throws(InvalidPartException::class) - private fun createAttachmentPointer(attachment: DatabaseAttachment, useArchiveCdn: Boolean): SignalServiceAttachmentPointer { - if (TextUtils.isEmpty(attachment.remoteKey)) { - throw InvalidPartException("empty encrypted key") + @Throws(InvalidAttachmentException::class) + private fun createAttachmentPointer(attachment: DatabaseAttachment): SignalServiceAttachmentPointer { + if (attachment.remoteKey.isNullOrEmpty()) { + throw InvalidAttachmentException("empty encrypted key") + } + + if (attachment.remoteLocation.isNullOrEmpty()) { + throw InvalidAttachmentException("empty content id") } return try { - val remoteData: RemoteData = if (useArchiveCdn) { - val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey() - val backupDirectories = BackupRepository.getCdnBackupDirectories().successOrThrow() + val remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation) + val cdnNumber = attachment.cdn.cdnNumber - RemoteData( - remoteId = SignalServiceAttachmentRemoteId.Backup( - backupDir = backupDirectories.backupDir, - mediaDir = backupDirectories.mediaDir, - mediaId = backupKey.deriveMediaId(MediaName(attachment.archiveMediaName!!)).encode() - ), - cdnNumber = attachment.archiveCdn - ) - } else { - if (attachment.remoteLocation.isNullOrEmpty()) { - throw InvalidPartException("empty content id") - } - - RemoteData( - remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation), - cdnNumber = attachment.cdn.cdnNumber - ) - } - - val key = Base64.decode(attachment.remoteKey!!) + val key = Base64.decode(attachment.remoteKey) if (attachment.remoteDigest != null) { Log.i(TAG, "Downloading attachment with digest: " + Hex.toString(attachment.remoteDigest)) } else { - throw InvalidPartException("Null remote digest for $attachmentId") + throw InvalidAttachmentException("Null remote digest for $attachmentId") } SignalServiceAttachmentPointer( - remoteData.cdnNumber, - remoteData.remoteId, + cdnNumber, + remoteId, null, key, Optional.of(Util.toIntExact(attachment.size)), @@ -396,10 +352,10 @@ class AttachmentDownloadJob private constructor( ) } catch (e: IOException) { Log.w(TAG, e) - throw InvalidPartException(e) + throw InvalidAttachmentException(e) } catch (e: ArithmeticException) { Log.w(TAG, e) - throw InvalidPartException(e) + throw InvalidAttachmentException(e) } } @@ -442,14 +398,6 @@ class AttachmentDownloadJob private constructor( SignalDatabase.attachments.setTransferProgressPermanentFailure(attachmentId, messageId) } - @VisibleForTesting - internal class InvalidPartException : Exception { - constructor(s: String?) : super(s) - constructor(e: Exception?) : super(e) - } - - private data class RemoteData(val remoteId: SignalServiceAttachmentRemoteId, val cdnNumber: Int) - class Factory : Job.Factory { override fun create(parameters: Parameters, serializedData: ByteArray?): AttachmentDownloadJob { val data = JsonJobData.deserialize(serializedData) @@ -457,8 +405,7 @@ class AttachmentDownloadJob private constructor( parameters = parameters, messageId = data.getLong(KEY_MESSAGE_ID), attachmentId = AttachmentId(data.getLong(KEY_ATTACHMENT_ID)), - manual = data.getBoolean(KEY_MANUAL), - forceArchiveDownload = data.getBooleanOrDefault(KEY_FORCE_ARCHIVE, false) + manual = data.getBoolean(KEY_MANUAL) ) } } 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 2b5e61974b..40dae504cf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreMediaJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreMediaJob.kt @@ -6,7 +6,9 @@ package org.thoughtcrime.securesms.jobs import org.signal.core.util.logging.Log +import org.signal.core.util.withinTransaction import org.thoughtcrime.securesms.database.AttachmentTable +import org.thoughtcrime.securesms.database.AttachmentTable.RestorableAttachment import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -48,40 +50,64 @@ class BackupRestoreMediaJob private constructor(parameters: Parameters) : BaseJo throw NotPushRegisteredException() } - SignalStore.backup.totalRestorableAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize() val jobManager = AppDependencies.jobManager - val batchSize = 100 + val batchSize = 500 val restoreTime = System.currentTimeMillis() - var restoreJobBatch: List + do { + val restoreThumbnailJobs: MutableList = mutableListOf() + val restoreFullAttachmentJobs: MutableMap = mutableMapOf() + + val restoreThumbnailOnlyAttachments: MutableList = mutableListOf() + val notRestorable: MutableList = mutableListOf() + val attachmentBatch = SignalDatabase.attachments.getRestorableAttachments(batchSize) val messageIds = attachmentBatch.map { it.mmsId }.toSet() val messageMap = SignalDatabase.messages.getMessages(messageIds).associate { it.id to (it as MmsMessageRecord) } - restoreJobBatch = SignalDatabase.attachments.getRestorableAttachments(batchSize).map { attachment -> - val message = messageMap[attachment.mmsId]!! + + for (attachment in attachmentBatch) { + val message = messageMap[attachment.mmsId] + if (message == null) { + Log.w(TAG, "Unable to find message for ${attachment.attachmentId}") + notRestorable += attachment + continue + } + + restoreThumbnailJobs += RestoreAttachmentThumbnailJob( + messageId = attachment.mmsId, + attachmentId = attachment.attachmentId, + highPriority = false + ) + if (shouldRestoreFullSize(message, restoreTime, SignalStore.backup.optimizeStorage)) { - RestoreAttachmentJob( + restoreFullAttachmentJobs += attachment to RestoreAttachmentJob( messageId = attachment.mmsId, - attachmentId = attachment.attachmentId, - manual = false, - forceArchiveDownload = true, - restoreMode = RestoreAttachmentJob.RestoreMode.ORIGINAL + attachmentId = attachment.attachmentId ) } else { - SignalDatabase.attachments.setTransferState( - messageId = attachment.mmsId, - attachmentId = attachment.attachmentId, - transferState = AttachmentTable.TRANSFER_RESTORE_OFFLOADED - ) - RestoreAttachmentThumbnailJob( - messageId = attachment.mmsId, - attachmentId = attachment.attachmentId, - highPriority = false - ) + restoreThumbnailOnlyAttachments += attachment } } - jobManager.addAll(restoreJobBatch) - } while (restoreJobBatch.isNotEmpty()) + + SignalDatabase.rawDatabase.withinTransaction { + // Mark not restorable thumbnails and attachments as failed + SignalDatabase.attachments.setThumbnailRestoreState(notRestorable.map { it.attachmentId }, AttachmentTable.ThumbnailRestoreState.PERMANENT_FAILURE) + SignalDatabase.attachments.setRestoreTransferState(notRestorable, AttachmentTable.TRANSFER_PROGRESS_FAILED) + + // Mark restorable thumbnails and attachments as in progress + SignalDatabase.attachments.setThumbnailRestoreState(restoreThumbnailJobs.map { it.attachmentId }, AttachmentTable.ThumbnailRestoreState.IN_PROGRESS) + SignalDatabase.attachments.setRestoreTransferState(restoreFullAttachmentJobs.keys, AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS) + + // Set thumbnail only attachments as offloaded + SignalDatabase.attachments.setRestoreTransferState(restoreThumbnailOnlyAttachments, AttachmentTable.TRANSFER_RESTORE_OFFLOADED) + + jobManager.addAll(restoreThumbnailJobs + restoreFullAttachmentJobs.values) + } + } while (restoreThumbnailJobs.isNotEmpty() && restoreFullAttachmentJobs.isNotEmpty() && notRestorable.isNotEmpty()) + + SignalStore.backup.totalRestorableAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize() + + jobManager.add(CheckRestoreMediaLeftJob(RestoreAttachmentJob.constructQueueString())) } private fun shouldRestoreFullSize(message: MmsMessageRecord, restoreTime: Long, optimizeStorage: Boolean): Boolean { 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 9be28520a6..5cdebe41f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt @@ -4,44 +4,36 @@ */ package org.thoughtcrime.securesms.jobs -import android.text.TextUtils -import androidx.annotation.VisibleForTesting import org.greenrobot.eventbus.EventBus -import org.signal.core.util.Base64 -import org.signal.core.util.Hex import org.signal.core.util.logging.Log import org.signal.libsignal.protocol.InvalidMacException import org.signal.libsignal.protocol.InvalidMessageException import org.thoughtcrime.securesms.attachments.AttachmentId import org.thoughtcrime.securesms.attachments.DatabaseAttachment +import org.thoughtcrime.securesms.attachments.InvalidAttachmentException import org.thoughtcrime.securesms.backup.v2.BackupRepository -import org.thoughtcrime.securesms.backup.v2.BackupRepository.getThumbnailMediaName +import org.thoughtcrime.securesms.backup.v2.database.createArchiveAttachmentPointer import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.events.PartProgressEvent import org.thoughtcrime.securesms.jobmanager.Job import org.thoughtcrime.securesms.jobmanager.JobLogger.format -import org.thoughtcrime.securesms.jobmanager.JsonJobData import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint -import org.thoughtcrime.securesms.jobmanager.persistence.JobSpec +import org.thoughtcrime.securesms.jobs.protos.RestoreAttachmentJobData import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mms.MmsException import org.thoughtcrime.securesms.notifications.v2.ConversationId.Companion.forConversation import org.thoughtcrime.securesms.transport.RetryLaterException import org.thoughtcrime.securesms.util.RemoteConfig -import org.thoughtcrime.securesms.util.Util import org.whispersystems.signalservice.api.backup.MediaName import org.whispersystems.signalservice.api.messages.SignalServiceAttachment -import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer -import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException import org.whispersystems.signalservice.api.push.exceptions.RangeException import java.io.File import java.io.IOException -import java.util.Optional import java.util.concurrent.TimeUnit /** @@ -50,83 +42,46 @@ import java.util.concurrent.TimeUnit class RestoreAttachmentJob private constructor( parameters: Parameters, private val messageId: Long, - attachmentId: AttachmentId, - private val manual: Boolean, - private var forceArchiveDownload: Boolean, - private val restoreMode: RestoreMode + private val attachmentId: AttachmentId, + private val offloaded: Boolean ) : BaseJob(parameters) { companion object { const val KEY = "RestoreAttachmentJob" val TAG = Log.tag(RestoreAttachmentJob::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_RESTORE_MODE = "restore_mode" - @JvmStatic - fun constructQueueString(attachmentId: AttachmentId): String { + fun constructQueueString(): String { // TODO: decide how many queues return "RestoreAttachmentJob" } - private fun getJsonJobData(jobSpec: JobSpec): JsonJobData? { - if (KEY != jobSpec.factoryKey) { - return null - } - - 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) - } - @JvmStatic - fun restoreOffloadedAttachment(attachment: DatabaseAttachment): String { + fun restoreAttachment(attachment: DatabaseAttachment): String { val restoreJob = RestoreAttachmentJob( messageId = attachment.mmsId, attachmentId = attachment.attachmentId, - manual = false, - forceArchiveDownload = true, - restoreMode = RestoreAttachmentJob.RestoreMode.ORIGINAL + offloaded = true ) AppDependencies.jobManager.add(restoreJob) return restoreJob.id } } - private val attachmentId: Long = attachmentId.id - - constructor(messageId: Long, attachmentId: AttachmentId, manual: Boolean, forceArchiveDownload: Boolean = false, restoreMode: RestoreMode = RestoreMode.ORIGINAL) : this( + constructor(messageId: Long, attachmentId: AttachmentId, offloaded: Boolean = false) : this( Parameters.Builder() - .setQueue(constructQueueString(attachmentId)) + .setQueue(constructQueueString()) .addConstraint(NetworkConstraint.KEY) .setLifespan(TimeUnit.DAYS.toMillis(1)) .setMaxAttempts(Parameters.UNLIMITED) .build(), messageId, attachmentId, - manual, - forceArchiveDownload, - restoreMode + offloaded ) override fun serialize(): ByteArray? { - return JsonJobData.Builder() - .putLong(KEY_MESSAGE_ID, messageId) - .putLong(KEY_ATTACHMENT_ID, attachmentId) - .putBoolean(KEY_MANUAL, manual) - .putBoolean(KEY_FORCE_ARCHIVE, forceArchiveDownload) - .putInt(KEY_RESTORE_MODE, restoreMode.value) - .serialize() + return RestoreAttachmentJobData(messageId = messageId, attachmentId = attachmentId.id, offloaded = offloaded).encode() } override fun getFactoryKey(): String { @@ -134,14 +89,14 @@ class RestoreAttachmentJob private constructor( } override fun onAdded() { - Log.i(TAG, "onAdded() messageId: $messageId attachmentId: $attachmentId manual: $manual") + if (offloaded) { + Log.i(TAG, "onAdded() messageId: $messageId attachmentId: $attachmentId") - val attachmentId = AttachmentId(attachmentId) - val attachment = SignalDatabase.attachments.getAttachment(attachmentId) - val pending = attachment != null && attachment.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && attachment.transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE - if (attachment?.transferState == AttachmentTable.TRANSFER_NEEDS_RESTORE || attachment?.transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED) { - Log.i(TAG, "onAdded() Marking attachment restore progress as 'started'") - SignalDatabase.attachments.setTransferState(messageId, attachmentId, AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS) + val attachment = SignalDatabase.attachments.getAttachment(attachmentId) + if (attachment?.transferState == AttachmentTable.TRANSFER_NEEDS_RESTORE || attachment?.transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED) { + Log.i(TAG, "onAdded() Marking attachment restore progress as 'started'") + SignalDatabase.attachments.setTransferState(messageId, attachmentId, AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS) + } } } @@ -152,17 +107,12 @@ class RestoreAttachmentJob private constructor( if (!SignalDatabase.messages.isStory(messageId)) { AppDependencies.messageNotifier.updateNotification(context, forConversation(0)) } - - if (SignalDatabase.attachments.getRemainingRestorableAttachmentSize() == 0L) { - SignalStore.backup.totalRestorableAttachmentSize = 0L - } } @Throws(IOException::class, RetryLaterException::class) fun doWork() { - Log.i(TAG, "onRun() messageId: $messageId attachmentId: $attachmentId manual: $manual") + Log.i(TAG, "onRun() messageId: $messageId attachmentId: $attachmentId") - val attachmentId = AttachmentId(attachmentId) val attachment = SignalDatabase.attachments.getAttachment(attachmentId) if (attachment == null) { @@ -177,23 +127,18 @@ class RestoreAttachmentJob private constructor( if (attachment.transferState != AttachmentTable.TRANSFER_NEEDS_RESTORE && attachment.transferState != AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS && - (attachment.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED || restoreMode == RestoreMode.THUMBNAIL) + (attachment.transferState != AttachmentTable.TRANSFER_RESTORE_OFFLOADED) ) { Log.w(TAG, "Attachment does not need to be restored.") return } - if (attachment.thumbnailUri == null && (restoreMode == RestoreMode.THUMBNAIL || restoreMode == RestoreMode.BOTH)) { - downloadThumbnail(attachmentId, attachment) - } - if (restoreMode == RestoreMode.ORIGINAL || restoreMode == RestoreMode.BOTH) { - retrieveAttachment(messageId, attachmentId, attachment) - } + + retrieveAttachment(messageId, attachmentId, attachment) } override fun onFailure() { - Log.w(TAG, format(this, "onFailure() messageId: $messageId attachmentId: $attachmentId manual: $manual")) + Log.w(TAG, format(this, "onFailure() messageId: $messageId attachmentId: $attachmentId")) - val attachmentId = AttachmentId(attachmentId) markFailed(messageId, attachmentId) } @@ -206,7 +151,8 @@ class RestoreAttachmentJob private constructor( private fun retrieveAttachment( messageId: Long, attachmentId: AttachmentId, - attachment: DatabaseAttachment + attachment: DatabaseAttachment, + forceTransitTier: Boolean = false ) { val maxReceiveSize: Long = RemoteConfig.maxAttachmentReceiveSizeBytes val attachmentFile: File = SignalDatabase.attachments.getOrCreateTransferFile(attachmentId) @@ -218,9 +164,9 @@ class RestoreAttachmentJob private constructor( throw MmsException("Attachment too large, failing download") } - useArchiveCdn = if (SignalStore.backup.backsUpMedia && (forceArchiveDownload || attachment.remoteLocation == null)) { + useArchiveCdn = if (SignalStore.backup.backsUpMedia && !forceTransitTier) { if (attachment.archiveMediaName.isNullOrEmpty()) { - throw InvalidPartException("Invalid attachment configuration") + throw InvalidAttachmentException("Invalid attachment configuration") } true } else { @@ -228,7 +174,7 @@ class RestoreAttachmentJob private constructor( } val messageReceiver = AppDependencies.signalServiceMessageReceiver - val pointer = createAttachmentPointer(attachment, useArchiveCdn) + val pointer = attachment.createArchiveAttachmentPointer(useArchiveCdn) val progressListener = object : SignalServiceAttachment.ProgressListener { override fun onAttachmentProgress(total: Long, progress: Long) { @@ -275,15 +221,14 @@ class RestoreAttachmentJob private constructor( } else { throw IOException("Failed to delete temp download file following range exception") } - } catch (e: InvalidPartException) { + } catch (e: InvalidAttachmentException) { Log.w(TAG, "Experienced exception while trying to download an attachment.", e) markFailed(messageId, attachmentId) } catch (e: NonSuccessfulResponseCodeException) { if (SignalStore.backup.backsUpMedia) { - if (e.code == 404 && !useArchiveCdn && attachment.archiveMediaName?.isNotEmpty() == true) { - Log.i(TAG, "Retrying download from archive CDN") - forceArchiveDownload = true - retrieveAttachment(messageId, attachmentId, attachment) + if (e.code == 404 && !forceTransitTier && attachment.remoteLocation?.isNotBlank() == true) { + Log.i(TAG, "Retrying download from transit CDN") + retrieveAttachment(messageId, attachmentId, attachment, true) return } else if (e.code == 401 && useArchiveCdn) { SignalStore.backup.cdnReadCredentials = null @@ -310,212 +255,22 @@ class RestoreAttachmentJob private constructor( } } - @Throws(InvalidPartException::class) - private fun createAttachmentPointer(attachment: DatabaseAttachment, useArchiveCdn: Boolean): SignalServiceAttachmentPointer { - if (TextUtils.isEmpty(attachment.remoteKey)) { - throw InvalidPartException("empty encrypted key") - } - - return try { - val remoteData: RemoteData = if (useArchiveCdn) { - val backupKey = SignalStore.svr.getOrCreateMasterKey().deriveBackupKey() - val backupDirectories = BackupRepository.getCdnBackupDirectories().successOrThrow() - - RemoteData( - remoteId = SignalServiceAttachmentRemoteId.Backup( - backupDir = backupDirectories.backupDir, - mediaDir = backupDirectories.mediaDir, - mediaId = backupKey.deriveMediaId(MediaName(attachment.archiveMediaName!!)).encode() - ), - cdnNumber = attachment.archiveCdn - ) - } else { - if (attachment.remoteLocation.isNullOrEmpty()) { - throw InvalidPartException("empty content id") - } - - RemoteData( - remoteId = SignalServiceAttachmentRemoteId.from(attachment.remoteLocation), - cdnNumber = attachment.cdn.cdnNumber - ) - } - - val key = Base64.decode(attachment.remoteKey!!) - - if (attachment.remoteDigest != null) { - Log.i(TAG, "Downloading attachment with digest: " + Hex.toString(attachment.remoteDigest)) - } else { - Log.i(TAG, "Downloading attachment with no digest...") - } - - SignalServiceAttachmentPointer( - remoteData.cdnNumber, - remoteData.remoteId, - null, - key, - Optional.of(Util.toIntExact(attachment.size)), - Optional.empty(), - 0, - 0, - Optional.ofNullable(attachment.remoteDigest), - Optional.ofNullable(attachment.getIncrementalDigest()), - attachment.incrementalMacChunkSize, - Optional.ofNullable(attachment.fileName), - attachment.voiceNote, - attachment.borderless, - attachment.videoGif, - Optional.empty(), - Optional.ofNullable(attachment.blurHash).map { it.hash }, - attachment.uploadTimestamp, - attachment.uuid - ) - } catch (e: IOException) { - Log.w(TAG, e) - throw InvalidPartException(e) - } catch (e: ArithmeticException) { - Log.w(TAG, e) - throw InvalidPartException(e) - } - } - - @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()) - val mediaId = backupKey.deriveMediaId(attachment.getThumbnailMediaName()).encode() - SignalServiceAttachmentPointer( - attachment.archiveThumbnailCdn, - SignalServiceAttachmentRemoteId.Backup( - backupDir = backupDirectories.backupDir, - mediaDir = backupDirectories.mediaDir, - mediaId = mediaId - ), - null, - key, - Optional.empty(), - Optional.empty(), - 0, - 0, - Optional.empty(), - Optional.empty(), - attachment.incrementalMacChunkSize, - Optional.empty(), - attachment.voiceNote, - attachment.borderless, - attachment.videoGif, - Optional.empty(), - Optional.ofNullable(attachment.blurHash).map { it.hash }, - attachment.uploadTimestamp, - attachment.uuid - ) - } 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.thumbnailRestoreState == AttachmentTable.ThumbnailRestoreState.FINISHED) { - Log.w(TAG, "$attachmentId already has thumbnail downloaded") - return - } - if (attachment.thumbnailRestoreState == AttachmentTable.ThumbnailRestoreState.NONE) { - Log.w(TAG, "$attachmentId has no thumbnail state") - return - } - if (attachment.thumbnailRestoreState == AttachmentTable.ThumbnailRestoreState.PERMANENT_FAILURE) { - Log.w(TAG, "$attachmentId thumbnail permanently failed") - return - } - if (attachment.archiveMediaName == null) { - Log.w(TAG, "$attachmentId was never archived! Cannot proceed.") - return - } - - val maxThumbnailSize: Long = RemoteConfig.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) { - } - - override fun shouldCancel(): Boolean { - return this@RestoreAttachmentJob.isCanceled - } - } - - val cdnCredentials = BackupRepository.getCdnReadCredentials(attachment.archiveCdn).successOrThrow().headers - val messageReceiver = AppDependencies.signalServiceMessageReceiver - val pointer = createThumbnailPointer(attachment) - - Log.w(TAG, "Downloading thumbnail for $attachmentId mediaName=${attachment.getThumbnailMediaName()}") - val downloadResult = messageReceiver - .retrieveArchivedAttachment( - SignalStore.svr.getOrCreateMasterKey().deriveBackupKey().deriveMediaSecrets(attachment.getThumbnailMediaName()), - cdnCredentials, - thumbnailTransferFile, - pointer, - thumbnailFile, - maxThumbnailSize, - true, - progressListener - ) - - SignalDatabase.attachments.finalizeAttachmentThumbnailAfterDownload(attachmentId, attachment.archiveMediaId!!, downloadResult.dataStream, thumbnailTransferFile) - } - private fun markFailed(messageId: Long, attachmentId: AttachmentId) { - try { - SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId) - } catch (e: MmsException) { - Log.w(TAG, e) - } + SignalDatabase.attachments.setTransferProgressFailed(attachmentId, messageId) } private fun markPermanentlyFailed(messageId: Long, attachmentId: AttachmentId) { SignalDatabase.attachments.setTransferProgressPermanentFailure(attachmentId, messageId) } - @VisibleForTesting - internal class InvalidPartException : Exception { - constructor(s: String?) : super(s) - 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 { override fun create(parameters: Parameters, serializedData: ByteArray?): RestoreAttachmentJob { - val data = JsonJobData.deserialize(serializedData) + val data = RestoreAttachmentJobData.ADAPTER.decode(serializedData!!) return RestoreAttachmentJob( parameters = parameters, - messageId = data.getLong(KEY_MESSAGE_ID), - attachmentId = AttachmentId(data.getLong(KEY_ATTACHMENT_ID)), - manual = data.getBoolean(KEY_MANUAL), - forceArchiveDownload = data.getBooleanOrDefault(KEY_FORCE_ARCHIVE, false), - restoreMode = RestoreMode.deserialize(data.getIntOrDefault(KEY_RESTORE_MODE, RestoreMode.ORIGINAL.value)) + messageId = data.messageId, + attachmentId = AttachmentId(data.attachmentId), + offloaded = data.offloaded ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt index 75f2c95b0e..b4b706fb67 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt @@ -4,13 +4,13 @@ */ package org.thoughtcrime.securesms.jobs -import android.text.TextUtils -import androidx.annotation.VisibleForTesting import org.signal.core.util.logging.Log +import org.signal.libsignal.protocol.InvalidMessageException import org.thoughtcrime.securesms.attachments.AttachmentId -import org.thoughtcrime.securesms.attachments.DatabaseAttachment +import org.thoughtcrime.securesms.attachments.InvalidAttachmentException import org.thoughtcrime.securesms.backup.v2.BackupRepository import org.thoughtcrime.securesms.backup.v2.BackupRepository.getThumbnailMediaName +import org.thoughtcrime.securesms.backup.v2.database.createArchiveThumbnailPointer import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -19,16 +19,11 @@ import org.thoughtcrime.securesms.jobmanager.JobLogger.format import org.thoughtcrime.securesms.jobmanager.JsonJobData import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.notifications.v2.ConversationId.Companion.forConversation -import org.thoughtcrime.securesms.transport.RetryLaterException import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.api.messages.SignalServiceAttachment -import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer -import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId -import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException +import org.whispersystems.signalservice.api.push.exceptions.MissingConfigurationException import java.io.File import java.io.IOException -import java.util.Optional import java.util.concurrent.TimeUnit /** @@ -37,7 +32,7 @@ import java.util.concurrent.TimeUnit class RestoreAttachmentThumbnailJob private constructor( parameters: Parameters, private val messageId: Long, - attachmentId: AttachmentId + val attachmentId: AttachmentId ) : BaseJob(parameters) { companion object { @@ -54,8 +49,6 @@ class RestoreAttachmentThumbnailJob private constructor( } } - private val attachmentId: Long - constructor(messageId: Long, attachmentId: AttachmentId, highPriority: Boolean = false) : this( Parameters.Builder() .setQueue(constructQueueString(attachmentId)) @@ -68,14 +61,10 @@ class RestoreAttachmentThumbnailJob private constructor( attachmentId ) - init { - this.attachmentId = attachmentId.id - } - override fun serialize(): ByteArray? { return JsonJobData.Builder() .putLong(KEY_MESSAGE_ID, messageId) - .putLong(KEY_ATTACHMENT_ID, attachmentId) + .putLong(KEY_ATTACHMENT_ID, attachmentId.id) .serialize() } @@ -83,35 +72,10 @@ class RestoreAttachmentThumbnailJob private constructor( return KEY } - override fun onAdded() { - Log.i(TAG, "onAdded() messageId: $messageId attachmentId: $attachmentId") - - val attachmentId = AttachmentId(attachmentId) - val attachment = SignalDatabase.attachments.getAttachment(attachmentId) - val pending = attachment != null && - attachment.thumbnailRestoreState != AttachmentTable.ThumbnailRestoreState.FINISHED && - attachment.thumbnailRestoreState != AttachmentTable.ThumbnailRestoreState.PERMANENT_FAILURE && - attachment.thumbnailRestoreState != AttachmentTable.ThumbnailRestoreState.NONE - if (pending) { - Log.i(TAG, "onAdded() Marking thumbnail restore progress as 'started'") - SignalDatabase.attachments.setThumbnailTransferState(messageId, attachmentId, AttachmentTable.ThumbnailRestoreState.IN_PROGRESS) - } - } - - @Throws(Exception::class) + @Throws(Exception::class, IOException::class, InvalidAttachmentException::class, InvalidMessageException::class, MissingConfigurationException::class) public override fun onRun() { - doWork() - - if (!SignalDatabase.messages.isStory(messageId)) { - AppDependencies.messageNotifier.updateNotification(context, forConversation(0)) - } - } - - @Throws(IOException::class, RetryLaterException::class) - fun doWork() { Log.i(TAG, "onRun() messageId: $messageId attachmentId: $attachmentId") - val attachmentId = AttachmentId(attachmentId) val attachment = SignalDatabase.attachments.getAttachment(attachmentId) if (attachment == null) { @@ -119,79 +83,21 @@ class RestoreAttachmentThumbnailJob private constructor( return } - downloadThumbnail(attachmentId, attachment) - } - - override fun onFailure() { - Log.w(TAG, format(this, "onFailure() thumbnail messageId: $messageId attachmentId: $attachmentId ")) - - val attachmentId = AttachmentId(attachmentId) - markFailed(messageId, attachmentId) - } - - override fun onShouldRetry(exception: Exception): Boolean { - return exception is PushNetworkException || - exception is RetryLaterException - } - - @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()) - val mediaId = backupKey.deriveMediaId(attachment.getThumbnailMediaName()).encode() - SignalServiceAttachmentPointer( - attachment.archiveThumbnailCdn, - SignalServiceAttachmentRemoteId.Backup( - backupDir = backupDirectories.backupDir, - mediaDir = backupDirectories.mediaDir, - mediaId = mediaId - ), - null, - key, - Optional.empty(), - Optional.empty(), - 0, - 0, - Optional.empty(), - Optional.empty(), - attachment.incrementalMacChunkSize, - Optional.empty(), - attachment.voiceNote, - attachment.borderless, - attachment.videoGif, - Optional.empty(), - Optional.ofNullable(attachment.blurHash).map { it.hash }, - attachment.uploadTimestamp, - attachment.uuid - ) - } 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.thumbnailRestoreState == AttachmentTable.ThumbnailRestoreState.FINISHED) { Log.w(TAG, "$attachmentId already has thumbnail downloaded") return } + if (attachment.thumbnailRestoreState == AttachmentTable.ThumbnailRestoreState.NONE) { Log.w(TAG, "$attachmentId has no thumbnail state") return } + if (attachment.thumbnailRestoreState == AttachmentTable.ThumbnailRestoreState.PERMANENT_FAILURE) { Log.w(TAG, "$attachmentId thumbnail permanently failed") return } + if (attachment.archiveMediaName == null) { Log.w(TAG, "$attachmentId was never archived! Cannot proceed.") return @@ -202,20 +108,15 @@ class RestoreAttachmentThumbnailJob private constructor( val thumbnailFile: File = SignalDatabase.attachments.createArchiveThumbnailTransferFile() val progressListener = object : SignalServiceAttachment.ProgressListener { - override fun onAttachmentProgress(total: Long, progress: Long) { - } - - override fun shouldCancel(): Boolean { - return this@RestoreAttachmentThumbnailJob.isCanceled - } + override fun onAttachmentProgress(total: Long, progress: Long) = Unit + override fun shouldCancel(): Boolean = this@RestoreAttachmentThumbnailJob.isCanceled } val cdnCredentials = BackupRepository.getCdnReadCredentials(attachment.archiveCdn).successOrThrow().headers - val messageReceiver = AppDependencies.signalServiceMessageReceiver - val pointer = createThumbnailPointer(attachment) + val pointer = attachment.createArchiveThumbnailPointer() - Log.w(TAG, "Downloading thumbnail for $attachmentId") - val downloadResult = messageReceiver + Log.i(TAG, "Downloading thumbnail for $attachmentId") + val downloadResult = AppDependencies.signalServiceMessageReceiver .retrieveArchivedAttachment( SignalStore.svr.getOrCreateMasterKey().deriveBackupKey().deriveMediaSecrets(attachment.getThumbnailMediaName()), cdnCredentials, @@ -228,16 +129,20 @@ class RestoreAttachmentThumbnailJob private constructor( ) SignalDatabase.attachments.finalizeAttachmentThumbnailAfterDownload(attachmentId, attachment.archiveMediaId!!, downloadResult.dataStream, thumbnailTransferFile) + + if (!SignalDatabase.messages.isStory(messageId)) { + AppDependencies.messageNotifier.updateNotification(context) + } } - private fun markFailed(messageId: Long, attachmentId: AttachmentId) { + override fun onFailure() { + Log.w(TAG, format(this, "onFailure() thumbnail messageId: $messageId attachmentId: $attachmentId ")) + SignalDatabase.attachments.setThumbnailRestoreProgressFailed(attachmentId, messageId) } - @VisibleForTesting - internal class InvalidPartException : Exception { - constructor(s: String?) : super(s) - constructor(e: Exception?) : super(e) + override fun onShouldRetry(exception: Exception): Boolean { + return exception is IOException } class Factory : Job.Factory { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreLocalAttachmentJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreLocalAttachmentJob.kt index 26e819f790..9e9e7ec3ad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreLocalAttachmentJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreLocalAttachmentJob.kt @@ -15,7 +15,7 @@ import org.signal.libsignal.protocol.InvalidMessageException import org.thoughtcrime.securesms.attachments.AttachmentId import org.thoughtcrime.securesms.backup.v2.local.ArchiveFileSystem import org.thoughtcrime.securesms.database.AttachmentTable -import org.thoughtcrime.securesms.database.AttachmentTable.LocalRestorableAttachment +import org.thoughtcrime.securesms.database.AttachmentTable.RestorableAttachment import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.jobmanager.Job @@ -47,9 +47,9 @@ class RestoreLocalAttachmentJob private constructor( var restoreAttachmentJobs: MutableList do { - val possibleRestorableAttachments: List = SignalDatabase.attachments.getLocalRestorableAttachments(500) - val restorableAttachments = ArrayList(possibleRestorableAttachments.size) - val notRestorableAttachments = ArrayList(possibleRestorableAttachments.size) + val possibleRestorableAttachments: List = SignalDatabase.attachments.getRestorableAttachments(500) + val restorableAttachments = ArrayList(possibleRestorableAttachments.size) + val notRestorableAttachments = ArrayList(possibleRestorableAttachments.size) restoreAttachmentJobs = ArrayList(possibleRestorableAttachments.size) @@ -71,8 +71,8 @@ class RestoreLocalAttachmentJob private constructor( } SignalDatabase.rawDatabase.withinTransaction { - SignalDatabase.attachments.setRestoreInProgressTransferState(restorableAttachments) - SignalDatabase.attachments.setRestoreFailedTransferState(notRestorableAttachments) + SignalDatabase.attachments.setRestoreTransferState(restorableAttachments, AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS) + SignalDatabase.attachments.setRestoreTransferState(notRestorableAttachments, AttachmentTable.TRANSFER_PROGRESS_FAILED) SignalStore.backup.totalRestorableAttachmentSize = SignalDatabase.attachments.getRemainingRestorableAttachmentSize() AppDependencies.jobManager.addAll(restoreAttachmentJobs) @@ -92,7 +92,7 @@ class RestoreLocalAttachmentJob private constructor( } } - private constructor(queue: String, attachment: LocalRestorableAttachment, info: DocumentFileInfo) : this( + private constructor(queue: String, attachment: RestorableAttachment, info: DocumentFileInfo) : this( Parameters.Builder() .setQueue(queue) .setLifespan(Parameters.IMMORTAL) diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/LegacyMigrationJob.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/LegacyMigrationJob.java index ff36ec38cb..bd7b204cb3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/LegacyMigrationJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/LegacyMigrationJob.java @@ -255,7 +255,7 @@ public class LegacyMigrationJob extends MigrationJob { attachmentDb.setTransferState(attachment.mmsId, attachment.attachmentId, AttachmentTable.TRANSFER_PROGRESS_DONE); } else if (record != null && !record.isOutgoing() && record.isPush()) { Log.i(TAG, "queuing new attachment download job for incoming push part " + attachment.attachmentId + "."); - AppDependencies.getJobManager().add(new AttachmentDownloadJob(attachment.mmsId, attachment.attachmentId, false, false)); + AppDependencies.getJobManager().add(new AttachmentDownloadJob(attachment.mmsId, attachment.attachmentId, false)); } reader.close(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/PendingParticipantsState.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/PendingParticipantsState.kt index e986794a04..be67cbc79c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/PendingParticipantsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/PendingParticipantsState.kt @@ -13,4 +13,4 @@ import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection data class PendingParticipantsState( val pendingParticipantCollection: PendingParticipantCollection, val isInPipMode: Boolean -) \ No newline at end of file +) diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index efd28552e1..a708db2ca0 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -129,3 +129,9 @@ message RestoreLocalAttachmentJobData { message BackfillDigestJobData { uint64 attachmentId = 1; } + +message RestoreAttachmentJobData { + uint64 messageId = 1; + uint64 attachmentId = 2; + bool offloaded = 3; +} 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 9a93a15968..5703e7512c 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/sms/UploadDependencyGraphTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/sms/UploadDependencyGraphTest.kt @@ -262,7 +262,6 @@ class UploadDependencyGraphTest { archiveMediaId = null, archiveMediaName = null, archiveCdn = 0, - archiveThumbnailCdn = 0, thumbnailRestoreState = AttachmentTable.ThumbnailRestoreState.NONE, uuid = null ) diff --git a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt index a88c90501a..a7fc3eef4d 100644 --- a/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt +++ b/app/src/testShared/org/thoughtcrime/securesms/database/FakeMessageRecords.kt @@ -61,7 +61,6 @@ object FakeMessageRecords { uploadTimestamp: Long = 200, dataHash: String? = null, archiveCdn: Int = 0, - archiveThumbnailCdn: Int = 0, archiveMediaName: String? = null, archiveMediaId: String? = null, archiveThumbnailId: String? = null, @@ -100,7 +99,6 @@ object FakeMessageRecords { uploadTimestamp = uploadTimestamp, dataHash = dataHash, archiveCdn = archiveCdn, - archiveThumbnailCdn = archiveThumbnailCdn, archiveMediaName = archiveMediaId, archiveMediaId = archiveMediaName, thumbnailRestoreState = thumbnailRestoreState,