From b6f6d1e42f42f423efdeb54d9923d675c410fef5 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 1 Jul 2026 14:23:18 -0300 Subject: [PATCH] Add spinner API support for inserting attachments. --- .../securesms/database/AttachmentTable.kt | 2 + .../org/thoughtcrime/securesms/ApiPlugin.kt | 107 ++++++++++++++++++ 2 files changed, 109 insertions(+) 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 3f30f77893..317616c320 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -2631,6 +2631,8 @@ class AttachmentTable( REMOTE_KEY to Base64.encodeWithPadding(Util.getSecretBytes(64)), REMOTE_DIGEST to Util.getSecretBytes(64) ) + .where("$ID = ?", attachmentId.id) + .run() } private fun deleteDataFiles(filePaths: Set, contentTypes: Set) { diff --git a/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt b/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt index 5c47c7bbb0..9dda658994 100644 --- a/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt +++ b/app/src/spinner/java/org/thoughtcrime/securesms/ApiPlugin.kt @@ -8,7 +8,9 @@ package org.thoughtcrime.securesms import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonProperty import org.signal.core.models.ServiceId +import org.signal.core.util.Base64 import org.signal.core.util.Hex +import org.signal.core.util.Util import org.signal.core.util.logging.Log import org.signal.core.util.orNull import org.signal.libsignal.zkgroup.groups.GroupMasterKey @@ -19,7 +21,9 @@ import org.signal.storageservice.storage.protos.groups.Member import org.signal.storageservice.storage.protos.groups.local.DecryptedGroup import org.signal.storageservice.storage.protos.groups.local.DecryptedMember import org.signal.storageservice.storage.protos.groups.local.DecryptedPendingMember +import org.thoughtcrime.securesms.attachments.UriAttachment import org.thoughtcrime.securesms.crypto.ProfileKeyUtil +import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.JobDatabase import org.thoughtcrime.securesms.database.KeyValueDatabase import org.thoughtcrime.securesms.database.LocalMetricsDatabase @@ -107,6 +111,20 @@ class ApiPlugin : Plugin { Param("type", "NORMAL", placeholder = "NORMAL|IDENTITY_UPDATE|IDENTITY_VERIFIED|IDENTITY_DEFAULT|CONTACT_JOINED|EXPIRATION_UPDATE"), Param("timestamp", "", placeholder = "blank = now (epoch millis)") ) + ), + "createAttachment" to ApiSpec( + ::createAttachment, + listOf( + Param("messageId", "1"), + Param("contentType", "image/jpeg", placeholder = "any MIME type: image/jpeg, audio/aac, video/mp4, ..."), + Param("data", "", placeholder = "base64 bytes; blank = random of sizeBytes"), + Param("sizeBytes", "1024", placeholder = "random byte count when data is blank"), + Param("fileName", "", placeholder = "optional original filename"), + Param("voiceNote", "false"), + Param("width", "0"), + Param("height", "0"), + Param("validForArchive", "true", placeholder = "true = fill remote_key + data_hash_end so it exports to backup") + ) ) ) @@ -565,6 +583,87 @@ class ApiPlugin : Plugin { } } + /** + * Attaches a media file to an existing message so it lands on-device with real bytes on disk. + * + * The bytes are written through the normal attachment pipeline ([AttachmentTable.insertAttachmentsForMessage]), + * leaving the attachment at [AttachmentTable.TRANSFER_PROGRESS_DONE] with a local `data_file`. When + * `validForArchive` is set (the default) we additionally fill `remote_key` + `data_hash_end` via + * [AttachmentTable.debugMakeValidForArchive] so the attachment satisfies the backup exporter's validity + * check and its media is included when a backup is created. + * + * `contentType` is arbitrary — images, audio (set `voiceNote=true` for a voice note), video, or any file. + */ + private fun createAttachment(parameters: Map>): PluginResult { + val messageId = parameters["messageId"]?.firstOrNull()?.toLongOrNull() + ?: return PluginResult.ErrorResult(message = "Missing or invalid 'messageId' parameter") + + if (SignalDatabase.messages.getMessageRecordOrNull(messageId) == null) { + return PluginResult.ErrorResult(message = "No message with id $messageId") + } + + val contentType = parameters["contentType"]?.firstOrNull()?.takeIf { it.isNotBlank() } ?: "image/jpeg" + val fileName = parameters["fileName"]?.firstOrNull()?.takeIf { it.isNotBlank() } + val voiceNote = parameters.boolOrDefault("voiceNote", false) + val width = parameters["width"]?.firstOrNull()?.toIntOrNull() ?: 0 + val height = parameters["height"]?.firstOrNull()?.toIntOrNull() ?: 0 + val validForArchive = parameters.boolOrDefault("validForArchive", true) + + val bytes = try { + parameters["data"]?.firstOrNull()?.takeIf { it.isNotBlank() }?.let { Base64.decode(it) } + ?: Util.getSecretBytes(parameters["sizeBytes"]?.firstOrNull()?.toIntOrNull() ?: 1024) + } catch (e: Exception) { + return PluginResult.ErrorResult(message = "Failed to decode 'data' as base64: ${e.message}") + } + + return try { + val blobUri = AppDependencies.blobs + .forData(bytes) + .withMimeType(contentType) + .apply { if (fileName != null) withFileName(fileName) } + .createForSingleSessionInMemory() + + val attachment = UriAttachment( + dataUri = blobUri, + contentType = contentType, + transferState = AttachmentTable.TRANSFER_PROGRESS_DONE, + size = bytes.size.toLong(), + width = width, + height = height, + fileName = fileName, + fastPreflightId = null, + voiceNote = voiceNote, + borderless = false, + videoGif = false, + quote = false, + quoteTargetContentType = null, + caption = null, + stickerLocator = null, + blurHash = null, + audioHash = null, + transformProperties = null + ) + + val attachmentId = SignalDatabase.attachments.insertAttachmentsForMessage(messageId, listOf(attachment), emptyList())[attachment] + ?: return PluginResult.ErrorResult(message = "Attachment was not inserted") + + if (validForArchive) { + SignalDatabase.attachments.debugMakeValidForArchive(attachmentId) + } + + CreateAttachmentResponse( + attachmentId = attachmentId.id, + messageId = messageId, + size = bytes.size.toLong(), + contentType = contentType, + validForArchive = validForArchive + ).toJsonResult() + } catch (e: Exception) { + Log.w(TAG, "Failed to create attachment", e) + PluginResult.ErrorResult(message = "Failed: ${e.message}") + } + } + private data class ApiSpec( val handler: (Map>) -> PluginResult, val params: List @@ -594,6 +693,14 @@ class ApiPlugin : Plugin { @field:JsonProperty val messageId: Long ) + data class CreateAttachmentResponse @JsonCreator constructor( + @field:JsonProperty val attachmentId: Long, + @field:JsonProperty val messageId: Long, + @field:JsonProperty val size: Long, + @field:JsonProperty val contentType: String, + @field:JsonProperty val validForArchive: Boolean + ) + data class LocalBackups @JsonCreator constructor(@field:JsonProperty val backups: List) data class LocalBackup @JsonCreator constructor(@field:JsonProperty val name: String, @field:JsonProperty val timestamp: Long)