diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 1d12147f74..3fa583a761 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -14,6 +14,7 @@ import org.signal.libsignal.messagebackup.MessageBackup.ValidationResult import org.signal.libsignal.messagebackup.MessageBackupKey import org.signal.libsignal.protocol.ServiceId.Aci import org.signal.libsignal.zkgroup.profiles.ProfileKey +import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.attachments.AttachmentId import org.thoughtcrime.securesms.attachments.Cdn import org.thoughtcrime.securesms.attachments.DatabaseAttachment @@ -40,6 +41,7 @@ import org.whispersystems.signalservice.api.NetworkResult import org.whispersystems.signalservice.api.StatusCodeErrorAction import org.whispersystems.signalservice.api.archive.ArchiveGetMediaItemsResponse import org.whispersystems.signalservice.api.archive.ArchiveMediaRequest +import org.whispersystems.signalservice.api.archive.ArchiveMediaResponse import org.whispersystems.signalservice.api.archive.ArchiveServiceCredential import org.whispersystems.signalservice.api.archive.DeleteArchivedMediaRequest import org.whispersystems.signalservice.api.archive.GetArchiveCdnCredentialsResponse @@ -351,15 +353,25 @@ object BackupRepository { } } - fun archiveMedia(attachment: DatabaseAttachment): NetworkResult { + fun archiveThumbnail(thumbnailAttachment: Attachment, parentAttachment: DatabaseAttachment): NetworkResult { val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey() return initBackupAndFetchAuth(backupKey) .then { credential -> - api.setPublicKey(backupKey, credential) - .map { credential } + api.archiveAttachmentMedia( + backupKey = backupKey, + serviceCredential = credential, + item = thumbnailAttachment.toArchiveMediaRequest(parentAttachment.getThumbnailMediaName(), backupKey) + ) } + } + + fun archiveMedia(attachment: DatabaseAttachment): NetworkResult { + val api = ApplicationDependencies.getSignalServiceAccountManager().archiveApi + val backupKey = SignalStore.svr().getOrCreateMasterKey().deriveBackupKey() + + return initBackupAndFetchAuth(backupKey) .then { credential -> val mediaName = attachment.getMediaName() val request = attachment.toArchiveMediaRequest(mediaName, backupKey) @@ -625,7 +637,11 @@ object BackupRepository { return MediaName.fromDigest(remoteDigest!!) } - private fun DatabaseAttachment.toArchiveMediaRequest(mediaName: MediaName, backupKey: BackupKey): ArchiveMediaRequest { + fun DatabaseAttachment.getThumbnailMediaName(): MediaName { + return MediaName.fromDigestForThumbnail(remoteDigest!!) + } + + private fun Attachment.toArchiveMediaRequest(mediaName: MediaName, backupKey: BackupKey): ArchiveMediaRequest { val mediaSecrets = backupKey.deriveMediaSecrets(mediaName) return ArchiveMediaRequest( diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentBackfillJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentBackfillJob.kt index 08e5ddc79f..ef2995690a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentBackfillJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentBackfillJob.kt @@ -186,6 +186,7 @@ class ArchiveAttachmentBackfillJob private constructor( Log.d(TAG, "Move complete!") SignalDatabase.attachments.setArchiveTransferState(attachmentRecord.attachmentId, AttachmentTable.ArchiveTransferState.FINISHED) + ArchiveThumbnailUploadJob.enqueueIfNecessary(attachmentRecord.attachmentId) reenqueueWithIncrementedProgress() Result.success() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentJob.kt index ebdbb9df89..d814178408 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveAttachmentJob.kt @@ -61,6 +61,7 @@ class ArchiveAttachmentJob private constructor(private val attachmentId: Attachm } BackupRepository.archiveMedia(attachment).successOrThrow() + ArchiveThumbnailUploadJob.enqueueIfNecessary(attachmentId) SignalStore.backup().usedBackupMediaSpace += attachment.size } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt new file mode 100644 index 0000000000..a41ab66895 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt @@ -0,0 +1,174 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.jobs + +import android.os.Build +import org.signal.core.util.logging.Log +import org.signal.protos.resumableuploads.ResumableUpload +import org.thoughtcrime.securesms.attachments.Attachment +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.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobmanager.Job +import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint +import org.thoughtcrime.securesms.jobs.protos.ArchiveThumbnailUploadJobData +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri +import org.thoughtcrime.securesms.util.ImageCompressionUtil +import org.thoughtcrime.securesms.util.MediaUtil +import org.whispersystems.signalservice.api.NetworkResult +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment +import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream +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 + +/** + * Uploads a thumbnail for the specified attachment to the archive service, if possible. + */ +class ArchiveThumbnailUploadJob private constructor( + params: Parameters, + val attachmentId: AttachmentId +) : Job(params) { + + companion object { + const val KEY = "ArchiveThumbnailUploadJob" + private val TAG = Log.tag(ArchiveThumbnailUploadJob::class.java) + + fun enqueueIfNecessary(attachmentId: AttachmentId) { + if (SignalStore.backup().canReadWriteToArchiveCdn) { + ApplicationDependencies.getJobManager().add(ArchiveThumbnailUploadJob(attachmentId)) + } + } + } + + constructor(attachmentId: AttachmentId) : this( + Parameters.Builder() + .setQueue("ArchiveThumbnailUploadJob") + .addConstraint(NetworkConstraint.KEY) + .setLifespan(1.days.inWholeMilliseconds) + .setMaxAttempts(Parameters.UNLIMITED) + .build(), + attachmentId + ) + + override fun serialize(): ByteArray { + return ArchiveThumbnailUploadJobData( + attachmentId = attachmentId.id + ).encode() + } + + override fun getFactoryKey(): String = KEY + + override fun run(): Result { + val attachment = SignalDatabase.attachments.getAttachment(attachmentId) + if (attachment == null) { + Log.w(TAG, "$attachmentId not found, assuming this job is no longer necessary.") + return Result.success() + } + + if (attachment.remoteDigest == null) { + Log.w(TAG, "$attachmentId was never uploaded! Cannot proceed.") + return Result.success() + } + + val thumbnailResult = generateThumbnailIfPossible(attachment) + if (thumbnailResult == null) { + Log.w(TAG, "Unable to generate a thumbnail result for $attachmentId") + return Result.success() + } + + val resumableUpload = when (val result = BackupRepository.getMediaUploadSpec()) { + is NetworkResult.Success -> { + Log.d(TAG, "Got an upload spec!") + result.result.toProto() + } + is NetworkResult.ApplicationError -> { + Log.w(TAG, "Failed to get an upload spec due to an application error. Retrying.", result.throwable) + return Result.retry(defaultBackoff()) + } + is NetworkResult.NetworkError -> { + Log.w(TAG, "Encountered a transient network error when getting upload spec. Retrying.") + return Result.retry(defaultBackoff()) + } + is NetworkResult.StatusCodeError -> { + Log.w(TAG, "Failed to get an upload spec with status code ${result.code}") + return Result.retry(defaultBackoff()) + } + } + + val stream = buildSignalServiceAttachmentStream(thumbnailResult, resumableUpload) + + val attachmentPointer: Attachment = try { + val pointer = ApplicationDependencies.getSignalServiceMessageSender().uploadAttachment(stream) + PointerAttachment.forPointer(Optional.of(pointer)).get() + } catch (e: IOException) { + Log.w(TAG, "Failed to upload attachment", e) + return Result.retry(defaultBackoff()) + } + + return when (val result = BackupRepository.archiveThumbnail(attachmentPointer, attachment)) { + is NetworkResult.Success -> { + Log.d(TAG, "Successfully archived thumbnail for $attachmentId") + Result.success() + } + is NetworkResult.NetworkError -> { + Log.w(TAG, "Hit a network error when trying to archive thumbnail for $attachmentId", result.exception) + Result.retry(defaultBackoff()) + } + is NetworkResult.StatusCodeError -> { + Log.w(TAG, "Hit a status code error of ${result.code} when trying to archive thumbnail for $attachmentId") + Result.retry(defaultBackoff()) + } + is NetworkResult.ApplicationError -> Result.fatalFailure(RuntimeException(result.throwable)) + } + } + + override fun onFailure() { + } + + private fun generateThumbnailIfPossible(attachment: DatabaseAttachment): ImageCompressionUtil.Result? { + val uri: DecryptableUri = attachment.uri?.let { DecryptableUri(it) } ?: return null + + return if (MediaUtil.isImageType(attachment.contentType)) { + ImageCompressionUtil.compress(context, attachment.contentType, uri, 256, 50) + } else if (Build.VERSION.SDK_INT >= 23 && MediaUtil.isVideoType(attachment.contentType)) { + MediaUtil.getVideoThumbnail(context, attachment.uri)?.let { + ImageCompressionUtil.compress(context, attachment.contentType, uri, 256, 50) + } + } else { + null + } + } + + private fun buildSignalServiceAttachmentStream(result: ImageCompressionUtil.Result, uploadSpec: ResumableUpload): SignalServiceAttachmentStream { + return SignalServiceAttachment.newStreamBuilder() + .withStream(ByteArrayInputStream(result.data)) + .withContentType(result.mimeType) + .withLength(result.data.size.toLong()) + .withWidth(result.width) + .withHeight(result.height) + .withUploadTimestamp(System.currentTimeMillis()) + .withResumableUploadSpec(ResumableUploadSpec.from(uploadSpec)) + .build() + } + + class Factory : Job.Factory { + override fun create(parameters: Parameters, serializedData: ByteArray?): ArchiveThumbnailUploadJob { + val data = ArchiveThumbnailUploadJobData.ADAPTER.decode(serializedData!!) + return ArchiveThumbnailUploadJob( + params = parameters, + attachmentId = AttachmentId(data.attachmentId) + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt index c6097ef5fe..edb56342bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt @@ -158,6 +158,7 @@ class AttachmentUploadJob private constructor( val remoteAttachment = messageSender.uploadAttachment(localAttachment) val attachment = PointerAttachment.forPointer(Optional.of(remoteAttachment), null, databaseAttachment.fastPreflightId).get() SignalDatabase.attachments.finalizeAttachmentAfterUpload(databaseAttachment.attachmentId, attachment, remoteAttachment.uploadTimestamp) + ArchiveThumbnailUploadJob.enqueueIfNecessary(databaseAttachment.attachmentId) } } } catch (e: NonSuccessfulResumableUploadResponseCodeException) { 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 d79a476d69..9255aa3b11 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -102,6 +102,7 @@ public final class JobManagerFactories { put(AnalyzeDatabaseJob.KEY, new AnalyzeDatabaseJob.Factory()); put(ArchiveAttachmentJob.KEY, new ArchiveAttachmentJob.Factory()); put(ArchiveAttachmentBackfillJob.KEY, new ArchiveAttachmentBackfillJob.Factory()); + put(ArchiveThumbnailUploadJob.KEY, new ArchiveThumbnailUploadJob.Factory()); put(AttachmentCompressionJob.KEY, new AttachmentCompressionJob.Factory()); put(AttachmentCopyJob.KEY, new AttachmentCopyJob.Factory()); put(AttachmentDownloadJob.KEY, new AttachmentDownloadJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java index a49575ed5a..dc4b780f01 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java @@ -407,6 +407,11 @@ public class MediaUtil { } else return PartAuthority.isAttachmentUri(uri) && MediaUtil.isVideoType(PartAuthority.getAttachmentContentType(context, uri)); } + @WorkerThread + public static @Nullable Bitmap getVideoThumbnail(@NonNull Context context, @Nullable Uri uri) { + return getVideoThumbnail(context, uri, 1000); + } + @WorkerThread public static @Nullable Bitmap getVideoThumbnail(@NonNull Context context, @Nullable Uri uri, long timeUs) { if (uri == null) { diff --git a/app/src/main/protowire/JobData.proto b/app/src/main/protowire/JobData.proto index 2d82d28378..2284d9fa5b 100644 --- a/app/src/main/protowire/JobData.proto +++ b/app/src/main/protowire/JobData.proto @@ -57,4 +57,8 @@ message ArchiveAttachmentBackfillJobData { ResumableUpload uploadSpec = 2; optional uint32 count = 3; optional uint32 totalCount = 4; +} + +message ArchiveThumbnailUploadJobData { + uint64 attachmentId = 1; } \ No newline at end of file