- 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:
Vitor Pamplona 2024-09-25 17:58:37 -04:00
parent b682e90f81
commit afe8a06486
74 changed files with 1904 additions and 1245 deletions

View File

@ -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(

View File

@ -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,

View File

@ -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)

View File

@ -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 {

View File

@ -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,
)

View File

@ -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)
},
)
}
}

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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 {

View File

@ -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()

View File

@ -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()) }

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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)

View File

@ -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")

View File

@ -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

View File

@ -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 =

View File

@ -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()

View File

@ -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))
}

View File

@ -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)

View File

@ -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

View File

@ -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) {

View File

@ -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,

View File

@ -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 {

View File

@ -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")
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}
}

View File

@ -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)
}

View File

@ -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()
}

View File

@ -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

View File

@ -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
}

View File

@ -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,

View File

@ -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,
) {

View File

@ -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

View File

@ -187,6 +187,7 @@ class NewUserMetadataViewModel : ViewModel() {
sensitiveContent = null,
server = account.settings.defaultFileServer,
contentResolver = contentResolver,
forceProxy = account::shouldUseTorForNIP96,
onProgress = {},
context = context,
)

View File

@ -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))
}
}

View File

@ -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)
},

View File

@ -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)
},

View File

@ -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)
},

View File

@ -116,7 +116,11 @@ fun CashuPreview(
accountViewModel: AccountViewModel,
) {
tokens.forEach {
CashuPreviewNew(it, accountViewModel::meltCashu, accountViewModel::toast)
CashuPreviewNew(
it,
accountViewModel::meltCashu,
accountViewModel::toast,
)
}
}

View File

@ -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)
},
)
}

View File

@ -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)
},
)
}

View File

@ -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(

View File

@ -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 {

View File

@ -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,

View File

@ -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("")
}

View File

@ -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,

View File

@ -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

View File

@ -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
}

View File

@ -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,

View File

@ -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)
}
}

View File

@ -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,

View File

@ -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
}

View File

@ -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()
}
}
}

View File

@ -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) {

View File

@ -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,
)
}
}

View File

@ -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)
}
},
)

View File

@ -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,
)
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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 })
}
}

View File

@ -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),
)
}
}

View File

@ -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>

View File

@ -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)
}
}

View File

@ -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

View File

@ -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

View File

@ -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() {

View File

@ -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>,
)

View File

@ -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)))
}
}

View File

@ -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)
}
}

View File

@ -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?,
)
}