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..bfd3ab05d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/BlurhashMetadataCalculator.kt @@ -0,0 +1,133 @@ +/** + * 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 { + 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, + mimeType: String?, + dimPrecomputed: DimensionTag?, + ): Pair = + when { + isImage(mimeType) -> { + val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size, createBitmapOptions()) + processImage(bitmap, dimPrecomputed) + } + isVideo(mimeType) -> { + 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 { + isImage(mimeType) -> + context.contentResolver.openInputStream(uri)?.use { stream -> + val bitmap = BitmapFactory.decodeStream(stream, null, createBitmapOptions()) + processImage(bitmap, dimPrecomputed) + } ?: (null to dimPrecomputed) + + isVideo(mimeType) -> { + 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/ImageDownloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/ImageDownloader.kt index 0407f799a..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 @@ -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.sha256StreamWithCount import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -34,37 +37,67 @@ class ImageDownloader { val contentType: String?, ) - suspend fun waitAndGetImage( - imageUrl: String, - okHttpClient: (url: String) -> OkHttpClient, - ): Blob? = + /** + * 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.) + */ + private suspend fun retryWithDelay( + maxAttempts: Int = 15, + delayMs: Long = 1000, + operation: suspend () -> T?, + ): T? = withContext(Dispatchers.IO) { - var imageData: Blob? = null + var result: T? = null var tentatives = 0 // Servers are usually not ready, so tries to download it for 15 times/seconds. - while (imageData == null && tentatives < 15) { - imageData = + while (result == null && tentatives < maxAttempts) { + result = try { - tryGetTheImage(imageUrl, okHttpClient) + operation() } catch (e: Exception) { if (e is CancellationException) throw e null } - if (imageData == null) { + if (result == null) { tentatives++ - delay(1000) + delay(delayMs) } } - return@withContext imageData + return@withContext result } - private suspend fun tryGetTheImage( + suspend fun waitAndVerifyStream( imageUrl: String, okHttpClient: (url: String) -> OkHttpClient, - ): Blob? = + ): StreamVerification? = retryWithDelay { tryStreamAndVerify(imageUrl, okHttpClient) } + + suspend fun waitAndGetImage( + imageUrl: String, + okHttpClient: (url: String) -> OkHttpClient, + ): Blob? = retryWithDelay { tryGetTheImage(imageUrl, okHttpClient) } + + private data class HttpConnection( + val connection: HttpURLConnection, + val responseCode: Int, + val contentType: String?, + ) + + private suspend fun openHttpConnection( + imageUrl: String, + okHttpClient: (url: String) -> OkHttpClient, + ): HttpConnection = withContext(Dispatchers.IO) { // TODO: Migrate to OkHttp HttpURLConnection.setFollowRedirects(true) @@ -79,10 +112,11 @@ class ImageDownloader { huc.instanceFollowRedirects = true var responseCode = huc.responseCode + // Handle redirects if (responseCode in 300..400) { val newUrl: String = huc.getHeaderField("Location") - // open the new connnection again + // open the new connection again url = URL(newUrl) clientProxy = okHttpClient(newUrl).proxy huc = @@ -94,13 +128,59 @@ class ImageDownloader { responseCode = huc.responseCode } - return@withContext if (responseCode in 200..300) { - Blob( - huc.inputStream.use { it.readBytes() }, - huc.headerFields.get("Content-Type")?.firstOrNull(), - ) - } else { - null + 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 (httpConn.responseCode in 200..300) { + val (hash, totalBytes) = + httpConn.connection.inputStream.use { + sha256StreamWithCount(it) + } + + StreamVerification( + hash = hash.toHexKey(), + size = totalBytes, + contentType = httpConn.contentType, + ) + } else { + null + } + } finally { + // Always disconnect to release connection resources + httpConn.connection.disconnect() + } + } + + private suspend fun tryGetTheImage( + imageUrl: String, + okHttpClient: (url: String) -> OkHttpClient, + ): Blob? = + withContext(Dispatchers.IO) { + val httpConn = openHttpConnection(imageUrl, okHttpClient) + + return@withContext try { + if (httpConn.responseCode in 200..300) { + Blob( + httpConn.connection.inputStream.use { it.readBytes() }, + httpConn.contentType, + ) + } 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 ad65e1756..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 @@ -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,14 @@ data class MediaUploadResult( val infohash: String? = null, // ipfs link 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/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index 42a763dee..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 @@ -231,38 +231,34 @@ 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().waitAndVerifyStream(uploadResult.url, okHttpClient) + ?: return error(R.string.could_not_download_from_the_server) - if (imageData != null) { - updateState(0.8, UploadingState.Hashing) + updateState(0.8, UploadingState.Hashing) - val result = - FileHeader.prepare( - imageData.bytes, - uploadResult.type ?: localContentType ?: imageData.contentType, - uploadResult.dimension, - ) - - 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) - }, + // 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 d97367448..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 @@ -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 @@ -36,7 +37,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.sha256 +import com.vitorpamplona.quartz.utils.sha256.sha256StreamWithCount import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType @@ -83,34 +84,35 @@ class BlossomUploader { val fileName = context.getFileName(uri) val imageInputStreamForHash = contentResolver.openInputStream(uri) - val payload = - imageInputStreamForHash?.use { - it.readBytes() + checkNotNull(imageInputStreamForHash) { "Can't open the image input stream" } + + val (hash, size) = + imageInputStreamForHash.use { stream -> + val (hashBytes, totalBytes) = sha256StreamWithCount(stream) + hashBytes.toHexKey() to totalBytes } - checkNotNull(payload) { "Can't open the image input stream" } - - val hash = sha256(payload).toHexKey() + 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, - hash, - payload.size, - fileName, - myContentType, - alt, - sensitiveContent, - serverBaseUrl, - okHttpClient, - httpAuth, - context, - ) - } + return imageInputStream + .use { stream -> + upload( + stream, + hash, + size, + fileName, + myContentType, + alt, + sensitiveContent, + serverBaseUrl, + okHttpClient, + httpAuth, + context, + ) + }.mergeLocalMetadata(localMetadata) } fun encodeAuth(event: BlossomAuthorizationEvent): String { @@ -121,7 +123,7 @@ class BlossomUploader { suspend fun upload( inputStream: InputStream, hash: HexKey, - length: Int, + length: Long, baseFileName: String?, contentType: String?, alt: String?, @@ -146,14 +148,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)) } 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..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 @@ -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,26 @@ 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, - ) - } + return imageInputStream + .use { stream -> + upload( + stream, + length, + myContentType, + alt, + sensitiveContent, + server, + okHttpClient, + onProgress, + httpAuth, + context, + ) + }.mergeLocalMetadata(localMetadata) } suspend fun upload( 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 c71dae98e..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 @@ -20,6 +20,26 @@ */ 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 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 Pair of (hash bytes, bytes read count) + */ +fun sha256StreamWithCount( + inputStream: InputStream, + bufferSize: Int = 8192, +): 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 bd4709780..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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.utils.sha256 +import java.io.InputStream import java.security.MessageDigest class Sha256Hasher { @@ -30,4 +31,31 @@ 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) + try { + var bytesRead: Int + + 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() + } + } } 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) + } + } }