feat(media): encrypted file sharing in desktop DMs (NIP-17 Phase 6)

Add full send + receive encrypted media support in desktop DM chat:
- Paperclip attach button and drag-and-drop in ChatPane (NIP-17 mode only)
- AES-GCM encryption before upload to Blossom server
- ChatMessageEncryptedFileHeaderEvent (kind 15) wrapped in GiftWrap
- sendNip17EncryptedFile() added to IAccount interface and implementations
- DesktopUploadOrchestrator.uploadEncrypted() with proper encrypted hash
- DesktopBlossomClient ByteArray upload overload for encrypted blobs
- LRU cache in EncryptedMediaService to avoid re-downloading
- Error handling: retry on failure, disable send during upload

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-03-19 06:53:36 +02:00
parent c90bf8f610
commit 24d4073b9b
9 changed files with 343 additions and 27 deletions
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent
@@ -156,6 +157,37 @@ class DesktopIAccount(
scope.launch { dmSendTracker.sendBatch(batch) }
}
override suspend fun sendNip17EncryptedFile(template: EventTemplate<ChatMessageEncryptedFileHeaderEvent>) {
if (!isWriteable()) return
val result = NIP17Factory().createEncryptedFileNIP17(template, signer)
// Optimistic local add
val innerEvent = result.msg as ChatMessageEncryptedFileHeaderEvent
addEventToChatroom(innerEvent, innerEvent.chatroomKey(pubKey))
// Collect wraps with target relays and send
val batch =
result.wraps.map { wrap ->
val recipientKey = wrap.recipientPubKey()
val targetRelays =
if (recipientKey != null) {
val dmRelays =
localCache
.getOrCreateUser(recipientKey)
.dmInboxRelays()
?.toSet()
dmRelays?.ifEmpty { null }
?: relayManager.connectedRelays.value
} else {
relayManager.connectedRelays.value
}
wrap to targetRelays
}
scope.launch { dmSendTracker.sendBatch(batch) }
}
override suspend fun sendGiftWraps(wraps: List<GiftWrapEvent>) {
val batch =
wraps.map { wrap ->
@@ -25,24 +25,31 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.ConcurrentHashMap
/**
* Handles decryption of media files for NIP-17 DMs.
* Uses AESGCM cipher from quartz (commonMain).
* Caches decrypted bytes in memory to avoid re-downloading on recomposition.
*/
object EncryptedMediaService {
private val httpClient = OkHttpClient()
private val cache = ConcurrentHashMap<String, ByteArray>()
private const val MAX_CACHE_ENTRIES = 20
/**
* Download and decrypt an encrypted file from a URL.
* Results are cached by URL to avoid re-downloading on scroll/recomposition.
* Returns the decrypted bytes.
*/
suspend fun downloadAndDecrypt(
url: String,
keyBytes: ByteArray,
nonce: ByteArray,
): ByteArray =
withContext(Dispatchers.IO) {
): ByteArray {
cache[url]?.let { return it }
return withContext(Dispatchers.IO) {
val request = Request.Builder().url(url).build()
val response = httpClient.newCall(request).execute()
val encryptedBytes =
@@ -52,6 +59,15 @@ object EncryptedMediaService {
}
val cipher = AESGCM(keyBytes, nonce)
cipher.decrypt(encryptedBytes)
val decrypted = cipher.decrypt(encryptedBytes)
// Evict oldest entries if cache is full
if (cache.size >= MAX_CACHE_ENTRIES) {
cache.keys.firstOrNull()?.let { cache.remove(it) }
}
cache[url] = decrypted
decrypted
}
}
}
@@ -28,6 +28,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import java.io.File
@@ -62,6 +63,37 @@ class DesktopBlossomClient(
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
val response = okHttpClient.newCall(requestBuilder.build()).execute()
response.use {
if (!it.isSuccessful) {
val reason = it.headers["X-Reason"] ?: it.code.toString()
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
}
JsonMapper.fromJson<BlossomUploadResult>(it.body.string())
}
}
/**
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
*/
suspend fun upload(
bytes: ByteArray,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
val requestBody = bytes.toRequestBody(contentType.toMediaType())
val requestBuilder =
Request
.Builder()
.url(apiUrl)
.put(requestBody)
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
val response = okHttpClient.newCall(requestBuilder.build()).execute()
response.use {
if (!it.isSuccessful) {
@@ -20,8 +20,11 @@
*/
package com.vitorpamplona.amethyst.desktop.service.upload
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import com.vitorpamplona.quartz.utils.sha256.sha256
import java.io.File
data class UploadResult(
@@ -29,6 +32,13 @@ data class UploadResult(
val metadata: MediaMetadata,
)
data class EncryptedUploadResult(
val blossom: BlossomUploadResult,
val metadata: MediaMetadata,
val encryptedHash: String,
val encryptedSize: Int,
)
class DesktopUploadOrchestrator(
private val client: DesktopBlossomClient = DesktopBlossomClient(),
) {
@@ -75,4 +85,52 @@ class DesktopUploadOrchestrator(
return UploadResult(blossom = result, metadata = metadata)
}
/**
* Upload a file encrypted with AES-GCM for NIP-17 DM file sharing.
* Computes pre-encryption metadata (dimensions, blurhash), encrypts bytes,
* then uploads the encrypted blob to Blossom.
*/
suspend fun uploadEncrypted(
file: File,
cipher: AESGCM,
serverBaseUrl: String,
signer: NostrSigner,
): EncryptedUploadResult {
// 1. Compute pre-encryption metadata (dimensions, blurhash, mime, originalHash)
val metadata = DesktopMediaMetadata.compute(file)
// 2. Read file bytes and encrypt
val plaintext = file.readBytes()
val encrypted = cipher.encrypt(plaintext)
// 3. Compute SHA256 of ENCRYPTED blob (not plaintext)
val encryptedHash = sha256(encrypted).toHexKey()
val encryptedSize = encrypted.size
// 4. Create Blossom auth with encrypted hash and size
val authHeader =
DesktopBlossomAuth.createUploadAuth(
hash = encryptedHash,
size = encryptedSize.toLong(),
alt = "Encrypted upload",
signer = signer,
)
// 5. Upload encrypted blob as opaque binary
val result =
client.upload(
bytes = encrypted,
contentType = "application/octet-stream",
serverBaseUrl = serverBaseUrl,
authHeader = authHeader,
)
return EncryptedUploadResult(
blossom = result,
metadata = metadata,
encryptedHash = encryptedHash,
encryptedSize = encryptedSize,
)
}
}
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.desktop.ui.chats
import androidx.compose.foundation.border
import androidx.compose.foundation.draganddrop.dragAndDropTarget
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -38,6 +40,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.outlined.AddReaction
@@ -53,6 +56,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -60,6 +64,8 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed
@@ -88,15 +94,28 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState
import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopFilePicker
import com.vitorpamplona.amethyst.desktop.ui.media.MediaAttachmentRow
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip17Dm.NIP17Factory
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
import kotlinx.coroutines.launch
import java.awt.datatransfer.DataFlavor
import java.awt.dnd.DnDConstants
import java.awt.dnd.DropTargetDropEvent
import java.io.File
private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
private val MEDIA_EXTENSIONS =
setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "mp4", "webm", "mov", "mp3", "ogg", "wav", "flac")
/**
* Right panel of the DM split-pane layout (flexible width).
*
@@ -112,6 +131,7 @@ private val isMacOS = System.getProperty("os.name").lowercase().contains("mac")
* @param messageState ChatNewMessageState for composition
* @param onNavigateToProfile Called when user clicks on a profile
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ChatPane(
roomKey: ChatroomKey,
@@ -129,6 +149,41 @@ fun ChatPane(
val isNip17 by messageState.nip17.collectAsState()
val requiresNip17 by messageState.requiresNip17.collectAsState()
// File attachment state
val attachedFiles = remember { mutableStateListOf<File>() }
var isUploading by remember { mutableStateOf(false) }
// Drag-and-drop target for file attachments (NIP-17 only)
var isDragOver by remember { mutableStateOf(false) }
val dropTarget =
remember {
object : DragAndDropTarget {
override fun onDrop(event: DragAndDropEvent): Boolean {
isDragOver = false
val dropEvent = event.nativeEvent as? DropTargetDropEvent ?: return false
dropEvent.acceptDrop(DnDConstants.ACTION_COPY)
val transferable = dropEvent.transferable
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
@Suppress("UNCHECKED_CAST")
val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<File>
attachedFiles.addAll(files.filter { it.extension.lowercase() in MEDIA_EXTENSIONS })
dropEvent.dropComplete(true)
return true
}
dropEvent.dropComplete(false)
return false
}
override fun onStarted(event: DragAndDropEvent) {
isDragOver = true
}
override fun onEnded(event: DragAndDropEvent) {
isDragOver = false
}
}
}
// Resolve users for the header
val users = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }
val isGroup = users.size > 1
@@ -138,7 +193,21 @@ fun ChatPane(
messageState.load(roomKey)
}
Column(modifier = modifier.fillMaxSize()) {
Column(
modifier =
modifier
.fillMaxSize()
.dragAndDropTarget(
shouldStartDragAndDrop = { isNip17 },
target = dropTarget,
).then(
if (isDragOver) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp))
} else {
Modifier
},
),
) {
// Header
if (isGroup) {
GroupChatroomHeader(
@@ -225,17 +294,59 @@ fun ChatPane(
HorizontalDivider()
// File attachment row (only when NIP-17 and files attached)
if (isNip17 && attachedFiles.isNotEmpty()) {
MediaAttachmentRow(
attachedFiles = attachedFiles,
isUploading = isUploading,
onAttach = {
val files = DesktopFilePicker.pickMediaFiles()
attachedFiles.addAll(files)
},
onPaste = {},
onRemove = { attachedFiles.remove(it) },
)
}
// Message input
MessageInput(
messageText = messageText.text,
isNip17 = isNip17,
requiresNip17 = requiresNip17,
canSend = messageState.canSend,
canSend = messageState.canSend || attachedFiles.isNotEmpty(),
isUploading = isUploading,
hasAttachments = attachedFiles.isNotEmpty(),
onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) },
onToggleNip17 = { messageState.toggleNip17() },
onAttach = {
val files = DesktopFilePicker.pickMediaFiles()
attachedFiles.addAll(files)
},
onSend = {
scope.launch {
if (messageState.send()) {
if (attachedFiles.isNotEmpty()) {
isUploading = true
try {
sendEncryptedFiles(
files = attachedFiles.toList(),
roomKey = roomKey,
account = account,
cacheProvider = cacheProvider,
)
attachedFiles.clear()
} catch (e: Exception) {
// Keep files in attachment row for retry
println("Encrypted file send failed: ${e.message}")
} finally {
isUploading = false
}
}
// Also send text message if present
if (messageState.canSend) {
if (messageState.send()) {
messageState.clear()
}
} else if (attachedFiles.isEmpty()) {
messageState.clear()
}
}
@@ -529,8 +640,11 @@ private fun MessageInput(
isNip17: Boolean,
requiresNip17: Boolean,
canSend: Boolean,
isUploading: Boolean = false,
hasAttachments: Boolean = false,
onMessageChange: (String) -> Unit,
onToggleNip17: () -> Unit,
onAttach: () -> Unit = {},
onSend: () -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
@@ -539,6 +653,26 @@ private fun MessageInput(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
// Paperclip attach button (NIP-17 only)
if (isNip17) {
IconButton(
onClick = onAttach,
enabled = !isUploading,
modifier = Modifier.size(40.dp),
) {
Icon(
Icons.Default.AttachFile,
contentDescription = "Attach file",
tint =
if (isUploading) {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.primary
},
)
}
}
OutlinedTextField(
value = messageText,
onValueChange = onMessageChange,
@@ -564,14 +698,14 @@ private fun MessageInput(
IconButton(
onClick = onSend,
enabled = canSend,
enabled = canSend && !isUploading,
modifier = Modifier.size(40.dp),
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint =
if (canSend) {
if (canSend && !isUploading) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
@@ -625,3 +759,43 @@ private fun MessageInput(
}
}
}
/**
* Encrypts and uploads files, then sends each as a ChatMessageEncryptedFileHeaderEvent (kind 15)
* wrapped in GiftWrap for each recipient.
*/
private suspend fun sendEncryptedFiles(
files: List<File>,
roomKey: ChatroomKey,
account: IAccount,
cacheProvider: ICacheProvider,
) {
val orchestrator = DesktopUploadOrchestrator()
val server = DesktopPreferences.preferredBlossomServer
val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User }.map { it.toPTag() }
for (file in files) {
val cipher = AESGCM()
val result = orchestrator.uploadEncrypted(file, cipher, server, account.signer)
val url = result.blossom.url ?: continue
val template =
ChatMessageEncryptedFileHeaderEvent.build(
to = recipients,
url = url,
cipher = cipher,
mimeType = result.metadata.mimeType,
hash = result.encryptedHash,
size = result.encryptedSize,
dimension =
if (result.metadata.width != null && result.metadata.height != null) {
DimensionTag(result.metadata.width, result.metadata.height)
} else {
null
},
blurhash = result.metadata.blurhash,
originalHash = result.metadata.sha256,
)
account.sendNip17EncryptedFile(template)
}
}