feat(media): Phase 2 — Desktop Blossom upload client
- DesktopBlossomClient: PUT /upload, DELETE, HEAD /upload (BUD-06) - DesktopBlossomAuth: kind 24242 auth with base64 Nostr header - DesktopMediaMetadata: SHA-256, dimensions, blurhash via ImageIO - DesktopMediaCompressor: lossless EXIF stripping (Commons Imaging) - DesktopUploadOrchestrator: strip EXIF → metadata → auth → upload - DesktopUploadTracker: StateFlow-based upload state tracking Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+52
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
val b64 = Base64.getEncoder().encodeToString(event.toJson().toByteArray())
|
||||||
|
return "Nostr $b64"
|
||||||
|
}
|
||||||
|
}
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
* 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.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 okio.BufferedSink
|
||||||
|
import okio.source
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class DesktopBlossomClient(
|
||||||
|
private val okHttpClient: OkHttpClient = OkHttpClient(),
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
.addHeader("Content-Length", file.length().toString())
|
||||||
|
.addHeader("Content-Type", contentType)
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* 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.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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if (mimeType.startsWith("image/")) {
|
||||||
|
try {
|
||||||
|
val image = ImageIO.read(file)
|
||||||
|
if (image != null) {
|
||||||
|
width = image.width
|
||||||
|
height = image.height
|
||||||
|
blurhash = image.toPlatformImage().toBlurhash()
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaMetadata(
|
||||||
|
sha256 = hash,
|
||||||
|
size = bytes.size.toLong(),
|
||||||
|
mimeType = mimeType,
|
||||||
|
width = width,
|
||||||
|
height = height,
|
||||||
|
blurhash = blurhash,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* 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.signers.NostrSigner
|
||||||
|
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
data class UploadResult(
|
||||||
|
val blossom: BlossomUploadResult,
|
||||||
|
val metadata: MediaMetadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* 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 kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
data class UploadState(
|
||||||
|
val isUploading: Boolean = false,
|
||||||
|
val fileName: String? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val result: UploadResult? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
class DesktopUploadTracker {
|
||||||
|
private val _state = MutableStateFlow(UploadState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun startUpload(fileName: String) {
|
||||||
|
_state.value = UploadState(isUploading = true, fileName = fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSuccess(result: UploadResult) {
|
||||||
|
_state.value = UploadState(isUploading = false, result = result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onError(error: String) {
|
||||||
|
_state.value = UploadState(isUploading = false, error = error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
_state.value = UploadState()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user