Merge branch 'vitorpamplona:main' into follows-and-followsets-unified

This commit is contained in:
KotlinGeekDev
2025-09-26 09:55:31 +00:00
committed by GitHub
22 changed files with 663 additions and 223 deletions
@@ -81,7 +81,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
@@ -315,7 +314,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
@@ -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<NormalizedRelayUrl>>): Set<NormalizedRelayUrl> = lists.reduce { acc, set -> acc + set }
val flow: StateFlow<Set<NormalizedRelayUrl>> =
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,
),
),
)
}
@@ -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.")
}
@@ -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<Logger>()
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)
}
@@ -50,7 +50,7 @@ abstract class BaseEoseManager<T>(
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 {
@@ -38,14 +38,16 @@ val MetadataAndRelayListKinds =
fun filterFindUserMetadataForKey(
author: HexKey,
indexRelays: Set<NormalizedRelayUrl>,
defaultRelays: Set<NormalizedRelayUrl>,
): List<RelayBasedFilter> =
LocalCache.checkGetOrCreateUser(author)?.let {
filterFindUserMetadataForKey(setOf(it), defaultRelays)
filterFindUserMetadataForKey(setOf(it), indexRelays, defaultRelays)
} ?: emptyList()
fun filterFindUserMetadataForKey(
authors: Set<User>,
indexRelays: Set<NormalizedRelayUrl>,
defaultRelays: Set<NormalizedRelayUrl>,
): List<RelayBasedFilter> {
val perRelayKeys =
@@ -53,8 +55,8 @@ 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 }
?: defaultRelays.toList()
relays.forEach {
add(it, key.pubkeyHex)
@@ -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
@@ -43,10 +44,15 @@ class UserLoaderSubAssembler(
}
}
val indexRelays = mutableSetOf<NormalizedRelayUrl>()
val defaultRelays = mutableSetOf<NormalizedRelayUrl>()
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 +66,7 @@ class UserLoaderSubAssembler(
if (firstTimers.isEmpty()) return null
return filterFindUserMetadataForKey(firstTimers, defaultRelays)
return filterFindUserMetadataForKey(firstTimers, indexRelays, defaultRelays)
}
override fun distinct(key: UserFinderQueryState) = key.user
@@ -26,5 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
fun filterByAuthor(
pubKey: HexKey,
indexRelays: Set<NormalizedRelayUrl>,
defaultRelays: Set<NormalizedRelayUrl>,
) = filterFindUserMetadataForKey(pubKey, defaultRelays)
) = filterFindUserMetadataForKey(pubKey, indexRelays, defaultRelays)
@@ -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()
@@ -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() {
@@ -25,23 +25,12 @@ import android.graphics.Bitmap
import android.net.Uri
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.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.components.util.MediaCompressorFileUtils
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.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
class MediaCompressorResult(
val uri: Uri,
@@ -67,7 +56,9 @@ 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 -> {
VideoCompressionHelper.compressVideo(uri, contentType, applicationContext, mediaQuality)
}
contentType?.startsWith("image", ignoreCase = true) == true &&
!contentType.contains("gif") &&
!contentType.contains("svg") ->
@@ -76,91 +67,6 @@ class MediaCompressor {
}
}
@OptIn(ExperimentalUuidApi::class)
private suspend fun compressVideo(
uri: Uri,
contentType: String?,
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
}
Log.d("MediaCompressor", "Using video compression $videoQuality")
val result =
withTimeoutOrNull(30000) {
suspendCancellableCoroutine { continuation ->
VideoCompressor.start(
// => This is required
context = applicationContext,
// => Source can be provided as content uris
uris = listOf(uri),
isStreamable = false,
// THIS STORAGE
// sharedStorageConfiguration = SharedStorageConfiguration(
// saveAt = SaveLocation.movies, // => default is movies
// videoName = "compressed_video" // => required name
// ),
// OR AND NOT BOTH
storageConfiguration = AppSpecificStorageConfiguration(),
configureWith =
Configuration(
quality = videoQuality,
// => required name
videoNames = listOf(Uuid.random().toString()),
),
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) {
Log.d("MediaCompressor", "Video compression success. Compressed size [$size]")
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?,
@@ -203,15 +109,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
}
}
}
@@ -0,0 +1,362 @@
/**
* 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.os.Handler
import android.os.Looper
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 getStandard(): VideoStandard =
when {
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,
val bitrateMbps: Float,
val description: String,
) {
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 (bitrateMbps * multiplier).roundToInt().coerceAtLeast(1)
}
}
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"),
),
)
suspend fun compressVideo(
uri: Uri,
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 bitrateMbpsInt =
compressionRules
.getValue(mediaQuality)
.getValue(videoInfo.resolution.getStandard())
.getBitrateMbpsInt(videoInfo.framerate)
Log.d(
LOG_TAG,
"Bitrate: ${bitrateMbpsInt}Mbps for ${videoInfo.resolution.getStandard()} " +
"quality=$mediaQuality framerate=${videoInfo.framerate}fps.",
)
} else {
Log.w(LOG_TAG, "Video bitrate fallback: 2Mbps (videoInfo unavailable)")
2
}
val resizer =
if (videoInfo != null) {
val rules =
compressionRules
.getValue(mediaQuality)
.getValue(videoInfo.resolution.getStandard())
Log.d(
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(LOG_TAG, "Resizer: null (original resolution preserved)")
null
}
// Get original file size safely
val originalSize = applicationContext.getFileSize(uri)
val result =
withTimeoutOrNull(timeoutMs) {
suspendCancellableCoroutine { continuation ->
VideoCompressor.start(
context = applicationContext,
uris = listOf(uri),
isStreamable = true,
storageConfiguration = AppSpecificStorageConfiguration(),
configureWith =
Configuration(
videoBitrateInMbps = videoBitrateInMbps,
resizer = resizer,
videoNames = listOf(UUID.randomUUID().toString()),
isMinBitrateCheckEnabled = false,
),
listener =
object : CompressionListener {
override fun onStart(index: Int) {}
override fun onProgress(
index: Int,
percent: Float,
) {}
override fun onSuccess(
index: Int,
size: Long,
path: String?,
) {
if (path == null) {
applicationContext.notifyUser(
"Video compression succeeded, but path was null",
Log.WARN,
)
if (continuation.isActive) continuation.resume(null)
return
}
val reductionPercent =
if (originalSize > 0) {
((originalSize - size) * 100.0 / originalSize).toInt()
} else {
0
}
// Sanity check: compression not smaller than original
if (originalSize > 0 && size >= originalSize) {
applicationContext.notifyUser(
"Compressed file larger than original. Using original.",
Log.WARN,
)
if (continuation.isActive) {
continuation.resume(
MediaCompressorResult(uri, contentType, 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)",
)
}
Log.d(
LOG_TAG,
"Compression success: Original [$originalSize] -> " +
"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(correctedPath)), contentType, size),
)
}
}
override fun onFailure(
index: Int,
failureMessage: String,
) {
applicationContext.notifyUser(
"Video compression failed: $failureMessage",
Log.ERROR,
)
if (continuation.isActive) continuation.resume(null)
}
override fun onCancelled(index: Int) {
Log.w(LOG_TAG, "Video compression cancelled")
if (continuation.isActive) continuation.resume(null)
}
},
)
}
}
return result ?: MediaCompressorResult(uri, contentType, null)
}
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(LOG_TAG, "Failed to get file size: ${e.message}")
0L
}
private fun Context.notifyUser(
message: 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(LOG_TAG, message)
Log.WARN -> Log.w(LOG_TAG, message)
else -> Log.d(LOG_TAG, message)
}
}
private fun getVideoInfo(
uri: Uri,
context: Context,
): VideoInfo? {
var retriever: MediaMetadataRetriever? = null
return try {
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
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(LOG_TAG, "Failed to get video resolution: ${e.message}")
null
} finally {
try {
retriever?.release()
} catch (e: Exception) {
Log.w(LOG_TAG, "Failed to release MediaMetadataRetriever: ${e.message}")
}
}
}
}
@@ -43,7 +43,7 @@ fun filterCommunitiesGlobal(
filter =
Filter(
kinds = listOf(CommunityDefinitionEvent.KIND),
limit = 500,
limit = 200,
since = since,
),
),
@@ -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,
),
),
@@ -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)
@@ -112,6 +112,7 @@
<string name="post">पत्र प्रकाशन</string>
<string name="save">अभिलेखन करें</string>
<string name="create">बनाएँ</string>
<string name="rename">पुनःनामकरण</string>
<string name="cancel">निरस्त करें</string>
<string name="failed_to_upload_the_image">चित्र आरोहण असफल</string>
<string name="relay_address">पुनःप्रसारक पता</string>
@@ -440,10 +441,38 @@
<string name="no">नहीं</string>
<string name="follow_list_selection">अनुचरण सूची</string>
<string name="follow_list_kind3follows">सभी अनुचरित</string>
<string name="follow_list_kind3follows_users_only">प्रयोक्ता के सभी अनुगामी</string>
<string name="follow_list_kind3follows_proxy">प्रतिनिधि द्वारा अनुचरित</string>
<string name="follow_list_aroundme">मेरे आसपास</string>
<string name="follow_list_global">वैश्विक</string>
<string name="follow_list_mute_list">मौन सूची</string>
<string name="follow_sets">अनुगम्य सूचियाँ</string>
<string name="labeled_bookmarks">सूचक युक्त स्मर्त्तव्य सूची</string>
<string name="general_bookmarks">सामान्य स्मर्त्तव्य सूची</string>
<string name="follow_set_type_public">सार्वजनिक</string>
<string name="follow_set_type_private">निजी</string>
<string name="follow_set_type_mixed">मिश्रित</string>
<string name="follow_set_empty_feed_msg"> लगता है आपका अब तक कोइ अनुगम्य समुच्चय नहीं है।
\nनवीकरण के लिए नीचे दबाएँ। अथवा एक नया बनाने के लिए जोड घुण्डियाँ टाँकें।
</string>
<string name="follow_set_add_author_from_note_action">लेखक जोडें अनुगम्य सूची में</string>
<string name="follow_set_profile_actions_menu_description">प्रयोक्ता को सूचियों में जोडें अथवा हटाएँ अथवा इस प्रयोक्ता के साथ नई सूची बनाएँ।</string>
<string name="follow_set_type_description">सूची %1$s के लिए चिह्न</string>
<string name="follow_set_presence_indicator">"%1$s इस सूची में है"</string>
<string name="follow_set_absence_indicator">"%1$s इस सूची में नहीं है"</string>
<string name="follow_set_man_dialog_title">आपके अनुगम्य सूचियाँ</string>
<string name="follow_set_empty_dialog_msg">कोई अनुगम्य सूचियाँ प्राप्त नहीं। अथवा आपका कोई अनुगम्य सूचियाँ हैं नहीं। नवीकरण के लिए नीचे दबाएँ अथवा विकल्पसूची द्वारा एक नया बनाएँ।</string>
<string name="follow_set_error_dialog_msg">लाने में अपक्रम : %1$s</string>
<string name="follow_set_creation_menu_title">नई सूची बनाएँ</string>
<string name="follow_set_creation_item_label">प्रयोक्ता के साथ नई सूची %1$s बनाएँ</string>
<string name="follow_set_creation_item_description">अनुगम्य सूची %1$s बनाता है तथा उससे %2$s जोडता है।</string>
<string name="follow_set_creation_dialog_title">नई %1$s सूची</string>
<string name="follow_set_creation_name_label">समुच्चय नाम</string>
<string name="follow_set_creation_desc_label">समुच्चय विवरण (आवश्यक नहीं)</string>
<string name="follow_set_creation_action_btn_label">समुच्चय बनाएँ</string>
<string name="follow_set_rename_btn_label">समुच्चय पुनःनामकरण</string>
<string name="follow_set_rename_dialog_indicator_first_part">आप पनःनामकरण कर रहे हैं इस से </string>
<string name="follow_set_rename_dialog_indicator_second_part"> इस तक..</string>
<string name="connect_through_your_orbot_setup_short">मूलविकल्प द्वार ९०५० है</string>
<string name="connect_through_your_orbot_setup_markdown">## टोर द्वारा संयोजन करें ओर्बोट के साथ
\n\n१. स्थापित करें [ओर्बोट](https://play.google.com/store/apps/details?id=org.torproject.android)
@@ -1013,6 +1042,8 @@
<string name="torrent_download">अवरोहण</string>
<string name="torrent_failure">अभिलेख खोलने में असफल</string>
<string name="torrent_no_apps">कोई उग्रप्रवाह क्रमक स्थापित नहीं अभिलेख खोलने तथा अवरोहण करने के लिए।</string>
<string name="torrent_no_info">अभिलेखविभेदक युक्त जालनिर्देशक बनाने के लिए पर्याप्त जानकारी नहीं है घटना में</string>
<string name="my_lists_and_sets">मेरे सूचियाँ / समुच्चय</string>
<string name="select_list_to_filter">सूचनावली छानने के लिए सूची चुनें</string>
<string name="temporary_account">यन्त्र ताला लगने पर निर्गमनांकन करें</string>
<string name="private_message">निजी सन्देश</string>
@@ -1020,6 +1051,7 @@
<string name="group_relay">चर्चा पुनःप्रसारक</string>
<string name="group_relay_explanation">वह पुनःप्रसारक जिससे इस चर्चा के सभी उपयोगकर्ता जुडते हैं</string>
<string name="share_image">चित्र बाँटें…</string>
<string name="unable_to_share_image">चित्र बाँटने में असफल। कृपया कुछ समय पश्चात पुनःप्रयास करें…</string>
<string name="search_by_hashtag">विषयसूचक खोज : #%1$s</string>
<string name="dont_translate_from">अनुवाद ना करें</string>
<string name="dont_translate_from_description">यहाँ प्रस्तुत भाषाओं का अनुवाद नहीं होगा। भाषा चयन करें हटाने के लिए जिससे उसका अनुवाद पुनः होने लगेगा।</string>
@@ -112,6 +112,7 @@
<string name="post">发布</string>
<string name="save">保存</string>
<string name="create">创建</string>
<string name="rename">重命名</string>
<string name="cancel">取消</string>
<string name="failed_to_upload_the_image">上传图片失败</string>
<string name="relay_address">中继器地址</string>
@@ -445,6 +446,33 @@
<string name="follow_list_aroundme">周围的人</string>
<string name="follow_list_global">全球</string>
<string name="follow_list_mute_list">静音列表</string>
<string name="follow_sets">关注集</string>
<string name="labeled_bookmarks">有标签的书签</string>
<string name="general_bookmarks">常规书签</string>
<string name="follow_set_type_public">公开</string>
<string name="follow_set_type_private">私密</string>
<string name="follow_set_type_mixed">混合</string>
<string name="follow_set_empty_feed_msg"> 似乎你还没有任何关注集。
\n轻按下方刷新,或者轻按“+”按钮新建一个。
</string>
<string name="follow_set_add_author_from_note_action">添加作者到关注集</string>
<string name="follow_set_profile_actions_menu_description">从列表中添加或删除用户,或用此用户创建一个新列表。</string>
<string name="follow_set_type_description">%1$s 列表的图标</string>
<string name="follow_set_presence_indicator">"此列表中有 %1$s"</string>
<string name="follow_set_absence_indicator">"此列表中没有 %1$s"</string>
<string name="follow_set_man_dialog_title">您的关注集</string>
<string name="follow_set_empty_dialog_msg">未找到关注集,或者你还没有任何关注集。轻按下方刷新,或使用按钮新建。</string>
<string name="follow_set_error_dialog_msg">获取时出了问题: %1$s</string>
<string name="follow_set_creation_menu_title">新建列表</string>
<string name="follow_set_creation_item_label">创建用户新的 %1$s 列表</string>
<string name="follow_set_creation_item_description">创建 %1$s 关注集,并添加 %2$s 到其中。</string>
<string name="follow_set_creation_dialog_title">新的 %1$s 列表</string>
<string name="follow_set_creation_name_label">集合名</string>
<string name="follow_set_creation_desc_label">集合描述(可选)</string>
<string name="follow_set_creation_action_btn_label">新建集</string>
<string name="follow_set_rename_btn_label">重命名集</string>
<string name="follow_set_rename_dialog_indicator_first_part">正将集合名称从 </string>
<string name="follow_set_rename_dialog_indicator_second_part"> 改为</string>
<string name="connect_through_your_orbot_setup_short">默认端口为 9050</string>
<string name="connect_through_your_orbot_setup_markdown"> ## 通过 Orbot 连线 Tor
\n\n1. 安装 [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
@@ -1014,6 +1042,8 @@
<string name="torrent_download">下载</string>
<string name="torrent_failure">打开文件失败</string>
<string name="torrent_no_apps">没有用于打开和下载文件的 Torrent 客户端</string>
<string name="torrent_no_info">事件没有足够信息来构建磁力链</string>
<string name="my_lists_and_sets">我的列表/集合</string>
<string name="select_list_to_filter">选择一个用于过滤订阅源的列表</string>
<string name="temporary_account">当设备锁定时注销</string>
<string name="private_message">私信</string>
+1
View File
@@ -1289,4 +1289,5 @@
<string name="would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared">Would you like to send the recent crash report to Amethyst in a DM? No personal information will be shared</string>
<string name="crashreport_found_send">Send it</string>
<string name="this_message_will_disappear_in_days">This message will disappear in %1$d days</string>
<string name="select_signer">Select Signer</string>
</resources>
@@ -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<Permission> = LoginRequest.DefaultPermissions): Intent {
fun createIntent(
permissions: List<Permission> = 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
}
@@ -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<ResolveInfo> =
context.packageManager
.queryIntentActivities(
Intent().apply {
action = Intent.ACTION_VIEW
data = "nostrsigner:".toUri()
},
0,
)
@@ -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())
@@ -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,