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 b0803d92ed..480db64fc3 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 @@ -589,6 +589,10 @@ object BackupRepository { @JvmStatic fun maybeFixAnyDanglingUploadProgress() { + if (SignalStore.account.isLinkedDevice) { + return + } + if (SignalStore.backup.archiveUploadState?.backupPhase == ArchiveUploadProgressState.BackupPhase.Message && AppDependencies.jobManager.find { it.factoryKey == BackupMessagesJob.KEY }.isEmpty()) { SignalStore.backup.archiveUploadState = null BackupMessagesJob.enqueue() 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 18e49668d4..2ad5c1f68f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ThumbnailView.java @@ -403,7 +403,7 @@ public class ThumbnailView extends FrameLayout { } if (hasSameContents(this.slide, slide)) { - Log.i(TAG, "Not re-loading slide " + slide.asAttachment().getUri()); + Log.i(TAG, "Not re-loading slide " + slide.asAttachment().getDisplayUri()); return new SettableFuture<>(false); } 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 81154c9346..bf587ff55b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -869,6 +869,7 @@ class AttachmentTable( """ ${buildAttachmentsThatNeedUploadQuery("$ARCHIVE_THUMBNAIL_TRANSFER_STATE IN (${ArchiveTransferState.NONE.value}, ${ArchiveTransferState.TEMPORARY_FAILURE.value})")} AND $QUOTE = 0 AND + $STICKER_ID = -1 AND ($CONTENT_TYPE LIKE 'image/%' OR $CONTENT_TYPE LIKE 'video/%') AND $CONTENT_TYPE != 'image/svg+xml' AND $MESSAGE_ID != $WALLPAPER_MESSAGE_ID @@ -888,6 +889,7 @@ class AttachmentTable( """ ${buildAttachmentsThatNeedUploadQuery("$ARCHIVE_THUMBNAIL_TRANSFER_STATE IN (${ArchiveTransferState.NONE.value}, ${ArchiveTransferState.TEMPORARY_FAILURE.value})")} AND $QUOTE = 0 AND + $STICKER_ID = -1 AND ($CONTENT_TYPE LIKE 'image/%' OR $CONTENT_TYPE LIKE 'video/%') AND $CONTENT_TYPE != 'image/svg+xml' AND $MESSAGE_ID != $WALLPAPER_MESSAGE_ID @@ -1122,11 +1124,15 @@ class AttachmentTable( $TABLE_NAME.$OFFLOAD_RESTORED_AT < ${now - 7.days.inWholeMilliseconds} AND $TABLE_NAME.$TRANSFER_STATE = $TRANSFER_PROGRESS_DONE AND $TABLE_NAME.$ARCHIVE_TRANSFER_STATE = ${ArchiveTransferState.FINISHED.value} AND + $TABLE_NAME.$DATA_FILE IS NOT NULL AND + $TABLE_NAME.$STICKER_ID = -1 AND + $TABLE_NAME.$REMOTE_KEY IS NOT NULL AND + $TABLE_NAME.$DATA_HASH_END IS NOT NULL AND ( $TABLE_NAME.$THUMBNAIL_FILE IS NOT NULL OR - NOT ($TABLE_NAME.$CONTENT_TYPE like 'image/%' OR $TABLE_NAME.$CONTENT_TYPE like 'video/%') - ) AND - $TABLE_NAME.$DATA_FILE IS NOT NULL + NOT ($TABLE_NAME.$CONTENT_TYPE LIKE 'image/%' OR $TABLE_NAME.$CONTENT_TYPE LIKE 'video/%') OR + $TABLE_NAME.$CONTENT_TYPE = 'image/svg+xml' + ) ) AND ( @@ -1134,7 +1140,7 @@ class AttachmentTable( ) """ - writableDatabase + val count = writableDatabase .update(TABLE_NAME) .values( TRANSFER_STATE to TRANSFER_RESTORE_OFFLOADED, @@ -1142,11 +1148,12 @@ class AttachmentTable( DATA_RANDOM to null, TRANSFORM_PROPERTIES to null, DATA_HASH_START to null, - DATA_HASH_END to null, OFFLOAD_RESTORED_AT to 0 ) .where("$ID in ($subSelect)") .run() + + Log.i(TAG, "Marked $count attachments as optimized") } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/OptimizeMediaJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/OptimizeMediaJob.kt index 961148fb0f..89778c9d8a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/OptimizeMediaJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/OptimizeMediaJob.kt @@ -54,7 +54,8 @@ class OptimizeMediaJob private constructor(parameters: Parameters) : Job(paramet SignalDatabase.attachments.markEligibleAttachmentsAsOptimized() Log.i(TAG, "Deleting abandoned attachment files") - SignalDatabase.attachments.deleteAbandonedAttachmentFiles() + val count = SignalDatabase.attachments.deleteAbandonedAttachmentFiles() + Log.i(TAG, "Deleted $count attachments") return Result.success() } 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 5ae05b744a..367ba8b414 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RestoreAttachmentJob.kt @@ -164,7 +164,7 @@ class RestoreAttachmentJob private constructor( messageId = attachment.mmsId, attachmentId = attachment.attachmentId, manual = true, - queue = Queues.MANUAL_RESTORE.random(), + queue = Queues.random(Queues.MANUAL_RESTORE, attachment.dataHash?.hashCode() ?: attachment.remoteKey?.hashCode()), priority = Parameters.PRIORITY_DEFAULT ) @@ -410,7 +410,7 @@ class RestoreAttachmentJob private constructor( inputStream = input, offloadRestoredAt = if (manual) System.currentTimeMillis().milliseconds else null, archiveRestore = true, - notify = false + notify = manual ) ArchiveDatabaseExecutor.throttledNotifyAttachmentAndChatListObservers() } 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 608dd7ad84..472970e578 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java @@ -251,12 +251,13 @@ public abstract class Slide { this.hasImage() == that.hasImage() && this.hasVideo() == that.hasVideo() && this.getTransferState() == that.getTransferState() && - Util.equals(this.getUri(), that.getUri()); + Util.equals(this.getUri(), that.getUri()) && + Util.equals(this.getThumbnailUri(), that.getThumbnailUri()); } @Override public int hashCode() { return Util.hashCode(getContentType(), hasAudio(), hasImage(), - hasVideo(), getUri(), getTransferState()); + hasVideo(), getUri(), getTransferState(), getThumbnailUri()); } } diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/AttachmentPlugin.kt b/app/src/spinner/java/org/thoughtcrime/securesms/AttachmentPlugin.kt new file mode 100644 index 0000000000..d7955ddac3 --- /dev/null +++ b/app/src/spinner/java/org/thoughtcrime/securesms/AttachmentPlugin.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms + +import okio.IOException +import org.signal.spinner.Plugin +import org.signal.spinner.PluginResult +import org.thoughtcrime.securesms.attachments.AttachmentId +import org.thoughtcrime.securesms.database.AttachmentTable +import org.thoughtcrime.securesms.database.SignalDatabase + +class AttachmentPlugin : Plugin { + companion object { + const val PATH = "/attachment" + } + + override val name: String = "Attachment" + override val path: String = PATH + + override fun get(parameters: Map>): PluginResult { + var errorContent = "" + + parameters["attachment_id"]?.firstOrNull()?.let { id -> + val attachmentId = id.toLongOrNull()?.let { AttachmentId(it) } + if (attachmentId != null) { + try { + val attachment = SignalDatabase.attachments.getAttachment(attachmentId) + if (attachment != null) { + val inputStream = if (attachment.transferState == AttachmentTable.TRANSFER_PROGRESS_DONE) { + SignalDatabase.attachments.getAttachmentStream(attachmentId, 0) + } else { + SignalDatabase.attachments.getAttachmentThumbnailStream(attachmentId, 0) + } + return PluginResult.RawFileResult(attachment.size, inputStream, attachment.contentType ?: "application/octet-stream") + } else { + throw IOException("Missing attachment, not found for: $attachmentId") + } + } catch (e: IOException) { + errorContent = "${e.javaClass}: ${e.message}" + } + } + } + + val formContent = """ +
+ + + +
+ """.trimIndent() + + return PluginResult.RawHtmlResult("$formContent
$errorContent") + } +} diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt index 59dc92d953..77b0566e3f 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/SpinnerApplicationContext.kt @@ -85,7 +85,8 @@ class SpinnerApplicationContext : ApplicationContext() { ) ), linkedMapOf( - StorageServicePlugin.PATH to StorageServicePlugin() + StorageServicePlugin.PATH to StorageServicePlugin(), + AttachmentPlugin.PATH to AttachmentPlugin() ) ) diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/StorageServicePlugin.kt b/app/src/spinner/java/org/thoughtcrime/securesms/StorageServicePlugin.kt index 1af4413a05..926be3ed72 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/StorageServicePlugin.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/StorageServicePlugin.kt @@ -11,7 +11,7 @@ class StorageServicePlugin : Plugin { override val name: String = "Storage" override val path: String = PATH - override fun get(): PluginResult { + override fun get(parameters: Map>): PluginResult { val columns = listOf("Type", "Id", "Data") val rows = mutableListOf>() diff --git a/spinner/lib/src/main/assets/plugin.hbs b/spinner/lib/src/main/assets/plugin.hbs index bb7b37543c..91176e9b08 100644 --- a/spinner/lib/src/main/assets/plugin.hbs +++ b/spinner/lib/src/main/assets/plugin.hbs @@ -26,6 +26,9 @@ {{#if (eq "string" pluginResult.type)}}

{{pluginResult.text}}

{{/if}} + {{#if (eq "html" pluginResult.type)}} + {{{pluginResult.html}}} + {{/if}} {{> partials/suffix }} diff --git a/spinner/lib/src/main/java/org/signal/spinner/Plugin.kt b/spinner/lib/src/main/java/org/signal/spinner/Plugin.kt index 8c7e9068d9..a4226d857b 100644 --- a/spinner/lib/src/main/java/org/signal/spinner/Plugin.kt +++ b/spinner/lib/src/main/java/org/signal/spinner/Plugin.kt @@ -1,7 +1,7 @@ package org.signal.spinner interface Plugin { - fun get(): PluginResult + fun get(parameters: Map>): PluginResult val name: String val path: String } diff --git a/spinner/lib/src/main/java/org/signal/spinner/PluginResult.kt b/spinner/lib/src/main/java/org/signal/spinner/PluginResult.kt index 26b845ada7..8aeb93bce4 100644 --- a/spinner/lib/src/main/java/org/signal/spinner/PluginResult.kt +++ b/spinner/lib/src/main/java/org/signal/spinner/PluginResult.kt @@ -1,5 +1,7 @@ package org.signal.spinner +import java.io.InputStream + sealed class PluginResult(val type: String) { data class TableResult( val columns: List, @@ -10,4 +12,14 @@ sealed class PluginResult(val type: String) { data class StringResult( val text: String ) : PluginResult("string") + + data class RawHtmlResult( + val html: String + ) : PluginResult("html") + + data class RawFileResult( + val length: Long, + val data: InputStream, + val mimeType: String + ) : PluginResult("file") } diff --git a/spinner/lib/src/main/java/org/signal/spinner/SpinnerServer.kt b/spinner/lib/src/main/java/org/signal/spinner/SpinnerServer.kt index ef4ab4c6c2..f4366d6946 100644 --- a/spinner/lib/src/main/java/org/signal/spinner/SpinnerServer.kt +++ b/spinner/lib/src/main/java/org/signal/spinner/SpinnerServer.kt @@ -15,7 +15,6 @@ import org.signal.core.util.logging.Log import org.signal.core.util.tracing.Tracer import org.signal.spinner.Spinner.DatabaseConfig import java.io.ByteArrayInputStream -import java.lang.IllegalArgumentException import java.security.NoSuchAlgorithmException import java.text.SimpleDateFormat import java.util.Date @@ -79,7 +78,7 @@ internal class SpinnerServer( else -> { val plugin = plugins[session.uri] if (plugin != null && session.method == Method.GET) { - getPlugin(dbParam, plugin) + getPlugin(dbParam, plugin, session.parameters) } else { newFixedLengthResponse(Response.Status.NOT_FOUND, MIME_HTML, "Not found") } @@ -280,19 +279,28 @@ internal class SpinnerServer( ) } - private fun getPlugin(dbName: String, plugin: Plugin): Response { - return renderTemplate( - "plugin", - PluginPageModel( - environment = environment, - deviceInfo = deviceInfo.resolve(), - database = dbName, - databases = databases.keys.toList(), - plugins = plugins.values.toList(), - activePlugin = plugin, - pluginResult = plugin.get() - ) - ) + private fun getPlugin(dbName: String, plugin: Plugin, parameters: Map>): Response { + val pluginResult = plugin.get(parameters) + + when (pluginResult) { + is PluginResult.RawFileResult -> { + return newFixedLengthResponse(Response.Status.OK, pluginResult.mimeType, pluginResult.data, pluginResult.length) + } + else -> { + return renderTemplate( + "plugin", + PluginPageModel( + environment = environment, + deviceInfo = deviceInfo.resolve(), + database = dbName, + databases = databases.keys.toList(), + plugins = plugins.values.toList(), + activePlugin = plugin, + pluginResult = plugin.get(parameters) + ) + ) + } + } } private fun internalError(throwable: Throwable): Response {