diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 27385b737..37914e356 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 2495dc81e..06f2af1f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -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(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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt index cbcd93430..e58cacace 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ServiceManager.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a57d5b714..ba1e1e63c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 { + 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 { + 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 = userProfile().flow().follows.stateFlow.transformLatest { @@ -562,6 +635,13 @@ class Account( fun authorsPerRelay( followsNIP65RelayLists: List, defaultRelayList: List, + torType: TorType, + ): Map> = authorsPerRelay(followsNIP65RelayLists, defaultRelayList, torType != TorType.OFF) + + fun authorsPerRelay( + followsNIP65RelayLists: List, + defaultRelayList: List, + acceptOnion: Boolean, ): Map> { checkNotInMainThread() @@ -595,7 +675,7 @@ class Account( } } }.toMap(), - hasOnionConnection = settings.proxy != null, + hasOnionConnection = acceptOnion, ) } @@ -611,12 +691,13 @@ class Account( } val liveHomeListAuthorsPerRelayFlow: Flow>?> 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>?> 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>?> 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? = null, + relayList: List, ): Note? { if (!isWriteable()) return null @@ -1671,7 +1773,7 @@ class Account( fun sendNip95( data: FileStorageEvent, signedEvent: FileStorageHeaderEvent, - relayList: List? = null, + relayList: List, ) { Client.send(data, relayList = relayList) Client.send(signedEvent, relayList = relayList) @@ -1679,7 +1781,7 @@ class Account( fun sendHeader( signedEvent: FileHeaderEvent, - relayList: List? = null, + relayList: List, onReady: (Note) -> Unit, ) { Client.send(signedEvent, relayList = relayList) @@ -1723,7 +1825,7 @@ class Account( alt: String?, sensitiveContent: Boolean, originalHash: String? = null, - relayList: List? = null, + relayList: List, onReady: (Note) -> Unit, ) { if (!isWriteable()) return @@ -1758,7 +1860,7 @@ class Account( zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, - relayList: List? = null, + relayList: List, geohash: String? = null, nip94attachments: List? = 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, forkedFrom: Event?, - relayList: List? = null, + relayList: List, geohash: String? = null, nip94attachments: List? = 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, forkedFrom: Event?, - relayList: List? = null, + relayList: List, geohash: String? = null, nip94attachments: List? = 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, forkedFrom: Event?, - relayList: List? = null, + relayList: List, geohash: String? = null, nip94attachments: List? = 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? = null, + relayList: List, ) { if (!isWriteable()) return @@ -2090,7 +2179,7 @@ class Account( zapReceiver: List? = null, wantsToMarkAsSensitive: Boolean, zapRaiserAmount: Long? = null, - relayList: List? = null, + relayList: List, geohash: String? = null, nip94attachments: List? = 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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 28aa5610f..530412b2e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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 = 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, -) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt index 466726a86..2615ac2bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UrlCachedPreviewer.kt @@ -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) + }, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt index 485189e11..406128116 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/CashuProcessor.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index dc49a2f49..1e3d6d5be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt index 507149e68..f6fcd4b1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip05NostrAddressVerifier.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt index b0f4134a1..f201464f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip11RelayInfoRetriever.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt index 9efe85934..b0f7ffd69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96MediaServers.kt @@ -47,11 +47,14 @@ object Nip96MediaServers { val cache: MutableMap = 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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt index b4ac4ad7b..4db170ef7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/Nip96Uploader.kt @@ -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()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt index c01127f2a..08d630c66 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/NostrAccountDataSource.kt @@ -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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt index 72c8fd396..9bf1b0bc2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/OnlineCheck.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 88b162a2c..fed20d3c0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -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) -> 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( 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 4f142daf0..468735a4a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt index a94509d9d..1084cc338 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/RegisterAccounts.kt @@ -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") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBlockstreamExplorer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBlockstreamExplorer.kt index defcf6cbb..d50501df7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBlockstreamExplorer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpBlockstreamExplorer.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt index 0e16a2320..076977ac5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendar.kt @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt index 855b7c761..1630d5251 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarAsyncSubmit.kt @@ -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>? = null @@ -45,7 +46,7 @@ class OkHttpCalendarAsyncSubmit( @Throws(Exception::class) override fun call(): Optional { - val client = HttpClientManager.getHttpClient() + val client = HttpClientManager.getHttpClient(forceProxy) val url = "$url/digest" val mediaType = "application/x-www-form-urlencoded; charset=utf-8".toMediaType() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt index a565d5fde..5240d8777 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ots/OkHttpCalendarBuilder.kt @@ -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)) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/CustomMediaSourceFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/CustomMediaSourceFactory.kt index 0b79bd061..2de2f4b1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/CustomMediaSourceFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/CustomMediaSourceFactory.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt index e1ba0d90a..c0c1f0b97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/MultiPlayerPlaybackManager.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt index 8d28d947c..75718cc25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackClientController.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt index 0c73a3c99..f36a5a2b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackService.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt index 02de3fe7b..dd41a05e4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/VideoCache.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt new file mode 100644 index 000000000..d43077047 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/HtmlParser.kt @@ -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 = + 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") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt deleted file mode 100644 index 6bfdacd8d..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/IUrlPreviewCallback.kt +++ /dev/null @@ -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) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/OpenGraphParser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/OpenGraphParser.kt new file mode 100644 index 000000000..62ce54366 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/OpenGraphParser.kt @@ -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 ): 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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt new file mode 100644 index 000000000..99a11aa56 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreview.kt @@ -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) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt deleted file mode 100644 index 3161f82bd..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/UrlPreviewUtils.kt +++ /dev/null @@ -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 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, - 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) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 3a97de9a5..76240732d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt index b0d3826a5..a287f90ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostViewModel.kt @@ -107,11 +107,11 @@ open class EditPostViewModel : ViewModel() { editedFromNote = edit } - fun sendPost(relayList: List? = null) { + fun sendPost(relayList: List) { viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList) } } - suspend fun innerSendPost(relayList: List? = null) { + suspend fun innerSendPost(relayList: List) { 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt index 466fd0a7f..6a2fb7408 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageDownloader.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt index 63dcd3a23..f93837adb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/ImageSaver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/MediaSaverToDisk.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt index 0c8ac3137..4a9f4c080 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaModel.kt @@ -78,7 +78,7 @@ open class NewMediaModel : ViewModel() { fun upload( context: Context, - relayList: List? = null, + relayList: List, 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? = null, + relayList: List, + 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? = null, + relayList: List, onError: (String) -> Unit = {}, context: Context, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt index ad7402f66..51c20e8ae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPostViewModel.kt @@ -473,7 +473,7 @@ open class NewPostViewModel : ViewModel() { urlPreview = findUrlInMessage() } - fun sendPost(relayList: List? = null) { + fun sendPost(relayList: List) { viewModelScope.launch(Dispatchers.IO) { innerSendPost(relayList, null) accountViewModel?.deleteDraft(draftTag) @@ -481,18 +481,18 @@ open class NewPostViewModel : ViewModel() { } } - fun sendDraft(relayList: List? = null) { + fun sendDraft(relayList: List) { viewModelScope.launch { sendDraftSync(relayList) } } - suspend fun sendDraftSync(relayList: List? = null) { + suspend fun sendDraftSync(relayList: List) { innerSendPost(relayList, draftTag) } private suspend fun innerSendPost( - relayList: List? = null, + relayList: List, 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index 055e24eb5..21cdfc6ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -187,6 +187,7 @@ class NewUserMetadataViewModel : ViewModel() { sensitiveContent = null, server = account.settings.defaultFileServer, contentResolver = contentResolver, + forceProxy = account::shouldUseTorForNIP96, onProgress = {}, context = context, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt deleted file mode 100644 index 7c6ffb30d..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/SaveToGallery.kt +++ /dev/null @@ -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)) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/BasicRelaySetupInfoModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/BasicRelaySetupInfoModel.kt index a4f64e1b5..a7623c75d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/BasicRelaySetupInfoModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/BasicRelaySetupInfoModel.kt @@ -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) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt index fd96ea9f7..b58ec3a14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Kind3RelayListViewModel.kt @@ -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) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Nip65RelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Nip65RelayListViewModel.kt index 90ac92b98..516580fd1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Nip65RelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/relays/Nip65RelayListViewModel.kt @@ -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) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt index b8c4294f7..2200eea05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/CashuRedeem.kt @@ -116,7 +116,11 @@ fun CashuPreview( accountViewModel: AccountViewModel, ) { tokens.forEach { - CashuPreviewNew(it, accountViewModel::meltCashu, accountViewModel::toast) + CashuPreviewNew( + it, + accountViewModel::meltCashu, + accountViewModel::toast, + ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 871d35754..e1bdf3587 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -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, videoUri: String, + proxyPort: Int?, defaultToStart: Boolean = false, nostrUriCallback: String? = null, inner: @Composable (controller: MediaController, keepPlaying: MutableState) -> 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) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index 167adc4ed..2862e80d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -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) }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt index 5760feff6..f71736172 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/DrawerContent.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt index 41b3a7768..37c8209fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ChannelCardCompose.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 1d0014cf6..a36f40f91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt index b03a7d7a3..3dbbaa5f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/DisplayReward.kt @@ -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("") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt index 54f37f94b..730ad697d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivity.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt index 778dbf7b7..8df699705 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt index 129ad0ebb..10a5f21d1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/SharedPreferencesViewModel.kt @@ -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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 51a614025..d2432f926 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt deleted file mode 100644 index 491d31fcc..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/ConnectOrbotDialog.kt +++ /dev/null @@ -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) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt index f8a7a2337..5d6683576 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chatrooms/ChannelScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 8401acb49..4b3d98539 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 03727c61f..45e4e4cea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -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, 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() + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt index 963fa6969..99ef50f22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginScreen.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/OrbotCheckBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/OrbotCheckBox.kt index 4ac380941..9ff26f26c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/OrbotCheckBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/OrbotCheckBox.kt @@ -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, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt index 9af95c790..d24cfe66d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/SignUpScreen.kt @@ -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) } }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt new file mode 100644 index 000000000..938337d50 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt @@ -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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt new file mode 100644 index 000000000..5272101ee --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt similarity index 50% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt index 0678a92c5..a65b02af4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/previews/BahaUrlPreview.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt @@ -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 + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt new file mode 100644 index 000000000..c4b6e19ab --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt @@ -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() + + 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, +) { + SettingsRow( + name, + desc, + ) { + Switch(checked.value, onCheckedChange = { checked.value = it }) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt new file mode 100644 index 000000000..9e4da606c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt @@ -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 = MutableStateFlow(TorType.INTERNAL), + val externalSocksPort: MutableStateFlow = MutableStateFlow(9050), + val onionRelaysViaTor: MutableStateFlow = MutableStateFlow(true), + val dmRelaysViaTor: MutableStateFlow = MutableStateFlow(true), + val newRelaysViaTor: MutableStateFlow = MutableStateFlow(true), + val trustedRelaysViaTor: MutableStateFlow = MutableStateFlow(false), + val urlPreviewsViaTor: MutableStateFlow = MutableStateFlow(false), + val profilePicsViaTor: MutableStateFlow = MutableStateFlow(false), + val imagesViaTor: MutableStateFlow = MutableStateFlow(false), + val videosViaTor: MutableStateFlow = MutableStateFlow(false), + val moneyOperationsViaTor: MutableStateFlow = MutableStateFlow(false), + val nip05VerificationsViaTor: MutableStateFlow = MutableStateFlow(false), + val nip96UploadsViaTor: MutableStateFlow = 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), + ) + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 619f21439..f69c77c35 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -124,8 +124,10 @@ LN Address LN URL (outdated) Save to Gallery - Image saved to the gallery + Image saved to the phone\'s photo gallery Failed to save the image + Video saved to the phone\'s video gallery + Failed to save the video Upload Image Uploading… User does not have a lightning address set up to receive sats @@ -412,6 +414,8 @@ All Follows Global Mute List + + Default port is 9050 ## 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 Orbot Socks Port + + Active Tor Engine + Use the internal version or Orbot + + Onion Relays + Enables .onion urls from your relay list + + DM Relays + Force Tor to send and receive DMs + + Untrusted Relays + Force Tor on outbox/inbox relays + + Trusted Relays + Force Tor on all relays in your lists + + Profile Pictures + Force Tor when loading profile pictures + + URL Previews + Force Tor when loading url previews + + Images + Force Tor when loading images + + Videos + Force Tor when loading videos + + Money Operations + Force Tor on zaps, lightning and cashu transfers + + Nostr Address Verification + Force Tor when verifying NIP-05 addresses + + Media Uploads + Force Tor when uploading content + + Internal + Orbot + Off + Invalid port number Use Orbot Disconnect Tor/Orbot diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Client.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Client.kt index d93dd45f0..47d05e51a 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Client.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Client.kt @@ -44,10 +44,10 @@ object Client : RelayPool.Listener { @Synchronized fun reconnect( - relays: Array?, + relays: Array?, 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?): Boolean { + fun isSameRelaySetConfig(newRelayConfig: Array?): 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? = null, relayList: List? = 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? = null, - relayList: List? = 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, + ) { + checkNotInMainThread() + + RelayPool.sendToSelectedRelays(relayList, signedEvent) + } + fun sendPrivately( signedEvent: EventInterface, - relayList: List, + relayList: List, ) { checkNotInMainThread() - relayList.forEach { relayUrl -> - RelayPool.getOrCreateRelay(relayUrl, emptySet(), { }) { + relayList.forEach { relayTemplate -> + RelayPool.getOrCreateRelay(relayTemplate, { }) { it.sendOverride(signedEvent) } } diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt index fe08e6395..79878a561 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Constants.kt @@ -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 = 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 diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt index 097d7a563..694a2472b 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/Relay.kt @@ -56,6 +56,7 @@ class Relay( val url: String, val read: Boolean = true, val write: Boolean = true, + val forceProxy: Boolean = false, val activeTypes: Set, ) { 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 diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt index 40e02dbe5..b22bfec38 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelayPool.kt @@ -58,20 +58,22 @@ object RelayPool : Relay.Listener { fun getAll() = relays fun getOrCreateRelay( - url: String, - feedTypes: Set? = 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?, 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) { - 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() { diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelaySetupInfo.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelaySetupInfo.kt index 6c18edca8..0803ba270 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelaySetupInfo.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/relays/RelaySetupInfo.kt @@ -29,3 +29,12 @@ data class RelaySetupInfo( val write: Boolean, val feedTypes: Set, ) + +@Immutable +data class RelaySetupInfoToConnect( + val url: String, + val forceProxy: Boolean, + val read: Boolean, + val write: Boolean, + val feedTypes: Set, +) diff --git a/ammolite/src/main/java/com/vitorpamplona/ammolite/service/HttpClientManager.kt b/ammolite/src/main/java/com/vitorpamplona/ammolite/service/HttpClientManager.kt index 62e907a61..3a48c4fcf 100644 --- a/ammolite/src/main/java/com/vitorpamplona/ammolite/service/HttpClientManager.kt +++ b/ammolite/src/main/java/com/vitorpamplona/ammolite/service/HttpClientManager.kt @@ -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))) + } } diff --git a/ammolite/src/test/java/com/vitorpamplona/ammolite/HttpClientHelperTest.kt b/ammolite/src/test/java/com/vitorpamplona/ammolite/HttpClientHelperTest.kt deleted file mode 100644 index e13e41ea3..000000000 --- a/ammolite/src/test/java/com/vitorpamplona/ammolite/HttpClientHelperTest.kt +++ /dev/null @@ -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) - } -} diff --git a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip47WalletConnect.kt b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip47WalletConnect.kt index ce77cf6c8..7916615f8 100644 --- a/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip47WalletConnect.kt +++ b/quartz/src/main/java/com/vitorpamplona/quartz/encoders/Nip47WalletConnect.kt @@ -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?, ) }