Add ui wiring for archive thumbnail support.

This commit is contained in:
Clark
2024-05-22 19:02:20 -04:00
committed by Cody Henthorne
parent 3d382ee15e
commit 2a3cb80217
15 changed files with 451 additions and 100 deletions

View File

@@ -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()!!,

View File

@@ -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 {

View File

@@ -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<MessageRecord>) {
SignalExecutors.BOUNDED.execute {
synchronized(this) {
val restoringAttachments: List<AttachmentId> = 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)
}
}
}

View File

@@ -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<Boolean> 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<Boolean> thumbnailFuture = new SettableFuture<>();
thumbnailFuture.deferTo(result);
@@ -542,7 +554,7 @@ public class ThumbnailView extends FrameLayout {
}
private RequestBuilder<Drawable> buildThumbnailRequestBuilder(@NonNull RequestManager requestManager, @NonNull Slide slide) {
RequestBuilder<Drawable> requestBuilder = applySizing(requestManager.load(new DecryptableUri(Objects.requireNonNull(slide.getUri())))
RequestBuilder<Drawable> 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<Bitmap> buildArchiveThumbnailRequestBuilder(@NonNull RequestManager requestManager, @NonNull Slide slide) {
RequestBuilder<Bitmap> 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<Bitmap> resizedRequest = applySizing(bitmap.diskCacheStrategy(DiskCacheStrategy.NONE));
if (thumbnailUri != null) {
return resizedRequest.centerCrop();
} else {
return resizedRequest;
}
}
private <TranscodeType> RequestBuilder<TranscodeType> applySizing(@NonNull RequestBuilder<TranscodeType> 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);

View File

@@ -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;

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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

View File

@@ -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<RestoreAttachmentJob>
var restoreJobBatch: List<Job>
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())

View File

@@ -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());

View File

@@ -89,22 +89,17 @@ class RestoreAttachmentJob private constructor(
return ids.contains(parsed)
}
fun modifyPriorities(ids: Set<AttachmentId>, 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 {

View File

@@ -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<RestoreAttachmentThumbnailJob?> {
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))
)
}
}
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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;
}