Add spinner API support for inserting attachments.

This commit is contained in:
Alex Hart
2026-07-01 15:11:49 -04:00
committed by Michelle Tang
parent db899e92f5
commit b6f6d1e42f
2 changed files with 109 additions and 0 deletions
@@ -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<String>, contentTypes: Set<String>) {
@@ -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<String, List<String>>): 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<String, List<String>>) -> PluginResult,
val params: List<Param>
@@ -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<LocalBackup>)
data class LocalBackup @JsonCreator constructor(@field:JsonProperty val name: String, @field:JsonProperty val timestamp: Long)