fix(media): code review — dead code removal, perf fixes, bug fixes
- Remove unused: MediaAspectRatioCache, VideoPlayerState, resetZoom, onDoubleClick, delete/headUpload/createDeleteAuth, encryptAndUpload, EncryptedUploadResult, unused options params, formatAudioTime dupe - Fix VlcjPlayerPool.release() accumulating stale event listeners - Fix SaveMediaAction FileDialog running on IO instead of EDT - Pre-allocate video frame ByteArray to avoid ~240MB/s GC pressure - Pool audio players in VlcjPlayerPool instead of factory-per-instance - Remove listener in onDispose before returning player to pool - Fix acquire() race condition by moving poll inside synchronized - Reduce memory cache to 15%/256MB with weak references - Parallelize server health checks - Remove redundant Content-Type/Content-Length headers - Deduplicate BlurHashFetcher bitmap conversion via bufferedImageToSkiaBitmap - Hoist audioExtensions to top-level constant, rename allMediaUrls - Remove nonfunctional Save button and dead decryptedBytes state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
-36
@@ -1,36 +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.model
|
|
||||||
|
|
||||||
import androidx.collection.LruCache
|
|
||||||
|
|
||||||
object MediaAspectRatioCache {
|
|
||||||
private val cache = LruCache<String, Float>(1000)
|
|
||||||
|
|
||||||
fun get(url: String): Float? = cache[url]
|
|
||||||
|
|
||||||
fun put(
|
|
||||||
url: String,
|
|
||||||
ratio: Float,
|
|
||||||
) {
|
|
||||||
cache.put(url, ratio)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-2
@@ -42,7 +42,6 @@ import java.awt.image.BufferedImage
|
|||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
class DesktopBase64Fetcher(
|
class DesktopBase64Fetcher(
|
||||||
private val options: Options,
|
|
||||||
private val data: Uri,
|
private val data: Uri,
|
||||||
) : Fetcher {
|
) : Fetcher {
|
||||||
override suspend fun fetch(): FetchResult? =
|
override suspend fun fetch(): FetchResult? =
|
||||||
@@ -64,7 +63,7 @@ class DesktopBase64Fetcher(
|
|||||||
imageLoader: ImageLoader,
|
imageLoader: ImageLoader,
|
||||||
): Fetcher? =
|
): Fetcher? =
|
||||||
if (data.scheme == "data") {
|
if (data.scheme == "data") {
|
||||||
DesktopBase64Fetcher(options, data)
|
DesktopBase64Fetcher(data)
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-15
@@ -31,9 +31,6 @@ import coil3.key.Keyer
|
|||||||
import coil3.request.Options
|
import coil3.request.Options
|
||||||
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
|
import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder
|
||||||
import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage
|
import com.vitorpamplona.amethyst.commons.blurhash.toBufferedImage
|
||||||
import org.jetbrains.skia.Bitmap
|
|
||||||
import org.jetbrains.skia.ColorAlphaType
|
|
||||||
import org.jetbrains.skia.ImageInfo
|
|
||||||
|
|
||||||
data class BlurhashWrapper(
|
data class BlurhashWrapper(
|
||||||
val blurhash: String,
|
val blurhash: String,
|
||||||
@@ -41,23 +38,13 @@ data class BlurhashWrapper(
|
|||||||
|
|
||||||
@Stable
|
@Stable
|
||||||
class DesktopBlurHashFetcher(
|
class DesktopBlurHashFetcher(
|
||||||
private val options: Options,
|
|
||||||
private val data: BlurhashWrapper,
|
private val data: BlurhashWrapper,
|
||||||
) : Fetcher {
|
) : Fetcher {
|
||||||
override suspend fun fetch(): FetchResult? {
|
override suspend fun fetch(): FetchResult? {
|
||||||
val hash = data.blurhash
|
val hash = data.blurhash
|
||||||
val platformImage = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null
|
val platformImage = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null
|
||||||
val bufferedImage = platformImage.toBufferedImage()
|
val bufferedImage = platformImage.toBufferedImage()
|
||||||
|
val bitmap = bufferedImageToSkiaBitmap(bufferedImage)
|
||||||
val w = bufferedImage.width
|
|
||||||
val h = bufferedImage.height
|
|
||||||
val pixels = IntArray(w * h)
|
|
||||||
bufferedImage.getRGB(0, 0, w, h, pixels, 0, w)
|
|
||||||
|
|
||||||
val bitmap = Bitmap()
|
|
||||||
bitmap.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL))
|
|
||||||
bitmap.installPixels(convertArgbToBgra(pixels))
|
|
||||||
bitmap.setImmutable()
|
|
||||||
|
|
||||||
return ImageFetchResult(
|
return ImageFetchResult(
|
||||||
image = bitmap.asImage(true),
|
image = bitmap.asImage(true),
|
||||||
@@ -71,7 +58,7 @@ class DesktopBlurHashFetcher(
|
|||||||
data: BlurhashWrapper,
|
data: BlurhashWrapper,
|
||||||
options: Options,
|
options: Options,
|
||||||
imageLoader: ImageLoader,
|
imageLoader: ImageLoader,
|
||||||
): Fetcher = DesktopBlurHashFetcher(options, data)
|
): Fetcher = DesktopBlurHashFetcher(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
object BKeyer : Keyer<BlurhashWrapper> {
|
object BKeyer : Keyer<BlurhashWrapper> {
|
||||||
|
|||||||
+2
-2
@@ -54,11 +54,11 @@ object DesktopImageLoaderSetup {
|
|||||||
|
|
||||||
private fun newMemoryCache(): MemoryCache {
|
private fun newMemoryCache(): MemoryCache {
|
||||||
val maxMemory = Runtime.getRuntime().maxMemory()
|
val maxMemory = Runtime.getRuntime().maxMemory()
|
||||||
val cacheSize = (maxMemory * 0.25).toLong().coerceAtMost(512L * 1024 * 1024)
|
val cacheSize = (maxMemory * 0.15).toLong().coerceAtMost(256L * 1024 * 1024)
|
||||||
return MemoryCache
|
return MemoryCache
|
||||||
.Builder()
|
.Builder()
|
||||||
.maxSizeBytes(cacheSize)
|
.maxSizeBytes(cacheSize)
|
||||||
.strongReferencesEnabled(true)
|
.strongReferencesEnabled(false)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-65
@@ -20,82 +20,18 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.service.media
|
package com.vitorpamplona.amethyst.desktop.service.media
|
||||||
|
|
||||||
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopBlossomClient
|
|
||||||
import com.vitorpamplona.amethyst.desktop.service.upload.DesktopMediaMetadata
|
|
||||||
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
import com.vitorpamplona.quartz.utils.ciphers.AESGCM
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles encryption/decryption of media files for NIP-17 DMs.
|
* Handles decryption of media files for NIP-17 DMs.
|
||||||
* Uses AESGCM cipher from quartz (commonMain).
|
* Uses AESGCM cipher from quartz (commonMain).
|
||||||
*/
|
*/
|
||||||
object EncryptedMediaService {
|
object EncryptedMediaService {
|
||||||
private val httpClient = OkHttpClient()
|
private val httpClient = OkHttpClient()
|
||||||
private val blossomClient = DesktopBlossomClient()
|
|
||||||
|
|
||||||
data class EncryptedUploadResult(
|
|
||||||
val url: String,
|
|
||||||
val cipher: AESGCM,
|
|
||||||
val mimeType: String?,
|
|
||||||
val hash: String?,
|
|
||||||
val size: Int,
|
|
||||||
val dimensions: Pair<Int, Int>?,
|
|
||||||
val blurhash: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt a file and upload to Blossom.
|
|
||||||
* Returns the encrypted upload result with cipher details.
|
|
||||||
*/
|
|
||||||
suspend fun encryptAndUpload(
|
|
||||||
file: File,
|
|
||||||
serverBaseUrl: String,
|
|
||||||
authHeader: String?,
|
|
||||||
): EncryptedUploadResult =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val metadata = DesktopMediaMetadata.compute(file)
|
|
||||||
val cipher = AESGCM()
|
|
||||||
|
|
||||||
// Read file bytes and encrypt
|
|
||||||
val plainBytes = file.readBytes()
|
|
||||||
val encryptedBytes = cipher.encrypt(plainBytes)
|
|
||||||
|
|
||||||
// Write encrypted bytes to temp file for upload
|
|
||||||
val tempFile = File.createTempFile("encrypted_", ".enc")
|
|
||||||
tempFile.deleteOnExit()
|
|
||||||
tempFile.writeBytes(encryptedBytes)
|
|
||||||
|
|
||||||
try {
|
|
||||||
val result =
|
|
||||||
blossomClient.upload(
|
|
||||||
file = tempFile,
|
|
||||||
contentType = "application/octet-stream",
|
|
||||||
serverBaseUrl = serverBaseUrl,
|
|
||||||
authHeader = authHeader,
|
|
||||||
)
|
|
||||||
|
|
||||||
EncryptedUploadResult(
|
|
||||||
url = result.url ?: throw IllegalStateException("No URL in upload result"),
|
|
||||||
cipher = cipher,
|
|
||||||
mimeType = metadata.mimeType,
|
|
||||||
hash = metadata.sha256,
|
|
||||||
size = plainBytes.size,
|
|
||||||
dimensions =
|
|
||||||
if (metadata.width != null && metadata.height != null) {
|
|
||||||
metadata.width to metadata.height
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
blurhash = metadata.blurhash,
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
tempFile.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Download and decrypt an encrypted file from a URL.
|
* Download and decrypt an encrypted file from a URL.
|
||||||
|
|||||||
+69
-13
@@ -21,7 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.desktop.service.media
|
package com.vitorpamplona.amethyst.desktop.service.media
|
||||||
|
|
||||||
import uk.co.caprica.vlcj.factory.MediaPlayerFactory
|
import uk.co.caprica.vlcj.factory.MediaPlayerFactory
|
||||||
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
|
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
||||||
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
|
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
|
||||||
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface
|
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface
|
||||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
|
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
|
||||||
@@ -40,12 +40,17 @@ object VlcjPlayerPool {
|
|||||||
private val available = AtomicBoolean(false)
|
private val available = AtomicBoolean(false)
|
||||||
private var factory: MediaPlayerFactory? = null
|
private var factory: MediaPlayerFactory? = null
|
||||||
|
|
||||||
// Strong references to ALL created players (prevents GC crash)
|
// Video player pool
|
||||||
private val allPlayers = mutableListOf<EmbeddedMediaPlayer>()
|
private val allPlayers = mutableListOf<EmbeddedMediaPlayer>()
|
||||||
private val idlePlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
|
private val idlePlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
|
||||||
|
|
||||||
private const val MAX_POOL_SIZE = 3
|
private const val MAX_POOL_SIZE = 3
|
||||||
|
|
||||||
|
// Audio player pool (shared factory with --no-video)
|
||||||
|
private var audioFactory: MediaPlayerFactory? = null
|
||||||
|
private val allAudioPlayers = mutableListOf<MediaPlayer>()
|
||||||
|
private val idleAudioPlayers = ConcurrentLinkedQueue<MediaPlayer>()
|
||||||
|
private const val MAX_AUDIO_POOL_SIZE = 5
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the pool. Returns false if VLC is not installed.
|
* Initialize the pool. Returns false if VLC is not installed.
|
||||||
*/
|
*/
|
||||||
@@ -76,18 +81,15 @@ object VlcjPlayerPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Acquire a player from the pool or create a new one.
|
* Acquire a video player from the pool or create a new one.
|
||||||
* Returns null if VLC is not available or pool is at capacity.
|
* Returns null if VLC is not available or pool is at capacity.
|
||||||
*/
|
*/
|
||||||
fun acquire(): EmbeddedMediaPlayer? {
|
fun acquire(): EmbeddedMediaPlayer? {
|
||||||
if (!available.get()) return null
|
if (!available.get()) return null
|
||||||
val f = factory ?: return null
|
val f = factory ?: return null
|
||||||
|
|
||||||
// Reuse idle player
|
|
||||||
idlePlayers.poll()?.let { return it }
|
|
||||||
|
|
||||||
// Create new if under limit
|
|
||||||
synchronized(allPlayers) {
|
synchronized(allPlayers) {
|
||||||
|
idlePlayers.poll()?.let { return it }
|
||||||
if (allPlayers.size >= MAX_POOL_SIZE) return null
|
if (allPlayers.size >= MAX_POOL_SIZE) return null
|
||||||
return try {
|
return try {
|
||||||
val player = f.mediaPlayers().newEmbeddedMediaPlayer()
|
val player = f.mediaPlayers().newEmbeddedMediaPlayer()
|
||||||
@@ -100,21 +102,57 @@ object VlcjPlayerPool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a player to the pool for reuse. Stops playback first.
|
* Acquire an audio-only player from the pool.
|
||||||
|
* Uses a separate factory with --no-video for efficiency.
|
||||||
|
*/
|
||||||
|
fun acquireAudioPlayer(): MediaPlayer? {
|
||||||
|
if (!init()) return null
|
||||||
|
|
||||||
|
synchronized(allAudioPlayers) {
|
||||||
|
idleAudioPlayers.poll()?.let { return it }
|
||||||
|
if (allAudioPlayers.size >= MAX_AUDIO_POOL_SIZE) return null
|
||||||
|
|
||||||
|
val af =
|
||||||
|
audioFactory ?: try {
|
||||||
|
MediaPlayerFactory("--no-video", "--no-xlib").also { audioFactory = it }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val player = af.mediaPlayers().newMediaPlayer()
|
||||||
|
allAudioPlayers.add(player)
|
||||||
|
player
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a video player to the pool for reuse.
|
||||||
*/
|
*/
|
||||||
fun release(player: EmbeddedMediaPlayer) {
|
fun release(player: EmbeddedMediaPlayer) {
|
||||||
try {
|
try {
|
||||||
player.controls().stop()
|
player.controls().stop()
|
||||||
// Remove any event listeners to prevent stale callbacks
|
|
||||||
player.events().addMediaPlayerEventListener(
|
|
||||||
object : MediaPlayerEventAdapter() {},
|
|
||||||
)
|
|
||||||
idlePlayers.offer(player)
|
idlePlayers.offer(player)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Player may already be disposed
|
// Player may already be disposed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return an audio player to the pool for reuse.
|
||||||
|
*/
|
||||||
|
fun releaseAudioPlayer(player: MediaPlayer) {
|
||||||
|
try {
|
||||||
|
player.controls().stop()
|
||||||
|
idleAudioPlayers.offer(player)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Player may already be disposed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shut down the entire pool. Call on app exit.
|
* Shut down the entire pool. Call on app exit.
|
||||||
*/
|
*/
|
||||||
@@ -131,12 +169,30 @@ object VlcjPlayerPool {
|
|||||||
}
|
}
|
||||||
allPlayers.clear()
|
allPlayers.clear()
|
||||||
}
|
}
|
||||||
|
synchronized(allAudioPlayers) {
|
||||||
|
idleAudioPlayers.clear()
|
||||||
|
for (player in allAudioPlayers) {
|
||||||
|
try {
|
||||||
|
player.controls().stop()
|
||||||
|
player.release()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allAudioPlayers.clear()
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
factory?.release()
|
factory?.release()
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Ignore
|
// Ignore
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
audioFactory?.release()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
factory = null
|
factory = null
|
||||||
|
audioFactory = null
|
||||||
available.set(false)
|
available.set(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-9
@@ -36,15 +36,6 @@ object DesktopBlossomAuth {
|
|||||||
return encodeAuthHeader(event)
|
return encodeAuthHeader(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun createDeleteAuth(
|
|
||||||
hash: HexKey,
|
|
||||||
alt: String,
|
|
||||||
signer: NostrSigner,
|
|
||||||
): String {
|
|
||||||
val event = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer)
|
|
||||||
return encodeAuthHeader(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun encodeAuthHeader(event: BlossomAuthorizationEvent): String {
|
fun encodeAuthHeader(event: BlossomAuthorizationEvent): String {
|
||||||
val b64 = Base64.getEncoder().encodeToString(event.toJson().toByteArray())
|
val b64 = Base64.getEncoder().encodeToString(event.toJson().toByteArray())
|
||||||
return "Nostr $b64"
|
return "Nostr $b64"
|
||||||
|
|||||||
-39
@@ -59,8 +59,6 @@ class DesktopBlossomClient(
|
|||||||
.Builder()
|
.Builder()
|
||||||
.url(apiUrl)
|
.url(apiUrl)
|
||||||
.put(requestBody)
|
.put(requestBody)
|
||||||
.addHeader("Content-Length", file.length().toString())
|
|
||||||
.addHeader("Content-Type", contentType)
|
|
||||||
|
|
||||||
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
|
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
|
||||||
|
|
||||||
@@ -73,41 +71,4 @@ class DesktopBlossomClient(
|
|||||||
JsonMapper.fromJson<BlossomUploadResult>(it.body.string())
|
JsonMapper.fromJson<BlossomUploadResult>(it.body.string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun delete(
|
|
||||||
hash: String,
|
|
||||||
serverBaseUrl: String,
|
|
||||||
authHeader: String?,
|
|
||||||
): Boolean =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val apiUrl = serverBaseUrl.removeSuffix("/") + "/$hash"
|
|
||||||
val requestBuilder = Request.Builder().url(apiUrl).delete()
|
|
||||||
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
|
|
||||||
|
|
||||||
val response = okHttpClient.newCall(requestBuilder.build()).execute()
|
|
||||||
response.use { it.isSuccessful }
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun headUpload(
|
|
||||||
contentType: String,
|
|
||||||
contentLength: Long,
|
|
||||||
sha256: String,
|
|
||||||
serverBaseUrl: String,
|
|
||||||
authHeader: String?,
|
|
||||||
): Boolean =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val apiUrl = serverBaseUrl.removeSuffix("/") + "/upload"
|
|
||||||
val requestBuilder =
|
|
||||||
Request
|
|
||||||
.Builder()
|
|
||||||
.url(apiUrl)
|
|
||||||
.head()
|
|
||||||
.addHeader("X-Content-Type", contentType)
|
|
||||||
.addHeader("X-Content-Length", contentLength.toString())
|
|
||||||
.addHeader("X-SHA-256", sha256)
|
|
||||||
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
|
|
||||||
|
|
||||||
val response = okHttpClient.newCall(requestBuilder.build()).execute()
|
|
||||||
response.use { it.isSuccessful }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-15
@@ -39,7 +39,6 @@ import androidx.compose.material3.CircularProgressIndicator
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -71,7 +70,6 @@ fun ChatFileAttachment(
|
|||||||
val isImage = mimeType?.startsWith("image/") == true
|
val isImage = mimeType?.startsWith("image/") == true
|
||||||
|
|
||||||
var decryptedImage by remember { mutableStateOf<ImageBitmap?>(null) }
|
var decryptedImage by remember { mutableStateOf<ImageBitmap?>(null) }
|
||||||
var decryptedBytes by remember { mutableStateOf<ByteArray?>(null) }
|
|
||||||
var isLoading by remember { mutableStateOf(false) }
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
@@ -81,7 +79,6 @@ fun ChatFileAttachment(
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
try {
|
try {
|
||||||
val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce)
|
val bytes = EncryptedMediaService.downloadAndDecrypt(url, keyBytes, nonce)
|
||||||
decryptedBytes = bytes
|
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
val skImage = SkiaImage.makeFromEncoded(bytes)
|
val skImage = SkiaImage.makeFromEncoded(bytes)
|
||||||
decryptedImage = skImage.toComposeImageBitmap()
|
decryptedImage = skImage.toComposeImageBitmap()
|
||||||
@@ -173,18 +170,6 @@ fun ChatFileAttachment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save button for decrypted files
|
|
||||||
if (decryptedBytes != null) {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
// For encrypted files, we'd need to save decrypted bytes
|
|
||||||
// This is handled through the save action
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text("Save", style = MaterialTheme.typography.labelSmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-35
@@ -49,14 +49,14 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import uk.co.caprica.vlcj.factory.MediaPlayerFactory
|
|
||||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
||||||
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
|
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Audio-only player using VLCJ with no video surface.
|
* Audio-only player using VLCJ with pooled audio players.
|
||||||
* Creates its own MediaPlayerFactory with "--no-video" flag.
|
* Uses VlcjPlayerPool's shared audio factory instead of creating one per instance.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AudioPlayer(
|
fun AudioPlayer(
|
||||||
@@ -71,17 +71,13 @@ fun AudioPlayer(
|
|||||||
var player by remember { mutableStateOf<MediaPlayer?>(null) }
|
var player by remember { mutableStateOf<MediaPlayer?>(null) }
|
||||||
|
|
||||||
DisposableEffect(url) {
|
DisposableEffect(url) {
|
||||||
val factory =
|
val mp = VlcjPlayerPool.acquireAudioPlayer()
|
||||||
try {
|
if (mp == null) {
|
||||||
MediaPlayerFactory("--no-video", "--no-xlib")
|
vlcAvailable = false
|
||||||
} catch (_: Exception) {
|
return@DisposableEffect onDispose {}
|
||||||
vlcAvailable = false
|
}
|
||||||
return@DisposableEffect onDispose {}
|
|
||||||
}
|
|
||||||
|
|
||||||
val mp = factory.mediaPlayers().newMediaPlayer()
|
val listener =
|
||||||
|
|
||||||
mp.events().addMediaPlayerEventListener(
|
|
||||||
object : MediaPlayerEventAdapter() {
|
object : MediaPlayerEventAdapter() {
|
||||||
override fun playing(mediaPlayer: MediaPlayer) {
|
override fun playing(mediaPlayer: MediaPlayer) {
|
||||||
isPlaying = true
|
isPlaying = true
|
||||||
@@ -109,24 +105,15 @@ fun AudioPlayer(
|
|||||||
position = 0f
|
position = 0f
|
||||||
currentTime = 0L
|
currentTime = 0L
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
|
||||||
|
mp.events().addMediaPlayerEventListener(listener)
|
||||||
player = mp
|
player = mp
|
||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
player = null
|
player = null
|
||||||
try {
|
mp.events().removeMediaPlayerEventListener(listener)
|
||||||
mp.controls().stop()
|
VlcjPlayerPool.releaseAudioPlayer(mp)
|
||||||
mp.release()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
factory.release()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +179,7 @@ fun AudioPlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = formatAudioTime(currentTime),
|
text = formatTime(currentTime),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -203,15 +190,8 @@ fun AudioPlayer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = formatAudioTime(duration),
|
text = formatTime(duration),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatAudioTime(millis: Long): String {
|
|
||||||
val totalSeconds = millis / 1000
|
|
||||||
val minutes = totalSeconds / 60
|
|
||||||
val seconds = totalSeconds % 60
|
|
||||||
return "%d:%02d".format(minutes, seconds)
|
|
||||||
}
|
|
||||||
|
|||||||
+9
-17
@@ -58,13 +58,6 @@ import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32Buffe
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import org.jetbrains.skia.Image as SkiaImage
|
import org.jetbrains.skia.Image as SkiaImage
|
||||||
|
|
||||||
data class VideoPlayerState(
|
|
||||||
val isPlaying: Boolean = false,
|
|
||||||
val position: Float = 0f,
|
|
||||||
val duration: Long = 0L,
|
|
||||||
val currentTime: Long = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DesktopVideoPlayer(
|
fun DesktopVideoPlayer(
|
||||||
url: String,
|
url: String,
|
||||||
@@ -93,10 +86,9 @@ fun DesktopVideoPlayer(
|
|||||||
return@DisposableEffect onDispose {}
|
return@DisposableEffect onDispose {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skia bitmap for DirectRendering
|
// Skia bitmap for DirectRendering — pre-allocated to avoid per-frame GC
|
||||||
var skBitmap: Bitmap? = null
|
var skBitmap: Bitmap? = null
|
||||||
var videoWidth = 0
|
var pixelBytes: ByteArray? = null
|
||||||
var videoHeight = 0
|
|
||||||
|
|
||||||
val bufferFormatCallback =
|
val bufferFormatCallback =
|
||||||
object : BufferFormatCallback {
|
object : BufferFormatCallback {
|
||||||
@@ -104,17 +96,15 @@ fun DesktopVideoPlayer(
|
|||||||
sourceWidth: Int,
|
sourceWidth: Int,
|
||||||
sourceHeight: Int,
|
sourceHeight: Int,
|
||||||
): BufferFormat {
|
): BufferFormat {
|
||||||
videoWidth = sourceWidth
|
|
||||||
videoHeight = sourceHeight
|
|
||||||
if (sourceHeight > 0) {
|
if (sourceHeight > 0) {
|
||||||
aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat()
|
aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat()
|
||||||
}
|
}
|
||||||
// Allocate Skia bitmap
|
|
||||||
val bmp = Bitmap()
|
val bmp = Bitmap()
|
||||||
bmp.allocPixels(
|
bmp.allocPixels(
|
||||||
ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL),
|
ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL),
|
||||||
)
|
)
|
||||||
skBitmap = bmp
|
skBitmap = bmp
|
||||||
|
pixelBytes = ByteArray(sourceWidth * sourceHeight * 4)
|
||||||
return RV32BufferFormat(sourceWidth, sourceHeight)
|
return RV32BufferFormat(sourceWidth, sourceHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,9 +116,9 @@ fun DesktopVideoPlayer(
|
|||||||
val renderCallback =
|
val renderCallback =
|
||||||
RenderCallback { _, nativeBuffers, _ ->
|
RenderCallback { _, nativeBuffers, _ ->
|
||||||
val bmp = skBitmap ?: return@RenderCallback
|
val bmp = skBitmap ?: return@RenderCallback
|
||||||
|
val bytes = pixelBytes ?: return@RenderCallback
|
||||||
val buffer = nativeBuffers[0]
|
val buffer = nativeBuffers[0]
|
||||||
buffer.rewind()
|
buffer.rewind()
|
||||||
val bytes = ByteArray(buffer.remaining())
|
|
||||||
buffer.get(bytes)
|
buffer.get(bytes)
|
||||||
bmp.installPixels(bytes)
|
bmp.installPixels(bytes)
|
||||||
frame = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
|
frame = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
|
||||||
@@ -142,7 +132,7 @@ fun DesktopVideoPlayer(
|
|||||||
|
|
||||||
acquired.videoSurface().set(surface)
|
acquired.videoSurface().set(surface)
|
||||||
|
|
||||||
acquired.events().addMediaPlayerEventListener(
|
val listener =
|
||||||
object : MediaPlayerEventAdapter() {
|
object : MediaPlayerEventAdapter() {
|
||||||
override fun playing(mediaPlayer: MediaPlayer) {
|
override fun playing(mediaPlayer: MediaPlayer) {
|
||||||
isPlaying = true
|
isPlaying = true
|
||||||
@@ -170,8 +160,9 @@ fun DesktopVideoPlayer(
|
|||||||
position = 0f
|
position = 0f
|
||||||
currentTime = 0L
|
currentTime = 0L
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
acquired.events().addMediaPlayerEventListener(listener)
|
||||||
|
|
||||||
player = acquired
|
player = acquired
|
||||||
|
|
||||||
@@ -183,6 +174,7 @@ fun DesktopVideoPlayer(
|
|||||||
|
|
||||||
onDispose {
|
onDispose {
|
||||||
player = null
|
player = null
|
||||||
|
acquired.events().removeMediaPlayerEventListener(listener)
|
||||||
VlcjPlayerPool.release(acquired)
|
VlcjPlayerPool.release(acquired)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-10
@@ -38,19 +38,24 @@ object SaveMediaAction {
|
|||||||
suspend fun saveMedia(
|
suspend fun saveMedia(
|
||||||
url: String,
|
url: String,
|
||||||
suggestedFilename: String? = null,
|
suggestedFilename: String? = null,
|
||||||
): File? =
|
): File? {
|
||||||
withContext(Dispatchers.IO) {
|
val filename = suggestedFilename ?: url.substringAfterLast('/').substringBefore('?').ifBlank { "media" }
|
||||||
val filename = suggestedFilename ?: url.substringAfterLast('/').substringBefore('?').ifBlank { "media" }
|
|
||||||
|
|
||||||
val dialog =
|
// FileDialog must be shown on EDT
|
||||||
FileDialog(null as Frame?, "Save Media", FileDialog.SAVE).apply {
|
val file =
|
||||||
file = filename
|
withContext(Dispatchers.Main) {
|
||||||
}
|
val dialog =
|
||||||
dialog.isVisible = true
|
FileDialog(null as Frame?, "Save Media", FileDialog.SAVE).apply {
|
||||||
|
this.file = filename
|
||||||
|
}
|
||||||
|
dialog.isVisible = true
|
||||||
|
|
||||||
val dir = dialog.directory ?: return@withContext null
|
val dir = dialog.directory ?: return@withContext null
|
||||||
val file = File(dir, dialog.file ?: return@withContext null)
|
File(dir, dialog.file ?: return@withContext null)
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
// Download on IO
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val request = Request.Builder().url(url).build()
|
val request = Request.Builder().url(url).build()
|
||||||
val response = httpClient.newCall(request).execute()
|
val response = httpClient.newCall(request).execute()
|
||||||
@@ -67,4 +72,5 @@ object SaveMediaAction {
|
|||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -169,7 +169,7 @@ fun VideoControls(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatTime(millis: Long): String {
|
internal fun formatTime(millis: Long): String {
|
||||||
val totalSeconds = millis / 1000
|
val totalSeconds = millis / 1000
|
||||||
val minutes = totalSeconds / 60
|
val minutes = totalSeconds / 60
|
||||||
val seconds = totalSeconds % 60
|
val seconds = totalSeconds % 60
|
||||||
|
|||||||
-5
@@ -42,7 +42,6 @@ import coil3.compose.AsyncImage
|
|||||||
fun ZoomableImage(
|
fun ZoomableImage(
|
||||||
url: String,
|
url: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onDoubleClick: (() -> Unit)? = null,
|
|
||||||
) {
|
) {
|
||||||
var scale by remember { mutableFloatStateOf(1f) }
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
var offsetX by remember { mutableFloatStateOf(0f) }
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||||
@@ -94,7 +93,3 @@ fun ZoomableImage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetZoom(onReset: () -> Unit) {
|
|
||||||
onReset()
|
|
||||||
}
|
|
||||||
|
|||||||
+9
-8
@@ -57,6 +57,8 @@ import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
|||||||
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
|
import com.vitorpamplona.amethyst.desktop.ui.media.AudioPlayer
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
|
import com.vitorpamplona.amethyst.desktop.ui.media.DesktopVideoPlayer
|
||||||
|
|
||||||
|
private val AUDIO_EXTENSIONS = setOf("mp3", "ogg", "wav", "flac", "aac", "opus", "m4a")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Data class for displaying a note card.
|
* Data class for displaying a note card.
|
||||||
*/
|
*/
|
||||||
@@ -86,27 +88,26 @@ fun NoteCard(
|
|||||||
remember(urls) {
|
remember(urls) {
|
||||||
urls.withScheme.filter { RichTextParser.isImageUrl(it) }
|
urls.withScheme.filter { RichTextParser.isImageUrl(it) }
|
||||||
}
|
}
|
||||||
val allMediaUrls =
|
val videoAndAudioUrls =
|
||||||
remember(urls) {
|
remember(urls) {
|
||||||
urls.withScheme.filter { RichTextParser.isVideoUrl(it) }
|
urls.withScheme.filter { RichTextParser.isVideoUrl(it) }
|
||||||
}
|
}
|
||||||
val audioExtensions = setOf("mp3", "ogg", "wav", "flac", "aac", "opus", "m4a")
|
|
||||||
val audioUrls =
|
val audioUrls =
|
||||||
remember(allMediaUrls) {
|
remember(videoAndAudioUrls) {
|
||||||
allMediaUrls.filter { url ->
|
videoAndAudioUrls.filter { url ->
|
||||||
val ext =
|
val ext =
|
||||||
url
|
url
|
||||||
.substringAfterLast('.', "")
|
.substringAfterLast('.', "")
|
||||||
.substringBefore('?')
|
.substringBefore('?')
|
||||||
.lowercase()
|
.lowercase()
|
||||||
ext in audioExtensions
|
ext in AUDIO_EXTENSIONS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val videoUrls =
|
val videoUrls =
|
||||||
remember(allMediaUrls, audioUrls) {
|
remember(videoAndAudioUrls, audioUrls) {
|
||||||
allMediaUrls - audioUrls.toSet()
|
videoAndAudioUrls - audioUrls.toSet()
|
||||||
}
|
}
|
||||||
val mediaUrls = remember(imageUrls, allMediaUrls) { (imageUrls + allMediaUrls).toSet() }
|
val mediaUrls = remember(imageUrls, videoAndAudioUrls) { (imageUrls + videoAndAudioUrls).toSet() }
|
||||||
val strippedContent =
|
val strippedContent =
|
||||||
remember(note.content, mediaUrls) {
|
remember(note.content, mediaUrls) {
|
||||||
var text = note.content
|
var text = note.content
|
||||||
|
|||||||
+18
-9
@@ -60,6 +60,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.desktop.service.media.ServerHealthCheck
|
import com.vitorpamplona.amethyst.desktop.service.media.ServerHealthCheck
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -74,12 +75,16 @@ fun MediaServerSettings(
|
|||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var isChecking by remember { mutableStateOf(false) }
|
var isChecking by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Check health on first load
|
// Check health on first load (parallel)
|
||||||
LaunchedEffect(servers.toList()) {
|
LaunchedEffect(servers.toList()) {
|
||||||
for (server in servers) {
|
coroutineScope {
|
||||||
if (server !in serverStatuses) {
|
for (server in servers) {
|
||||||
val status = ServerHealthCheck.check(server)
|
if (server !in serverStatuses) {
|
||||||
serverStatuses[server] = status
|
launch {
|
||||||
|
val status = ServerHealthCheck.check(server)
|
||||||
|
serverStatuses[server] = status
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,10 +177,14 @@ fun MediaServerSettings(
|
|||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
isChecking = true
|
isChecking = true
|
||||||
for (server in servers) {
|
coroutineScope {
|
||||||
serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN
|
for (server in servers) {
|
||||||
val status = ServerHealthCheck.check(server)
|
launch {
|
||||||
serverStatuses[server] = status
|
serverStatuses[server] = ServerHealthCheck.ServerStatus.UNKNOWN
|
||||||
|
val status = ServerHealthCheck.check(server)
|
||||||
|
serverStatuses[server] = status
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
isChecking = false
|
isChecking = false
|
||||||
}
|
}
|
||||||
|
|||||||
-65
@@ -35,7 +35,6 @@ import java.io.File
|
|||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertFailsWith
|
import kotlin.test.assertFailsWith
|
||||||
import kotlin.test.assertFalse
|
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class DesktopBlossomClientTest {
|
class DesktopBlossomClientTest {
|
||||||
@@ -142,70 +141,6 @@ class DesktopBlossomClientTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deleteSuccessReturnsTrue() =
|
|
||||||
runTest {
|
|
||||||
val client = DesktopBlossomClient(mockOkHttp(200))
|
|
||||||
|
|
||||||
val result =
|
|
||||||
client.delete(
|
|
||||||
hash = "abc123",
|
|
||||||
serverBaseUrl = "https://blossom.example.com",
|
|
||||||
authHeader = "Nostr xyz",
|
|
||||||
)
|
|
||||||
|
|
||||||
assertTrue(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deleteFailureReturnsFalse() =
|
|
||||||
runTest {
|
|
||||||
val client = DesktopBlossomClient(mockOkHttp(404))
|
|
||||||
|
|
||||||
val result =
|
|
||||||
client.delete(
|
|
||||||
hash = "abc123",
|
|
||||||
serverBaseUrl = "https://blossom.example.com",
|
|
||||||
authHeader = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertFalse(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun headUploadSuccessReturnsTrue() =
|
|
||||||
runTest {
|
|
||||||
val client = DesktopBlossomClient(mockOkHttp(200))
|
|
||||||
|
|
||||||
val result =
|
|
||||||
client.headUpload(
|
|
||||||
contentType = "image/png",
|
|
||||||
contentLength = 1024,
|
|
||||||
sha256 = "abc123",
|
|
||||||
serverBaseUrl = "https://blossom.example.com",
|
|
||||||
authHeader = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertTrue(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun headUploadFailureReturnsFalse() =
|
|
||||||
runTest {
|
|
||||||
val client = DesktopBlossomClient(mockOkHttp(403))
|
|
||||||
|
|
||||||
val result =
|
|
||||||
client.headUpload(
|
|
||||||
contentType = "image/png",
|
|
||||||
contentLength = 1024,
|
|
||||||
sha256 = "abc123",
|
|
||||||
serverBaseUrl = "https://blossom.example.com",
|
|
||||||
authHeader = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertFalse(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun uploadSendsAuthorizationHeader() =
|
fun uploadSendsAuthorizationHeader() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
Reference in New Issue
Block a user