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
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) {

View File

@@ -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 <T> 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()
}
}
}

View File

@@ -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<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)
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 {

View File

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

View File

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

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
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<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
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()
}
}
}

View File

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