mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2025-03-26 17:52:29 +01:00
- Adds a new Settings page for Tor
- Adjusts all web calls to use Tor if that setting is enabled. - Allows .onion urls to use Tor - Blocks localhost from using Tor - Moves OTS web calls to use the Tor Proxy as well. - Starts to build all OkHttp clients from a main root node to keep the same thread pool - Refactors the URL Preview code - Changes ammolite to force proxy. - Changes NIP-47 implementation to force relay for the NWC connection.
This commit is contained in:
parent
b682e90f81
commit
afe8a06486
@ -32,11 +32,8 @@ import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
|
||||
import com.vitorpamplona.amethyst.service.playback.VideoCache
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.ots.OpenTimestamps
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -94,7 +91,7 @@ class Amethyst : Application() {
|
||||
|
||||
instance = this
|
||||
|
||||
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer(), OkHttpCalendarBuilder())
|
||||
HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
|
||||
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
|
||||
StrictMode.setThreadPolicy(
|
||||
|
@ -34,8 +34,10 @@ import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS
|
||||
import com.vitorpamplona.amethyst.model.Settings
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
@ -104,6 +106,7 @@ private object PrefKeys {
|
||||
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
|
||||
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
|
||||
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
|
||||
const val TOR_SETTINGS = "tor_settings"
|
||||
const val USE_PROXY = "use_proxy"
|
||||
const val PROXY_PORT = "proxy_port"
|
||||
const val SHOW_SENSITIVE_CONTENT = "show_sensitive_content"
|
||||
@ -402,8 +405,13 @@ object LocalPreferences {
|
||||
putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog)
|
||||
putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog)
|
||||
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
|
||||
putBoolean(PrefKeys.USE_PROXY, settings.proxy != null)
|
||||
putInt(PrefKeys.PROXY_PORT, settings.proxyPort)
|
||||
|
||||
// migrating from previous design
|
||||
remove(PrefKeys.USE_PROXY)
|
||||
remove(PrefKeys.PROXY_PORT)
|
||||
|
||||
putString(PrefKeys.TOR_SETTINGS, Event.mapper.writeValueAsString(settings.torSettings.toSettings()))
|
||||
|
||||
putBoolean(PrefKeys.WARN_ABOUT_REPORTS, settings.warnAboutPostsWithReports)
|
||||
putBoolean(PrefKeys.FILTER_SPAM_FROM_STRANGERS, settings.filterSpamFromStrangers)
|
||||
|
||||
@ -538,8 +546,28 @@ object LocalPreferences {
|
||||
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
|
||||
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
|
||||
val useProxy = getBoolean(PrefKeys.USE_PROXY, false)
|
||||
val proxyPort = getInt(PrefKeys.PROXY_PORT, 9050)
|
||||
val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
|
||||
val torSettings =
|
||||
if (useProxy) {
|
||||
// old settings, means Orbot
|
||||
TorSettings(
|
||||
TorType.EXTERNAL,
|
||||
getInt(PrefKeys.PROXY_PORT, 9050),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
|
||||
}
|
||||
|
||||
val showSensitiveContent =
|
||||
if (contains(PrefKeys.SHOW_SENSITIVE_CONTENT)) {
|
||||
@ -586,8 +614,7 @@ object LocalPreferences {
|
||||
backupSearchRelayList = latestSearchRelayList,
|
||||
backupPrivateHomeRelayList = latestPrivateHomeRelayList,
|
||||
backupMuteList = latestMuteList,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
torSettings = TorSettingsFlow.build(torSettings),
|
||||
showSensitiveContent = MutableStateFlow(showSensitiveContent),
|
||||
warnAboutPostsWithReports = warnAboutReports,
|
||||
filterSpamFromStrangers = filterSpam,
|
||||
|
@ -48,11 +48,17 @@ import com.vitorpamplona.amethyst.service.NostrSingleUserDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrThreadDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource
|
||||
import com.vitorpamplona.amethyst.service.NostrVideoDataSource
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpBlockstreamExplorer
|
||||
import com.vitorpamplona.amethyst.service.ots.OkHttpCalendarBuilder
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import com.vitorpamplona.ammolite.relays.Client
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.encoders.bechToBytes
|
||||
import com.vitorpamplona.quartz.encoders.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.encoders.toHexKey
|
||||
import com.vitorpamplona.quartz.events.OtsEvent
|
||||
import com.vitorpamplona.quartz.ots.OpenTimestamps
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -88,8 +94,27 @@ class ServiceManager(
|
||||
val myAccount = account
|
||||
|
||||
// Resets Proxy Use
|
||||
HttpClientManager.setDefaultProxy(account?.settings?.proxy)
|
||||
HttpClientManager.setDefaultUserAgent("Amethyst/${BuildConfig.VERSION_NAME}")
|
||||
if (myAccount != null) {
|
||||
when (myAccount.settings.torSettings.torType.value) {
|
||||
TorType.INTERNAL -> {
|
||||
Log.d("TorManager", "Relays Service Connected ${TorManager.socksPort()}")
|
||||
HttpClientManager.setDefaultProxyOnPort(TorManager.socksPort())
|
||||
}
|
||||
TorType.EXTERNAL -> HttpClientManager.setDefaultProxyOnPort(myAccount.settings.torSettings.externalSocksPort.value)
|
||||
else -> HttpClientManager.setDefaultProxy(null)
|
||||
}
|
||||
|
||||
OtsEvent.otsInstance =
|
||||
OpenTimestamps(
|
||||
OkHttpBlockstreamExplorer(myAccount::shouldUseTorForMoneyOperations),
|
||||
OkHttpCalendarBuilder(myAccount::shouldUseTorForMoneyOperations),
|
||||
)
|
||||
} else {
|
||||
OtsEvent.otsInstance = OpenTimestamps(OkHttpBlockstreamExplorer { false }, OkHttpCalendarBuilder { false })
|
||||
|
||||
HttpClientManager.setDefaultProxy(null)
|
||||
}
|
||||
|
||||
LocalCache.antiSpam.active = account?.settings?.filterSpamFromStrangers ?: true
|
||||
Coil.setImageLoader {
|
||||
Amethyst.instance
|
||||
@ -106,22 +131,23 @@ class ServiceManager(
|
||||
if (BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark") {
|
||||
this.logger(DebugLogger())
|
||||
}
|
||||
}.okHttpClient { HttpClientManager.getHttpClient() }
|
||||
.precision(Precision.INEXACT)
|
||||
}.okHttpClient {
|
||||
myAccount?.shouldUseTorForImageDownload()?.let { HttpClientManager.getHttpClient(it) }
|
||||
?: HttpClientManager.getHttpClient(false)
|
||||
}.precision(Precision.INEXACT)
|
||||
.respectCacheHeaders(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
if (myAccount != null) {
|
||||
val relaySet = myAccount.connectToRelays.value
|
||||
Log.d("Relay", "Service Manager Connect Connecting ${relaySet.size}")
|
||||
val relaySet = myAccount.connectToRelaysWithProxy.value
|
||||
Client.reconnect(relaySet)
|
||||
|
||||
collectorJob?.cancel()
|
||||
collectorJob = null
|
||||
collectorJob =
|
||||
scope.launch {
|
||||
myAccount.connectToRelaysFlow.collectLatest {
|
||||
myAccount.connectToRelaysWithProxy.collectLatest {
|
||||
delay(500)
|
||||
if (isStarted) {
|
||||
Client.reconnect(it, onlyIfChanged = true)
|
||||
|
@ -31,13 +31,16 @@ import com.vitorpamplona.amethyst.BuildConfig
|
||||
import com.vitorpamplona.amethyst.service.FileHeader
|
||||
import com.vitorpamplona.amethyst.service.NostrLnZapPaymentResponseDataSource
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import com.vitorpamplona.ammolite.relays.Client
|
||||
import com.vitorpamplona.ammolite.relays.Constants
|
||||
import com.vitorpamplona.ammolite.relays.FeedType
|
||||
import com.vitorpamplona.ammolite.relays.Relay
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfoToConnect
|
||||
import com.vitorpamplona.ammolite.relays.TypedFilter
|
||||
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
@ -115,6 +118,7 @@ import kotlinx.coroutines.flow.combineTransform
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
@ -405,6 +409,45 @@ class Account(
|
||||
).toTypedArray(),
|
||||
)
|
||||
|
||||
val connectToRelaysWithProxy =
|
||||
combineTransform(
|
||||
connectToRelays,
|
||||
settings.torSettings.torType,
|
||||
settings.torSettings.trustedRelaysViaTor,
|
||||
) { relays, torType, forceTor ->
|
||||
emit(
|
||||
relays
|
||||
.map {
|
||||
RelaySetupInfoToConnect(
|
||||
it.url,
|
||||
torType != TorType.OFF && forceTor,
|
||||
it.read,
|
||||
it.write,
|
||||
it.feedTypes,
|
||||
)
|
||||
}.toTypedArray(),
|
||||
)
|
||||
}.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
normalizeAndCombineRelayListsWithFallbacks(
|
||||
kind3Relays(),
|
||||
getDMRelayList(),
|
||||
getSearchRelayList(),
|
||||
getPrivateOutboxRelayList(),
|
||||
getNIP65RelayList(),
|
||||
).map {
|
||||
RelaySetupInfoToConnect(
|
||||
it.url,
|
||||
settings.torSettings.torType.value != TorType.OFF && settings.torSettings.trustedRelaysViaTor.value,
|
||||
it.read,
|
||||
it.write,
|
||||
it.feedTypes,
|
||||
)
|
||||
}.toTypedArray(),
|
||||
)
|
||||
|
||||
fun buildFollowLists(latestContactList: ContactListEvent?): LiveFollowLists {
|
||||
// makes sure the output include only valid p tags
|
||||
val verifiedFollowingUsers = latestContactList?.verifiedFollowKeySet() ?: emptySet()
|
||||
@ -425,6 +468,36 @@ class Account(
|
||||
)
|
||||
}
|
||||
|
||||
fun normalizeDMRelayListWithBackup(note: Note): Set<String> {
|
||||
val event = note.event as? ChatMessageRelayListEvent ?: settings.backupDMRelayList
|
||||
return event?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
val normalizedDmRelaySet =
|
||||
getDMRelayListFlow()
|
||||
.map { normalizeDMRelayListWithBackup(it.note) }
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
normalizeDMRelayListWithBackup(getDMRelayListNote()),
|
||||
)
|
||||
|
||||
fun normalizePrivateOutboxRelayListWithBackup(note: Note): Set<String> {
|
||||
val event = note.event as? PrivateOutboxRelayListEvent ?: settings.backupPrivateHomeRelayList
|
||||
return event?.relays()?.map { RelayUrlFormatter.normalize(it) }?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
val normalizedPrivateOutBoxRelaySet =
|
||||
getPrivateOutboxRelayListFlow()
|
||||
.map { normalizePrivateOutboxRelayListWithBackup(it.note) }
|
||||
.flowOn(Dispatchers.Default)
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
normalizePrivateOutboxRelayListWithBackup(getPrivateOutboxRelayListNote()),
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val liveKind3FollowsFlow: Flow<LiveFollowLists> =
|
||||
userProfile().flow().follows.stateFlow.transformLatest {
|
||||
@ -562,6 +635,13 @@ class Account(
|
||||
fun authorsPerRelay(
|
||||
followsNIP65RelayLists: List<Note>,
|
||||
defaultRelayList: List<String>,
|
||||
torType: TorType,
|
||||
): Map<String, List<HexKey>> = authorsPerRelay(followsNIP65RelayLists, defaultRelayList, torType != TorType.OFF)
|
||||
|
||||
fun authorsPerRelay(
|
||||
followsNIP65RelayLists: List<Note>,
|
||||
defaultRelayList: List<String>,
|
||||
acceptOnion: Boolean,
|
||||
): Map<String, List<HexKey>> {
|
||||
checkNotInMainThread()
|
||||
|
||||
@ -595,7 +675,7 @@ class Account(
|
||||
}
|
||||
}
|
||||
}.toMap(),
|
||||
hasOnionConnection = settings.proxy != null,
|
||||
hasOnionConnection = acceptOnion,
|
||||
)
|
||||
}
|
||||
|
||||
@ -611,12 +691,13 @@ class Account(
|
||||
}
|
||||
|
||||
val liveHomeListAuthorsPerRelayFlow: Flow<Map<String, List<HexKey>>?> by lazy {
|
||||
combineTransform(liveHomeFollowListAdvertizedRelayListFlow, connectToRelays) { adverisedRelayList, existing ->
|
||||
combineTransform(liveHomeFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torStatus ->
|
||||
if (adverisedRelayList != null) {
|
||||
emit(
|
||||
authorsPerRelay(
|
||||
adverisedRelayList.map { it.note },
|
||||
existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
|
||||
torStatus,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
@ -632,6 +713,7 @@ class Account(
|
||||
authorsPerRelay(
|
||||
liveHomeFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(),
|
||||
connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
|
||||
settings.torSettings.torType.value,
|
||||
).ifEmpty { null },
|
||||
)
|
||||
}
|
||||
@ -692,9 +774,15 @@ class Account(
|
||||
}
|
||||
|
||||
val liveStoriesListAuthorsPerRelayFlow: Flow<Map<String, List<String>>?> by lazy {
|
||||
combineTransform(liveStoriesFollowListAdvertizedRelayListFlow, connectToRelays) { adverisedRelayList, existing ->
|
||||
combineTransform(liveStoriesFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torState ->
|
||||
if (adverisedRelayList != null) {
|
||||
emit(authorsPerRelay(adverisedRelayList.map { it.note }, existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url }))
|
||||
emit(
|
||||
authorsPerRelay(
|
||||
adverisedRelayList.map { it.note },
|
||||
existing.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
|
||||
torState,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
emit(null)
|
||||
}
|
||||
@ -708,6 +796,7 @@ class Account(
|
||||
authorsPerRelay(
|
||||
liveStoriesFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(),
|
||||
connectToRelays.value.filter { it.feedTypes.contains(FeedType.FOLLOWS) && it.read }.map { it.url },
|
||||
settings.torSettings.torType.value,
|
||||
).ifEmpty { null },
|
||||
)
|
||||
}
|
||||
@ -746,9 +835,15 @@ class Account(
|
||||
}
|
||||
|
||||
val liveDiscoveryListAuthorsPerRelayFlow: Flow<Map<String, List<String>>?> by lazy {
|
||||
combineTransform(liveDiscoveryFollowListAdvertizedRelayListFlow, connectToRelays) { adverisedRelayList, existing ->
|
||||
combineTransform(liveDiscoveryFollowListAdvertizedRelayListFlow, connectToRelays, settings.torSettings.torType) { adverisedRelayList, existing, torState ->
|
||||
if (adverisedRelayList != null) {
|
||||
emit(authorsPerRelay(adverisedRelayList.map { it.note }, existing.filter { it.read }.map { it.url }))
|
||||
emit(
|
||||
authorsPerRelay(
|
||||
adverisedRelayList.map { it.note },
|
||||
existing.filter { it.read }.map { it.url },
|
||||
torState,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
emit(null)
|
||||
}
|
||||
@ -762,6 +857,7 @@ class Account(
|
||||
authorsPerRelay(
|
||||
liveDiscoveryFollowLists.value?.usersPlusMe?.map { getNIP65RelayListNote(it) } ?: emptyList(),
|
||||
connectToRelays.value.filter { it.read }.map { it.url },
|
||||
settings.torSettings.torType.value,
|
||||
).ifEmpty { null },
|
||||
)
|
||||
}
|
||||
@ -1192,10 +1288,16 @@ class Account(
|
||||
|
||||
LocalCache.consume(event, zappedNote) { it.response(signer) { onResponse(it) } }
|
||||
|
||||
Client.send(
|
||||
Client.sendSingle(
|
||||
signedEvent = event,
|
||||
relay = nip47.relayUri,
|
||||
feedTypes = wcListener.feedTypes,
|
||||
relayTemplate =
|
||||
RelaySetupInfoToConnect(
|
||||
nip47.relayUri,
|
||||
shouldUseTorForTrustedRelays(), // this is trusted.
|
||||
true,
|
||||
true,
|
||||
wcListener.feedTypes,
|
||||
),
|
||||
onDone = { wcListener.destroy() },
|
||||
)
|
||||
|
||||
@ -1645,7 +1747,7 @@ class Account(
|
||||
fun consumeAndSendNip95(
|
||||
data: FileStorageEvent,
|
||||
signedEvent: FileStorageHeaderEvent,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
): Note? {
|
||||
if (!isWriteable()) return null
|
||||
|
||||
@ -1671,7 +1773,7 @@ class Account(
|
||||
fun sendNip95(
|
||||
data: FileStorageEvent,
|
||||
signedEvent: FileStorageHeaderEvent,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
) {
|
||||
Client.send(data, relayList = relayList)
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
@ -1679,7 +1781,7 @@ class Account(
|
||||
|
||||
fun sendHeader(
|
||||
signedEvent: FileHeaderEvent,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
onReady: (Note) -> Unit,
|
||||
) {
|
||||
Client.send(signedEvent, relayList = relayList)
|
||||
@ -1723,7 +1825,7 @@ class Account(
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
originalHash: String? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
onReady: (Note) -> Unit,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
@ -1758,7 +1860,7 @@ class Account(
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<Event>? = null,
|
||||
draftTag: String?,
|
||||
@ -1796,13 +1898,7 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent ->
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
sendDraftEvent(draftEvent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -1831,7 +1927,7 @@ class Account(
|
||||
root: String?,
|
||||
directMentions: Set<HexKey>,
|
||||
forkedFrom: Event?,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String?,
|
||||
@ -1865,13 +1961,7 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
sendDraftEvent(draftEvent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -1905,7 +1995,7 @@ class Account(
|
||||
root: String,
|
||||
directMentions: Set<HexKey>,
|
||||
forkedFrom: Event?,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String?,
|
||||
@ -1937,13 +2027,7 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
sendDraftEvent(draftEvent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -1972,7 +2056,18 @@ class Account(
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is DraftEvent) {
|
||||
noteEvent.createDeletedEvent(signer) {
|
||||
Client.sendPrivately(it, relayList = note.relays.map { it.url })
|
||||
Client.sendPrivately(
|
||||
it,
|
||||
note.relays.map { it.url }.map {
|
||||
RelaySetupInfoToConnect(
|
||||
it,
|
||||
shouldUseTorForClean(it),
|
||||
false,
|
||||
true,
|
||||
emptySet(),
|
||||
)
|
||||
},
|
||||
)
|
||||
LocalCache.justConsume(it, null)
|
||||
}
|
||||
}
|
||||
@ -1992,7 +2087,7 @@ class Account(
|
||||
root: String?,
|
||||
directMentions: Set<HexKey>,
|
||||
forkedFrom: Event?,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String?,
|
||||
@ -2026,13 +2121,7 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
sendDraftEvent(draftEvent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -2060,7 +2149,7 @@ class Account(
|
||||
originalNote: Note,
|
||||
notify: HexKey?,
|
||||
summary: String? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
) {
|
||||
if (!isWriteable()) return
|
||||
|
||||
@ -2090,7 +2179,7 @@ class Account(
|
||||
zapReceiver: List<ZapSplitSetup>? = null,
|
||||
wantsToMarkAsSensitive: Boolean,
|
||||
zapRaiserAmount: Long? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
geohash: String? = null,
|
||||
nip94attachments: List<FileHeaderEvent>? = null,
|
||||
draftTag: String?,
|
||||
@ -2124,13 +2213,7 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent, relayList = relayList)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
sendDraftEvent(draftEvent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -2183,13 +2266,7 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
sendDraftEvent(draftEvent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -2235,13 +2312,7 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, signer) { draftEvent ->
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
sendDraftEvent(draftEvent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -2314,13 +2385,7 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it, emptyList(), signer) { draftEvent ->
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
sendDraftEvent(draftEvent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -2367,13 +2432,7 @@ class Account(
|
||||
deleteDraft(draftTag)
|
||||
} else {
|
||||
DraftEvent.create(draftTag, it.msg, emptyList(), signer) { draftEvent ->
|
||||
val newRelayList = getPrivateOutboxRelayList()?.relays()
|
||||
if (newRelayList != null) {
|
||||
Client.sendPrivately(draftEvent, newRelayList)
|
||||
} else {
|
||||
Client.send(draftEvent)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
sendDraftEvent(draftEvent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -2382,6 +2441,26 @@ class Account(
|
||||
}
|
||||
}
|
||||
|
||||
fun sendDraftEvent(draftEvent: DraftEvent) {
|
||||
val relayList =
|
||||
normalizedPrivateOutBoxRelaySet.value.map {
|
||||
RelaySetupInfoToConnect(
|
||||
it,
|
||||
shouldUseTorForClean(it),
|
||||
true,
|
||||
true,
|
||||
emptySet(),
|
||||
)
|
||||
}
|
||||
|
||||
if (relayList.isNotEmpty()) {
|
||||
Client.sendPrivately(draftEvent, relayList)
|
||||
} else {
|
||||
Client.send(draftEvent)
|
||||
}
|
||||
LocalCache.justConsume(draftEvent, null)
|
||||
}
|
||||
|
||||
fun broadcastPrivately(signedEvents: NIP17Factory.Result) {
|
||||
val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) }
|
||||
|
||||
@ -2415,7 +2494,16 @@ class Account(
|
||||
LocalCache
|
||||
.getAddressableNoteIfExists(ChatMessageRelayListEvent.createAddressTag(receiver))
|
||||
?.event as? ChatMessageRelayListEvent
|
||||
)?.relays()?.ifEmpty { null }
|
||||
)?.relays()?.ifEmpty { null }?.map {
|
||||
val normalizedUrl = RelayUrlFormatter.normalize(it)
|
||||
RelaySetupInfoToConnect(
|
||||
normalizedUrl,
|
||||
shouldUseTorForClean(normalizedUrl),
|
||||
false,
|
||||
true,
|
||||
feedTypes = setOf(FeedType.PRIVATE_DMS),
|
||||
)
|
||||
}
|
||||
|
||||
if (relayList != null) {
|
||||
Client.sendPrivately(signedEvent = wrap, relayList = relayList)
|
||||
@ -2868,7 +2956,22 @@ class Account(
|
||||
onReady: (event: NIP90ContentDiscoveryRequestEvent) -> Unit,
|
||||
) {
|
||||
NIP90ContentDiscoveryRequestEvent.create(dvmPublicKey, signer.pubKey, getReceivingRelays(), signer) {
|
||||
val relayList = (LocalCache.getAddressableNoteIfExists(AdvertisedRelayListEvent.createAddressTag(dvmPublicKey))?.event as? AdvertisedRelayListEvent)?.readRelays()
|
||||
val relayList =
|
||||
(
|
||||
LocalCache
|
||||
.getAddressableNoteIfExists(
|
||||
AdvertisedRelayListEvent.createAddressTag(dvmPublicKey),
|
||||
)?.event as? AdvertisedRelayListEvent
|
||||
)?.readRelays()?.ifEmpty { null }?.map {
|
||||
val normalizedUrl = RelayUrlFormatter.normalize(it)
|
||||
RelaySetupInfoToConnect(
|
||||
normalizedUrl,
|
||||
shouldUseTorForClean(normalizedUrl),
|
||||
true,
|
||||
true,
|
||||
setOf(FeedType.GLOBAL),
|
||||
)
|
||||
}
|
||||
|
||||
if (relayList != null) {
|
||||
Client.sendPrivately(it, relayList)
|
||||
@ -3267,6 +3370,98 @@ class Account(
|
||||
|
||||
fun markDonatedInThisVersion() = settings.markDonatedInThisVersion(BuildConfig.VERSION_NAME)
|
||||
|
||||
fun httpClientForCoil() = HttpClientManager.getHttpClient(shouldUseTorForImageDownload())
|
||||
|
||||
fun httpClientForRelay(dirtyUrl: String) = HttpClientManager.getHttpClient(shouldUseTorForDirty(dirtyUrl))
|
||||
|
||||
fun httpClientForPreviewUrl(url: String) = HttpClientManager.getHttpClient(shouldUseTorForPreviewUrl(url))
|
||||
|
||||
fun shouldUseTorForImageDownload() =
|
||||
when (settings.torSettings.torType.value) {
|
||||
TorType.OFF -> false
|
||||
TorType.INTERNAL -> settings.torSettings.imagesViaTor.value
|
||||
TorType.EXTERNAL -> settings.torSettings.imagesViaTor.value
|
||||
}
|
||||
|
||||
fun shouldUseTorForVideoDownload() =
|
||||
when (settings.torSettings.torType.value) {
|
||||
TorType.OFF -> false
|
||||
TorType.INTERNAL -> settings.torSettings.videosViaTor.value
|
||||
TorType.EXTERNAL -> settings.torSettings.videosViaTor.value
|
||||
}
|
||||
|
||||
fun shouldUseTorForVideoDownload(url: String) =
|
||||
when (settings.torSettings.torType.value) {
|
||||
TorType.OFF -> false
|
||||
TorType.INTERNAL -> !isLocalHost(url) && (isOnionUrl(url) || settings.torSettings.videosViaTor.value)
|
||||
TorType.EXTERNAL -> !isLocalHost(url) && (isOnionUrl(url) || settings.torSettings.videosViaTor.value)
|
||||
}
|
||||
|
||||
fun shouldUseTorForPreviewUrl(url: String) =
|
||||
when (settings.torSettings.torType.value) {
|
||||
TorType.OFF -> false
|
||||
TorType.INTERNAL -> !isLocalHost(url) && (isOnionUrl(url) || settings.torSettings.urlPreviewsViaTor.value)
|
||||
TorType.EXTERNAL -> !isLocalHost(url) && (isOnionUrl(url) || settings.torSettings.urlPreviewsViaTor.value)
|
||||
}
|
||||
|
||||
fun shouldUseTorForTrustedRelays() =
|
||||
when (settings.torSettings.torType.value) {
|
||||
TorType.OFF -> false
|
||||
TorType.INTERNAL -> settings.torSettings.trustedRelaysViaTor.value
|
||||
TorType.EXTERNAL -> settings.torSettings.trustedRelaysViaTor.value
|
||||
}
|
||||
|
||||
fun shouldUseTorForDirty(dirtyUrl: String) = shouldUseTorForClean(RelayUrlFormatter.normalize(dirtyUrl))
|
||||
|
||||
fun shouldUseTorForClean(normalizedUrl: String) =
|
||||
when (settings.torSettings.torType.value) {
|
||||
TorType.OFF -> false
|
||||
TorType.INTERNAL -> shouldUseTor(normalizedUrl)
|
||||
TorType.EXTERNAL -> shouldUseTor(normalizedUrl)
|
||||
}
|
||||
|
||||
private fun shouldUseTor(normalizedUrl: String): Boolean =
|
||||
if (isLocalHost(normalizedUrl)) {
|
||||
false
|
||||
} else if (isOnionUrl(normalizedUrl)) {
|
||||
settings.torSettings.onionRelaysViaTor.value
|
||||
} else if (isDMRelay(normalizedUrl)) {
|
||||
settings.torSettings.dmRelaysViaTor.value
|
||||
} else if (isTrustedRelay(normalizedUrl)) {
|
||||
settings.torSettings.trustedRelaysViaTor.value
|
||||
} else {
|
||||
settings.torSettings.newRelaysViaTor.value
|
||||
}
|
||||
|
||||
fun shouldUseTorForMoneyOperations(url: String) =
|
||||
when (settings.torSettings.torType.value) {
|
||||
TorType.OFF -> false
|
||||
TorType.INTERNAL -> !isLocalHost(url) && (isOnionUrl(url) || settings.torSettings.moneyOperationsViaTor.value)
|
||||
TorType.EXTERNAL -> !isLocalHost(url) && (isOnionUrl(url) || settings.torSettings.moneyOperationsViaTor.value)
|
||||
}
|
||||
|
||||
fun shouldUseTorForNIP05(url: String) =
|
||||
when (settings.torSettings.torType.value) {
|
||||
TorType.OFF -> false
|
||||
TorType.INTERNAL -> !isLocalHost(url) && (isOnionUrl(url) || settings.torSettings.nip05VerificationsViaTor.value)
|
||||
TorType.EXTERNAL -> !isLocalHost(url) && (isOnionUrl(url) || settings.torSettings.nip05VerificationsViaTor.value)
|
||||
}
|
||||
|
||||
fun shouldUseTorForNIP96(url: String) =
|
||||
when (settings.torSettings.torType.value) {
|
||||
TorType.OFF -> false
|
||||
TorType.INTERNAL -> !isLocalHost(url) && (isOnionUrl(url) || settings.torSettings.nip96UploadsViaTor.value)
|
||||
TorType.EXTERNAL -> !isLocalHost(url) && (isOnionUrl(url) || settings.torSettings.nip96UploadsViaTor.value)
|
||||
}
|
||||
|
||||
fun isLocalHost(url: String) = url.contains("//127.0.0.1") || url.contains("//localhost")
|
||||
|
||||
fun isOnionUrl(url: String) = url.contains(".onion")
|
||||
|
||||
fun isDMRelay(url: String) = url in normalizedDmRelaySet.value
|
||||
|
||||
fun isTrustedRelay(url: String): Boolean = connectToRelays.value.any { it.url == url } || url == settings.zapPaymentRequest?.relayUri
|
||||
|
||||
init {
|
||||
Log.d("AccountRegisterObservers", "Init")
|
||||
settings.backupContactList?.let {
|
||||
|
@ -24,9 +24,10 @@ import android.content.res.Resources
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.os.ConfigurationCompat
|
||||
import com.vitorpamplona.amethyst.service.Nip96MediaServers
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.ammolite.relays.Constants
|
||||
import com.vitorpamplona.ammolite.relays.RelaySetupInfo
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.HexKey
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
@ -50,7 +51,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.net.Proxy
|
||||
import java.util.Locale
|
||||
|
||||
val DefaultChannels =
|
||||
@ -139,8 +139,7 @@ class AccountSettings(
|
||||
var backupSearchRelayList: SearchRelayListEvent? = null,
|
||||
var backupMuteList: MuteListEvent? = null,
|
||||
var backupPrivateHomeRelayList: PrivateOutboxRelayListEvent? = null,
|
||||
var proxy: Proxy? = null,
|
||||
var proxyPort: Int = 9050,
|
||||
val torSettings: TorSettingsFlow = TorSettingsFlow(),
|
||||
val showSensitiveContent: MutableStateFlow<Boolean?> = MutableStateFlow(null),
|
||||
var warnAboutPostsWithReports: Boolean = true,
|
||||
var filterSpamFromStrangers: Boolean = true,
|
||||
@ -150,6 +149,10 @@ class AccountSettings(
|
||||
) {
|
||||
val saveable = MutableStateFlow(AccountSettingsUpdater(this))
|
||||
|
||||
class AccountSettingsUpdater(
|
||||
val accountSettings: AccountSettings,
|
||||
)
|
||||
|
||||
fun saveAccountSettings() {
|
||||
saveable.update { AccountSettingsUpdater(this) }
|
||||
}
|
||||
@ -244,21 +247,12 @@ class AccountSettings(
|
||||
// ---
|
||||
// proxy settings
|
||||
// ---
|
||||
|
||||
fun isProxyEnabled() = proxy != null
|
||||
|
||||
fun disableProxy() {
|
||||
if (isProxyEnabled()) {
|
||||
proxy = HttpClientManager.initProxy(false, "127.0.0.1", proxyPort)
|
||||
saveAccountSettings()
|
||||
}
|
||||
}
|
||||
|
||||
fun enableProxy(portNumber: Int) {
|
||||
if (proxyPort != portNumber || !isProxyEnabled()) {
|
||||
proxyPort = portNumber
|
||||
proxy = HttpClientManager.initProxy(true, "127.0.0.1", proxyPort)
|
||||
fun setTorSettings(newTorSettings: TorSettings): Boolean {
|
||||
if (torSettings.update(newTorSettings)) {
|
||||
saveAccountSettings()
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@ -521,7 +515,3 @@ class AccountSettings(
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
class AccountSettingsUpdater(
|
||||
val accountSettings: AccountSettings,
|
||||
)
|
||||
|
@ -22,9 +22,7 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.service.previews.BahaUrlPreview
|
||||
import com.vitorpamplona.amethyst.service.previews.IUrlPreviewCallback
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlInfoItem
|
||||
import com.vitorpamplona.amethyst.service.previews.UrlPreview
|
||||
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
|
||||
|
||||
@Stable
|
||||
@ -34,6 +32,7 @@ object UrlCachedPreviewer {
|
||||
|
||||
suspend fun previewInfo(
|
||||
url: String,
|
||||
forceProxy: Boolean,
|
||||
onReady: suspend (UrlPreviewState) -> Unit,
|
||||
) {
|
||||
cache[url]?.let {
|
||||
@ -41,39 +40,37 @@ object UrlCachedPreviewer {
|
||||
return
|
||||
}
|
||||
|
||||
BahaUrlPreview(
|
||||
UrlPreview().fetch(
|
||||
url,
|
||||
object : IUrlPreviewCallback {
|
||||
override suspend fun onComplete(urlInfo: UrlInfoItem) {
|
||||
cache[url]?.let {
|
||||
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
|
||||
onReady(it)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val state =
|
||||
if (urlInfo.fetchComplete() && urlInfo.url == url) {
|
||||
UrlPreviewState.Loaded(urlInfo)
|
||||
} else {
|
||||
UrlPreviewState.Empty
|
||||
}
|
||||
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
}
|
||||
|
||||
override suspend fun onFailed(throwable: Throwable) {
|
||||
cache[url]?.let {
|
||||
forceProxy,
|
||||
onComplete = { urlInfo ->
|
||||
cache[url]?.let {
|
||||
if (it is UrlPreviewState.Loaded || it is UrlPreviewState.Empty) {
|
||||
onReady(it)
|
||||
return
|
||||
return@fetch
|
||||
}
|
||||
}
|
||||
|
||||
val state =
|
||||
if (urlInfo.fetchComplete() && urlInfo.url == url) {
|
||||
UrlPreviewState.Loaded(urlInfo)
|
||||
} else {
|
||||
UrlPreviewState.Empty
|
||||
}
|
||||
|
||||
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
}
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
},
|
||||
).fetchUrlPreview()
|
||||
onFailed = { throwable ->
|
||||
cache[url]?.let {
|
||||
onReady(it)
|
||||
return@fetch
|
||||
}
|
||||
|
||||
val state = UrlPreviewState.Error(throwable.message ?: "Error Loading url preview")
|
||||
cache.put(url, state)
|
||||
onReady(state)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -218,6 +218,7 @@ class CashuProcessor {
|
||||
suspend fun melt(
|
||||
token: CashuToken,
|
||||
lud16: String,
|
||||
forceProxy: (url: String) -> Boolean,
|
||||
onSuccess: (String, String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
@ -231,10 +232,12 @@ class CashuProcessor {
|
||||
// Make invoice and leave room for fees
|
||||
milliSats = token.totalAmount * 1000,
|
||||
message = "Calculate Fees for Cashu",
|
||||
forceProxy = forceProxy,
|
||||
onSuccess = { baseInvoice ->
|
||||
feeCalculator(
|
||||
token.mint,
|
||||
baseInvoice,
|
||||
forceProxy = forceProxy,
|
||||
onSuccess = { fees ->
|
||||
LightningAddressResolver()
|
||||
.lnAddressInvoice(
|
||||
@ -242,8 +245,9 @@ class CashuProcessor {
|
||||
// Make invoice and leave room for fees
|
||||
milliSats = (token.totalAmount - fees) * 1000,
|
||||
message = "Redeem Cashu",
|
||||
forceProxy = forceProxy,
|
||||
onSuccess = { invoice ->
|
||||
meltInvoice(token, invoice, fees, onSuccess, onError, context)
|
||||
meltInvoice(token, invoice, fees, forceProxy, onSuccess, onError, context)
|
||||
},
|
||||
onProgress = {},
|
||||
onError = onError,
|
||||
@ -264,6 +268,7 @@ class CashuProcessor {
|
||||
fun feeCalculator(
|
||||
mintAddress: String,
|
||||
invoice: String,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onSuccess: (Int) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
@ -271,8 +276,8 @@ class CashuProcessor {
|
||||
checkNotInMainThread()
|
||||
|
||||
try {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val url = "$mintAddress/checkfees" // Melt cashu tokens at Mint
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
val factory = Event.mapper.nodeFactory
|
||||
|
||||
@ -329,13 +334,14 @@ class CashuProcessor {
|
||||
token: CashuToken,
|
||||
invoice: String,
|
||||
fees: Int,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onSuccess: (String, String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
try {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val url = token.mint + "/melt" // Melt cashu tokens at Mint
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
val factory = Event.mapper.nodeFactory
|
||||
|
||||
|
@ -48,11 +48,12 @@ class FileHeader(
|
||||
fileUrl: String,
|
||||
mimeType: String?,
|
||||
dimPrecomputed: String?,
|
||||
forceProxy: Boolean,
|
||||
onReady: (FileHeader) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
try {
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl)
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(fileUrl, forceProxy)
|
||||
|
||||
if (imageData != null) {
|
||||
prepare(imageData, mimeType, dimPrecomputed, onReady, onError)
|
||||
|
@ -44,6 +44,7 @@ class Nip05NostrAddressVerifier {
|
||||
|
||||
suspend fun fetchNip05Json(
|
||||
nip05: String,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onSuccess: suspend (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
@ -65,7 +66,7 @@ class Nip05NostrAddressVerifier {
|
||||
.build()
|
||||
// Fetchers MUST ignore any HTTP redirects given by the /.well-known/nostr.json endpoint.
|
||||
HttpClientManager
|
||||
.getHttpClient()
|
||||
.getHttpClient(forceProxy(url))
|
||||
.newBuilder()
|
||||
.followRedirects(false)
|
||||
.build()
|
||||
@ -90,6 +91,7 @@ class Nip05NostrAddressVerifier {
|
||||
|
||||
suspend fun verifyNip05(
|
||||
nip05: String,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onSuccess: suspend (String) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
@ -100,6 +102,7 @@ class Nip05NostrAddressVerifier {
|
||||
|
||||
fetchNip05Json(
|
||||
nip05,
|
||||
forceProxy,
|
||||
onSuccess = {
|
||||
checkNotInMainThread()
|
||||
|
||||
|
@ -60,6 +60,7 @@ object Nip11CachedRetriever {
|
||||
|
||||
suspend fun loadRelayInfo(
|
||||
dirtyUrl: String,
|
||||
forceProxy: Boolean,
|
||||
onInfo: (Nip11RelayInformation) -> Unit,
|
||||
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
|
||||
) {
|
||||
@ -74,23 +75,24 @@ object Nip11CachedRetriever {
|
||||
if (TimeUtils.now() - doc.time < TimeUtils.ONE_MINUTE) {
|
||||
// just wait.
|
||||
} else {
|
||||
retrieve(url, dirtyUrl, onInfo, onError)
|
||||
retrieve(url, dirtyUrl, forceProxy, onInfo, onError)
|
||||
}
|
||||
} else if (doc is RetrieveResultError) {
|
||||
if (TimeUtils.now() - doc.time < TimeUtils.ONE_HOUR) {
|
||||
onError(dirtyUrl, doc.error, null)
|
||||
} else {
|
||||
retrieve(url, dirtyUrl, onInfo, onError)
|
||||
retrieve(url, dirtyUrl, forceProxy, onInfo, onError)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
retrieve(url, dirtyUrl, onInfo, onError)
|
||||
retrieve(url, dirtyUrl, forceProxy, onInfo, onError)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun retrieve(
|
||||
url: String,
|
||||
dirtyUrl: String,
|
||||
forceProxy: Boolean,
|
||||
onInfo: (Nip11RelayInformation) -> Unit,
|
||||
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
|
||||
) {
|
||||
@ -98,6 +100,7 @@ object Nip11CachedRetriever {
|
||||
retriever.loadRelayInfo(
|
||||
url = url,
|
||||
dirtyUrl = dirtyUrl,
|
||||
forceProxy = forceProxy,
|
||||
onInfo = {
|
||||
checkNotInMainThread()
|
||||
relayInformationDocumentCache.put(url, RetrieveResultSuccess(it))
|
||||
@ -123,6 +126,7 @@ class Nip11Retriever {
|
||||
suspend fun loadRelayInfo(
|
||||
url: String,
|
||||
dirtyUrl: String,
|
||||
forceProxy: Boolean,
|
||||
onInfo: (Nip11RelayInformation) -> Unit,
|
||||
onError: (String, ErrorCode, String?) -> Unit,
|
||||
) {
|
||||
@ -136,7 +140,7 @@ class Nip11Retriever {
|
||||
.build()
|
||||
|
||||
HttpClientManager
|
||||
.getHttpClientForUrl(dirtyUrl)
|
||||
.getHttpClient(forceProxy)
|
||||
.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
|
@ -47,11 +47,14 @@ object Nip96MediaServers {
|
||||
|
||||
val cache: MutableMap<String, Nip96Retriever.ServerInfo> = mutableMapOf()
|
||||
|
||||
suspend fun load(url: String): Nip96Retriever.ServerInfo {
|
||||
suspend fun load(
|
||||
url: String,
|
||||
forceProxy: Boolean,
|
||||
): Nip96Retriever.ServerInfo {
|
||||
val cached = cache[url]
|
||||
if (cached != null) return cached
|
||||
|
||||
val fetched = Nip96Retriever().loadInfo(url)
|
||||
val fetched = Nip96Retriever().loadInfo(url, forceProxy)
|
||||
cache[url] = fetched
|
||||
return fetched
|
||||
}
|
||||
@ -100,7 +103,10 @@ class Nip96Retriever {
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun loadInfo(baseUrl: String): ServerInfo {
|
||||
suspend fun loadInfo(
|
||||
baseUrl: String,
|
||||
forceProxy: Boolean,
|
||||
): ServerInfo {
|
||||
checkNotInMainThread()
|
||||
|
||||
val request: Request =
|
||||
@ -110,7 +116,7 @@ class Nip96Retriever {
|
||||
.url(baseUrl.removeSuffix("/") + "/.well-known/nostr/nip96.json")
|
||||
.build()
|
||||
|
||||
HttpClientManager.getHttpClient().newCall(request).execute().use { response ->
|
||||
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use { response ->
|
||||
checkNotInMainThread()
|
||||
response.use {
|
||||
val body = it.body.string()
|
||||
|
@ -62,6 +62,7 @@ class Nip96Uploader(
|
||||
sensitiveContent: String?,
|
||||
server: Nip96MediaServers.ServerName,
|
||||
contentResolver: ContentResolver,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
context: Context,
|
||||
): PartialEvent {
|
||||
@ -69,6 +70,7 @@ class Nip96Uploader(
|
||||
Nip96Retriever()
|
||||
.loadInfo(
|
||||
server.baseUrl,
|
||||
forceProxy(server.baseUrl),
|
||||
)
|
||||
|
||||
return uploadImage(
|
||||
@ -79,6 +81,7 @@ class Nip96Uploader(
|
||||
sensitiveContent,
|
||||
serverInfo,
|
||||
contentResolver,
|
||||
forceProxy,
|
||||
onProgress,
|
||||
context,
|
||||
)
|
||||
@ -92,6 +95,7 @@ class Nip96Uploader(
|
||||
sensitiveContent: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
contentResolver: ContentResolver,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
context: Context,
|
||||
): PartialEvent {
|
||||
@ -118,6 +122,7 @@ class Nip96Uploader(
|
||||
alt,
|
||||
sensitiveContent,
|
||||
server,
|
||||
forceProxy,
|
||||
onProgress,
|
||||
context,
|
||||
)
|
||||
@ -130,6 +135,7 @@ class Nip96Uploader(
|
||||
alt: String?,
|
||||
sensitiveContent: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
context: Context,
|
||||
): PartialEvent {
|
||||
@ -139,7 +145,7 @@ class Nip96Uploader(
|
||||
val extension =
|
||||
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(server.apiUrl))
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
val requestBody: RequestBody =
|
||||
@ -182,7 +188,7 @@ class Nip96Uploader(
|
||||
val result = parseResults(str)
|
||||
|
||||
if (!result.processingUrl.isNullOrBlank()) {
|
||||
return waitProcessing(result, server, onProgress)
|
||||
return waitProcessing(result, server, forceProxy, onProgress)
|
||||
} else if (result.status == "success" && result.nip94Event != null) {
|
||||
return result.nip94Event
|
||||
} else {
|
||||
@ -222,12 +228,13 @@ class Nip96Uploader(
|
||||
hash: String,
|
||||
contentType: String?,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
forceProxy: (String) -> Boolean,
|
||||
context: Context,
|
||||
): Boolean {
|
||||
val extension =
|
||||
contentType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } ?: ""
|
||||
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(server.apiUrl))
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
|
||||
@ -261,9 +268,9 @@ class Nip96Uploader(
|
||||
private suspend fun waitProcessing(
|
||||
result: Nip96Result,
|
||||
server: Nip96Retriever.ServerInfo,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onProgress: (percentage: Float) -> Unit,
|
||||
): PartialEvent {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
var currentResult = result
|
||||
|
||||
while (!result.processingUrl.isNullOrBlank() && (currentResult.percentage ?: 100) < 100) {
|
||||
@ -276,6 +283,7 @@ class Nip96Uploader(
|
||||
.url(result.processingUrl)
|
||||
.build()
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(result.processingUrl))
|
||||
client.newCall(request).execute().use {
|
||||
if (it.isSuccessful) {
|
||||
it.body.use { currentResult = parseResults(it.string()) }
|
||||
|
@ -494,10 +494,7 @@ object NostrAccountDataSource : AmethystNostrDataSource("AccountData") {
|
||||
|
||||
if (this::account.isInitialized) {
|
||||
account.createAuthEvent(relay, challenge) {
|
||||
Client.send(
|
||||
it,
|
||||
relay.url,
|
||||
)
|
||||
Client.sendIfExists(it, relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -49,7 +49,10 @@ object OnlineChecker {
|
||||
return false
|
||||
}
|
||||
|
||||
fun isOnline(url: String?): Boolean {
|
||||
fun isOnline(
|
||||
url: String?,
|
||||
forceProxy: Boolean,
|
||||
): Boolean {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (url.isNullOrBlank()) return false
|
||||
@ -74,7 +77,7 @@ object OnlineChecker {
|
||||
|
||||
val client =
|
||||
HttpClientManager
|
||||
.getHttpClient()
|
||||
.getHttpClient(forceProxy)
|
||||
.newBuilder()
|
||||
.eventListener(EventListener.NONE)
|
||||
.protocols(listOf(Protocol.HTTP_1_1))
|
||||
@ -93,7 +96,7 @@ object OnlineChecker {
|
||||
.get()
|
||||
.build()
|
||||
|
||||
HttpClientManager.getHttpClient().newCall(request).execute().use {
|
||||
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
it.isSuccessful
|
||||
}
|
||||
|
@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.NostrUserProfileDataSource.user
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.collectSuccessfulSigningOperations
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@ -60,6 +61,7 @@ class ZapPaymentHandler(
|
||||
message: String,
|
||||
context: Context,
|
||||
showErrorIfNoLnAddress: Boolean,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
onPayViaIntent: (ImmutableList<Payable>) -> Unit,
|
||||
@ -116,6 +118,10 @@ class ZapPaymentHandler(
|
||||
|
||||
onProgress(0.02f)
|
||||
signAllZapRequests(note, pollOption, message, zapType, zapsToSend) { splitZapRequestPairs ->
|
||||
splitZapRequestPairs.forEach {
|
||||
println("AABBCC 1 ${it.key} ${it.value} $amountMilliSats")
|
||||
}
|
||||
|
||||
if (splitZapRequestPairs.isEmpty()) {
|
||||
onProgress(0.00f)
|
||||
return@signAllZapRequests
|
||||
@ -123,9 +129,13 @@ class ZapPaymentHandler(
|
||||
onProgress(0.05f)
|
||||
}
|
||||
|
||||
assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, showErrorIfNoLnAddress, onError, onProgress = {
|
||||
assembleAllInvoices(splitZapRequestPairs.toList(), amountMilliSats, message, showErrorIfNoLnAddress, forceProxy, onError, onProgress = {
|
||||
onProgress(it * 0.7f + 0.05f) // keeps within range.
|
||||
}, context) {
|
||||
splitZapRequestPairs.forEach {
|
||||
println("AABBCC 2 ${it.key} ${it.value} $amountMilliSats")
|
||||
}
|
||||
|
||||
if (it.isEmpty()) {
|
||||
onProgress(0.00f)
|
||||
return@assembleAllInvoices
|
||||
@ -169,7 +179,7 @@ class ZapPaymentHandler(
|
||||
}
|
||||
|
||||
class SignAllZapRequestsReturn(
|
||||
val zapRequestJson: String,
|
||||
val zapRequestJson: String?,
|
||||
val user: User? = null,
|
||||
)
|
||||
|
||||
@ -215,9 +225,8 @@ class ZapPaymentHandler(
|
||||
) + (authorRelayList ?: emptySet())
|
||||
|
||||
prepareZapRequestIfNeeded(note, pollOption, message, zapType, user, userRelayList) { zapRequestJson ->
|
||||
if (zapRequestJson != null) {
|
||||
onReady(SignAllZapRequestsReturn(zapRequestJson, user))
|
||||
}
|
||||
println("AABBCC middle ${user?.toBestDisplayName()} + $zapRequestJson")
|
||||
onReady(SignAllZapRequestsReturn(zapRequestJson, user))
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -230,6 +239,7 @@ class ZapPaymentHandler(
|
||||
totalAmountMilliSats: Long,
|
||||
message: String,
|
||||
showErrorIfNoLnAddress: Boolean,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
@ -247,6 +257,7 @@ class ZapPaymentHandler(
|
||||
zapValue = calculateZapValue(totalAmountMilliSats, splitZapRequestPair.first.weight, totalWeight),
|
||||
message = message,
|
||||
showErrorIfNoLnAddress = showErrorIfNoLnAddress,
|
||||
forceProxy = forceProxy,
|
||||
onError = onError,
|
||||
onProgressStep = { percentStepForThisPayment ->
|
||||
progressAllPayments += percentStepForThisPayment / invoices.size
|
||||
@ -273,15 +284,20 @@ class ZapPaymentHandler(
|
||||
collectSuccessfulSigningOperations<String, Boolean>(
|
||||
operationsInput = invoices,
|
||||
runRequestFor = { invoice: String, onReady ->
|
||||
println("AABBCC sending $invoice")
|
||||
|
||||
account.sendZapPaymentRequestFor(
|
||||
bolt11 = invoice,
|
||||
zappedNote = note,
|
||||
onSent = {
|
||||
println("AABBCC sent $invoice")
|
||||
progressAllPayments += 0.5f / invoices.size
|
||||
onProgress(progressAllPayments)
|
||||
onReady(true)
|
||||
},
|
||||
onResponse = { response ->
|
||||
println("AABBCC response $invoice")
|
||||
|
||||
if (response is PayInvoiceErrorResponse) {
|
||||
progressAllPayments += 0.5f / invoices.size
|
||||
onProgress(progressAllPayments)
|
||||
@ -312,10 +328,11 @@ class ZapPaymentHandler(
|
||||
|
||||
private fun assembleInvoice(
|
||||
splitSetup: ZapSplitSetup,
|
||||
nostrZapRequest: String,
|
||||
nostrZapRequest: String?,
|
||||
zapValue: Long,
|
||||
message: String,
|
||||
showErrorIfNoLnAddress: Boolean = true,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgressStep: (percent: Float) -> Unit,
|
||||
context: Context,
|
||||
@ -339,6 +356,7 @@ class ZapPaymentHandler(
|
||||
milliSats = zapValue,
|
||||
message = message,
|
||||
nostrRequest = nostrZapRequest,
|
||||
forceProxy = forceProxy,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
val step = it - progressThisPayment
|
||||
|
@ -36,8 +36,6 @@ import java.net.URLEncoder
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
class LightningAddressResolver {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
|
||||
fun assembleUrl(lnaddress: String): String? {
|
||||
val parts = lnaddress.split("@")
|
||||
|
||||
@ -54,6 +52,7 @@ class LightningAddressResolver {
|
||||
|
||||
private fun fetchLightningAddressJson(
|
||||
lnaddress: String,
|
||||
forceProxy: (url: String) -> Boolean,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
@ -74,6 +73,8 @@ class LightningAddressResolver {
|
||||
return
|
||||
}
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
try {
|
||||
val request: Request =
|
||||
Request
|
||||
@ -121,6 +122,7 @@ class LightningAddressResolver {
|
||||
milliSats: Long,
|
||||
message: String,
|
||||
nostrRequest: String? = null,
|
||||
forceProxy: (url: String) -> Boolean,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
context: Context,
|
||||
@ -137,6 +139,8 @@ class LightningAddressResolver {
|
||||
url += "&nostr=$encodedNostrRequest"
|
||||
}
|
||||
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
val request: Request =
|
||||
Request
|
||||
.Builder()
|
||||
@ -161,6 +165,7 @@ class LightningAddressResolver {
|
||||
milliSats: Long,
|
||||
message: String,
|
||||
nostrRequest: String? = null,
|
||||
forceProxy: (url: String) -> Boolean,
|
||||
onSuccess: (String) -> Unit,
|
||||
onError: (String, String) -> Unit,
|
||||
onProgress: (percent: Float) -> Unit,
|
||||
@ -170,6 +175,7 @@ class LightningAddressResolver {
|
||||
|
||||
fetchLightningAddressJson(
|
||||
lnaddress,
|
||||
forceProxy,
|
||||
onSuccess = { lnAddressJson ->
|
||||
onProgress(0.4f)
|
||||
|
||||
@ -210,6 +216,7 @@ class LightningAddressResolver {
|
||||
milliSats,
|
||||
message,
|
||||
if (allowsNostr) nostrRequest else null,
|
||||
forceProxy,
|
||||
onSuccess = {
|
||||
onProgress(0.6f)
|
||||
|
||||
|
@ -79,6 +79,7 @@ class RegisterAccounts(
|
||||
contentResolver = Amethyst.instance::contentResolverFn,
|
||||
)
|
||||
}
|
||||
|
||||
RelayAuthEvent.create(accountRelayPair.second, notificationToken, signer) { result ->
|
||||
continuation.resume(result)
|
||||
}
|
||||
@ -165,7 +166,8 @@ class RegisterAccounts(
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
// Always try via Tor for Amethyst.
|
||||
val client = HttpClientManager.getHttpClient(true)
|
||||
|
||||
val isSucess = client.newCall(request).execute().use { it.isSuccessful }
|
||||
Log.i(tag, "Server registration $isSucess")
|
||||
|
@ -29,7 +29,9 @@ import com.vitorpamplona.quartz.ots.BlockHeader
|
||||
import com.vitorpamplona.quartz.ots.exceptions.UrlException
|
||||
import okhttp3.Request
|
||||
|
||||
class OkHttpBlockstreamExplorer : BitcoinExplorer {
|
||||
class OkHttpBlockstreamExplorer(
|
||||
val forceProxy: (String) -> Boolean,
|
||||
) : BitcoinExplorer {
|
||||
/**
|
||||
* Retrieve the block information from the block hash.
|
||||
*
|
||||
@ -38,8 +40,8 @@ class OkHttpBlockstreamExplorer : BitcoinExplorer {
|
||||
* @throws Exception desc
|
||||
*/
|
||||
override fun block(hash: String): BlockHeader {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val url = "$BLOCKSTREAM_API_URL/block/$hash"
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
val request =
|
||||
Request
|
||||
@ -75,9 +77,8 @@ class OkHttpBlockstreamExplorer : BitcoinExplorer {
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
override fun blockHash(height: Int): String {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
|
||||
val url = "$BLOCKSTREAM_API_URL/block-height/$height"
|
||||
val client = HttpClientManager.getHttpClient(forceProxy(url))
|
||||
|
||||
val request =
|
||||
Request
|
||||
|
@ -38,6 +38,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
*/
|
||||
class OkHttpCalendar(
|
||||
val url: String,
|
||||
val forceProxy: Boolean,
|
||||
) : ICalendar {
|
||||
/**
|
||||
* Submitting a digest to remote calendar. Returns a com.eternitywall.ots.Timestamp committing to that digest.
|
||||
@ -51,7 +52,7 @@ class OkHttpCalendar(
|
||||
@Throws(ExceededSizeException::class, UrlException::class, DeserializationException::class)
|
||||
override fun submit(digest: ByteArray): Timestamp {
|
||||
try {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient(forceProxy)
|
||||
val url = "$url/digest"
|
||||
|
||||
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()
|
||||
@ -102,7 +103,7 @@ class OkHttpCalendar(
|
||||
)
|
||||
override fun getTimestamp(commitment: ByteArray): Timestamp {
|
||||
try {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient(forceProxy)
|
||||
val url = url + "/timestamp/" + Hex.encode(commitment)
|
||||
|
||||
val request =
|
||||
|
@ -36,6 +36,7 @@ import java.util.concurrent.BlockingQueue
|
||||
class OkHttpCalendarAsyncSubmit(
|
||||
private val url: String,
|
||||
private val digest: ByteArray,
|
||||
private val forceProxy: Boolean,
|
||||
) : ICalendarAsyncSubmit {
|
||||
private var queue: BlockingQueue<Optional<Timestamp>>? = null
|
||||
|
||||
@ -45,7 +46,7 @@ class OkHttpCalendarAsyncSubmit(
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun call(): Optional<Timestamp> {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient(forceProxy)
|
||||
val url = "$url/digest"
|
||||
|
||||
val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType()
|
||||
|
@ -24,11 +24,13 @@ import com.vitorpamplona.quartz.ots.CalendarBuilder
|
||||
import com.vitorpamplona.quartz.ots.ICalendar
|
||||
import com.vitorpamplona.quartz.ots.ICalendarAsyncSubmit
|
||||
|
||||
class OkHttpCalendarBuilder : CalendarBuilder {
|
||||
override fun newSyncCalendar(url: String): ICalendar = OkHttpCalendar(url)
|
||||
class OkHttpCalendarBuilder(
|
||||
val forceProxy: (String) -> Boolean,
|
||||
) : CalendarBuilder {
|
||||
override fun newSyncCalendar(url: String): ICalendar = OkHttpCalendar(url, forceProxy(url))
|
||||
|
||||
override fun newAsyncCalendar(
|
||||
url: String,
|
||||
digest: ByteArray,
|
||||
): ICalendarAsyncSubmit = OkHttpCalendarAsyncSubmit(url, digest)
|
||||
): ICalendarAsyncSubmit = OkHttpCalendarAsyncSubmit(url, digest, forceProxy(url))
|
||||
}
|
||||
|
@ -28,17 +28,17 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
* HLS LiveStreams cannot use cache.
|
||||
*/
|
||||
@UnstableApi
|
||||
class CustomMediaSourceFactory : MediaSource.Factory {
|
||||
private var cachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(HttpClientManager.getHttpClient()))
|
||||
private var nonCachingFactory: MediaSource.Factory =
|
||||
DefaultMediaSourceFactory(OkHttpDataSource.Factory(HttpClientManager.getHttpClient()))
|
||||
class CustomMediaSourceFactory(
|
||||
val okHttpClient: OkHttpClient,
|
||||
) : MediaSource.Factory {
|
||||
private var cachingFactory: MediaSource.Factory = DefaultMediaSourceFactory(Amethyst.instance.videoCache.get(okHttpClient))
|
||||
private var nonCachingFactory: MediaSource.Factory = DefaultMediaSourceFactory(OkHttpDataSource.Factory(okHttpClient))
|
||||
|
||||
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
|
||||
cachingFactory.setDrmSessionManagerProvider(drmSessionManagerProvider)
|
||||
|
@ -30,6 +30,7 @@ import androidx.media3.common.Player
|
||||
import androidx.media3.common.Player.PositionInfo
|
||||
import androidx.media3.common.Player.STATE_IDLE
|
||||
import androidx.media3.common.Player.STATE_READY
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
@ -40,7 +41,8 @@ import kotlinx.coroutines.launch
|
||||
import kotlin.math.abs
|
||||
|
||||
class MultiPlayerPlaybackManager(
|
||||
private val dataSourceFactory: androidx.media3.exoplayer.source.MediaSource.Factory? = null,
|
||||
@UnstableApi
|
||||
val dataSourceFactory: CustomMediaSourceFactory,
|
||||
private val cachedPositions: VideoViewedPositionCache,
|
||||
) {
|
||||
// protects from LruCache killing playing sessions
|
||||
|
@ -38,6 +38,7 @@ object PlaybackClientController {
|
||||
controllerID: String,
|
||||
videoUri: String,
|
||||
callbackUri: String?,
|
||||
proxyPort: Int? = 0,
|
||||
context: Context,
|
||||
onReady: (MediaController) -> Unit,
|
||||
) {
|
||||
@ -48,6 +49,9 @@ object PlaybackClientController {
|
||||
bundle.putString("id", controllerID)
|
||||
bundle.putString("uri", videoUri)
|
||||
bundle.putString("callbackUri", callbackUri)
|
||||
proxyPort?.let {
|
||||
bundle.putInt("proxyPort", it)
|
||||
}
|
||||
|
||||
var session = cache.get(context.hashCode())
|
||||
if (session == null) {
|
||||
|
@ -30,18 +30,47 @@ import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
|
||||
class PlaybackService : MediaSessionService() {
|
||||
private var videoViewedPositionCache = VideoViewedPositionCache()
|
||||
|
||||
private var managerAllInOne: MultiPlayerPlaybackManager? = null
|
||||
private var managerAllInOneNoProxy: MultiPlayerPlaybackManager? = null
|
||||
private var managerAllInOneProxy: MultiPlayerPlaybackManager? = null
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
fun lazyDS(): MultiPlayerPlaybackManager {
|
||||
managerAllInOne?.let {
|
||||
return it
|
||||
}
|
||||
fun lazyDS(proxyPort: Int): MultiPlayerPlaybackManager {
|
||||
if (proxyPort <= 0) {
|
||||
// no proxy
|
||||
managerAllInOneNoProxy?.let {
|
||||
return it
|
||||
}
|
||||
|
||||
val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(), videoViewedPositionCache)
|
||||
managerAllInOne = newInstance
|
||||
return newInstance
|
||||
// creates new
|
||||
val okHttp = HttpClientManager.getHttpClient(false)
|
||||
val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(okHttp), videoViewedPositionCache)
|
||||
managerAllInOneNoProxy = newInstance
|
||||
return newInstance
|
||||
} else {
|
||||
// with proxy, check if the port is the same.
|
||||
managerAllInOneProxy?.let {
|
||||
val okHttp = HttpClientManager.getHttpClient(true)
|
||||
if (okHttp == it.dataSourceFactory.okHttpClient.proxy) {
|
||||
return it
|
||||
}
|
||||
|
||||
val toDestroyAllInOne = managerAllInOneProxy
|
||||
|
||||
val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(okHttp), videoViewedPositionCache)
|
||||
|
||||
managerAllInOneProxy = newInstance
|
||||
|
||||
toDestroyAllInOne?.releaseAppPlayers()
|
||||
|
||||
return newInstance
|
||||
}
|
||||
|
||||
// creates new
|
||||
val okHttp = HttpClientManager.getHttpClient(true)
|
||||
val newInstance = MultiPlayerPlaybackManager(CustomMediaSourceFactory(okHttp), videoViewedPositionCache)
|
||||
managerAllInOneProxy = newInstance
|
||||
return newInstance
|
||||
}
|
||||
}
|
||||
|
||||
// Create your Player and MediaSession in the onCreate lifecycle event
|
||||
@ -50,18 +79,6 @@ class PlaybackService : MediaSessionService() {
|
||||
super.onCreate()
|
||||
|
||||
Log.d("Lifetime Event", "PlaybackService.onCreate")
|
||||
|
||||
// Stop all videos and recreates all managers when the proxy changes.
|
||||
HttpClientManager.proxyChangeListeners.add(this@PlaybackService::onProxyUpdated)
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private fun onProxyUpdated() {
|
||||
val toDestroyAllInOne = managerAllInOne
|
||||
|
||||
managerAllInOne = MultiPlayerPlaybackManager(CustomMediaSourceFactory(), videoViewedPositionCache)
|
||||
|
||||
toDestroyAllInOne?.releaseAppPlayers()
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
@ -73,9 +90,8 @@ class PlaybackService : MediaSessionService() {
|
||||
override fun onDestroy() {
|
||||
Log.d("Lifetime Event", "PlaybackService.onDestroy")
|
||||
|
||||
HttpClientManager.proxyChangeListeners.remove(this@PlaybackService::onProxyUpdated)
|
||||
|
||||
managerAllInOne?.releaseAppPlayers()
|
||||
managerAllInOneProxy?.releaseAppPlayers()
|
||||
managerAllInOneNoProxy?.releaseAppPlayers()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
@ -88,14 +104,28 @@ class PlaybackService : MediaSessionService() {
|
||||
super.onUpdateNotification(session, startInForegroundRequired)
|
||||
|
||||
// Overrides the notification with any player actually playing
|
||||
managerAllInOne?.playingContent()?.forEach {
|
||||
managerAllInOneProxy?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides again with playing with audio
|
||||
managerAllInOne?.playingContent()?.forEach {
|
||||
managerAllInOneProxy?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides the notification with any player actually playing
|
||||
managerAllInOneNoProxy?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
}
|
||||
|
||||
// Overrides again with playing with audio
|
||||
managerAllInOneNoProxy?.playingContent()?.forEach {
|
||||
if (it.player.isPlaying && it.player.volume > 0) {
|
||||
super.onUpdateNotification(it, startInForegroundRequired)
|
||||
}
|
||||
@ -108,8 +138,9 @@ class PlaybackService : MediaSessionService() {
|
||||
val id = controllerInfo.connectionHints.getString("id") ?: return null
|
||||
val uri = controllerInfo.connectionHints.getString("uri") ?: return null
|
||||
val callbackUri = controllerInfo.connectionHints.getString("callbackUri")
|
||||
val proxyPort = controllerInfo.connectionHints.getInt("proxyPort")
|
||||
|
||||
val manager = lazyDS()
|
||||
val manager = lazyDS(proxyPort)
|
||||
|
||||
return manager.getMediaSession(
|
||||
id,
|
||||
|
@ -65,9 +65,8 @@ class VideoCache {
|
||||
CacheDataSource
|
||||
.Factory()
|
||||
.setCache(simpleCache)
|
||||
.setUpstreamDataSourceFactory(
|
||||
OkHttpDataSource.Factory(client),
|
||||
).setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(client))
|
||||
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||
}
|
||||
|
||||
fun get(client: OkHttpClient): CacheDataSource.Factory {
|
||||
|
@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.previews
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.preview.MetaTag
|
||||
import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType
|
||||
import okio.BufferedSource
|
||||
import okio.ByteString.Companion.decodeHex
|
||||
import okio.Options
|
||||
import java.nio.charset.Charset
|
||||
|
||||
class HtmlParser {
|
||||
companion object {
|
||||
val ATTRIBUTE_VALUE_CHARSET = "charset"
|
||||
val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv"
|
||||
val CONTENT = "content"
|
||||
|
||||
// taken from okhttp
|
||||
private val UNICODE_BOMS =
|
||||
Options.of(
|
||||
// UTF-8
|
||||
"efbbbf".decodeHex(),
|
||||
// UTF-16BE
|
||||
"feff".decodeHex(),
|
||||
// UTF-16LE
|
||||
"fffe".decodeHex(),
|
||||
// UTF-32BE
|
||||
"0000ffff".decodeHex(),
|
||||
// UTF-32LE
|
||||
"ffff0000".decodeHex(),
|
||||
)
|
||||
|
||||
private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""")
|
||||
}
|
||||
|
||||
suspend fun parseHtml(
|
||||
source: BufferedSource,
|
||||
type: MediaType,
|
||||
): Sequence<MetaTag> =
|
||||
withContext(Dispatchers.IO) {
|
||||
// sniff charset from Content-Type header or BOM
|
||||
val sniffedCharset = type.charset() ?: source.readBomAsCharset()
|
||||
if (sniffedCharset != null) {
|
||||
val content = source.readByteArray().toString(sniffedCharset)
|
||||
return@withContext MetaTagsParser.parse(content)
|
||||
}
|
||||
|
||||
// if sniffing was failed, detect charset from content
|
||||
val bodyBytes = source.readByteArray()
|
||||
val charset = detectCharset(bodyBytes)
|
||||
val content = bodyBytes.toString(charset)
|
||||
return@withContext MetaTagsParser.parse(content)
|
||||
}
|
||||
|
||||
private fun BufferedSource.readBomAsCharset(): Charset? =
|
||||
when (select(UNICODE_BOMS)) {
|
||||
0 -> Charsets.UTF_8
|
||||
1 -> Charsets.UTF_16BE
|
||||
2 -> Charsets.UTF_16LE
|
||||
3 -> Charsets.UTF_32BE
|
||||
4 -> Charsets.UTF_32LE
|
||||
-1 -> null
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
private fun detectCharset(bodyBytes: ByteArray): Charset {
|
||||
// try to detect charset from meta tags parsed from first 1024 bytes of body
|
||||
val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8"))
|
||||
val metaTags = MetaTagsParser.parse(firstPart)
|
||||
metaTags.forEach { meta ->
|
||||
val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET)
|
||||
if (charsetAttr.isNotEmpty()) {
|
||||
runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") {
|
||||
RE_CONTENT_TYPE_CHARSET
|
||||
.find(meta.attr(CONTENT))
|
||||
?.let {
|
||||
runCatching { Charset.forName(it.groupValues[1]) }.getOrNull()
|
||||
}?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
}
|
||||
// defaults to UTF-8
|
||||
return Charset.forName("utf-8")
|
||||
}
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.previews
|
||||
|
||||
interface IUrlPreviewCallback {
|
||||
suspend fun onComplete(urlInfo: UrlInfoItem)
|
||||
|
||||
suspend fun onFailed(throwable: Throwable)
|
||||
}
|
@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.previews
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.preview.MetaTag
|
||||
|
||||
class OpenGraphParser {
|
||||
class Result(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val image: String,
|
||||
)
|
||||
|
||||
companion object {
|
||||
val ATTRIBUTE_VALUE_PROPERTY = "property"
|
||||
val ATTRIBUTE_VALUE_NAME = "name"
|
||||
val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
|
||||
|
||||
// for <meta itemprop=... to get title
|
||||
private val META_X_TITLE =
|
||||
arrayOf(
|
||||
"og:title",
|
||||
"twitter:title",
|
||||
"title",
|
||||
)
|
||||
|
||||
// for <meta itemprop=... to get description
|
||||
private val META_X_DESCRIPTION =
|
||||
arrayOf(
|
||||
"og:description",
|
||||
"twitter:description",
|
||||
"description",
|
||||
)
|
||||
|
||||
// for <meta itemprop=... to get image
|
||||
private val META_X_IMAGE =
|
||||
arrayOf(
|
||||
"og:image",
|
||||
"twitter:image",
|
||||
"image",
|
||||
)
|
||||
|
||||
private val CONTENT = "content"
|
||||
}
|
||||
|
||||
fun extractUrlInfo(metaTags: Sequence<MetaTag>): Result {
|
||||
var title: String = ""
|
||||
var description: String = ""
|
||||
var image: String = ""
|
||||
|
||||
metaTags.forEach {
|
||||
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
|
||||
return Result(title, description, image)
|
||||
}
|
||||
}
|
||||
return Result(title, description, image)
|
||||
}
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.previews
|
||||
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
|
||||
class UrlPreview {
|
||||
suspend fun fetch(
|
||||
url: String,
|
||||
forceProxy: Boolean,
|
||||
onComplete: suspend (urlInfo: UrlInfoItem) -> Unit,
|
||||
onFailed: suspend (t: Throwable) -> Unit,
|
||||
) = try {
|
||||
onComplete(getDocument(url, forceProxy))
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
onFailed(t)
|
||||
}
|
||||
|
||||
suspend fun getDocument(
|
||||
url: String,
|
||||
forceProxy: Boolean,
|
||||
): UrlInfoItem =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
HttpClientManager.getHttpClient(forceProxy).newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
if (it.isSuccessful) {
|
||||
val mimeType =
|
||||
it.headers["Content-Type"]?.toMediaType()
|
||||
?: throw IllegalArgumentException("Website returned unknown mimetype: ${it.headers["Content-Type"]}")
|
||||
if (mimeType.type == "text" && mimeType.subtype == "html") {
|
||||
val data = OpenGraphParser().extractUrlInfo(HtmlParser().parseHtml(it.body.source(), mimeType))
|
||||
UrlInfoItem(url, data.title, data.description, data.image, mimeType)
|
||||
} else if (mimeType.type == "image") {
|
||||
UrlInfoItem(url, image = url, mimeType = mimeType)
|
||||
} else if (mimeType.type == "video") {
|
||||
UrlInfoItem(url, image = url, mimeType = mimeType)
|
||||
} else {
|
||||
throw IllegalArgumentException("Website returned unknown encoding for previews: $mimeType")
|
||||
}
|
||||
} else {
|
||||
throw IllegalArgumentException("Website returned: " + it.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,246 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.previews
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.preview.MetaTag
|
||||
import com.vitorpamplona.amethyst.commons.preview.MetaTagsParser
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okio.BufferedSource
|
||||
import okio.ByteString.Companion.decodeHex
|
||||
import okio.Options
|
||||
import java.nio.charset.Charset
|
||||
|
||||
private const val ATTRIBUTE_VALUE_PROPERTY = "property"
|
||||
private const val ATTRIBUTE_VALUE_NAME = "name"
|
||||
private const val ATTRIBUTE_VALUE_ITEMPROP = "itemprop"
|
||||
private const val ATTRIBUTE_VALUE_CHARSET = "charset"
|
||||
private const val ATTRIBUTE_VALUE_HTTP_EQUIV = "http-equiv"
|
||||
|
||||
// for <meta itemprop=... to get title
|
||||
private val META_X_TITLE =
|
||||
arrayOf(
|
||||
"og:title",
|
||||
"twitter:title",
|
||||
"title",
|
||||
)
|
||||
|
||||
// for <meta itemprop=... to get description
|
||||
private val META_X_DESCRIPTION =
|
||||
arrayOf(
|
||||
"og:description",
|
||||
"twitter:description",
|
||||
"description",
|
||||
)
|
||||
|
||||
// for <meta itemprop=... to get image
|
||||
private val META_X_IMAGE =
|
||||
arrayOf(
|
||||
"og:image",
|
||||
"twitter:image",
|
||||
"image",
|
||||
)
|
||||
|
||||
private const val CONTENT = "content"
|
||||
|
||||
suspend fun getDocument(
|
||||
url: String,
|
||||
timeOut: Int = 30000,
|
||||
): UrlInfoItem =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request: Request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build()
|
||||
HttpClientManager.getHttpClient().newCall(request).execute().use {
|
||||
checkNotInMainThread()
|
||||
if (it.isSuccessful) {
|
||||
val mimeType =
|
||||
it.headers["Content-Type"]?.toMediaType()
|
||||
?: throw IllegalArgumentException(
|
||||
"Website returned unknown mimetype: ${it.headers["Content-Type"]}",
|
||||
)
|
||||
if (mimeType.type == "text" && mimeType.subtype == "html") {
|
||||
parseHtml(url, it.body.source(), mimeType)
|
||||
} else if (mimeType.type == "image") {
|
||||
UrlInfoItem(url, image = url, mimeType = mimeType)
|
||||
} else if (mimeType.type == "video") {
|
||||
UrlInfoItem(url, image = url, mimeType = mimeType)
|
||||
} else {
|
||||
throw IllegalArgumentException(
|
||||
"Website returned unknown encoding for previews: $mimeType",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
throw IllegalArgumentException("Website returned: " + it.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun parseHtml(
|
||||
url: String,
|
||||
source: BufferedSource,
|
||||
type: MediaType,
|
||||
): UrlInfoItem =
|
||||
withContext(Dispatchers.IO) {
|
||||
// sniff charset from Content-Type header or BOM
|
||||
val sniffedCharset = type.charset() ?: source.readBomAsCharset()
|
||||
if (sniffedCharset != null) {
|
||||
val content = source.readByteArray().toString(sniffedCharset)
|
||||
val metaTags = MetaTagsParser.parse(content)
|
||||
return@withContext extractUrlInfo(url, metaTags, type)
|
||||
}
|
||||
|
||||
// if sniffing was failed, detect charset from content
|
||||
val bodyBytes = source.readByteArray()
|
||||
val charset = detectCharset(bodyBytes)
|
||||
val content = bodyBytes.toString(charset)
|
||||
val metaTags = MetaTagsParser.parse(content)
|
||||
return@withContext extractUrlInfo(url, metaTags, type)
|
||||
}
|
||||
|
||||
// taken from okhttp
|
||||
private val UNICODE_BOMS =
|
||||
Options.of(
|
||||
// UTF-8
|
||||
"efbbbf".decodeHex(),
|
||||
// UTF-16BE
|
||||
"feff".decodeHex(),
|
||||
// UTF-16LE
|
||||
"fffe".decodeHex(),
|
||||
// UTF-32BE
|
||||
"0000ffff".decodeHex(),
|
||||
// UTF-32LE
|
||||
"ffff0000".decodeHex(),
|
||||
)
|
||||
|
||||
private fun BufferedSource.readBomAsCharset(): Charset? =
|
||||
when (select(UNICODE_BOMS)) {
|
||||
0 -> Charsets.UTF_8
|
||||
1 -> Charsets.UTF_16BE
|
||||
2 -> Charsets.UTF_16LE
|
||||
3 -> Charsets.UTF_32BE
|
||||
4 -> Charsets.UTF_32LE
|
||||
-1 -> null
|
||||
else -> throw AssertionError()
|
||||
}
|
||||
|
||||
private val RE_CONTENT_TYPE_CHARSET = Regex("""charset=([^;]+)""")
|
||||
|
||||
private fun detectCharset(bodyBytes: ByteArray): Charset {
|
||||
// try to detect charset from meta tags parsed from first 1024 bytes of body
|
||||
val firstPart = String(bodyBytes, 0, 1024, Charset.forName("utf-8"))
|
||||
val metaTags = MetaTagsParser.parse(firstPart)
|
||||
metaTags.forEach { meta ->
|
||||
val charsetAttr = meta.attr(ATTRIBUTE_VALUE_CHARSET)
|
||||
if (charsetAttr.isNotEmpty()) {
|
||||
runCatching { Charset.forName(charsetAttr) }.getOrNull()?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
if (meta.attr(ATTRIBUTE_VALUE_HTTP_EQUIV).lowercase() == "content-type") {
|
||||
RE_CONTENT_TYPE_CHARSET
|
||||
.find(meta.attr(CONTENT))
|
||||
?.let {
|
||||
runCatching { Charset.forName(it.groupValues[1]) }.getOrNull()
|
||||
}?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
}
|
||||
// defaults to UTF-8
|
||||
return Charset.forName("utf-8")
|
||||
}
|
||||
|
||||
private fun extractUrlInfo(
|
||||
url: String,
|
||||
metaTags: Sequence<MetaTag>,
|
||||
type: MediaType,
|
||||
): UrlInfoItem {
|
||||
var title: String = ""
|
||||
var description: String = ""
|
||||
var image: String = ""
|
||||
|
||||
metaTags.forEach {
|
||||
when (it.attr(ATTRIBUTE_VALUE_PROPERTY)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_NAME)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
when (it.attr(ATTRIBUTE_VALUE_ITEMPROP)) {
|
||||
in META_X_TITLE ->
|
||||
if (title.isEmpty()) {
|
||||
title = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_DESCRIPTION ->
|
||||
if (description.isEmpty()) {
|
||||
description = it.attr(CONTENT)
|
||||
}
|
||||
|
||||
in META_X_IMAGE ->
|
||||
if (image.isEmpty()) {
|
||||
image = it.attr(CONTENT)
|
||||
}
|
||||
}
|
||||
|
||||
if (title.isNotEmpty() && description.isNotEmpty() && image.isNotEmpty()) {
|
||||
return UrlInfoItem(url, title, description, image, type)
|
||||
}
|
||||
}
|
||||
return UrlInfoItem(url, title, description, image, type)
|
||||
}
|
@ -21,18 +21,14 @@
|
||||
package com.vitorpamplona.amethyst.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@ -53,6 +49,7 @@ import com.vitorpamplona.amethyst.ui.navigation.Route
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorManager
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.encoders.Nip47WalletConnect
|
||||
@ -67,8 +64,6 @@ import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.torproject.jni.TorService
|
||||
import org.torproject.jni.TorService.LocalBinder
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.Timer
|
||||
@ -115,6 +110,8 @@ class MainActivity : AppCompatActivity() {
|
||||
checkLanguage(locales.get(0).language)
|
||||
}
|
||||
|
||||
TorManager.startTor(this)
|
||||
|
||||
Log.d("Lifetime Event", "MainActivity.onResume")
|
||||
|
||||
// starts muted every time
|
||||
@ -139,34 +136,6 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
// resets state until next External Signer Call
|
||||
Timer().schedule(350) { shouldPauseService = true }
|
||||
|
||||
bindService(
|
||||
Intent(this, TorService::class.java),
|
||||
object : ServiceConnection {
|
||||
override fun onServiceConnected(
|
||||
name: ComponentName,
|
||||
service: IBinder,
|
||||
) {
|
||||
// moved torService to a local variable, since we only need it once
|
||||
|
||||
val torService = (service as LocalBinder).service
|
||||
|
||||
while (torService.torControlConnection == null) {
|
||||
try {
|
||||
Thread.sleep(500)
|
||||
} catch (e: InterruptedException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
Toast.makeText(this@MainActivity, "Got Tor control connection", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
}
|
||||
},
|
||||
BIND_AUTO_CREATE,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@ -189,7 +158,7 @@ class MainActivity : AppCompatActivity() {
|
||||
(getSystemService(ConnectivityManager::class.java) as ConnectivityManager)
|
||||
.unregisterNetworkCallback(networkCallback)
|
||||
|
||||
stopService(Intent(baseContext, TorService::class.java))
|
||||
TorManager.stopTor(this)
|
||||
|
||||
super.onPause()
|
||||
}
|
||||
|
@ -107,11 +107,11 @@ open class EditPostViewModel : ViewModel() {
|
||||
editedFromNote = edit
|
||||
}
|
||||
|
||||
fun sendPost(relayList: List<RelaySetupInfo>? = null) {
|
||||
fun sendPost(relayList: List<RelaySetupInfo>) {
|
||||
viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) }
|
||||
}
|
||||
|
||||
suspend fun innerSendPost(relayList: List<RelaySetupInfo>? = null) {
|
||||
suspend fun innerSendPost(relayList: List<RelaySetupInfo>) {
|
||||
if (accountViewModel == null) {
|
||||
cancel()
|
||||
return
|
||||
@ -193,6 +193,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server.server,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
@ -202,6 +203,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
@ -318,6 +320,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
@ -356,6 +359,7 @@ open class EditPostViewModel : ViewModel() {
|
||||
fileUrl = imageUrl,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
forceProxy = forceProxy(imageUrl),
|
||||
onReady = { header: FileHeader ->
|
||||
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
|
||||
isUploadingImage = false
|
||||
|
@ -27,7 +27,10 @@ import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class ImageDownloader {
|
||||
suspend fun waitAndGetImage(imageUrl: String): ByteArray? {
|
||||
suspend fun waitAndGetImage(
|
||||
imageUrl: String,
|
||||
forceProxy: Boolean,
|
||||
): ByteArray? {
|
||||
var imageData: ByteArray? = null
|
||||
var tentatives = 0
|
||||
|
||||
@ -35,11 +38,12 @@ class ImageDownloader {
|
||||
while (imageData == null && tentatives < 15) {
|
||||
imageData =
|
||||
try {
|
||||
// TODO: Migrate to OkHttp
|
||||
HttpURLConnection.setFollowRedirects(true)
|
||||
var url = URL(imageUrl)
|
||||
var huc =
|
||||
if (HttpClientManager.getDefaultProxy() != null) {
|
||||
url.openConnection(HttpClientManager.getDefaultProxy()) as HttpURLConnection
|
||||
if (forceProxy) {
|
||||
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
|
||||
} else {
|
||||
url.openConnection() as HttpURLConnection
|
||||
}
|
||||
@ -52,8 +56,8 @@ class ImageDownloader {
|
||||
// open the new connnection again
|
||||
url = URL(newUrl)
|
||||
huc =
|
||||
if (HttpClientManager.getDefaultProxy() != null) {
|
||||
url.openConnection(HttpClientManager.getDefaultProxy()) as HttpURLConnection
|
||||
if (forceProxy) {
|
||||
url.openConnection(HttpClientManager.getCurrentProxy()) as HttpURLConnection
|
||||
} else {
|
||||
url.openConnection() as HttpURLConnection
|
||||
}
|
||||
|
@ -46,9 +46,10 @@ import okio.source
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
object ImageSaver {
|
||||
fun saveImage(
|
||||
object MediaSaverToDisk {
|
||||
fun saveDownloadingIfNeeded(
|
||||
videoUri: String?,
|
||||
forceProxy: Boolean,
|
||||
mimeType: String?,
|
||||
localContext: Context,
|
||||
onSuccess: () -> Any?,
|
||||
@ -56,14 +57,15 @@ object ImageSaver {
|
||||
) {
|
||||
if (videoUri != null) {
|
||||
if (!videoUri.startsWith("file")) {
|
||||
saveImage(
|
||||
downloadAndSave(
|
||||
context = localContext,
|
||||
forceProxy = forceProxy,
|
||||
url = videoUri,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
)
|
||||
} else {
|
||||
saveImage(
|
||||
save(
|
||||
context = localContext,
|
||||
localFile = videoUri.toUri().toFile(),
|
||||
mimeType = mimeType,
|
||||
@ -79,13 +81,14 @@ object ImageSaver {
|
||||
*
|
||||
* @see PICTURES_SUBDIRECTORY
|
||||
*/
|
||||
fun saveImage(
|
||||
fun downloadAndSave(
|
||||
url: String,
|
||||
forceProxy: Boolean,
|
||||
context: Context,
|
||||
onSuccess: () -> Any?,
|
||||
onError: (Throwable) -> Any?,
|
||||
) {
|
||||
val client = HttpClientManager.getHttpClient()
|
||||
val client = HttpClientManager.getHttpClient(forceProxy)
|
||||
|
||||
val request =
|
||||
Request
|
||||
@ -142,7 +145,7 @@ object ImageSaver {
|
||||
)
|
||||
}
|
||||
|
||||
fun saveImage(
|
||||
fun save(
|
||||
localFile: File,
|
||||
mimeType: String?,
|
||||
context: Context,
|
@ -78,7 +78,7 @@ open class NewMediaModel : ViewModel() {
|
||||
|
||||
fun upload(
|
||||
context: Context,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
mediaQuality: Int,
|
||||
onError: (String) -> Unit = {},
|
||||
) {
|
||||
@ -136,6 +136,7 @@ open class NewMediaModel : ViewModel() {
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = serverToUse.server,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onProgress = { percent: Float ->
|
||||
uploadingPercentage.value = 0.2f + (0.2f * percent)
|
||||
},
|
||||
@ -148,6 +149,7 @@ open class NewMediaModel : ViewModel() {
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
relayList = relayList,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onError = onError,
|
||||
context,
|
||||
)
|
||||
@ -190,7 +192,8 @@ open class NewMediaModel : ViewModel() {
|
||||
localContentType: String?,
|
||||
alt: String,
|
||||
sensitiveContent: Boolean,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
@ -233,7 +236,7 @@ open class NewMediaModel : ViewModel() {
|
||||
uploadingDescription.value = "Downloading"
|
||||
uploadingPercentage.value = 0.60f
|
||||
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl)
|
||||
val imageData: ByteArray? = ImageDownloader().waitAndGetImage(imageUrl, forceProxy(imageUrl))
|
||||
|
||||
if (imageData != null) {
|
||||
uploadingPercentage.value = 0.80f
|
||||
@ -284,7 +287,7 @@ open class NewMediaModel : ViewModel() {
|
||||
mimeType: String?,
|
||||
alt: String,
|
||||
sensitiveContent: Boolean,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
onError: (String) -> Unit = {},
|
||||
context: Context,
|
||||
) {
|
||||
|
@ -473,7 +473,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
urlPreview = findUrlInMessage()
|
||||
}
|
||||
|
||||
fun sendPost(relayList: List<RelaySetupInfo>? = null) {
|
||||
fun sendPost(relayList: List<RelaySetupInfo>) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
innerSendPost(relayList, null)
|
||||
accountViewModel?.deleteDraft(draftTag)
|
||||
@ -481,18 +481,18 @@ open class NewPostViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun sendDraft(relayList: List<RelaySetupInfo>? = null) {
|
||||
fun sendDraft(relayList: List<RelaySetupInfo>) {
|
||||
viewModelScope.launch {
|
||||
sendDraftSync(relayList)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendDraftSync(relayList: List<RelaySetupInfo>? = null) {
|
||||
suspend fun sendDraftSync(relayList: List<RelaySetupInfo>) {
|
||||
innerSendPost(relayList, draftTag)
|
||||
}
|
||||
|
||||
private suspend fun innerSendPost(
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
localDraft: String?,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (accountViewModel == null) {
|
||||
@ -879,6 +879,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
sensitiveContent = if (sensitiveContent) "" else null,
|
||||
server = server.server,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
@ -888,6 +889,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
localContentType = contentType,
|
||||
alt = alt,
|
||||
sensitiveContent = sensitiveContent,
|
||||
forceProxy = account?.let { it::shouldUseTorForNIP96 } ?: { false },
|
||||
onError = {
|
||||
onError(stringRes(context, R.string.failed_to_upload_media_no_details), it)
|
||||
},
|
||||
@ -1146,6 +1148,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
localContentType: String?,
|
||||
alt: String?,
|
||||
sensitiveContent: Boolean,
|
||||
forceProxy: (String) -> Boolean,
|
||||
onError: (message: String) -> Unit,
|
||||
context: Context,
|
||||
) {
|
||||
@ -1184,6 +1187,7 @@ open class NewPostViewModel : ViewModel() {
|
||||
fileUrl = imageUrl,
|
||||
mimeType = remoteMimeType ?: localContentType,
|
||||
dimPrecomputed = dim,
|
||||
forceProxy = forceProxy(imageUrl),
|
||||
onReady = { header: FileHeader ->
|
||||
account?.createHeader(imageUrl, magnet, header, alt, sensitiveContent, originalHash) { event ->
|
||||
isUploadingImage = false
|
||||
|
@ -187,6 +187,7 @@ class NewUserMetadataViewModel : ViewModel() {
|
||||
sensitiveContent = null,
|
||||
server = account.settings.defaultFileServer,
|
||||
contentResolver = contentResolver,
|
||||
forceProxy = account::shouldUseTorForNIP96,
|
||||
onProgress = {},
|
||||
context = context,
|
||||
)
|
||||
|
@ -1,164 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.ui.actions
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A button to save the remote image to the gallery. May require a storage permission.
|
||||
*
|
||||
* @param url URL of the image
|
||||
*/
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun SaveToGallery(url: String) {
|
||||
val localContext = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun saveImage() {
|
||||
ImageSaver.saveImage(
|
||||
context = localContext,
|
||||
url = url,
|
||||
onSuccess = {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.image_saved_to_the_gallery),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.failed_to_save_the_image),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val writeStoragePermissionState =
|
||||
rememberPermissionState(
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
saveImage()
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
writeStoragePermissionState.status.isGranted
|
||||
) {
|
||||
saveImage()
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text(text = stringRes(id = R.string.save))
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
fun SaveToGallery(
|
||||
localFile: File,
|
||||
mimeType: String?,
|
||||
) {
|
||||
val localContext = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun saveImage() {
|
||||
ImageSaver.saveImage(
|
||||
context = localContext,
|
||||
localFile = localFile,
|
||||
mimeType = mimeType,
|
||||
onSuccess = {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.image_saved_to_the_gallery),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
Toast
|
||||
.makeText(
|
||||
localContext,
|
||||
stringRes(localContext, R.string.failed_to_save_the_image),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val writeStoragePermissionState =
|
||||
rememberPermissionState(
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE,
|
||||
) { isGranted ->
|
||||
if (isGranted) {
|
||||
saveImage()
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
writeStoragePermissionState.status.isGranted
|
||||
) {
|
||||
saveImage()
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
}
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
) {
|
||||
Text(text = stringRes(id = R.string.save))
|
||||
}
|
||||
}
|
@ -64,6 +64,7 @@ abstract class BasicRelaySetupInfoModel : ViewModel() {
|
||||
_relays.value.forEach { item ->
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl = item.url,
|
||||
forceProxy = account.shouldUseTorForDirty(item.url),
|
||||
onInfo = {
|
||||
togglePaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
},
|
||||
|
@ -80,6 +80,7 @@ class Kind3RelayListViewModel : ViewModel() {
|
||||
_relays.value.forEach { item ->
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl = item.url,
|
||||
forceProxy = account.shouldUseTorForDirty(item.url),
|
||||
onInfo = {
|
||||
togglePaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
},
|
||||
|
@ -83,6 +83,7 @@ class Nip65RelayListViewModel : ViewModel() {
|
||||
_homeRelays.value.forEach { item ->
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl = item.url,
|
||||
forceProxy = account.shouldUseTorForDirty(item.url),
|
||||
onInfo = {
|
||||
toggleHomePaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
},
|
||||
@ -93,6 +94,7 @@ class Nip65RelayListViewModel : ViewModel() {
|
||||
_notificationRelays.value.forEach { item ->
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl = item.url,
|
||||
forceProxy = account.shouldUseTorForDirty(item.url),
|
||||
onInfo = {
|
||||
toggleNotifPaidRelay(item, it.limitation?.payment_required ?: false)
|
||||
},
|
||||
|
@ -116,7 +116,11 @@ fun CashuPreview(
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
tokens.forEach {
|
||||
CashuPreviewNew(it, accountViewModel::meltCashu, accountViewModel::toast)
|
||||
CashuPreviewNew(
|
||||
it,
|
||||
accountViewModel::meltCashu,
|
||||
accountViewModel::toast,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -30,7 +30,6 @@ import android.util.Log
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.VideoView
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
@ -101,7 +100,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.compose.GenericBaseCache
|
||||
import com.vitorpamplona.amethyst.commons.compose.produceCachedState
|
||||
import com.vitorpamplona.amethyst.service.playback.PlaybackClientController
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageSaver
|
||||
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
|
||||
import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.LyricsOffIcon
|
||||
@ -120,6 +119,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize
|
||||
import com.vitorpamplona.amethyst.ui.theme.imageModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@ -369,6 +369,7 @@ fun VideoViewInner(
|
||||
videoUri = videoUri,
|
||||
defaultToStart = defaultToStart,
|
||||
nostrUriCallback = nostrUriCallback,
|
||||
proxyPort = HttpClientManager.getCurrentProxyPort(accountViewModel.account.shouldUseTorForVideoDownload(videoUri)),
|
||||
) { controller, keepPlaying ->
|
||||
RenderVideoPlayer(
|
||||
videoUri = videoUri,
|
||||
@ -459,6 +460,7 @@ fun GetMediaItem(
|
||||
fun GetVideoController(
|
||||
mediaItem: State<MediaItem>,
|
||||
videoUri: String,
|
||||
proxyPort: Int?,
|
||||
defaultToStart: Boolean = false,
|
||||
nostrUriCallback: String? = null,
|
||||
inner: @Composable (controller: MediaController, keepPlaying: MutableState<Boolean>) -> Unit,
|
||||
@ -502,6 +504,7 @@ fun GetVideoController(
|
||||
uid,
|
||||
videoUri,
|
||||
nostrUriCallback,
|
||||
proxyPort,
|
||||
context,
|
||||
) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
@ -590,6 +593,7 @@ fun GetVideoController(
|
||||
uid,
|
||||
videoUri,
|
||||
nostrUriCallback,
|
||||
proxyPort,
|
||||
context,
|
||||
) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
@ -828,7 +832,7 @@ private fun RenderVideoPlayer(
|
||||
|
||||
if (!videoUri.endsWith(".m3u8")) {
|
||||
AnimatedSaveButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size110dp)) { context ->
|
||||
saveImage(videoUri, mimeType, context, accountViewModel)
|
||||
saveMediaToGallery(videoUri, mimeType, context, accountViewModel)
|
||||
}
|
||||
|
||||
AnimatedShareButton(controllerVisible, Modifier.align(Alignment.TopEnd).padding(end = Size165dp)) { popupExpanded, toggle ->
|
||||
@ -1205,21 +1209,22 @@ fun SaveButton(onSaveClick: (localContext: Context) -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveImage(
|
||||
private fun saveMediaToGallery(
|
||||
videoUri: String?,
|
||||
mimeType: String?,
|
||||
localContext: Context,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
ImageSaver.saveImage(
|
||||
MediaSaverToDisk.saveDownloadingIfNeeded(
|
||||
videoUri = videoUri,
|
||||
forceProxy = accountViewModel.account.shouldUseTorForVideoDownload(),
|
||||
mimeType = mimeType,
|
||||
localContext = localContext,
|
||||
onSuccess = {
|
||||
accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
|
||||
accountViewModel.toast(R.string.video_saved_to_the_gallery, R.string.video_saved_to_the_gallery)
|
||||
},
|
||||
onError = {
|
||||
accountViewModel.toast(R.string.failed_to_save_the_image, null, it)
|
||||
accountViewModel.toast(R.string.failed_to_save_the_video, null, it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@ -80,7 +80,7 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaPreloadedContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.ui.actions.ImageSaver
|
||||
import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
@ -261,7 +261,7 @@ private fun DialogContent(
|
||||
val writeStoragePermissionState =
|
||||
rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted ->
|
||||
if (isGranted) {
|
||||
saveImage(myContent, localContext, accountViewModel)
|
||||
saveMediaToGallery(myContent, localContext, accountViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@ -271,7 +271,7 @@ private fun DialogContent(
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ||
|
||||
writeStoragePermissionState.status.isGranted
|
||||
) {
|
||||
saveImage(myContent, localContext, accountViewModel)
|
||||
saveMediaToGallery(myContent, localContext, accountViewModel)
|
||||
} else {
|
||||
writeStoragePermissionState.launchPermissionRequest()
|
||||
}
|
||||
@ -291,33 +291,46 @@ private fun DialogContent(
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveImage(
|
||||
private fun saveMediaToGallery(
|
||||
content: BaseMediaContent,
|
||||
localContext: Context,
|
||||
accountViewModel: AccountViewModel,
|
||||
) {
|
||||
val isImage = content is MediaUrlImage && content is MediaLocalImage
|
||||
|
||||
val success = if (isImage) R.string.image_saved_to_the_gallery else R.string.video_saved_to_the_gallery
|
||||
val failure = if (isImage) R.string.failed_to_save_the_image else R.string.failed_to_save_the_video
|
||||
|
||||
if (content is MediaUrlContent) {
|
||||
ImageSaver.saveImage(
|
||||
val useTor =
|
||||
if (isImage) {
|
||||
accountViewModel.account.shouldUseTorForImageDownload()
|
||||
} else {
|
||||
accountViewModel.account.shouldUseTorForVideoDownload()
|
||||
}
|
||||
|
||||
MediaSaverToDisk.downloadAndSave(
|
||||
content.url,
|
||||
forceProxy = useTor,
|
||||
localContext,
|
||||
onSuccess = {
|
||||
accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
|
||||
accountViewModel.toast(success, success)
|
||||
},
|
||||
onError = {
|
||||
accountViewModel.toast(R.string.failed_to_save_the_image, null, it)
|
||||
accountViewModel.toast(failure, null, it)
|
||||
},
|
||||
)
|
||||
} else if (content is MediaPreloadedContent) {
|
||||
content.localFile?.let {
|
||||
ImageSaver.saveImage(
|
||||
MediaSaverToDisk.save(
|
||||
it,
|
||||
content.mimeType,
|
||||
localContext,
|
||||
onSuccess = {
|
||||
accountViewModel.toast(R.string.image_saved_to_the_gallery, R.string.image_saved_to_the_gallery)
|
||||
accountViewModel.toast(success, success)
|
||||
},
|
||||
onError = {
|
||||
accountViewModel.toast(R.string.failed_to_save_the_image, null, it)
|
||||
accountViewModel.toast(failure, null, it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
@ -20,11 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@ -47,7 +45,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@ -55,7 +52,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@ -97,7 +93,6 @@ import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadStatuses
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountBackupDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRDialog
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
|
||||
@ -113,6 +108,7 @@ import com.vitorpamplona.amethyst.ui.theme.bannerModifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.drawerSpacing
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.theme.profileContentHeaderModifier
|
||||
import com.vitorpamplona.amethyst.ui.tor.ConnectTorDialog
|
||||
import com.vitorpamplona.ammolite.relays.RelayPool
|
||||
import com.vitorpamplona.ammolite.relays.RelayPoolStatus
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
@ -442,8 +438,7 @@ fun ListContent(
|
||||
var editMediaServers by remember { mutableStateOf(false) }
|
||||
|
||||
var backupDialogOpen by remember { mutableStateOf(false) }
|
||||
var checked by remember { mutableStateOf(accountViewModel.account.settings.proxy != null) }
|
||||
var disconnectTorDialog by remember { mutableStateOf(false) }
|
||||
|
||||
var conectOrbotDialogOpen by remember { mutableStateOf(false) }
|
||||
|
||||
val context = LocalContext.current
|
||||
@ -512,26 +507,13 @@ fun ListContent(
|
||||
}
|
||||
|
||||
IconRow(
|
||||
title =
|
||||
if (checked) {
|
||||
stringRes(R.string.disconnect_from_your_orbot_setup)
|
||||
} else {
|
||||
stringRes(R.string.connect_via_tor_short)
|
||||
},
|
||||
title = stringRes(R.string.connect_via_tor_short),
|
||||
icon = R.drawable.ic_tor,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
onLongClick = {
|
||||
onClick = {
|
||||
nav.closeDrawer()
|
||||
conectOrbotDialogOpen = true
|
||||
},
|
||||
onClick = {
|
||||
if (checked) {
|
||||
disconnectTorDialog = true
|
||||
} else {
|
||||
nav.closeDrawer()
|
||||
conectOrbotDialogOpen = true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
NavigationRow(
|
||||
@ -562,13 +544,14 @@ fun ListContent(
|
||||
AccountBackupDialog(accountViewModel, onClose = { backupDialogOpen = false })
|
||||
}
|
||||
if (conectOrbotDialogOpen) {
|
||||
ConnectOrbotDialog(
|
||||
ConnectTorDialog(
|
||||
torSettings =
|
||||
accountViewModel.account.settings.torSettings
|
||||
.toSettings(),
|
||||
onClose = { conectOrbotDialogOpen = false },
|
||||
onPost = {
|
||||
onPost = { torSettings ->
|
||||
conectOrbotDialogOpen = false
|
||||
disconnectTorDialog = false
|
||||
checked = true
|
||||
accountViewModel.enableTor(it)
|
||||
accountViewModel.setTorSettings(torSettings)
|
||||
},
|
||||
onError = {
|
||||
accountViewModel.toast(
|
||||
@ -576,33 +559,6 @@ fun ListContent(
|
||||
it,
|
||||
)
|
||||
},
|
||||
currentPortNumber = accountViewModel.account.settings.proxyPort,
|
||||
)
|
||||
}
|
||||
|
||||
if (disconnectTorDialog) {
|
||||
AlertDialog(
|
||||
title = { Text(text = stringRes(R.string.do_you_really_want_to_disable_tor_title)) },
|
||||
text = { Text(text = stringRes(R.string.do_you_really_want_to_disable_tor_text)) },
|
||||
onDismissRequest = { disconnectTorDialog = false },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
disconnectTorDialog = false
|
||||
checked = false
|
||||
accountViewModel.disableTor()
|
||||
},
|
||||
) {
|
||||
Text(text = stringRes(R.string.yes))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { disconnectTorDialog = false },
|
||||
) {
|
||||
Text(text = stringRes(R.string.no))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -658,22 +614,19 @@ fun NavigationRow(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun IconRow(
|
||||
title: String,
|
||||
icon: Int,
|
||||
tint: Color,
|
||||
onClick: () -> Unit,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
|
@ -79,7 +79,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LiveFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.OfflineFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ScheduledFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.observeAppDefinition
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfUrlIsOnline
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
@ -480,7 +480,7 @@ fun RenderLiveActivityThumb(
|
||||
if (url.isNullOrBlank()) {
|
||||
LiveFlag()
|
||||
} else {
|
||||
CheckIfUrlIsOnline(url, accountViewModel) { isOnline ->
|
||||
CheckIfVideoIsOnline(url, accountViewModel) { isOnline ->
|
||||
if (isOnline) {
|
||||
LiveFlag()
|
||||
} else {
|
||||
|
@ -164,14 +164,10 @@ class UpdateZapAmountViewModel(
|
||||
null
|
||||
}
|
||||
|
||||
val relayUrl =
|
||||
walletConnectRelay.text
|
||||
.ifBlank { null }
|
||||
?.let { RelayUrlFormatter.normalize(it) }
|
||||
|
||||
val relayUrl = walletConnectRelay.text.ifBlank { null }?.let { RelayUrlFormatter.normalize(it) }
|
||||
val privKeyHex = walletConnectSecret.text.ifBlank { null }?.let { decodePrivateKeyAsHexOrNull(it) }
|
||||
|
||||
if (pubkeyHex != null) {
|
||||
if (pubkeyHex != null && relayUrl != null) {
|
||||
accountSettings.changeZapPaymentRequest(
|
||||
Nip47WalletConnect.Nip47URI(
|
||||
pubkeyHex,
|
||||
|
@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
@ -180,18 +181,21 @@ class AddBountyAmountViewModel : ViewModel() {
|
||||
|
||||
if (newValue != null) {
|
||||
viewModelScope.launch {
|
||||
account?.sendPost(
|
||||
message = newValue.toString(),
|
||||
replyTo = listOfNotNull(bounty),
|
||||
mentions = listOfNotNull(bounty?.author),
|
||||
tags = listOf("bounty-added-reward"),
|
||||
wantsToMarkAsSensitive = false,
|
||||
replyingTo = null,
|
||||
root = null,
|
||||
directMentions = setOf(),
|
||||
forkedFrom = null,
|
||||
draftTag = null,
|
||||
)
|
||||
account?.let {
|
||||
it.sendPost(
|
||||
message = newValue.toString(),
|
||||
replyTo = listOfNotNull(bounty),
|
||||
mentions = listOfNotNull(bounty?.author),
|
||||
tags = listOf("bounty-added-reward"),
|
||||
wantsToMarkAsSensitive = false,
|
||||
replyingTo = null,
|
||||
root = null,
|
||||
directMentions = setOf(),
|
||||
forkedFrom = null,
|
||||
draftTag = null,
|
||||
relayList = it.activeWriteRelays().toImmutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
nextAmount = TextFieldValue("")
|
||||
}
|
||||
|
@ -59,8 +59,8 @@ import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.LiveFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chatrooms.ScheduledFlag
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfUrlIsOnline
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfUrlIsOnline
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CheckIfVideoIsOnline
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfVideoIsOnline
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@ -159,7 +159,7 @@ fun RenderLiveActivityEventInner(
|
||||
CrossfadeIfEnabled(targetState = status, label = "RenderLiveActivityEventInner", accountViewModel = accountViewModel) {
|
||||
when (it) {
|
||||
LiveActivitiesEvent.STATUS_LIVE -> {
|
||||
media?.let { CrossfadeCheckIfUrlIsOnline(it, accountViewModel) { LiveFlag() } }
|
||||
media?.let { CrossfadeCheckIfVideoIsOnline(it, accountViewModel) { LiveFlag() } }
|
||||
}
|
||||
|
||||
LiveActivitiesEvent.STATUS_PLANNED -> {
|
||||
@ -171,7 +171,7 @@ fun RenderLiveActivityEventInner(
|
||||
|
||||
media?.let { media ->
|
||||
if (status == LiveActivitiesEvent.STATUS_LIVE) {
|
||||
CheckIfUrlIsOnline(media, accountViewModel) { isOnline ->
|
||||
CheckIfVideoIsOnline(media, accountViewModel) { isOnline ->
|
||||
if (isOnline) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
@ -33,9 +33,10 @@ import com.vitorpamplona.amethyst.model.DefaultDMRelayList
|
||||
import com.vitorpamplona.amethyst.model.DefaultNIP65List
|
||||
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
|
||||
import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.ammolite.relays.Client
|
||||
import com.vitorpamplona.ammolite.relays.Constants
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import com.vitorpamplona.quartz.crypto.CryptoUtils
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.Hex
|
||||
@ -92,8 +93,7 @@ class AccountStateViewModel : ViewModel() {
|
||||
|
||||
suspend fun loginAndStartUI(
|
||||
key: String,
|
||||
useProxy: Boolean,
|
||||
proxyPort: Int,
|
||||
torSettings: TorSettings,
|
||||
transientAccount: Boolean,
|
||||
loginWithExternalSigner: Boolean = false,
|
||||
packageName: String = "",
|
||||
@ -112,8 +112,6 @@ class AccountStateViewModel : ViewModel() {
|
||||
else -> null
|
||||
}
|
||||
|
||||
val proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort)
|
||||
|
||||
if (loginWithExternalSigner && pubKeyParsed == null) {
|
||||
throw Exception("Invalid key while trying to login with external signer")
|
||||
}
|
||||
@ -124,36 +122,31 @@ class AccountStateViewModel : ViewModel() {
|
||||
keyPair = KeyPair(pubKey = pubKeyParsed),
|
||||
transientAccount = transientAccount,
|
||||
externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" },
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
torSettings = TorSettingsFlow.build(torSettings),
|
||||
)
|
||||
} else if (key.startsWith("nsec")) {
|
||||
AccountSettings(
|
||||
keyPair = KeyPair(privKey = key.bechToBytes()),
|
||||
transientAccount = transientAccount,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
torSettings = TorSettingsFlow.build(torSettings),
|
||||
)
|
||||
} else if (key.contains(" ") && CryptoUtils.isValidMnemonic(key)) {
|
||||
AccountSettings(
|
||||
keyPair = KeyPair(privKey = CryptoUtils.privateKeyFromMnemonic(key)),
|
||||
transientAccount = transientAccount,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
torSettings = TorSettingsFlow.build(torSettings),
|
||||
)
|
||||
} else if (pubKeyParsed != null) {
|
||||
AccountSettings(
|
||||
keyPair = KeyPair(pubKey = pubKeyParsed),
|
||||
transientAccount = transientAccount,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
torSettings = TorSettingsFlow.build(torSettings),
|
||||
)
|
||||
} else {
|
||||
AccountSettings(
|
||||
keyPair = KeyPair(Hex.decode(key)),
|
||||
transientAccount = transientAccount,
|
||||
proxy = proxy,
|
||||
proxyPort = proxyPort,
|
||||
torSettings = TorSettingsFlow.build(torSettings),
|
||||
)
|
||||
}
|
||||
|
||||
@ -198,8 +191,7 @@ class AccountStateViewModel : ViewModel() {
|
||||
fun login(
|
||||
key: String,
|
||||
password: String,
|
||||
useProxy: Boolean,
|
||||
proxyPort: Int,
|
||||
torSettings: TorSettings,
|
||||
transientAccount: Boolean,
|
||||
loginWithExternalSigner: Boolean = false,
|
||||
packageName: String = "",
|
||||
@ -220,49 +212,48 @@ class AccountStateViewModel : ViewModel() {
|
||||
onError("Could not decrypt key with provided password")
|
||||
Log.e("Login", "Could not decrypt ncryptsec")
|
||||
} else {
|
||||
loginSync(newKey, useProxy, proxyPort, transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
loginSync(newKey, torSettings, transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
}
|
||||
} else if (EMAIL_PATTERN.matcher(key).matches()) {
|
||||
Nip05NostrAddressVerifier().verifyNip05(
|
||||
key,
|
||||
forceProxy = { false },
|
||||
onSuccess = { publicKey ->
|
||||
loginSync(Hex.decode(publicKey).toNpub(), useProxy, proxyPort, transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
loginSync(Hex.decode(publicKey).toNpub(), torSettings, transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
},
|
||||
onError = {
|
||||
onError(it)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
loginSync(key, useProxy, proxyPort, transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
loginSync(key, torSettings, transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun login(
|
||||
key: String,
|
||||
useProxy: Boolean,
|
||||
proxyPort: Int,
|
||||
torSettings: TorSettings,
|
||||
transientAccount: Boolean,
|
||||
loginWithExternalSigner: Boolean = false,
|
||||
packageName: String = "",
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
loginSync(key, useProxy, proxyPort, transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
loginSync(key, torSettings, transientAccount, loginWithExternalSigner, packageName, onError)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loginSync(
|
||||
key: String,
|
||||
useProxy: Boolean,
|
||||
proxyPort: Int,
|
||||
torSettings: TorSettings,
|
||||
transientAccount: Boolean,
|
||||
loginWithExternalSigner: Boolean = false,
|
||||
packageName: String = "",
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
try {
|
||||
loginAndStartUI(key, useProxy, proxyPort, transientAccount, loginWithExternalSigner, packageName)
|
||||
loginAndStartUI(key, torSettings, transientAccount, loginWithExternalSigner, packageName)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("Login", "Could not sign in", e)
|
||||
@ -271,8 +262,7 @@ class AccountStateViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun newKey(
|
||||
useProxy: Boolean,
|
||||
proxyPort: Int,
|
||||
torSettings: TorSettings,
|
||||
name: String? = null,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
@ -299,8 +289,7 @@ class AccountStateViewModel : ViewModel() {
|
||||
backupNIP65RelayList = AdvertisedRelayListEvent.create(DefaultNIP65List, tempSigner),
|
||||
backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner),
|
||||
backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList, tempSigner),
|
||||
proxy = HttpClientManager.initProxy(useProxy, "127.0.0.1", proxyPort),
|
||||
proxyPort = proxyPort,
|
||||
torSettings = TorSettingsFlow.build(torSettings),
|
||||
)
|
||||
|
||||
// saves to local preferences
|
||||
|
@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@ -31,6 +32,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.window.layout.DisplayFeature
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.BooleanType
|
||||
@ -240,3 +242,10 @@ class SharedPreferencesViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun mockSharedPreferencesViewModel(): SharedPreferencesViewModel {
|
||||
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
|
||||
sharedPreferencesViewModel.init()
|
||||
return sharedPreferencesViewModel
|
||||
}
|
||||
|
@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedState
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.showAmountAxis
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.ammolite.relays.BundledInsert
|
||||
import com.vitorpamplona.quartz.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.encoders.ATag
|
||||
@ -707,6 +708,7 @@ class AccountViewModel(
|
||||
message = message,
|
||||
context = context,
|
||||
showErrorIfNoLnAddress = showErrorIfNoLnAddress,
|
||||
forceProxy = account::shouldUseTorForMoneyOperations,
|
||||
onError = onError,
|
||||
onProgress = {
|
||||
onProgress(it)
|
||||
@ -955,7 +957,9 @@ class AccountViewModel(
|
||||
url: String,
|
||||
onResult: suspend (UrlPreviewState) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) { UrlCachedPreviewer.previewInfo(url, onResult) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
UrlCachedPreviewer.previewInfo(url, account.shouldUseTorForPreviewUrl(url), onResult)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadReactionTo(note: Note?): String? {
|
||||
@ -975,6 +979,7 @@ class AccountViewModel(
|
||||
Nip05NostrAddressVerifier()
|
||||
.verifyNip05(
|
||||
nip05,
|
||||
forceProxy = account::shouldUseTorForNIP05,
|
||||
onSuccess = {
|
||||
// Marks user as verified
|
||||
if (it == pubkeyHex) {
|
||||
@ -1007,7 +1012,12 @@ class AccountViewModel(
|
||||
onError: (String, Nip11Retriever.ErrorCode, String?) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
Nip11CachedRetriever.loadRelayInfo(dirtyUrl, onInfo, onError)
|
||||
Nip11CachedRetriever.loadRelayInfo(
|
||||
dirtyUrl,
|
||||
account.shouldUseTorForDirty(dirtyUrl),
|
||||
onInfo,
|
||||
onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1156,11 +1166,13 @@ class AccountViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun checkIsOnline(
|
||||
media: String?,
|
||||
fun checkVideoIsOnline(
|
||||
videoUrl: String,
|
||||
onDone: (Boolean) -> Unit,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) { onDone(OnlineChecker.isOnline(media)) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
onDone(OnlineChecker.isOnline(videoUrl, account.shouldUseTorForVideoDownload(videoUrl)))
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAndMarkAsRead(
|
||||
@ -1221,17 +1233,12 @@ class AccountViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun disableTor() {
|
||||
fun setTorSettings(newTorSettings: TorSettings) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.settings.disableProxy()
|
||||
Amethyst.instance.serviceManager.forceRestart()
|
||||
}
|
||||
}
|
||||
|
||||
fun enableTor(portNumber: Int) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
account.settings.enableProxy(portNumber)
|
||||
Amethyst.instance.serviceManager.forceRestart()
|
||||
// Only restart relay connections if port or type changes
|
||||
if (account.settings.setTorSettings(newTorSettings)) {
|
||||
Amethyst.instance.serviceManager.forceRestart()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1353,6 +1360,7 @@ class AccountViewModel(
|
||||
.melt(
|
||||
token,
|
||||
lud16,
|
||||
forceProxy = account::shouldUseTorForMoneyOperations,
|
||||
onSuccess = { title, message -> onDone(title, message) },
|
||||
onError = { title, message -> onDone(title, message) },
|
||||
context,
|
||||
@ -1538,6 +1546,7 @@ class AccountViewModel(
|
||||
milliSats,
|
||||
message,
|
||||
null,
|
||||
forceProxy = account::shouldUseTorForMoneyOperations,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
onProgress = onProgress,
|
||||
@ -1552,6 +1561,7 @@ class AccountViewModel(
|
||||
milliSats,
|
||||
message,
|
||||
zapRequest.toJson(),
|
||||
forceProxy = account::shouldUseTorForMoneyOperations,
|
||||
onSuccess = onSuccess,
|
||||
onError = onError,
|
||||
onProgress = onProgress,
|
||||
|
@ -1,191 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.ui.screen.loggedIn
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
|
||||
import com.halilibo.richtext.commonmark.MarkdownParseOptions
|
||||
import com.halilibo.richtext.markdown.BasicMarkdown
|
||||
import com.halilibo.richtext.ui.material3.RichText
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
import com.vitorpamplona.amethyst.ui.theme.RichTextDefaults
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
@Composable
|
||||
fun ConnectOrbotDialog(
|
||||
onClose: () -> Unit,
|
||||
onPost: (port: Int) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
currentPortNumber: Int?,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
|
||||
) {
|
||||
Surface {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
) {
|
||||
val proxyPort =
|
||||
remember {
|
||||
mutableStateOf(
|
||||
currentPortNumber?.toString() ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
CloseButton(onPress = { onClose() })
|
||||
|
||||
val toastMessage = stringRes(R.string.invalid_port_number)
|
||||
|
||||
UseOrbotButton(
|
||||
onPost = {
|
||||
val port =
|
||||
try {
|
||||
Integer.parseInt(proxyPort.value)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError(toastMessage)
|
||||
return@UseOrbotButton
|
||||
}
|
||||
|
||||
onPost(port)
|
||||
},
|
||||
isActive = true,
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(30.dp),
|
||||
) {
|
||||
val myMarkDownStyle =
|
||||
RichTextDefaults.copy(
|
||||
stringStyle =
|
||||
RichTextDefaults.stringStyle?.copy(
|
||||
linkStyle =
|
||||
SpanStyle(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
Row {
|
||||
val content1 = stringRes(R.string.connect_through_your_orbot_setup_markdown)
|
||||
|
||||
val astNode1 =
|
||||
remember {
|
||||
CommonmarkAstNodeParser(MarkdownParseOptions.MarkdownWithLinks).parse(content1)
|
||||
}
|
||||
|
||||
RichText(
|
||||
style = myMarkDownStyle,
|
||||
renderer = null,
|
||||
) {
|
||||
BasicMarkdown(astNode1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(15.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = proxyPort.value,
|
||||
onValueChange = { proxyPort.value = it },
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
label = { Text(text = stringRes(R.string.orbot_socks_port)) },
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "9050",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UseOrbotButton(
|
||||
onPost: () -> Unit = {},
|
||||
isActive: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Button(
|
||||
modifier = modifier,
|
||||
onClick = {
|
||||
if (isActive) {
|
||||
onPost()
|
||||
}
|
||||
},
|
||||
shape = ButtonBorder,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray,
|
||||
),
|
||||
) {
|
||||
Text(text = stringRes(R.string.use_orbot), color = Color.White)
|
||||
}
|
||||
}
|
@ -140,7 +140,7 @@ import com.vitorpamplona.amethyst.ui.note.timeAgoShort
|
||||
import com.vitorpamplona.amethyst.ui.screen.NostrChannelFeedViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfUrlIsOnline
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.CrossfadeCheckIfVideoIsOnline
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.ButtonBorder
|
||||
@ -769,7 +769,7 @@ fun ShowVideoStreaming(
|
||||
val url = remember(streamingInfo) { event.streaming() }
|
||||
|
||||
url?.let {
|
||||
CrossfadeCheckIfUrlIsOnline(url, accountViewModel) {
|
||||
CrossfadeCheckIfVideoIsOnline(url, accountViewModel) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
|
@ -273,7 +273,7 @@ fun HomeFeedEmpty(onRefresh: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CheckIfUrlIsOnline(
|
||||
fun CheckIfVideoIsOnline(
|
||||
url: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
whenOnline: @Composable (Boolean) -> Unit,
|
||||
@ -285,7 +285,7 @@ fun CheckIfUrlIsOnline(
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = url) {
|
||||
accountViewModel.checkIsOnline(url) { isOnline ->
|
||||
accountViewModel.checkVideoIsOnline(url) { isOnline ->
|
||||
if (online != isOnline) {
|
||||
online = isOnline
|
||||
}
|
||||
@ -296,7 +296,7 @@ fun CheckIfUrlIsOnline(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CrossfadeCheckIfUrlIsOnline(
|
||||
fun CrossfadeCheckIfVideoIsOnline(
|
||||
url: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
whenOnline: @Composable () -> Unit,
|
||||
@ -308,7 +308,7 @@ fun CrossfadeCheckIfUrlIsOnline(
|
||||
}
|
||||
|
||||
LaunchedEffect(key1 = url) {
|
||||
accountViewModel.checkIsOnline(url) { isOnline ->
|
||||
accountViewModel.checkVideoIsOnline(url) { isOnline ->
|
||||
if (online != isOnline) {
|
||||
online = isOnline
|
||||
}
|
||||
|
@ -42,6 +42,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@ -60,10 +61,12 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TextSpinner
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.screen.mockSharedPreferencesViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@ -146,6 +149,15 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(device = "spec:width=2160px,height=2340px,dpi=440")
|
||||
@Composable
|
||||
fun SettingsScreenPreview() {
|
||||
val sharedPreferencesViewModel = mockSharedPreferencesViewModel()
|
||||
ThemeComparisonRow {
|
||||
SettingsScreen(sharedPreferencesViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) {
|
||||
val selectedItens =
|
||||
@ -297,10 +309,28 @@ fun SettingsRow(
|
||||
selectedItens: ImmutableList<TitleExplainer>,
|
||||
selectedIndex: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
) {
|
||||
SettingsRow(name, description) {
|
||||
TextSpinner(
|
||||
label = "",
|
||||
placeholder = selectedItens[selectedIndex].title,
|
||||
options = selectedItens,
|
||||
onSelect = onSelect,
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsRow(
|
||||
name: Int,
|
||||
description: Int,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(2.0f),
|
||||
@ -320,12 +350,11 @@ fun SettingsRow(
|
||||
)
|
||||
}
|
||||
|
||||
TextSpinner(
|
||||
label = "",
|
||||
placeholder = selectedItens[selectedIndex].title,
|
||||
options = selectedItens,
|
||||
onSelect = onSelect,
|
||||
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)).weight(1f),
|
||||
)
|
||||
Column(
|
||||
Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -104,6 +104,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.quartz.encoders.Nip19Bech32
|
||||
import com.vitorpamplona.quartz.signers.ExternalSignerLauncher
|
||||
import com.vitorpamplona.quartz.signers.SignerType
|
||||
@ -138,8 +139,7 @@ fun LoginPage(
|
||||
var termsAcceptanceIsRequired by remember { mutableStateOf("") }
|
||||
|
||||
val context = LocalContext.current
|
||||
val useProxy = remember { mutableStateOf(false) }
|
||||
val proxyPort = remember { mutableStateOf("9050") }
|
||||
val torSettings = remember { mutableStateOf(TorSettings()) }
|
||||
val isNFCOrQR = remember { mutableStateOf(false) }
|
||||
val isTemporary = remember { mutableStateOf(false) }
|
||||
|
||||
@ -172,8 +172,7 @@ fun LoginPage(
|
||||
if (acceptedTerms.value && key.value.text.isNotBlank()) {
|
||||
accountStateViewModel.login(
|
||||
key = key.value.text,
|
||||
useProxy = useProxy.value,
|
||||
proxyPort = proxyPort.value.toInt(),
|
||||
torSettings = torSettings.value,
|
||||
transientAccount = isTemporary.value,
|
||||
loginWithExternalSigner = true,
|
||||
packageName = packageName,
|
||||
@ -233,8 +232,7 @@ fun LoginPage(
|
||||
accountStateViewModel.login(
|
||||
key = key.value.text,
|
||||
password = password.value.text,
|
||||
useProxy = useProxy.value,
|
||||
proxyPort = proxyPort.value.toInt(),
|
||||
torSettings = torSettings.value,
|
||||
transientAccount = isTemporary.value,
|
||||
) {
|
||||
processingLogin = false
|
||||
@ -283,7 +281,7 @@ fun LoginPage(
|
||||
|
||||
if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) {
|
||||
processingLogin = true
|
||||
accountStateViewModel.login(key.value.text, password.value.text, useProxy.value, proxyPort.value.toInt(), isTemporary.value) {
|
||||
accountStateViewModel.login(key.value.text, password.value.text, torSettings.value, isTemporary.value) {
|
||||
processingLogin = false
|
||||
errorMessage =
|
||||
if (it != null) {
|
||||
@ -299,10 +297,9 @@ fun LoginPage(
|
||||
|
||||
if (PackageUtils.isOrbotInstalled(context)) {
|
||||
OrbotCheckBox(
|
||||
currentPort = proxyPort.value.toIntOrNull(),
|
||||
useProxy = useProxy.value,
|
||||
torSettings = torSettings.value,
|
||||
onCheckedChange = {
|
||||
useProxy.value = it
|
||||
torSettings.value = it
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
@ -361,7 +358,7 @@ fun LoginPage(
|
||||
|
||||
if (acceptedTerms.value && key.value.text.isNotBlank() && !(needsPassword.value && password.value.text.isBlank())) {
|
||||
processingLogin = true
|
||||
accountStateViewModel.login(key.value.text, password.value.text, useProxy.value, proxyPort.value.toInt(), isTemporary.value) {
|
||||
accountStateViewModel.login(key.value.text, password.value.text, torSettings.value, isTemporary.value) {
|
||||
processingLogin = false
|
||||
errorMessage =
|
||||
if (it != null) {
|
||||
|
@ -30,21 +30,23 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.ConnectOrbotDialog
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.tor.ConnectTorDialog
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
|
||||
@Composable
|
||||
fun OrbotCheckBox(
|
||||
currentPort: Int?,
|
||||
useProxy: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
torSettings: TorSettings,
|
||||
onCheckedChange: (TorSettings) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
var connectOrbotDialogOpen by remember { mutableStateOf(false) }
|
||||
var activeTor by remember { mutableStateOf(false) }
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(
|
||||
checked = useProxy,
|
||||
checked = activeTor,
|
||||
onCheckedChange = {
|
||||
if (it) {
|
||||
connectOrbotDialogOpen = true
|
||||
@ -56,14 +58,15 @@ fun OrbotCheckBox(
|
||||
}
|
||||
|
||||
if (connectOrbotDialogOpen) {
|
||||
ConnectOrbotDialog(
|
||||
ConnectTorDialog(
|
||||
torSettings = torSettings,
|
||||
onClose = { connectOrbotDialogOpen = false },
|
||||
onPost = {
|
||||
onPost = { torSettings ->
|
||||
activeTor = torSettings.torType != TorType.OFF
|
||||
connectOrbotDialogOpen = false
|
||||
onCheckedChange(true)
|
||||
onCheckedChange(torSettings)
|
||||
},
|
||||
onError = onError,
|
||||
currentPort,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size35dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size40dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorSettings
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Preview(device = "spec:width=2160px,height=2340px,dpi=440")
|
||||
@ -94,9 +95,7 @@ fun SignUpPage(
|
||||
var termsAcceptanceIsRequired by remember { mutableStateOf("") }
|
||||
|
||||
val context = LocalContext.current
|
||||
val useProxy = remember { mutableStateOf(false) }
|
||||
val proxyPort = remember { mutableStateOf("9050") }
|
||||
var connectOrbotDialogOpen by remember { mutableStateOf(false) }
|
||||
val torSettings = remember { mutableStateOf(TorSettings()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
@ -154,7 +153,7 @@ fun SignUpPage(
|
||||
}
|
||||
|
||||
if (acceptedTerms.value && displayName.value.text.isNotBlank()) {
|
||||
accountStateViewModel.newKey(useProxy.value, proxyPort.value.toInt(), displayName.value.text)
|
||||
accountStateViewModel.newKey(torSettings.value, displayName.value.text)
|
||||
}
|
||||
},
|
||||
),
|
||||
@ -184,10 +183,9 @@ fun SignUpPage(
|
||||
|
||||
if (PackageUtils.isOrbotInstalled(context)) {
|
||||
OrbotCheckBox(
|
||||
currentPort = proxyPort.value.toIntOrNull(),
|
||||
useProxy = useProxy.value,
|
||||
torSettings = torSettings.value,
|
||||
onCheckedChange = {
|
||||
useProxy.value = it
|
||||
torSettings.value = it
|
||||
},
|
||||
onError = {
|
||||
scope.launch {
|
||||
@ -217,7 +215,7 @@ fun SignUpPage(
|
||||
}
|
||||
|
||||
if (acceptedTerms.value && displayName.value.text.isNotBlank()) {
|
||||
accountStateViewModel.newKey(useProxy.value, proxyPort.value.toInt(), displayName.value.text)
|
||||
accountStateViewModel.newKey(torSettings.value, displayName.value.text)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.ui.tor
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class TorDialogViewModel : ViewModel() {
|
||||
val torType = mutableStateOf(TorType.INTERNAL)
|
||||
val socksPortStr = mutableStateOf("9050")
|
||||
|
||||
// usage types
|
||||
val onionRelaysViaTor = mutableStateOf(true)
|
||||
val dmRelaysViaTor = mutableStateOf(true)
|
||||
val newRelaysViaTor = mutableStateOf(true)
|
||||
|
||||
val trustedRelaysViaTor = mutableStateOf(false)
|
||||
val urlPreviewsViaTor = mutableStateOf(false)
|
||||
val profilePicsViaTor = mutableStateOf(false)
|
||||
val imagesViaTor = mutableStateOf(false)
|
||||
val videosViaTor = mutableStateOf(false)
|
||||
val moneyOperationsViaTor = mutableStateOf(false)
|
||||
val nip05VerificationsViaTor = mutableStateOf(false)
|
||||
val nip96UploadsViaTor = mutableStateOf(false)
|
||||
|
||||
fun reset(torSettings: TorSettings) {
|
||||
torType.value = torSettings.torType
|
||||
socksPortStr.value = torSettings.externalSocksPort.toString()
|
||||
onionRelaysViaTor.value = torSettings.onionRelaysViaTor
|
||||
dmRelaysViaTor.value = torSettings.dmRelaysViaTor
|
||||
newRelaysViaTor.value = torSettings.newRelaysViaTor
|
||||
trustedRelaysViaTor.value = torSettings.trustedRelaysViaTor
|
||||
urlPreviewsViaTor.value = torSettings.urlPreviewsViaTor
|
||||
profilePicsViaTor.value = torSettings.profilePicsViaTor
|
||||
imagesViaTor.value = torSettings.imagesViaTor
|
||||
videosViaTor.value = torSettings.videosViaTor
|
||||
moneyOperationsViaTor.value = torSettings.moneyOperationsViaTor
|
||||
nip05VerificationsViaTor.value = torSettings.nip05VerificationsViaTor
|
||||
nip96UploadsViaTor.value = torSettings.nip96UploadsViaTor
|
||||
}
|
||||
|
||||
fun save(): TorSettings =
|
||||
TorSettings(
|
||||
torType = torType.value,
|
||||
externalSocksPort = Integer.parseInt(socksPortStr.value),
|
||||
onionRelaysViaTor = onionRelaysViaTor.value,
|
||||
dmRelaysViaTor = dmRelaysViaTor.value,
|
||||
newRelaysViaTor = newRelaysViaTor.value,
|
||||
trustedRelaysViaTor = trustedRelaysViaTor.value,
|
||||
urlPreviewsViaTor = urlPreviewsViaTor.value,
|
||||
profilePicsViaTor = profilePicsViaTor.value,
|
||||
imagesViaTor = imagesViaTor.value,
|
||||
videosViaTor = videosViaTor.value,
|
||||
moneyOperationsViaTor = moneyOperationsViaTor.value,
|
||||
nip05VerificationsViaTor = nip05VerificationsViaTor.value,
|
||||
nip96UploadsViaTor = nip96UploadsViaTor.value,
|
||||
)
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.ui.tor
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.appcompat.app.AppCompatActivity.BIND_AUTO_CREATE
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import org.torproject.jni.TorService
|
||||
import org.torproject.jni.TorService.LocalBinder
|
||||
|
||||
object TorManager {
|
||||
var torService: TorService? = null
|
||||
|
||||
fun startTorIfNotAlreadyOn(ctx: Context) {
|
||||
if (torService == null) {
|
||||
startTor(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
fun startTor(ctx: Context) {
|
||||
Log.d("TorManager", "Binding Tor Service")
|
||||
ctx.bindService(
|
||||
Intent(ctx, TorService::class.java),
|
||||
object : ServiceConnection {
|
||||
override fun onServiceConnected(
|
||||
name: ComponentName,
|
||||
service: IBinder,
|
||||
) {
|
||||
// moved torService to a local variable, since we only need it once
|
||||
torService = (service as LocalBinder).service
|
||||
|
||||
while (!isSocksReady()) {
|
||||
try {
|
||||
Thread.sleep(100)
|
||||
} catch (e: InterruptedException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
HttpClientManager.setDefaultProxyOnPort(socksPort())
|
||||
|
||||
Log.d("TorManager", "Tor Service Connected ${socksPort()}")
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
torService = null
|
||||
Log.d("TorManager", "Tor Service Disconected")
|
||||
}
|
||||
},
|
||||
BIND_AUTO_CREATE,
|
||||
)
|
||||
}
|
||||
|
||||
fun stopTor(ctx: Context) {
|
||||
Log.d("TorManager", "Stopping Tor Service")
|
||||
torService = null
|
||||
ctx.stopService(Intent(ctx, TorService::class.java))
|
||||
}
|
||||
|
||||
fun isSocksReady() =
|
||||
torService?.let {
|
||||
it.socksPort > 0
|
||||
} ?: false
|
||||
|
||||
fun socksPort(): Int = torService?.socksPort ?: 9050
|
||||
|
||||
fun httpPort(): Int = torService?.httpTunnelPort ?: 9050
|
||||
}
|
@ -18,27 +18,41 @@
|
||||
* 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.previews
|
||||
package com.vitorpamplona.amethyst.ui.tor
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import com.vitorpamplona.amethyst.R
|
||||
|
||||
class BahaUrlPreview(
|
||||
val url: String,
|
||||
var callback: IUrlPreviewCallback?,
|
||||
class TorSettings(
|
||||
val torType: TorType = TorType.INTERNAL,
|
||||
val externalSocksPort: Int = 9050,
|
||||
val onionRelaysViaTor: Boolean = true,
|
||||
val dmRelaysViaTor: Boolean = true,
|
||||
val newRelaysViaTor: Boolean = true,
|
||||
val trustedRelaysViaTor: Boolean = false,
|
||||
val urlPreviewsViaTor: Boolean = false,
|
||||
val profilePicsViaTor: Boolean = false,
|
||||
val imagesViaTor: Boolean = false,
|
||||
val videosViaTor: Boolean = false,
|
||||
val moneyOperationsViaTor: Boolean = false,
|
||||
val nip05VerificationsViaTor: Boolean = false,
|
||||
val nip96UploadsViaTor: Boolean = false,
|
||||
)
|
||||
|
||||
enum class TorType(
|
||||
val screenCode: Int,
|
||||
val resourceId: Int,
|
||||
) {
|
||||
suspend fun fetchUrlPreview(timeOut: Int = 30000) =
|
||||
try {
|
||||
fetch(timeOut)
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
callback?.onFailed(t)
|
||||
}
|
||||
|
||||
private suspend fun fetch(timeOut: Int = 30000) {
|
||||
callback?.onComplete(getDocument(url, timeOut))
|
||||
}
|
||||
|
||||
fun cleanUp() {
|
||||
callback = null
|
||||
}
|
||||
OFF(0, R.string.tor_off),
|
||||
INTERNAL(1, R.string.tor_internal),
|
||||
EXTERNAL(2, R.string.tor_external),
|
||||
}
|
||||
|
||||
fun parseTorType(code: Int?): TorType =
|
||||
when (code) {
|
||||
TorType.OFF.screenCode -> TorType.OFF
|
||||
TorType.INTERNAL.screenCode -> TorType.INTERNAL
|
||||
TorType.EXTERNAL.screenCode -> TorType.EXTERNAL
|
||||
else -> {
|
||||
TorType.INTERNAL
|
||||
}
|
||||
}
|
@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.ui.tor
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.actions.CloseButton
|
||||
import com.vitorpamplona.amethyst.ui.actions.SaveButton
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.TitleExplainer
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size15dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn
|
||||
import com.vitorpamplona.amethyst.ui.theme.placeholderText
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
@Composable
|
||||
fun ConnectTorDialog(
|
||||
torSettings: TorSettings = TorSettings(),
|
||||
onClose: () -> Unit,
|
||||
onPost: (torSettings: TorSettings) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onClose,
|
||||
properties =
|
||||
DialogProperties(
|
||||
usePlatformDefaultWidth = false,
|
||||
decorFitsSystemWindows = false,
|
||||
),
|
||||
) {
|
||||
Surface {
|
||||
TorDialogContents(
|
||||
torSettings,
|
||||
onClose,
|
||||
onPost,
|
||||
onError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun TorDialogContentsPreview() {
|
||||
ThemeComparisonColumn {
|
||||
TorDialogContents(
|
||||
onClose = {},
|
||||
onPost = { },
|
||||
onError = {},
|
||||
torSettings = TorSettings(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TorDialogContents(
|
||||
torSettings: TorSettings,
|
||||
onClose: () -> Unit,
|
||||
onPost: (torSettings: TorSettings) -> Unit,
|
||||
onError: (String) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(
|
||||
rememberScrollState(),
|
||||
).padding(10.dp),
|
||||
) {
|
||||
val dialogViewModel = viewModel<TorDialogViewModel>()
|
||||
|
||||
LaunchedEffect(dialogViewModel, torSettings) {
|
||||
dialogViewModel.reset(torSettings)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
CloseButton(onPress = { onClose() })
|
||||
|
||||
val toastMessage = stringRes(R.string.invalid_port_number)
|
||||
|
||||
SaveButton(
|
||||
onPost = {
|
||||
try {
|
||||
onPost(dialogViewModel.save())
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError(toastMessage)
|
||||
}
|
||||
},
|
||||
isActive = true,
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 10.dp, horizontal = 5.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(Size15dp),
|
||||
) {
|
||||
SettingsRow(
|
||||
R.string.use_internal_tor,
|
||||
R.string.use_internal_tor_explainer,
|
||||
persistentListOf(
|
||||
TitleExplainer(stringRes(TorType.OFF.resourceId)),
|
||||
TitleExplainer(stringRes(TorType.INTERNAL.resourceId)),
|
||||
TitleExplainer(stringRes(TorType.EXTERNAL.resourceId)),
|
||||
),
|
||||
dialogViewModel.torType.value.screenCode,
|
||||
) {
|
||||
dialogViewModel.torType.value = parseTorType(it)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = dialogViewModel.torType.value == TorType.EXTERNAL,
|
||||
) {
|
||||
SettingsRow(
|
||||
R.string.orbot_socks_port,
|
||||
R.string.connect_through_your_orbot_setup_short,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = dialogViewModel.socksPortStr.value,
|
||||
onValueChange = { dialogViewModel.socksPortStr.value = it },
|
||||
keyboardOptions =
|
||||
KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = "9050",
|
||||
color = MaterialTheme.colorScheme.placeholderText,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SwitchSettingsRow(
|
||||
R.string.tor_use_onion_address,
|
||||
R.string.tor_use_onion_address_explainer,
|
||||
dialogViewModel.onionRelaysViaTor,
|
||||
)
|
||||
|
||||
SwitchSettingsRow(
|
||||
R.string.tor_use_dm_relays,
|
||||
R.string.tor_use_dm_relays_explainer,
|
||||
dialogViewModel.dmRelaysViaTor,
|
||||
)
|
||||
|
||||
SwitchSettingsRow(
|
||||
R.string.tor_use_new_relays,
|
||||
R.string.tor_use_new_relays_explainer,
|
||||
dialogViewModel.newRelaysViaTor,
|
||||
)
|
||||
|
||||
SwitchSettingsRow(
|
||||
R.string.tor_use_trusted_relays,
|
||||
R.string.tor_use_trusted_relays_explainer,
|
||||
dialogViewModel.trustedRelaysViaTor,
|
||||
)
|
||||
|
||||
SwitchSettingsRow(
|
||||
R.string.tor_use_money_operations,
|
||||
R.string.tor_use_money_operations_explainer,
|
||||
dialogViewModel.moneyOperationsViaTor,
|
||||
)
|
||||
|
||||
SwitchSettingsRow(
|
||||
R.string.tor_use_profile_pictures,
|
||||
R.string.tor_use_profile_pictures_explainer,
|
||||
dialogViewModel.profilePicsViaTor,
|
||||
)
|
||||
|
||||
SwitchSettingsRow(
|
||||
R.string.tor_use_nip05_verification,
|
||||
R.string.tor_use_nip05_verification_explainer,
|
||||
dialogViewModel.nip05VerificationsViaTor,
|
||||
)
|
||||
|
||||
SwitchSettingsRow(
|
||||
R.string.tor_use_url_previews,
|
||||
R.string.tor_use_url_previews_explainer,
|
||||
dialogViewModel.urlPreviewsViaTor,
|
||||
)
|
||||
|
||||
SwitchSettingsRow(
|
||||
R.string.tor_use_images,
|
||||
R.string.tor_use_images_explainer,
|
||||
dialogViewModel.imagesViaTor,
|
||||
)
|
||||
|
||||
SwitchSettingsRow(
|
||||
R.string.tor_use_videos,
|
||||
R.string.tor_use_videos_explainer,
|
||||
dialogViewModel.videosViaTor,
|
||||
)
|
||||
|
||||
SwitchSettingsRow(
|
||||
R.string.tor_use_nip96_uploads,
|
||||
R.string.tor_use_nip96_uploads_explainer,
|
||||
dialogViewModel.nip96UploadsViaTor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SwitchSettingsRow(
|
||||
name: Int,
|
||||
desc: Int,
|
||||
checked: MutableState<Boolean>,
|
||||
) {
|
||||
SettingsRow(
|
||||
name,
|
||||
desc,
|
||||
) {
|
||||
Switch(checked.value, onCheckedChange = { checked.value = it })
|
||||
}
|
||||
}
|
@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.ui.tor
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class TorSettingsFlow(
|
||||
val torType: MutableStateFlow<TorType> = MutableStateFlow(TorType.INTERNAL),
|
||||
val externalSocksPort: MutableStateFlow<Int> = MutableStateFlow(9050),
|
||||
val onionRelaysViaTor: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val dmRelaysViaTor: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val newRelaysViaTor: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val trustedRelaysViaTor: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val urlPreviewsViaTor: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val profilePicsViaTor: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val imagesViaTor: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val videosViaTor: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val moneyOperationsViaTor: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val nip05VerificationsViaTor: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val nip96UploadsViaTor: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
) {
|
||||
fun toSettings(): TorSettings =
|
||||
TorSettings(
|
||||
torType.value,
|
||||
externalSocksPort.value,
|
||||
onionRelaysViaTor.value,
|
||||
dmRelaysViaTor.value,
|
||||
newRelaysViaTor.value,
|
||||
trustedRelaysViaTor.value,
|
||||
urlPreviewsViaTor.value,
|
||||
profilePicsViaTor.value,
|
||||
imagesViaTor.value,
|
||||
videosViaTor.value,
|
||||
moneyOperationsViaTor.value,
|
||||
nip05VerificationsViaTor.value,
|
||||
nip96UploadsViaTor.value,
|
||||
)
|
||||
|
||||
fun update(torSettings: TorSettings): Boolean {
|
||||
var any = false
|
||||
|
||||
if (torType.value != torSettings.torType) {
|
||||
torType.tryEmit(torSettings.torType)
|
||||
any = true
|
||||
}
|
||||
if (externalSocksPort.value != torSettings.externalSocksPort) {
|
||||
externalSocksPort.tryEmit(torSettings.externalSocksPort)
|
||||
any = true
|
||||
}
|
||||
if (onionRelaysViaTor.value != torSettings.onionRelaysViaTor) {
|
||||
onionRelaysViaTor.tryEmit(torSettings.onionRelaysViaTor)
|
||||
any = true
|
||||
}
|
||||
if (dmRelaysViaTor.value != torSettings.dmRelaysViaTor) {
|
||||
dmRelaysViaTor.tryEmit(torSettings.dmRelaysViaTor)
|
||||
any = true
|
||||
}
|
||||
if (newRelaysViaTor.value != torSettings.newRelaysViaTor) {
|
||||
newRelaysViaTor.tryEmit(torSettings.newRelaysViaTor)
|
||||
any = true
|
||||
}
|
||||
if (trustedRelaysViaTor.value != torSettings.trustedRelaysViaTor) {
|
||||
trustedRelaysViaTor.tryEmit(torSettings.trustedRelaysViaTor)
|
||||
any = true
|
||||
}
|
||||
if (urlPreviewsViaTor.value != torSettings.urlPreviewsViaTor) {
|
||||
urlPreviewsViaTor.tryEmit(torSettings.urlPreviewsViaTor)
|
||||
any = true
|
||||
}
|
||||
if (profilePicsViaTor.value != torSettings.profilePicsViaTor) {
|
||||
profilePicsViaTor.tryEmit(torSettings.profilePicsViaTor)
|
||||
any = true
|
||||
}
|
||||
if (imagesViaTor.value != torSettings.imagesViaTor) {
|
||||
imagesViaTor.tryEmit(torSettings.imagesViaTor)
|
||||
any = true
|
||||
}
|
||||
if (videosViaTor.value != torSettings.videosViaTor) {
|
||||
videosViaTor.tryEmit(torSettings.videosViaTor)
|
||||
any = true
|
||||
}
|
||||
if (moneyOperationsViaTor.value != torSettings.moneyOperationsViaTor) {
|
||||
moneyOperationsViaTor.tryEmit(torSettings.moneyOperationsViaTor)
|
||||
any = true
|
||||
}
|
||||
|
||||
if (nip05VerificationsViaTor.value != torSettings.nip05VerificationsViaTor) {
|
||||
nip05VerificationsViaTor.tryEmit(torSettings.nip05VerificationsViaTor)
|
||||
any = true
|
||||
}
|
||||
if (nip96UploadsViaTor.value != torSettings.nip96UploadsViaTor) {
|
||||
nip96UploadsViaTor.tryEmit(torSettings.nip96UploadsViaTor)
|
||||
any = true
|
||||
}
|
||||
|
||||
return any
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun build(torSettings: TorSettings): TorSettingsFlow =
|
||||
TorSettingsFlow(
|
||||
MutableStateFlow(torSettings.torType),
|
||||
MutableStateFlow(torSettings.externalSocksPort),
|
||||
MutableStateFlow(torSettings.onionRelaysViaTor),
|
||||
MutableStateFlow(torSettings.dmRelaysViaTor),
|
||||
MutableStateFlow(torSettings.newRelaysViaTor),
|
||||
MutableStateFlow(torSettings.trustedRelaysViaTor),
|
||||
MutableStateFlow(torSettings.urlPreviewsViaTor),
|
||||
MutableStateFlow(torSettings.profilePicsViaTor),
|
||||
MutableStateFlow(torSettings.imagesViaTor),
|
||||
MutableStateFlow(torSettings.videosViaTor),
|
||||
MutableStateFlow(torSettings.moneyOperationsViaTor),
|
||||
MutableStateFlow(torSettings.nip05VerificationsViaTor),
|
||||
MutableStateFlow(torSettings.nip96UploadsViaTor),
|
||||
)
|
||||
}
|
||||
}
|
@ -124,8 +124,10 @@
|
||||
<string name="ln_address">LN Address</string>
|
||||
<string name="ln_url_outdated">LN URL (outdated)</string>
|
||||
<string name="save_to_gallery">Save to Gallery</string>
|
||||
<string name="image_saved_to_the_gallery">Image saved to the gallery</string>
|
||||
<string name="image_saved_to_the_gallery">Image saved to the phone\'s photo gallery</string>
|
||||
<string name="failed_to_save_the_image">Failed to save the image</string>
|
||||
<string name="video_saved_to_the_gallery">Video saved to the phone\'s video gallery</string>
|
||||
<string name="failed_to_save_the_video">Failed to save the video</string>
|
||||
<string name="upload_image">Upload Image</string>
|
||||
<string name="uploading">Uploading…</string>
|
||||
<string name="user_does_not_have_a_lightning_address_setup_to_receive_sats">User does not have a lightning address set up to receive sats</string>
|
||||
@ -412,6 +414,8 @@
|
||||
<string name="follow_list_kind3follows">All Follows</string>
|
||||
<string name="follow_list_global">Global</string>
|
||||
<string name="follow_list_mute_list">Mute List</string>
|
||||
|
||||
<string name="connect_through_your_orbot_setup_short">Default port is 9050</string>
|
||||
<string name="connect_through_your_orbot_setup_markdown">
|
||||
## Connect through Tor with Orbot
|
||||
\n\n1. Install [Orbot](https://play.google.com/store/apps/details?id=org.torproject.android)
|
||||
@ -422,6 +426,47 @@
|
||||
\n6. Press the Activate button to use Orbot as a proxy
|
||||
</string>
|
||||
<string name="orbot_socks_port">Orbot Socks Port</string>
|
||||
|
||||
<string name="use_internal_tor">Active Tor Engine</string>
|
||||
<string name="use_internal_tor_explainer">Use the internal version or Orbot</string>
|
||||
|
||||
<string name="tor_use_onion_address">Onion Relays</string>
|
||||
<string name="tor_use_onion_address_explainer">Enables .onion urls from your relay list</string>
|
||||
|
||||
<string name="tor_use_dm_relays">DM Relays</string>
|
||||
<string name="tor_use_dm_relays_explainer">Force Tor to send and receive DMs</string>
|
||||
|
||||
<string name="tor_use_new_relays">Untrusted Relays</string>
|
||||
<string name="tor_use_new_relays_explainer">Force Tor on outbox/inbox relays</string>
|
||||
|
||||
<string name="tor_use_trusted_relays">Trusted Relays</string>
|
||||
<string name="tor_use_trusted_relays_explainer">Force Tor on all relays in your lists</string>
|
||||
|
||||
<string name="tor_use_profile_pictures">Profile Pictures</string>
|
||||
<string name="tor_use_profile_pictures_explainer">Force Tor when loading profile pictures</string>
|
||||
|
||||
<string name="tor_use_url_previews">URL Previews</string>
|
||||
<string name="tor_use_url_previews_explainer">Force Tor when loading url previews</string>
|
||||
|
||||
<string name="tor_use_images">Images</string>
|
||||
<string name="tor_use_images_explainer">Force Tor when loading images</string>
|
||||
|
||||
<string name="tor_use_videos">Videos</string>
|
||||
<string name="tor_use_videos_explainer">Force Tor when loading videos</string>
|
||||
|
||||
<string name="tor_use_money_operations">Money Operations</string>
|
||||
<string name="tor_use_money_operations_explainer">Force Tor on zaps, lightning and cashu transfers</string>
|
||||
|
||||
<string name="tor_use_nip05_verification">Nostr Address Verification</string>
|
||||
<string name="tor_use_nip05_verification_explainer">Force Tor when verifying NIP-05 addresses</string>
|
||||
|
||||
<string name="tor_use_nip96_uploads">Media Uploads</string>
|
||||
<string name="tor_use_nip96_uploads_explainer">Force Tor when uploading content</string>
|
||||
|
||||
<string name="tor_internal">Internal</string>
|
||||
<string name="tor_external">Orbot</string>
|
||||
<string name="tor_off">Off</string>
|
||||
|
||||
<string name="invalid_port_number">Invalid port number</string>
|
||||
<string name="use_orbot">Use Orbot</string>
|
||||
<string name="disconnect_from_your_orbot_setup">Disconnect Tor/Orbot</string>
|
||||
|
@ -44,10 +44,10 @@ object Client : RelayPool.Listener {
|
||||
|
||||
@Synchronized
|
||||
fun reconnect(
|
||||
relays: Array<RelaySetupInfo>?,
|
||||
relays: Array<RelaySetupInfoToConnect>?,
|
||||
onlyIfChanged: Boolean = false,
|
||||
) {
|
||||
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays: \n${relays?.joinToString("\n") { it.url + " " + it.read + " " + it.write + " " + it.feedTypes.joinToString(",") { it.name } }}")
|
||||
Log.d("Relay", "Relay Pool Reconnecting to ${relays?.size} relays: \n${relays?.joinToString("\n") { it.url + " " + it.forceProxy + " " + it.read + " " + it.write + " " + it.feedTypes.joinToString(",") { it.name } }}")
|
||||
checkNotInMainThread()
|
||||
|
||||
if (onlyIfChanged) {
|
||||
@ -59,7 +59,7 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
|
||||
if (relays != null) {
|
||||
val newRelays = relays.map { Relay(it.url, it.read, it.write, it.feedTypes) }
|
||||
val newRelays = relays.map { Relay(it.url, it.read, it.write, it.forceProxy, it.feedTypes) }
|
||||
RelayPool.register(this)
|
||||
RelayPool.loadRelays(newRelays)
|
||||
RelayPool.requestAndWatch()
|
||||
@ -74,7 +74,7 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
|
||||
if (relays != null) {
|
||||
val newRelays = relays.map { Relay(it.url, it.read, it.write, it.feedTypes) }
|
||||
val newRelays = relays.map { Relay(it.url, it.read, it.write, it.forceProxy, it.feedTypes) }
|
||||
RelayPool.register(this)
|
||||
RelayPool.loadRelays(newRelays)
|
||||
RelayPool.requestAndWatch()
|
||||
@ -83,7 +83,7 @@ object Client : RelayPool.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
fun isSameRelaySetConfig(newRelayConfig: Array<RelaySetupInfo>?): Boolean {
|
||||
fun isSameRelaySetConfig(newRelayConfig: Array<RelaySetupInfoToConnect>?): Boolean {
|
||||
if (relays.size != newRelayConfig?.size) return false
|
||||
|
||||
relays.forEach { oldRelayInfo ->
|
||||
@ -137,6 +137,7 @@ object Client : RelayPool.Listener {
|
||||
suspend fun sendAndWaitForResponse(
|
||||
signedEvent: EventInterface,
|
||||
relay: String? = null,
|
||||
forceProxy: Boolean = false,
|
||||
feedTypes: Set<FeedType>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
onDone: (() -> Unit)? = null,
|
||||
@ -201,7 +202,13 @@ object Client : RelayPool.Listener {
|
||||
|
||||
val job =
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
send(signedEvent, relay, feedTypes, relayList, onDone)
|
||||
if (relayList != null) {
|
||||
send(signedEvent, relayList)
|
||||
} else if (relay == null) {
|
||||
send(signedEvent)
|
||||
} else {
|
||||
sendSingle(signedEvent, RelaySetupInfoToConnect(relay, forceProxy, true, true, emptySet()), onDone ?: {})
|
||||
}
|
||||
}
|
||||
job.join()
|
||||
|
||||
@ -224,34 +231,51 @@ object Client : RelayPool.Listener {
|
||||
RelayPool.connectAndSendFiltersIfDisconnected()
|
||||
}
|
||||
|
||||
fun send(
|
||||
fun sendIfExists(
|
||||
signedEvent: EventInterface,
|
||||
relay: String? = null,
|
||||
feedTypes: Set<FeedType>? = null,
|
||||
relayList: List<RelaySetupInfo>? = null,
|
||||
onDone: (() -> Unit)? = null,
|
||||
connectedRelay: Relay,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (relayList != null) {
|
||||
RelayPool.sendToSelectedRelays(relayList, signedEvent)
|
||||
} else if (relay == null) {
|
||||
RelayPool.send(signedEvent)
|
||||
} else {
|
||||
RelayPool.getOrCreateRelay(relay, feedTypes, onDone) {
|
||||
it.send(signedEvent)
|
||||
}
|
||||
RelayPool.getRelays(connectedRelay.url).forEach {
|
||||
it.send(signedEvent)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendSingle(
|
||||
signedEvent: EventInterface,
|
||||
relayTemplate: RelaySetupInfoToConnect,
|
||||
onDone: (() -> Unit),
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
RelayPool.getOrCreateRelay(relayTemplate, onDone) {
|
||||
it.send(signedEvent)
|
||||
}
|
||||
}
|
||||
|
||||
fun send(signedEvent: EventInterface) {
|
||||
checkNotInMainThread()
|
||||
RelayPool.send(signedEvent)
|
||||
}
|
||||
|
||||
fun send(
|
||||
signedEvent: EventInterface,
|
||||
relayList: List<RelaySetupInfo>,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
RelayPool.sendToSelectedRelays(relayList, signedEvent)
|
||||
}
|
||||
|
||||
fun sendPrivately(
|
||||
signedEvent: EventInterface,
|
||||
relayList: List<String>,
|
||||
relayList: List<RelaySetupInfoToConnect>,
|
||||
) {
|
||||
checkNotInMainThread()
|
||||
|
||||
relayList.forEach { relayUrl ->
|
||||
RelayPool.getOrCreateRelay(relayUrl, emptySet(), { }) {
|
||||
relayList.forEach { relayTemplate ->
|
||||
RelayPool.getOrCreateRelay(relayTemplate, { }) {
|
||||
it.sendOverride(signedEvent)
|
||||
}
|
||||
}
|
||||
|
@ -25,12 +25,9 @@ import com.vitorpamplona.quartz.encoders.RelayUrlFormatter
|
||||
object Constants {
|
||||
val activeTypes = setOf(FeedType.FOLLOWS, FeedType.PRIVATE_DMS)
|
||||
val activeTypesChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS)
|
||||
val activeTypesGlobalChats =
|
||||
setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
|
||||
val activeTypesGlobalChats = setOf(FeedType.FOLLOWS, FeedType.PUBLIC_CHATS, FeedType.PRIVATE_DMS, FeedType.GLOBAL)
|
||||
val activeTypesSearch = setOf(FeedType.SEARCH)
|
||||
|
||||
fun convertDefaultRelays(): Array<Relay> = defaultRelays.map { Relay(it.url, it.read, it.write, it.feedTypes) }.toTypedArray()
|
||||
|
||||
val defaultRelays =
|
||||
arrayOf(
|
||||
// Free relays for only DMs, Chats and Follows due to the amount of spam
|
||||
|
@ -56,6 +56,7 @@ class Relay(
|
||||
val url: String,
|
||||
val read: Boolean = true,
|
||||
val write: Boolean = true,
|
||||
val forceProxy: Boolean = false,
|
||||
val activeTypes: Set<FeedType>,
|
||||
) {
|
||||
companion object {
|
||||
@ -109,7 +110,7 @@ class Relay(
|
||||
private var connectingBlock = AtomicBoolean()
|
||||
|
||||
fun connectAndRun(onConnected: (Relay) -> Unit) {
|
||||
Log.d("Relay", "Relay.connect $url isAlreadyConnecting: ${connectingBlock.get()}")
|
||||
Log.d("Relay", "Relay.connect $url proxy: $forceProxy isAlreadyConnecting: ${connectingBlock.get()}")
|
||||
// BRB is crashing OkHttp Deflater object :(
|
||||
if (url.contains("brb.io")) return
|
||||
|
||||
@ -131,11 +132,10 @@ class Relay(
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.header("User-Agent", HttpClientManager.getDefaultUserAgentHeader())
|
||||
.url(url.trim())
|
||||
.build()
|
||||
|
||||
socket = HttpClientManager.getHttpClientForUrl(url).newWebSocket(request, RelayListener(onConnected))
|
||||
socket = HttpClientManager.getHttpClient(forceProxy).newWebSocket(request, RelayListener(onConnected))
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
|
||||
@ -558,8 +558,9 @@ class Relay(
|
||||
writeToSocket("""["CLOSE","$subscriptionId"]""")
|
||||
}
|
||||
|
||||
fun isSameRelayConfig(other: RelaySetupInfo): Boolean =
|
||||
fun isSameRelayConfig(other: RelaySetupInfoToConnect): Boolean =
|
||||
url == other.url &&
|
||||
forceProxy == other.forceProxy &&
|
||||
write == other.write &&
|
||||
read == other.read &&
|
||||
activeTypes == other.feedTypes
|
||||
|
@ -58,20 +58,22 @@ object RelayPool : Relay.Listener {
|
||||
fun getAll() = relays
|
||||
|
||||
fun getOrCreateRelay(
|
||||
url: String,
|
||||
feedTypes: Set<FeedType>? = null,
|
||||
relayTemplate: RelaySetupInfoToConnect,
|
||||
onDone: (() -> Unit)? = null,
|
||||
whenConnected: (Relay) -> Unit,
|
||||
) {
|
||||
synchronized(this) {
|
||||
val matching = getRelays(url)
|
||||
val matching = getRelays(relayTemplate.url)
|
||||
if (matching.isNotEmpty()) {
|
||||
matching.forEach { whenConnected(it) }
|
||||
} else {
|
||||
/** temporary connection */
|
||||
newSporadicRelay(
|
||||
url,
|
||||
feedTypes,
|
||||
relayTemplate.url,
|
||||
relayTemplate.read,
|
||||
relayTemplate.write,
|
||||
relayTemplate.forceProxy,
|
||||
relayTemplate.feedTypes,
|
||||
onConnected = whenConnected,
|
||||
onDone = onDone,
|
||||
)
|
||||
@ -82,12 +84,15 @@ object RelayPool : Relay.Listener {
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun newSporadicRelay(
|
||||
url: String,
|
||||
read: Boolean,
|
||||
write: Boolean,
|
||||
forceProxy: Boolean,
|
||||
feedTypes: Set<FeedType>?,
|
||||
onConnected: (Relay) -> Unit,
|
||||
onDone: (() -> Unit)?,
|
||||
timeout: Long = 60000,
|
||||
) {
|
||||
val relay = Relay(url, true, true, feedTypes ?: emptySet())
|
||||
val relay = Relay(url, read, write, forceProxy, feedTypes ?: emptySet())
|
||||
addRelay(relay)
|
||||
|
||||
relay.connectAndRun {
|
||||
@ -110,11 +115,8 @@ object RelayPool : Relay.Listener {
|
||||
}
|
||||
|
||||
fun loadRelays(relayList: List<Relay>) {
|
||||
if (!relayList.isNullOrEmpty()) {
|
||||
relayList.forEach { addRelay(it) }
|
||||
} else {
|
||||
Constants.convertDefaultRelays().forEach { addRelay(it) }
|
||||
}
|
||||
check(relayList.isNotEmpty()) { "Relay list should never be empty" }
|
||||
relayList.forEach { addRelay(it) }
|
||||
}
|
||||
|
||||
fun unloadRelays() {
|
||||
|
@ -29,3 +29,12 @@ data class RelaySetupInfo(
|
||||
val write: Boolean,
|
||||
val feedTypes: Set<FeedType>,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class RelaySetupInfoToConnect(
|
||||
val url: String,
|
||||
val forceProxy: Boolean,
|
||||
val read: Boolean,
|
||||
val write: Boolean,
|
||||
val feedTypes: Set<FeedType>,
|
||||
)
|
||||
|
@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.ammolite.service
|
||||
|
||||
import android.util.Log
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager.setDefaultProxy
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@ -29,37 +30,58 @@ import java.io.IOException
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import java.time.Duration
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
class LoggingInterceptor : Interceptor {
|
||||
@Throws(IOException::class)
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request: Request = chain.request()
|
||||
val t1 = System.nanoTime()
|
||||
val port =
|
||||
(
|
||||
chain
|
||||
.connection()
|
||||
?.route()
|
||||
?.proxy
|
||||
?.address() as? InetSocketAddress
|
||||
)?.port
|
||||
val response: Response = chain.proceed(request)
|
||||
val t2 = System.nanoTime()
|
||||
|
||||
Log.d("OkHttpLog", "Req $port ${request.url} in ${(t2 - t1) / 1e6}ms")
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
object HttpClientManager {
|
||||
val rootClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.build()
|
||||
|
||||
val DEFAULT_TIMEOUT_ON_WIFI: Duration = Duration.ofSeconds(10L)
|
||||
val DEFAULT_TIMEOUT_ON_MOBILE: Duration = Duration.ofSeconds(30L)
|
||||
|
||||
var proxyChangeListeners = ArrayList<() -> Unit>()
|
||||
private var defaultTimeout = DEFAULT_TIMEOUT_ON_WIFI
|
||||
private var defaultHttpClient: OkHttpClient? = null
|
||||
private var defaultHttpClientWithoutProxy: OkHttpClient? = null
|
||||
private var userAgent: String = "Amethyst"
|
||||
|
||||
// fires off every time value of the property changes
|
||||
private var internalProxy: Proxy? by
|
||||
Delegates.observable(null) { _, oldValue, newValue ->
|
||||
if (oldValue != newValue) {
|
||||
proxyChangeListeners.forEach { it() }
|
||||
}
|
||||
}
|
||||
private var currentProxy: Proxy? = null
|
||||
|
||||
fun setDefaultProxy(proxy: Proxy?) {
|
||||
if (internalProxy != proxy) {
|
||||
if (currentProxy != proxy) {
|
||||
Log.d("HttpClient", "Changing proxy to: ${proxy != null}")
|
||||
this.internalProxy = proxy
|
||||
this.currentProxy = proxy
|
||||
|
||||
// recreates singleton
|
||||
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
|
||||
this.defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDefaultProxy(): Proxy? = this.internalProxy
|
||||
fun getCurrentProxy(): Proxy? = this.currentProxy
|
||||
|
||||
fun setDefaultTimeout(timeout: Duration) {
|
||||
Log.d("HttpClient", "Changing timeout to: $timeout")
|
||||
@ -67,33 +89,34 @@ object HttpClientManager {
|
||||
this.defaultTimeout = timeout
|
||||
|
||||
// recreates singleton
|
||||
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
|
||||
this.defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout)
|
||||
this.defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
fun setDefaultUserAgent(userAgentHeader: String) {
|
||||
Log.d("HttpClient", "Changing userAgent")
|
||||
this.userAgent = userAgentHeader
|
||||
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
|
||||
if (userAgent != userAgentHeader) {
|
||||
this.userAgent = userAgentHeader
|
||||
this.defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout)
|
||||
this.defaultHttpClientWithoutProxy = buildHttpClient(null, defaultTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDefaultUserAgentHeader() = this.userAgent
|
||||
|
||||
private fun buildHttpClient(
|
||||
proxy: Proxy?,
|
||||
timeout: Duration,
|
||||
): OkHttpClient {
|
||||
val seconds = if (proxy != null) timeout.seconds * 3 else timeout.seconds
|
||||
val duration = Duration.ofSeconds(seconds)
|
||||
return OkHttpClient
|
||||
.Builder()
|
||||
return rootClient
|
||||
.newBuilder()
|
||||
.proxy(proxy)
|
||||
.readTimeout(duration)
|
||||
.connectTimeout(duration)
|
||||
.writeTimeout(duration)
|
||||
.addInterceptor(DefaultContentTypeInterceptor(userAgent))
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addNetworkInterceptor(LoggingInterceptor())
|
||||
.build()
|
||||
}
|
||||
|
||||
@ -112,20 +135,17 @@ object HttpClientManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun getHttpClientForUrl(url: String): OkHttpClient {
|
||||
// TODO: How to identify relays on the local network?
|
||||
val isLocalHost = url.startsWith("ws://127.0.0.1") || url.startsWith("ws://localhost")
|
||||
return if (isLocalHost) {
|
||||
getHttpClient(false)
|
||||
fun getCurrentProxyPort(useProxy: Boolean): Int? =
|
||||
if (useProxy) {
|
||||
(currentProxy?.address() as? InetSocketAddress)?.port
|
||||
} else {
|
||||
getHttpClient()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getHttpClient(useProxy: Boolean = true): OkHttpClient =
|
||||
fun getHttpClient(useProxy: Boolean): OkHttpClient =
|
||||
if (useProxy) {
|
||||
if (this.defaultHttpClient == null) {
|
||||
this.defaultHttpClient = buildHttpClient(internalProxy, defaultTimeout)
|
||||
this.defaultHttpClient = buildHttpClient(currentProxy, defaultTimeout)
|
||||
}
|
||||
defaultHttpClient!!
|
||||
} else {
|
||||
@ -135,9 +155,7 @@ object HttpClientManager {
|
||||
defaultHttpClientWithoutProxy!!
|
||||
}
|
||||
|
||||
fun initProxy(
|
||||
useProxy: Boolean,
|
||||
hostname: String,
|
||||
port: Int,
|
||||
): Proxy? = if (useProxy) Proxy(Proxy.Type.SOCKS, InetSocketAddress(hostname, port)) else null
|
||||
fun setDefaultProxyOnPort(port: Int) {
|
||||
setDefaultProxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port)))
|
||||
}
|
||||
}
|
||||
|
@ -1,36 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2024 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.ammolite
|
||||
|
||||
import com.vitorpamplona.ammolite.service.HttpClientManager
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.JUnit4
|
||||
|
||||
@RunWith(JUnit4::class)
|
||||
class HttpClientHelperTest {
|
||||
@Test
|
||||
fun test() {
|
||||
val proxy = HttpClientManager.getDefaultProxy()
|
||||
assertEquals(null, proxy)
|
||||
}
|
||||
}
|
@ -45,7 +45,7 @@ class Nip47WalletConnect {
|
||||
throw IllegalArgumentException("Hostname is not a valid Nostr Pubkey")
|
||||
}
|
||||
|
||||
val relay = url.getQueryParameter("relay")
|
||||
val relay = url.getQueryParameter("relay") ?: throw IllegalArgumentException("Relay cannot be null")
|
||||
val secret = url.getQueryParameter("secret")
|
||||
|
||||
return Nip47URI(pubkeyHex, relay, secret)
|
||||
@ -54,7 +54,7 @@ class Nip47WalletConnect {
|
||||
|
||||
data class Nip47URI(
|
||||
val pubKeyHex: HexKey,
|
||||
val relayUri: String?,
|
||||
val relayUri: String,
|
||||
val secret: HexKey?,
|
||||
)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user