diff --git a/README.md b/README.md index de0f34551..d69818c00 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [ ] Key Migration (NIP-xx/Draft) - [ ] Time-based Sync (NIP-xx/Draft) - [x] Image Capture in the app -- [ ] Video Capture in the app +- [x] Video Capture in the app - [ ] Local Database - [ ] Workspaces - [ ] Infinity Scroll diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 9328f7d39..7f825ffb8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -66,7 +66,6 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import java.io.File -import kotlin.jvm.java // Release mode (!BuildConfig.DEBUG) always uses encrypted preferences // To use plaintext SharedPreferences for debugging, set this to true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index e15a6c799..36fc9474c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -49,7 +49,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch -import kotlin.collections.forEach /** * This downloads the outbox events for all Follows of all users. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt index 3ed810db1..afd76dec4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt @@ -30,7 +30,6 @@ import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet -import kotlin.collections.ifEmpty fun pickRelaysToLoadUsers( users: Set, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt index a540b972b..bb145322d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt @@ -36,7 +36,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.TimeUtils -import kotlin.collections.ifEmpty class UserOutboxFinderSubAssembler( client: INostrClient, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index 3f2deb186..a997ab173 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -33,8 +33,6 @@ import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.mapOfSet -import kotlin.collections.forEach -import kotlin.collections.plus val UserMetadataForKeyKinds = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt index c453f70f2..2aa980512 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAuthor.kt @@ -27,8 +27,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.mapOfSet -import kotlin.collections.ifEmpty -import kotlin.collections.plus val MetadataKindList = listOf(MetadataEvent.KIND) 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/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index b9388edba..ce7e7ea98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -81,6 +81,7 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji import com.vitorpamplona.amethyst.commons.richtext.Segment +import com.vitorpamplona.amethyst.commons.richtext.VideoSegment import com.vitorpamplona.amethyst.commons.richtext.WithdrawSegment import com.vitorpamplona.amethyst.model.HashtagIcon import com.vitorpamplona.amethyst.model.LocalCache @@ -92,7 +93,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.markdown.RenderContentAsMarkdown import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav -import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.nav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor @@ -112,7 +112,6 @@ import com.vitorpamplona.quartz.nip01Core.core.ImmutableListOfLists import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlin.text.Typography.paragraph fun isMarkdown(content: String): Boolean = content.startsWith("> ") || @@ -416,6 +415,8 @@ private fun RenderWordWithoutPreview( when (word) { // Don't preview Images is ImageSegment -> ClickableUrl(word.segmentText, word.segmentText) + // Don't preview Videos + is VideoSegment -> ClickableUrl(word.segmentText, word.segmentText) is LinkSegment -> ClickableUrl(word.segmentText, word.segmentText) is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) // Don't offer to pay invoices @@ -447,6 +448,7 @@ private fun RenderWordWithPreview( ) { when (word) { is ImageSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) + is VideoSegment -> ZoomableContentView(word.segmentText, state, accountViewModel) is LinkSegment -> LoadUrlPreview(word.segmentText, word.segmentText, callbackUri, accountViewModel) is EmojiSegment -> RenderCustomEmoji(word.segmentText, state) is InvoiceSegment -> MayBeInvoicePreview(word.segmentText, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBottomBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBottomBar.kt index 835a362a5..3d6f9077c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBottomBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingBottomBar.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.material3.BottomAppBarScrollBehavior import androidx.compose.material3.BottomAppBarState import androidx.compose.material3.ExperimentalMaterial3Api diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt index 9de17d8f9..5f8836b0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt @@ -22,14 +22,12 @@ package com.vitorpamplona.myapplication import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.height import androidx.compose.material3.BottomAppBarScrollBehavior import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.layout -import com.vitorpamplona.myapplication.ResetDisappearingOnResume import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index ac1422be7..ed36b2e20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -441,7 +441,7 @@ class ChatNewMessageViewModel : val urls = findURLs(message.text) val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet()) val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value) - val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null val message = message.text val contentWarningReason = if (wantsToMarkAsSensitive) "" else null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt index 3ebd7f116..433a9aaf2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelNewMessageViewModel.kt @@ -385,7 +385,7 @@ open class ChannelNewMessageViewModel : val emojis = findEmoji(message.text, accountViewModel.account.emoji.myEmojis.value) val channelRelays = channel.relays() - val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null return if (channel is PublicChatChannel) { val replyingToEvent = replyTo.value?.toEventHint() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt index 43315ef45..6b7d21dd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductViewModel.kt @@ -327,7 +327,7 @@ open class NewProductViewModel : val urls = findURLs(tagger.message) val usedAttachments = iMetaDescription.filterIsIn(urls.toSet()) + productImages.map { it.toIMeta() } - val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null val localZapRaiserAmount = if (wantsZapraiser) zapRaiserAmount.value else null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 09da6c2f2..28bafef82 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -515,7 +515,7 @@ open class ShortNotePostViewModel : val zapReceiver = if (wantsForwardZapTo) forwardZapTo.value.toZapSplitSetup() else null - val geoHash = (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() + val geoHash = if (wantsToAddGeoHash) (location?.value as? LocationState.LocationResult.Success)?.geoHash?.toString() else null val localZapRaiserAmount = if (wantsZapRaiser) zapRaiserAmount.value else null val emojis = findEmoji(tagger.message, account.emoji.myEmojis.value) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 4c75a29de..0aa4a5f27 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -20,8 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.theme -import android.R.attr.fontFamily -import android.R.id.primary import android.app.Activity import android.app.UiModeManager import android.content.Context diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 48986cd9e..1828c8a4e 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -459,6 +459,8 @@ %1$s je soukromý člen Přidat jako veřejného člena Přidat jako soukromého člena + Veřejní členové + Soukromí členové Veřejné profily Soukromé profily člen @@ -703,6 +705,16 @@ Vložit ze schránky Neplatné NIP-47 URI URI %1$s není platné přihlašovací URI NIP-47. + Připojit novou NWC peněženku + Peněženka nebyla nalezená + Amethyst nenašel žádné aplikace peněženek, které podporují Nostr Wallet Connect (NWC). +\n\nCo můžete udělat: +\n\t1. Ověřte podporu NWC: Zkontrolujte, že vaše oblíbená peněženka podporuje Nostr Wallet Connect. +\n\t2. Vytvořte připojení: Otevřete svou peněženku a vytvořte nové NWC připojení. +\n\t3. Propojte s Amethyst: Vaše peněženka obvykle nabídne tři možnosti: +\n\t\t3.1 Automatické propojení: Tlačítko pro automatické otevření a připojení k Amethyst. Klikněte na něj, otevřete Amethyst a uložte na nové obrazovce. +\n\t\t3.2 Ruční propojení: URI připojení, které můžete zkopírovat a vložit přes ikonu vložení na této obrazovce. +\n\t\t3.3 QR kód: Peněženka vytvoří QR kód, který můžete naskenovat pomocí tlačítka QR kódu na této obrazovce. Pro rozhraní aplikace Tmavé, světlé nebo systémové téma Automaticky načítat obrázky a GIFy @@ -934,6 +946,16 @@ Vytvoření seznamu relé speciálně navržených pro vyhledávání a označování uživatelů zlepší tyto výsledky. Vložte mezi 1–3 relé, která chcete použít při vyhledávání obsahu nebo označování uživatelů. Ujistěte se, že vaše vybraná relé implementují NIP-50 Dobré možnosti jsou:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Relaye pro odesílání + Nastavte své veřejné relaye pro odesílání příspěvků + Vytvoření seznamu relayů určených pro přijímání vašeho obsahu je zásadní pro váš zážitek z Nostr a jediný způsob, jak vás mohou vaši sledující najít. + Vložte 1–3 relaye, které přijímají vaše příspěvky. Ujistěte se, že nevyžadují platbu, pokud neplatíte za vložení. + Dobré možnosti jsou:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social + Relaye pro přijímání + Nastavte své veřejné relaye pro přijímání oznámení + Vytvoření seznamu relayů určených pro přijímání oznámení je zásadní pro váš zážitek z Nostr. + Vložte 1–3 relaye, které přijímají oznámení pro vás. Ujistěte se, že nevyžadují platbu ani web of trust od odesílatele, jinak budete dostávat oznámení pouze od plátců nebo dlouhodobých uživatelů. + Dobré možnosti jsou:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social Nahrát v DM Nastavení relé Veřejná domácí relé diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index bd0b9b409..99e04e593 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -465,6 +465,8 @@ anz der Bedingungen ist erforderlich %1$s ist ein privates Mitglied Als öffentliches Mitglied hinzufügen Als privates Mitglied hinzufügen + Öffentliche Mitglieder + Private Mitglieder Öffentliche Profile Private Profile Mitglied @@ -708,6 +710,16 @@ anz der Bedingungen ist erforderlich Aus Zwischenablage einfügen Ungültige NIP-47 URI Die URI %1$s ist keine gültige NIP-47 Anmelde-URI. + Mit neuer NWC-Wallet verbinden + Wallet nicht gefunden + Amethyst konnte keine Wallet-Apps finden, die Nostr Wallet Connect (NWC) unterstützen. +\n\nWas du tun kannst: +\n\t1. NWC-Unterstützung überprüfen: Stelle sicher, dass deine bevorzugte Wallet-App mit Nostr Wallet Connect kompatibel ist. +\n\t2. Verbindung erstellen: Öffne deine Wallet-App und erstelle eine neue NWC-Verbindung. +\n\t3. Mit Amethyst verknüpfen: Deine Wallet bietet normalerweise drei Optionen: +\n\t\t3.1 Automatische Verknüpfung: Eine Schaltfläche zum automatischen Öffnen und Verbinden mit Amethyst. Klicke darauf, öffne Amethyst und speichere auf dem neuen Bildschirm. +\n\t\t3.2 Manuelle Verknüpfung: Eine Verbindungs-URI, die du kopieren und über das Einfügesymbol auf diesem Bildschirm einfügen kannst. +\n\t\t3.3 QR-Code: Die Wallet erstellt einen QR-Code, den du über die QR-Schaltfläche auf diesem Bildschirm scannen kannst. Für die App-Benutzeroberfläche Dunkles, helles oder Systemdesign Bilder und GIFs automatisch laden @@ -939,6 +951,16 @@ anz der Bedingungen ist erforderlich Das Erstellen einer Relaisliste, die speziell für die Suche und Benutzerkennzeichnung entwickelt wurde, wird diese Ergebnisse verbessern. Fügen Sie 1–3 Relais ein, die beim Suchen nach Inhalten oder beim Taggen von Benutzern verwendet werden sollen. Stellen Sie sicher, dass Ihre ausgewählten Relais NIP-50 implementieren Gute Optionen sind:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Ausgehende Relays + Richte deine öffentlichen Ausgehenden Relays zum Posten ein + Das Erstellen einer Relay-Liste, die speziell zum Empfangen deiner Inhalte gedacht ist, ist entscheidend für dein Nostr-Erlebnis und der einzige Weg, wie dich deine Follower finden können. + Füge 1–3 Relays hinzu, die deine Beiträge empfangen. Stelle sicher, dass sie keine Zahlung verlangen, wenn du nicht für das Einfügen zahlst. + Gute Optionen sind:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social + Eingehende Relays + Richte deine öffentlichen Eingehenden Relays zum Empfangen von Benachrichtigungen ein + Das Erstellen einer Relay-Liste, die speziell zum Empfangen von Benachrichtigungen gedacht ist, ist entscheidend für dein Nostr-Erlebnis. + Füge 1–3 Relays hinzu, die Benachrichtigungen für dich empfangen. Stelle sicher, dass sie keine Zahlung oder Web of Trust vom Absender verlangen, sonst erhältst du nur Benachrichtigungen von zahlenden oder langjährigen Nutzern. + Gute Optionen sind:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social DM-Upload Relaiseinstellungen Öffentliche Heimrelais diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index c73a4c1e5..72b71b730 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -951,6 +951,16 @@ खोज तथा उपयोगकर्ता सूचक जोडने के लिए स्पष्टतः रूपांकित पुनःप्रसारक सूची बनाने से इन परिणामों में शोधन होगा। विषयवस्तु खोजने के लिए अथवा उपयोगकर्ता सूचक जोडने में उपयोग करनें के लिए १ - ३ पुनःप्रसारकों को जोडें। सुनिश्चित करें कि आपके चयनित पुनःप्रसारक निप॰-५० को कार्यान्वित किये हैं। ये अच्छे विकल्प हैं :\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + निर्गतपेटिका पुनःप्रसारक सूची + अपने सार्वजनिक निर्गतपेटिका पुनःप्रसारकों की स्थापना करें पत्र प्रकाशन के लिए + आपके विषयवस्तु प्राप्त करने के लिए पुनःप्रसारक सूची की विशिष्ट रचना आवश्यक है नोस्टर में। तथा आपके अनुचरों के लिए आपको ढूँढने की एकमात्र विधि। + १ - ३ पुनःप्रसारकों को जोडें जो आपके पत्र स्वीकारते हैं। सुनिश्चित करें कि इनके लिए भुगतान अनावश्यक हैं यदि आप भुगतान नहीं कर रहे हैं अभिलेखन के लिए + ये अच्छे विकल्प हैं :\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social + आगतपेटिका पुनःप्रसारक सूची + आपके सार्वजनिक आगतपेटिका पुनःप्रसारकों की स्थापना करें सूचनाएँ प्राप्त करने के लिए + सूचनाएँ प्राप्त करने के लिए पुनःप्रसारक सूची की विशिष्ट रचना आवश्यक है नोस्टर में। + १ - ३ पुनःप्रसारकों को जोडें जो आपके लिए सूचनाएँ स्वीकारते हैं। सुनिश्चित करें कि इनके लिए भुगतान अथवा विश्वासजाल अनुमति अनावश्यक हैं अन्यथा आप केवल भुगतान करनेवालों से अथवा दीर्घकालिक प्रयोक्ताओं से सूचनाएँ प्राप्त करेंगे + ये अच्छे विकल्प हैं :\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social सीधा संदेश आरोहण पुनःप्रसारक स्थापना विकल्प सार्वजनिक मुख्य पुनःप्रसारक diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index f4b2e1f27..8f65532f8 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -951,6 +951,16 @@ A kifejezetten a kereséshez és a felhasználói címkézéshez tervezett átjátszólista létrehozása javítani fogja ezeket az eredményeket. Adjon hozzá 1–3 átjátszót a tartalom kereséshez vagy a felhasználók címkézéséhez. Győződjön meg arról, hogy a kiválasztott átjátszók alkalmazzák a NIP-50-et Jó választási lehetőségek:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Átjátszók a kimenő üzenetkhez + Állítsa be a nyilvános kimenő üzenetek átjátszóit a bejegyzéshez + A tartalom fogadására kifejezetten kialakított átjátszólista létrehozása elengedhetetlen a Nostr élményhez, és ez az egyetlen módja annak, hogy a követői megtalálják Önt. + Adjon meg 1-3 átjátszót, amelyek fogadják az Ön bejegyzéseit. Győződjön meg arról, hogy nem kérnek fizetést, ha Ön nem fizet a használatukért + Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social + Átjátszók a bejövő üzenetkhez + Állítsa be a nyilvános bejövő üzenetek átjátszóit az értesítések fogadásához + Az értesítések fogadásához kifejezetten kialakított átjátszólista létrehozása elengedhetetlen a Nostr élményhez. + Adjon meg 1-3 átjátszót, amelyeken keresztül fogadni tudja az értesítéseket. Győződjön meg arról, hogy azok nem igényelnek fizetést vagy bizalmi hálózatot a feladótól, különben csak a fizetősektől vagy a hosszú távú felhasználóktól fog értesítéseket kapni + Jó választási lehetőségek:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social Feltöltés közvetlen üzenetbe Átjátszók beállításai Nyilvános kimenő és saját átjátszók diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 787dcb9eb..967436d49 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -948,6 +948,16 @@ Stworzenie listy transmiterów specjalnie przeznaczonych do wyszukiwania i tagowania użytkowników poprawi te wyniki. Wstaw od 1 do 3 transmiterów, które będą używane podczas wyszukiwania treści lub tagowania użytkowników. Upewnij się, że zaimplementowano NIP-50 Dobre opcje to:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Transmitery wysyłające + Skonfiguruj swoje publiczne transmitery wysyłające, aby publikować + Stworzenie listy transmiterów specjalnie zaprojektowanej do odbierania Twoich wpisów ma kluczowe znaczenie dla korzystania z sieci Nostr i jest jedynym sposobem, w jaki Twoi obserwujący mogą Cię znaleźć. + Wstaw od 1 do 3 transmiterów, które odbierają Twoje wpisy. Upewnij się, że nie wymagają one płatności, jeśli nie opłacasz wstawiania wpisów + Dobre opcje to:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social + Transmitery odbiorcze + Skonfiguruj swoje publiczne transmitery skrzynki odbiorczej, aby otrzymywać powiadomienia + Stworzenie listy transmiterów specjalnie zaprojektowanej do odbierania powiadomień ma kluczowe znaczenie dla korzystania z protokołu Nostr. + Wstaw od 1 do 3 transmiterów, które będą odbierać powiadomienia. Upewnij się, że nie wymagają one płatności ani statusu \"web of trust\" od nadawcy, w przeciwnym razie będziesz otrzymywać powiadomienia tylko od płatników lub stałych użytkowników + Dobre opcje to:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social Wgrywanie DM Ustawienia Transmiterów Publiczne transmitery domowe diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index f6790d9c3..f5c8e6cc9 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -459,6 +459,8 @@ %1$s é um membro privado Adicionar como membro público Adicionar como membro privado + Membros Públicos + Membros Privados Perfis públicos Perfis privados membro @@ -703,6 +705,16 @@ Colar da área de transferência URI NIP-47 inválido O URI %1$s não é um URI de login NIP-47 válido. + Conectar a nova carteira NWC + Carteira não encontrada + O Amethyst não conseguiu encontrar nenhum aplicativo de carteira que suporte Nostr Wallet Connect (NWC). +\n\nO que você pode fazer: +\n\t1. Verifique o suporte NWC: Confirme se o aplicativo da sua carteira é compatível com o Nostr Wallet Connect. +\n\t2. Criar conexão: Vá até o aplicativo da sua carteira e crie uma nova conexão NWC. +\n\t3. Vincular ao Amethyst: Sua carteira geralmente oferece três opções: +\n\t\t3.1 Link Automático: Um botão para abrir e conectar automaticamente ao Amethyst. Clique nele, abra o Amethyst e salve na nova tela. +\n\t\t3.2 Link Manual: Um URI de conexão que você pode copiar e colar usando o ícone de colar nesta tela. +\n\t\t3.3 Código QR: A carteira gera um código QR que você pode ler usando o botão de código QR nesta tela. Para a interface do aplicativo Tema Escuro, Claro ou Padrão Carregar automaticamente imagens e GIFs @@ -934,6 +946,16 @@ Criar uma lista de relés especificamente projetada para pesquisa e marcação de usuários melhorará esses resultados. Insira entre 1–3 relés para usar ao pesquisar conteúdo ou marcar usuários. Certifique-se de que seus relés escolhidos implementem o NIP-50 Boas opções são:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Relays de Saída + Configure seus relays públicos de saída para postar + Criar uma lista de relays especificamente projetada para receber seu conteúdo é essencial para sua experiência no Nostr e a única forma de seus seguidores encontrarem você. + Insira entre 1–3 relays que recebam suas postagens. Certifique-se de que eles não exijam pagamento se você não estiver pagando para inserir. + Boas opções são:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social + Relays de Entrada + Configure seus relays públicos de entrada para receber notificações + Criar uma lista de relays especificamente projetada para receber notificações é essencial para sua experiência no Nostr. + Insira entre 1–3 relays que recebam notificações para você. Certifique-se de que eles não exijam pagamento ou rede de confiança do remetente, caso contrário você receberá notificações apenas de pagadores ou usuários de longo prazo. + Boas opções são:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social Envio de DM Configurações de Relay Relés Públicos de Casa diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index b2b942706..e326e921c 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -459,6 +459,8 @@ %1$s är en privat medlem Lägg till som offentlig medlem Lägg till som privat medlem + Offentliga medlemmar + Privata medlemmar Offentliga profiler Privata profiler medlem @@ -702,6 +704,16 @@ Klistra in från urklipp Ogiltig NIP-47 URI URI %1$s är inte en giltig NIP-47 inloggnings-URI. + Anslut till ny NWC-plånbok + Plånbok hittades inte + Amethyst kunde inte hitta några plånboksappar som stöder Nostr Wallet Connect (NWC). +\n\nVad du kan göra: +\n\t1. Verifiera NWC-stöd: Kontrollera att din föredragna plånbok stöder Nostr Wallet Connect. +\n\t2. Skapa anslutning: Gå till din plånbok och skapa en ny NWC-anslutning. +\n\t3. Länka till Amethyst: Din plånbok erbjuder vanligtvis tre alternativ: +\n\t\t3.1 Automatisk länk: En knapp för att automatiskt öppna och ansluta till Amethyst. Klicka på den, öppna Amethyst och spara på den nya skärmen. +\n\t\t3.2 Manuell länk: En anslutnings-URI som du kan kopiera och klistra in via klistra-ikonen på den här skärmen. +\n\t\t3.3 QR-kod: Plånboken skapar en QR-kod som du kan skanna med QR-knappens funktion på den här skärmen. För appens gränssnitt Mörkt, Ljust eller Systemtema Ladda automatiskt bilder och GIFs @@ -933,6 +945,16 @@ Att skapa en relälista speciellt utformad för sökning och användartaggning kommer att förbättra dessa resultat. Sätt in mellan 1–3 reläer att använda när du söker efter innehåll eller taggar användare. Se till att dina valda reläer implementerar NIP-50 Bra alternativ är:\n - nostr.wine\n - relay.nostr.band\n - relay.noswhere.com + Utgående reläer + Ställ in dina offentliga utgående reläer för att kunna posta + Att skapa en relälista specifikt för att ta emot ditt innehåll är avgörande för din Nostr-upplevelse och det enda sättet dina följare kan hitta dig. + Lägg till mellan 1–3 reläer som tar emot dina inlägg. Se till att de inte kräver betalning om du inte betalar för att lägga till dem. + Bra alternativ är:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social + Inkommande reläer + Ställ in dina offentliga inkommande reläer för att ta emot aviseringar + Att skapa en relälista specifikt för att ta emot aviseringar är avgörande för din Nostr-upplevelse. + Lägg till mellan 1–3 reläer som tar emot aviseringar åt dig. Se till att de inte kräver betalning eller web of trust från avsändaren, annars får du bara aviseringar från betalande eller långsiktiga användare. + Bra alternativ är:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social DM uppladdning Relä inställningar Offentliga hemreläer diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 434f9503b..548230370 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -455,9 +455,21 @@ 似乎你还没有任何关注集。 \n轻按下方刷新,或者轻按“+”按钮新建一个。 + 新建 添加作者到关注集 从列表中添加或删除用户,或用此用户创建一个新列表。 - %1$s 列表的图标 + 关注集的图标 + %1$s 是公开成员 + %1$s 是私密成员 + 添加为公开成员 + 添加为私密成员 + 公开成员 + 私密成员 + 公开的个人资料 + 私密配置文件 + 成员 + 成员 + 无成员 此列表中没有 %1$s 您的关注集 未找到关注集,或者你还没有任何关注集。轻按下方刷新,或使用按钮新建。 @@ -466,10 +478,19 @@ 创建用户新的 %1$s 列表 创建 %1$s 关注集,并添加 %2$s 到其中。 新的 %1$s 列表 + 复制/克隆关注集 + 修改描述 + 此列表没有描述 + 当前描述: + 你可以为下方的克隆集合设置自定义名称、描述。 集合名 + (原始集合名称) 集合描述(可选) + (原始集合描述) 新建集 + 复制/克隆集 重命名集 + 修改 正将集合名称从 改为 默认端口为 9050 @@ -688,6 +709,17 @@ 粘贴自剪贴板 无效的 NIP-47 URI URI %1$s 不是有效的 NIP-47 登录 URI。 + 连接到新的 NWC 钱包 + 未找到钱包 + Amethyst 找不到任何支持 Nostr Wallet Connect (NWC) 的钱包应用. +\n\n你可以做的事情: +\n\t1. 验证 NWC 支持:仔细确认偏好的钱包应用和 Nostr Wallet Connect 兼容。 +\n\t2. 创建连接:前往钱包应用并创建新的 NWC 连接。 +\n\t3.链接到 Amethyst: 你的钱包通常会给你三个选项: +\n\t\t3.1 自动链接:自动打开并连接到 Amethyst 的按钮。如果是这样, 单击那个按钮,打开 Amethyst 并在新屏上按保存。 +\n\t\t3.2 手动链接: 你可以复制的连接 URI,单击这个屏幕上的粘贴图标直接输入它。 +\n\t\t3.3 二维码: 钱包会创建一个你可以从这个屏幕中的二维码按钮那里读取的二维码。 + 用于应用程序界面 暗色、亮色或系统主题 自动加载图像和 GIF @@ -919,6 +951,16 @@ 通过创建专用于关键词和标签检索的中继列表能够改善搜索结果。 设置 1 ~ 3 个支持 NIP-50 的中继,用于关键词和标签检索。 示例:\n - nostr.wine\n - reiny.nostr.band\n - reliy.noswhere.com + 发件箱中继 + 设置您的公共发件箱中继来发布内容 + 创建专为接收您的内容而设计的中继列表对于您的Nostr体验至关重要,也是您的关注者找到您的唯一途径。 + 插入 1-3 个接收你帖子的中继。确保它们不需要付款,如果你没有付费来插入 + 好的选项是:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social + 收件箱中继 + 设置您的公共收件箱中继来接收通知 + 创建专为接收通知而设计的中继列表对于您的 Nostr 体验至关重要。 + 插入 1-3 个为你接受通知的中继。确保它们不需要发送者的付款或 web of trust,否则你只能接收付款人或长期用户的通知 + 好的选项是:\n - nos.lol\n - nostr.mom\n - nostr.bitcoiner.social DM 上传 中继设置 公共时间线中继 diff --git a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt index a0b832feb..d49ad783c 100644 --- a/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt +++ b/commons/src/androidTest/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParserTest.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import android.R.attr.text import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.EmptyTagList import org.junit.Assert @@ -31,7 +30,7 @@ import org.junit.runner.RunWith class GalleryParserTest { @Test fun testMixedImageAndVideoRenderedIndividually() { - // Test the bug: when mixed image + video, both should be rendered individually + // Test that when image + video are in consecutive paragraphs, both should be rendered individually (not as gallery) val text = "Renfield (2023)\n" + "https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg\n" + @@ -39,19 +38,29 @@ class GalleryParserTest { val state = RichTextParser().parseText(text, EmptyTagList, null) - // Should have 2 image segments (image + video URLs) - Assert.assertEquals(2, state.paragraphs.size) + // Should have 3 paragraphs (text, image, video) + Assert.assertEquals(3, state.paragraphs.size) + // First paragraph is text Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) Assert.assertEquals(1, state.paragraphs[0].words.size) - Assert.assertTrue(state.paragraphs[1] is ImageGalleryParagraph) + + // Second paragraph is image (rendered individually, not as gallery) + Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) + Assert.assertEquals(1, state.paragraphs[1].words.size) + Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment) Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[1].words[0].segmentText) - Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[1].words[1].segmentText) + + // Third paragraph is video (rendered individually) + Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) + Assert.assertEquals(1, state.paragraphs[2].words.size) + Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment) + Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[2].words[0].segmentText) } @Test fun testMixedImageAndVideoRenderedIndividuallyDoubled() { - // Test the bug: when mixed image + video, both should be rendered individually + // Test that when image + video are repeated, both should be rendered individually (not as gallery) val text = "Renfield (2023)\n" + "https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg\n" + @@ -62,19 +71,28 @@ class GalleryParserTest { val state = RichTextParser().parseText(text, EmptyTagList, null) - // Should have 2 image segments (image + video URLs) - Assert.assertEquals(4, state.paragraphs.size) + // Should have 6 paragraphs (text, image, video, text, image, video) + Assert.assertEquals(6, state.paragraphs.size) + // First set: text, image, video Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) Assert.assertEquals(1, state.paragraphs[0].words.size) - Assert.assertTrue(state.paragraphs[1] is ImageGalleryParagraph) + Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) + Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment) Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[1].words[0].segmentText) - Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[1].words[1].segmentText) Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) - Assert.assertEquals(1, state.paragraphs[2].words.size) - Assert.assertTrue(state.paragraphs[3] is ImageGalleryParagraph) - Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[3].words[0].segmentText) - Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[3].words[1].segmentText) + Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment) + Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[2].words[0].segmentText) + + // Second set: text, image, video + Assert.assertTrue(state.paragraphs[3] !is ImageGalleryParagraph) + Assert.assertEquals(1, state.paragraphs[3].words.size) + Assert.assertTrue(state.paragraphs[4] !is ImageGalleryParagraph) + Assert.assertTrue(state.paragraphs[4].words[0] is ImageSegment) + Assert.assertEquals("https://image.tmdb.org/t/p/original/ekfIcBvqfqKbI6m227NFipBNh7O.jpg", state.paragraphs[4].words[0].segmentText) + Assert.assertTrue(state.paragraphs[5] !is ImageGalleryParagraph) + Assert.assertTrue(state.paragraphs[5].words[0] is VideoSegment) + Assert.assertEquals("https://archive.org/download/cinema-horror-sci-fi/Renfield.2023.ia.mp4", state.paragraphs[5].words[0].segmentText) } @Test @@ -141,4 +159,67 @@ class GalleryParserTest { Assert.assertEquals("https://relay.dergigi.com/d6a3e33b101fe219ef251ac6261c10392c2af9918c3c252d4c202016b0b4ec83.jpg", state.paragraphs[1].words[0].segmentText) Assert.assertEquals("https://relay.dergigi.com/d60c9c562912573f214c2b1958cc20bf8913cd718d2ed9e020621e3e3120634b.jpg", state.paragraphs[1].words[1].segmentText) } + + @Test + fun testMixedImageAndVideoInSameParagraph() { + // Test the user's specific example: mixed image and video in same paragraph should render individually + val text = + "The Crow (1994)\n" + + "https://image.tmdb.org/t/p/original/1r7iOhXSbbuUTORNEUOvCUkQ86K.jpg\n" + + "https://archive.org/download/the-crow-1994_20231024/The%20Crow%201994.rar/The%20Crow%201994.1080p.BluRay.x264%20.%20NVEE%2FThe%20Crow%201994.1080p.BluRay.x264%20.%20NVEE.mp4\n" + + "#fantasy #action #thriller #film #movies #kinostr" + val state = RichTextParser().parseText(text, EmptyTagList, null) + + // Should have 4 paragraphs (text, image, video, hashtags) + Assert.assertEquals(4, state.paragraphs.size) + + // First paragraph is text + Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) + + // Second paragraph is image (rendered individually, not as gallery) + Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) + Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment) + Assert.assertEquals("https://image.tmdb.org/t/p/original/1r7iOhXSbbuUTORNEUOvCUkQ86K.jpg", state.paragraphs[1].words[0].segmentText) + + // Third paragraph is video (rendered individually) + Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) + Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment) + Assert.assertEquals("https://archive.org/download/the-crow-1994_20231024/The%20Crow%201994.rar/The%20Crow%201994.1080p.BluRay.x264%20.%20NVEE%2FThe%20Crow%201994.1080p.BluRay.x264%20.%20NVEE.mp4", state.paragraphs[2].words[0].segmentText) + + // Fourth paragraph is hashtags + Assert.assertTrue(state.paragraphs[3] !is ImageGalleryParagraph) + } + + @Test + fun testDesperadoExample() { + // Test the exact example provided by the user + val text = + "Desperado (1995)\n" + + "https://media.themoviedb.org/t/p/w440_and_h660_face/e3gwpBeXpvGZsxUya9zNym5QXrw.jpg\n" + + "https://archive.org/download/desperado.-1995.1080p.-blu-ray.x-264.-yify/Desperado.1995.1080p.BluRay.x264.YIFY.mp4\n" + + "#action #crime #film #movies #kinostr" + val state = RichTextParser().parseText(text, EmptyTagList, null) + + // Should have 4 paragraphs (text, image, video, hashtags) + Assert.assertEquals(4, state.paragraphs.size) + + // First paragraph is text + Assert.assertTrue(state.paragraphs[0] !is ImageGalleryParagraph) + Assert.assertEquals("Desperado (1995)", state.paragraphs[0].words[0].segmentText) + + // Second paragraph is image (rendered individually, not as gallery) + Assert.assertTrue(state.paragraphs[1] !is ImageGalleryParagraph) + Assert.assertEquals(1, state.paragraphs[1].words.size) + Assert.assertTrue(state.paragraphs[1].words[0] is ImageSegment) + Assert.assertEquals("https://media.themoviedb.org/t/p/w440_and_h660_face/e3gwpBeXpvGZsxUya9zNym5QXrw.jpg", state.paragraphs[1].words[0].segmentText) + + // Third paragraph is video (rendered individually) + Assert.assertTrue(state.paragraphs[2] !is ImageGalleryParagraph) + Assert.assertEquals(1, state.paragraphs[2].words.size) + Assert.assertTrue(state.paragraphs[2].words[0] is VideoSegment) + Assert.assertEquals("https://archive.org/download/desperado.-1995.1080p.-blu-ray.x-264.-yify/Desperado.1995.1080p.BluRay.x264.YIFY.mp4", state.paragraphs[2].words[0].segmentText) + + // Fourth paragraph is hashtags + Assert.assertTrue(state.paragraphs[3] !is ImageGalleryParagraph) + } } diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt index 51eec881d..6eb4d9700 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt @@ -37,6 +37,7 @@ class GalleryParser { paragraph.words.forEach { word -> when (word) { is ImageSegment, is Base64Segment -> imageCount++ + is VideoSegment -> hasNonWhitespaceNonImageContent = true // Videos are not images is RegularTextSegment -> { if (word.segmentText.isNotBlank()) { hasNonWhitespaceNonImageContent = true @@ -146,13 +147,18 @@ class GalleryParser { val word = paragraph.words[i] if (word is ImageSegment || word is Base64Segment) { - // Collect consecutive image/whitespace segments without extra list allocations + // Collect consecutive image/whitespace segments (but not videos) val imageSegments = mutableListOf() var j = i + var hasVideo = false while (j < n) { val seg = paragraph.words[j] when { + seg is VideoSegment -> { + hasVideo = true + break + } seg is ImageSegment || seg is Base64Segment -> imageSegments.add(seg) seg is RegularTextSegment && seg.segmentText.isBlank() -> { /* skip whitespace */ } else -> break @@ -160,8 +166,9 @@ class GalleryParser { j++ } - if (imageSegments.size <= 1) { - currentParagraphSegments.add(word) + // If we found a video, don't create a gallery - render images individually + if (hasVideo || imageSegments.size <= 1) { + currentParagraphSegments.addAll(imageSegments) } else { if (currentParagraphSegments.isNotEmpty()) { resultingParagraphs.add(ParagraphState(currentParagraphSegments.toImmutableList(), paragraph.isRTL)) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index edeebed7f..14bc34a4f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -139,9 +139,12 @@ class RichTextParser { val imagesForPager = urlSet.mapNotNull { fullUrl -> createMediaContent(fullUrl, imetas, content, callbackUri) }.associateBy { it.url } + val imageUrls = imagesForPager.filterValues { it is MediaUrlImage }.keys + val videoUrls = imagesForPager.filterValues { it is MediaUrlVideo }.keys + val emojiMap = CustomEmoji.createEmojiMap(tags) - val segments = findTextSegments(content, imagesForPager.keys, urlSet, emojiMap, tags) + val segments = findTextSegments(content, imageUrls, videoUrls, urlSet, emojiMap, tags) val base64Images = segments.map { it.words.filterIsInstance() }.flatten() @@ -164,6 +167,7 @@ class RichTextParser { private fun findTextSegments( content: String, images: Set, + videos: Set, urls: Set, emojis: Map, tags: ImmutableListOfLists, @@ -177,7 +181,7 @@ class RichTextParser { val wordList = paragraph.trimEnd().split(' ') val segments = ArrayList(wordList.size) wordList.forEach { word -> - segments.add(wordIdentifier(word, images, urls, emojis, tags)) + segments.add(wordIdentifier(word, images, videos, urls, emojis, tags)) } paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL)) @@ -229,6 +233,7 @@ class RichTextParser { private fun wordIdentifier( word: String, images: Set, + videos: Set, urls: Set, emojis: Map, tags: ImmutableListOfLists, @@ -239,6 +244,8 @@ class RichTextParser { if (images.contains(word)) return ImageSegment(word) + if (videos.contains(word)) return VideoSegment(word) + if (urls.contains(word)) return LinkSegment(word) if (CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 4fd05413a..f826220c6 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -58,6 +58,11 @@ class ImageSegment( segment: String, ) : Segment(segment) +@Immutable +class VideoSegment( + segment: String, +) : Segment(segment) + @Immutable class LinkSegment( segment: String, diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/JsonMapperNip55.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/JsonMapperNip55.kt index b42945d9d..eedede150 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/JsonMapperNip55.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/JsonMapperNip55.kt @@ -35,7 +35,6 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.PermissionDeserializer import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.PermissionSerializer import java.io.InputStream -import kotlin.jvm.java object JsonMapperNip55 { val defaultMapper: ObjectMapper = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index e5e6d4c98..c9fb6784e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -35,7 +35,6 @@ import com.vitorpamplona.quartz.nip01Core.metadata.tags.Nip05Tag import com.vitorpamplona.quartz.nip01Core.metadata.tags.PictureTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.PronounsTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.WebsiteTag -import com.vitorpamplona.quartz.nip01Core.metadata.website import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt index 81d7789b1..06ef476b4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/pool/PoolCounts.kt @@ -33,7 +33,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.MutableStateFlow -import kotlin.collections.plus class PoolCounts { private var queries = LargeCache>>() @@ -102,7 +101,7 @@ class PoolCounts { } } - suspend fun syncState( + fun syncState( relay: NormalizedRelayUrl, sync: (Command) -> Unit, ) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivation.kt index f34a195b8..0975c8e5b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip06KeyDerivation/Bip32SeedDerivation.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.nip06KeyDerivation -import androidx.compose.runtime.key import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.mac.MacInstance @@ -77,9 +76,9 @@ class Bip32SeedDerivation { return ExtendedPrivateKey(key, ir) } - public fun writeInt32BE(n: Int): ByteArray = ByteArray(Int.SIZE_BYTES).also { writeInt32BE(n, it) } + fun writeInt32BE(n: Int): ByteArray = ByteArray(Int.SIZE_BYTES).also { writeInt32BE(n, it) } - public fun writeInt32BE( + fun writeInt32BE( n: Int, bs: ByteArray, off: Int = 0, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt index 8751562e2..fdea6b724 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt @@ -38,8 +38,6 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NSec import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException -import kotlin.sequences.forEach -import kotlin.text.Regex object Nip19Parser { private val nip19PlusNip46regex: Regex = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt index ea9b6fe9e..6f9f633c0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/WrappedEvent.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.nip59Giftwrap import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import kotlinx.serialization.Transient @Immutable open class WrappedEvent( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt index d2cd247e8..44b6d8bb8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/definition/AppMetadata.kt @@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.nip89AppHandlers.definition import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.core.JsonMapper -import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppMetadata import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes import kotlinx.serialization.SerialName diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt index 516cd049a..c4e1500b3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip90Dvms/NIP90ContentDiscoveryResponseEvent.kt @@ -30,7 +30,6 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.bytesUsedInMemory import com.vitorpamplona.quartz.utils.pointerSizeInBytes -import kotlinx.serialization.Transient @Immutable class NIP90ContentDiscoveryResponseEvent( diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/RandomInstanceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/RandomInstanceTest.kt index 921987541..2d92c16d6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/RandomInstanceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/RandomInstanceTest.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.quartz import com.vitorpamplona.quartz.utils.RandomInstance -import kotlin.collections.all import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt index e362ede01..84f062b18 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt @@ -24,10 +24,6 @@ import com.vitorpamplona.quartz.TestResourceLoader import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.hints.HintIndexerTest.Companion.addresses -import com.vitorpamplona.quartz.nip01Core.hints.HintIndexerTest.Companion.eventIds -import com.vitorpamplona.quartz.nip01Core.hints.HintIndexerTest.Companion.indexer -import com.vitorpamplona.quartz.nip01Core.hints.HintIndexerTest.Companion.keys import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import com.vitorpamplona.quartz.utils.RandomInstance diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index a57fcf79d..958bd7381 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -59,7 +59,6 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer import java.io.InputStream -import kotlin.jvm.java class JacksonMapper { companion object { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt index 21d00b3b0..20f94d0b4 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/relay/commands/toClient/CountResultSerializer.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.nip01Core.relay.commands.toClient import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.ser.std.StdSerializer -import kotlin.jvm.java class CountResultSerializer : StdSerializer(CountResult::class.java) { override fun serialize( diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerMessageDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerMessageDeserializer.kt index fe1159b11..38584bdfa 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerMessageDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerMessageDeserializer.kt @@ -27,7 +27,6 @@ import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse -import kotlin.jvm.java class BunkerMessageDeserializer : StdDeserializer(BunkerMessage::class.java) { override fun deserialize( diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerRequestSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerRequestSerializer.kt index 46c0b52ee..a3f89ea4a 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerRequestSerializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerRequestSerializer.kt @@ -24,7 +24,6 @@ import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.SerializerProvider import com.fasterxml.jackson.databind.ser.std.StdSerializer import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest -import kotlin.collections.forEach class BunkerRequestSerializer : StdSerializer(BunkerRequest::class.java) { override fun serialize( 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) + } + } }