mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
Merge branch 'vitorpamplona:main' into follows-and-followsets-unified
This commit is contained in:
@@ -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
|
||||
|
||||
-92
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
+2
-1
@@ -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.")
|
||||
}
|
||||
|
||||
+67
@@ -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)
|
||||
}
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+5
-3
@@ -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)
|
||||
|
||||
+8
-2
@@ -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
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+8
-5
@@ -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()
|
||||
|
||||
+3
-1
@@ -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() {
|
||||
|
||||
+3
-106
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+362
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -43,7 +43,7 @@ fun filterCommunitiesGlobal(
|
||||
filter =
|
||||
Filter(
|
||||
kinds = listOf(CommunityDefinitionEvent.KIND),
|
||||
limit = 500,
|
||||
limit = 200,
|
||||
since = since,
|
||||
),
|
||||
),
|
||||
|
||||
+3
-3
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
+111
-1
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+7
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+12
@@ -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,
|
||||
)
|
||||
|
||||
+4
-2
@@ -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())
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user