From 96b8c77bcdbbc096f76ced3ded02c994a1275f2b Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 22 Sep 2025 18:11:29 +0200 Subject: [PATCH 01/32] there is a plan --- .../amethyst/service/uploads/MediaCompressor.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index 9f5cfe255f..ba61af1809 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -49,6 +49,19 @@ class MediaCompressorResult( val size: Long?, ) +/** The plan + * 1. Check input resolution and input fps + * 2. Create configuration matrix: for each quality level, set bitrate based on input resolution + * 3. Create Configuration with no quality setting, a bitrate setting, resizer, streamable = true, isMinBitrateCheckEnabled=false + * + * + * Don't use Configuration.quality which only determines bitrate. Instead let's create aggressive bitrates based on input and selected quality + * H265 is not supported, use bitrates that suit h264 + * Detect fps in source, multiple bitrate by 1.5 if 60fps or higher + * Bitrate floor has to be 1Mbps for now + * Future extension: Modify library to use bps for bitrate instead of Mbps which allows only 1Mbps as lowest and increments of 1 (Int) + * + */ class MediaCompressor { // ALL ERRORS ARE IGNORED. The original file is returned. suspend fun compress( From 931c6681c6e52d9d78b1bb04b4a636a30e49d80d Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 22 Sep 2025 18:20:30 +0200 Subject: [PATCH 02/32] added compression rules --- .../service/uploads/MediaCompressor.kt | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index ba61af1809..72c6da6171 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -49,6 +49,50 @@ class MediaCompressorResult( val size: Long?, ) +data class CompressionRule( + val width: Int, + val height: Int, + val bitrateMbps: Float, + val description: String, +) + +private val compressionRules = + mapOf( + CompressorQuality.LOW to + mapOf( + "4K" to CompressionRule(1280, 720, 2f, "4K→720p, 2Mbps"), + "1440p" to CompressionRule(1280, 720, 2f, "1440p→720p, 2Mbps"), + "1080p" to CompressionRule(854, 480, 1f, "1080p→480p, 1Mbps"), + "720p" to CompressionRule(640, 360, 1f, "720p→360p, 1Mbps"), + "480p" to CompressionRule(426, 240, 1f, "480p→240p, 1Mbps"), + "360p" to CompressionRule(426, 240, 0.3f, "360p→240p, 0.3Mbps"), + "240p" to CompressionRule(320, 180, 0.2f, "240p→180p, 0.2Mbps"), + "default" to CompressionRule(854, 480, 1f, "Low quality fallback, 1Mbps"), + ), + CompressorQuality.MEDIUM to + mapOf( + "4K" to CompressionRule(1920, 1080, 6f, "4K→1080p, 6Mbps"), + "1440p" to CompressionRule(1920, 1080, 6f, "1440p→1080p, 6Mbps"), + "1080p" to CompressionRule(1280, 720, 3f, "1080p→720p, 3Mbps"), + "720p" to CompressionRule(854, 480, 2f, "720p→480p, 2Mbps"), + "480p" to CompressionRule(640, 360, 1f, "480p→360p, 1Mbps"), + "360p" to CompressionRule(426, 240, 0.5f, "360p→240p, 0.5Mbps"), + "240p" to CompressionRule(320, 180, 0.3f, "240p→180p, 0.3Mbps"), + "default" to CompressionRule(1280, 720, 2f, "Medium quality fallback, 2Mbps"), + ), + CompressorQuality.HIGH to + mapOf( + "4K" to CompressionRule(3840, 2160, 16f, "4K→4K, 16Mbps"), + "1440p" to CompressionRule(1920, 1080, 8f, "1440p→1080p, 8Mbps"), + "1080p" to CompressionRule(1920, 1080, 6f, "1080p→1080p, 6Mbps"), + "720p" to CompressionRule(1280, 720, 3f, "720p→720p, 3Mbps"), + "480p" to CompressionRule(854, 480, 2f, "480p→480p, 2Mbps"), + "360p" to CompressionRule(640, 360, 1f, "360p→360p, 1Mbps"), + "240p" to CompressionRule(426, 240, 0.5f, "240p→240p, 0.5Mbps"), + "default" to CompressionRule(1920, 1080, 3f, "High quality fallback, 3Mbps"), + ), + ) + /** The plan * 1. Check input resolution and input fps * 2. Create configuration matrix: for each quality level, set bitrate based on input resolution From 87f224b6b85d66113002859ce5711cec392bcd3a Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 22 Sep 2025 18:28:23 +0200 Subject: [PATCH 03/32] get video resolution, framerate and rotation --- .../service/uploads/MediaCompressor.kt | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index 72c6da6171..730384d95e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.uploads import android.content.Context import android.graphics.Bitmap +import android.media.MediaMetadataRetriever import android.net.Uri import androidx.core.net.toUri import androidx.media3.common.MimeTypes @@ -56,6 +57,31 @@ data class CompressionRule( val description: String, ) +private data class VideoInfo( + val resolution: VideoResolution, + val framerate: Float, +) + +data class VideoResolution( + val width: Int, + val height: Int, +) { + val pixels: Int get() = width * height + val isPortrait: Boolean get() = height > width + + fun getStandardName(): String = + when { + pixels >= 3840 * 2160 -> "4K" + pixels >= 2560 * 1440 -> "1440p" + pixels >= 1920 * 1080 -> "1080p" + pixels >= 1280 * 720 -> "720p" + pixels >= 854 * 480 -> "480p" + pixels >= 640 * 360 -> "360p" + pixels >= 426 * 240 -> "240p" + else -> "${width}x$height" + } +} + private val compressionRules = mapOf( CompressorQuality.LOW to @@ -93,9 +119,43 @@ private val compressionRules = ), ) +private fun getVideoInfo( + uri: Uri, + context: Context, +): VideoInfo? = + try { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(context, uri) + val width = retriever.prepareVideoWidth() + val height = retriever.prepareVideoHeight() + val rotation = retriever.prepareRotation() ?: 0 + + // Get framerate + val framerateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE) + val framerate = framerateString?.toFloatOrNull() ?: 30.0f + + retriever.release() + + if (width != null && height != null && width > 0 && height > 0) { + // Account for rotation + val resolution = + if (rotation == 90 || rotation == 270) { + VideoResolution(height, width) + } else { + VideoResolution(width, height) + } + VideoInfo(resolution, framerate) + } else { + null + } + } catch (e: Exception) { + Log.w("MediaCompressor", "Failed to get video resolution: ${e.message}") + null + } + /** The plan - * 1. Check input resolution and input fps - * 2. Create configuration matrix: for each quality level, set bitrate based on input resolution + * xxx 1. Check input resolution and input fps + * xxx 2. Create configuration matrix: for each quality level, set bitrate based on input resolution * 3. Create Configuration with no quality setting, a bitrate setting, resizer, streamable = true, isMinBitrateCheckEnabled=false * * From 97d6c791c09e187f271e05b24f9a19658a5e5349 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 22 Sep 2025 19:00:56 +0200 Subject: [PATCH 04/32] determine resizer and bitrate from input source combined with our compression rules use 1 as lowest BitrateMbps --- .../service/uploads/MediaCompressor.kt | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index 730384d95e..b0f113d5d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -28,9 +28,9 @@ import androidx.core.net.toUri import androidx.media3.common.MimeTypes import com.abedelazizshe.lightcompressorlibrary.CompressionListener import com.abedelazizshe.lightcompressorlibrary.VideoCompressor -import com.abedelazizshe.lightcompressorlibrary.VideoQuality import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration import com.abedelazizshe.lightcompressorlibrary.config.Configuration +import com.abedelazizshe.lightcompressorlibrary.config.VideoResizer import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils import com.vitorpamplona.quartz.utils.Log @@ -41,6 +41,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import java.io.File import kotlin.coroutines.resume +import kotlin.math.roundToInt import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -55,7 +56,16 @@ data class CompressionRule( val height: Int, val bitrateMbps: Float, val description: String, -) +) { + fun getBitrateMbpsInt(): Int { + // Library doesn't support float so we have to convert it to int and use 1 as minimum + return if (bitrateMbps < 1) { + 1 + } else { + bitrateMbps.roundToInt() + } + } +} private data class VideoInfo( val resolution: VideoResolution, @@ -157,6 +167,7 @@ private fun getVideoInfo( * xxx 1. Check input resolution and input fps * xxx 2. Create configuration matrix: for each quality level, set bitrate based on input resolution * 3. Create Configuration with no quality setting, a bitrate setting, resizer, streamable = true, isMinBitrateCheckEnabled=false + * 4. Don't upload converted file if compression results in larger file * * * Don't use Configuration.quality which only determines bitrate. Instead let's create aggressive bitrates based on input and selected quality @@ -200,18 +211,24 @@ class MediaCompressor { applicationContext: Context, mediaQuality: CompressorQuality, ): MediaCompressorResult { - val videoQuality = - when (mediaQuality) { - CompressorQuality.VERY_LOW -> VideoQuality.VERY_LOW - // Override user selection LOW to use VERY_LOW for better video streaming experience - CompressorQuality.LOW -> VideoQuality.VERY_LOW - CompressorQuality.MEDIUM -> VideoQuality.MEDIUM - CompressorQuality.HIGH -> VideoQuality.HIGH - CompressorQuality.VERY_HIGH -> VideoQuality.VERY_HIGH - else -> VideoQuality.MEDIUM + val videoInfo = getVideoInfo(uri, applicationContext) + + val videoBitrateInMbps = + if (videoInfo != null) { + compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName()).getBitrateMbpsInt() + } else { + // Default/fallback logic when videoInfo is null + 2 } - Log.d("MediaCompressor", "Using video compression $videoQuality") + val resizer = + if (videoInfo != null) { + val rules = compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName()) + VideoResizer.limitSize(rules.width.toDouble(), rules.height.toDouble()) + } else { + // null VideoResizer should result in unchanged resolution + null + } val result = withTimeoutOrNull(30000) { @@ -221,7 +238,7 @@ class MediaCompressor { context = applicationContext, // => Source can be provided as content uris uris = listOf(uri), - isStreamable = false, + isStreamable = true, // THIS STORAGE // sharedStorageConfiguration = SharedStorageConfiguration( // saveAt = SaveLocation.movies, // => default is movies @@ -231,9 +248,11 @@ class MediaCompressor { storageConfiguration = AppSpecificStorageConfiguration(), configureWith = Configuration( - quality = videoQuality, + videoBitrateInMbps = videoBitrateInMbps, + resizer = resizer, // => required name videoNames = listOf(Uuid.random().toString()), + isMinBitrateCheckEnabled = false, ), listener = object : CompressionListener { From f5202dd8a989e10b8ccd376411bf1f4ed282dd53 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 22 Sep 2025 19:29:58 +0200 Subject: [PATCH 05/32] added log statements --- .../amethyst/service/uploads/MediaCompressor.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index b0f113d5d9..b55bd03b9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -167,7 +167,7 @@ private fun getVideoInfo( * xxx 1. Check input resolution and input fps * xxx 2. Create configuration matrix: for each quality level, set bitrate based on input resolution * 3. Create Configuration with no quality setting, a bitrate setting, resizer, streamable = true, isMinBitrateCheckEnabled=false - * 4. Don't upload converted file if compression results in larger file + * 4. Don't upload converted file if compression results in larger file (return MediaCompressorResult(uri, contentType, null)) * * * Don't use Configuration.quality which only determines bitrate. Instead let's create aggressive bitrates based on input and selected quality @@ -215,18 +215,23 @@ class MediaCompressor { val videoBitrateInMbps = if (videoInfo != null) { - compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName()).getBitrateMbpsInt() + val bitrate = compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName()).getBitrateMbpsInt() + Log.d("MediaCompressor", "Video bitrate calculated: ${bitrate}Mbps for ${videoInfo.resolution.getStandardName()} quality=$mediaQuality") + bitrate } else { // Default/fallback logic when videoInfo is null + Log.d("MediaCompressor", "Video bitrate fallback: 2Mbps (videoInfo unavailable)") 2 } val resizer = if (videoInfo != null) { val rules = compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName()) + Log.d("MediaCompressor", "Video resizer: ${videoInfo.resolution.width}x${videoInfo.resolution.height} -> ${rules.width}x${rules.height} (${rules.description})") VideoResizer.limitSize(rules.width.toDouble(), rules.height.toDouble()) } else { // null VideoResizer should result in unchanged resolution + Log.d("MediaCompressor", "Video resizer: null (original resolution preserved)") null } From f805b70ec0bb8049b708c90b0a6866b0c63f5a3f Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 22 Sep 2025 19:42:38 +0200 Subject: [PATCH 06/32] added log statements and a toast --- .../service/uploads/MediaCompressor.kt | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index b55bd03b9f..3551c70b0e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -24,6 +24,9 @@ import android.content.Context import android.graphics.Bitmap import android.media.MediaMetadataRetriever import android.net.Uri +import android.text.format.Formatter.formatFileSize +import android.util.Log +import android.widget.Toast import androidx.core.net.toUri import androidx.media3.common.MimeTypes import com.abedelazizshe.lightcompressorlibrary.CompressionListener @@ -235,6 +238,17 @@ class MediaCompressor { null } + // Get original file size for compression reporting + val originalSize = + try { + applicationContext.contentResolver.openInputStream(uri)?.use { inputStream -> + inputStream.available().toLong() + } ?: 0L + } catch (e: Exception) { + Log.w("MediaCompressor", "Failed to get original file size: ${e.message}") + 0L + } + val result = withTimeoutOrNull(30000) { suspendCancellableCoroutine { continuation -> @@ -274,7 +288,25 @@ class MediaCompressor { path: String?, ) { if (path != null) { - Log.d("MediaCompressor", "Video compression success. Compressed size [$size]") + val reductionPercent = + if (originalSize > 0) { + ((originalSize - size) * 100.0 / originalSize).toInt() + } else { + 0 + } + + // Show compression result toast + if (originalSize > 0 && size > 0) { + val message = + "Video compressed: ${formatFileSize(applicationContext, size)} " + + "(${if (reductionPercent > 0) "-$reductionPercent%" else "+${-reductionPercent}%"})" + + // Post on main thread for Toast + android.os.Handler(android.os.Looper.getMainLooper()).post { + Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() + } + } + Log.d("MediaCompressor", "Video compression success. Original size [$originalSize] -> Compressed size [$size] ($reductionPercent% reduction)") continuation.resume(MediaCompressorResult(Uri.fromFile(File(path)), contentType, size)) } else { Log.d("MediaCompressor", "Video compression successful, but returned null path") From 8588c9a6fcdc882411e35d7aa136901d0b1a65c6 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 22 Sep 2025 19:52:59 +0200 Subject: [PATCH 07/32] update plan --- .../vitorpamplona/amethyst/service/uploads/MediaCompressor.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index 3551c70b0e..8f46b90975 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -169,8 +169,10 @@ private fun getVideoInfo( /** The plan * xxx 1. Check input resolution and input fps * xxx 2. Create configuration matrix: for each quality level, set bitrate based on input resolution - * 3. Create Configuration with no quality setting, a bitrate setting, resizer, streamable = true, isMinBitrateCheckEnabled=false + * xxx 3. Create Configuration with no quality setting, a bitrate setting, resizer, streamable = true, isMinBitrateCheckEnabled=false * 4. Don't upload converted file if compression results in larger file (return MediaCompressorResult(uri, contentType, null)) + * xxx 5. Add toast message about file size saving + * 7. refactor (helper class for video compression) * * * Don't use Configuration.quality which only determines bitrate. Instead let's create aggressive bitrates based on input and selected quality From 49dc63c876e415cc76451cfc77798aad5bf19ad4 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 22 Sep 2025 20:22:45 +0200 Subject: [PATCH 08/32] Sanity check: if compressed file is larger than original, return original --- .../amethyst/service/uploads/MediaCompressor.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index 8f46b90975..7260adae44 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -290,6 +290,13 @@ class MediaCompressor { path: String?, ) { if (path != null) { + // Sanity check: if compressed file is larger than original, return original + if (originalSize > 0 && size >= originalSize) { + Log.d("MediaCompressor", "Compressed file ($size bytes) is larger than original ($originalSize bytes). Using original file.") + continuation.resume(MediaCompressorResult(uri, contentType, null)) + return + } + val reductionPercent = if (originalSize > 0) { ((originalSize - size) * 100.0 / originalSize).toInt() From 524ead2eacba55a96774b9be1615ff1a31427cad Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 22 Sep 2025 20:51:24 +0200 Subject: [PATCH 09/32] move video compression into helper --- .../service/uploads/MediaCompressor.kt | 130 +------- .../service/uploads/VideoCompressionHelper.kt | 287 ++++++++++++++++++ 2 files changed, 294 insertions(+), 123 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index 7260adae44..6655472005 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -22,18 +22,10 @@ package com.vitorpamplona.amethyst.service.uploads import android.content.Context import android.graphics.Bitmap -import android.media.MediaMetadataRetriever import android.net.Uri -import android.text.format.Formatter.formatFileSize import android.util.Log -import android.widget.Toast import androidx.core.net.toUri import androidx.media3.common.MimeTypes -import com.abedelazizshe.lightcompressorlibrary.CompressionListener -import com.abedelazizshe.lightcompressorlibrary.VideoCompressor -import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration -import com.abedelazizshe.lightcompressorlibrary.config.Configuration -import com.abedelazizshe.lightcompressorlibrary.config.VideoResizer import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils import com.vitorpamplona.quartz.utils.Log @@ -54,125 +46,14 @@ class MediaCompressorResult( val size: Long?, ) -data class CompressionRule( - val width: Int, - val height: Int, - val bitrateMbps: Float, - val description: String, -) { - fun getBitrateMbpsInt(): Int { - // Library doesn't support float so we have to convert it to int and use 1 as minimum - return if (bitrateMbps < 1) { - 1 - } else { - bitrateMbps.roundToInt() - } - } -} - -private data class VideoInfo( - val resolution: VideoResolution, - val framerate: Float, -) - -data class VideoResolution( - val width: Int, - val height: Int, -) { - val pixels: Int get() = width * height - val isPortrait: Boolean get() = height > width - - fun getStandardName(): String = - when { - pixels >= 3840 * 2160 -> "4K" - pixels >= 2560 * 1440 -> "1440p" - pixels >= 1920 * 1080 -> "1080p" - pixels >= 1280 * 720 -> "720p" - pixels >= 854 * 480 -> "480p" - pixels >= 640 * 360 -> "360p" - pixels >= 426 * 240 -> "240p" - else -> "${width}x$height" - } -} - -private val compressionRules = - mapOf( - CompressorQuality.LOW to - mapOf( - "4K" to CompressionRule(1280, 720, 2f, "4K→720p, 2Mbps"), - "1440p" to CompressionRule(1280, 720, 2f, "1440p→720p, 2Mbps"), - "1080p" to CompressionRule(854, 480, 1f, "1080p→480p, 1Mbps"), - "720p" to CompressionRule(640, 360, 1f, "720p→360p, 1Mbps"), - "480p" to CompressionRule(426, 240, 1f, "480p→240p, 1Mbps"), - "360p" to CompressionRule(426, 240, 0.3f, "360p→240p, 0.3Mbps"), - "240p" to CompressionRule(320, 180, 0.2f, "240p→180p, 0.2Mbps"), - "default" to CompressionRule(854, 480, 1f, "Low quality fallback, 1Mbps"), - ), - CompressorQuality.MEDIUM to - mapOf( - "4K" to CompressionRule(1920, 1080, 6f, "4K→1080p, 6Mbps"), - "1440p" to CompressionRule(1920, 1080, 6f, "1440p→1080p, 6Mbps"), - "1080p" to CompressionRule(1280, 720, 3f, "1080p→720p, 3Mbps"), - "720p" to CompressionRule(854, 480, 2f, "720p→480p, 2Mbps"), - "480p" to CompressionRule(640, 360, 1f, "480p→360p, 1Mbps"), - "360p" to CompressionRule(426, 240, 0.5f, "360p→240p, 0.5Mbps"), - "240p" to CompressionRule(320, 180, 0.3f, "240p→180p, 0.3Mbps"), - "default" to CompressionRule(1280, 720, 2f, "Medium quality fallback, 2Mbps"), - ), - CompressorQuality.HIGH to - mapOf( - "4K" to CompressionRule(3840, 2160, 16f, "4K→4K, 16Mbps"), - "1440p" to CompressionRule(1920, 1080, 8f, "1440p→1080p, 8Mbps"), - "1080p" to CompressionRule(1920, 1080, 6f, "1080p→1080p, 6Mbps"), - "720p" to CompressionRule(1280, 720, 3f, "720p→720p, 3Mbps"), - "480p" to CompressionRule(854, 480, 2f, "480p→480p, 2Mbps"), - "360p" to CompressionRule(640, 360, 1f, "360p→360p, 1Mbps"), - "240p" to CompressionRule(426, 240, 0.5f, "240p→240p, 0.5Mbps"), - "default" to CompressionRule(1920, 1080, 3f, "High quality fallback, 3Mbps"), - ), - ) - -private fun getVideoInfo( - uri: Uri, - context: Context, -): VideoInfo? = - try { - val retriever = MediaMetadataRetriever() - retriever.setDataSource(context, uri) - val width = retriever.prepareVideoWidth() - val height = retriever.prepareVideoHeight() - val rotation = retriever.prepareRotation() ?: 0 - - // Get framerate - val framerateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE) - val framerate = framerateString?.toFloatOrNull() ?: 30.0f - - retriever.release() - - if (width != null && height != null && width > 0 && height > 0) { - // Account for rotation - val resolution = - if (rotation == 90 || rotation == 270) { - VideoResolution(height, width) - } else { - VideoResolution(width, height) - } - VideoInfo(resolution, framerate) - } else { - null - } - } catch (e: Exception) { - Log.w("MediaCompressor", "Failed to get video resolution: ${e.message}") - null - } - /** The plan * xxx 1. Check input resolution and input fps * xxx 2. Create configuration matrix: for each quality level, set bitrate based on input resolution * xxx 3. Create Configuration with no quality setting, a bitrate setting, resizer, streamable = true, isMinBitrateCheckEnabled=false - * 4. Don't upload converted file if compression results in larger file (return MediaCompressorResult(uri, contentType, null)) + * xxx 4. Don't upload converted file if compression results in larger file (return MediaCompressorResult(uri, contentType, null)) * xxx 5. Add toast message about file size saving - * 7. refactor (helper class for video compression) + * xxx 6. refactor (helper class for video compression) + * 7. Fix toast for case when compressed file is larger than original * * * Don't use Configuration.quality which only determines bitrate. Instead let's create aggressive bitrates based on input and selected quality @@ -200,7 +81,10 @@ class MediaCompressor { // branch into compression based on content type return when { - contentType?.startsWith("video", ignoreCase = true) == true -> compressVideo(uri, contentType, applicationContext, mediaQuality) + contentType?.startsWith("video", ignoreCase = true) == true -> { + val helper = VideoCompressionHelper() + helper.compressVideo(uri, contentType, applicationContext, mediaQuality) + } contentType?.startsWith("image", ignoreCase = true) == true && !contentType.contains("gif") && !contentType.contains("svg") -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt new file mode 100644 index 0000000000..f53fae2573 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt @@ -0,0 +1,287 @@ +/** + * 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.media.MediaMetadataRetriever +import android.net.Uri +import android.text.format.Formatter.formatFileSize +import android.util.Log +import android.widget.Toast +import com.abedelazizshe.lightcompressorlibrary.CompressionListener +import com.abedelazizshe.lightcompressorlibrary.VideoCompressor +import com.abedelazizshe.lightcompressorlibrary.config.AppSpecificStorageConfiguration +import com.abedelazizshe.lightcompressorlibrary.config.Configuration +import com.abedelazizshe.lightcompressorlibrary.config.VideoResizer +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import java.io.File +import java.util.UUID +import kotlin.coroutines.resume +import kotlin.math.roundToInt + +data class VideoInfo( + val resolution: VideoResolution, + val framerate: Float, +) + +data class VideoResolution( + val width: Int, + val height: Int, +) { + val pixels: Int get() = width * height + + fun getStandardName(): String = + when { + pixels >= 3840 * 2160 -> "4K" + pixels >= 2560 * 1440 -> "1440p" + pixels >= 1920 * 1080 -> "1080p" + pixels >= 1280 * 720 -> "720p" + pixels >= 854 * 480 -> "480p" + pixels >= 640 * 360 -> "360p" + pixels >= 426 * 240 -> "240p" + else -> "${width}x$height" + } +} + +data class CompressionRule( + val width: Int, + val height: Int, + val bitrateMbps: Float, + val description: String, +) { + fun getBitrateMbpsInt(): Int { + // Library doesn't support float so we have to convert it to int and use 1 as minimum + return if (bitrateMbps < 1) { + 1 + } else { + bitrateMbps.roundToInt() + } + } +} + +class VideoCompressionHelper { + companion object { + private val compressionRules = + mapOf( + CompressorQuality.LOW to + mapOf( + "4K" to CompressionRule(1280, 720, 2f, "4K→720p, 2Mbps"), + "1440p" to CompressionRule(1280, 720, 2f, "1440p→720p, 2Mbps"), + "1080p" to CompressionRule(854, 480, 1f, "1080p→480p, 1Mbps"), + "720p" to CompressionRule(640, 360, 1f, "720p→360p, 1Mbps"), + "480p" to CompressionRule(426, 240, 1f, "480p→240p, 1Mbps"), + "360p" to CompressionRule(426, 240, 0.3f, "360p→240p, 0.3Mbps"), + "240p" to CompressionRule(320, 180, 0.2f, "240p→180p, 0.2Mbps"), + "default" to CompressionRule(854, 480, 1f, "Low quality fallback, 1Mbps"), + ), + CompressorQuality.MEDIUM to + mapOf( + "4K" to CompressionRule(1920, 1080, 6f, "4K→1080p, 6Mbps"), + "1440p" to CompressionRule(1920, 1080, 6f, "1440p→1080p, 6Mbps"), + "1080p" to CompressionRule(1280, 720, 3f, "1080p→720p, 3Mbps"), + "720p" to CompressionRule(854, 480, 2f, "720p→480p, 2Mbps"), + "480p" to CompressionRule(640, 360, 1f, "480p→360p, 1Mbps"), + "360p" to CompressionRule(426, 240, 0.5f, "360p→240p, 0.5Mbps"), + "240p" to CompressionRule(320, 180, 0.3f, "240p→180p, 0.3Mbps"), + "default" to CompressionRule(1280, 720, 2f, "Medium quality fallback, 2Mbps"), + ), + CompressorQuality.HIGH to + mapOf( + "4K" to CompressionRule(3840, 2160, 16f, "4K→4K, 16Mbps"), + "1440p" to CompressionRule(1920, 1080, 8f, "1440p→1080p, 8Mbps"), + "1080p" to CompressionRule(1920, 1080, 6f, "1080p→1080p, 6Mbps"), + "720p" to CompressionRule(1280, 720, 3f, "720p→720p, 3Mbps"), + "480p" to CompressionRule(854, 480, 2f, "480p→480p, 2Mbps"), + "360p" to CompressionRule(640, 360, 1f, "360p→360p, 1Mbps"), + "240p" to CompressionRule(426, 240, 0.5f, "240p→240p, 0.5Mbps"), + "default" to CompressionRule(1920, 1080, 3f, "High quality fallback, 3Mbps"), + ), + ) + } + + suspend fun compressVideo( + uri: Uri, + contentType: String?, + applicationContext: Context, + mediaQuality: CompressorQuality, + ): MediaCompressorResult { + val videoInfo = getVideoInfo(uri, applicationContext) + + val videoBitrateInMbps = + if (videoInfo != null) { + val bitrate = compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName()).getBitrateMbpsInt() + Log.d("VideoCompressionHelper", "Video bitrate calculated: ${bitrate}Mbps for ${videoInfo.resolution.getStandardName()} quality=$mediaQuality") + bitrate + } else { + // Default/fallback logic when videoInfo is null + Log.d("VideoCompressionHelper", "Video bitrate fallback: 2Mbps (videoInfo unavailable)") + 2 + } + + val resizer = + if (videoInfo != null) { + val rules = compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName()) + Log.d("VideoCompressionHelper", "Video resizer: ${videoInfo.resolution.width}x${videoInfo.resolution.height} -> ${rules.width}x${rules.height} (${rules.description})") + VideoResizer.limitSize(rules.width.toDouble(), rules.height.toDouble()) + } else { + // null VideoResizer should result in unchanged resolution + Log.d("VideoCompressionHelper", "Video resizer: null (original resolution preserved)") + null + } + + // Get original file size for compression reporting + val originalSize = + try { + applicationContext.contentResolver.openInputStream(uri)?.use { inputStream -> + inputStream.available().toLong() + } ?: 0L + } catch (e: Exception) { + Log.w("VideoCompressionHelper", "Failed to get original file size: ${e.message}") + 0L + } + + val result = + withTimeoutOrNull(30000) { + suspendCancellableCoroutine { continuation -> + VideoCompressor.start( + // => This is required + context = applicationContext, + // => Source can be provided as content uris + uris = listOf(uri), + isStreamable = true, + // THIS STORAGE + // sharedStorageConfiguration = SharedStorageConfiguration( + // saveAt = SaveLocation.movies, // => default is movies + // videoName = "compressed_video" // => required name + // ), + // OR AND NOT BOTH + storageConfiguration = AppSpecificStorageConfiguration(), + configureWith = + Configuration( + videoBitrateInMbps = videoBitrateInMbps, + resizer = resizer, + // => required name + videoNames = listOf(UUID.randomUUID().toString()), + isMinBitrateCheckEnabled = false, + ), + listener = + object : CompressionListener { + override fun onProgress( + index: Int, + percent: Float, + ) {} + + override fun onStart(index: Int) {} + + override fun onSuccess( + index: Int, + size: Long, + path: String?, + ) { + if (path != null) { + // Sanity check: if compressed file is larger than original, return original + if (originalSize > 0 && size >= originalSize) { + Log.d("VideoCompressionHelper", "Compressed file ($size bytes) is larger than original ($originalSize bytes). Using original file.") + continuation.resume(MediaCompressorResult(uri, contentType, null)) + return + } + + val reductionPercent = + if (originalSize > 0) { + ((originalSize - size) * 100.0 / originalSize).toInt() + } else { + 0 + } + + // Show compression result toast + if (originalSize > 0 && size > 0) { + val message = + "Video compressed: ${formatFileSize(applicationContext, size)} " + + "(${if (reductionPercent > 0) "-$reductionPercent%" else "+${-reductionPercent}%"})" + + // Post on main thread for Toast + android.os.Handler(android.os.Looper.getMainLooper()).post { + Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() + } + } + Log.d("VideoCompressionHelper", "Video compression success. Original size [$originalSize] -> Compressed size [$size] ($reductionPercent% reduction)") + continuation.resume(MediaCompressorResult(Uri.fromFile(File(path)), contentType, size)) + } else { + Log.d("VideoCompressionHelper", "Video compression successful, but returned null path") + continuation.resume(null) + } + } + + override fun onFailure( + index: Int, + failureMessage: String, + ) { + Log.d("VideoCompressionHelper", "Video compression failed: $failureMessage") + // keeps going with original video + continuation.resume(null) + } + + override fun onCancelled(index: Int) { + continuation.resume(null) + } + }, + ) + } + } + + return result ?: MediaCompressorResult(uri, contentType, null) + } + + private fun getVideoInfo( + uri: Uri, + context: Context, + ): VideoInfo? = + try { + val retriever = MediaMetadataRetriever() + retriever.setDataSource(context, uri) + val width = retriever.prepareVideoWidth() + val height = retriever.prepareVideoHeight() + val rotation = retriever.prepareRotation() ?: 0 + + // Get framerate + val framerateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE) + val framerate = framerateString?.toFloatOrNull() ?: 30.0f + + retriever.release() + + if (width != null && height != null && width > 0 && height > 0) { + // Account for rotation + val resolution = + if (rotation == 90 || rotation == 270) { + VideoResolution(height, width) + } else { + VideoResolution(width, height) + } + VideoInfo(resolution, framerate) + } else { + null + } + } catch (e: Exception) { + Log.w("VideoCompressionHelper", "Failed to get video resolution: ${e.message}") + null + } +} From 947f59fa6faa3f8b515c9f84ee9fe256a792e30b Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 22 Sep 2025 21:09:53 +0200 Subject: [PATCH 10/32] add bitrate multiplier for 60fps content fix toast when new file larger than old --- .../service/uploads/MediaCompressor.kt | 3 +- .../service/uploads/VideoCompressionHelper.kt | 55 ++++++++++++------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index 6655472005..f916d0556c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -53,7 +53,8 @@ class MediaCompressorResult( * xxx 4. Don't upload converted file if compression results in larger file (return MediaCompressorResult(uri, contentType, null)) * xxx 5. Add toast message about file size saving * xxx 6. refactor (helper class for video compression) - * 7. Fix toast for case when compressed file is larger than original + * xxx 7. Fix toast for case when compressed file is larger than original + * xxx 8. fix ratio multiplier for framerate ->60 * * * Don't use Configuration.quality which only determines bitrate. Instead let's create aggressive bitrates based on input and selected quality diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt index f53fae2573..51a2e5eaa6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.service.uploads import android.content.Context import android.media.MediaMetadataRetriever import android.net.Uri +import android.os.Handler +import android.os.Looper import android.text.format.Formatter.formatFileSize import android.util.Log import android.widget.Toast @@ -128,9 +130,16 @@ class VideoCompressionHelper { val videoBitrateInMbps = if (videoInfo != null) { - val bitrate = compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName()).getBitrateMbpsInt() - Log.d("VideoCompressionHelper", "Video bitrate calculated: ${bitrate}Mbps for ${videoInfo.resolution.getStandardName()} quality=$mediaQuality") - bitrate + val baseBitrate = compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName()).getBitrateMbpsInt() + // Apply 1.5x multiplier for 60fps or higher videos + val adjustedBitrate = + if (videoInfo.framerate >= 60f) { + (baseBitrate * 1.5f).roundToInt() + } else { + baseBitrate + } + Log.d("VideoCompressionHelper", "Video bitrate calculated: ${adjustedBitrate}Mbps for ${videoInfo.resolution.getStandardName()} quality=$mediaQuality framerate=${videoInfo.framerate}fps") + adjustedBitrate } else { // Default/fallback logic when videoInfo is null Log.d("VideoCompressionHelper", "Video bitrate fallback: 2Mbps (videoInfo unavailable)") @@ -188,7 +197,8 @@ class VideoCompressionHelper { override fun onProgress( index: Int, percent: Float, - ) {} + ) { + } override fun onStart(index: Int) {} @@ -198,13 +208,6 @@ class VideoCompressionHelper { path: String?, ) { if (path != null) { - // Sanity check: if compressed file is larger than original, return original - if (originalSize > 0 && size >= originalSize) { - Log.d("VideoCompressionHelper", "Compressed file ($size bytes) is larger than original ($originalSize bytes). Using original file.") - continuation.resume(MediaCompressorResult(uri, contentType, null)) - return - } - val reductionPercent = if (originalSize > 0) { ((originalSize - size) * 100.0 / originalSize).toInt() @@ -212,16 +215,19 @@ class VideoCompressionHelper { 0 } - // Show compression result toast - if (originalSize > 0 && size > 0) { - val message = - "Video compressed: ${formatFileSize(applicationContext, size)} " + - "(${if (reductionPercent > 0) "-$reductionPercent%" else "+${-reductionPercent}%"})" + // Sanity check: if compressed file is larger than original, return original + if (originalSize > 0 && size >= originalSize) { + Log.d("VideoCompressionHelper", "Compressed file ($size bytes) is larger than original ($originalSize bytes). Using original file.") + applicationContext.showToast("Video compression didn't reduce size. Using original file.") + continuation.resume(MediaCompressorResult(uri, contentType, null)) + return + } - // Post on main thread for Toast - android.os.Handler(android.os.Looper.getMainLooper()).post { - Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() - } + if (originalSize > 0 && size > 0) { + val sizeLabel = formatFileSize(applicationContext, size) + val percentLabel = if (reductionPercent >= 0) "-$reductionPercent%" else "+${-reductionPercent}%" + + applicationContext.showToast("Video compressed: $sizeLabel ($percentLabel)") } Log.d("VideoCompressionHelper", "Video compression success. Original size [$originalSize] -> Compressed size [$size] ($reductionPercent% reduction)") continuation.resume(MediaCompressorResult(Uri.fromFile(File(path)), contentType, size)) @@ -251,6 +257,15 @@ class VideoCompressionHelper { return result ?: MediaCompressorResult(uri, contentType, null) } + private fun Context.showToast( + message: String, + duration: Int = Toast.LENGTH_LONG, + ) { + Handler(Looper.getMainLooper()).post { + Toast.makeText(this, message, duration).show() + } + } + private fun getVideoInfo( uri: Uri, context: Context, From ed758ef13ef4052d26b0f5ae1a075272527ca3b7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 22 Sep 2025 21:10:21 +0200 Subject: [PATCH 11/32] remove unused code --- .../amethyst/service/uploads/MediaCompressor.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index f916d0556c..2effe46748 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -270,15 +270,6 @@ class MediaCompressor { 3 -> CompressorQuality.UNCOMPRESSED else -> CompressorQuality.MEDIUM } - - fun compressorQualityToInt(compressorQuality: CompressorQuality): Int = - when (compressorQuality) { - CompressorQuality.LOW -> 0 - CompressorQuality.MEDIUM -> 1 - CompressorQuality.HIGH -> 2 - CompressorQuality.UNCOMPRESSED -> 3 - else -> 1 - } } } From 9af1e10ec20d50128e7f9473ede2a49d3df4ede6 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 22 Sep 2025 21:26:07 +0200 Subject: [PATCH 12/32] refactor: Safe file size lookup using OpenableColumns.SIZE if (continuation.isActive) before resuming. Better logging levels (Log.e for errors, Log.w for warnings). Configurable compression timeout --- .../service/uploads/VideoCompressionHelper.kt | 172 +++++++++++------- 1 file changed, 109 insertions(+), 63 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt index 51a2e5eaa6..9a6033fb86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt @@ -125,115 +125,139 @@ class VideoCompressionHelper { contentType: String?, applicationContext: Context, mediaQuality: CompressorQuality, + timeoutMs: Long = 60_000L, // configurable, default 60s ): MediaCompressorResult { val videoInfo = getVideoInfo(uri, applicationContext) val videoBitrateInMbps = if (videoInfo != null) { - val baseBitrate = compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName()).getBitrateMbpsInt() - // Apply 1.5x multiplier for 60fps or higher videos - val adjustedBitrate = + val baseBitrate = + compressionRules + .getValue(mediaQuality) + .getValue(videoInfo.resolution.getStandardName()) + .getBitrateMbpsInt() + + // Apply 1.5x multiplier for 60fps+ + val adjusted = if (videoInfo.framerate >= 60f) { (baseBitrate * 1.5f).roundToInt() } else { baseBitrate } - Log.d("VideoCompressionHelper", "Video bitrate calculated: ${adjustedBitrate}Mbps for ${videoInfo.resolution.getStandardName()} quality=$mediaQuality framerate=${videoInfo.framerate}fps") - adjustedBitrate + + Log.d( + "VideoCompressionHelper", + "Bitrate: ${adjusted}Mbps for ${videoInfo.resolution.getStandardName()} " + + "quality=$mediaQuality framerate=${videoInfo.framerate}fps", + ) + adjusted } else { - // Default/fallback logic when videoInfo is null - Log.d("VideoCompressionHelper", "Video bitrate fallback: 2Mbps (videoInfo unavailable)") + Log.w("VideoCompressionHelper", "Video bitrate fallback: 2Mbps (videoInfo unavailable)") 2 } val resizer = if (videoInfo != null) { - val rules = compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName()) - Log.d("VideoCompressionHelper", "Video resizer: ${videoInfo.resolution.width}x${videoInfo.resolution.height} -> ${rules.width}x${rules.height} (${rules.description})") + val rules = + compressionRules + .getValue(mediaQuality) + .getValue(videoInfo.resolution.getStandardName()) + Log.d( + "VideoCompressionHelper", + "Resizer: ${videoInfo.resolution.width}x${videoInfo.resolution.height} -> " + + "${rules.width}x${rules.height} (${rules.description})", + ) VideoResizer.limitSize(rules.width.toDouble(), rules.height.toDouble()) } else { - // null VideoResizer should result in unchanged resolution - Log.d("VideoCompressionHelper", "Video resizer: null (original resolution preserved)") + Log.d("VideoCompressionHelper", "Resizer: null (original resolution preserved)") null } - // Get original file size for compression reporting - val originalSize = - try { - applicationContext.contentResolver.openInputStream(uri)?.use { inputStream -> - inputStream.available().toLong() - } ?: 0L - } catch (e: Exception) { - Log.w("VideoCompressionHelper", "Failed to get original file size: ${e.message}") - 0L - } + // Get original file size safely + val originalSize = applicationContext.getFileSize(uri) val result = - withTimeoutOrNull(30000) { + withTimeoutOrNull(timeoutMs) { suspendCancellableCoroutine { continuation -> VideoCompressor.start( - // => This is required context = applicationContext, - // => Source can be provided as content uris uris = listOf(uri), isStreamable = true, - // THIS STORAGE - // sharedStorageConfiguration = SharedStorageConfiguration( - // saveAt = SaveLocation.movies, // => default is movies - // videoName = "compressed_video" // => required name - // ), - // OR AND NOT BOTH storageConfiguration = AppSpecificStorageConfiguration(), configureWith = Configuration( videoBitrateInMbps = videoBitrateInMbps, resizer = resizer, - // => required name videoNames = listOf(UUID.randomUUID().toString()), isMinBitrateCheckEnabled = false, ), listener = object : CompressionListener { + override fun onStart(index: Int) {} + override fun onProgress( index: Int, percent: Float, - ) { - } - - override fun onStart(index: Int) {} + ) {} override fun onSuccess( index: Int, size: Long, path: String?, ) { - if (path != null) { - val reductionPercent = - if (originalSize > 0) { - ((originalSize - size) * 100.0 / originalSize).toInt() - } else { - 0 - } + if (path == null) { + applicationContext.notifyUser( + "Video compression succeeded, but path was null", + "VideoCompressionHelper", + Log.WARN, + ) + if (continuation.isActive) continuation.resume(null) + return + } - // Sanity check: if compressed file is larger than original, return original - if (originalSize > 0 && size >= originalSize) { - Log.d("VideoCompressionHelper", "Compressed file ($size bytes) is larger than original ($originalSize bytes). Using original file.") - applicationContext.showToast("Video compression didn't reduce size. Using original file.") - continuation.resume(MediaCompressorResult(uri, contentType, null)) - return + val reductionPercent = + if (originalSize > 0) { + ((originalSize - size) * 100.0 / originalSize).toInt() + } else { + 0 } - if (originalSize > 0 && size > 0) { - val sizeLabel = formatFileSize(applicationContext, size) - val percentLabel = if (reductionPercent >= 0) "-$reductionPercent%" else "+${-reductionPercent}%" - - applicationContext.showToast("Video compressed: $sizeLabel ($percentLabel)") + // Sanity check: compression not smaller than original + if (originalSize > 0 && size >= originalSize) { + applicationContext.notifyUser( + "Compressed file larger than original. Using original.", + "VideoCompressionHelper", + Log.WARN, + ) + if (continuation.isActive) { + continuation.resume( + MediaCompressorResult(uri, contentType, null), + ) } - Log.d("VideoCompressionHelper", "Video compression success. Original size [$originalSize] -> Compressed size [$size] ($reductionPercent% reduction)") - continuation.resume(MediaCompressorResult(Uri.fromFile(File(path)), contentType, size)) - } else { - Log.d("VideoCompressionHelper", "Video compression successful, but returned null path") - continuation.resume(null) + return + } + + // Show compression result + if (originalSize > 0 && size > 0) { + val sizeLabel = formatFileSize(applicationContext, size) + val percentLabel = + if (reductionPercent >= 0) "-$reductionPercent%" else "+${-reductionPercent}%" + applicationContext.notifyUser( + "Video compressed: $sizeLabel ($percentLabel)", + "VideoCompressionHelper", + ) + } + + Log.d( + "VideoCompressionHelper", + "Compression success: Original [$originalSize] -> " + + "Compressed [$size] ($reductionPercent% reduction)", + ) + + if (continuation.isActive) { + continuation.resume( + MediaCompressorResult(Uri.fromFile(File(path)), contentType, size), + ) } } @@ -241,13 +265,17 @@ class VideoCompressionHelper { index: Int, failureMessage: String, ) { - Log.d("VideoCompressionHelper", "Video compression failed: $failureMessage") - // keeps going with original video - continuation.resume(null) + applicationContext.notifyUser( + "Video compression failed: $failureMessage", + "VideoCompressionHelper", + Log.ERROR, + ) + if (continuation.isActive) continuation.resume(null) } override fun onCancelled(index: Int) { - continuation.resume(null) + Log.w("VideoCompressionHelper", "Video compression cancelled") + if (continuation.isActive) continuation.resume(null) } }, ) @@ -257,13 +285,31 @@ class VideoCompressionHelper { return result ?: MediaCompressorResult(uri, contentType, null) } - private fun Context.showToast( + private fun Context.getFileSize(uri: Uri): Long = + try { + contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.SIZE), null, null, null)?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(android.provider.OpenableColumns.SIZE) + if (cursor.moveToFirst()) cursor.getLong(sizeIndex) else 0L + } ?: 0L + } catch (e: Exception) { + Log.w("VideoCompressionHelper", "Failed to get file size: ${e.message}") + 0L + } + + private fun Context.notifyUser( message: String, + logTag: String, + logLevel: Int = Log.DEBUG, duration: Int = Toast.LENGTH_LONG, ) { Handler(Looper.getMainLooper()).post { Toast.makeText(this, message, duration).show() } + when (logLevel) { + Log.ERROR -> Log.e(logTag, message) + Log.WARN -> Log.w(logTag, message) + else -> Log.d(logTag, message) + } } private fun getVideoInfo( From b6dd6a988b58007fbdc7b7f20ece9ee07ca8f7ae Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 22 Sep 2025 21:33:12 +0200 Subject: [PATCH 13/32] change to enum for video resolutions --- .../service/uploads/VideoCompressionHelper.kt | 88 +++++++++++-------- 1 file changed, 52 insertions(+), 36 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt index 9a6033fb86..e622c798e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt @@ -51,19 +51,35 @@ data class VideoResolution( ) { val pixels: Int get() = width * height - fun getStandardName(): String = + fun getStandard(): VideoStandard = when { - pixels >= 3840 * 2160 -> "4K" - pixels >= 2560 * 1440 -> "1440p" - pixels >= 1920 * 1080 -> "1080p" - pixels >= 1280 * 720 -> "720p" - pixels >= 854 * 480 -> "480p" - pixels >= 640 * 360 -> "360p" - pixels >= 426 * 240 -> "240p" - else -> "${width}x$height" + pixels >= 3840 * 2160 -> VideoStandard.UHD_4K + pixels >= 2560 * 1440 -> VideoStandard.QHD_1440P + pixels >= 1920 * 1080 -> VideoStandard.FHD_1080P + pixels >= 1280 * 720 -> VideoStandard.HD_720P + pixels >= 854 * 480 -> VideoStandard.SD_480P + pixels >= 640 * 360 -> VideoStandard.NHD_360P + pixels >= 426 * 240 -> VideoStandard.QVGA_240P + else -> VideoStandard.UNKNOWN } } +enum class VideoStandard( + val label: String, +) { + UHD_4K("4K"), + QHD_1440P("1440p"), + FHD_1080P("1080p"), + HD_720P("720p"), + SD_480P("480p"), + NHD_360P("360p"), + QVGA_240P("240p"), + UNKNOWN("unknown"), + ; + + override fun toString(): String = label +} + data class CompressionRule( val width: Int, val height: Int, @@ -86,36 +102,36 @@ class VideoCompressionHelper { mapOf( CompressorQuality.LOW to mapOf( - "4K" to CompressionRule(1280, 720, 2f, "4K→720p, 2Mbps"), - "1440p" to CompressionRule(1280, 720, 2f, "1440p→720p, 2Mbps"), - "1080p" to CompressionRule(854, 480, 1f, "1080p→480p, 1Mbps"), - "720p" to CompressionRule(640, 360, 1f, "720p→360p, 1Mbps"), - "480p" to CompressionRule(426, 240, 1f, "480p→240p, 1Mbps"), - "360p" to CompressionRule(426, 240, 0.3f, "360p→240p, 0.3Mbps"), - "240p" to CompressionRule(320, 180, 0.2f, "240p→180p, 0.2Mbps"), - "default" to CompressionRule(854, 480, 1f, "Low quality fallback, 1Mbps"), + VideoStandard.UHD_4K to CompressionRule(1280, 720, 2f, "4K→720p, 2Mbps"), + VideoStandard.QHD_1440P to CompressionRule(1280, 720, 2f, "1440p→720p, 2Mbps"), + VideoStandard.FHD_1080P to CompressionRule(854, 480, 1f, "1080p→480p, 1Mbps"), + VideoStandard.HD_720P to CompressionRule(640, 360, 1f, "720p→360p, 1Mbps"), + VideoStandard.SD_480P to CompressionRule(426, 240, 1f, "480p→240p, 1Mbps"), + VideoStandard.NHD_360P to CompressionRule(426, 240, 0.3f, "360p→240p, 0.3Mbps"), + VideoStandard.QVGA_240P to CompressionRule(320, 180, 0.2f, "240p→180p, 0.2Mbps"), + VideoStandard.UNKNOWN to CompressionRule(854, 480, 1f, "Low quality fallback, 1Mbps"), ), CompressorQuality.MEDIUM to mapOf( - "4K" to CompressionRule(1920, 1080, 6f, "4K→1080p, 6Mbps"), - "1440p" to CompressionRule(1920, 1080, 6f, "1440p→1080p, 6Mbps"), - "1080p" to CompressionRule(1280, 720, 3f, "1080p→720p, 3Mbps"), - "720p" to CompressionRule(854, 480, 2f, "720p→480p, 2Mbps"), - "480p" to CompressionRule(640, 360, 1f, "480p→360p, 1Mbps"), - "360p" to CompressionRule(426, 240, 0.5f, "360p→240p, 0.5Mbps"), - "240p" to CompressionRule(320, 180, 0.3f, "240p→180p, 0.3Mbps"), - "default" to CompressionRule(1280, 720, 2f, "Medium quality fallback, 2Mbps"), + VideoStandard.UHD_4K to CompressionRule(1920, 1080, 6f, "4K→1080p, 6Mbps"), + VideoStandard.QHD_1440P to CompressionRule(1920, 1080, 6f, "1440p→1080p, 6Mbps"), + VideoStandard.FHD_1080P to CompressionRule(1280, 720, 3f, "1080p→720p, 3Mbps"), + VideoStandard.HD_720P to CompressionRule(854, 480, 2f, "720p→480p, 2Mbps"), + VideoStandard.SD_480P to CompressionRule(640, 360, 1f, "480p→360p, 1Mbps"), + VideoStandard.NHD_360P to CompressionRule(426, 240, 0.5f, "360p→240p, 0.5Mbps"), + VideoStandard.QVGA_240P to CompressionRule(320, 180, 0.3f, "240p→180p, 0.3Mbps"), + VideoStandard.UNKNOWN to CompressionRule(1280, 720, 2f, "Medium quality fallback, 2Mbps"), ), CompressorQuality.HIGH to mapOf( - "4K" to CompressionRule(3840, 2160, 16f, "4K→4K, 16Mbps"), - "1440p" to CompressionRule(1920, 1080, 8f, "1440p→1080p, 8Mbps"), - "1080p" to CompressionRule(1920, 1080, 6f, "1080p→1080p, 6Mbps"), - "720p" to CompressionRule(1280, 720, 3f, "720p→720p, 3Mbps"), - "480p" to CompressionRule(854, 480, 2f, "480p→480p, 2Mbps"), - "360p" to CompressionRule(640, 360, 1f, "360p→360p, 1Mbps"), - "240p" to CompressionRule(426, 240, 0.5f, "240p→240p, 0.5Mbps"), - "default" to CompressionRule(1920, 1080, 3f, "High quality fallback, 3Mbps"), + VideoStandard.UHD_4K to CompressionRule(3840, 2160, 16f, "4K→4K, 16Mbps"), + VideoStandard.QHD_1440P to CompressionRule(1920, 1080, 8f, "1440p→1080p, 8Mbps"), + VideoStandard.FHD_1080P to CompressionRule(1920, 1080, 6f, "1080p→1080p, 6Mbps"), + VideoStandard.HD_720P to CompressionRule(1280, 720, 3f, "720p→720p, 3Mbps"), + VideoStandard.SD_480P to CompressionRule(854, 480, 2f, "480p→480p, 2Mbps"), + VideoStandard.NHD_360P to CompressionRule(640, 360, 1f, "360p→360p, 1Mbps"), + VideoStandard.QVGA_240P to CompressionRule(426, 240, 0.5f, "240p→240p, 0.5Mbps"), + VideoStandard.UNKNOWN to CompressionRule(1920, 1080, 3f, "High quality fallback, 3Mbps"), ), ) } @@ -134,7 +150,7 @@ class VideoCompressionHelper { val baseBitrate = compressionRules .getValue(mediaQuality) - .getValue(videoInfo.resolution.getStandardName()) + .getValue(videoInfo.resolution.getStandard()) .getBitrateMbpsInt() // Apply 1.5x multiplier for 60fps+ @@ -147,7 +163,7 @@ class VideoCompressionHelper { Log.d( "VideoCompressionHelper", - "Bitrate: ${adjusted}Mbps for ${videoInfo.resolution.getStandardName()} " + + "Bitrate: ${adjusted}Mbps for ${videoInfo.resolution.getStandard()} " + "quality=$mediaQuality framerate=${videoInfo.framerate}fps", ) adjusted @@ -161,7 +177,7 @@ class VideoCompressionHelper { val rules = compressionRules .getValue(mediaQuality) - .getValue(videoInfo.resolution.getStandardName()) + .getValue(videoInfo.resolution.getStandard()) Log.d( "VideoCompressionHelper", "Resizer: ${videoInfo.resolution.width}x${videoInfo.resolution.height} -> " + From 41a233d1757f7644179563a1a01c2af1bc7c42f7 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 22 Sep 2025 22:54:37 +0200 Subject: [PATCH 14/32] fix for library bug. Temp file name with "_temp" is returned instead of final file name --- .../amethyst/service/uploads/VideoCompressionHelper.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt index e622c798e0..3153797b53 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt @@ -270,9 +270,16 @@ class VideoCompressionHelper { "Compressed [$size] ($reductionPercent% reduction)", ) + // Attempt to correct the path: if it contains "_temp" then remove it + val correctedPath = + if (path.contains("_temp")) { + path.replace("_temp", "") + } else { + path + } if (continuation.isActive) { continuation.resume( - MediaCompressorResult(Uri.fromFile(File(path)), contentType, size), + MediaCompressorResult(Uri.fromFile(File(correctedPath)), contentType, size), ) } } From db9160a6c239196c23fdba96eb08549a726fc4c9 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 23 Sep 2025 09:19:10 +0200 Subject: [PATCH 15/32] finally block ensures release() is called even if exceptions occur --- .../service/uploads/VideoCompressionHelper.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt index 3153797b53..94ac643489 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt @@ -338,9 +338,10 @@ class VideoCompressionHelper { private fun getVideoInfo( uri: Uri, context: Context, - ): VideoInfo? = - try { - val retriever = MediaMetadataRetriever() + ): VideoInfo? { + var retriever: MediaMetadataRetriever? = null + return try { + retriever = MediaMetadataRetriever() retriever.setDataSource(context, uri) val width = retriever.prepareVideoWidth() val height = retriever.prepareVideoHeight() @@ -350,8 +351,6 @@ class VideoCompressionHelper { val framerateString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE) val framerate = framerateString?.toFloatOrNull() ?: 30.0f - retriever.release() - if (width != null && height != null && width > 0 && height > 0) { // Account for rotation val resolution = @@ -367,5 +366,12 @@ class VideoCompressionHelper { } catch (e: Exception) { Log.w("VideoCompressionHelper", "Failed to get video resolution: ${e.message}") null + } finally { + try { + retriever?.release() + } catch (e: Exception) { + Log.w("VideoCompressionHelper", "Failed to release MediaMetadataRetriever: ${e.message}") + } } + } } From 544d6d39335a2333ec037b560c15585355a4a529 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 23 Sep 2025 09:20:41 +0200 Subject: [PATCH 16/32] remove plan --- .../service/uploads/MediaCompressor.kt | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index 2effe46748..ba24ed61f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -46,24 +46,6 @@ class MediaCompressorResult( val size: Long?, ) -/** The plan - * xxx 1. Check input resolution and input fps - * xxx 2. Create configuration matrix: for each quality level, set bitrate based on input resolution - * xxx 3. Create Configuration with no quality setting, a bitrate setting, resizer, streamable = true, isMinBitrateCheckEnabled=false - * xxx 4. Don't upload converted file if compression results in larger file (return MediaCompressorResult(uri, contentType, null)) - * xxx 5. Add toast message about file size saving - * xxx 6. refactor (helper class for video compression) - * xxx 7. Fix toast for case when compressed file is larger than original - * xxx 8. fix ratio multiplier for framerate ->60 - * - * - * Don't use Configuration.quality which only determines bitrate. Instead let's create aggressive bitrates based on input and selected quality - * H265 is not supported, use bitrates that suit h264 - * Detect fps in source, multiple bitrate by 1.5 if 60fps or higher - * Bitrate floor has to be 1Mbps for now - * Future extension: Modify library to use bps for bitrate instead of Mbps which allows only 1Mbps as lowest and increments of 1 (Int) - * - */ class MediaCompressor { // ALL ERRORS ARE IGNORED. The original file is returned. suspend fun compress( From b030486e7e51e3fe522a3a327225e38d3b45e827 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 23 Sep 2025 09:55:03 +0200 Subject: [PATCH 17/32] fixes after rebase --- .../service/uploads/MediaCompressor.kt | 142 ------------------ 1 file changed, 142 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index ba24ed61f7..aa508e31c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.service.uploads import android.content.Context import android.graphics.Bitmap import android.net.Uri -import android.util.Log import androidx.core.net.toUri import androidx.media3.common.MimeTypes import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -32,13 +31,6 @@ import com.vitorpamplona.quartz.utils.Log import id.zelory.compressor.Compressor import id.zelory.compressor.constraint.default import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withTimeoutOrNull -import java.io.File -import kotlin.coroutines.resume -import kotlin.math.roundToInt -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid class MediaCompressorResult( val uri: Uri, @@ -76,140 +68,6 @@ class MediaCompressor { } } - @OptIn(ExperimentalUuidApi::class) - private suspend fun compressVideo( - uri: Uri, - contentType: String?, - applicationContext: Context, - mediaQuality: CompressorQuality, - ): MediaCompressorResult { - val videoInfo = getVideoInfo(uri, applicationContext) - - val videoBitrateInMbps = - if (videoInfo != null) { - val bitrate = compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName()).getBitrateMbpsInt() - Log.d("MediaCompressor", "Video bitrate calculated: ${bitrate}Mbps for ${videoInfo.resolution.getStandardName()} quality=$mediaQuality") - bitrate - } else { - // Default/fallback logic when videoInfo is null - Log.d("MediaCompressor", "Video bitrate fallback: 2Mbps (videoInfo unavailable)") - 2 - } - - val resizer = - if (videoInfo != null) { - val rules = compressionRules.getValue(mediaQuality).getValue(videoInfo.resolution.getStandardName()) - Log.d("MediaCompressor", "Video resizer: ${videoInfo.resolution.width}x${videoInfo.resolution.height} -> ${rules.width}x${rules.height} (${rules.description})") - VideoResizer.limitSize(rules.width.toDouble(), rules.height.toDouble()) - } else { - // null VideoResizer should result in unchanged resolution - Log.d("MediaCompressor", "Video resizer: null (original resolution preserved)") - null - } - - // Get original file size for compression reporting - val originalSize = - try { - applicationContext.contentResolver.openInputStream(uri)?.use { inputStream -> - inputStream.available().toLong() - } ?: 0L - } catch (e: Exception) { - Log.w("MediaCompressor", "Failed to get original file size: ${e.message}") - 0L - } - - val result = - withTimeoutOrNull(30000) { - suspendCancellableCoroutine { continuation -> - VideoCompressor.start( - // => This is required - context = applicationContext, - // => Source can be provided as content uris - uris = listOf(uri), - isStreamable = true, - // THIS STORAGE - // sharedStorageConfiguration = SharedStorageConfiguration( - // saveAt = SaveLocation.movies, // => default is movies - // videoName = "compressed_video" // => required name - // ), - // OR AND NOT BOTH - storageConfiguration = AppSpecificStorageConfiguration(), - configureWith = - Configuration( - videoBitrateInMbps = videoBitrateInMbps, - resizer = resizer, - // => required name - videoNames = listOf(Uuid.random().toString()), - isMinBitrateCheckEnabled = false, - ), - listener = - object : CompressionListener { - override fun onProgress( - index: Int, - percent: Float, - ) {} - - override fun onStart(index: Int) {} - - override fun onSuccess( - index: Int, - size: Long, - path: String?, - ) { - if (path != null) { - // Sanity check: if compressed file is larger than original, return original - if (originalSize > 0 && size >= originalSize) { - Log.d("MediaCompressor", "Compressed file ($size bytes) is larger than original ($originalSize bytes). Using original file.") - continuation.resume(MediaCompressorResult(uri, contentType, null)) - return - } - - val reductionPercent = - if (originalSize > 0) { - ((originalSize - size) * 100.0 / originalSize).toInt() - } else { - 0 - } - - // Show compression result toast - if (originalSize > 0 && size > 0) { - val message = - "Video compressed: ${formatFileSize(applicationContext, size)} " + - "(${if (reductionPercent > 0) "-$reductionPercent%" else "+${-reductionPercent}%"})" - - // Post on main thread for Toast - android.os.Handler(android.os.Looper.getMainLooper()).post { - Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() - } - } - Log.d("MediaCompressor", "Video compression success. Original size [$originalSize] -> Compressed size [$size] ($reductionPercent% reduction)") - continuation.resume(MediaCompressorResult(Uri.fromFile(File(path)), contentType, size)) - } else { - Log.d("MediaCompressor", "Video compression successful, but returned null path") - continuation.resume(null) - } - } - - override fun onFailure( - index: Int, - failureMessage: String, - ) { - Log.d("MediaCompressor", "Video compression failed: $failureMessage") - // keeps going with original video - continuation.resume(null) - } - - override fun onCancelled(index: Int) { - continuation.resume(null) - } - }, - ) - } - } - - return result ?: MediaCompressorResult(uri, contentType, null) - } - private suspend fun compressImage( uri: Uri, contentType: String?, From e5df870ace505da2acf987c604e75216b19e1c2e Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 23 Sep 2025 10:24:48 +0200 Subject: [PATCH 18/32] move framerate adjustment into getBitrateMbpsInt to prevent ,precision errors Clean up logging --- .../service/uploads/VideoCompressionHelper.kt | 59 ++++++++----------- 1 file changed, 23 insertions(+), 36 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt index 94ac643489..7ce6cdb98c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt @@ -86,18 +86,19 @@ data class CompressionRule( val bitrateMbps: Float, val description: String, ) { - fun getBitrateMbpsInt(): Int { + fun getBitrateMbpsInt(framerate: Float): Int { + // Apply 1.5x multiplier for 60fps+ videos + val multiplier = if (framerate >= 60f) 1.5f else 1.0f + // Library doesn't support float so we have to convert it to int and use 1 as minimum - return if (bitrateMbps < 1) { - 1 - } else { - bitrateMbps.roundToInt() - } + return (bitrateMbps * multiplier).roundToInt().coerceAtLeast(1) } } class VideoCompressionHelper { companion object { + private const val LOG_TAG = "VideoCompressionHelper" + private val compressionRules = mapOf( CompressorQuality.LOW to @@ -147,28 +148,19 @@ class VideoCompressionHelper { val videoBitrateInMbps = if (videoInfo != null) { - val baseBitrate = + val bitrateMbpsInt = compressionRules .getValue(mediaQuality) .getValue(videoInfo.resolution.getStandard()) - .getBitrateMbpsInt() - - // Apply 1.5x multiplier for 60fps+ - val adjusted = - if (videoInfo.framerate >= 60f) { - (baseBitrate * 1.5f).roundToInt() - } else { - baseBitrate - } + .getBitrateMbpsInt(videoInfo.framerate) Log.d( - "VideoCompressionHelper", - "Bitrate: ${adjusted}Mbps for ${videoInfo.resolution.getStandard()} " + - "quality=$mediaQuality framerate=${videoInfo.framerate}fps", + LOG_TAG, + "Bitrate: ${bitrateMbpsInt}Mbps for ${videoInfo.resolution.getStandard()} " + + "quality=$mediaQuality framerate=${videoInfo.framerate}fps.", ) - adjusted } else { - Log.w("VideoCompressionHelper", "Video bitrate fallback: 2Mbps (videoInfo unavailable)") + Log.w(LOG_TAG, "Video bitrate fallback: 2Mbps (videoInfo unavailable)") 2 } @@ -179,13 +171,13 @@ class VideoCompressionHelper { .getValue(mediaQuality) .getValue(videoInfo.resolution.getStandard()) Log.d( - "VideoCompressionHelper", + LOG_TAG, "Resizer: ${videoInfo.resolution.width}x${videoInfo.resolution.height} -> " + "${rules.width}x${rules.height} (${rules.description})", ) VideoResizer.limitSize(rules.width.toDouble(), rules.height.toDouble()) } else { - Log.d("VideoCompressionHelper", "Resizer: null (original resolution preserved)") + Log.d(LOG_TAG, "Resizer: null (original resolution preserved)") null } @@ -224,7 +216,6 @@ class VideoCompressionHelper { if (path == null) { applicationContext.notifyUser( "Video compression succeeded, but path was null", - "VideoCompressionHelper", Log.WARN, ) if (continuation.isActive) continuation.resume(null) @@ -242,7 +233,6 @@ class VideoCompressionHelper { if (originalSize > 0 && size >= originalSize) { applicationContext.notifyUser( "Compressed file larger than original. Using original.", - "VideoCompressionHelper", Log.WARN, ) if (continuation.isActive) { @@ -260,12 +250,11 @@ class VideoCompressionHelper { if (reductionPercent >= 0) "-$reductionPercent%" else "+${-reductionPercent}%" applicationContext.notifyUser( "Video compressed: $sizeLabel ($percentLabel)", - "VideoCompressionHelper", ) } Log.d( - "VideoCompressionHelper", + LOG_TAG, "Compression success: Original [$originalSize] -> " + "Compressed [$size] ($reductionPercent% reduction)", ) @@ -290,14 +279,13 @@ class VideoCompressionHelper { ) { applicationContext.notifyUser( "Video compression failed: $failureMessage", - "VideoCompressionHelper", Log.ERROR, ) if (continuation.isActive) continuation.resume(null) } override fun onCancelled(index: Int) { - Log.w("VideoCompressionHelper", "Video compression cancelled") + Log.w(LOG_TAG, "Video compression cancelled") if (continuation.isActive) continuation.resume(null) } }, @@ -315,13 +303,12 @@ class VideoCompressionHelper { if (cursor.moveToFirst()) cursor.getLong(sizeIndex) else 0L } ?: 0L } catch (e: Exception) { - Log.w("VideoCompressionHelper", "Failed to get file size: ${e.message}") + Log.w(LOG_TAG, "Failed to get file size: ${e.message}") 0L } private fun Context.notifyUser( message: String, - logTag: String, logLevel: Int = Log.DEBUG, duration: Int = Toast.LENGTH_LONG, ) { @@ -329,9 +316,9 @@ class VideoCompressionHelper { Toast.makeText(this, message, duration).show() } when (logLevel) { - Log.ERROR -> Log.e(logTag, message) - Log.WARN -> Log.w(logTag, message) - else -> Log.d(logTag, message) + Log.ERROR -> Log.e(LOG_TAG, message) + Log.WARN -> Log.w(LOG_TAG, message) + else -> Log.d(LOG_TAG, message) } } @@ -364,13 +351,13 @@ class VideoCompressionHelper { null } } catch (e: Exception) { - Log.w("VideoCompressionHelper", "Failed to get video resolution: ${e.message}") + Log.w(LOG_TAG, "Failed to get video resolution: ${e.message}") null } finally { try { retriever?.release() } catch (e: Exception) { - Log.w("VideoCompressionHelper", "Failed to release MediaMetadataRetriever: ${e.message}") + Log.w(LOG_TAG, "Failed to release MediaMetadataRetriever: ${e.message}") } } } From ccc5d03d84482f17e7f27ebc54bf930d78bcbab9 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 23 Sep 2025 11:28:09 +0200 Subject: [PATCH 19/32] converted VideoCompressionHelper to singleton --- .../service/uploads/MediaCompressor.kt | 3 +- .../service/uploads/VideoCompressionHelper.kt | 78 +++++++++---------- 2 files changed, 39 insertions(+), 42 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt index aa508e31c4..0b506c4dcb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MediaCompressor.kt @@ -57,8 +57,7 @@ class MediaCompressor { // branch into compression based on content type return when { contentType?.startsWith("video", ignoreCase = true) == true -> { - val helper = VideoCompressionHelper() - helper.compressVideo(uri, contentType, applicationContext, mediaQuality) + VideoCompressionHelper.compressVideo(uri, contentType, applicationContext, mediaQuality) } contentType?.startsWith("image", ignoreCase = true) == true && !contentType.contains("gif") && diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt index 7ce6cdb98c..894b40708f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt @@ -95,47 +95,45 @@ data class CompressionRule( } } -class VideoCompressionHelper { - companion object { - private const val LOG_TAG = "VideoCompressionHelper" +object VideoCompressionHelper { + private const val LOG_TAG = "VideoCompressionHelper" - private val compressionRules = - mapOf( - CompressorQuality.LOW to - mapOf( - VideoStandard.UHD_4K to CompressionRule(1280, 720, 2f, "4K→720p, 2Mbps"), - VideoStandard.QHD_1440P to CompressionRule(1280, 720, 2f, "1440p→720p, 2Mbps"), - VideoStandard.FHD_1080P to CompressionRule(854, 480, 1f, "1080p→480p, 1Mbps"), - VideoStandard.HD_720P to CompressionRule(640, 360, 1f, "720p→360p, 1Mbps"), - VideoStandard.SD_480P to CompressionRule(426, 240, 1f, "480p→240p, 1Mbps"), - VideoStandard.NHD_360P to CompressionRule(426, 240, 0.3f, "360p→240p, 0.3Mbps"), - VideoStandard.QVGA_240P to CompressionRule(320, 180, 0.2f, "240p→180p, 0.2Mbps"), - VideoStandard.UNKNOWN to CompressionRule(854, 480, 1f, "Low quality fallback, 1Mbps"), - ), - CompressorQuality.MEDIUM to - mapOf( - VideoStandard.UHD_4K to CompressionRule(1920, 1080, 6f, "4K→1080p, 6Mbps"), - VideoStandard.QHD_1440P to CompressionRule(1920, 1080, 6f, "1440p→1080p, 6Mbps"), - VideoStandard.FHD_1080P to CompressionRule(1280, 720, 3f, "1080p→720p, 3Mbps"), - VideoStandard.HD_720P to CompressionRule(854, 480, 2f, "720p→480p, 2Mbps"), - VideoStandard.SD_480P to CompressionRule(640, 360, 1f, "480p→360p, 1Mbps"), - VideoStandard.NHD_360P to CompressionRule(426, 240, 0.5f, "360p→240p, 0.5Mbps"), - VideoStandard.QVGA_240P to CompressionRule(320, 180, 0.3f, "240p→180p, 0.3Mbps"), - VideoStandard.UNKNOWN to CompressionRule(1280, 720, 2f, "Medium quality fallback, 2Mbps"), - ), - CompressorQuality.HIGH to - mapOf( - VideoStandard.UHD_4K to CompressionRule(3840, 2160, 16f, "4K→4K, 16Mbps"), - VideoStandard.QHD_1440P to CompressionRule(1920, 1080, 8f, "1440p→1080p, 8Mbps"), - VideoStandard.FHD_1080P to CompressionRule(1920, 1080, 6f, "1080p→1080p, 6Mbps"), - VideoStandard.HD_720P to CompressionRule(1280, 720, 3f, "720p→720p, 3Mbps"), - VideoStandard.SD_480P to CompressionRule(854, 480, 2f, "480p→480p, 2Mbps"), - VideoStandard.NHD_360P to CompressionRule(640, 360, 1f, "360p→360p, 1Mbps"), - VideoStandard.QVGA_240P to CompressionRule(426, 240, 0.5f, "240p→240p, 0.5Mbps"), - VideoStandard.UNKNOWN to CompressionRule(1920, 1080, 3f, "High quality fallback, 3Mbps"), - ), - ) - } + private val compressionRules = + mapOf( + CompressorQuality.LOW to + mapOf( + VideoStandard.UHD_4K to CompressionRule(1280, 720, 2f, "4K→720p, 2Mbps"), + VideoStandard.QHD_1440P to CompressionRule(1280, 720, 2f, "1440p→720p, 2Mbps"), + VideoStandard.FHD_1080P to CompressionRule(854, 480, 1f, "1080p→480p, 1Mbps"), + VideoStandard.HD_720P to CompressionRule(640, 360, 1f, "720p→360p, 1Mbps"), + VideoStandard.SD_480P to CompressionRule(426, 240, 1f, "480p→240p, 1Mbps"), + VideoStandard.NHD_360P to CompressionRule(426, 240, 0.3f, "360p→240p, 0.3Mbps"), + VideoStandard.QVGA_240P to CompressionRule(320, 180, 0.2f, "240p→180p, 0.2Mbps"), + VideoStandard.UNKNOWN to CompressionRule(854, 480, 1f, "Low quality fallback, 1Mbps"), + ), + CompressorQuality.MEDIUM to + mapOf( + VideoStandard.UHD_4K to CompressionRule(1920, 1080, 6f, "4K→1080p, 6Mbps"), + VideoStandard.QHD_1440P to CompressionRule(1920, 1080, 6f, "1440p→1080p, 6Mbps"), + VideoStandard.FHD_1080P to CompressionRule(1280, 720, 3f, "1080p→720p, 3Mbps"), + VideoStandard.HD_720P to CompressionRule(854, 480, 2f, "720p→480p, 2Mbps"), + VideoStandard.SD_480P to CompressionRule(640, 360, 1f, "480p→360p, 1Mbps"), + VideoStandard.NHD_360P to CompressionRule(426, 240, 0.5f, "360p→240p, 0.5Mbps"), + VideoStandard.QVGA_240P to CompressionRule(320, 180, 0.3f, "240p→180p, 0.3Mbps"), + VideoStandard.UNKNOWN to CompressionRule(1280, 720, 2f, "Medium quality fallback, 2Mbps"), + ), + CompressorQuality.HIGH to + mapOf( + VideoStandard.UHD_4K to CompressionRule(3840, 2160, 16f, "4K→4K, 16Mbps"), + VideoStandard.QHD_1440P to CompressionRule(1920, 1080, 8f, "1440p→1080p, 8Mbps"), + VideoStandard.FHD_1080P to CompressionRule(1920, 1080, 6f, "1080p→1080p, 6Mbps"), + VideoStandard.HD_720P to CompressionRule(1280, 720, 3f, "720p→720p, 3Mbps"), + VideoStandard.SD_480P to CompressionRule(854, 480, 2f, "480p→480p, 2Mbps"), + VideoStandard.NHD_360P to CompressionRule(640, 360, 1f, "360p→360p, 1Mbps"), + VideoStandard.QVGA_240P to CompressionRule(426, 240, 0.5f, "240p→240p, 0.5Mbps"), + VideoStandard.UNKNOWN to CompressionRule(1920, 1080, 3f, "High quality fallback, 3Mbps"), + ), + ) suspend fun compressVideo( uri: Uri, From 6391ddbbaad8aac3eb68f9e083c0c5846d0519eb Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Wed, 24 Sep 2025 09:09:51 -0300 Subject: [PATCH 20/32] Show a dialog to select a signer when using multiple signers --- .../loggedOff/login/ExternalSignerButton.kt | 112 +++++++++++++++++- amethyst/src/main/res/values/strings.xml | 1 + .../client/ExternalSignerLogin.kt | 8 +- .../client/IsExternalSignerInstalled.kt | 12 ++ 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/ExternalSignerButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/ExternalSignerButton.kt index 4285dd0872..6280c09bd8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/ExternalSignerButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/ExternalSignerButton.kt @@ -23,12 +23,39 @@ package com.vitorpamplona.amethyst.ui.screen.loggedOff.login import android.app.Activity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.core.graphics.drawable.toBitmap +import coil3.compose.rememberAsyncImagePainter import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.DefaultSignerPermissions import com.vitorpamplona.amethyst.ui.theme.Size0dp @@ -37,6 +64,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.quartz.nip55AndroidSigner.api.PubKeyResult import com.vitorpamplona.quartz.nip55AndroidSigner.api.SignerResult import com.vitorpamplona.quartz.nip55AndroidSigner.client.ExternalSignerLogin +import com.vitorpamplona.quartz.nip55AndroidSigner.client.getExternalSignersInstalled import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch @@ -44,6 +72,9 @@ import kotlinx.coroutines.launch @Composable fun ExternalSignerButton(loginViewModel: LoginViewModel) { val scope = rememberCoroutineScope() + val context = LocalContext.current + val installedSigners = getExternalSignersInstalled(context) + var shouldSelectSigner by remember { mutableStateOf(false) } val launcher = rememberLauncherForActivityResult( @@ -63,6 +94,81 @@ fun ExternalSignerButton(loginViewModel: LoginViewModel) { } } + if (shouldSelectSigner) { + Dialog( + onDismissRequest = { + shouldSelectSigner = false + }, + content = { + Surface( + shape = RoundedCornerShape(4.dp), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + modifier = Modifier.padding(8.dp), + text = stringResource(R.string.select_signer), + fontWeight = FontWeight.Bold, + fontSize = 24.sp, + ) + Spacer(Modifier.height(4.dp)) + LazyColumn { + items(installedSigners) { + val appName = it.loadLabel(context.packageManager).toString() + val appIcon = it.loadIcon(context.packageManager) + val iconBitmap = appIcon.toBitmap() + + Row( + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 8.dp, top = 8.dp) + .clickable { + if (!loginViewModel.acceptedTerms) { + loginViewModel.termsAcceptanceIsRequiredError = true + } else { + try { + launcher.launch(ExternalSignerLogin.createIntent(DefaultSignerPermissions, it.activityInfo.packageName)) + } catch (e: Exception) { + if (e is CancellationException) throw e + Log.e("ExternalSigner", "Error opening Signer app", e) + loginViewModel.errorManager.error(R.string.error_opening_external_signer) + } finally { + shouldSelectSigner = false + } + } + }, + ) { + val painter = + rememberAsyncImagePainter( + iconBitmap, + ) + + Image( + painter = painter, + contentDescription = appName, + modifier = + Modifier + .size(48.dp) + .padding(end = 16.dp), + ) + Column { + Text(appName) + Text( + it.activityInfo.packageName, + fontSize = 14.sp, + color = Color.Gray, + ) + } + } + } + } + } + } + }, + ) + } + Box(modifier = Modifier.padding(Size40dp, Size20dp, Size40dp, Size0dp)) { LoginWithAmberButton( enabled = loginViewModel.acceptedTerms, @@ -71,7 +177,11 @@ fun ExternalSignerButton(loginViewModel: LoginViewModel) { loginViewModel.termsAcceptanceIsRequiredError = true } else { try { - launcher.launch(ExternalSignerLogin.createIntent(DefaultSignerPermissions)) + if (installedSigners.size == 1) { + launcher.launch(ExternalSignerLogin.createIntent(DefaultSignerPermissions)) + } else { + shouldSelectSigner = true + } } catch (e: Exception) { if (e is CancellationException) throw e Log.e("ExternalSigner", "Error opening Signer app", e) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d5b837e887..9c6f4283cf 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1289,4 +1289,5 @@ Would you like to send the recent crash report to Amethyst in a DM? No personal information will be shared Send it This message will disappear in %1$d days + Select Signer diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/ExternalSignerLogin.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/ExternalSignerLogin.kt index 015c3fc65f..2ec43bd092 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/ExternalSignerLogin.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/ExternalSignerLogin.kt @@ -29,9 +29,15 @@ import com.vitorpamplona.quartz.nip55AndroidSigner.api.foreground.intents.result import com.vitorpamplona.quartz.nip55AndroidSigner.api.permission.Permission object ExternalSignerLogin { - fun createIntent(permissions: List = LoginRequest.DefaultPermissions): Intent { + fun createIntent( + permissions: List = LoginRequest.DefaultPermissions, + packageName: String = "", + ): Intent { val intent = LoginRequest.assemble(permissions) intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + if (packageName.isNotBlank()) { + intent.`package` = packageName + } return intent } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/IsExternalSignerInstalled.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/IsExternalSignerInstalled.kt index 92d7f5e48c..cef8fa6310 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/IsExternalSignerInstalled.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/client/IsExternalSignerInstalled.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.quartz.nip55AndroidSigner.client import android.annotation.SuppressLint import android.content.Context import android.content.Intent +import android.content.pm.ResolveInfo import androidx.core.net.toUri @SuppressLint("QueryPermissionsNeeded") @@ -35,3 +36,14 @@ fun isExternalSignerInstalled(context: Context): Boolean = }, 0, ).isNotEmpty() + +@SuppressLint("QueryPermissionsNeeded") +fun getExternalSignersInstalled(context: Context): List = + context.packageManager + .queryIntentActivities( + Intent().apply { + action = Intent.ACTION_VIEW + data = "nostrsigner:".toUri() + }, + 0, + ) From 94a2d9e07d86ae79bc0c7a7e6cd1619ebcab3673 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 24 Sep 2025 12:19:05 +0000 Subject: [PATCH 21/32] New Crowdin translations by GitHub Action --- .../src/main/res/values-hi-rIN/strings.xml | 32 +++++++++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 30 +++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 6dec4915ce..8e6ffcb6a1 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -112,6 +112,7 @@ पत्र प्रकाशन अभिलेखन करें बनाएँ + पुनःनामकरण निरस्त करें चित्र आरोहण असफल पुनःप्रसारक पता @@ -440,10 +441,38 @@ नहीं अनुचरण सूची सभी अनुचरित + प्रयोक्ता के सभी अनुगामी प्रतिनिधि द्वारा अनुचरित मेरे आसपास वैश्विक मौन सूची + अनुगम्य सूचियाँ + सूचक युक्त स्मर्त्तव्य सूची + सामान्य स्मर्त्तव्य सूची + सार्वजनिक + निजी + मिश्रित + लगता है आपका अब तक कोइ अनुगम्य समुच्चय नहीं है। + \nनवीकरण के लिए नीचे दबाएँ। अथवा एक नया बनाने के लिए जोड घुण्डियाँ टाँकें। + + लेखक जोडें अनुगम्य सूची में + प्रयोक्ता को सूचियों में जोडें अथवा हटाएँ अथवा इस प्रयोक्ता के साथ नई सूची बनाएँ। + सूची %1$s के लिए चिह्न + "%1$s इस सूची में है" + "%1$s इस सूची में नहीं है" + आपके अनुगम्य सूचियाँ + कोई अनुगम्य सूचियाँ प्राप्त नहीं। अथवा आपका कोई अनुगम्य सूचियाँ हैं नहीं। नवीकरण के लिए नीचे दबाएँ अथवा विकल्पसूची द्वारा एक नया बनाएँ। + लाने में अपक्रम : %1$s + नई सूची बनाएँ + प्रयोक्ता के साथ नई सूची %1$s बनाएँ + अनुगम्य सूची %1$s बनाता है तथा उससे %2$s जोडता है। + नई %1$s सूची + समुच्चय नाम + समुच्चय विवरण (आवश्यक नहीं) + समुच्चय बनाएँ + समुच्चय पुनःनामकरण + आप पनःनामकरण कर रहे हैं इस से + इस तक.. मूलविकल्प द्वार ९०५० है ## टोर द्वारा संयोजन करें ओर्बोट के साथ \n\n१. स्थापित करें [ओर्बोट](https://play.google.com/store/apps/details?id=org.torproject.android) @@ -1013,6 +1042,8 @@ अवरोहण अभिलेख खोलने में असफल कोई उग्रप्रवाह क्रमक स्थापित नहीं अभिलेख खोलने तथा अवरोहण करने के लिए। + अभिलेखविभेदक युक्त जालनिर्देशक बनाने के लिए पर्याप्त जानकारी नहीं है घटना में + मेरे सूचियाँ / समुच्चय सूचनावली छानने के लिए सूची चुनें यन्त्र ताला लगने पर निर्गमनांकन करें निजी सन्देश @@ -1020,6 +1051,7 @@ चर्चा पुनःप्रसारक वह पुनःप्रसारक जिससे इस चर्चा के सभी उपयोगकर्ता जुडते हैं चित्र बाँटें… + चित्र बाँटने में असफल। कृपया कुछ समय पश्चात पुनःप्रयास करें… विषयसूचक खोज : #%1$s अनुवाद ना करें यहाँ प्रस्तुत भाषाओं का अनुवाद नहीं होगा। भाषा चयन करें हटाने के लिए जिससे उसका अनुवाद पुनः होने लगेगा। diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index cfc05388b6..02f2316e42 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -112,6 +112,7 @@ 发布 保存 创建 + 重命名 取消 上传图片失败 中继器地址 @@ -445,6 +446,33 @@ 周围的人 全球 静音列表 + 关注集 + 有标签的书签 + 常规书签 + 公开 + 私密 + 混合 + 似乎你还没有任何关注集。 + \n轻按下方刷新,或者轻按“+”按钮新建一个。 + + 添加作者到关注集 + 从列表中添加或删除用户,或用此用户创建一个新列表。 + %1$s 列表的图标 + "此列表中有 %1$s" + "此列表中没有 %1$s" + 您的关注集 + 未找到关注集,或者你还没有任何关注集。轻按下方刷新,或使用按钮新建。 + 获取时出了问题: %1$s + 新建列表 + 创建用户新的 %1$s 列表 + 创建 %1$s 关注集,并添加 %2$s 到其中。 + 新的 %1$s 列表 + 集合名 + 集合描述(可选) + 新建集 + 重命名集 + 正将集合名称从 + 改为 默认端口为 9050 ## 通过 Orbot 连线 Tor \n\n1. 安装 [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) @@ -1014,6 +1042,8 @@ 下载 打开文件失败 没有用于打开和下载文件的 Torrent 客户端 + 事件没有足够信息来构建磁力链 + 我的列表/集合 选择一个用于过滤订阅源的列表 当设备锁定时注销 私信 From 206f8847fceb94521538be958b02ebc6ae57af27 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Wed, 24 Sep 2025 12:39:55 +0000 Subject: [PATCH 22/32] New Crowdin translations by GitHub Action --- .../src/main/res/values-hi-rIN/strings.xml | 32 +++++++++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 30 +++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index 6dec4915ce..8e6ffcb6a1 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -112,6 +112,7 @@ पत्र प्रकाशन अभिलेखन करें बनाएँ + पुनःनामकरण निरस्त करें चित्र आरोहण असफल पुनःप्रसारक पता @@ -440,10 +441,38 @@ नहीं अनुचरण सूची सभी अनुचरित + प्रयोक्ता के सभी अनुगामी प्रतिनिधि द्वारा अनुचरित मेरे आसपास वैश्विक मौन सूची + अनुगम्य सूचियाँ + सूचक युक्त स्मर्त्तव्य सूची + सामान्य स्मर्त्तव्य सूची + सार्वजनिक + निजी + मिश्रित + लगता है आपका अब तक कोइ अनुगम्य समुच्चय नहीं है। + \nनवीकरण के लिए नीचे दबाएँ। अथवा एक नया बनाने के लिए जोड घुण्डियाँ टाँकें। + + लेखक जोडें अनुगम्य सूची में + प्रयोक्ता को सूचियों में जोडें अथवा हटाएँ अथवा इस प्रयोक्ता के साथ नई सूची बनाएँ। + सूची %1$s के लिए चिह्न + "%1$s इस सूची में है" + "%1$s इस सूची में नहीं है" + आपके अनुगम्य सूचियाँ + कोई अनुगम्य सूचियाँ प्राप्त नहीं। अथवा आपका कोई अनुगम्य सूचियाँ हैं नहीं। नवीकरण के लिए नीचे दबाएँ अथवा विकल्पसूची द्वारा एक नया बनाएँ। + लाने में अपक्रम : %1$s + नई सूची बनाएँ + प्रयोक्ता के साथ नई सूची %1$s बनाएँ + अनुगम्य सूची %1$s बनाता है तथा उससे %2$s जोडता है। + नई %1$s सूची + समुच्चय नाम + समुच्चय विवरण (आवश्यक नहीं) + समुच्चय बनाएँ + समुच्चय पुनःनामकरण + आप पनःनामकरण कर रहे हैं इस से + इस तक.. मूलविकल्प द्वार ९०५० है ## टोर द्वारा संयोजन करें ओर्बोट के साथ \n\n१. स्थापित करें [ओर्बोट](https://play.google.com/store/apps/details?id=org.torproject.android) @@ -1013,6 +1042,8 @@ अवरोहण अभिलेख खोलने में असफल कोई उग्रप्रवाह क्रमक स्थापित नहीं अभिलेख खोलने तथा अवरोहण करने के लिए। + अभिलेखविभेदक युक्त जालनिर्देशक बनाने के लिए पर्याप्त जानकारी नहीं है घटना में + मेरे सूचियाँ / समुच्चय सूचनावली छानने के लिए सूची चुनें यन्त्र ताला लगने पर निर्गमनांकन करें निजी सन्देश @@ -1020,6 +1051,7 @@ चर्चा पुनःप्रसारक वह पुनःप्रसारक जिससे इस चर्चा के सभी उपयोगकर्ता जुडते हैं चित्र बाँटें… + चित्र बाँटने में असफल। कृपया कुछ समय पश्चात पुनःप्रयास करें… विषयसूचक खोज : #%1$s अनुवाद ना करें यहाँ प्रस्तुत भाषाओं का अनुवाद नहीं होगा। भाषा चयन करें हटाने के लिए जिससे उसका अनुवाद पुनः होने लगेगा। diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index cfc05388b6..02f2316e42 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -112,6 +112,7 @@ 发布 保存 创建 + 重命名 取消 上传图片失败 中继器地址 @@ -445,6 +446,33 @@ 周围的人 全球 静音列表 + 关注集 + 有标签的书签 + 常规书签 + 公开 + 私密 + 混合 + 似乎你还没有任何关注集。 + \n轻按下方刷新,或者轻按“+”按钮新建一个。 + + 添加作者到关注集 + 从列表中添加或删除用户,或用此用户创建一个新列表。 + %1$s 列表的图标 + "此列表中有 %1$s" + "此列表中没有 %1$s" + 您的关注集 + 未找到关注集,或者你还没有任何关注集。轻按下方刷新,或使用按钮新建。 + 获取时出了问题: %1$s + 新建列表 + 创建用户新的 %1$s 列表 + 创建 %1$s 关注集,并添加 %2$s 到其中。 + 新的 %1$s 列表 + 集合名 + 集合描述(可选) + 新建集 + 重命名集 + 正将集合名称从 + 改为 默认端口为 9050 ## 通过 Orbot 连线 Tor \n\n1. 安装 [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android) @@ -1014,6 +1042,8 @@ 下载 打开文件失败 没有用于打开和下载文件的 Torrent 客户端 + 事件没有足够信息来构建磁力链 + 我的列表/集合 选择一个用于过滤订阅源的列表 当设备锁定时注销 私信 From ccf64687a81ab84891f5567b7025de780b5f24bd Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Sep 2025 12:36:39 -0400 Subject: [PATCH 23/32] Separates index relays from the default and put it into the hint-based queries for unkown users. --- .../vitorpamplona/amethyst/model/Account.kt | 2 - ...usMineWithIndexAndSearchRelayListsState.kt | 92 ------------------- .../user/loaders/FilterUserMetadataForKey.kt | 10 +- .../user/loaders/UserLoaderSubAssembler.kt | 11 ++- .../subassemblies/FilterByAuthor.kt | 3 +- .../SearchWatcherSubAssembler.kt | 13 ++- 6 files changed, 26 insertions(+), 105 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineWithIndexAndSearchRelayListsState.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 3191aae0a2..a54781a1af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -80,7 +80,6 @@ import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListSt import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState -import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineWithIndexAndSearchRelayListsState import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineWithIndexRelayListsState import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineWithSearchRelayListsState import com.vitorpamplona.amethyst.model.serverList.MergedServerListState @@ -312,7 +311,6 @@ class Account( val followOutboxesOrProxy = FollowListOutboxOrProxyRelays(kind3FollowList, blockedRelayList, proxyRelayList, cache, scope) val followPlusAllMineWithIndex = MergedFollowPlusMineWithIndexRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, indexerRelayList, scope) val followPlusAllMineWithSearch = MergedFollowPlusMineWithSearchRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, searchRelayList, scope) - val followPlusAllMineWithIndexAndSearch = MergedFollowPlusMineWithIndexAndSearchRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, indexerRelayList, searchRelayList, scope) val defaultGlobalRelays = MergedFollowPlusMineRelayListsState(followOutboxesOrProxy, nip65RelayList, privateStorageRelayList, localRelayList, scope) // keeps a cache of the outbox relays for each author diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineWithIndexAndSearchRelayListsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineWithIndexAndSearchRelayListsState.kt deleted file mode 100644 index 68956135bb..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedFollowPlusMineWithIndexAndSearchRelayListsState.kt +++ /dev/null @@ -1,92 +0,0 @@ -/** - * 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.model.serverList - -import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState -import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState -import com.vitorpamplona.amethyst.model.nip02FollowLists.FollowListOutboxOrProxyRelays -import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState -import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState -import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn - -class MergedFollowPlusMineWithIndexAndSearchRelayListsState( - val followsOutboxOrProxyRelayList: FollowListOutboxOrProxyRelays, - val nip65RelayList: Nip65RelayListState, - val privateOutboxRelayList: PrivateStorageRelayListState, - val localRelayList: LocalRelayListState, - val indexerRelayList: IndexerRelayListState, - val searchRelayListsState: SearchRelayListState, - val scope: CoroutineScope, -) { - fun mergeLists(lists: Array>): Set = lists.reduce { acc, set -> acc + set } - - val flow: StateFlow> = - combine( - listOf( - followsOutboxOrProxyRelayList.flow, - nip65RelayList.outboxFlow, - nip65RelayList.inboxFlow, - privateOutboxRelayList.flow, - localRelayList.flow, - indexerRelayList.flow, - searchRelayListsState.flow, - ), - ::mergeLists, - ).onStart { - emit( - mergeLists( - arrayOf( - followsOutboxOrProxyRelayList.flow.value, - nip65RelayList.outboxFlow.value, - nip65RelayList.inboxFlow.value, - privateOutboxRelayList.flow.value, - localRelayList.flow.value, - indexerRelayList.flow.value, - searchRelayListsState.flow.value, - ), - ), - ) - }.flowOn(Dispatchers.Default) - .stateIn( - scope, - SharingStarted.Eagerly, - mergeLists( - arrayOf( - followsOutboxOrProxyRelayList.flow.value, - nip65RelayList.outboxFlow.value, - nip65RelayList.inboxFlow.value, - privateOutboxRelayList.flow.value, - localRelayList.flow.value, - indexerRelayList.flow.value, - searchRelayListsState.flow.value, - ), - ), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt index b419e86a58..c41f9af4dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt @@ -38,14 +38,16 @@ val MetadataAndRelayListKinds = fun filterFindUserMetadataForKey( author: HexKey, + indexRelays: Set, defaultRelays: Set, ): List = LocalCache.checkGetOrCreateUser(author)?.let { - filterFindUserMetadataForKey(setOf(it), defaultRelays) + filterFindUserMetadataForKey(setOf(it), indexRelays, defaultRelays) } ?: emptyList() fun filterFindUserMetadataForKey( authors: Set, + indexRelays: Set, defaultRelays: Set, ): List { val perRelayKeys = @@ -53,8 +55,10 @@ fun filterFindUserMetadataForKey( authors.forEach { key -> val relays = key.authorRelayList()?.writeRelaysNorm() - ?: LocalCache.relayHints.hintsForKey(key.pubkeyHex).ifEmpty { null } - ?: (key.relaysBeingUsed.keys + defaultRelays).toList() + ?: (key.relaysBeingUsed.keys + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays) + .ifEmpty { null } + ?.also { println("Using relay hints ${it.size} for ${key.pubkeyHex}") } + ?: defaultRelays.toList() relays.forEach { add(it, key.pubkeyHex) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt index 357f29434d..c138f365c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders +import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager @@ -33,6 +34,7 @@ class UserLoaderSubAssembler( allKeys: () -> Set, ) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { override fun updateFilter(keys: List): List? { + println("01f6901bc401e87962fa8da15acfe16ef72b17ed965114384d69aa857a21fbfc updating user assembly filter 2") val firstTimers = mutableSetOf() keys.forEach { @@ -43,10 +45,15 @@ class UserLoaderSubAssembler( } } + val indexRelays = mutableSetOf() val defaultRelays = mutableSetOf() keys.mapTo(mutableSetOf()) { it.account }.forEach { - defaultRelays.addAll(it.followPlusAllMineWithIndexAndSearch.flow.value) + indexRelays.addAll( + it.indexerRelayList.flow.value + .ifEmpty { DefaultIndexerRelayList }, + ) + defaultRelays.addAll(it.followPlusAllMineWithSearch.flow.value) it.kind3FollowList.flow.value.authors.forEach { val user = LocalCache.getOrCreateUser(it) @@ -60,7 +67,7 @@ class UserLoaderSubAssembler( if (firstTimers.isEmpty()) return null - return filterFindUserMetadataForKey(firstTimers, defaultRelays) + return filterFindUserMetadataForKey(firstTimers, indexRelays, defaultRelays) } override fun distinct(key: UserFinderQueryState) = key.user 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 c3b2efd28a..916bfa09c1 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 @@ -26,5 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun filterByAuthor( pubKey: HexKey, + indexRelays: Set, defaultRelays: Set, -) = filterFindUserMetadataForKey(pubKey, defaultRelays) +) = filterFindUserMetadataForKey(pubKey, indexRelays, defaultRelays) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt index 098d8e0710..d7fbba00f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchWatcherSubAssembler.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies +import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState @@ -55,23 +56,25 @@ class SearchWatcherSubAssembler( if (mySearchString.isBlank()) return null - val defaultRelaysWithIndexAndSearch = key.account.followPlusAllMineWithIndexAndSearch.flow.value + val indexRelays = + key.account.indexerRelayList.flow.value + .ifEmpty { DefaultIndexerRelayList } val defaultRelaysWithSearch = key.account.followPlusAllMineWithSearch.flow.value val directFilters = runCatching { if (Hex.isHex(mySearchString)) { val hexKey = Hex.decode(mySearchString).toHexKey() - filterByAuthor(hexKey, defaultRelaysWithIndexAndSearch) + filterByEvent(hexKey, defaultRelaysWithSearch) + filterByAuthor(hexKey, indexRelays, defaultRelaysWithSearch) + filterByEvent(hexKey, defaultRelaysWithSearch) } else { val parsed = Nip19Parser.uriToRoute(mySearchString)?.entity if (parsed != null) { cache.consume(parsed) when (parsed) { - is NSec -> filterByAuthor(parsed.toPubKeyHex(), defaultRelaysWithIndexAndSearch) - is NPub -> filterByAuthor(parsed.hex, defaultRelaysWithIndexAndSearch) - is NProfile -> filterByAuthor(parsed.hex, defaultRelaysWithIndexAndSearch) + is NSec -> filterByAuthor(parsed.toPubKeyHex(), indexRelays, defaultRelaysWithSearch) + is NPub -> filterByAuthor(parsed.hex, indexRelays, defaultRelaysWithSearch) + is NProfile -> filterByAuthor(parsed.hex, indexRelays, defaultRelaysWithSearch) is NNote -> filterByEvent(parsed.hex, defaultRelaysWithSearch) is NEvent -> filterByEvent(parsed.hex, defaultRelaysWithSearch) is NEmbed -> emptyList() From 45b7b5601b0548d02083e5e97b83add9e6d5d314 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Sep 2025 12:37:04 -0400 Subject: [PATCH 24/32] Ups the max requests thread allowed to support people with over 1000 follows --- .../amethyst/service/okhttp/OkHttpClientFactory.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index 8e6e1267e0..43c70ef1bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -61,7 +61,8 @@ class OkHttpClientFactory( val myDispatcher = Dispatcher().apply { if (!isEmulator()) { - maxRequests = 512 + maxRequestsPerHost = 10 + maxRequests = 1024 } else { Log.i("OkHttpClientFactory", "Emulator detected, using default maxRequests: 64.") } From f9e0c82fc8a05b8ae5b42fb980c68394bee4b800 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Sep 2025 12:37:35 -0400 Subject: [PATCH 25/32] No need to import 500 communities for each relay it connects to --- .../nip72Communities/subassemblies/FilterCommunitiesGlobal.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt index 2fdc48c33d..3aae8fbbb8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/subassemblies/FilterCommunitiesGlobal.kt @@ -43,7 +43,7 @@ fun filterCommunitiesGlobal( filter = Filter( kinds = listOf(CommunityDefinitionEvent.KIND), - limit = 500, + limit = 200, since = since, ), ), From 6ba60dce3f8560524d4082a7a21f0cdbb1d726aa Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Sep 2025 12:38:01 -0400 Subject: [PATCH 26/32] Throws an error if the RelayStats cannot be created. --- .../quartz/nip01Core/relay/client/stats/RelayStats.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt index b65a22bffc..3ae818730d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/stats/RelayStats.kt @@ -29,7 +29,7 @@ object RelayStats { override fun create(key: NormalizedRelayUrl): RelayStat = RelayStat() } - fun get(url: NormalizedRelayUrl): RelayStat = innerCache.get(url) ?: RelayStat() + fun get(url: NormalizedRelayUrl): RelayStat = innerCache.get(url) ?: throw IllegalArgumentException("Should never happen") fun addBytesReceived( url: NormalizedRelayUrl, From 361e46c99f38278da46c641de7ee7dc7b84f4ca1 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Sep 2025 12:38:48 -0400 Subject: [PATCH 27/32] Limits the number of posts coming from big relays with too many authors to 500 max --- .../datasource/nip65Follows/FilterHomePostsByAuthors.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index b9de073882..46bebbc444 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -42,7 +42,7 @@ import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent -import kotlin.math.max +import kotlin.math.min val HomePostsNewThreadKinds = listOf( @@ -81,7 +81,7 @@ fun filterNewHomePostsByAuthors( Filter( kinds = HomePostsNewThreadKinds, authors = authorList, - limit = max(authorList.size * 10, 300), + limit = min(authorList.size * 10, 500), since = since, ), ), @@ -102,7 +102,7 @@ fun filterReplyHomePostsByAuthors( Filter( kinds = HomePostsConversationKinds, authors = authorList, - limit = max(authorList.size * 10, 300), + limit = min(authorList.size * 10, 500), since = since, ), ), From 06f94150f98aa32a57d6b2b2d946463998128510 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Sep 2025 12:43:04 -0400 Subject: [PATCH 28/32] Okhttp log helper class. --- .../service/okhttp/OkHttpDebugLogging.kt | 67 +++++++++++++++++++ .../speedLogger/RelaySpeedLogger.kt | 4 +- 2 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpDebugLogging.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpDebugLogging.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpDebugLogging.kt new file mode 100644 index 0000000000..79bb1abeb2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpDebugLogging.kt @@ -0,0 +1,67 @@ +/** + * 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.okhttp + +import okhttp3.internal.concurrent.TaskRunner +import okhttp3.internal.http2.Http2 +import java.io.Closeable +import java.util.concurrent.CopyOnWriteArraySet +import java.util.logging.ConsoleHandler +import java.util.logging.Handler +import java.util.logging.Level +import java.util.logging.LogRecord +import java.util.logging.Logger +import java.util.logging.SimpleFormatter +import kotlin.reflect.KClass + +object OkHttpDebugLogging { + // Keep references to loggers to prevent their configuration from being GC'd. + private val configuredLoggers = CopyOnWriteArraySet() + + fun enableHttp2() = enable(Http2::class) + + fun enableTaskRunner() = enable(TaskRunner::class) + + fun logHandler() = + ConsoleHandler().apply { + level = Level.FINE + formatter = + object : SimpleFormatter() { + override fun format(record: LogRecord) = String.format("[%1\$tF %1\$tT] %2\$s %n", record.millis, record.message) + } + } + + fun enable( + loggerClass: String, + handler: Handler = logHandler(), + ): Closeable { + val logger = Logger.getLogger(loggerClass) + if (configuredLoggers.add(logger)) { + logger.addHandler(handler) + logger.level = Level.FINEST + } + return Closeable { + logger.removeHandler(handler) + } + } + + fun enable(loggerClass: KClass<*>) = enable(loggerClass.java.name) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt index fc565f5394..c4397535b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt @@ -33,7 +33,7 @@ class RelaySpeedLogger( val client: INostrClient, ) { companion object { - val TAG = RelaySpeedLogger::class.java.simpleName + val TAG: String = RelaySpeedLogger::class.java.simpleName } var current = FrameStat() @@ -55,6 +55,8 @@ class RelaySpeedLogger( init { Log.d(TAG, "Init, Subscribe") client.subscribe(clientListener) + // OkHttpDebugLogging.enableHttp2() + // OkHttpDebugLogging.enableTaskRunner() } fun destroy() { From ed7fa2a80fb527cde1b78ec2e6832b7986c428e6 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Sep 2025 12:43:15 -0400 Subject: [PATCH 29/32] removes log --- .../reqCommand/user/loaders/FilterUserMetadataForKey.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt index c41f9af4dd..b20cc7dccc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/FilterUserMetadataForKey.kt @@ -55,9 +55,7 @@ fun filterFindUserMetadataForKey( authors.forEach { key -> val relays = key.authorRelayList()?.writeRelaysNorm() - ?: (key.relaysBeingUsed.keys + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays) - .ifEmpty { null } - ?.also { println("Using relay hints ${it.size} for ${key.pubkeyHex}") } + ?: (key.relaysBeingUsed.keys + LocalCache.relayHints.hintsForKey(key.pubkeyHex) + indexRelays).ifEmpty { null } ?: defaultRelays.toList() relays.forEach { From 54afc2c4dca54e5b6ea149ea91d12592990f8c41 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Sep 2025 14:34:24 -0400 Subject: [PATCH 30/32] Remove logs --- .../reqCommand/user/loaders/UserLoaderSubAssembler.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt index c138f365c2..04057d91ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserLoaderSubAssembler.kt @@ -34,7 +34,6 @@ class UserLoaderSubAssembler( allKeys: () -> Set, ) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { override fun updateFilter(keys: List): List? { - println("01f6901bc401e87962fa8da15acfe16ef72b17ed965114384d69aa857a21fbfc updating user assembly filter 2") val firstTimers = mutableSetOf() keys.forEach { From 8466020137c1c42357104c850abe2f5939603d40 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Sep 2025 14:34:35 -0400 Subject: [PATCH 31/32] Increase the delay to update filters --- .../service/relayClient/eoseManagers/BaseEoseManager.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt index 70a0c54553..06e79b57ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/BaseEoseManager.kt @@ -50,7 +50,7 @@ abstract class BaseEoseManager( fun dismissSubscription(subId: String) = orchestrator.dismissSubscription(subId) // Refreshes observers in batches. - private val bundler = BundledUpdate(300, Dispatchers.Default) + private val bundler = BundledUpdate(500, Dispatchers.Default) fun invalidateFilters() { bundler.invalidate { From d04cf9741871ecba78e44df87f8c996c2d19eb25 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Sep 2025 19:24:01 -0400 Subject: [PATCH 32/32] Better logs on BasicRelayClient --- .../nip01Core/relay/client/single/basic/BasicRelayClient.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt index 27410def9c..ecf3e21435 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/single/basic/BasicRelayClient.kt @@ -174,6 +174,8 @@ open class BasicRelayClient( } override fun onMessage(text: String) { + // Log.d(logTag, "Receiving: $text") + if (text.startsWith(EVENT_MESSAGE_PREFIX)) { // defers the parsing of ["EVENTS" to avoid blocking the HTTP thread scope.launch(Dispatchers.Default) { @@ -239,7 +241,7 @@ open class BasicRelayClient( text: String, onConnected: () -> Unit, ) { - // Log.d(logTag, "Receiving: $text") + // Log.d(logTag, "Processing: $text") stats.addBytesReceived(text.bytesUsedInMemory()) try { @@ -465,7 +467,7 @@ open class BasicRelayClient( ) } socket?.let { - Log.d(logTag, "Sending: $str") + // Log.d(logTag, "Sending (${str.length} chars): $str") val result = it.send(str) listener.onSend(this@BasicRelayClient, str, result) stats.addBytesSent(str.bytesUsedInMemory())