From 2a3cb8021735a9397a45959f04b96a979d4085cb Mon Sep 17 00:00:00 2001 From: Clark Date: Wed, 22 May 2024 19:02:20 -0400 Subject: [PATCH] Add ui wiring for archive thumbnail support. --- .../securesms/attachments/Attachment.kt | 2 + .../attachments/DatabaseAttachment.kt | 2 +- .../backup/v2/BackupRestoreManager.kt | 24 +- .../securesms/components/ThumbnailView.java | 48 +++- .../conversation/ConversationItem.java | 42 +-- .../securesms/database/AttachmentTable.kt | 31 ++- .../securesms/database/MediaTable.kt | 17 +- .../securesms/jobs/AttachmentDownloadJob.kt | 20 ++ .../securesms/jobs/BackupRestoreMediaJob.kt | 34 ++- .../securesms/jobs/JobManagerFactories.java | 1 + .../securesms/jobs/RestoreAttachmentJob.kt | 40 +-- .../jobs/RestoreAttachmentThumbnailJob.kt | 257 ++++++++++++++++++ .../mediapreview/MediaPreviewV2Adapter.kt | 2 +- .../mms/DecryptableStreamLocalUriFetcher.java | 13 + .../org/thoughtcrime/securesms/mms/Slide.java | 18 +- 15 files changed, 451 insertions(+), 100 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt 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 784871a778..6006dc701b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/Attachment.kt @@ -71,6 +71,8 @@ abstract class Attachment( abstract val uri: Uri? abstract val publicUri: Uri? abstract val thumbnailUri: Uri? + val displayUri: Uri? + get() = uri ?: thumbnailUri protected constructor(parcel: Parcel) : this( contentType = parcel.readString()!!, 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 5965f84b20..bfb7706937 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachment.kt @@ -172,7 +172,7 @@ class DatabaseAttachment : Attachment { override fun equals(other: Any?): Boolean { return other != null && - other is DatabaseAttachment && other.attachmentId == attachmentId + other is DatabaseAttachment && other.attachmentId == attachmentId && other.uri == uri } override fun hashCode(): Int { 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 0a716f3363..132a978a73 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 @@ -11,7 +11,8 @@ import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord -import org.thoughtcrime.securesms.jobs.RestoreAttachmentJob +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.jobs.RestoreAttachmentThumbnailJob /** * Responsible for managing logic around restore prioritization @@ -28,17 +29,24 @@ object BackupRestoreManager { fun prioritizeAttachmentsIfNeeded(messageRecords: List) { SignalExecutors.BOUNDED.execute { synchronized(this) { - val restoringAttachments: List = messageRecords + val restoringAttachments = messageRecords .mapNotNull { (it as? MmsMessageRecord?)?.slideDeck?.slides } .flatten() .mapNotNull { it.asAttachment() as? DatabaseAttachment } - .filter { it.transferState == AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS && !reprioritizedAttachments.contains(it.attachmentId) } - .map { it.attachmentId } + .filter { + val needThumbnail = it.thumbnailRestoreState == AttachmentTable.ThumbnailRestoreState.NEEDS_RESTORE && it.transferState == AttachmentTable.TRANSFER_RESTORE_IN_PROGRESS + (needThumbnail || it.thumbnailRestoreState == AttachmentTable.ThumbnailRestoreState.IN_PROGRESS) && !reprioritizedAttachments.contains(it.attachmentId) + } + .map { it.attachmentId to it.mmsId } + .toSet() - reprioritizedAttachments += restoringAttachments - - if (restoringAttachments.isNotEmpty()) { - RestoreAttachmentJob.modifyPriorities(restoringAttachments.toSet(), 1) + reprioritizedAttachments += restoringAttachments.map { it.first } + val thumbnailJobs = restoringAttachments.map { + val (attachmentId, mmsId) = it + RestoreAttachmentThumbnailJob(attachmentId = attachmentId, messageId = mmsId, highPriority = true) + } + if (thumbnailJobs.isNotEmpty()) { + AppDependencies.jobManager.addAll(thumbnailJobs) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java index cfc0414e52..91942682b6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -370,7 +370,16 @@ public class ThumbnailView extends FrameLayout { transferControlViewStub.get().setSlides(List.of(slide)); } int transferState = TransferControlView.getTransferState(List.of(slide)); - transferControlViewStub.get().setVisible(showControls && transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE); + boolean isOffloadedImage = transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED && MediaUtil.isImageType(slide.getContentType()); + + if (!showControls || + transferState == AttachmentTable.TRANSFER_PROGRESS_DONE || + transferState == AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE || + isOffloadedImage) { + transferControlViewStub.get().setVisible(false); + } else { + transferControlViewStub.get().setVisible(true); + } if (slide.getUri() != null && slide.hasPlayOverlay() && (slide.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_DONE || isPreview)) @@ -412,7 +421,10 @@ public class ThumbnailView extends FrameLayout { SettableFuture result = new SettableFuture<>(); boolean resultHandled = false; - if (slide.hasPlaceholder() && (previousBlurHash == null || !Objects.equals(slide.getPlaceholderBlur(), previousBlurHash))) { + if (slide.hasThumbnail()) { + buildArchiveThumbnailRequestBuilder(requestManager, slide).into(new GlideBitmapListeningTarget(blurHash, result)); + resultHandled = true; + } else if (slide.hasPlaceholder() && (previousBlurHash == null || !Objects.equals(slide.getPlaceholderBlur(), previousBlurHash))) { buildPlaceholderRequestBuilder(requestManager, slide).into(new GlideBitmapListeningTarget(blurHash, result)); resultHandled = true; } else if (!slide.hasPlaceholder()) { @@ -420,7 +432,7 @@ public class ThumbnailView extends FrameLayout { blurHash.setImageDrawable(null); } - if (slide.getUri() != null) { + if (slide.getDisplayUri() != null) { if (!MediaUtil.isJpegType(slide.getContentType()) && !MediaUtil.isVideoType(slide.getContentType())) { SettableFuture thumbnailFuture = new SettableFuture<>(); thumbnailFuture.deferTo(result); @@ -542,7 +554,7 @@ public class ThumbnailView extends FrameLayout { } private RequestBuilder buildThumbnailRequestBuilder(@NonNull RequestManager requestManager, @NonNull Slide slide) { - RequestBuilder requestBuilder = applySizing(requestManager.load(new DecryptableUri(Objects.requireNonNull(slide.getUri()))) + RequestBuilder requestBuilder = applySizing(requestManager.load(new DecryptableUri(Objects.requireNonNull(slide.getDisplayUri()))) .diskCacheStrategy(DiskCacheStrategy.RESOURCE) .downsample(SignalDownsampleStrategy.CENTER_OUTSIDE_NO_UPSCALE) .transition(withCrossFade())); @@ -611,6 +623,25 @@ public class ThumbnailView extends FrameLayout { } } + private RequestBuilder buildArchiveThumbnailRequestBuilder(@NonNull RequestManager requestManager, @NonNull Slide slide) { + RequestBuilder bitmap = requestManager.asBitmap(); + + Uri thumbnailUri = slide.getThumbnailUri(); + + if (thumbnailUri != null) { + bitmap = bitmap.load(slide.getThumbnailUri()); + } else { + bitmap = bitmap.load(slide.getPlaceholderRes(getContext().getTheme())); + } + + final RequestBuilder resizedRequest = applySizing(bitmap.diskCacheStrategy(DiskCacheStrategy.NONE)); + if (thumbnailUri != null) { + return resizedRequest.centerCrop(); + } else { + return resizedRequest; + } + } + private RequestBuilder applySizing(@NonNull RequestBuilder request) { int[] size = new int[2]; fillTargetDimensions(size, dimens, bounds); @@ -648,13 +679,8 @@ public class ThumbnailView extends FrameLayout { private class ThumbnailClickDispatcher implements View.OnClickListener { @Override public void onClick(View view) { - boolean validThumbnail = slide != null && - slide.asAttachment().getUri() != null && - slide.getTransferState() == AttachmentTable.TRANSFER_PROGRESS_DONE; - - boolean permanentFailure = slide != null && slide.asAttachment().isPermanentlyFailed(); - - if (thumbnailClickListener != null && (validThumbnail || permanentFailure)) { + boolean controlsVisible = transferControlViewStub.getVisibility() == View.VISIBLE; + if (thumbnailClickListener != null && !controlsVisible) { thumbnailClickListener.onClick(view, slide); } else if (parentClickListener != null) { parentClickListener.onClick(view); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index acc4b29c91..408b3e711c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -2468,10 +2468,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo Log.i(TAG, "Scheduling push attachment downloads for " + slides.size() + " items"); for (Slide slide : slides) { - AppDependencies.getJobManager().add(new AttachmentDownloadJob(messageRecord.getId(), - ((DatabaseAttachment) slide.asAttachment()).attachmentId, - true, - false)); + AttachmentDownloadJob.downloadAttachmentIfNeeded((DatabaseAttachment) slide.asAttachment()); } } } @@ -2484,37 +2481,12 @@ public final class ConversationItem extends RelativeLayout implements BindableCo @Override public void onClick(View v, Slide slide) { - if (messageRecord.isOutgoing()) { - Log.d(TAG, "Video player button for outgoing slide clicked."); - return; - } if (MediaUtil.isInstantVideoSupported(slide)) { final DatabaseAttachment databaseAttachment = (DatabaseAttachment) slide.asAttachment(); - if (databaseAttachment.transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED) { - final AttachmentId attachmentId = databaseAttachment.attachmentId; - final JobManager jobManager = AppDependencies.getJobManager(); - final String queue = RestoreAttachmentJob.constructQueueString(attachmentId); + String jobId = AttachmentDownloadJob.downloadAttachmentIfNeeded(databaseAttachment); + if (jobId != null) { setup(v, slide); - jobManager.add(new RestoreAttachmentJob(messageRecord.getId(), - attachmentId, - true, - false, - RestoreAttachmentJob.RestoreMode.ORIGINAL)); - jobManager.addListener(queue, (job, jobState) -> { - if (jobState.isComplete()) { - cleanup(); - } - }); - } else if (databaseAttachment.transferState != AttachmentTable.TRANSFER_PROGRESS_STARTED) { - final AttachmentId attachmentId = databaseAttachment.attachmentId; - final JobManager jobManager = AppDependencies.getJobManager(); - final String queue = AttachmentDownloadJob.constructQueueString(attachmentId); - setup(v, slide); - jobManager.add(new AttachmentDownloadJob(messageRecord.getId(), - attachmentId, - true, - false)); - jobManager.addListener(queue, (job, jobState) -> { + AppDependencies.getJobManager().addListener(jobId, (job, jobState) -> { if (jobState.isComplete()) { cleanup(); } @@ -2590,7 +2562,8 @@ public final class ConversationItem extends RelativeLayout implements BindableCo performClick(); } else if (!canPlayContent && mediaItem != null && eventListener != null) { eventListener.onPlayInlineContent(conversationMessage); - } else if (MediaPreviewV2Fragment.isContentTypeSupported(slide.getContentType()) && slide.getUri() != null) { + } else if (MediaPreviewV2Fragment.isContentTypeSupported(slide.getContentType()) && slide.getDisplayUri() != null) { + AttachmentDownloadJob.downloadAttachmentIfNeeded((DatabaseAttachment) slide.asAttachment()); launchMediaPreview(v, slide); } else if (slide.getUri() != null) { Log.i(TAG, "Clicked: " + slide.getUri() + " , " + slide.getContentType()); @@ -2634,8 +2607,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo return; } - Uri mediaUri = slide.getUri(); - + Uri mediaUri = slide.getDisplayUri(); if (mediaUri == null) { Log.w(TAG, "Could not launch media preview for item: uri was null"); return; 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 43c3a89eed..e851330f17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -739,6 +739,17 @@ 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) + } + @Throws(MmsException::class) fun setTransferProgressFailed(attachmentId: AttachmentId, mmsId: Long) { writableDatabase @@ -750,6 +761,17 @@ class AttachmentTable( notifyConversationListeners(messages.getThreadIdForMessage(mmsId)) } + @Throws(MmsException::class) + fun setThumbnailRestoreProgressFailed(attachmentId: AttachmentId, mmsId: Long) { + writableDatabase + .update(TABLE_NAME) + .values(THUMBNAIL_RESTORE_STATE to ThumbnailRestoreState.PERMANENT_FAILURE.value) + .where("$ID = ? AND $THUMBNAIL_RESTORE_STATE != ?", attachmentId.id, ThumbnailRestoreState.FINISHED) + .run() + + notifyConversationListeners(messages.getThreadIdForMessage(mmsId)) + } + @Throws(MmsException::class) fun setTransferProgressPermanentFailure(attachmentId: AttachmentId, mmsId: Long) { writableDatabase @@ -860,18 +882,14 @@ class AttachmentTable( writableDatabase.withinTransaction { db -> val values = contentValuesOf( THUMBNAIL_FILE to fileWriteResult.file.absolutePath, - THUMBNAIL_RANDOM to fileWriteResult.random + THUMBNAIL_RANDOM to fileWriteResult.random, + THUMBNAIL_RESTORE_STATE to ThumbnailRestoreState.FINISHED.value ) 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() @@ -1776,6 +1794,7 @@ class AttachmentTable( put(ARCHIVE_MEDIA_NAME, attachment.archiveMediaName) put(ARCHIVE_MEDIA_ID, attachment.archiveMediaId) put(ARCHIVE_THUMBNAIL_MEDIA_ID, attachment.archiveThumbnailMediaId) + put(THUMBNAIL_RESTORE_STATE, ThumbnailRestoreState.NEEDS_RESTORE.value) attachment.stickerLocator?.let { sticker -> put(STICKER_PACK_ID, sticker.packId) 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 da54f799f5..b412b91fd5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MediaTable.kt @@ -9,6 +9,7 @@ import org.signal.core.util.requireLong import org.signal.core.util.requireNonNullString import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MediaUtil.SlideType @@ -115,6 +116,16 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD """ ) + private val GALLERY_MEDIA_QUERY_INCLUDING_TEMP_VIDEOS_AND_THUMBNAILS = String.format( + BASE_MEDIA_QUERY, + """ + (${AttachmentTable.DATA_FILE} IS NOT NULL OR (${AttachmentTable.CONTENT_TYPE} LIKE 'video/%' AND ${AttachmentTable.REMOTE_INCREMENTAL_DIGEST} IS NOT NULL) OR (${AttachmentTable.THUMBNAIL_FILE} IS NOT NULL)) AND + ${AttachmentTable.CONTENT_TYPE} NOT LIKE 'image/svg%' AND + (${AttachmentTable.CONTENT_TYPE} LIKE 'image/%' OR ${AttachmentTable.CONTENT_TYPE} LIKE 'video/%') AND + ${MessageTable.LINK_PREVIEWS} IS NULL + """ + ) + private val AUDIO_MEDIA_QUERY = String.format( BASE_MEDIA_QUERY, """ @@ -153,7 +164,11 @@ class MediaTable internal constructor(context: Context?, databaseHelper: SignalD @JvmOverloads fun getGalleryMediaForThread(threadId: Long, sorting: Sorting, limit: Int = 0): Cursor { - var query = sorting.applyToQuery(applyEqualityOperator(threadId, GALLERY_MEDIA_QUERY_INCLUDING_TEMP_VIDEOS)) + var query = if (FeatureFlags.messageBackups()) { + sorting.applyToQuery(applyEqualityOperator(threadId, GALLERY_MEDIA_QUERY_INCLUDING_TEMP_VIDEOS_AND_THUMBNAILS)) + } else { + sorting.applyToQuery(applyEqualityOperator(threadId, GALLERY_MEDIA_QUERY_INCLUDING_TEMP_VIDEOS)) + } val args = arrayOf(threadId.toString() + "") if (limit > 0) { 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 99045da4cf..1235c5f297 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentDownloadJob.kt @@ -84,6 +84,26 @@ class AttachmentDownloadJob private constructor( val parsed = AttachmentId(data.getLong(KEY_ATTACHMENT_ID)) return attachmentId == parsed } + + @JvmStatic + fun downloadAttachmentIfNeeded(databaseAttachment: DatabaseAttachment): String? { + if (databaseAttachment.transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED) { + return RestoreAttachmentJob.restoreOffloadedAttachment(databaseAttachment) + } else if (databaseAttachment.transferState != AttachmentTable.TRANSFER_PROGRESS_STARTED && + databaseAttachment.transferState != AttachmentTable.TRANSFER_PROGRESS_DONE && + databaseAttachment.transferState != AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE + ) { + val downloadJob = AttachmentDownloadJob( + messageId = databaseAttachment.mmsId, + attachmentId = databaseAttachment.attachmentId, + manual = true, + forceArchiveDownload = false + ) + AppDependencies.jobManager.add(downloadJob) + return downloadJob.id + } + return null + } } private val attachmentId: Long 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 58e4c7178d..0d9bbd1949 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreMediaJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/BackupRestoreMediaJob.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.jobs import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.dependencies.AppDependencies @@ -50,24 +51,33 @@ class BackupRestoreMediaJob private constructor(parameters: Parameters) : BaseJo val jobManager = AppDependencies.jobManager val batchSize = 100 val restoreTime = System.currentTimeMillis() - var restoreJobBatch: List + var restoreJobBatch: List do { 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]!! - RestoreAttachmentJob( - messageId = attachment.mmsId, - attachmentId = attachment.attachmentId, - manual = false, - forceArchiveDownload = true, - restoreMode = if (shouldRestoreFullSize(message, restoreTime, optimizeStorage = SignalStore.backup().optimizeStorage)) { - RestoreAttachmentJob.RestoreMode.ORIGINAL - } else { - RestoreAttachmentJob.RestoreMode.THUMBNAIL - } - ) + if (shouldRestoreFullSize(message, restoreTime, SignalStore.backup().optimizeStorage)) { + RestoreAttachmentJob( + messageId = attachment.mmsId, + attachmentId = attachment.attachmentId, + manual = false, + forceArchiveDownload = true, + restoreMode = RestoreAttachmentJob.RestoreMode.ORIGINAL + ) + } else { + SignalDatabase.attachments.setTransferState( + messageId = attachment.mmsId, + attachmentId = attachment.attachmentId, + transferState = AttachmentTable.TRANSFER_RESTORE_OFFLOADED + ) + RestoreAttachmentThumbnailJob( + messageId = attachment.mmsId, + attachmentId = attachment.attachmentId, + highPriority = false + ) + } } jobManager.addAll(restoreJobBatch) } while (restoreJobBatch.isNotEmpty()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 899db0dd5d..78e60c07c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -211,6 +211,7 @@ public final class JobManagerFactories { put(RequestGroupV2InfoWorkerJob.KEY, new RequestGroupV2InfoWorkerJob.Factory()); put(RequestGroupV2InfoJob.KEY, new RequestGroupV2InfoJob.Factory()); put(RestoreAttachmentJob.KEY, new RestoreAttachmentJob.Factory()); + put(RestoreAttachmentThumbnailJob.KEY, new RestoreAttachmentThumbnailJob.Factory()); put(RetrieveProfileAvatarJob.KEY, new RetrieveProfileAvatarJob.Factory()); put(RetrieveProfileJob.KEY, new RetrieveProfileJob.Factory()); put(RetrieveRemoteAnnouncementsJob.KEY, new RetrieveRemoteAnnouncementsJob.Factory()); 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 69e661748b..f482a90cb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt @@ -89,22 +89,17 @@ class RestoreAttachmentJob private constructor( return ids.contains(parsed) } - fun modifyPriorities(ids: Set, priority: Int) { - val jobManager = AppDependencies.jobManager - jobManager.update { spec -> - 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 - } - } + @JvmStatic + fun restoreOffloadedAttachment(attachment: DatabaseAttachment): String { + val restoreJob = RestoreAttachmentJob( + messageId = attachment.mmsId, + attachmentId = attachment.attachmentId, + manual = false, + forceArchiveDownload = true, + restoreMode = RestoreAttachmentJob.RestoreMode.ORIGINAL + ) + AppDependencies.jobManager.add(restoreJob) + return restoreJob.id } } @@ -148,7 +143,7 @@ class RestoreAttachmentJob private constructor( 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) { + 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) } @@ -427,10 +422,18 @@ class RestoreAttachmentJob private constructor( } private fun downloadThumbnail(attachmentId: AttachmentId, attachment: DatabaseAttachment) { - if (attachment.transferState == AttachmentTable.TRANSFER_RESTORE_OFFLOADED) { + 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 @@ -442,7 +445,6 @@ class RestoreAttachmentJob private constructor( 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt new file mode 100644 index 0000000000..318c6a3209 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentThumbnailJob.kt @@ -0,0 +1,257 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ +package org.thoughtcrime.securesms.jobs + +import android.text.TextUtils +import androidx.annotation.VisibleForTesting +import org.signal.core.util.logging.Log +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.AppDependencies +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.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.FeatureFlags +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 java.io.File +import java.io.IOException +import java.util.Optional +import java.util.concurrent.TimeUnit + +/** + * Download attachment from locations as specified in their record. + */ +class RestoreAttachmentThumbnailJob private constructor( + parameters: Parameters, + private val messageId: Long, + attachmentId: AttachmentId +) : BaseJob(parameters) { + + companion object { + const val KEY = "RestoreAttachmentThumbnailJob" + val TAG = Log.tag(RestoreAttachmentThumbnailJob::class.java) + + private const val KEY_MESSAGE_ID = "message_id" + private const val KEY_ATTACHMENT_ID = "part_row_id" + + @JvmStatic + fun constructQueueString(attachmentId: AttachmentId): String { + // TODO: decide how many queues + return "RestoreAttachmentThumbnailJob" + } + } + + private val attachmentId: Long + + constructor(messageId: Long, attachmentId: AttachmentId, highPriority: Boolean = false) : this( + Parameters.Builder() + .setQueue(constructQueueString(attachmentId)) + .addConstraint(NetworkConstraint.KEY) + .setLifespan(TimeUnit.DAYS.toMillis(1)) + .setMaxAttempts(Parameters.UNLIMITED) + .setPriority(if (highPriority) Parameters.PRIORITY_HIGH else Parameters.PRIORITY_DEFAULT) + .build(), + messageId, + attachmentId + ) + + init { + this.attachmentId = attachmentId.id + } + + override fun serialize(): ByteArray? { + return JsonJobData.Builder() + .putLong(KEY_MESSAGE_ID, messageId) + .putLong(KEY_ATTACHMENT_ID, attachmentId) + .serialize() + } + + override fun getFactoryKey(): String { + 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) + 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) { + Log.w(TAG, "attachment no longer exists.") + 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 + ) + } 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 = 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) { + } + + override fun shouldCancel(): Boolean { + return this@RestoreAttachmentThumbnailJob.isCanceled + } + } + + val cdnCredentials = BackupRepository.getCdnReadCredentials(attachment.archiveCdn).successOrThrow().headers + val messageReceiver = AppDependencies.signalServiceMessageReceiver + val pointer = createThumbnailPointer(attachment) + + Log.w(TAG, "Downloading thumbnail for $attachmentId") + val stream = messageReceiver + .retrieveArchivedAttachment( + SignalStore.svr().getOrCreateMasterKey().deriveBackupKey().deriveMediaSecrets(attachment.getThumbnailMediaName()), + cdnCredentials, + thumbnailTransferFile, + pointer, + thumbnailFile, + maxThumbnailSize, + true, + progressListener + ) + + SignalDatabase.attachments.finalizeAttachmentThumbnailAfterDownload(attachmentId, attachment.archiveMediaId!!, stream, thumbnailTransferFile) + } + + private fun markFailed(messageId: Long, attachmentId: AttachmentId) { + try { + SignalDatabase.attachments.setThumbnailRestoreProgressFailed(attachmentId, messageId) + } catch (e: MmsException) { + Log.w(TAG, e) + } + } + + @VisibleForTesting + internal class InvalidPartException : Exception { + constructor(s: String?) : super(s) + constructor(e: Exception?) : super(e) + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): RestoreAttachmentThumbnailJob { + val data = JsonJobData.deserialize(serializedData) + return RestoreAttachmentThumbnailJob( + parameters = parameters, + messageId = data.getLong(KEY_MESSAGE_ID), + attachmentId = AttachmentId(data.getLong(KEY_ATTACHMENT_ID)) + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Adapter.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Adapter.kt index 5ad24e4c72..27969cdc23 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Adapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Adapter.kt @@ -28,7 +28,7 @@ class MediaPreviewV2Adapter(fragment: Fragment) : FragmentStateAdapter(fragment) val contentType = attachment.contentType val args = bundleOf( - MediaPreviewFragment.DATA_URI to attachment.uri, + MediaPreviewFragment.DATA_URI to attachment.displayUri, MediaPreviewFragment.DATA_CONTENT_TYPE to contentType, MediaPreviewFragment.DATA_SIZE to attachment.size, MediaPreviewFragment.AUTO_PLAY to attachment.videoGif, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamLocalUriFetcher.java b/app/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamLocalUriFetcher.java index b4172dd5ce..cdb147724c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamLocalUriFetcher.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/DecryptableStreamLocalUriFetcher.java @@ -8,6 +8,7 @@ import android.net.Uri; import com.bumptech.glide.load.data.StreamLocalUriFetcher; import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.attachments.AttachmentId; import org.thoughtcrime.securesms.util.MediaUtil; import java.io.ByteArrayInputStream; @@ -39,6 +40,18 @@ class DecryptableStreamLocalUriFetcher extends StreamLocalUriFetcher { thumbnail.recycle(); return thumbnailStream; } + if (PartAuthority.isAttachmentUri(uri) && MediaUtil.isVideoType(PartAuthority.getAttachmentContentType(context, uri))) { + try { + AttachmentId attachmentId = PartAuthority.requireAttachmentId(uri); + Uri thumbnailUri = PartAuthority.getAttachmentThumbnailUri(attachmentId); + InputStream thumbStream = PartAuthority.getAttachmentThumbnailStream(context, thumbnailUri); + if (thumbStream != null) { + return thumbStream; + } + } catch (IOException e) { + Log.i(TAG, "Failed to fetch thumbnail", e); + } + } } try { 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 3ae762881c..0892946baa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java @@ -56,11 +56,12 @@ public abstract class Slide { @Nullable public Uri getUri() { - Uri attachmentUri = attachment.getUri(); - if (attachmentUri != null) { - return attachmentUri; - } - return attachment.getThumbnailUri(); + return attachment.getUri(); + } + + @Nullable + public Uri getDisplayUri() { + return attachment.getDisplayUri(); } public @Nullable Uri getPublicUri() { @@ -141,7 +142,8 @@ public abstract class Slide { public boolean isPendingDownload() { return getTransferState() == AttachmentTable.TRANSFER_PROGRESS_FAILED || - getTransferState() == AttachmentTable.TRANSFER_PROGRESS_PENDING; + getTransferState() == AttachmentTable.TRANSFER_PROGRESS_PENDING || + getTransferState() == AttachmentTable.TRANSFER_RESTORE_OFFLOADED; } public int getTransferState() { @@ -160,6 +162,10 @@ public abstract class Slide { return false; } + public boolean hasThumbnail() { + return attachment.getThumbnailUri() != null; + } + public boolean hasPlayOverlay() { return false; }