From e1c54f52e3d4d14672fa8c23544f6bda892dd2da Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 25 Oct 2025 21:24:12 +0200 Subject: [PATCH 01/10] stream file to calculate both hash and size without loading it all at once --- .../uploads/blossom/BlossomUploader.kt | 45 ++++++++++++++----- 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index d97367448..7f8cf4166 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -36,7 +36,6 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult import com.vitorpamplona.quartz.utils.RandomInstance -import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -48,9 +47,34 @@ import okio.BufferedSink import okio.source import java.io.File import java.io.InputStream +import java.security.MessageDigest import java.util.Base64 class BlossomUploader { + data class StreamInfo( + val hash: HexKey, + val size: Long, + ) + + /** + * Calculate SHA256 hash and size of a file by streaming it in chunks + * to avoid loading the entire file into memory. + */ + private fun calculateHashAndSize(inputStream: InputStream): StreamInfo { + val digest = MessageDigest.getInstance("SHA-256") + val buffer = ByteArray(8192) // 8KB buffer + var bytesRead: Int + var totalBytes = 0L + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + totalBytes += bytesRead + } + + val hash = digest.digest().toHexKey() + return StreamInfo(hash, totalBytes) + } + fun Context.getFileName(uri: Uri): String? = when (uri.scheme) { ContentResolver.SCHEME_CONTENT -> getContentFileName(uri) @@ -82,25 +106,24 @@ class BlossomUploader { val myContentType = contentType ?: contentResolver.getType(uri) val fileName = context.getFileName(uri) + // Calculate hash and size by streaming the file in chunks + // to avoid loading the entire file into memory val imageInputStreamForHash = contentResolver.openInputStream(uri) - val payload = - imageInputStreamForHash?.use { - it.readBytes() + checkNotNull(imageInputStreamForHash) { "Can't open the image input stream" } + + val streamInfo = + imageInputStreamForHash.use { + calculateHashAndSize(it) } - checkNotNull(payload) { "Can't open the image input stream" } - - val hash = sha256(payload).toHexKey() - val imageInputStream = contentResolver.openInputStream(uri) - checkNotNull(imageInputStream) { "Can't open the image input stream" } return imageInputStream.use { stream -> upload( stream, - hash, - payload.size, + streamInfo.hash, + streamInfo.size.toInt(), fileName, myContentType, alt, From 93994564f6790a9c0691ac13d407c51e905c7785 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 25 Oct 2025 23:22:31 +0200 Subject: [PATCH 02/10] Add streaming hash utility function to quartz multiplatform, follow the existing pool/worker design Change hashing in ImageDownloader.kt to use streaming --- .../service/uploads/ImageDownloader.kt | 115 ++++++++++++++++++ .../service/uploads/UploadOrchestrator.kt | 44 ++++--- .../uploads/blossom/BlossomUploader.kt | 30 +++-- .../quartz/utils/sha256/Sha256.jvmAndroid.kt | 16 +++ .../utils/sha256/Sha256Hasher.jvmAndroid.kt | 23 ++++ .../utils/sha256/Sha256Pool.jvmAndroid.kt | 21 ++++ 6 files changed, 217 insertions(+), 32 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt index 0407f799a..98426c9b5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.amethyst.service.uploads +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256Stream import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -34,6 +37,46 @@ class ImageDownloader { val contentType: String?, ) + /** + * Result of streaming verification - hash and metadata without storing full file + */ + class StreamVerification( + val hash: HexKey, + val size: Long, + val contentType: String?, + ) + + /** + * Stream download and calculate hash for verification without loading entire file into memory. + * This is memory-efficient for large files (videos, high-res images, etc.) + */ + suspend fun waitAndVerifyStream( + imageUrl: String, + okHttpClient: (url: String) -> OkHttpClient, + ): StreamVerification? = + withContext(Dispatchers.IO) { + var verification: StreamVerification? = null + var tentatives = 0 + + // Servers are usually not ready, so tries to download it for 15 times/seconds. + while (verification == null && tentatives < 15) { + verification = + try { + tryStreamAndVerify(imageUrl, okHttpClient) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + + if (verification == null) { + tentatives++ + delay(1000) + } + } + + return@withContext verification + } + suspend fun waitAndGetImage( imageUrl: String, okHttpClient: (url: String) -> OkHttpClient, @@ -61,6 +104,78 @@ class ImageDownloader { return@withContext imageData } + private suspend fun tryStreamAndVerify( + imageUrl: String, + okHttpClient: (url: String) -> OkHttpClient, + ): StreamVerification? = + withContext(Dispatchers.IO) { + // TODO: Migrate to OkHttp + HttpURLConnection.setFollowRedirects(true) + var url = URL(imageUrl) + var clientProxy = okHttpClient(imageUrl).proxy + var huc = + if (clientProxy != null) { + url.openConnection(clientProxy) as HttpURLConnection + } else { + url.openConnection() as HttpURLConnection + } + huc.instanceFollowRedirects = true + var responseCode = huc.responseCode + + if (responseCode in 300..400) { + val newUrl: String = huc.getHeaderField("Location") + + // open the new connection again + url = URL(newUrl) + clientProxy = okHttpClient(newUrl).proxy + huc = + if (clientProxy != null) { + url.openConnection(clientProxy) as HttpURLConnection + } else { + url.openConnection() as HttpURLConnection + } + responseCode = huc.responseCode + } + + return@withContext if (responseCode in 200..300) { + var totalBytes = 0L + + // Wrap the input stream to count bytes while hashing + val countingStream = + object : java.io.InputStream() { + val inner = huc.inputStream + + override fun read(): Int { + val byte = inner.read() + if (byte != -1) totalBytes++ + return byte + } + + override fun read( + b: ByteArray, + off: Int, + len: Int, + ): Int { + val bytesRead = inner.read(b, off, len) + if (bytesRead > 0) totalBytes += bytesRead + return bytesRead + } + + override fun close() = inner.close() + } + + val hash = countingStream.use { sha256Stream(it).toHexKey() } + + StreamVerification( + hash = hash, + size = totalBytes, + contentType = huc.headerFields.get("Content-Type")?.firstOrNull(), + ) + } else { + null + } + } + private suspend fun tryGetTheImage( imageUrl: String, okHttpClient: (url: String) -> OkHttpClient, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 42a763dee..3c087c587 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -231,34 +231,32 @@ class UploadOrchestrator { updateState(0.6, UploadingState.Downloading) - val imageData: ImageDownloader.Blob? = ImageDownloader().waitAndGetImage(uploadResult.url, okHttpClient) + // Use streaming verification for memory efficiency with large files + val verification: ImageDownloader.StreamVerification? = ImageDownloader().waitAndVerifyStream(uploadResult.url, okHttpClient) - if (imageData != null) { + if (verification != null) { updateState(0.8, UploadingState.Hashing) - val result = - FileHeader.prepare( - imageData.bytes, - uploadResult.type ?: localContentType ?: imageData.contentType, - uploadResult.dimension, + // Create FileHeader with hash from streaming verification + // Note: We skip blurhash/dimensions since we already have them from upload + val fileHeader = + FileHeader( + mimeType = uploadResult.type ?: localContentType ?: verification.contentType, + hash = verification.hash, + size = verification.size.toInt(), + dim = uploadResult.dimension, + blurHash = null, // Skip blurhash generation for verification ) - result.fold( - onSuccess = { - return finish( - OrchestratorResult.ServerResult( - it, - uploadResult.url, - uploadResult.magnet, - uploadResult.sha256, - originalContentType, - originalHash, - ), - ) - }, - onFailure = { - return error(R.string.could_not_prepare_local_file_to_upload, it.message ?: it.javaClass.simpleName) - }, + return finish( + OrchestratorResult.ServerResult( + fileHeader, + uploadResult.url, + uploadResult.magnet, + uploadResult.sha256, + originalContentType, + originalHash, + ), ) } else { return error(R.string.could_not_download_from_the_server) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 7f8cf4166..032b1d91e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.sha256.sha256Stream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -47,7 +48,6 @@ import okio.BufferedSink import okio.source import java.io.File import java.io.InputStream -import java.security.MessageDigest import java.util.Base64 class BlossomUploader { @@ -61,17 +61,29 @@ class BlossomUploader { * to avoid loading the entire file into memory. */ private fun calculateHashAndSize(inputStream: InputStream): StreamInfo { - val digest = MessageDigest.getInstance("SHA-256") - val buffer = ByteArray(8192) // 8KB buffer - var bytesRead: Int var totalBytes = 0L - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - digest.update(buffer, 0, bytesRead) - totalBytes += bytesRead - } + // Wrap the input stream to count bytes while hashing + val countingStream = + object : InputStream() { + override fun read(): Int { + val byte = inputStream.read() + if (byte != -1) totalBytes++ + return byte + } - val hash = digest.digest().toHexKey() + override fun read( + b: ByteArray, + off: Int, + len: Int, + ): Int { + val bytesRead = inputStream.read(b, off, len) + if (bytesRead > 0) totalBytes += bytesRead + return bytesRead + } + } + + val hash = sha256Stream(countingStream).toHexKey() return StreamInfo(hash, totalBytes) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt index c71dae98e..c19a70269 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt @@ -20,6 +20,22 @@ */ package com.vitorpamplona.quartz.utils.sha256 +import java.io.InputStream + val pool = Sha256Pool(5) // max parallel operations actual fun sha256(data: ByteArray) = pool.hash(data) + +/** + * Calculate SHA256 hash by streaming the input in chunks. + * This avoids loading the entire input into memory at once. + * Useful for hashing large files without running out of memory. + * + * @param inputStream The input stream to hash + * @param bufferSize Size of chunks to read (default 8KB) + * @return SHA256 hash bytes + */ +fun sha256Stream( + inputStream: InputStream, + bufferSize: Int = 8192, +) = pool.hashStream(inputStream, bufferSize) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt index bd4709780..9289c678b 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.utils.sha256 +import java.io.InputStream import java.security.MessageDigest class Sha256Hasher { @@ -30,4 +31,26 @@ class Sha256Hasher { fun digest(byteArray: ByteArray) = digest.digest(byteArray) fun reset() = digest.reset() + + /** + * Calculate SHA256 hash by streaming the input in chunks. + * This avoids loading the entire input into memory at once. + * + * @param inputStream The input stream to hash + * @param bufferSize Size of chunks to read (default 8KB) + * @return SHA256 hash bytes + */ + fun hashStream( + inputStream: InputStream, + bufferSize: Int = 8192, + ): ByteArray { + val buffer = ByteArray(bufferSize) + var bytesRead: Int + + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + + return digest.digest().also { digest.reset() } + } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.jvmAndroid.kt index 6c76f66d4..8efa3c65c 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.jvmAndroid.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.utils.sha256 import com.vitorpamplona.quartz.utils.Log +import java.io.InputStream import java.util.concurrent.ArrayBlockingQueue class Sha256Pool( @@ -54,4 +55,24 @@ class Sha256Pool( release(hasher) } } + + /** + * Calculate SHA256 hash by streaming the input in chunks. + * This avoids loading the entire input into memory at once. + * + * @param inputStream The input stream to hash + * @param bufferSize Size of chunks to read (default 8KB) + * @return SHA256 hash bytes + */ + fun hashStream( + inputStream: InputStream, + bufferSize: Int = 8192, + ): ByteArray { + val hasher = acquire() + try { + return hasher.hashStream(inputStream, bufferSize) + } finally { + release(hasher) + } + } } From 2332623cdeb70a1a6bbf460f1400c8248dbc0326 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 25 Oct 2025 23:40:31 +0200 Subject: [PATCH 03/10] Create a CountingInputStream utility to avoid duplication Prevent INT overflow in BlossomUploader.kt Connection Cleanup in ImageDownloader.kt Added try-finally in Sha256Hasher.jvmAndroid.kt --- .../service/uploads/ImageDownloader.kt | 52 ++++++------------ .../uploads/blossom/BlossomUploader.kt | 39 +++++--------- .../sha256/CountingInputStream.jvmAndroid.kt | 53 +++++++++++++++++++ .../quartz/utils/sha256/Sha256.jvmAndroid.kt | 16 +++--- .../utils/sha256/Sha256Hasher.jvmAndroid.kt | 15 ++++-- 5 files changed, 103 insertions(+), 72 deletions(-) create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/CountingInputStream.jvmAndroid.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt index 98426c9b5..4a1f60643 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.utils.sha256.sha256Stream +import com.vitorpamplona.quartz.utils.sha256.sha256StreamWithCount import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -137,42 +137,24 @@ class ImageDownloader { responseCode = huc.responseCode } - return@withContext if (responseCode in 200..300) { - var totalBytes = 0L - - // Wrap the input stream to count bytes while hashing - val countingStream = - object : java.io.InputStream() { - val inner = huc.inputStream - - override fun read(): Int { - val byte = inner.read() - if (byte != -1) totalBytes++ - return byte + return@withContext try { + if (responseCode in 200..300) { + val (hash, totalBytes) = + huc.inputStream.use { + sha256StreamWithCount(it) } - override fun read( - b: ByteArray, - off: Int, - len: Int, - ): Int { - val bytesRead = inner.read(b, off, len) - if (bytesRead > 0) totalBytes += bytesRead - return bytesRead - } - - override fun close() = inner.close() - } - - val hash = countingStream.use { sha256Stream(it).toHexKey() } - - StreamVerification( - hash = hash, - size = totalBytes, - contentType = huc.headerFields.get("Content-Type")?.firstOrNull(), - ) - } else { - null + StreamVerification( + hash = hash.toHexKey(), + size = totalBytes, + contentType = huc.headerFields.get("Content-Type")?.firstOrNull(), + ) + } else { + null + } + } finally { + // Always disconnect to release connection resources + huc.disconnect() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 032b1d91e..0cef56e1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -36,7 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult import com.vitorpamplona.quartz.utils.RandomInstance -import com.vitorpamplona.quartz.utils.sha256.sha256Stream +import com.vitorpamplona.quartz.utils.sha256.sha256StreamWithCount import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -61,30 +61,8 @@ class BlossomUploader { * to avoid loading the entire file into memory. */ private fun calculateHashAndSize(inputStream: InputStream): StreamInfo { - var totalBytes = 0L - - // Wrap the input stream to count bytes while hashing - val countingStream = - object : InputStream() { - override fun read(): Int { - val byte = inputStream.read() - if (byte != -1) totalBytes++ - return byte - } - - override fun read( - b: ByteArray, - off: Int, - len: Int, - ): Int { - val bytesRead = inputStream.read(b, off, len) - if (bytesRead > 0) totalBytes += bytesRead - return bytesRead - } - } - - val hash = sha256Stream(countingStream).toHexKey() - return StreamInfo(hash, totalBytes) + val (hash, size) = sha256StreamWithCount(inputStream) + return StreamInfo(hash.toHexKey(), size) } fun Context.getFileName(uri: Uri): String? = @@ -132,10 +110,19 @@ class BlossomUploader { checkNotNull(imageInputStream) { "Can't open the image input stream" } return imageInputStream.use { stream -> + // Validate file size to prevent overflow when converting to Int + val sizeInt = + streamInfo.size.let { + require(it <= Int.MAX_VALUE) { + "File too large: ${it / 1_048_576}MB exceeds maximum size of ${Int.MAX_VALUE / 1_048_576}MB" + } + it.toInt() + } + upload( stream, streamInfo.hash, - streamInfo.size.toInt(), + sizeInt, fileName, myContentType, alt, diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/CountingInputStream.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/CountingInputStream.jvmAndroid.kt new file mode 100644 index 000000000..b5107061e --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/CountingInputStream.jvmAndroid.kt @@ -0,0 +1,53 @@ +/** + * 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.quartz.utils.sha256 + +import java.io.FilterInputStream +import java.io.InputStream + +class CountingInputStream( + inputStream: InputStream, +) : FilterInputStream(inputStream) { + var bytesRead: Long = 0 + private set + + override fun read(): Int { + val byte = super.read() + if (byte != -1) bytesRead++ + return byte + } + + override fun read( + b: ByteArray, + off: Int, + len: Int, + ): Int { + val count = super.read(b, off, len) + if (count > 0) bytesRead += count + return count + } + + override fun skip(n: Long): Long { + val skipped = super.skip(n) + bytesRead += skipped + return skipped + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt index c19a70269..17234cfa9 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt @@ -27,15 +27,19 @@ val pool = Sha256Pool(5) // max parallel operations actual fun sha256(data: ByteArray) = pool.hash(data) /** - * Calculate SHA256 hash by streaming the input in chunks. - * This avoids loading the entire input into memory at once. - * Useful for hashing large files without running out of memory. + * Calculate SHA256 hash while counting bytes read from the stream. + * Returns both the hash and the number of bytes processed. + * This is more efficient than reading the stream twice. * * @param inputStream The input stream to hash * @param bufferSize Size of chunks to read (default 8KB) - * @return SHA256 hash bytes + * @return Pair of (hash bytes, bytes read count) */ -fun sha256Stream( +fun sha256StreamWithCount( inputStream: InputStream, bufferSize: Int = 8192, -) = pool.hashStream(inputStream, bufferSize) +): Pair { + val countingStream = CountingInputStream(inputStream) + val hash = pool.hashStream(countingStream, bufferSize) + return Pair(hash, countingStream.bytesRead) +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt index 9289c678b..04354963f 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt @@ -45,12 +45,17 @@ class Sha256Hasher { bufferSize: Int = 8192, ): ByteArray { val buffer = ByteArray(bufferSize) - var bytesRead: Int + try { + var bytesRead: Int - while (inputStream.read(buffer).also { bytesRead = it } != -1) { - digest.update(buffer, 0, bytesRead) + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + + return digest.digest() + } finally { + // Always reset digest to prevent contaminating the pool on exceptions + digest.reset() } - - return digest.digest().also { digest.reset() } } } From d01f6a21780ff7e1fa87c5f17402bb84403eaa5b Mon Sep 17 00:00:00 2001 From: davotoula Date: Sat, 25 Oct 2025 23:54:56 +0200 Subject: [PATCH 04/10] Change length to Long from Int: avoids potential overflow, Long seems to be used everywhere else --- .../uploads/blossom/BlossomUploader.kt | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 0cef56e1b..90033b4ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -96,8 +96,6 @@ class BlossomUploader { val myContentType = contentType ?: contentResolver.getType(uri) val fileName = context.getFileName(uri) - // Calculate hash and size by streaming the file in chunks - // to avoid loading the entire file into memory val imageInputStreamForHash = contentResolver.openInputStream(uri) checkNotNull(imageInputStreamForHash) { "Can't open the image input stream" } @@ -107,22 +105,14 @@ class BlossomUploader { } val imageInputStream = contentResolver.openInputStream(uri) + checkNotNull(imageInputStream) { "Can't open the image input stream" } return imageInputStream.use { stream -> - // Validate file size to prevent overflow when converting to Int - val sizeInt = - streamInfo.size.let { - require(it <= Int.MAX_VALUE) { - "File too large: ${it / 1_048_576}MB exceeds maximum size of ${Int.MAX_VALUE / 1_048_576}MB" - } - it.toInt() - } - upload( stream, streamInfo.hash, - sizeInt, + streamInfo.size, fileName, myContentType, alt, @@ -143,7 +133,7 @@ class BlossomUploader { suspend fun upload( inputStream: InputStream, hash: HexKey, - length: Int, + length: Long, baseFileName: String?, contentType: String?, alt: String?, @@ -168,14 +158,14 @@ class BlossomUploader { object : RequestBody() { override fun contentType() = contentType?.toMediaType() - override fun contentLength() = length.toLong() + override fun contentLength() = length override fun writeTo(sink: BufferedSink) { inputStream.source().use(sink::writeAll) } } - httpAuth(hash, length.toLong(), alt?.let { "Uploading $it" } ?: "Uploading $fileName")?.let { + httpAuth(hash, length, alt?.let { "Uploading $it" } ?: "Uploading $fileName")?.let { requestBuilder.addHeader("Authorization", encodeAuth(it)) } From 4e31a9a5ac8e9fc0924903963587e3f50233664f Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 26 Oct 2025 00:09:17 +0200 Subject: [PATCH 05/10] update buffer to 64kb --- .../vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt | 4 ++-- .../quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt | 4 ++-- .../quartz/utils/sha256/Sha256Pool.jvmAndroid.kt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt index 17234cfa9..25fffbde9 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt @@ -32,12 +32,12 @@ actual fun sha256(data: ByteArray) = pool.hash(data) * This is more efficient than reading the stream twice. * * @param inputStream The input stream to hash - * @param bufferSize Size of chunks to read (default 8KB) + * @param bufferSize Size of chunks to read (default 64KB) * @return Pair of (hash bytes, bytes read count) */ fun sha256StreamWithCount( inputStream: InputStream, - bufferSize: Int = 8192, + bufferSize: Int = 65536, ): Pair { val countingStream = CountingInputStream(inputStream) val hash = pool.hashStream(countingStream, bufferSize) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt index 04354963f..aabd5ab00 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt @@ -37,12 +37,12 @@ class Sha256Hasher { * This avoids loading the entire input into memory at once. * * @param inputStream The input stream to hash - * @param bufferSize Size of chunks to read (default 8KB) + * @param bufferSize Size of chunks to read (default 64KB) * @return SHA256 hash bytes */ fun hashStream( inputStream: InputStream, - bufferSize: Int = 8192, + bufferSize: Int = 65536, ): ByteArray { val buffer = ByteArray(bufferSize) try { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.jvmAndroid.kt index 8efa3c65c..86cd0dbf9 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.jvmAndroid.kt @@ -61,12 +61,12 @@ class Sha256Pool( * This avoids loading the entire input into memory at once. * * @param inputStream The input stream to hash - * @param bufferSize Size of chunks to read (default 8KB) + * @param bufferSize Size of chunks to read (default 64KB) * @return SHA256 hash bytes */ fun hashStream( inputStream: InputStream, - bufferSize: Int = 8192, + bufferSize: Int = 65536, ): ByteArray { val hasher = acquire() try { From 7254dce3a97d0e209b40467ced72333a58620bcf Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 26 Oct 2025 10:25:40 +0100 Subject: [PATCH 06/10] Revert "update buffer to 64kb" This reverts commit 4e31a9a5ac8e9fc0924903963587e3f50233664f. --- .../vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt | 4 ++-- .../quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt | 4 ++-- .../quartz/utils/sha256/Sha256Pool.jvmAndroid.kt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt index 25fffbde9..17234cfa9 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256.jvmAndroid.kt @@ -32,12 +32,12 @@ actual fun sha256(data: ByteArray) = pool.hash(data) * This is more efficient than reading the stream twice. * * @param inputStream The input stream to hash - * @param bufferSize Size of chunks to read (default 64KB) + * @param bufferSize Size of chunks to read (default 8KB) * @return Pair of (hash bytes, bytes read count) */ fun sha256StreamWithCount( inputStream: InputStream, - bufferSize: Int = 65536, + bufferSize: Int = 8192, ): Pair { val countingStream = CountingInputStream(inputStream) val hash = pool.hashStream(countingStream, bufferSize) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt index aabd5ab00..04354963f 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Hasher.jvmAndroid.kt @@ -37,12 +37,12 @@ class Sha256Hasher { * This avoids loading the entire input into memory at once. * * @param inputStream The input stream to hash - * @param bufferSize Size of chunks to read (default 64KB) + * @param bufferSize Size of chunks to read (default 8KB) * @return SHA256 hash bytes */ fun hashStream( inputStream: InputStream, - bufferSize: Int = 65536, + bufferSize: Int = 8192, ): ByteArray { val buffer = ByteArray(bufferSize) try { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.jvmAndroid.kt index 86cd0dbf9..8efa3c65c 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/sha256/Sha256Pool.jvmAndroid.kt @@ -61,12 +61,12 @@ class Sha256Pool( * This avoids loading the entire input into memory at once. * * @param inputStream The input stream to hash - * @param bufferSize Size of chunks to read (default 64KB) + * @param bufferSize Size of chunks to read (default 8KB) * @return SHA256 hash bytes */ fun hashStream( inputStream: InputStream, - bufferSize: Int = 65536, + bufferSize: Int = 8192, ): ByteArray { val hasher = acquire() try { From eee1487883bc97c1ce079e876dfc2b705364f207 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 29 Oct 2025 12:08:14 +0100 Subject: [PATCH 07/10] added blurhash to blossom upload --- .../uploads/BlurhashMetadataCalculator.kt | 124 ++++++++++++++++++ .../amethyst/service/uploads/FileHeader.kt | 26 +--- .../service/uploads/MediaUploadResult.kt | 3 + .../service/uploads/UploadOrchestrator.kt | 2 +- .../uploads/blossom/BlossomUploader.kt | 39 ++++-- 5 files changed, 154 insertions(+), 40 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt new file mode 100644 index 000000000..c90473a47 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt @@ -0,0 +1,124 @@ +/** + * 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.service.uploads + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import android.net.Uri +import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash +import com.vitorpamplona.amethyst.service.images.BlurhashWrapper +import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag +import com.vitorpamplona.quartz.utils.Log + +object BlurhashMetadataCalculator { + fun shouldAttempt(mimeType: String?): Boolean = + mimeType?.let { + it.startsWith("image/", ignoreCase = true) || it.startsWith("video/", ignoreCase = true) + } ?: false + + fun computeFromBytes( + data: ByteArray, + mimeType: String?, + dimPrecomputed: DimensionTag?, + ): Pair = + when { + mimeType?.startsWith("image/", ignoreCase = true) == true -> { + val options = BitmapFactory.Options() + options.inPreferredConfig = Bitmap.Config.ARGB_8888 + val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, options) + val (blur, dim) = processBitmap(bitmap) + blur to (dim ?: dimPrecomputed) + } + mimeType?.startsWith("video/", ignoreCase = true) == true -> { + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(ByteArrayMediaDataSource(data)) + processRetriever(retriever, dimPrecomputed) + } finally { + retriever.release() + } + } + else -> null to dimPrecomputed + } + + fun computeFromUri( + context: Context, + uri: Uri, + mimeType: String?, + dimPrecomputed: DimensionTag? = null, + ): Pair? { + if (!shouldAttempt(mimeType)) return null + + return try { + when { + mimeType?.startsWith("image/", ignoreCase = true) == true -> + context.contentResolver.openInputStream(uri)?.use { stream -> + val options = BitmapFactory.Options() + options.inPreferredConfig = Bitmap.Config.ARGB_8888 + val bitmap = BitmapFactory.decodeStream(stream, null, options) + val (blur, dim) = processBitmap(bitmap) + blur to (dim ?: dimPrecomputed) + } ?: (null to dimPrecomputed) + + mimeType?.startsWith("video/", ignoreCase = true) == true -> { + val retriever = MediaMetadataRetriever() + try { + retriever.setDataSource(context, uri) + processRetriever(retriever, dimPrecomputed) + } finally { + retriever.release() + } + } + else -> null + } + } catch (e: Exception) { + Log.w("BlurhashMetadataCalc", "Failed to compute metadata from uri", e) + null + } + } + + private fun processBitmap(bitmap: Bitmap?): Pair = + if (bitmap != null) { + try { + val blurhash = BlurhashWrapper(bitmap.toBlurhash()) + blurhash to DimensionTag(bitmap.width, bitmap.height) + } finally { + bitmap.recycle() + } + } else { + null to null + } + + private fun processRetriever( + retriever: MediaMetadataRetriever, + dimPrecomputed: DimensionTag?, + ): Pair { + val dim = retriever.prepareDimFromVideo() ?: dimPrecomputed + val blurhash = retriever.getThumbnail()?.toBlurhash()?.let { BlurhashWrapper(it) } + return if (dim?.hasSize() == true) { + blurhash to dim + } else { + blurhash to null + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt index 29d86b669..cd12d86f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt @@ -20,11 +20,7 @@ */ package com.vitorpamplona.amethyst.service.uploads -import android.graphics.Bitmap -import android.graphics.BitmapFactory import android.media.MediaDataSource -import android.media.MediaMetadataRetriever -import com.vitorpamplona.amethyst.commons.blurhash.toBlurhash import com.vitorpamplona.amethyst.service.images.BlurhashWrapper import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -75,27 +71,7 @@ class FileHeader( val hash = sha256(data).toHexKey() val size = data.size - val (blurHash, dim) = - if (mimeType?.startsWith("image/") == true) { - val opt = BitmapFactory.Options() - opt.inPreferredConfig = Bitmap.Config.ARGB_8888 - val mBitmap = BitmapFactory.decodeByteArray(data, 0, data.size, opt) - Pair(BlurhashWrapper(mBitmap.toBlurhash()), DimensionTag(mBitmap.width, mBitmap.height)) - } else if (mimeType?.startsWith("video/") == true) { - val mediaMetadataRetriever = MediaMetadataRetriever() - mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data)) - - val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed - val blurhash = mediaMetadataRetriever.getThumbnail()?.toBlurhash()?.let { BlurhashWrapper(it) } - - if (newDim?.hasSize() == true) { - Pair(blurhash, newDim) - } else { - Pair(blurhash, null) - } - } else { - Pair(null, null) - } + val (blurHash, dim) = BlurhashMetadataCalculator.computeFromBytes(data, mimeType, dimPrecomputed) Result.success(FileHeader(mimeType, hash, size, dim, blurHash)) } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt index ad65e1756..b897d5dd9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.uploads +import com.vitorpamplona.amethyst.service.images.BlurhashWrapper import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag @@ -42,4 +43,6 @@ data class MediaUploadResult( val infohash: String? = null, // ipfs link val ipfs: String? = null, + // blurhash value for previews + val blurHash: BlurhashWrapper? = null, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 3c087c587..ca4311edd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -245,7 +245,7 @@ class UploadOrchestrator { hash = verification.hash, size = verification.size.toInt(), dim = uploadResult.dimension, - blurHash = null, // Skip blurhash generation for verification + blurHash = uploadResult.blurHash, ) return finish( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 90033b4ca..924b63eec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -28,6 +28,7 @@ import android.webkit.MimeTypeMap import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.service.uploads.BlurhashMetadataCalculator import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -104,25 +105,35 @@ class BlossomUploader { calculateHashAndSize(it) } + val localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType) + val imageInputStream = contentResolver.openInputStream(uri) checkNotNull(imageInputStream) { "Can't open the image input stream" } - return imageInputStream.use { stream -> - upload( - stream, - streamInfo.hash, - streamInfo.size, - fileName, - myContentType, - alt, - sensitiveContent, - serverBaseUrl, - okHttpClient, - httpAuth, - context, + val serverResult = + imageInputStream.use { stream -> + upload( + stream, + streamInfo.hash, + streamInfo.size, + fileName, + myContentType, + alt, + sensitiveContent, + serverBaseUrl, + okHttpClient, + httpAuth, + context, + ) + } + + return localMetadata?.let { (blur, dim) -> + serverResult.copy( + dimension = dim ?: serverResult.dimension, + blurHash = blur ?: serverResult.blurHash, ) - } + } ?: serverResult } fun encodeAuth(event: BlossomAuthorizationEvent): String { From 4b260cfc0788e5cfcb889d51167ecb38be70e992 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 2 Nov 2025 17:38:24 +0100 Subject: [PATCH 08/10] Add blurhash and dim to Nip96Uploader.kt --- .../service/uploads/nip96/Nip96Uploader.kt | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 1490c1d7d..9e17ed898 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -30,6 +30,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.checkNotInMainThread +import com.vitorpamplona.amethyst.service.uploads.BlurhashMetadataCalculator import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip01Core.core.JsonMapper @@ -106,24 +107,36 @@ class Nip96Uploader { val myContentType = contentType ?: contentResolver.getType(uri) val length = size ?: contentResolver.querySize(uri) ?: fileSize(uri) ?: 0 + val localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType) val imageInputStream = contentResolver.openInputStream(uri) checkNotNull(imageInputStream) { "Can't open the image input stream" } - return imageInputStream.use { stream -> - upload( - stream, - length, - myContentType, - alt, - sensitiveContent, - server, - okHttpClient, - onProgress, - httpAuth, - context, - ) - } + val serverResult = + imageInputStream.use { stream -> + upload( + stream, + length, + myContentType, + alt, + sensitiveContent, + server, + okHttpClient, + onProgress, + httpAuth, + context, + ) + } + + val merged = + localMetadata?.let { (blur, dim) -> + serverResult.copy( + dimension = dim ?: serverResult.dimension, + blurHash = blur ?: serverResult.blurHash, + ) + } ?: serverResult + + return merged } suspend fun upload( From 76be7b0d58bfcafef6d39eba760ee992bcbbe148 Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 2 Nov 2025 19:45:51 +0100 Subject: [PATCH 09/10] code simplification and deduplication --- .../uploads/BlurhashMetadataCalculator.kt | 45 ++++++++++------- .../service/uploads/UploadOrchestrator.kt | 50 +++++++++---------- .../uploads/blossom/BlossomUploader.kt | 26 +++------- .../service/uploads/nip96/Nip96Uploader.kt | 15 +++--- 4 files changed, 63 insertions(+), 73 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt index c90473a47..bfd3ab05d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt @@ -31,10 +31,24 @@ import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.utils.Log object BlurhashMetadataCalculator { - fun shouldAttempt(mimeType: String?): Boolean = - mimeType?.let { - it.startsWith("image/", ignoreCase = true) || it.startsWith("video/", ignoreCase = true) - } ?: false + private fun isImage(mimeType: String?) = mimeType?.startsWith("image/", ignoreCase = true) == true + + private fun isVideo(mimeType: String?) = mimeType?.startsWith("video/", ignoreCase = true) == true + + fun shouldAttempt(mimeType: String?): Boolean = isImage(mimeType) || isVideo(mimeType) + + private fun createBitmapOptions() = + BitmapFactory.Options().apply { + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + + private fun processImage( + bitmap: Bitmap?, + dimPrecomputed: DimensionTag?, + ): Pair { + val (blur, dim) = processBitmap(bitmap) + return blur to (dim ?: dimPrecomputed) + } fun computeFromBytes( data: ByteArray, @@ -42,14 +56,11 @@ object BlurhashMetadataCalculator { dimPrecomputed: DimensionTag?, ): Pair = when { - mimeType?.startsWith("image/", ignoreCase = true) == true -> { - val options = BitmapFactory.Options() - options.inPreferredConfig = Bitmap.Config.ARGB_8888 - val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, options) - val (blur, dim) = processBitmap(bitmap) - blur to (dim ?: dimPrecomputed) + isImage(mimeType) -> { + val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, createBitmapOptions()) + processImage(bitmap, dimPrecomputed) } - mimeType?.startsWith("video/", ignoreCase = true) == true -> { + isVideo(mimeType) -> { val retriever = MediaMetadataRetriever() try { retriever.setDataSource(ByteArrayMediaDataSource(data)) @@ -71,16 +82,13 @@ object BlurhashMetadataCalculator { return try { when { - mimeType?.startsWith("image/", ignoreCase = true) == true -> + isImage(mimeType) -> context.contentResolver.openInputStream(uri)?.use { stream -> - val options = BitmapFactory.Options() - options.inPreferredConfig = Bitmap.Config.ARGB_8888 - val bitmap = BitmapFactory.decodeStream(stream, null, options) - val (blur, dim) = processBitmap(bitmap) - blur to (dim ?: dimPrecomputed) + val bitmap = BitmapFactory.decodeStream(stream, null, createBitmapOptions()) + processImage(bitmap, dimPrecomputed) } ?: (null to dimPrecomputed) - mimeType?.startsWith("video/", ignoreCase = true) == true -> { + isVideo(mimeType) -> { val retriever = MediaMetadataRetriever() try { retriever.setDataSource(context, uri) @@ -89,6 +97,7 @@ object BlurhashMetadataCalculator { retriever.release() } } + else -> null } } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index ca4311edd..8426eca3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -232,35 +232,33 @@ class UploadOrchestrator { updateState(0.6, UploadingState.Downloading) // Use streaming verification for memory efficiency with large files - val verification: ImageDownloader.StreamVerification? = ImageDownloader().waitAndVerifyStream(uploadResult.url, okHttpClient) + val verification = + ImageDownloader().waitAndVerifyStream(uploadResult.url, okHttpClient) + ?: return error(R.string.could_not_download_from_the_server) - if (verification != null) { - updateState(0.8, UploadingState.Hashing) + updateState(0.8, UploadingState.Hashing) - // Create FileHeader with hash from streaming verification - // Note: We skip blurhash/dimensions since we already have them from upload - val fileHeader = - FileHeader( - mimeType = uploadResult.type ?: localContentType ?: verification.contentType, - hash = verification.hash, - size = verification.size.toInt(), - dim = uploadResult.dimension, - blurHash = uploadResult.blurHash, - ) - - return finish( - OrchestratorResult.ServerResult( - fileHeader, - uploadResult.url, - uploadResult.magnet, - uploadResult.sha256, - originalContentType, - originalHash, - ), + // Create FileHeader with hash from streaming verification + // Note: We skip blurhash/dimensions since we already have them from upload + val fileHeader = + FileHeader( + mimeType = uploadResult.type ?: localContentType ?: verification.contentType, + hash = verification.hash, + size = verification.size.toInt(), + dim = uploadResult.dimension, + blurHash = uploadResult.blurHash, ) - } else { - return error(R.string.could_not_download_from_the_server) - } + + return finish( + OrchestratorResult.ServerResult( + fileHeader, + uploadResult.url, + uploadResult.magnet, + uploadResult.sha256, + originalContentType, + originalHash, + ), + ) } sealed class OrchestratorResult { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 924b63eec..6483ff09e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -52,20 +52,6 @@ import java.io.InputStream import java.util.Base64 class BlossomUploader { - data class StreamInfo( - val hash: HexKey, - val size: Long, - ) - - /** - * Calculate SHA256 hash and size of a file by streaming it in chunks - * to avoid loading the entire file into memory. - */ - private fun calculateHashAndSize(inputStream: InputStream): StreamInfo { - val (hash, size) = sha256StreamWithCount(inputStream) - return StreamInfo(hash.toHexKey(), size) - } - fun Context.getFileName(uri: Uri): String? = when (uri.scheme) { ContentResolver.SCHEME_CONTENT -> getContentFileName(uri) @@ -100,23 +86,23 @@ class BlossomUploader { val imageInputStreamForHash = contentResolver.openInputStream(uri) checkNotNull(imageInputStreamForHash) { "Can't open the image input stream" } - val streamInfo = - imageInputStreamForHash.use { - calculateHashAndSize(it) + val (hash, size) = + imageInputStreamForHash.use { stream -> + val (hashBytes, totalBytes) = sha256StreamWithCount(stream) + hashBytes.toHexKey() to totalBytes } val localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType) val imageInputStream = contentResolver.openInputStream(uri) - checkNotNull(imageInputStream) { "Can't open the image input stream" } val serverResult = imageInputStream.use { stream -> upload( stream, - streamInfo.hash, - streamInfo.size, + hash, + size, fileName, myContentType, alt, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 9e17ed898..3bb80367f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -128,15 +128,12 @@ class Nip96Uploader { ) } - val merged = - localMetadata?.let { (blur, dim) -> - serverResult.copy( - dimension = dim ?: serverResult.dimension, - blurHash = blur ?: serverResult.blurHash, - ) - } ?: serverResult - - return merged + return localMetadata?.let { (blur, dim) -> + serverResult.copy( + dimension = dim ?: serverResult.dimension, + blurHash = blur ?: serverResult.blurHash, + ) + } ?: serverResult } suspend fun upload( From 942de501482eca2db4ed09364f2a13b056b5179b Mon Sep 17 00:00:00 2001 From: davotoula Date: Sun, 2 Nov 2025 20:37:20 +0100 Subject: [PATCH 10/10] code simplification and deduplication --- .../service/uploads/ImageDownloader.kt | 127 ++++++++---------- .../service/uploads/MediaUploadResult.kt | 10 +- .../uploads/blossom/BlossomUploader.kt | 13 +- .../service/uploads/nip96/Nip96Uploader.kt | 13 +- 4 files changed, 70 insertions(+), 93 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt index 4a1f60643..65c2d5960 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt @@ -50,64 +50,54 @@ class ImageDownloader { * Stream download and calculate hash for verification without loading entire file into memory. * This is memory-efficient for large files (videos, high-res images, etc.) */ - suspend fun waitAndVerifyStream( - imageUrl: String, - okHttpClient: (url: String) -> OkHttpClient, - ): StreamVerification? = + private suspend fun retryWithDelay( + maxAttempts: Int = 15, + delayMs: Long = 1000, + operation: suspend () -> T?, + ): T? = withContext(Dispatchers.IO) { - var verification: StreamVerification? = null + var result: T? = null var tentatives = 0 // Servers are usually not ready, so tries to download it for 15 times/seconds. - while (verification == null && tentatives < 15) { - verification = + while (result == null && tentatives < maxAttempts) { + result = try { - tryStreamAndVerify(imageUrl, okHttpClient) + operation() } catch (e: Exception) { if (e is CancellationException) throw e null } - if (verification == null) { + if (result == null) { tentatives++ - delay(1000) + delay(delayMs) } } - return@withContext verification + return@withContext result } + suspend fun waitAndVerifyStream( + imageUrl: String, + okHttpClient: (url: String) -> OkHttpClient, + ): StreamVerification? = retryWithDelay { tryStreamAndVerify(imageUrl, okHttpClient) } + suspend fun waitAndGetImage( imageUrl: String, okHttpClient: (url: String) -> OkHttpClient, - ): Blob? = - withContext(Dispatchers.IO) { - var imageData: Blob? = null - var tentatives = 0 + ): Blob? = retryWithDelay { tryGetTheImage(imageUrl, okHttpClient) } - // Servers are usually not ready, so tries to download it for 15 times/seconds. - while (imageData == null && tentatives < 15) { - imageData = - try { - tryGetTheImage(imageUrl, okHttpClient) - } catch (e: Exception) { - if (e is CancellationException) throw e - null - } + private data class HttpConnection( + val connection: HttpURLConnection, + val responseCode: Int, + val contentType: String?, + ) - if (imageData == null) { - tentatives++ - delay(1000) - } - } - - return@withContext imageData - } - - private suspend fun tryStreamAndVerify( + private suspend fun openHttpConnection( imageUrl: String, okHttpClient: (url: String) -> OkHttpClient, - ): StreamVerification? = + ): HttpConnection = withContext(Dispatchers.IO) { // TODO: Migrate to OkHttp HttpURLConnection.setFollowRedirects(true) @@ -122,6 +112,7 @@ class ImageDownloader { huc.instanceFollowRedirects = true var responseCode = huc.responseCode + // Handle redirects if (responseCode in 300..400) { val newUrl: String = huc.getHeaderField("Location") @@ -137,24 +128,38 @@ class ImageDownloader { responseCode = huc.responseCode } + return@withContext HttpConnection( + connection = huc, + responseCode = responseCode, + contentType = huc.headerFields.get("Content-Type")?.firstOrNull(), + ) + } + + private suspend fun tryStreamAndVerify( + imageUrl: String, + okHttpClient: (url: String) -> OkHttpClient, + ): StreamVerification? = + withContext(Dispatchers.IO) { + val httpConn = openHttpConnection(imageUrl, okHttpClient) + return@withContext try { - if (responseCode in 200..300) { + if (httpConn.responseCode in 200..300) { val (hash, totalBytes) = - huc.inputStream.use { + httpConn.connection.inputStream.use { sha256StreamWithCount(it) } StreamVerification( hash = hash.toHexKey(), size = totalBytes, - contentType = huc.headerFields.get("Content-Type")?.firstOrNull(), + contentType = httpConn.contentType, ) } else { null } } finally { // Always disconnect to release connection resources - huc.disconnect() + httpConn.connection.disconnect() } } @@ -163,41 +168,19 @@ class ImageDownloader { okHttpClient: (url: String) -> OkHttpClient, ): Blob? = withContext(Dispatchers.IO) { - // TODO: Migrate to OkHttp - HttpURLConnection.setFollowRedirects(true) - var url = URL(imageUrl) - var clientProxy = okHttpClient(imageUrl).proxy - var huc = - if (clientProxy != null) { - url.openConnection(clientProxy) as HttpURLConnection + val httpConn = openHttpConnection(imageUrl, okHttpClient) + + return@withContext try { + if (httpConn.responseCode in 200..300) { + Blob( + httpConn.connection.inputStream.use { it.readBytes() }, + httpConn.contentType, + ) } else { - url.openConnection() as HttpURLConnection + null } - huc.instanceFollowRedirects = true - var responseCode = huc.responseCode - - if (responseCode in 300..400) { - val newUrl: String = huc.getHeaderField("Location") - - // open the new connnection again - url = URL(newUrl) - clientProxy = okHttpClient(newUrl).proxy - huc = - if (clientProxy != null) { - url.openConnection(clientProxy) as HttpURLConnection - } else { - url.openConnection() as HttpURLConnection - } - responseCode = huc.responseCode - } - - return@withContext if (responseCode in 200..300) { - Blob( - huc.inputStream.use { it.readBytes() }, - huc.headerFields.get("Content-Type")?.firstOrNull(), - ) - } else { - null + } finally { + httpConn.connection.disconnect() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt index b897d5dd9..d8d36b1b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaUploadResult.kt @@ -45,4 +45,12 @@ data class MediaUploadResult( val ipfs: String? = null, // blurhash value for previews val blurHash: BlurhashWrapper? = null, -) +) { + fun mergeLocalMetadata(localMetadata: Pair?): MediaUploadResult = + localMetadata?.let { (blur, dim) -> + copy( + dimension = dim ?: dimension, + blurHash = blur ?: blurHash, + ) + } ?: this +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt index 6483ff09e..b225f047e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/blossom/BlossomUploader.kt @@ -97,8 +97,8 @@ class BlossomUploader { val imageInputStream = contentResolver.openInputStream(uri) checkNotNull(imageInputStream) { "Can't open the image input stream" } - val serverResult = - imageInputStream.use { stream -> + return imageInputStream + .use { stream -> upload( stream, hash, @@ -112,14 +112,7 @@ class BlossomUploader { httpAuth, context, ) - } - - return localMetadata?.let { (blur, dim) -> - serverResult.copy( - dimension = dim ?: serverResult.dimension, - blurHash = blur ?: serverResult.blurHash, - ) - } ?: serverResult + }.mergeLocalMetadata(localMetadata) } fun encodeAuth(event: BlossomAuthorizationEvent): String { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt index 3bb80367f..a602af14e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/nip96/Nip96Uploader.kt @@ -112,8 +112,8 @@ class Nip96Uploader { checkNotNull(imageInputStream) { "Can't open the image input stream" } - val serverResult = - imageInputStream.use { stream -> + return imageInputStream + .use { stream -> upload( stream, length, @@ -126,14 +126,7 @@ class Nip96Uploader { httpAuth, context, ) - } - - return localMetadata?.let { (blur, dim) -> - serverResult.copy( - dimension = dim ?: serverResult.dimension, - blurHash = blur ?: serverResult.blurHash, - ) - } ?: serverResult + }.mergeLocalMetadata(localMetadata) } suspend fun upload(