refactor: promote desktop upload classes to commons/jvmMain

Moves five JVM-only upload helpers from desktopApp into commons so
the CLI can reuse the same Blossom upload + AES-GCM encryption path
without depending on :desktopApp:

  desktop/service/upload/DesktopBlossomAuth.kt        → commons/service/upload/BlossomAuth.kt
  desktop/service/upload/DesktopBlossomClient.kt      → commons/service/upload/BlossomClient.kt
  desktop/service/upload/DesktopMediaCompressor.kt    → commons/service/upload/MediaCompressor.kt
  desktop/service/upload/DesktopMediaMetadata.kt      → commons/service/upload/MediaMetadata.kt
  desktop/service/upload/DesktopUploadOrchestrator.kt → commons/service/upload/UploadOrchestrator.kt

API tweaks:
- Drop the `Desktop` prefix on every class.
- Rename the `DesktopMediaMetadata` object to `MediaMetadataReader`
  to avoid colliding with the `MediaMetadata` data class in the same
  package.
- BlossomClient no longer reaches into desktop's `DesktopHttpClient`.
  Callers pass an `OkHttpClient` (or use the default one-shot client);
  desktop continues to inject its Tor-aware client.
- Tighten BlossomClient's response handling — the OkHttp version on
  commons' classpath has a nullable `Response.body`, so explicitly
  null-check it.

Mechanical updates to keep desktop building:
- ComposeNoteDialog / ChatPane / DesktopUploadTracker switch to the
  new commons imports.
- DimensionTag-from-MediaMetadata: cross-module smart cast no longer
  works on the public `width`/`height` properties — replace with
  `?.let { … }` chains.
