Merge pull request #1531 from davotoula/1530-added-streaming-hashing-utility-to-avoid-OOM-on-large-files

Fix OutOfMemoryError for Large File Uploads
This commit is contained in:
Vitor Pamplona
2025-11-03 12:38:38 -05:00
committed by GitHub
11 changed files with 439 additions and 116 deletions

View File

@@ -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<BlurhashWrapper?, DimensionTag?> {
val (blur, dim) = processBitmap(bitmap)
return blur to (dim ?: dimPrecomputed)
}
fun computeFromBytes(
data: ByteArray,
mimeType: String?,
dimPrecomputed: DimensionTag?,
): Pair<BlurhashWrapper?, DimensionTag?> =
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<BlurhashWrapper?, DimensionTag?>? {
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<BlurhashWrapper?, DimensionTag?> =
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<BlurhashWrapper?, DimensionTag?> {
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
}
}
}

View File

@@ -20,11 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.service.uploads package com.vitorpamplona.amethyst.service.uploads
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.MediaDataSource 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.amethyst.service.images.BlurhashWrapper
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@@ -75,27 +71,7 @@ class FileHeader(
val hash = sha256(data).toHexKey() val hash = sha256(data).toHexKey()
val size = data.size val size = data.size
val (blurHash, dim) = val (blurHash, dim) = BlurhashMetadataCalculator.computeFromBytes(data, mimeType, dimPrecomputed)
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)
}
Result.success(FileHeader(mimeType, hash, size, dim, blurHash)) Result.success(FileHeader(mimeType, hash, size, dim, blurHash))
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -20,6 +20,9 @@
*/ */
package com.vitorpamplona.amethyst.service.uploads 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.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -34,37 +37,67 @@ class ImageDownloader {
val contentType: String?, val contentType: String?,
) )
suspend fun waitAndGetImage( /**
imageUrl: String, * Result of streaming verification - hash and metadata without storing full file
okHttpClient: (url: String) -> OkHttpClient, */
): Blob? = 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 <T> retryWithDelay(
maxAttempts: Int = 15,
delayMs: Long = 1000,
operation: suspend () -> T?,
): T? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
var imageData: Blob? = null var result: T? = null
var tentatives = 0 var tentatives = 0
// Servers are usually not ready, so tries to download it for 15 times/seconds. // Servers are usually not ready, so tries to download it for 15 times/seconds.
while (imageData == null && tentatives < 15) { while (result == null && tentatives < maxAttempts) {
imageData = result =
try { try {
tryGetTheImage(imageUrl, okHttpClient) operation()
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
null null
} }
if (imageData == null) { if (result == null) {
tentatives++ tentatives++
delay(1000) delay(delayMs)
} }
} }
return@withContext imageData return@withContext result
} }
private suspend fun tryGetTheImage( suspend fun waitAndVerifyStream(
imageUrl: String, imageUrl: String,
okHttpClient: (url: String) -> OkHttpClient, 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) { withContext(Dispatchers.IO) {
// TODO: Migrate to OkHttp // TODO: Migrate to OkHttp
HttpURLConnection.setFollowRedirects(true) HttpURLConnection.setFollowRedirects(true)
@@ -79,10 +112,11 @@ class ImageDownloader {
huc.instanceFollowRedirects = true huc.instanceFollowRedirects = true
var responseCode = huc.responseCode var responseCode = huc.responseCode
// Handle redirects
if (responseCode in 300..400) { if (responseCode in 300..400) {
val newUrl: String = huc.getHeaderField("Location") val newUrl: String = huc.getHeaderField("Location")
// open the new connnection again // open the new connection again
url = URL(newUrl) url = URL(newUrl)
clientProxy = okHttpClient(newUrl).proxy clientProxy = okHttpClient(newUrl).proxy
huc = huc =
@@ -94,13 +128,59 @@ class ImageDownloader {
responseCode = huc.responseCode responseCode = huc.responseCode
} }
return@withContext if (responseCode in 200..300) { return@withContext HttpConnection(
Blob( connection = huc,
huc.inputStream.use { it.readBytes() }, responseCode = responseCode,
huc.headerFields.get("Content-Type")?.firstOrNull(), contentType = huc.headerFields.get("Content-Type")?.firstOrNull(),
) )
} else { }
null
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()
} }
} }
} }

View File

@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.service.uploads package com.vitorpamplona.amethyst.service.uploads
import com.vitorpamplona.amethyst.service.images.BlurhashWrapper
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@@ -42,4 +43,14 @@ data class MediaUploadResult(
val infohash: String? = null, val infohash: String? = null,
// ipfs link // ipfs link
val ipfs: String? = null, val ipfs: String? = null,
) // blurhash value for previews
val blurHash: BlurhashWrapper? = null,
) {
fun mergeLocalMetadata(localMetadata: Pair<BlurhashWrapper?, DimensionTag?>?): MediaUploadResult =
localMetadata?.let { (blur, dim) ->
copy(
dimension = dim ?: dimension,
blurHash = blur ?: blurHash,
)
} ?: this
}

View File

