mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-11-11 10:46:45 +01:00
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:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
null
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -231,23 +231,27 @@ 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(),
|
||||||
|
dim = uploadResult.dimension,
|
||||||
|
blurHash = uploadResult.blurHash,
|
||||||
)
|
)
|
||||||
|
|
||||||
result.fold(
|
|
||||||
onSuccess = {
|
|
||||||
return finish(
|
return finish(
|
||||||
OrchestratorResult.ServerResult(
|
OrchestratorResult.ServerResult(
|
||||||
it,
|
fileHeader,
|
||||||
uploadResult.url,
|
uploadResult.url,
|
||||||
uploadResult.magnet,
|
uploadResult.magnet,
|
||||||
uploadResult.sha256,
|
uploadResult.sha256,
|
||||||
@@ -255,14 +259,6 @@ class UploadOrchestrator {
|
|||||||
originalHash,
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class OrchestratorResult {
|
sealed class OrchestratorResult {
|
||||||
|
|||||||
@@ -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,24 +84,25 @@ 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
|
||||||
|
.use { stream ->
|
||||||
upload(
|
upload(
|
||||||
stream,
|
stream,
|
||||||
hash,
|
hash,
|
||||||
payload.size,
|
size,
|
||||||
fileName,
|
fileName,
|
||||||
myContentType,
|
myContentType,
|
||||||
alt,
|
alt,
|
||||||
@@ -110,7 +112,7 @@ class BlossomUploader {
|
|||||||
httpAuth,
|
httpAuth,
|
||||||
context,
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 +107,13 @@ 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
|
||||||
|
.use { stream ->
|
||||||
upload(
|
upload(
|
||||||
stream,
|
stream,
|
||||||
length,
|
length,
|
||||||
@@ -123,7 +126,7 @@ class Nip96Uploader {
|
|||||||
httpAuth,
|
httpAuth,
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
}
|
}.mergeLocalMetadata(localMetadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun upload(
|
suspend fun upload(
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user