- commons/jvmMain now declares `commons-imaging` (used only by
  MediaCompressor's EXIF stripping).

Tests come along too: file names follow the renamed classes
(spotless ktlint enforces this).

Next commit will add `amy dm send-file --file PATH --server URL`,
which calls `UploadOrchestrator.uploadEncrypted(...)` directly.
This commit is contained in:
Claude
2026-04-23 19:47:41 +00:00
parent 305ce20b52
commit a7c1d45d93
14 changed files with 99 additions and 84 deletions
@@ -1,43 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.upload
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import java.util.Base64
object DesktopBlossomAuth {
suspend fun createUploadAuth(
hash: HexKey,
size: Long,
alt: String,
signer: NostrSigner,
): String {
val event = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer)
return encodeAuthHeader(event)
}
fun encodeAuthHeader(event: BlossomAuthorizationEvent): String {
val b64 = Base64.getEncoder().encodeToString(event.toJson().toByteArray())
return "Nostr $b64"
}
}
@@ -1,109 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.upload
import com.vitorpamplona.amethyst.desktop.network.DesktopHttpClient
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
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
class DesktopBlossomClient(
private val clientOverride: OkHttpClient? = null,
) {
private val okHttpClient: OkHttpClient get() = clientOverride ?: DesktopHttpClient.currentClient()
suspend fun upload(
file: File,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
val requestBody =
object : RequestBody() {
override fun contentType() = contentType.toMediaType()
override fun contentLength() = file.length()
override fun writeTo(sink: BufferedSink) {
file.inputStream().source().use(sink::writeAll)
}
}
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) {
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) {
val reason = it.headers["X-Reason"] ?: it.code.toString()
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
}
JsonMapper.fromJson<BlossomUploadResult>(it.body.string())
}
}
}
@@ -1,50 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.upload
import org.apache.commons.imaging.Imaging
import org.apache.commons.imaging.formats.jpeg.exif.ExifRewriter
import java.io.ByteArrayOutputStream
import java.io.File
object DesktopMediaCompressor {
fun stripExif(file: File): File {
if (!file.name.lowercase().let { it.endsWith(".jpg") || it.endsWith(".jpeg") }) {
return file
}
return try {
val bytes = file.readBytes()
// Check if it has EXIF data
val metadata = Imaging.getMetadata(bytes)
if (metadata == null) return file
val baos = ByteArrayOutputStream()
ExifRewriter().removeExifMetadata(bytes, baos)
val stripped = File.createTempFile("stripped_", ".jpg")
stripped.writeBytes(baos.toByteArray())
stripped.deleteOnExit()
stripped
} catch (_: Exception) {
file
}
}
}
@@ -1,95 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.upload
import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash
import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage
import com.vitorpamplona.amethyst.commons.thumbhash.toThumbhash
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.utils.sha256.sha256
import java.io.File
import javax.imageio.ImageIO
data class MediaMetadata(
val sha256: String,
val size: Long,
val mimeType: String,
val width: Int? = null,
val height: Int? = null,
val blurhash: String? = null,
val thumbhash: String? = null,
)
object DesktopMediaMetadata {
fun compute(file: File): MediaMetadata {
val bytes = file.readBytes()
val hash = sha256(bytes).toHexKey()
val mimeType = guessMimeType(file)
var width: Int? = null
var height: Int? = null
var blurhash: String? = null
var thumbhash: String? = null
if (mimeType.startsWith("image/")) {
try {
val image = ImageIO.read(file)
if (image != null) {
width = image.width
height = image.height
val platformImage = image.toPlatformImage()
blurhash = runCatching { platformImage.toBlurhash() }.getOrNull()
thumbhash = runCatching { platformImage.toThumbhash() }.getOrNull()
}
} catch (_: Exception) {
}
}
return MediaMetadata(
sha256 = hash,
size = bytes.size.toLong(),
mimeType = mimeType,
width = width,
height = height,
blurhash = blurhash,
thumbhash = thumbhash,
)
}
fun guessMimeType(file: File): String {
val ext = file.extension.lowercase()
return when (ext) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"webp" -> "image/webp"
"svg" -> "image/svg+xml"
"avif" -> "image/avif"
"mp4" -> "video/mp4"
"webm" -> "video/webm"
"mov" -> "video/quicktime"
"mp3" -> "audio/mpeg"
"ogg" -> "audio/ogg"
"wav" -> "audio/wav"
"flac" -> "audio/flac"
else -> "application/octet-stream"
}
}
}
@@ -1,136 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
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(
val blossom: BlossomUploadResult,
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(),
) {
suspend fun upload(
file: File,
alt: String?,
serverBaseUrl: String,
signer: NostrSigner,
stripExif: Boolean = true,
): UploadResult {
// 1. Strip EXIF if requested (JPEG only)
val processedFile =
if (stripExif) {
DesktopMediaCompressor.stripExif(file)
} else {
file
}
// 2. Compute metadata (hash, dimensions, blurhash)
val metadata = DesktopMediaMetadata.compute(processedFile)
// 3. Create auth header
val authHeader =
DesktopBlossomAuth.createUploadAuth(
hash = metadata.sha256,
size = metadata.size,
alt = alt ?: "Uploading ${file.name}",
signer = signer,
)
// 4. Upload
val result =
client.upload(
file = processedFile,
contentType = metadata.mimeType,
serverBaseUrl = serverBaseUrl,
authHeader = authHeader,
)
// 5. Clean up temp file if we stripped EXIF
if (processedFile != file) {
processedFile.delete()
}
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,7 @@
*/
package com.vitorpamplona.amethyst.desktop.service.upload
import com.vitorpamplona.amethyst.commons.service.upload.UploadResult
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -51,12 +51,12 @@ import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.upload.UploadResult
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadOrchestrator
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopUploadTracker
import com.vitorpamplona.amethyst.desktop.service.upload.UploadResult
import com.vitorpamplona.amethyst.desktop.ui.compose.ComposeRelayPicker
import com.vitorpamplona.amethyst.desktop.ui.compose.RelayPickerState
import com.vitorpamplona.amethyst.desktop.ui.media.ClipboardPasteHandler
@@ -103,7 +103,7 @@ fun ComposeNoteDialog(
val attachedFiles = remember { mutableStateListOf<File>() }
val uploadTracker = remember { DesktopUploadTracker() }
val uploadState by uploadTracker.state.collectAsState()
val orchestrator = remember { DesktopUploadOrchestrator() }
val orchestrator = remember { UploadOrchestrator() }
var selectedServer by remember { mutableStateOf(DesktopPreferences.preferredBlossomServer) }
var postAsPicture by remember { mutableStateOf(false) }
@@ -482,11 +482,11 @@ private fun buildPictureMetas(results: List<UploadResult>): List<com.vitorpamplo
mimeType = meta.mimeType,
blurhash = meta.blurhash,
dimension =
if (meta.width != null && meta.height != null) {
com.vitorpamplona.quartz.nip94FileMetadata.tags
.DimensionTag(meta.width, meta.height)
} else {
null
meta.width?.let { w ->
meta.height?.let { h ->
com.vitorpamplona.quartz.nip94FileMetadata.tags
.DimensionTag(w, h)
}
},
hash = meta.sha256,
size = meta.size.toInt(),
@@ -86,6 +86,7 @@ import androidx.compose.ui.window.PopupProperties
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.ui.chat.ChatMessageCompose
import com.vitorpamplona.amethyst.commons.ui.chat.ChatroomHeader
import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastBanner
@@ -97,7 +98,6 @@ 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
@@ -801,7 +801,7 @@ private suspend fun sendEncryptedFiles(
account: IAccount,
cacheProvider: ICacheProvider,
) {
val orchestrator = DesktopUploadOrchestrator()
val orchestrator = UploadOrchestrator()
val server = DesktopPreferences.preferredBlossomServer
val recipients = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) }.map { it.toPTag() }
@@ -819,10 +819,8 @@ private suspend fun sendEncryptedFiles(
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
result.metadata.width?.let { w ->
result.metadata.height?.let { h -> DimensionTag(w, h) }
},
blurhash = result.metadata.blurhash,
thumbhash = result.metadata.thumbhash,
@@ -37,7 +37,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class DesktopBlossomClientTest {
class BlossomClientTest {
private fun mockOkHttp(
responseCode: Int,
body: String = "",
@@ -67,7 +67,7 @@ class DesktopBlossomClientTest {
runTest {
val json =
"""{"url":"https://blossom.example.com/abc123.png","sha256":"abc123","size":1024}"""
val client = DesktopBlossomClient(mockOkHttp(200, json))
val client = BlossomClient(mockOkHttp(200, json))
val file = File.createTempFile("test_", ".png")
file.deleteOnExit()
@@ -94,7 +94,7 @@ class DesktopBlossomClientTest {
fun uploadFailureThrowsException() =
runTest {
val headers = Headers.headersOf("X-Reason", "File too large")
val client = DesktopBlossomClient(mockOkHttp(413, "", headers))
val client = BlossomClient(mockOkHttp(413, "", headers))
val file = File.createTempFile("test_", ".png")
file.deleteOnExit()
@@ -119,7 +119,7 @@ class DesktopBlossomClientTest {
@Test
fun uploadFailureUsesStatusCodeWhenNoXReason() =
runTest {
val client = DesktopBlossomClient(mockOkHttp(500))
val client = BlossomClient(mockOkHttp(500))
val file = File.createTempFile("test_", ".png")
file.deleteOnExit()
@@ -159,7 +159,7 @@ class DesktopBlossomClientTest {
.body("""{"url":"https://example.com/hash"}""".toResponseBody())
.build()
val client = DesktopBlossomClient(mockClient)
val client = BlossomClient(mockClient)
val file = File.createTempFile("test_", ".png")
file.deleteOnExit()
file.writeBytes(byteArrayOf(1))
@@ -199,7 +199,7 @@ class DesktopBlossomClientTest {
.body("""{"url":"https://example.com/hash"}""".toResponseBody())
.build()
val client = DesktopBlossomClient(mockClient)
val client = BlossomClient(mockClient)
val file = File.createTempFile("test_", ".png")
file.deleteOnExit()
file.writeBytes(byteArrayOf(1))
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.desktop.service.upload
import com.vitorpamplona.amethyst.commons.service.upload.MediaMetadata
import com.vitorpamplona.amethyst.commons.service.upload.UploadResult
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
@@ -27,7 +27,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class DesktopMediaCompressorTest {
class MediaCompressorTest {
@Test
fun stripExifReturnsSameFileForPng() {
val file = File.createTempFile("test_", ".png")
@@ -35,7 +35,7 @@ class DesktopMediaCompressorTest {
val img = BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB)
ImageIO.write(img, "png", file)
val result = DesktopMediaCompressor.stripExif(file)
val result = MediaCompressor.stripExif(file)
// Should return the same file object since it's not JPEG
assertEquals(file, result)
@@ -48,7 +48,7 @@ class DesktopMediaCompressorTest {
file.deleteOnExit()
file.writeText("not a jpeg")
val result = DesktopMediaCompressor.stripExif(file)
val result = MediaCompressor.stripExif(file)
assertEquals(file, result)
file.delete()
@@ -60,7 +60,7 @@ class DesktopMediaCompressorTest {
file.deleteOnExit()
file.writeBytes(byteArrayOf(0, 0, 0))
val result = DesktopMediaCompressor.stripExif(file)
val result = MediaCompressor.stripExif(file)
assertEquals(file, result)
file.delete()
@@ -71,7 +71,7 @@ class DesktopMediaCompressorTest {
// Create a minimal JPEG without EXIF
val file = createMinimalJpeg()
try {
val result = DesktopMediaCompressor.stripExif(file)
val result = MediaCompressor.stripExif(file)
// Should return the same file since there's no EXIF to strip
assertEquals(file, result)
} finally {
@@ -87,7 +87,7 @@ class DesktopMediaCompressorTest {
val img = BufferedImage(4, 4, BufferedImage.TYPE_INT_RGB)
ImageIO.write(img, "jpg", file)
val result = DesktopMediaCompressor.stripExif(file)
val result = MediaCompressor.stripExif(file)
// Result should be a valid file regardless
assertTrue(result.exists())
@@ -107,7 +107,7 @@ class DesktopMediaCompressorTest {
val img = BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB)
ImageIO.write(img, "jpg", file)
val result = DesktopMediaCompressor.stripExif(file)
val result = MediaCompressor.stripExif(file)
assertTrue(result.exists())
if (result != file) result.delete()
@@ -29,60 +29,60 @@ import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DesktopMediaMetadataTest {
class MediaMetadataReaderTest {
// --- guessMimeType ---
@Test
fun guessMimeTypeForJpeg() {
assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.jpg")))
assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.jpeg")))
assertEquals("image/jpeg", DesktopMediaMetadata.guessMimeType(File("photo.JPEG")))
assertEquals("image/jpeg", MediaMetadataReader.guessMimeType(File("photo.jpg")))
assertEquals("image/jpeg", MediaMetadataReader.guessMimeType(File("photo.jpeg")))
assertEquals("image/jpeg", MediaMetadataReader.guessMimeType(File("photo.JPEG")))
}
@Test
fun guessMimeTypeForPng() {
assertEquals("image/png", DesktopMediaMetadata.guessMimeType(File("image.png")))
assertEquals("image/png", MediaMetadataReader.guessMimeType(File("image.png")))
}
@Test
fun guessMimeTypeForGif() {
assertEquals("image/gif", DesktopMediaMetadata.guessMimeType(File("anim.gif")))
assertEquals("image/gif", MediaMetadataReader.guessMimeType(File("anim.gif")))
}
@Test
fun guessMimeTypeForWebp() {
assertEquals("image/webp", DesktopMediaMetadata.guessMimeType(File("image.webp")))
assertEquals("image/webp", MediaMetadataReader.guessMimeType(File("image.webp")))
}
@Test
fun guessMimeTypeForSvg() {
assertEquals("image/svg+xml", DesktopMediaMetadata.guessMimeType(File("icon.svg")))
assertEquals("image/svg+xml", MediaMetadataReader.guessMimeType(File("icon.svg")))
}
@Test
fun guessMimeTypeForAvif() {
assertEquals("image/avif", DesktopMediaMetadata.guessMimeType(File("photo.avif")))
assertEquals("image/avif", MediaMetadataReader.guessMimeType(File("photo.avif")))
}
@Test
fun guessMimeTypeForVideoFormats() {
assertEquals("video/mp4", DesktopMediaMetadata.guessMimeType(File("clip.mp4")))
assertEquals("video/webm", DesktopMediaMetadata.guessMimeType(File("clip.webm")))
assertEquals("video/quicktime", DesktopMediaMetadata.guessMimeType(File("clip.mov")))
assertEquals("video/mp4", MediaMetadataReader.guessMimeType(File("clip.mp4")))
assertEquals("video/webm", MediaMetadataReader.guessMimeType(File("clip.webm")))
assertEquals("video/quicktime", MediaMetadataReader.guessMimeType(File("clip.mov")))
}
@Test
fun guessMimeTypeForAudioFormats() {
assertEquals("audio/mpeg", DesktopMediaMetadata.guessMimeType(File("song.mp3")))
assertEquals("audio/ogg", DesktopMediaMetadata.guessMimeType(File("track.ogg")))
assertEquals("audio/wav", DesktopMediaMetadata.guessMimeType(File("sound.wav")))
assertEquals("audio/flac", DesktopMediaMetadata.guessMimeType(File("lossless.flac")))
assertEquals("audio/mpeg", MediaMetadataReader.guessMimeType(File("song.mp3")))
assertEquals("audio/ogg", MediaMetadataReader.guessMimeType(File("track.ogg")))
assertEquals("audio/wav", MediaMetadataReader.guessMimeType(File("sound.wav")))
assertEquals("audio/flac", MediaMetadataReader.guessMimeType(File("lossless.flac")))
}
@Test
fun guessMimeTypeForUnknownExtension() {
assertEquals("application/octet-stream", DesktopMediaMetadata.guessMimeType(File("data.xyz")))
assertEquals("application/octet-stream", DesktopMediaMetadata.guessMimeType(File("noext")))
assertEquals("application/octet-stream", MediaMetadataReader.guessMimeType(File("data.xyz")))
assertEquals("application/octet-stream", MediaMetadataReader.guessMimeType(File("noext")))
}
// --- compute ---
@@ -91,7 +91,7 @@ class DesktopMediaMetadataTest {
fun computeForPngImage() {
val file = createTempPng(width = 10, height = 5)
try {
val meta = DesktopMediaMetadata.compute(file)
val meta = MediaMetadataReader.compute(file)
assertEquals("image/png", meta.mimeType)
assertTrue(meta.size > 0)
@@ -110,7 +110,7 @@ class DesktopMediaMetadataTest {
file.deleteOnExit()
file.writeText("hello world")
try {
val meta = DesktopMediaMetadata.compute(file)
val meta = MediaMetadataReader.compute(file)
assertEquals("application/octet-stream", meta.mimeType)
assertEquals(11L, meta.size)
@@ -129,8 +129,8 @@ class DesktopMediaMetadataTest {
file.deleteOnExit()
file.writeBytes(byteArrayOf(1, 2, 3, 4, 5))
try {
val meta1 = DesktopMediaMetadata.compute(file)
val meta2 = DesktopMediaMetadata.compute(file)
val meta1 = MediaMetadataReader.compute(file)
val meta2 = MediaMetadataReader.compute(file)
assertEquals(meta1.sha256, meta2.sha256)
} finally {
file.delete()
@@ -143,7 +143,7 @@ class DesktopMediaMetadataTest {
file.deleteOnExit()
file.writeBytes(byteArrayOf(0, 0, 0))
try {
val meta = DesktopMediaMetadata.compute(file)
val meta = MediaMetadataReader.compute(file)
assertEquals("video/mp4", meta.mimeType)
assertNull(meta.width)
assertNull(meta.height)
@@ -36,24 +36,24 @@ import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class DesktopUploadOrchestratorTest {
class UploadOrchestratorTest {
@BeforeTest
fun setup() {
mockkObject(DesktopBlossomAuth)
mockkObject(BlossomAuth)
coEvery {
DesktopBlossomAuth.createUploadAuth(any(), any(), any(), any())
BlossomAuth.createUploadAuth(any(), any(), any(), any())
} returns "Nostr fakeAuthToken"
}
@AfterTest
fun teardown() {
unmockkObject(DesktopBlossomAuth)
unmockkObject(BlossomAuth)
}
@Test
fun uploadCallsClientWithCorrectParameters() =
runTest {
val mockClient = mockk<DesktopBlossomClient>()
val mockClient = mockk<BlossomClient>()
val fileSlot = slot<File>()
val contentTypeSlot = slot<String>()
val urlSlot = slot<String>()
@@ -72,7 +72,7 @@ class DesktopUploadOrchestratorTest {
size = 100,
)
val orchestrator = DesktopUploadOrchestrator(mockClient)
val orchestrator = UploadOrchestrator(mockClient)
val file = File.createTempFile("test_", ".png")
file.deleteOnExit()
@@ -112,7 +112,7 @@ class DesktopUploadOrchestratorTest {
@Test
fun uploadPassesSameFileWhenNoStripExif() =
runTest {
val mockClient = mockk<DesktopBlossomClient>()
val mockClient = mockk<BlossomClient>()
val fileSlot = slot<File>()
coEvery {
@@ -124,7 +124,7 @@ class DesktopUploadOrchestratorTest {
)
} returns BlossomUploadResult(url = "https://example.com/hash")
val orchestrator = DesktopUploadOrchestrator(mockClient)
val orchestrator = UploadOrchestrator(mockClient)
val file = File.createTempFile("test_", ".txt")
file.deleteOnExit()
@@ -150,13 +150,13 @@ class DesktopUploadOrchestratorTest {
@Test
fun uploadComputesMetadata() =
runTest {
val mockClient = mockk<DesktopBlossomClient>()
val mockClient = mockk<BlossomClient>()
coEvery {
mockClient.upload(any<java.io.File>(), any<String>(), any<String>(), any())
} returns BlossomUploadResult(url = "https://example.com/hash")
val orchestrator = DesktopUploadOrchestrator(mockClient)
val orchestrator = UploadOrchestrator(mockClient)
val file = File.createTempFile("test_", ".txt")
file.deleteOnExit()
@@ -185,7 +185,7 @@ class DesktopUploadOrchestratorTest {
@Test
fun uploadPassesAuthHeaderToClient() =
runTest {
val mockClient = mockk<DesktopBlossomClient>()
val mockClient = mockk<BlossomClient>()
val authSlot = slot<String?>()
coEvery {
@@ -197,7 +197,7 @@ class DesktopUploadOrchestratorTest {
)
} returns BlossomUploadResult(url = "https://example.com/hash")
val orchestrator = DesktopUploadOrchestrator(mockClient)
val orchestrator = UploadOrchestrator(mockClient)
val file = File.createTempFile("test_", ".txt")
file.deleteOnExit()