@@ -231,38 +231,34 @@ class UploadOrchestrator {
updateState(0.6, UploadingState.Downloading) 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 = // Create FileHeader with hash from streaming verification
FileHeader.prepare( // Note: We skip blurhash/dimensions since we already have them from upload
imageData.bytes, val fileHeader =
uploadResult.type ?: localContentType ?: imageData.contentType, FileHeader(
uploadResult.dimension, mimeType = uploadResult.type ?: localContentType ?: verification.contentType,
) hash = verification.hash,
size = verification.size.toInt(),
result.fold( dim = uploadResult.dimension,
onSuccess = { blurHash = uploadResult.blurHash,
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)
},
) )
} 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 { sealed class OrchestratorResult {

View File

@@ -28,6 +28,7 @@ import android.webkit.MimeTypeMap
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.BlurhashMetadataCalculator
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey 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.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import com.vitorpamplona.quartz.utils.RandomInstance 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -83,34 +84,35 @@ class BlossomUploader {
val fileName = context.getFileName(uri) val fileName = context.getFileName(uri)
val imageInputStreamForHash = contentResolver.openInputStream(uri) val imageInputStreamForHash = contentResolver.openInputStream(uri)
val payload = checkNotNull(imageInputStreamForHash) { "Can't open the image input stream" }
imageInputStreamForHash?.use {
it.readBytes() 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 localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType)
val hash = sha256(payload).toHexKey()
val imageInputStream = contentResolver.openInputStream(uri) val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) { "Can't open the image input stream" } checkNotNull(imageInputStream) { "Can't open the image input stream" }
return imageInputStream.use { stream -> return imageInputStream
upload( .use { stream ->
stream, upload(
hash, stream,
payload.size, hash,
fileName, size,
myContentType, fileName,
alt, myContentType,
sensitiveContent, alt,
serverBaseUrl, sensitiveContent,
okHttpClient, serverBaseUrl,
httpAuth, okHttpClient,
context, httpAuth,
) context,
} )
}.mergeLocalMetadata(localMetadata)
} }
fun encodeAuth(event: BlossomAuthorizationEvent): String { fun encodeAuth(event: BlossomAuthorizationEvent): String {
@@ -121,7 +123,7 @@ class BlossomUploader {
suspend fun upload( suspend fun upload(
inputStream: InputStream, inputStream: InputStream,
hash: HexKey, hash: HexKey,
length: Int, length: Long,
baseFileName: String?, baseFileName: String?,
contentType: String?, contentType: String?,
alt: String?, alt: String?,
@@ -146,14 +148,14 @@ class BlossomUploader {
object : RequestBody() { object : RequestBody() {
override fun contentType() = contentType?.toMediaType() override fun contentType() = contentType?.toMediaType()
override fun contentLength() = length.toLong() override fun contentLength() = length
override fun writeTo(sink: BufferedSink) { override fun writeTo(sink: BufferedSink) {
inputStream.source().use(sink::writeAll) 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)) requestBuilder.addHeader("Authorization", encodeAuth(it))
} }

View File

@@ -30,6 +30,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.HttpStatusMessages import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.BlurhashMetadataCalculator
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
@@ -106,24 +107,26 @@ class Nip96Uploader {
val myContentType = contentType ?: contentResolver.getType(uri) val myContentType = contentType ?: contentResolver.getType(uri)
val length = size ?: contentResolver.querySize(uri) ?: fileSize(uri) ?: 0 val length = size ?: contentResolver.querySize(uri) ?: fileSize(uri) ?: 0
val localMetadata = BlurhashMetadataCalculator.computeFromUri(context, uri, myContentType)
val imageInputStream = contentResolver.openInputStream(uri) val imageInputStream = contentResolver.openInputStream(uri)
checkNotNull(imageInputStream) { "Can't open the image input stream" } checkNotNull(imageInputStream) { "Can't open the image input stream" }
return imageInputStream.use { stream -> return imageInputStream
upload( .use { stream ->
stream, upload(
length, stream,
myContentType, length,
alt, myContentType,
sensitiveContent, alt,
server, sensitiveContent,
okHttpClient, server,
onProgress, okHttpClient,
httpAuth, onProgress,
context, httpAuth,
) context,
} )
}.mergeLocalMetadata(localMetadata)
} }
suspend fun upload( suspend fun upload(

View File

@@ -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
}
}

View File

@@ -20,6 +20,26 @@
*/ */
package com.vitorpamplona.quartz.utils.sha256 package com.vitorpamplona.quartz.utils.sha256
import java.io.InputStream
val pool = Sha256Pool(5) // max parallel operations val pool = Sha256Pool(5) // max parallel operations
actual fun sha256(data: ByteArray) = pool.hash(data) 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<ByteArray, Long> {
val countingStream = CountingInputStream(inputStream)
val hash = pool.hashStream(countingStream, bufferSize)
return Pair(hash, countingStream.bytesRead)
}

View File

@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.quartz.utils.sha256 package com.vitorpamplona.quartz.utils.sha256
import java.io.InputStream
import java.security.MessageDigest import java.security.MessageDigest
class Sha256Hasher { class Sha256Hasher {
@@ -30,4 +31,31 @@ class Sha256Hasher {
fun digest(byteArray: ByteArray) = digest.digest(byteArray) fun digest(byteArray: ByteArray) = digest.digest(byteArray)
fun reset() = digest.reset() 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()
}
}
} }

View File

@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.utils.sha256 package com.vitorpamplona.quartz.utils.sha256
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import java.io.InputStream
import java.util.concurrent.ArrayBlockingQueue import java.util.concurrent.ArrayBlockingQueue
class Sha256Pool( class Sha256Pool(
@@ -54,4 +55,24 @@ class Sha256Pool(
release(hasher) 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)
}
}
} }