- Separates Application dependencies into an AppModules class to create only after the OnCreate event.

- Switches TorSettings to be per Application and not per Account anymore
- Since TorSettings is now global, moves the okHttpClient determinations out of the Account-based classes into the Application.
- Since TorSettings is now global, set's up Coil's image loader only once when creating the Application
- Moves UISettings state to App Modules instead of viewmodel
- Migrates TorSettings and UISettings to DataStore
- Accounts now have their own coroutine scopes to allow cancellation when they are unloaded/logged off
- New tor evaluator service for relay connections now uses all account's trusted relays and dm relays at the same time.
- Migrates composable-state-based UISettings to Flow-based UI settings, while observing connectivity status
- Removes the displayFeatures and windowSizeClass from the shared model
- Fixes not requesting Notification Permissions for APIs older than Tiramisu in the FDroid flavor
- Moves the NIP-11 document cache from singleton to the App Modules
- Avoids using AccountViewModel to check NIP-11 Relay documents
- Moves the UI Settings usage in composables to functions that do not observe the state since they don't need to refresh the screen when changed.
- Refactors UI Settings screen to separate components and remove the sharedViewModel
- Only starts Internal Tor if that option is selected in the TorSettings.
- Turn TorSettings into a data class to observe changes to it
- Drops the SharedPreferences ViewModel to use UISettingsFlow directly from App Modules
- Reorganizes OTS Events after simplification of the OkHttp based on TorSettings.
This commit is contained in:
Vitor Pamplona
2025-09-11 18:04:10 -04:00
parent a078df6159
commit 1f8d0295d5
94 changed files with 1890 additions and 1386 deletions
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.Manifest
import android.os.Build
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
@@ -36,6 +38,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -44,8 +47,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
import com.halilibo.richtext.commonmark.MarkdownParseOptions
import com.halilibo.richtext.markdown.BasicMarkdown
@@ -53,11 +58,10 @@ import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.material3.RichText
import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.checkifItNeedsToRequestNotificationPermission
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
@@ -65,12 +69,31 @@ import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val notificationPermissionState =
checkifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
val notificationPermissionGranted =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
if (notificationPermissionState.status.isGranted) {
if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) {
if (!notificationPermissionState.status.isGranted) {
val dontAskForNotificationPermissions by sharedPrefs.dontAskForNotificationPermissions.collectAsStateWithLifecycle()
if (!dontAskForNotificationPermissions) {
sharedPrefs.dontAskForNotificationPermissions()
// This will pause the APP, including the connection with relays.
LaunchedEffect(notificationPermissionState) {
notificationPermissionState.launchPermissionRequest()
}
}
}
notificationPermissionState.status.isGranted
} else {
true
}
if (notificationPermissionGranted) {
val pushNotificationSelector by sharedPrefs.dontShowPushNotificationSelector.collectAsStateWithLifecycle()
if (!pushNotificationSelector) {
val context = LocalContext.current
var distributorPresent by remember {
mutableStateOf(PushDistributorHandler.savedDistributorExists())
@@ -84,8 +107,8 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
onSelect = { index ->
if (list[index] == "None") {
PushDistributorHandler.forceRemoveDistributor(context)
sharedPreferencesViewModel.dontAskForNotificationPermissions()
sharedPreferencesViewModel.dontShowPushNotificationSelector()
sharedPrefs.dontAskForNotificationPermissions()
sharedPrefs.dontShowPushNotificationSelector()
} else {
val fullDistributorName = list[index]
PushDistributorHandler.saveDistributor(fullDistributorName)
@@ -125,7 +148,7 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
TextButton(
onClick = {
distributorPresent = true
sharedPreferencesViewModel.dontShowPushNotificationSelector()
sharedPrefs.dontShowPushNotificationSelector()
},
) {
Text(stringRes(R.string.quick_action_dont_show_again_button))
@@ -187,7 +210,7 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, Immuta
}
@Composable
fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) {
fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {
val context = LocalContext.current
LoadDistributors { currentDistributor, list, readableListWithExplainer ->
@@ -198,8 +221,8 @@ fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesVie
selectedIndex = list.indexOf(currentDistributor),
) { index ->
if (list[index] == "None") {
sharedPreferencesViewModel.dontAskForNotificationPermissions()
sharedPreferencesViewModel.dontShowPushNotificationSelector()
sharedPrefs.dontAskForNotificationPermissions()
sharedPrefs.dontShowPushNotificationSelector()
PushDistributorHandler.forceRemoveDistributor(context)
} else {
PushDistributorHandler.saveDistributor(list[index])
@@ -21,210 +21,33 @@
package com.vitorpamplona.amethyst
import android.app.Application
import android.content.ContentResolver
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
import com.vitorpamplona.amethyst.service.crashreports.UnexpectedCrashSaver
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.okhttp.ProxySettingsAnchor
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav.scope
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OtsBlockHeightCache
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.io.File
class Amethyst : Application() {
val appAgent = "Amethyst/${BuildConfig.VERSION_NAME}"
// Exists to avoid exceptions stopping the coroutine
val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable)
}
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
// Key cache service to download and decrypt encrypted files before caching them.
val keyCache = EncryptionKeyCache()
// App services that should be run as soon as there are subscribers to their flows
val locationManager = LocationState(this, applicationIOScope)
val connManager = ConnectivityManager(this, applicationIOScope)
val torManager = TorManager(this, applicationIOScope)
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
// manages all the other connections separately from relays.
val okHttpClients =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
keyCache = keyCache,
scope = applicationIOScope,
)
// manages all relay connections
val okHttpClientForRelays =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
keyCache = keyCache,
scope = applicationIOScope,
)
// manages all relay connections
val okHttpClientForRelaysForDms =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
keyCache = keyCache,
scope = applicationIOScope,
)
val torProxySettingsAnchor = ProxySettingsAnchor()
// Connects the NostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder { url ->
val useTor = torProxySettingsAnchor.useProxy(url)
if (torProxySettingsAnchor.isDM(url)) {
okHttpClientForRelaysForDms.getHttpClient(useTor)
} else {
okHttpClientForRelays.getHttpClient(useTor)
}
}
// Caches all events in Memory
val cache: LocalCache = LocalCache
// Organizes cache clearing
val trimmingService = MemoryTrimmingService(cache)
// Provides a relay pool
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope)
// Watches for changes on Tor and Relay List Settings
val relayProxyClientConnector = RelayProxyClientConnector(torProxySettingsAnchor, okHttpClients, connManager, client, torManager, applicationIOScope)
// Verifies and inserts in the cache from all relays, all subscriptions
val cacheClientConnector = CacheClientConnector(client, cache)
// Show messages from the Relay and controls their dismissal
val notifyCoordinator = NotifyCoordinator(client)
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope)
val logger = if (isDebug) RelaySpeedLogger(client) else null
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationIOScope)
// keeps all accounts live
val accountsCache =
AccountCacheState(
geolocationFlow = locationManager.geohashStateFlow,
nwcFilterAssembler = sources.nwc,
contentResolverFn = ::contentResolverFn,
cache = cache,
client = client,
)
// saves the .content of NIP-95 blobs in disk to save memory
val nip95cache: File by lazy { Nip95CacheFactory.new(this) }
// local video cache with disk + memory
val videoCache: VideoCache by lazy { VideoCacheFactory.new(this) }
// image cache in disk for coil
val diskCache: DiskCache by lazy { ImageCacheFactory.newDisk(this) }
// image cache in memory for coil
val memoryCache: MemoryCache by lazy { ImageCacheFactory.newMemory(this) }
val crashReportCache: CrashReportCache by lazy { CrashReportCache(this) }
// Application-wide ots verification cache
val otsVerifCache by lazy { VerificationStateCache() }
// Application-wide block height request cache
val otsBlockHeightCache by lazy { OtsBlockHeightCache() }
companion object {
lateinit var instance: AppModules
private set
}
override fun onCreate() {
super.onCreate()
Log.d("AmethystApp", "onCreate $this")
instance = this
Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope))
instance = AppModules(this)
if (isDebug) {
Logging.setup()
}
// initializes diskcache on an IO thread.
applicationIOScope.launch {
diskCache
videoCache
}
// registers to receive events
pokeyReceiver.register(this)
instance.initiate(this)
}
override fun onTerminate() {
super.onTerminate()
Log.d("AmethystApp", "onTerminate $this")
pokeyReceiver.unregister(this)
applicationIOScope.cancel("Application onTerminate $this")
instance.terminate(this)
}
fun contentResolverFn(): ContentResolver = contentResolver
fun setImageLoader(shouldUseTor: (String) -> Boolean?) {
ImageLoaderSetup.setup(this, diskCache, memoryCache) { url ->
shouldUseTor(url)?.let { okHttpClients.getHttpClient(it) } ?: okHttpClients.getHttpClient(false)
}
}
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(instance, npub)
/**
* Release memory when the UI becomes hidden or when system resources become low.
*
@@ -233,13 +56,6 @@ class Amethyst : Application() {
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
Log.d("AmethystApp", "onTrimMemory $level")
applicationIOScope.launch(Dispatchers.Default) {
trimmingService.run(null, LocalPreferences.allSavedAccounts())
}
}
companion object {
lateinit var instance: Amethyst
private set
instance.trim()
}
}
@@ -0,0 +1,289 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst
import android.content.ContentResolver
import android.content.Context
import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.crashreports.CrashReportCache
import com.vitorpamplona.amethyst.service.crashreports.UnexpectedCrashSaver
import com.vitorpamplona.amethyst.service.eventCache.MemoryTrimmingService
import com.vitorpamplona.amethyst.service.images.ImageCacheFactory
import com.vitorpamplona.amethyst.service.images.ImageLoaderSetup
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.notifications.PokeyReceiver
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OkHttpOtsResolverBuilder
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OtsBlockHeightCache
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.io.File
import kotlin.run
class AppModules(
val appContext: Context,
) {
val appAgent = "Amethyst/${BuildConfig.VERSION_NAME}"
// Exists to avoid exceptions stopping the coroutine
val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable)
}
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
val applicationDefaultScope = CoroutineScope(Dispatchers.Default + SupervisorJob() + exceptionHandler)
// Blocking load of UI Preferences to avoid theme/language blinking
val uiPrefs by lazy {
UiSharedPreferences(appContext, applicationIOScope)
}
// Blocking load of Tor Settings to avoid connection leaks
val torPrefs by lazy {
TorSharedPreferences(appContext, applicationIOScope)
}
// App services that should be run as soon as there are subscribers to their flows
val locationManager = LocationState(appContext, applicationIOScope)
val connManager = ConnectivityManager(appContext, applicationIOScope)
val uiState by lazy {
UiSettingsState(uiPrefs.value, connManager.isMobileOrFalse, applicationIOScope)
}
val torManager = TorManager(torPrefs, appContext, applicationIOScope)
// Service that will run at all times to receive events from Pokey
val pokeyReceiver = PokeyReceiver()
// Key cache service to download and decrypt encrypted files before caching them.
val keyCache = EncryptionKeyCache()
// manages all the other connections separately from relays.
val okHttpClients =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
keyCache = keyCache,
scope = applicationIOScope,
)
// manages all relay connections
val okHttpClientForRelays =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
keyCache = keyCache,
scope = applicationIOScope,
)
// manages all relay connections
val okHttpClientForRelaysForDms =
DualHttpClientManager(
userAgent = appAgent,
proxyPortProvider = torManager.activePortOrNull,
isMobileDataProvider = connManager.isMobileOrNull,
keyCache = keyCache,
scope = applicationIOScope,
)
// Offers easy methods to know when connections are happening through Tor or not
val roleBasedHttpClientBuilder = RoleBasedHttpClientBuilder(okHttpClients, torPrefs.value)
// Application-wide block height request cache
val otsBlockHeightCache by lazy { OtsBlockHeightCache() }
val otsResolverBuilder: OkHttpOtsResolverBuilder =
OkHttpOtsResolverBuilder(
roleBasedHttpClientBuilder::okHttpClientForMoney,
roleBasedHttpClientBuilder::shouldUseTorForMoneyOperations,
otsBlockHeightCache,
)
// Application-wide ots verification cache
val otsVerifCache by lazy { VerificationStateCache(otsResolverBuilder) }
val torEvaluatorFlow =
TorRelayState(
okHttpClients,
torPrefs.value,
applicationDefaultScope,
)
// Connects the NostrClient class with okHttp
val websocketBuilder =
OkHttpWebSocket.Builder { url ->
val useTor = torEvaluatorFlow.flow.value.useTor(url)
if (url in torEvaluatorFlow.flow.value.dmRelayList) {
okHttpClientForRelaysForDms.getHttpClient(useTor)
} else {
okHttpClientForRelays.getHttpClient(useTor)
}
}
// Caches all events in Memory
val cache: LocalCache = LocalCache
// Organizes cache clearing
val trimmingService = MemoryTrimmingService(cache)
// Provides a relay pool
val client: INostrClient = NostrClient(websocketBuilder, applicationDefaultScope)
// Watches for changes on Tor and Relay List Settings
val relayProxyClientConnector = RelayProxyClientConnector(torEvaluatorFlow.flow, okHttpClients, connManager, client, torManager, applicationDefaultScope)
// Verifies and inserts in the cache from all relays, all subscriptions
val cacheClientConnector = CacheClientConnector(client, cache)
// Show messages from the Relay and controls their dismissal
val notifyCoordinator = NotifyCoordinator(client)
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationDefaultScope)
// Tries to verify new OTS events when they arrive.
val otsEventVerifier = IncomingOtsEventVerifier(otsVerifCache, cache, applicationDefaultScope)
val logger = if (isDebug) RelaySpeedLogger(client) else null
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator = RelaySubscriptionsCoordinator(LocalCache, client, applicationDefaultScope)
// keeps all accounts live
val accountsCache =
AccountCacheState(
geolocationFlow = locationManager.geohashStateFlow,
nwcFilterAssembler = sources.nwc,
contentResolverFn = ::contentResolverFn,
otsResolverBuilder = otsResolverBuilder,
cache = cache,
client = client,
)
// as new accounts are loaded, updates the state of the TorRelaySettings, which produces new TorRelayEvaluator
// and reconnects relays if the configuration has been changed.
val accountsTorStateConnector = AccountsTorStateConnector(accountsCache, torEvaluatorFlow, applicationDefaultScope)
// saves the .content of NIP-95 blobs in disk to save memory
val nip95cache: File by lazy { Nip95CacheFactory.new(appContext) }
// local video cache with disk + memory
val videoCache: VideoCache by lazy { VideoCacheFactory.new(appContext) }
// image cache in disk for coil
val diskCache: DiskCache by lazy { ImageCacheFactory.newDisk(appContext) }
// image cache in memory for coil
val memoryCache: MemoryCache by lazy { ImageCacheFactory.newMemory(appContext) }
// crash report storage
val crashReportCache: CrashReportCache by lazy { CrashReportCache(appContext) }
// cache for NIP-11 documents
val nip11Cache: Nip11CachedRetriever by lazy {
Nip11CachedRetriever(torEvaluatorFlow::okHttpClientForRelay)
}
fun contentResolverFn(): ContentResolver = appContext.contentResolver
fun setImageLoader() {
ImageLoaderSetup.setup(appContext, diskCache, memoryCache) { url ->
okHttpClients.getHttpClient(roleBasedHttpClientBuilder.shouldUseTorForImageDownload(url))
}
}
fun encryptedStorage(npub: String? = null): EncryptedSharedPreferences = EncryptedStorage.preferences(appContext, npub)
fun initiate(appContext: Context) {
Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope))
// forces initialization of uiPrefs in the main thread to avoid blinking themes
uiPrefs
// initializes diskcache on an IO thread.
applicationIOScope.launch {
// preloads tor preferences
torPrefs
// Sets Coil - Tor - OkHttp link
setImageLoader()
// prepares coil's disk cache
diskCache
// prepares exoplayer's disk cache
videoCache
}
// registers to receive events
pokeyReceiver.register(appContext)
}
fun terminate(appContext: Context) {
pokeyReceiver.unregister(appContext)
applicationIOScope.cancel("Application onTerminate $appContext")
applicationDefaultScope.cancel("Application onTerminate $appContext")
accountsCache.clear()
}
fun trim() {
applicationDefaultScope.launch {
trimmingService.run(null, LocalPreferences.allSavedAccounts())
}
}
}
@@ -30,12 +30,10 @@ import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.model.ALL_FOLLOWS
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -210,7 +208,7 @@ object LocalPreferences {
}
private val prefsDirPath: String
get() = "${Amethyst.instance.filesDir.parent}/shared_prefs/"
get() = "${Amethyst.instance.appContext.filesDir.parent}/shared_prefs/"
private suspend fun addAccount(accInfo: AccountInfo) {
val accounts = savedAccounts().filter { it.npub != accInfo.npub }.plus(accInfo)
@@ -255,7 +253,7 @@ object LocalPreferences {
return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
val preferenceFile =
if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub"
Amethyst.instance.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
Amethyst.instance.appContext.getSharedPreferences(preferenceFile, Context.MODE_PRIVATE)
} else {
Amethyst.instance.encryptedStorage(npub)
}
@@ -475,8 +473,6 @@ object LocalPreferences {
remove(PrefKeys.USE_PROXY)
remove(PrefKeys.PROXY_PORT)
putString(PrefKeys.TOR_SETTINGS, JsonMapper.mapper.writeValueAsString(settings.torSettings.toSettings()))
val regularMap =
settings.lastReadPerRoute.value.mapValues {
it.value.value
@@ -501,7 +497,7 @@ object LocalPreferences {
suspend fun loadAccountConfigFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadAccountConfigFromEncryptedStorage(it) }
suspend fun saveSharedSettings(
sharedSettings: Settings,
sharedSettings: UiSettings,
prefs: SharedPreferences = encryptedPreferences(),
) {
Log.d("LocalPreferences", "Saving to shared settings")
@@ -510,11 +506,13 @@ object LocalPreferences {
}
}
suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): Settings? {
suspend fun loadSharedSettings(prefs: SharedPreferences = encryptedPreferences()): UiSettings? {
Log.d("LocalPreferences", "Load shared settings")
with(prefs) {
return try {
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let { JsonMapper.mapper.readValue<Settings>(it) }
getString(PrefKeys.SHARED_SETTINGS, "{}")?.let {
JsonMapper.mapper.readValue<UiSettings>(it)
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
Log.w(
@@ -598,8 +596,6 @@ object LocalPreferences {
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val torSettings = parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
val lastReadPerRoute =
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
MutableStateFlow(it.value)
@@ -637,7 +633,6 @@ object LocalPreferences {
backupHashtagList = latestHashtagList,
backupGeohashList = latestGeohashList,
backupEphemeralChatList = latestEphemeralList,
torSettings = TorSettingsFlow.build(torSettings),
lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
pendingAttestations = MutableStateFlow(pendingAttestations),
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.model
import android.util.Log
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -80,7 +79,6 @@ import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState
import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
import com.vitorpamplona.amethyst.model.privacyOptions.PrivacyState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState
import com.vitorpamplona.amethyst.model.serverList.MergedServerListState
@@ -89,7 +87,6 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches
import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader
@@ -136,8 +133,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OkHttpOtsResolverBuilder
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder
import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
@@ -226,7 +222,7 @@ class Account(
val signer: NostrSigner,
val geolocationFlow: StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: NWCPaymentFilterAssembler,
val otsVerifCache: VerificationStateCache,
val otsResolverBuilder: OtsResolverBuilder,
val cache: LocalCache,
val client: INostrClient,
val scope: CoroutineScope,
@@ -323,17 +319,6 @@ class Account(
val chatroomList = cache.getOrCreateChatroomList(signer.pubKey)
val privacyState = PrivacyState(settings)
val torRelayState = TorRelayState(trustedRelays, dmRelayList, settings, scope)
val otsResolverBuilder: OkHttpOtsResolverBuilder =
OkHttpOtsResolverBuilder(
{
Amethyst.instance.okHttpClients.getHttpClient(privacyState.shouldUseTorForMoneyOperations(it))
},
privacyState::shouldUseTorForMoneyOperations,
Amethyst.instance.otsBlockHeightCache,
)
val newNotesPreProcessor = EventProcessor(this, cache)
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings)
@@ -24,8 +24,6 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -132,7 +130,6 @@ class AccountSettings(
var backupHashtagList: HashtagListEvent? = null,
var backupGeohashList: GeohashListEvent? = null,
var backupEphemeralChatList: EphemeralChatListEvent? = null,
val torSettings: TorSettingsFlow = TorSettingsFlow(),
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()),
@@ -249,17 +246,6 @@ class AccountSettings(
}
}
// ---
// proxy settings
// ---
fun setTorSettings(newTorSettings: TorSettings): Boolean =
if (torSettings.update(newTorSettings)) {
saveAccountSettings()
true
} else {
false
}
// ---
// language services
// ---
@@ -24,7 +24,7 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R
@Stable
data class Settings(
data class UiSettings(
val theme: ThemeType = ThemeType.SYSTEM,
val preferredLanguage: String? = null,
val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS,
@@ -0,0 +1,165 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
class UiSettingsFlow(
val theme: MutableStateFlow<ThemeType> = MutableStateFlow(ThemeType.SYSTEM),
val preferredLanguage: MutableStateFlow<String?> = MutableStateFlow(null),
val automaticallyShowImages: MutableStateFlow<ConnectivityType> = MutableStateFlow(ConnectivityType.ALWAYS),
val automaticallyStartPlayback: MutableStateFlow<ConnectivityType> = MutableStateFlow(ConnectivityType.ALWAYS),
val automaticallyShowUrlPreview: MutableStateFlow<ConnectivityType> = MutableStateFlow(ConnectivityType.ALWAYS),
val automaticallyHideNavigationBars: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val automaticallyShowProfilePictures: MutableStateFlow<ConnectivityType> = MutableStateFlow(ConnectivityType.ALWAYS),
val dontShowPushNotificationSelector: MutableStateFlow<Boolean> = MutableStateFlow(false),
val dontAskForNotificationPermissions: MutableStateFlow<Boolean> = MutableStateFlow(false),
val featureSet: MutableStateFlow<FeatureSetType> = MutableStateFlow(FeatureSetType.SIMPLIFIED),
val gallerySet: MutableStateFlow<ProfileGalleryType> = MutableStateFlow(ProfileGalleryType.CLASSIC),
) {
// emits at every change in any of the propertyes.
val propertyWatchFlow =
combine(
listOf(
theme,
preferredLanguage,
automaticallyShowImages,
automaticallyStartPlayback,
automaticallyShowUrlPreview,
automaticallyHideNavigationBars,
automaticallyShowProfilePictures,
dontShowPushNotificationSelector,
dontAskForNotificationPermissions,
featureSet,
gallerySet,
),
) { flows ->
UiSettings(
flows[0] as ThemeType,
flows[1] as String?,
flows[2] as ConnectivityType,
flows[3] as ConnectivityType,
flows[4] as ConnectivityType,
flows[5] as BooleanType,
flows[6] as ConnectivityType,
flows[7] as Boolean,
flows[8] as Boolean,
flows[9] as FeatureSetType,
flows[10] as ProfileGalleryType,
)
}
fun toSettings(): UiSettings =
UiSettings(
theme.value,
preferredLanguage.value,
automaticallyShowImages.value,
automaticallyStartPlayback.value,
automaticallyShowUrlPreview.value,
automaticallyHideNavigationBars.value,
automaticallyShowProfilePictures.value,
dontShowPushNotificationSelector.value,
dontAskForNotificationPermissions.value,
featureSet.value,
gallerySet.value,
)
fun update(torSettings: UiSettings): Boolean {
var any = false
if (theme.value != torSettings.theme) {
theme.tryEmit(torSettings.theme)
any = true
}
if (preferredLanguage.value != torSettings.preferredLanguage) {
preferredLanguage.tryEmit(torSettings.preferredLanguage)
any = true
}
if (automaticallyShowImages.value != torSettings.automaticallyShowImages) {
automaticallyShowImages.tryEmit(torSettings.automaticallyShowImages)
any = true
}
if (automaticallyStartPlayback.value != torSettings.automaticallyStartPlayback) {
automaticallyStartPlayback.tryEmit(torSettings.automaticallyStartPlayback)
any = true
}
if (automaticallyShowUrlPreview.value != torSettings.automaticallyShowUrlPreview) {
automaticallyShowUrlPreview.tryEmit(torSettings.automaticallyShowUrlPreview)
any = true
}
if (automaticallyHideNavigationBars.value != torSettings.automaticallyHideNavigationBars) {
automaticallyHideNavigationBars.tryEmit(torSettings.automaticallyHideNavigationBars)
any = true
}
if (automaticallyShowProfilePictures.value != torSettings.automaticallyShowProfilePictures) {
automaticallyShowProfilePictures.tryEmit(torSettings.automaticallyShowProfilePictures)
any = true
}
if (dontShowPushNotificationSelector.value != torSettings.dontShowPushNotificationSelector) {
dontShowPushNotificationSelector.tryEmit(torSettings.dontShowPushNotificationSelector)
any = true
}
if (dontAskForNotificationPermissions.value != torSettings.dontAskForNotificationPermissions) {
dontAskForNotificationPermissions.tryEmit(torSettings.dontAskForNotificationPermissions)
any = true
}
if (featureSet.value != torSettings.featureSet) {
featureSet.tryEmit(torSettings.featureSet)
any = true
}
if (gallerySet.value != torSettings.gallerySet) {
gallerySet.tryEmit(torSettings.gallerySet)
any = true
}
return any
}
fun dontShowPushNotificationSelector() {
if (!dontShowPushNotificationSelector.value) {
dontShowPushNotificationSelector.tryEmit(true)
}
}
fun dontAskForNotificationPermissions() {
if (!dontAskForNotificationPermissions.value) {
dontAskForNotificationPermissions.tryEmit(true)
}
}
companion object {
fun build(torSettings: UiSettings): UiSettingsFlow =
UiSettingsFlow(
MutableStateFlow(torSettings.theme),
MutableStateFlow(torSettings.preferredLanguage),
MutableStateFlow(torSettings.automaticallyShowImages),
MutableStateFlow(torSettings.automaticallyStartPlayback),
MutableStateFlow(torSettings.automaticallyShowUrlPreview),
MutableStateFlow(torSettings.automaticallyHideNavigationBars),
MutableStateFlow(torSettings.automaticallyShowProfilePictures),
MutableStateFlow(torSettings.dontShowPushNotificationSelector),
MutableStateFlow(torSettings.dontAskForNotificationPermissions),
MutableStateFlow(torSettings.featureSet),
MutableStateFlow(torSettings.gallerySet),
)
}
}
@@ -32,7 +32,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder
import com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -47,6 +47,7 @@ class AccountCacheState(
val geolocationFlow: StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: NWCPaymentFilterAssembler,
val contentResolverFn: () -> ContentResolver,
val otsResolverBuilder: OtsResolverBuilder,
val cache: LocalCache,
val client: INostrClient,
) {
@@ -91,7 +92,7 @@ class AccountCacheState(
signer = signer,
geolocationFlow = geolocationFlow,
nwcFilterAssembler = nwcFilterAssembler,
otsVerifCache = VerificationStateCache(),
otsResolverBuilder = otsResolverBuilder,
cache = cache,
client = client,
scope =
@@ -0,0 +1,52 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip11RelayInfo
import android.util.Log
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
@Composable
fun loadRelayInfo(relay: NormalizedRelayUrl): State<Nip11RelayInformation> = loadRelayInfo(relay, Amethyst.instance.nip11Cache)
@Composable
fun loadRelayInfo(
relay: NormalizedRelayUrl,
nip11Cache: Nip11CachedRetriever,
): State<Nip11RelayInformation> =
produceState(
nip11Cache.getFromCache(relay),
relay,
) {
nip11Cache.loadRelayInfo(
relay = relay,
onInfo = {
value = it
},
onError = { url, errorCode, exceptionMessage ->
Log.e("RelayInfo", "Error loading relay info for ${relay.url}: $errorCode - $exceptionMessage")
},
)
}
@@ -18,51 +18,21 @@
* 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
package com.vitorpamplona.amethyst.model.nip11RelayInfo
import android.util.Log
import android.util.LruCache
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
object Nip11CachedRetriever {
sealed class RetrieveResult(
val data: Nip11RelayInformation,
val time: Long,
) {
class Error(
data: Nip11RelayInformation,
val error: Nip11Retriever.ErrorCode,
val msg: String? = null,
) : RetrieveResult(data, TimeUtils.now())
class Success(
data: Nip11RelayInformation,
) : RetrieveResult(data, TimeUtils.now())
class Loading(
data: Nip11RelayInformation,
) : RetrieveResult(data, TimeUtils.now())
class Empty(
data: Nip11RelayInformation,
) : RetrieveResult(data, TimeUtils.now())
fun isValid() = time > TimeUtils.oneHourAgo()
}
class Nip11CachedRetriever(
val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient,
) {
private val relayInformationEmptyCache = LruCache<NormalizedRelayUrl, Nip11RelayInformation>(1000)
private val relayInformationDocumentCache = LruCache<NormalizedRelayUrl, RetrieveResult?>(1000)
private val retriever = Nip11Retriever()
private val retriever = Nip11Retriever(okHttpClient)
fun getEmpty(relay: NormalizedRelayUrl): Nip11RelayInformation {
relayInformationEmptyCache.get(relay)?.let { return it }
@@ -98,7 +68,6 @@ object Nip11CachedRetriever {
suspend fun loadRelayInfo(
relay: NormalizedRelayUrl,
okHttpClient: (String) -> OkHttpClient,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
@@ -110,33 +79,31 @@ object Nip11CachedRetriever {
if (doc.isValid()) {
// just wait.
} else {
retrieve(relay, okHttpClient, onInfo, onError)
retrieve(relay, onInfo, onError)
}
}
is RetrieveResult.Error -> {
if (doc.isValid()) {
onError(relay, doc.error, null)
} else {
retrieve(relay, okHttpClient, onInfo, onError)
retrieve(relay, onInfo, onError)
}
}
is RetrieveResult.Empty -> retrieve(relay, okHttpClient, onInfo, onError)
is RetrieveResult.Empty -> retrieve(relay, onInfo, onError)
}
} else {
retrieve(relay, okHttpClient, onInfo, onError)
retrieve(relay, onInfo, onError)
}
}
private suspend fun retrieve(
relay: NormalizedRelayUrl,
okHttpClient: (String) -> OkHttpClient,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
relayInformationDocumentCache.put(relay, RetrieveResult.Loading(getEmpty(relay)))
retriever.loadRelayInfo(
relay = relay,
okHttpClient = okHttpClient,
onInfo = {
relayInformationDocumentCache.put(relay, RetrieveResult.Success(it))
relayInformationEmptyCache.remove(relay)
@@ -150,60 +117,3 @@ object Nip11CachedRetriever {
)
}
}
class Nip11Retriever {
enum class ErrorCode {
FAIL_TO_ASSEMBLE_URL,
FAIL_TO_REACH_SERVER,
FAIL_TO_PARSE_RESULT,
FAIL_WITH_HTTP_STATUS,
}
suspend fun loadRelayInfo(
relay: NormalizedRelayUrl,
okHttpClient: (String) -> OkHttpClient,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit,
) {
val url = relay.toHttp()
try {
val request: Request =
Request
.Builder()
.header("Accept", "application/nostr+json")
.url(url)
.build()
val client = okHttpClient(url)
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
val body = response.body.string()
try {
if (response.isSuccessful) {
if (body.startsWith("{")) {
onInfo(Nip11RelayInformation.fromJson(body))
} else {
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, body)
}
} else {
onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, response.code.toString())
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"RelayInfoFail",
"Resulting Message from Relay ${relay.url} in not parseable: $body",
e,
)
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e)
onError(relay, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
}
}
}
@@ -0,0 +1,90 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip11RelayInfo
import android.util.Log
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
class Nip11Retriever(
val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient,
) {
enum class ErrorCode {
FAIL_TO_ASSEMBLE_URL,
FAIL_TO_REACH_SERVER,
FAIL_TO_PARSE_RESULT,
FAIL_WITH_HTTP_STATUS,
}
suspend fun loadRelayInfo(
relay: NormalizedRelayUrl,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (NormalizedRelayUrl, ErrorCode, String?) -> Unit,
) {
val url = relay.toHttp()
try {
val request: Request =
Request
.Builder()
.header("Accept", "application/nostr+json")
.url(url)
.build()
val client = okHttpClient(relay)
client.newCall(request).executeAsync().use { response ->
withContext(Dispatchers.IO) {
val body = response.body.string()
try {
if (response.isSuccessful) {
if (body.startsWith("{")) {
onInfo(Nip11RelayInformation.fromJson(body))
} else {
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, body)
}
} else {
onError(relay, ErrorCode.FAIL_WITH_HTTP_STATUS, response.code.toString())
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e(
"RelayInfoFail",
"Resulting Message from Relay ${relay.url} in not parseable: $body",
e,
)
onError(relay, ErrorCode.FAIL_TO_PARSE_RESULT, e.message)
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("RelayInfoFail", "Invalid URL ${relay.url}", e)
onError(relay, ErrorCode.FAIL_TO_ASSEMBLE_URL, e.message)
}
}
}
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip11RelayInfo
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.utils.TimeUtils
sealed class RetrieveResult(
val data: Nip11RelayInformation,
val time: Long,
) {
class Error(
data: Nip11RelayInformation,
val error: Nip11Retriever.ErrorCode,
val msg: String? = null,
) : RetrieveResult(data, TimeUtils.now())
class Success(
data: Nip11RelayInformation,
) : RetrieveResult(data, TimeUtils.now())
class Loading(
data: Nip11RelayInformation,
) : RetrieveResult(data, TimeUtils.now())
class Empty(
data: Nip11RelayInformation,
) : RetrieveResult(data, TimeUtils.now())
fun isValid() = time > TimeUtils.oneHourAgo()
}
@@ -0,0 +1,184 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.preferences
import android.content.Context
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Stable
import androidx.core.os.LocaleListCompat
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.quartz.nip01Core.jackson.JsonMapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.runBlocking
val Context.sharedPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(name = "shared_settings")
@Stable
class UiSharedPreferences(
val context: Context,
val scope: CoroutineScope,
) {
companion object {
val UI_SETTINGS = stringPreferencesKey("ui_settings")
}
// UI Preferences. Makes sure to wait for it to avoid blinking themes and language preferences
val value =
runBlocking {
UiSettingsFlow.build(uiPreferences() ?: UiSettings())
}
val languageUpdate =
value.preferredLanguage
.onEach { language ->
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(language),
)
}.flowOn(Dispatchers.Main)
.stateIn(
scope,
SharingStarted.Eagerly,
value.toSettings(),
)
@OptIn(FlowPreview::class)
val saving =
value.propertyWatchFlow
.debounce(1000)
.distinctUntilChanged()
.onEach(::save)
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
value.toSettings(),
)
suspend fun uiPreferences(): UiSettings? =
try {
// Get the preference flow and take the first value.
val preferences = context.sharedPreferencesDataStore.data.first()
val newVersion = preferences[UI_SETTINGS]?.let { JsonMapper.mapper.readValue<UiSettings>(it) }
if (newVersion != null) {
newVersion
} else {
val oldVersion = LocalPreferences.loadSharedSettings()
if (oldVersion != null) {
save(oldVersion)
}
oldVersion
}
} catch (e: Exception) {
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}")
null
}
suspend fun save(sharedSettings: UiSettings) {
try {
val str = JsonMapper.mapper.writeValueAsString(sharedSettings)
context.sharedPreferencesDataStore.edit { preferences ->
preferences[UI_SETTINGS] = str
}
} catch (e: Exception) {
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error saving DataStore preferences: ${e.message}")
}
}
}
@Stable
class TorSharedPreferences(
val context: Context,
val scope: CoroutineScope,
) {
companion object {
val TOR_SETTINGS = stringPreferencesKey("tor_settings")
}
// Tor Preferences. Makes sure to wait for it to avoid connecting with random IPs
val value =
runBlocking {
TorSettingsFlow.build(torPreferences() ?: TorSettings())
}
@OptIn(FlowPreview::class)
val saving =
value.propertyWatchFlow
.debounce(1000)
.distinctUntilChanged()
.onEach(::save)
.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
value.toSettings(),
)
suspend fun torPreferences(): TorSettings? =
try {
// Get the preference flow and take the first value.
val preferences = context.sharedPreferencesDataStore.data.first()
println("AABBCC Loading TorSettings: ${preferences[TOR_SETTINGS]}")
preferences[TOR_SETTINGS]?.let {
println("AABBCC Loading TorSettings: $it")
JsonMapper.mapper.readValue<TorSettings>(it)
}
} catch (e: Exception) {
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error reading DataStore preferences: ${e.message}")
null
}
suspend fun save(torSettings: TorSettings) {
try {
println("AABBCC Saving TorSettings: $torSettings")
val str = JsonMapper.mapper.writeValueAsString(torSettings)
context.sharedPreferencesDataStore.edit { preferences ->
preferences[TOR_SETTINGS] = str
}
} catch (e: Exception) {
// Log any errors that occur while reading the DataStore.
Log.e("SharedPreferences", "Error saving DataStore preferences: ${e.message}")
}
}
}
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.privacyOptions
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
class EmptyRoleBasedHttpClientBuilder : IRoleBasedHttpClientBuilder {
val rootOkHttpClient by lazy {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
}
override fun proxyPortForVideo(url: String) = (rootOkHttpClient.proxy?.address() as? InetSocketAddress)?.port
override fun okHttpClientForNip05(url: String) = rootOkHttpClient
override fun okHttpClientForUploads(url: String) = rootOkHttpClient
override fun okHttpClientForImage(url: String) = rootOkHttpClient
override fun okHttpClientForVideo(url: String) = rootOkHttpClient
override fun okHttpClientForMoney(url: String) = rootOkHttpClient
override fun okHttpClientForPreview(url: String) = rootOkHttpClient
override fun okHttpClientForPushRegistration(url: String) = rootOkHttpClient
}
@@ -0,0 +1,41 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.privacyOptions
import okhttp3.OkHttpClient
interface IRoleBasedHttpClientBuilder {
fun proxyPortForVideo(url: String): Int?
fun okHttpClientForNip05(url: String): OkHttpClient
fun okHttpClientForUploads(url: String): OkHttpClient
fun okHttpClientForImage(url: String): OkHttpClient
fun okHttpClientForVideo(url: String): OkHttpClient
fun okHttpClientForMoney(url: String): OkHttpClient
fun okHttpClientForPreview(url: String): OkHttpClient
fun okHttpClientForPushRegistration(url: String): OkHttpClient
}
@@ -20,18 +20,21 @@
*/
package com.vitorpamplona.amethyst.model.privacyOptions
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.ui.tor.TorType
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import okhttp3.OkHttpClient
class PrivacyState(
val settings: AccountSettings,
) {
class RoleBasedHttpClientBuilder(
val okHttpClient: DualHttpClientManager,
val torSettings: TorSettingsFlow,
) : IRoleBasedHttpClientBuilder {
fun shouldUseTorForImageDownload(url: String) =
shouldUseTorFor(
url,
settings.torSettings.torType.value,
settings.torSettings.imagesViaTor.value,
torSettings.torType.value,
torSettings.imagesViaTor.value,
)
fun shouldUseTorFor(
@@ -57,37 +60,37 @@ class PrivacyState(
}
fun shouldUseTorForVideoDownload() =
when (settings.torSettings.torType.value) {
when (torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> settings.torSettings.videosViaTor.value
TorType.EXTERNAL -> settings.torSettings.videosViaTor.value
TorType.INTERNAL -> torSettings.videosViaTor.value
TorType.EXTERNAL -> torSettings.videosViaTor.value
}
fun shouldUseTorForVideoDownload(url: String) =
when (settings.torSettings.torType.value) {
when (torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.videosViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.videosViaTor.value)
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.videosViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.videosViaTor.value)
}
fun shouldUseTorForPreviewUrl(url: String) =
when (settings.torSettings.torType.value) {
when (torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.urlPreviewsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.urlPreviewsViaTor.value)
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.urlPreviewsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.urlPreviewsViaTor.value)
}
fun shouldUseTorForTrustedRelays() =
when (settings.torSettings.torType.value) {
when (torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> settings.torSettings.trustedRelaysViaTor.value
TorType.EXTERNAL -> settings.torSettings.trustedRelaysViaTor.value
TorType.INTERNAL -> torSettings.trustedRelaysViaTor.value
TorType.EXTERNAL -> torSettings.trustedRelaysViaTor.value
}
private fun checkLocalHostOnionAndThen(
url: String,
final: Boolean,
): Boolean = checkLocalHostOnionAndThen(url, settings.torSettings.onionRelaysViaTor.value, final)
): Boolean = checkLocalHostOnionAndThen(url, torSettings.onionRelaysViaTor.value, final)
private fun checkLocalHostOnionAndThen(
normalizedUrl: String,
@@ -103,23 +106,39 @@ class PrivacyState(
}
fun shouldUseTorForMoneyOperations(url: String) =
when (settings.torSettings.torType.value) {
when (torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.moneyOperationsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.moneyOperationsViaTor.value)
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.moneyOperationsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.moneyOperationsViaTor.value)
}
fun shouldUseTorForNIP05(url: String) =
when (settings.torSettings.torType.value) {
when (torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip05VerificationsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip05VerificationsViaTor.value)
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip05VerificationsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip05VerificationsViaTor.value)
}
fun shouldUseTorForUploads(url: String) =
when (settings.torSettings.torType.value) {
when (torSettings.torType.value) {
TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value)
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip96UploadsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip96UploadsViaTor.value)
}
override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(shouldUseTorForVideoDownload(url))
override fun okHttpClientForNip05(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForNIP05(url))
override fun okHttpClientForUploads(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForUploads(url))
override fun okHttpClientForImage(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForImageDownload(url))
override fun okHttpClientForVideo(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForVideoDownload(url))
override fun okHttpClientForMoney(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForMoneyOperations(url))
override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url))
override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays())
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.privacyOptions
import okhttp3.OkHttpClient
import java.net.InetSocketAddress
class SingleRoleBasedHttpClientBuilder(
val okHttpClient: OkHttpClient,
) : IRoleBasedHttpClientBuilder {
override fun proxyPortForVideo(url: String) = (okHttpClient.proxy?.address() as? InetSocketAddress)?.port
override fun okHttpClientForNip05(url: String) = okHttpClient
override fun okHttpClientForUploads(url: String) = okHttpClient
override fun okHttpClientForImage(url: String) = okHttpClient
override fun okHttpClientForVideo(url: String) = okHttpClient
override fun okHttpClientForMoney(url: String) = okHttpClient
override fun okHttpClientForPreview(url: String) = okHttpClient
override fun okHttpClientForPushRegistration(url: String) = okHttpClient
}
@@ -0,0 +1,101 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.torState
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
import kotlin.collections.ifEmpty
class AccountsTorStateConnector(
accountsCache: AccountCacheState,
torEvaluatorFlow: TorRelayState,
scope: CoroutineScope,
) {
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
val allDmRelayFlows: Flow<Set<NormalizedRelayUrl>> =
accountsCache.accounts
.debounce(200)
.transformLatest { snapshot ->
val dmFlows = snapshot.map { it.value.dmRelayList.flow }
val dmFlowReady =
dmFlows.ifEmpty {
listOf(MutableStateFlow(emptySet()))
}
emitAll(
combine(dmFlowReady) {
val dmRelays = mutableSetOf<NormalizedRelayUrl>()
it.forEach {
dmRelays.addAll(it)
}
dmRelays.toSet()
},
)
}.onEach {
torEvaluatorFlow.dmRelays.tryEmit(it)
}.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val allTrustedRelaysFlow: Flow<Set<NormalizedRelayUrl>> =
accountsCache.accounts
.debounce(200)
.transformLatest { snapshot ->
val dmFlows = snapshot.map { it.value.dmRelayList.flow }
val dmFlowReady =
dmFlows.ifEmpty {
listOf(MutableStateFlow(emptySet()))
}
emitAll(
combine(dmFlowReady) {
val dmRelays = mutableSetOf<NormalizedRelayUrl>()
it.forEach {
dmRelays.addAll(it)
}
dmRelays.toSet()
},
)
}.onEach {
torEvaluatorFlow.trustedRelays.tryEmit(it)
}.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
)
}
@@ -20,33 +20,36 @@
*/
package com.vitorpamplona.amethyst.model.torState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.serverList.TrustedRelayListsState
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.ui.tor.TorType
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import okhttp3.OkHttpClient
class TorRelayState(
val trustedRelayState: TrustedRelayListsState,
val dmRelayState: DmRelayListState,
val settings: AccountSettings,
val okHttpClient: DualHttpClientManager,
val torSettingsFlow: TorSettingsFlow,
val scope: CoroutineScope,
) {
val dmRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
val trustedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
val torSettings =
combine(
settings.torSettings.torType,
settings.torSettings.onionRelaysViaTor,
settings.torSettings.dmRelaysViaTor,
settings.torSettings.trustedRelaysViaTor,
settings.torSettings.newRelaysViaTor,
torSettingsFlow.torType,
torSettingsFlow.onionRelaysViaTor,
torSettingsFlow.dmRelaysViaTor,
torSettingsFlow.trustedRelaysViaTor,
torSettingsFlow.newRelaysViaTor,
) {
torType: TorType,
onionRelaysViaTor: Boolean,
@@ -64,11 +67,11 @@ class TorRelayState(
}.onStart {
emit(
TorRelaySettings(
torType = settings.torSettings.torType.value,
onionRelaysViaTor = settings.torSettings.onionRelaysViaTor.value,
dmRelaysViaTor = settings.torSettings.dmRelaysViaTor.value,
trustedRelaysViaTor = settings.torSettings.trustedRelaysViaTor.value,
newRelaysViaTor = settings.torSettings.newRelaysViaTor.value,
torType = torSettingsFlow.torType.value,
onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value,
dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value,
trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value,
newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value,
),
)
}.flowOn(Dispatchers.Default)
@@ -76,20 +79,21 @@ class TorRelayState(
scope,
SharingStarted.Eagerly,
TorRelaySettings(
torType = settings.torSettings.torType.value,
onionRelaysViaTor = settings.torSettings.onionRelaysViaTor.value,
dmRelaysViaTor = settings.torSettings.dmRelaysViaTor.value,
trustedRelaysViaTor = settings.torSettings.trustedRelaysViaTor.value,
newRelaysViaTor = settings.torSettings.newRelaysViaTor.value,
torType = torSettingsFlow.torType.value,
onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value,
dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value,
trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value,
newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value,
),
)
val flow =
combineTransform(
torSettings,
trustedRelayState.flow,
dmRelayState.flow,
trustedRelays,
dmRelays,
) { torSettings: TorRelaySettings, trustedRelayList: Set<NormalizedRelayUrl>, dmRelayList: Set<NormalizedRelayUrl> ->
println("AABBCC New tor evaluation: $torSettings $trustedRelayList $dmRelayList")
emit(
TorRelayEvaluation(
torSettings = torSettings,
@@ -101,8 +105,8 @@ class TorRelayState(
emit(
TorRelayEvaluation(
torSettings = torSettings.value,
trustedRelayList = trustedRelayState.flow.value,
dmRelayList = dmRelayState.flow.value,
trustedRelayList = trustedRelays.value,
dmRelayList = dmRelays.value,
),
)
}.flowOn(Dispatchers.Default)
@@ -111,10 +115,12 @@ class TorRelayState(
SharingStarted.Eagerly,
TorRelayEvaluation(
torSettings = torSettings.value,
trustedRelayList = trustedRelayState.flow.value,
dmRelayList = dmRelayState.flow.value,
trustedRelayList = trustedRelays.value,
dmRelayList = dmRelays.value,
),
)
fun shouldUseTorForClean(relay: NormalizedRelayUrl) = flow.value.useTor(relay)
fun shouldUseTorForRelay(relay: NormalizedRelayUrl) = flow.value.useTor(relay)
fun okHttpClientForRelay(url: NormalizedRelayUrl): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForRelay(url))
}
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.service
import android.app.Application
import android.content.Context
import java.io.File
fun Application.safeCacheDir(): File {
fun Context.safeCacheDir(): File {
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
return cacheDir.apply { mkdirs() }
}
@@ -39,7 +39,7 @@ class ConnectivityFlow(
@OptIn(FlowPreview::class)
val status =
callbackFlow {
trySend(ConnectivityStatus.Connecting)
trySend(ConnectivityStatus.StartingService)
val connectivityManager = context.getConnectivityManager()
@@ -41,7 +41,7 @@ class ConnectivityManager(
ConnectivityFlow(context).status.distinctUntilChanged().stateIn(
scope,
SharingStarted.WhileSubscribed(30000),
ConnectivityStatus.Off,
ConnectivityStatus.StartingService,
)
val isMobileOrNull: StateFlow<Boolean?> =
@@ -28,5 +28,5 @@ sealed class ConnectivityStatus {
object Off : ConnectivityStatus()
object Connecting : ConnectivityStatus()
object StartingService : ConnectivityStatus()
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.service.notifications
import android.app.Application
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Context.RECEIVER_EXPORTED
@@ -28,8 +27,12 @@ import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.util.Log
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.nip01Core.core.Event
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class PokeyReceiver : BroadcastReceiver() {
@@ -39,7 +42,14 @@ class PokeyReceiver : BroadcastReceiver() {
val INTENT = IntentFilter(POKEY_ACTION)
}
fun register(app: Application) {
val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable)
}
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob() + exceptionHandler)
fun register(app: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
app.registerReceiver(this, INTENT, RECEIVER_EXPORTED)
} else {
@@ -48,8 +58,9 @@ class PokeyReceiver : BroadcastReceiver() {
}
}
fun unregister(app: Application) {
fun unregister(app: Context) {
app.unregisterReceiver(this)
scope.cancel()
}
override fun onReceive(
@@ -62,13 +73,9 @@ class PokeyReceiver : BroadcastReceiver() {
if (eventStr == null) return
val app = context.applicationContext as Amethyst
app.applicationIOScope.launch {
scope.launch {
try {
EventNotificationConsumer(app).findAccountAndConsume(
Event.fromJson(eventStr),
)
EventNotificationConsumer(context.applicationContext).findAccountAndConsume(Event.fromJson(eventStr))
} catch (e: Exception) {
Log.e(TAG, "Failed to parse Pokey Event", e)
}
@@ -62,7 +62,7 @@ fun VideoViewInner(
callbackUri = nostrUriCallback,
mimeType = mimeType,
aspectRatio = aspectRatio,
proxyPort = accountViewModel.proxyPortForVideo(videoUri),
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri),
keepPlaying = true,
waveformData = waveform,
) { mediaItem ->
@@ -25,7 +25,6 @@ import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.ProxySettingsAnchor
import com.vitorpamplona.amethyst.ui.tor.TorManager
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -45,7 +44,7 @@ import net.freehaven.tor.control.TorControlCommands
import okhttp3.OkHttpClient
class RelayProxyClientConnector(
val torProxySettingsAnchor: ProxySettingsAnchor,
val torEvaluator: StateFlow<TorRelayEvaluation>,
val okHttpClients: DualHttpClientManager,
val connManager: ConnectivityManager,
val client: INostrClient,
@@ -53,7 +52,7 @@ class RelayProxyClientConnector(
val scope: CoroutineScope,
) {
data class RelayServiceInfra(
val torSettings: StateFlow<TorRelayEvaluation>,
val evaluator: TorRelayEvaluation,
val torConnection: OkHttpClient,
val clearConnection: OkHttpClient,
val connectivity: ConnectivityStatus,
@@ -62,7 +61,7 @@ class RelayProxyClientConnector(
@OptIn(FlowPreview::class)
val relayServices =
combine(
torProxySettingsAnchor.flow,
torEvaluator,
okHttpClients.defaultHttpClient,
okHttpClients.defaultHttpClientWithoutProxy,
connManager.status,
@@ -70,7 +69,9 @@ class RelayProxyClientConnector(
RelayServiceInfra(torSettings, torConnection, clearConnection, connectivity)
}.debounce(100)
.onEach {
if (it.connectivity is ConnectivityStatus.Off) {
if (it.connectivity is ConnectivityStatus.StartingService) {
// ignore
} else if (it.connectivity is ConnectivityStatus.Off) {
Log.d("ManageRelayServices", "Pausing Relay Services ${it.connectivity}")
if (client.isActive()) {
client.disconnect()
@@ -152,7 +152,7 @@ class UploadOrchestrator {
alt = alt,
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) },
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = { percent: Float ->
updateState(0.2 + (0.2 * percent), UploadingState.Uploading)
},
@@ -165,7 +165,7 @@ class UploadOrchestrator {
localContentType = contentType,
originalContentType = contentTypeForResult,
originalHash = originalHash,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) },
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
)
} catch (_: SignerExceptions.ReadOnlyException) {
error(R.string.login_with_a_private_key_to_be_able_to_upload)
@@ -198,7 +198,7 @@ class UploadOrchestrator {
alt = alt,
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) },
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
@@ -206,7 +206,7 @@ class UploadOrchestrator {
verifyHeader(
uploadResult = result,
localContentType = contentType,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) },
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
originalHash = originalHash,
originalContentType = contentTypeForResult,
)
@@ -28,6 +28,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.debugState
@@ -40,7 +41,6 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.prepareSharedViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -67,16 +67,14 @@ class MainActivity : AppCompatActivity() {
setContent {
StringResSetup()
val sharedPreferencesViewModel = prepareSharedViewModel()
AmethystTheme(sharedPreferencesViewModel.sharedPrefs.theme) {
AmethystTheme {
val accountStateViewModel: AccountStateViewModel = viewModel()
LaunchedEffect(key1 = Unit) {
accountStateViewModel.loginWithDefaultAccountIfLoggedOff()
}
AccountScreen(accountStateViewModel, sharedPreferencesViewModel)
AccountScreen(accountStateViewModel)
}
}
}
@@ -37,7 +37,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.util.fastForEach
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
@@ -50,7 +49,7 @@ fun <T> CrossfadeIfEnabled(
accountViewModel: AccountViewModel,
content: @Composable (T) -> Unit,
) {
if (accountViewModel.settings.featureSet == FeatureSetType.PERFORMANCE) {
if (accountViewModel.settings.isPerformanceMode()) {
Box(modifier, contentAlignment) {
content(targetState)
}
@@ -193,7 +193,7 @@ class NewUserMetadataViewModel : ViewModel() {
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) },
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
@@ -206,7 +206,7 @@ class NewUserMetadataViewModel : ViewModel() {
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) },
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
@@ -56,6 +56,6 @@ tailrec fun Context.getActivity(): ComponentActivity =
}
@Composable
fun getAmethystApp(): Amethyst = LocalContext.current.getAmethystApp()
fun amethystApp(): Amethyst = LocalContext.current.asAmethystApp()
fun Context.getAmethystApp(): Amethyst = this.applicationContext as Amethyst
fun Context.asAmethystApp(): Amethyst = this.applicationContext as Amethyst
@@ -311,9 +311,9 @@ private suspend fun saveMediaToGallery(
mimeType = content.mimeType,
okHttpClient = {
if (isImage) {
accountViewModel.okHttpClientForImage(it)
accountViewModel.httpClientBuilder.okHttpClientForImage(it)
} else {
accountViewModel.okHttpClientForVideo(it)
accountViewModel.httpClientBuilder.okHttpClientForVideo(it)
}
},
localContext,
@@ -52,7 +52,6 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import kotlin.math.abs
@@ -70,7 +69,7 @@ fun DisappearingScaffold(
val shouldShow = remember { mutableStateOf(isActive()) }
val modifier =
if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) {
if (accountViewModel.settings.isImmersiveScrollingActive()) {
val bottomBarHeightPx = with(LocalDensity.current) { 50.dp.roundToPx().toFloat() }
val bottomBarOffsetHeightPx = remember { mutableFloatStateOf(0f) }
@@ -90,7 +89,7 @@ fun DisappearingScaffold(
val newOffset = bottomBarOffsetHeightPx.floatValue + available.y
if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) {
if (accountViewModel.settings.isImmersiveScrollingActive()) {
val newBottomBarOffset =
if (!isInvertedLayout) {
newOffset.coerceIn(-bottomBarHeightPx, 0f)
@@ -39,6 +39,7 @@ import androidx.core.net.toUri
import androidx.core.util.Consumer
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
@@ -54,7 +55,6 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.isBaseRoute
import com.vitorpamplona.amethyst.ui.navigation.routes.isSameRoute
import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen
@@ -106,7 +106,6 @@ import java.net.URI
fun AppNavigation(
accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel,
sharedPreferencesViewModel: SharedPreferencesViewModel,
) {
val nav = rememberNav()
@@ -121,16 +120,16 @@ fun AppNavigation(
composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
composable<Route.Video> { VideoScreen(accountViewModel, nav) }
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
composable<Route.Notification> { NotificationScreen(sharedPreferencesViewModel, accountViewModel, nav) }
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
composable<Route.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
composable<Route.Search> { SearchScreen(accountViewModel, nav) }
composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(accountViewModel, nav) }
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(accountViewModel, nav) }
composableFromEnd<Route.PrivacyOptions> { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) }
composableFromEnd<Route.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(accountViewModel, nav) }
composableFromEnd<Route.Settings> { SettingsScreen(sharedPreferencesViewModel, accountViewModel, nav) }
composableFromEnd<Route.Settings> { SettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) }
@@ -57,7 +57,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
@@ -218,7 +217,7 @@ private fun AccountPicture(
contentDescription = stringRes(R.string.profile_image),
modifier = AccountPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -91,7 +91,6 @@ import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowerCount
@@ -247,7 +246,7 @@ fun ProfileContentTemplate(
.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape)
.clickable(onClick = onClick),
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
if (bestDisplayName != null) {
@@ -32,7 +32,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -87,7 +86,7 @@ private fun LoggedInUserPictureDrawer(
modifier = HeaderPictureModifier,
contentScale = ContentScale.Crop,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
}
@@ -63,7 +63,6 @@ import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User
@@ -605,7 +604,7 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
modifier = MaterialTheme.colorScheme.profile35dpModifier,
contentScale = ContentScale.Crop,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -54,7 +54,6 @@ import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.compose.produceCachedStateAsync
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture
@@ -544,7 +543,7 @@ fun InnerNoteWithReactions(
baseNote.event !is GenericRepostEvent &&
!isBoostedNote &&
!isQuotedNote &&
accountViewModel.settings.featureSet == FeatureSetType.COMPLETE
accountViewModel.settings.isCompleteUIMode()
NoteBody(
baseNote = baseNote,
showAuthorPicture = isQuotedNote,
@@ -1256,7 +1255,7 @@ fun BadgeBox(
accountViewModel: AccountViewModel,
nav: INav,
) {
if (accountViewModel.settings.featureSet == FeatureSetType.COMPLETE) {
if (accountViewModel.settings.isCompleteUIMode()) {
if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) {
baseNote.replyTo?.lastOrNull()?.let { RelayBadges(it, accountViewModel, nav) }
} else {
@@ -1309,7 +1308,7 @@ private fun ChannelNotePicture(
contentDescription = stringRes(R.string.group_picture),
modifier = MaterialTheme.colorScheme.channelNotePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -96,7 +96,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
@@ -670,7 +669,7 @@ private fun SlidingAnimationCount(
textColor: Color,
accountViewModel: AccountViewModel,
) {
if (accountViewModel.settings.featureSet == FeatureSetType.PERFORMANCE) {
if (accountViewModel.settings.isPerformanceMode()) {
TextCount(baseCount, textColor)
} else {
AnimatedContent<Int>(
@@ -719,7 +718,7 @@ fun SlidingAnimationAmount(
textColor: Color,
accountViewModel: AccountViewModel,
) {
if (accountViewModel.settings.featureSet == FeatureSetType.PERFORMANCE) {
if (accountViewModel.settings.isPerformanceMode()) {
Text(
text = amount,
fontSize = Font14SP,
@@ -50,14 +50,13 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.ClickableBox
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
@@ -126,7 +125,7 @@ fun RenderRelay(
accountViewModel: AccountViewModel,
nav: INav,
) {
val relayInfo by loadRelayInfo(relay, accountViewModel)
val relayInfo by loadRelayInfo(relay)
val clipboardManager = LocalClipboardManager.current
val clickableModifier =
@@ -152,7 +151,7 @@ fun RenderRelay(
iconUrl = relayInfo.icon,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
pingInMs = 0,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
}
@@ -36,7 +36,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
@@ -94,7 +93,7 @@ fun DisplayBlankAuthor(
robot = "authornotfound",
contentDescription = stringRes(R.string.unknown_author),
modifier = nullModifier,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -360,7 +359,7 @@ fun InnerUserPicture(
modifier = myImageModifier,
contentScale = ContentScale.Crop,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -638,10 +638,7 @@ open class CommentPostViewModel :
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link.url) {
Amethyst.Companion.instance.okHttpClients
.getHttpClient(
accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url),
)
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link.url)
}
}
@@ -61,7 +61,6 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
@@ -328,7 +327,7 @@ fun ShortCommunityHeader(
contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -378,7 +377,7 @@ fun ShortCommunityHeaderNoActions(
contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -121,7 +121,7 @@ fun VoiceHeader(
callbackUri = callbackUri,
mimeType = null,
aspectRatio = null,
proxyPort = accountViewModel.proxyPortForVideo(media),
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(media),
keepPlaying = false,
waveformData = waveform,
) { mediaItem ->
@@ -34,16 +34,17 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen
import com.vitorpamplona.amethyst.ui.stringRes
@Composable
fun AccountScreen(
accountStateViewModel: AccountStateViewModel,
sharedPreferencesViewModel: SharedPreferencesViewModel,
) {
fun AccountScreen(accountStateViewModel: AccountStateViewModel) {
// Pauses relay services when the app pauses
ManageRelayServices()
val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle()
Log.d("ActivityLifecycle", "AccountScreen $accountState $accountStateViewModel")
@@ -55,11 +56,17 @@ fun AccountScreen(
when (state) {
is AccountState.Loading -> LoadingSetup()
is AccountState.LoggedOff -> LoggedOffSetup(accountStateViewModel)
is AccountState.LoggedIn -> LoggedInSetup(state, accountStateViewModel, sharedPreferencesViewModel)
is AccountState.LoggedIn -> LoggedInSetup(state, accountStateViewModel)
}
}
}
@Composable
fun ManageRelayServices() {
val relayServices by Amethyst.instance.relayProxyClientConnector.relayServices
.collectAsStateWithLifecycle()
}
@Composable
fun LoadingSetup() {
// A surface container using the 'background' color from the theme
@@ -91,14 +98,12 @@ fun LoggedOffSetup(accountStateViewModel: AccountStateViewModel) {
fun LoggedInSetup(
state: AccountState.LoggedIn,
accountStateViewModel: AccountStateViewModel,
sharedPreferencesViewModel: SharedPreferencesViewModel,
) {
SetAccountCentricViewModelStore(state) {
LoggedInPage(
state.account,
state.route,
accountStateViewModel,
sharedPreferencesViewModel,
account = state.account,
route = state.route,
accountStateViewModel = accountStateViewModel,
)
}
}
@@ -35,8 +35,6 @@ import com.vitorpamplona.amethyst.model.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -108,7 +106,6 @@ class AccountStateViewModel : ViewModel() {
suspend fun loginAndStartUI(
key: String,
torSettings: TorSettings,
transientAccount: Boolean,
loginWithExternalSigner: Boolean = false,
packageName: String = "",
@@ -142,31 +139,26 @@ class AccountStateViewModel : ViewModel() {
keyPair = KeyPair(pubKey = pubKeyParsed),
transientAccount = transientAccount,
externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" },
torSettings = TorSettingsFlow.build(torSettings),
)
} else if (key.startsWith("nsec")) {
AccountSettings(
keyPair = KeyPair(privKey = key.bechToBytes()),
transientAccount = transientAccount,
torSettings = TorSettingsFlow.build(torSettings),
)
} else if (key.contains(" ") && Nip06().isValidMnemonic(key)) {
AccountSettings(
keyPair = KeyPair(privKey = Nip06().privateKeyFromMnemonic(key)),
transientAccount = transientAccount,
torSettings = TorSettingsFlow.build(torSettings),
)
} else if (pubKeyParsed != null) {
AccountSettings(
keyPair = KeyPair(pubKey = pubKeyParsed),
transientAccount = transientAccount,
torSettings = TorSettingsFlow.build(torSettings),
)
} else {
AccountSettings(
keyPair = KeyPair(Hex.decode(key)),
transientAccount = transientAccount,
torSettings = TorSettingsFlow.build(torSettings),
)
}
@@ -197,7 +189,6 @@ class AccountStateViewModel : ViewModel() {
fun login(
key: String,
password: String,
torSettings: TorSettings,
transientAccount: Boolean,
loginWithExternalSigner: Boolean = false,
packageName: String = "",
@@ -222,41 +213,39 @@ class AccountStateViewModel : ViewModel() {
onError("Could not decrypt key with provided password")
Log.e("Login", "Could not decrypt ncryptsec")
} else {
loginSync(newKey, torSettings, transientAccount, loginWithExternalSigner, packageName, onError)
loginSync(newKey, transientAccount, loginWithExternalSigner, packageName, onError)
}
} else if (EMAIL_PATTERN.matcher(key).matches()) {
Nip05NostrAddressVerifier().verifyNip05(
key,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(false) },
onSuccess = { publicKey ->
loginSync(Hex.decode(publicKey).toNpub(), torSettings, transientAccount, loginWithExternalSigner, packageName, onError)
loginSync(Hex.decode(publicKey).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError)
},
onError = {
onError(it)
},
)
} else {
loginSync(key, torSettings, transientAccount, loginWithExternalSigner, packageName, onError)
loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError)
}
}
}
fun login(
key: String,
torSettings: TorSettings,
transientAccount: Boolean,
loginWithExternalSigner: Boolean = false,
packageName: String = "",
onError: (String) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
loginSync(key, torSettings, transientAccount, loginWithExternalSigner, packageName, onError)
loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError)
}
}
suspend fun loginSync(
key: String,
torSettings: TorSettings,
transientAccount: Boolean,
loginWithExternalSigner: Boolean = false,
packageName: String = "",
@@ -266,7 +255,7 @@ class AccountStateViewModel : ViewModel() {
if (_accountContent.value is AccountState.LoggedIn) {
prepareLogoutOrSwitch()
}
loginAndStartUI(key, torSettings, transientAccount, loginWithExternalSigner, packageName)
loginAndStartUI(key, transientAccount, loginWithExternalSigner, packageName)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Login", "Could not sign in", e)
@@ -274,10 +263,7 @@ class AccountStateViewModel : ViewModel() {
}
}
fun newKey(
torSettings: TorSettings,
name: String? = null,
) {
fun newKey(name: String? = null) {
viewModelScope.launch(Dispatchers.IO) {
if (_accountContent.value is AccountState.LoggedIn) {
prepareLogoutOrSwitch()
@@ -285,7 +271,7 @@ class AccountStateViewModel : ViewModel() {
_accountContent.update { AccountState.Loading }
val accountSettings = createNewAccount(torSettings, name)
val accountSettings = createNewAccount(name)
LocalPreferences.setDefaultAccount(accountSettings)
@@ -306,10 +292,7 @@ class AccountStateViewModel : ViewModel() {
}
}
fun createNewAccount(
torSettings: TorSettings,
name: String? = null,
): AccountSettings {
fun createNewAccount(name: String? = null): AccountSettings {
val keyPair = KeyPair()
val tempSigner = NostrSignerSync(keyPair)
@@ -327,7 +310,6 @@ class AccountStateViewModel : ViewModel() {
backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner),
backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), tempSigner),
backupChannelList = ChannelListEvent.create(emptyList(), DefaultChannels, tempSigner),
torSettings = TorSettingsFlow.build(torSettings),
)
}
@@ -1,72 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.accompanist.adaptive.calculateDisplayFeatures
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.ui.components.getActivity
@Composable
fun prepareSharedViewModel(): SharedPreferencesViewModel {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
LaunchedEffect(key1 = sharedPreferencesViewModel) {
sharedPreferencesViewModel.init()
}
MonitorDisplaySize(sharedPreferencesViewModel)
ManageConnectivity(sharedPreferencesViewModel)
return sharedPreferencesViewModel
}
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@Composable
fun MonitorDisplaySize(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val act = LocalContext.current.getActivity()
val displayFeatures = calculateDisplayFeatures(act)
val windowSizeClass = calculateWindowSizeClass(act)
LaunchedEffect(sharedPreferencesViewModel, displayFeatures, windowSizeClass) {
sharedPreferencesViewModel.updateDisplaySettings(windowSizeClass, displayFeatures)
}
}
@Composable
fun ManageConnectivity(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val status =
Amethyst.instance.connManager.status
.collectAsStateWithLifecycle()
(status.value as? ConnectivityStatus.Active)?.let {
sharedPreferencesViewModel.updateNetworkState(it.networkId)
sharedPreferencesViewModel.updateConnectivityStatusState(it.isMobile)
}
}
@@ -1,211 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen
import android.util.Log
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
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
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.Settings
import com.vitorpamplona.amethyst.model.ThemeType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Stable
class SharedPreferencesViewModel : ViewModel() {
val sharedPrefs: SharedSettingsState = SharedSettingsState()
fun init() {
viewModelScope.launch(Dispatchers.IO) {
Log.d("SharedPreferencesViewModel", "init")
val savedSettings =
LocalPreferences.loadSharedSettings() ?: Settings()
sharedPrefs.theme = savedSettings.theme
sharedPrefs.language = savedSettings.preferredLanguage
sharedPrefs.automaticallyShowImages = savedSettings.automaticallyShowImages
sharedPrefs.automaticallyStartPlayback = savedSettings.automaticallyStartPlayback
sharedPrefs.automaticallyShowUrlPreview = savedSettings.automaticallyShowUrlPreview
sharedPrefs.automaticallyHideNavigationBars = savedSettings.automaticallyHideNavigationBars
sharedPrefs.automaticallyShowProfilePictures = savedSettings.automaticallyShowProfilePictures
sharedPrefs.dontShowPushNotificationSelector = savedSettings.dontShowPushNotificationSelector
sharedPrefs.dontAskForNotificationPermissions = savedSettings.dontAskForNotificationPermissions
sharedPrefs.gallerySet = savedSettings.gallerySet
sharedPrefs.featureSet = savedSettings.featureSet
updateLanguageInTheUI()
}
}
fun updateTheme(newTheme: ThemeType) {
if (sharedPrefs.theme != newTheme) {
sharedPrefs.theme = newTheme
saveSharedSettings()
}
}
fun updateLanguage(newLanguage: String?) {
if (sharedPrefs.language != newLanguage) {
sharedPrefs.language = newLanguage
updateLanguageInTheUI()
saveSharedSettings()
}
}
fun updateLanguageInTheUI() {
if (sharedPrefs.language != null) {
viewModelScope.launch(Dispatchers.Main) {
AppCompatDelegate.setApplicationLocales(
LocaleListCompat.forLanguageTags(sharedPrefs.language),
)
}
}
}
fun updateAutomaticallyStartPlayback(newAutomaticallyStartPlayback: ConnectivityType) {
if (sharedPrefs.automaticallyStartPlayback != newAutomaticallyStartPlayback) {
sharedPrefs.automaticallyStartPlayback = newAutomaticallyStartPlayback
saveSharedSettings()
}
}
fun updateAutomaticallyShowUrlPreview(newAutomaticallyShowUrlPreview: ConnectivityType) {
if (sharedPrefs.automaticallyShowUrlPreview != newAutomaticallyShowUrlPreview) {
sharedPrefs.automaticallyShowUrlPreview = newAutomaticallyShowUrlPreview
saveSharedSettings()
}
}
fun updateAutomaticallyShowProfilePicture(newAutomaticallyShowProfilePictures: ConnectivityType) {
if (sharedPrefs.automaticallyShowProfilePictures != newAutomaticallyShowProfilePictures) {
sharedPrefs.automaticallyShowProfilePictures = newAutomaticallyShowProfilePictures
saveSharedSettings()
}
}
fun updateAutomaticallyHideNavBars(newAutomaticallyHideHavBars: BooleanType) {
if (sharedPrefs.automaticallyHideNavigationBars != newAutomaticallyHideHavBars) {
sharedPrefs.automaticallyHideNavigationBars = newAutomaticallyHideHavBars
saveSharedSettings()
}
}
fun updateAutomaticallyShowImages(newAutomaticallyShowImages: ConnectivityType) {
if (sharedPrefs.automaticallyShowImages != newAutomaticallyShowImages) {
sharedPrefs.automaticallyShowImages = newAutomaticallyShowImages
saveSharedSettings()
}
}
fun updateFeatureSetType(newFeatureSetType: FeatureSetType) {
if (sharedPrefs.featureSet != newFeatureSetType) {
sharedPrefs.featureSet = newFeatureSetType
saveSharedSettings()
}
}
fun updateGallerySetType(newgalleryType: ProfileGalleryType) {
if (sharedPrefs.gallerySet != newgalleryType) {
sharedPrefs.gallerySet = newgalleryType
saveSharedSettings()
}
}
fun dontShowPushNotificationSelector() {
if (sharedPrefs.dontShowPushNotificationSelector == false) {
sharedPrefs.dontShowPushNotificationSelector = true
saveSharedSettings()
}
}
fun dontAskForNotificationPermissions() {
if (sharedPrefs.dontAskForNotificationPermissions == false) {
sharedPrefs.dontAskForNotificationPermissions = true
saveSharedSettings()
}
}
fun updateConnectivityStatusState(isOnMobileDataState: Boolean) {
if (sharedPrefs.isOnMobileOrMeteredConnection != isOnMobileDataState) {
Log.d("Connectivity", "updateConnectivityStatusState ${sharedPrefs.currentNetworkId}: ${sharedPrefs.isOnMobileOrMeteredConnection} -> $isOnMobileDataState")
sharedPrefs.isOnMobileOrMeteredConnection = isOnMobileDataState
}
}
fun updateNetworkState(networkId: Long) {
if (sharedPrefs.currentNetworkId != networkId) {
Log.d("Connectivity", "updateNetworkState ${sharedPrefs.currentNetworkId} -> $networkId")
sharedPrefs.currentNetworkId = networkId
}
}
fun updateDisplaySettings(
windowSizeClass: WindowSizeClass,
displayFeatures: List<DisplayFeature>,
) {
if (sharedPrefs.windowSizeClass.value != windowSizeClass) {
sharedPrefs.windowSizeClass.value = windowSizeClass
}
if (sharedPrefs.displayFeatures.value != displayFeatures) {
sharedPrefs.displayFeatures.value = displayFeatures
}
}
fun saveSharedSettings() {
viewModelScope.launch(Dispatchers.IO) {
Log.d("SharedPreferencesViewModel", "Saving Shared Settings")
LocalPreferences.saveSharedSettings(
Settings(
sharedPrefs.theme,
sharedPrefs.language,
sharedPrefs.automaticallyShowImages,
sharedPrefs.automaticallyStartPlayback,
sharedPrefs.automaticallyShowUrlPreview,
sharedPrefs.automaticallyHideNavigationBars,
sharedPrefs.automaticallyShowProfilePictures,
sharedPrefs.dontShowPushNotificationSelector,
sharedPrefs.dontAskForNotificationPermissions,
sharedPrefs.featureSet,
sharedPrefs.gallerySet,
),
)
}
}
}
@Composable
fun mockSharedPreferencesViewModel(): SharedPreferencesViewModel {
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
sharedPreferencesViewModel.init()
return sharedPreferencesViewModel
}
@@ -1,101 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.window.layout.DisplayFeature
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.ThemeType
@Stable
class SharedSettingsState {
var theme by mutableStateOf(ThemeType.SYSTEM)
var language by mutableStateOf<String?>(null)
var automaticallyShowImages by mutableStateOf(ConnectivityType.ALWAYS)
var automaticallyStartPlayback by mutableStateOf(ConnectivityType.ALWAYS)
var automaticallyShowUrlPreview by mutableStateOf(ConnectivityType.ALWAYS)
var automaticallyHideNavigationBars by mutableStateOf(BooleanType.ALWAYS)
var automaticallyShowProfilePictures by mutableStateOf(ConnectivityType.ALWAYS)
var dontShowPushNotificationSelector by mutableStateOf<Boolean>(false)
var dontAskForNotificationPermissions by mutableStateOf<Boolean>(false)
var featureSet by mutableStateOf(FeatureSetType.SIMPLIFIED)
var gallerySet by mutableStateOf(ProfileGalleryType.CLASSIC)
var isOnMobileOrMeteredConnection by mutableStateOf(false)
var currentNetworkId by mutableLongStateOf(0L)
var windowSizeClass = mutableStateOf<WindowSizeClass?>(null)
var displayFeatures = mutableStateOf<List<DisplayFeature>>(emptyList())
val showProfilePictures =
derivedStateOf {
when (automaticallyShowProfilePictures) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}
val modernGalleryStyle =
derivedStateOf {
when (gallerySet) {
ProfileGalleryType.CLASSIC -> false
ProfileGalleryType.MODERN -> true
}
}
val showUrlPreview =
derivedStateOf {
when (automaticallyShowUrlPreview) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}
val startVideoPlayback =
derivedStateOf {
when (automaticallyStartPlayback) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}
val showImages =
derivedStateOf {
when (automaticallyShowImages) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}
}
@@ -0,0 +1,134 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
@Stable
class UiSettingsState(
val uiSettingsFlow: UiSettingsFlow,
val isMobileOrMeteredConnection: StateFlow<Boolean>,
val scope: CoroutineScope,
) {
val showProfilePictures =
combine(
uiSettingsFlow.automaticallyShowProfilePictures,
isMobileOrMeteredConnection,
) { showProfilePictures, isOnMobileOrMeteredConnection ->
when (showProfilePictures) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}.stateIn(
scope,
SharingStarted.Eagerly,
when (uiSettingsFlow.automaticallyShowProfilePictures.value) {
ConnectivityType.WIFI_ONLY -> !isMobileOrMeteredConnection.value
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
},
)
val showUrlPreview =
combine(
uiSettingsFlow.automaticallyShowUrlPreview,
isMobileOrMeteredConnection,
) { automaticallyShowUrlPreview, isOnMobileOrMeteredConnection ->
when (automaticallyShowUrlPreview) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}.stateIn(
scope,
SharingStarted.Eagerly,
when (uiSettingsFlow.automaticallyShowUrlPreview.value) {
ConnectivityType.WIFI_ONLY -> !isMobileOrMeteredConnection.value
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
},
)
val startVideoPlayback =
combine(
uiSettingsFlow.automaticallyStartPlayback,
isMobileOrMeteredConnection,
) { automaticallyStartPlayback, isOnMobileOrMeteredConnection ->
when (automaticallyStartPlayback) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}.stateIn(
scope,
SharingStarted.Eagerly,
when (uiSettingsFlow.automaticallyStartPlayback.value) {
ConnectivityType.WIFI_ONLY -> !isMobileOrMeteredConnection.value
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
},
)
val showImages =
combine(
uiSettingsFlow.automaticallyShowImages,
isMobileOrMeteredConnection,
) { automaticallyShowImages, isOnMobileOrMeteredConnection ->
when (automaticallyShowImages) {
ConnectivityType.WIFI_ONLY -> !isOnMobileOrMeteredConnection
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
}
}.stateIn(
scope,
SharingStarted.Eagerly,
when (uiSettingsFlow.automaticallyShowImages.value) {
ConnectivityType.WIFI_ONLY -> !isMobileOrMeteredConnection.value
ConnectivityType.NEVER -> false
ConnectivityType.ALWAYS -> true
},
)
fun modernGalleryStyle() =
when (uiSettingsFlow.gallerySet.value) {
ProfileGalleryType.CLASSIC -> false
ProfileGalleryType.MODERN -> true
}
fun isPerformanceMode() = uiSettingsFlow.featureSet.value == FeatureSetType.PERFORMANCE
fun isNotPerformanceMode() = uiSettingsFlow.featureSet.value != FeatureSetType.PERFORMANCE
fun isCompleteUIMode() = uiSettingsFlow.featureSet.value == FeatureSetType.COMPLETE
fun isImmersiveScrollingActive() = uiSettingsFlow.automaticallyHideNavigationBars.value == BooleanType.ALWAYS
}
@@ -33,7 +33,6 @@ import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.asDrawable
import coil3.imageLoader
import coil3.request.ImageRequest
@@ -49,6 +48,7 @@ import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Channel
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
@@ -56,9 +56,10 @@ import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator
import com.vitorpamplona.amethyst.model.privacyOptions.EmptyRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.cashu.CashuToken
@@ -66,8 +67,6 @@ import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.okhttp.EmptyHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.IHttpClientManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality
@@ -84,12 +83,12 @@ import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedSettingsState
import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.ui.tor.TorType
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
@@ -106,8 +105,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.tags.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip03Timestamp.DefaultOtsResolverBuilder
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
@@ -160,15 +158,15 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.util.Locale
@Stable
class AccountViewModel(
val account: Account,
val settings: SharedSettingsState,
val settings: UiSettingsState,
val torSettings: TorSettingsFlow,
val dataSources: RelaySubscriptionsCoordinator,
val okHttpClient: IHttpClientManager,
val httpClientBuilder: IRoleBasedHttpClientBuilder,
) : ViewModel(),
Dao {
var firstRoute: Route? = null
@@ -650,7 +648,7 @@ class AccountViewModel(
message = message,
context = context,
showErrorIfNoLnAddress = showErrorIfNoLnAddress,
okHttpClient = ::okHttpClientForMoney,
okHttpClient = httpClientBuilder::okHttpClientForMoney,
onError = onError,
onProgress = onProgress,
onPayViaIntent = onPayViaIntent,
@@ -886,7 +884,7 @@ class AccountViewModel(
onResult: suspend (UrlPreviewState) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url, ::okHttpClientForPreview, onResult)
UrlCachedPreviewer.previewInfo(url, httpClientBuilder::okHttpClientForPreview, onResult)
}
}
@@ -907,9 +905,7 @@ class AccountViewModel(
Nip05NostrAddressVerifier()
.verifyNip05(
nip05,
okHttpClient = {
okHttpClient.getHttpClient(account.privacyState.shouldUseTorForNIP05(it))
},
okHttpClient = httpClientBuilder::okHttpClientForNip05,
onSuccess = {
// Marks user as verified
if (it == pubkeyHex) {
@@ -936,21 +932,6 @@ class AccountViewModel(
}
}
fun retrieveRelayDocument(
relay: NormalizedRelayUrl,
onInfo: (Nip11RelayInformation) -> Unit,
onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit,
) {
viewModelScope.launch(Dispatchers.IO) {
Nip11CachedRetriever.loadRelayInfo(
relay,
okHttpClient = { okHttpClientForClean(relay) },
onInfo,
onError,
)
}
}
fun runOnIO(runOnIO: () -> Unit) {
viewModelScope.launch(Dispatchers.IO) { runOnIO() }
}
@@ -1073,7 +1054,7 @@ class AccountViewModel(
suspend fun checkVideoIsOnline(videoUrl: String): Boolean =
withContext(Dispatchers.IO) {
OnlineChecker.isOnline(videoUrl, ::okHttpClientForVideo)
OnlineChecker.isOnline(videoUrl, httpClientBuilder::okHttpClientForVideo)
}
fun loadAndMarkAsRead(
@@ -1108,19 +1089,19 @@ class AccountViewModel(
}
}
fun setTorSettings(newTorSettings: TorSettings) = runIOCatching { account.settings.setTorSettings(newTorSettings) }
class Factory(
val account: Account,
val settings: SharedSettingsState,
val settings: UiSettingsState,
val torSettings: TorSettingsFlow,
val dataSources: RelaySubscriptionsCoordinator,
val okHttpClient: IHttpClientManager,
val okHttpClient: RoleBasedHttpClientBuilder,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
AccountViewModel(
account,
settings,
torSettings,
dataSources,
okHttpClient,
) as T
@@ -1278,7 +1259,7 @@ class AccountViewModel(
if (lud16 != null) {
viewModelScope.launch(Dispatchers.IO) {
try {
val meltResult = MeltProcessor().melt(token, lud16, ::okHttpClientForMoney, context)
val meltResult = MeltProcessor().melt(token, lud16, httpClientBuilder::okHttpClientForMoney, context)
onDone(
stringRes(context, R.string.cashu_successful_redemption),
stringRes(
@@ -1395,22 +1376,6 @@ class AccountViewModel(
}
}
fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(account.privacyState.shouldUseTorForVideoDownload(url))
fun okHttpClientForNip96(url: String): OkHttpClient = okHttpClient.getHttpClient(account.privacyState.shouldUseTorForUploads(url))
fun okHttpClientForImage(url: String): OkHttpClient = okHttpClient.getHttpClient(account.privacyState.shouldUseTorForImageDownload(url))
fun okHttpClientForVideo(url: String): OkHttpClient = okHttpClient.getHttpClient(account.privacyState.shouldUseTorForVideoDownload(url))
fun okHttpClientForMoney(url: String): OkHttpClient = okHttpClient.getHttpClient(account.privacyState.shouldUseTorForMoneyOperations(url))
fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(account.privacyState.shouldUseTorForPreviewUrl(url))
fun okHttpClientForClean(url: NormalizedRelayUrl): OkHttpClient = okHttpClient.getHttpClient(account.torRelayState.shouldUseTorForClean(url))
fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(account.privacyState.shouldUseTorForTrustedRelays())
fun dataSources() = dataSources
suspend fun createTempDraftNote(noteEvent: DraftWrapEvent): Note? = draftNoteCache.update(noteEvent)
@@ -1526,7 +1491,7 @@ class AccountViewModel(
milliSats = milliSats,
message = message,
nostrRequest = zapRequest,
okHttpClient = ::okHttpClientForMoney,
okHttpClient = httpClientBuilder::okHttpClientForMoney,
onProgress = onProgress,
context = context,
)
@@ -1549,7 +1514,7 @@ class AccountViewModel(
viewModelScope.launch {
MediaSaverToDisk.saveDownloadingIfNeeded(
videoUri = videoUri,
okHttpClient = ::okHttpClientForVideo,
okHttpClient = httpClientBuilder::okHttpClientForVideo,
mimeType = mimeType,
localContext = localContext,
onSuccess = {
@@ -1668,8 +1633,17 @@ var mockedCache: AccountViewModel? = null
fun mockAccountViewModel(): AccountViewModel {
mockedCache?.let { return it }
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
sharedPreferencesViewModel.init()
val otsResolver = DefaultOtsResolverBuilder()
val scope = rememberCoroutineScope()
val uiState =
UiSettingsState(
uiSettingsFlow = UiSettingsFlow(),
isMobileOrMeteredConnection = MutableStateFlow(false),
scope = scope,
)
val keyPair =
KeyPair(
privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"),
@@ -1677,8 +1651,6 @@ fun mockAccountViewModel(): AccountViewModel {
forceReplacePubkey = false,
)
val scope = rememberCoroutineScope()
val client = EmptyNostrClient
val nwcFilters = NWCPaymentFilterAssembler(client)
@@ -1689,7 +1661,7 @@ fun mockAccountViewModel(): AccountViewModel {
signer = NostrSignerInternal(keyPair),
geolocationFlow = MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading),
nwcFilterAssembler = nwcFilters,
otsVerifCache = VerificationStateCache(),
otsResolverBuilder = otsResolver,
cache = LocalCache,
client = client,
scope = scope,
@@ -1697,8 +1669,9 @@ fun mockAccountViewModel(): AccountViewModel {
return AccountViewModel(
account = account,
settings = sharedPreferencesViewModel.sharedPrefs,
okHttpClient = EmptyHttpClientManager,
settings = uiState,
torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)),
httpClientBuilder = EmptyRoleBasedHttpClientBuilder(),
dataSources = RelaySubscriptionsCoordinator(LocalCache, client, scope),
).also {
mockedCache = it
@@ -1712,8 +1685,17 @@ var vitorCache: AccountViewModel? = null
fun mockVitorAccountViewModel(): AccountViewModel {
mockedCache?.let { return it }
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel()
sharedPreferencesViewModel.init()
val scope = rememberCoroutineScope()
val otsResolver = DefaultOtsResolverBuilder()
val uiState =
UiSettingsState(
uiSettingsFlow = UiSettingsFlow(),
isMobileOrMeteredConnection = MutableStateFlow(false),
scope = scope,
)
val keyPair =
KeyPair(
pubKey = Hex.decode("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"),
@@ -1723,15 +1705,13 @@ fun mockVitorAccountViewModel(): AccountViewModel {
val nwcFilters = NWCPaymentFilterAssembler(client)
val scope = rememberCoroutineScope()
val account =
Account(
settings = AccountSettings(keyPair),
signer = NostrSignerInternal(keyPair),
geolocationFlow = MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading),
nwcFilterAssembler = nwcFilters,
otsVerifCache = VerificationStateCache(),
otsResolverBuilder = otsResolver,
cache = LocalCache,
client = EmptyNostrClient,
scope = scope,
@@ -1739,8 +1719,9 @@ fun mockVitorAccountViewModel(): AccountViewModel {
return AccountViewModel(
account = account,
settings = sharedPreferencesViewModel.sharedPrefs,
okHttpClient = EmptyHttpClientManager,
settings = uiState,
torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)),
httpClientBuilder = EmptyRoleBasedHttpClientBuilder(),
dataSources = RelaySubscriptionsCoordinator(LocalCache, client, scope),
).also {
vitorCache = it
@@ -30,7 +30,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.LifecycleResumeEffect
@@ -49,7 +48,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.Account
import com.vitorpamplona.amethyst.ui.navigation.AppNavigation
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
@@ -62,7 +60,6 @@ fun LoggedInPage(
account: Account,
route: Route?,
accountStateViewModel: AccountStateViewModel,
sharedPreferencesViewModel: SharedPreferencesViewModel,
) {
val accountViewModel: AccountViewModel =
viewModel(
@@ -70,9 +67,10 @@ fun LoggedInPage(
factory =
AccountViewModel.Factory(
account = account,
settings = sharedPreferencesViewModel.sharedPrefs,
settings = Amethyst.instance.uiState,
torSettings = Amethyst.instance.torPrefs.value,
dataSources = Amethyst.instance.sources,
okHttpClient = Amethyst.instance.okHttpClients,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder,
),
)
@@ -81,12 +79,6 @@ fun LoggedInPage(
// Adds this account to the authentication procedures for relays.
RelayAuthSubscription(accountViewModel)
// Sets up Coil's Image Loader
ObserveImageLoadingTor(accountViewModel)
// Sets up the use of Proxy based on this Account's settings
SetProxyDeterminator(accountViewModel)
// Loads account information + DMs and Notifications from Relays.
AccountFilterAssemblerSubscription(accountViewModel)
@@ -99,9 +91,6 @@ fun LoggedInPage(
// Updates local cache of the anti-spam filter choice of this user.
ObserveAntiSpamFilterSettings(accountViewModel)
// Pauses relay services when the app pauses
ManageRelayServices(accountViewModel)
// Listens to Amber
ListenToExternalSignerIfNeeded(accountViewModel)
@@ -111,7 +100,6 @@ fun LoggedInPage(
AppNavigation(
accountViewModel = accountViewModel,
accountStateViewModel = accountStateViewModel,
sharedPreferencesViewModel = sharedPreferencesViewModel,
)
}
@@ -123,27 +111,6 @@ fun ObserveAntiSpamFilterSettings(accountViewModel: AccountViewModel) {
Amethyst.instance.cache.antiSpam.active = isSpamActive
}
@Composable
fun SetProxyDeterminator(accountViewModel: AccountViewModel) {
LaunchedEffect(accountViewModel) {
Amethyst.instance.torProxySettingsAnchor.flow
.tryEmit(accountViewModel.account.torRelayState.flow)
}
}
@Composable
fun ObserveImageLoadingTor(accountViewModel: AccountViewModel) {
LaunchedEffect(accountViewModel) {
Amethyst.instance.setImageLoader(accountViewModel.account.privacyState::shouldUseTorForImageDownload)
}
}
@Composable
fun ManageRelayServices(accountViewModel: AccountViewModel) {
val relayServices by Amethyst.instance.relayProxyClientConnector.relayServices
.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NotificationRegistration(accountViewModel: AccountViewModel) {
@@ -158,7 +125,7 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) {
scope.launch {
PushNotificationUtils.checkAndInit(
LocalPreferences.allSavedAccounts(),
accountViewModel::okHttpClientForPushRegistration,
accountViewModel.httpClientBuilder::okHttpClientForPushRegistration,
)
}
@@ -173,7 +140,7 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) {
scope.launch {
PushNotificationUtils.checkAndInit(
LocalPreferences.allSavedAccounts(),
accountViewModel::okHttpClientForPushRegistration,
accountViewModel.httpClientBuilder::okHttpClientForPushRegistration,
)
}
@@ -42,7 +42,6 @@ import androidx.compose.ui.Alignment.Companion.CenterStart
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -164,7 +163,7 @@ fun NormalChatNote(
isLoggedInUser = isLoggedInUser,
isDraft = note.event is DraftWrapEvent,
innerQuote = innerQuote,
isComplete = accountViewModel.settings.featureSet == FeatureSetType.COMPLETE,
isComplete = accountViewModel.settings.isCompleteUIMode(),
hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(),
drawAuthorInfo = drawAuthorInfo,
parentBackgroundColor = parentBackgroundColor,
@@ -49,8 +49,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
@@ -58,7 +58,6 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
@@ -160,7 +159,7 @@ fun RenderChannelData(
contentDescription = stringRes(R.string.channel_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
}
@@ -237,7 +236,7 @@ fun RenderRelayLinePublicChat(
nav: INav,
) {
@Suppress("ProduceStateDoesNotAssignValue")
val relayInfo by loadRelayInfo(relay, accountViewModel)
val relayInfo by loadRelayInfo(relay)
val clipboardManager = LocalClipboardManager.current
val clickableModifier =
@@ -255,7 +254,7 @@ fun RenderRelayLinePublicChat(
relayInfo.icon,
clickableModifier,
showPicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -667,7 +667,7 @@ class ChatNewMessageViewModel :
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link.url) {
Amethyst.instance.okHttpClients.getHttpClient(accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url))
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link.url)
}
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -28,9 +27,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -38,9 +35,8 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
@@ -51,28 +47,6 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemC
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
@Composable
fun loadRelayInfo(
relay: NormalizedRelayUrl,
accountViewModel: AccountViewModel,
): State<Nip11RelayInformation> =
produceState(
Nip11CachedRetriever.getFromCache(relay),
relay,
) {
accountViewModel.retrieveRelayDocument(
relay = relay,
onInfo = {
value = it
},
onError = { url, errorCode, exceptionMessage ->
Log.e("RelayInfo", "Error loading relay info for ${url.url}: $errorCode - $exceptionMessage")
},
)
}
@Composable
fun ShortEphemeralChatChannelHeader(
@@ -119,16 +93,16 @@ private fun DrawRelayIcon(
channel: EphemeralChatChannel,
accountViewModel: AccountViewModel,
) {
val relayInfo by loadRelayInfo(channel.roomId.relayUrl, accountViewModel)
val relayInfo by loadRelayInfo(channel.roomId.relayUrl)
RobohashFallbackAsyncImage(
robot = channel.roomId.toKey(),
model = relayInfo?.icon,
model = relayInfo.icon,
contentDescription = stringRes(R.string.profile_image),
contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -41,7 +41,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
@@ -89,7 +88,7 @@ fun LongPublicChatChannelHeader(
contentDescription = stringRes(R.string.channel_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
}
@@ -37,7 +37,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
@@ -72,7 +71,7 @@ fun ShortPublicChatChannelHeader(
contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -210,7 +210,7 @@ class ChannelMetadataViewModel : ViewModel() {
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) },
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {},
httpAuth = account::createHTTPAuthorization,
context = context,
@@ -223,7 +223,7 @@ class ChannelMetadataViewModel : ViewModel() {
alt = null,
sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) },
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth,
context = context,
)
@@ -34,7 +34,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
@@ -74,7 +73,7 @@ fun ChannelFileUploadDialog(
contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
@@ -582,9 +582,7 @@ open class ChannelNewMessageViewModel :
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link.url) {
Amethyst.instance.okHttpClients.getHttpClient(
accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url),
)
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link.url)
}
}
@@ -52,10 +52,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent
@@ -74,7 +74,6 @@ import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.LoadEphemeralChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp
@@ -197,7 +196,7 @@ private fun ChannelRoomCompose(
channelLastContent = "$authorName: $description",
hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(channel)) },
)
}
@@ -214,7 +213,7 @@ private fun ChannelRoomCompose(
val channel = channelState?.channel as? EphemeralChatChannel ?: return
val relayInfo by loadRelayInfo(channel.roomId.relayUrl, accountViewModel)
val relayInfo by loadRelayInfo(channel.roomId.relayUrl)
val noteEvent = lastMessage.event
val description = noteEvent?.content?.take(200)
@@ -229,7 +228,7 @@ private fun ChannelRoomCompose(
channelLastContent = "$authorName: $description",
hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(channel)) },
)
}
@@ -541,10 +541,9 @@ open class NewProductViewModel :
val wordToInsert = item.link.url + " "
viewModelScope.launch(Dispatchers.IO) {
iMetaDescription.downloadAndPrepare(
item.link.url,
{ Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.privacyState?.shouldUseTorForImageDownload(item.link.url) ?: false) },
)
iMetaDescription.downloadAndPrepare(item.link.url) {
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link.url)
}
}
message = message.replaceCurrentWord(wordToInsert)
@@ -815,12 +815,8 @@ open class ShortNotePostViewModel :
val wordToInsert = item.link.url + " "
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(
item.link.url,
) {
Amethyst.instance.okHttpClients.getHttpClient(
accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url),
)
iMetaAttachments.downloadAndPrepare(item.link.url) {
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link.url)
}
}
@@ -20,9 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications
import android.Manifest
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
@@ -31,29 +28,24 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.ui.components.SelectNotificationProvider
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable
fun NotificationScreen(
sharedPreferencesViewModel: SharedPreferencesViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
NotificationScreen(
notifFeedContentState = accountViewModel.feedStates.notifications,
notifSummaryState = accountViewModel.feedStates.notificationSummary,
sharedPreferencesViewModel = sharedPreferencesViewModel,
sharedPrefs = accountViewModel.settings.uiSettingsFlow,
accountViewModel = accountViewModel,
nav = nav,
)
@@ -63,11 +55,11 @@ fun NotificationScreen(
fun NotificationScreen(
notifFeedContentState: CardFeedContentState,
notifSummaryState: NotificationSummaryState,
sharedPreferencesViewModel: SharedPreferencesViewModel,
sharedPrefs: UiSettingsFlow,
accountViewModel: AccountViewModel,
nav: INav,
) {
SelectNotificationProvider(sharedPreferencesViewModel)
SelectNotificationProvider(sharedPrefs)
WatchAccountForNotifications(notifFeedContentState, accountViewModel)
@@ -106,29 +98,6 @@ fun NotificationScreen(
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun checkifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel: SharedPreferencesViewModel): PermissionState {
val notificationPermissionState =
rememberPermissionState(
Manifest.permission.POST_NOTIFICATIONS,
)
if (!sharedPreferencesViewModel.sharedPrefs.dontAskForNotificationPermissions) {
if (!notificationPermissionState.status.isGranted) {
sharedPreferencesViewModel.dontAskForNotificationPermissions()
// This will pause the APP, including the connection with relays.
LaunchedEffect(notificationPermissionState) {
notificationPermissionState.launchPermissionRequest()
}
}
}
return notificationPermissionState
}
@Composable
fun WatchAccountForNotifications(
notifFeedContentState: CardFeedContentState,
@@ -585,7 +585,7 @@ class NewPublicMessageViewModel :
viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link.url) {
Amethyst.instance.okHttpClients.getHttpClient(accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url))
Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link.url)
}
}
@@ -28,32 +28,47 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.torSettings
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.tor.PrivacySettingsBody
import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivacyOptionsScreen(
accountViewModel: AccountViewModel,
torSettingsFlow: TorSettingsFlow,
nav: INav,
) {
val dialogViewModel = viewModel<TorDialogViewModel>()
LaunchedEffect(dialogViewModel, accountViewModel) {
dialogViewModel.reset(
accountViewModel.account.settings.torSettings
.toSettings(),
)
}
// runs only once and before the rest of the screen is build
// to avoid blinking and animations from the default/previous
// state to the current state
val init =
remember(dialogViewModel) {
val torSettings = torSettingsFlow.toSettings()
dialogViewModel.reset(torSettings)
torSettings
}
PrivacyOptionsScreenContents(dialogViewModel, onPost = torSettingsFlow::update, nav)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PrivacyOptionsScreenContents(
dialogViewModel: TorDialogViewModel,
onPost: (TorSettings) -> Unit,
nav: INav,
) {
Scaffold(
topBar = {
SavingTopBar(
@@ -62,7 +77,7 @@ fun PrivacyOptionsScreen(
nav.popBack()
},
onPost = {
accountViewModel.setTorSettings(dialogViewModel.save())
onPost(dialogViewModel.save())
nav.popBack()
},
)
@@ -297,7 +297,7 @@ fun UrlVideoView(
callbackUri = content.uri,
mimeType = content.mimeType,
aspectRatio = ratio,
proxyPort = accountViewModel.proxyPortForVideo(content.url),
proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(content.url),
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
@@ -89,7 +89,7 @@ private fun GalleryFeedLoaded(
val items by loaded.feed.collectAsStateWithLifecycle()
val ratio =
if (accountViewModel.settings.modernGalleryStyle.value) {
if (accountViewModel.settings.modernGalleryStyle()) {
0.8f
} else {
1.0f
@@ -34,7 +34,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
@@ -194,7 +193,7 @@ private fun RenderBadgeImage(
robot = "badgenotfound",
contentDescription = description,
modifier = BadgePictureModifier,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
} else {
RobohashFallbackAsyncImage(
@@ -203,7 +202,7 @@ private fun RenderBadgeImage(
contentDescription = description,
modifier = BadgePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
}
@@ -55,7 +55,6 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.DisplayNIP05
@@ -166,7 +165,7 @@ fun ShowQRDialog(
contentDescription = stringRes(R.string.profile_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
)
}
Row(
@@ -51,7 +51,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
@@ -61,7 +61,6 @@ import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton
import com.vitorpamplona.amethyst.ui.stringRes
@@ -126,7 +125,7 @@ fun RelayInformationScreen(
)
},
) { pad ->
val relayInfo by loadRelayInfo(relay, accountViewModel)
val relayInfo by loadRelayInfo(relay)
val messages =
remember(relay) {
@@ -158,7 +157,7 @@ fun RelayInformationScreen(
displayUrl = relay.displayUrl(),
iconUrl = relayInfo.icon,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
RelayStats.get(relay).pingInMs,
iconModifier = LargeRelayIconModifier,
)
@@ -37,11 +37,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding
@@ -80,7 +80,7 @@ fun BasicRelaySetupInfoClickableRow(
verticalAlignment = Alignment.CenterVertically,
modifier = HalfVertPadding,
) {
val iconUrlFromRelayInfoDoc by loadRelayInfo(item.relay, accountViewModel)
val iconUrlFromRelayInfoDoc by loadRelayInfo(item.relay)
RenderRelayIcon(
iconUrlFromRelayInfoDoc.id ?: item.relay.displayUrl(),
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -37,7 +36,7 @@ fun BasicRelaySetupInfoDialog(
BasicRelaySetupInfoClickableRow(
item = item,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onDelete = onDelete,
accountViewModel = accountViewModel,
onClick = { nav.nav(Route.RelayInfo(item.relay.url)) },
@@ -24,7 +24,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.replace
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -69,11 +68,8 @@ abstract class BasicRelaySetupInfoModel : ViewModel() {
fun loadRelayDocuments() {
viewModelScope.launch(Dispatchers.IO) {
_relays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
Amethyst.instance.nip11Cache.loadRelayInfo(
relay = item.relay,
okHttpClient = {
Amethyst.instance.okHttpClients.getHttpClient(account.torRelayState.shouldUseTorForClean(item.relay))
},
onInfo = {
togglePaidRelay(item, it.limitation?.payment_required ?: false)
},
@@ -91,7 +87,7 @@ abstract class BasicRelaySetupInfoModel : ViewModel() {
relaySetupInfoBuilder(
normalized = it,
forcesTor =
account.torRelayState.flow.value
Amethyst.instance.torEvaluatorFlow.flow.value
.useTor(it),
)
}.distinctBy { it.relay }
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
@@ -36,7 +37,7 @@ class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() {
relay = it,
relayStat = RelayStats.get(it),
forcesTor =
account.torRelayState.flow.value
Amethyst.instance.torEvaluatorFlow.flow.value
.useTor(it),
users =
account.followsPerRelay.value[it]?.mapNotNull { hex ->
@@ -25,7 +25,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.replace
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo
@@ -91,9 +90,8 @@ class Nip65RelayListViewModel : ViewModel() {
fun loadRelayDocuments() {
viewModelScope.launch(Dispatchers.IO) {
_homeRelays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
Amethyst.instance.nip11Cache.loadRelayInfo(
relay = item.relay,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.torRelayState.shouldUseTorForClean(item.relay)) },
onInfo = {
toggleHomePaidRelay(item, it.limitation?.payment_required ?: false)
},
@@ -102,9 +100,8 @@ class Nip65RelayListViewModel : ViewModel() {
}
_notificationRelays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo(
Amethyst.instance.nip11Cache.loadRelayInfo(
relay = item.relay,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.torRelayState.shouldUseTorForClean(item.relay)) },
onInfo = {
toggleNotifPaidRelay(item, it.limitation?.payment_required ?: false)
},
@@ -55,8 +55,8 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.TextSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
@@ -69,7 +69,6 @@ import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.note.SearchIcon
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.ChannelName
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -294,7 +293,7 @@ private fun DisplaySearchResults(
channelLastContent = item.summary(),
hasNewMessages = false,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(item)) },
)
@@ -308,7 +307,7 @@ private fun DisplaySearchResults(
ephemeralChannels,
key = { _, item -> "ephem" + item.roomId.toKey() },
) { _, item ->
val relayInfo by loadRelayInfo(item.roomId.relayUrl, accountViewModel)
val relayInfo by loadRelayInfo(item.roomId.relayUrl)
ChannelName(
channelIdHex = item.roomId.toKey(),
@@ -323,7 +322,7 @@ private fun DisplaySearchResults(
channelLastContent = stringRes(R.string.ephemeral_relay_chat),
hasNewMessages = false,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(item)) },
)
@@ -350,7 +349,7 @@ private fun DisplaySearchResults(
channelLastContent = item.summary(),
hasNewMessages = false,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE,
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(item)) },
)
@@ -24,7 +24,6 @@ import android.content.Context
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.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -35,6 +34,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -50,6 +51,7 @@ import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.model.parseFeatureSetType
@@ -61,11 +63,9 @@ import com.vitorpamplona.amethyst.ui.components.TitleExplainer
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
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.RowColSpacing
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
@@ -78,6 +78,55 @@ import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException
import java.io.IOException
@Composable
fun SettingsScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
TopBarWithBackButton(stringRes(id = R.string.application_preferences), nav::popBack)
},
accountViewModel = accountViewModel,
) {
Column(Modifier.padding(it)) {
SettingsScreen(accountViewModel.settings.uiSettingsFlow)
}
}
}
@Preview(device = "spec:width=2160px,height=2340px,dpi=440")
@Composable
fun SettingsScreenPreview() {
ThemeComparisonRow {
SettingsScreen(UiSettingsFlow())
}
}
@Composable
fun SettingsScreen(sharedPrefs: UiSettingsFlow) {
Column(
Modifier
.fillMaxSize()
.padding(top = Size10dp, start = Size20dp, end = Size20dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = RowColSpacing,
) {
ShowLanguageChoice(sharedPrefs)
ShowThemeChoice(sharedPrefs)
ShowImagePreviewChoice(sharedPrefs)
ShowVideoPlaybackChoice(sharedPrefs)
ShowUrlPreviewChoice(sharedPrefs)
ShowProfilePictureChoice(sharedPrefs)
ImmersiveScrollingChoice(sharedPrefs)
FeatureSetChoice(sharedPrefs)
GalleryChoice(sharedPrefs)
PushNotificationSettingsRow(sharedPrefs)
}
}
fun Context.getLocaleListFromXml(): LocaleListCompat {
val tagsList = mutableListOf<CharSequence>()
try {
@@ -116,9 +165,8 @@ fun Context.getLangPreferenceDropdownEntries(): ImmutableMap<String, String> {
fun getLanguageIndex(
languageEntries: ImmutableMap<String, String>,
sharedPreferencesViewModel: SharedPreferencesViewModel,
language: String?,
): Int {
val language = sharedPreferencesViewModel.sharedPrefs.language
var languageIndex: Int
languageIndex =
if (language != null) {
@@ -134,55 +182,153 @@ fun getLanguageIndex(
}
@Composable
fun SettingsScreen(
sharedPreferencesViewModel: SharedPreferencesViewModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
DisappearingScaffold(
isInvertedLayout = false,
topBar = {
TopBarWithBackButton(stringRes(id = R.string.application_preferences), nav::popBack)
},
accountViewModel = accountViewModel,
fun ShowLanguageChoice(sharedPrefs: UiSettingsFlow) {
val context = LocalContext.current
val languageEntries = remember { context.getLangPreferenceDropdownEntries() }
val languageList = remember { languageEntries.keys.map { TitleExplainer(it) }.toImmutableList() }
val language by sharedPrefs.preferredLanguage.collectAsState()
val languageIndex = getLanguageIndex(languageEntries, language)
SettingsRow(
R.string.language,
R.string.language_description,
languageList,
languageIndex,
) {
Column(Modifier.padding(it)) {
SettingsScreen(sharedPreferencesViewModel)
}
}
}
@Preview(device = "spec:width=2160px,height=2340px,dpi=440")
@Composable
fun SettingsScreenPreview() {
val sharedPreferencesViewModel = mockSharedPreferencesViewModel()
ThemeComparisonRow {
SettingsScreen(sharedPreferencesViewModel)
sharedPrefs.preferredLanguage.tryEmit(languageEntries[languageList[it].title])
}
}
@Composable
fun SettingsScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) {
val selectedItens =
fun ShowThemeChoice(sharedPrefs: UiSettingsFlow) {
val themeOptions =
persistentListOf(
TitleExplainer(stringRes(ThemeType.SYSTEM.resourceId)),
TitleExplainer(stringRes(ThemeType.LIGHT.resourceId)),
TitleExplainer(stringRes(ThemeType.DARK.resourceId)),
)
val themeIndex by sharedPrefs.theme.collectAsState()
SettingsRow(
R.string.theme,
R.string.theme_description,
themeOptions,
themeIndex.screenCode,
) {
sharedPrefs.theme.tryEmit(parseThemeType(it))
}
}
@Composable
fun ShowImagePreviewChoice(sharedPrefs: UiSettingsFlow) {
val connectivityBasedOptions =
persistentListOf(
TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)),
TitleExplainer(stringRes(ConnectivityType.WIFI_ONLY.resourceId)),
TitleExplainer(stringRes(ConnectivityType.NEVER.resourceId)),
)
val themeItens =
val showImagesIndex by sharedPrefs.automaticallyShowImages.collectAsState()
SettingsRow(
R.string.automatically_load_images_gifs,
R.string.automatically_load_images_gifs_description,
connectivityBasedOptions,
showImagesIndex.screenCode,
) {
sharedPrefs.automaticallyShowImages.tryEmit(parseConnectivityType(it))
}
}
@Composable
fun ShowVideoPlaybackChoice(sharedPrefs: UiSettingsFlow) {
val connectivityBasedOptions =
persistentListOf(
TitleExplainer(stringRes(ThemeType.SYSTEM.resourceId)),
TitleExplainer(stringRes(ThemeType.LIGHT.resourceId)),
TitleExplainer(stringRes(ThemeType.DARK.resourceId)),
TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)),
TitleExplainer(stringRes(ConnectivityType.WIFI_ONLY.resourceId)),
TitleExplainer(stringRes(ConnectivityType.NEVER.resourceId)),
)
val videoIndex by sharedPrefs.automaticallyStartPlayback.collectAsState()
SettingsRow(
R.string.automatically_play_videos,
R.string.automatically_play_videos_description,
connectivityBasedOptions,
videoIndex.screenCode,
) {
sharedPrefs.automaticallyStartPlayback.tryEmit(parseConnectivityType(it))
}
}
@Composable
fun ShowUrlPreviewChoice(sharedPrefs: UiSettingsFlow) {
val connectivityBasedOptions =
persistentListOf(
TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)),
TitleExplainer(stringRes(ConnectivityType.WIFI_ONLY.resourceId)),
TitleExplainer(stringRes(ConnectivityType.NEVER.resourceId)),
)
val linkIndex by sharedPrefs.automaticallyShowUrlPreview.collectAsState()
SettingsRow(
R.string.automatically_show_url_preview,
R.string.automatically_show_url_preview_description,
connectivityBasedOptions,
linkIndex.screenCode,
) {
sharedPrefs.automaticallyShowUrlPreview.tryEmit(parseConnectivityType(it))
}
}
@Composable
fun ShowProfilePictureChoice(sharedPrefs: UiSettingsFlow) {
val profilePictureIndex by sharedPrefs.automaticallyShowProfilePictures.collectAsState()
val connectivityBasedOptions =
persistentListOf(
TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)),
TitleExplainer(stringRes(ConnectivityType.WIFI_ONLY.resourceId)),
TitleExplainer(stringRes(ConnectivityType.NEVER.resourceId)),
)
SettingsRow(
R.string.automatically_show_profile_picture,
R.string.automatically_show_profile_picture_description,
connectivityBasedOptions,
profilePictureIndex.screenCode,
) {
sharedPrefs.automaticallyShowProfilePictures.tryEmit(parseConnectivityType(it))
}
}
@Composable
fun ImmersiveScrollingChoice(sharedPrefs: UiSettingsFlow) {
val hideNavBarsIndex by sharedPrefs.automaticallyHideNavigationBars.collectAsState()
val booleanItems =
persistentListOf(
TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)),
TitleExplainer(stringRes(ConnectivityType.NEVER.resourceId)),
)
SettingsRow(
R.string.automatically_hide_nav_bars,
R.string.automatically_hide_nav_bars_description,
booleanItems,
hideNavBarsIndex.screenCode,
) {
sharedPrefs.automaticallyHideNavigationBars.tryEmit(parseBooleanType(it))
}
}
@Composable
fun FeatureSetChoice(sharedPrefs: UiSettingsFlow) {
val featureItems =
persistentListOf(
TitleExplainer(stringRes(FeatureSetType.COMPLETE.resourceId)),
@@ -190,136 +336,35 @@ fun SettingsScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) {
TitleExplainer(stringRes(FeatureSetType.PERFORMANCE.resourceId)),
)
val featureSetIndex by sharedPrefs.featureSet.collectAsState()
SettingsRow(
R.string.ui_style,
R.string.ui_style_description,
featureItems,
featureSetIndex.screenCode,
) {
sharedPrefs.featureSet.tryEmit(parseFeatureSetType(it))
}
}
@Composable
fun GalleryChoice(sharedPrefs: UiSettingsFlow) {
val galleryItems =
persistentListOf(
TitleExplainer(stringRes(ProfileGalleryType.CLASSIC.resourceId)),
TitleExplainer(stringRes(ProfileGalleryType.MODERN.resourceId)),
)
val showImagesIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyShowImages.screenCode
val videoIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyStartPlayback.screenCode
val linkIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyShowUrlPreview.screenCode
val hideNavBarsIndex =
sharedPreferencesViewModel.sharedPrefs.automaticallyHideNavigationBars.screenCode
val profilePictureIndex =
sharedPreferencesViewModel.sharedPrefs.automaticallyShowProfilePictures.screenCode
val themeIndex = sharedPreferencesViewModel.sharedPrefs.theme.screenCode
val galleryIndex by sharedPrefs.gallerySet.collectAsState()
val context = LocalContext.current
val languageEntries = remember { context.getLangPreferenceDropdownEntries() }
val languageList = remember { languageEntries.keys.map { TitleExplainer(it) }.toImmutableList() }
val languageIndex = getLanguageIndex(languageEntries, sharedPreferencesViewModel)
val featureSetIndex =
sharedPreferencesViewModel.sharedPrefs.featureSet.screenCode
val galleryIndex =
sharedPreferencesViewModel.sharedPrefs.gallerySet.screenCode
Column(
Modifier
.fillMaxSize()
.padding(top = Size10dp, start = Size20dp, end = Size20dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
SettingsRow(
R.string.gallery_style,
R.string.gallery_style_description,
galleryItems,
galleryIndex.screenCode,
) {
SettingsRow(
R.string.language,
R.string.language_description,
languageList,
languageIndex,
) {
sharedPreferencesViewModel.updateLanguage(languageEntries[languageList[it].title])
}
Spacer(modifier = HalfVertSpacer)
SettingsRow(
R.string.theme,
R.string.theme_description,
themeItens,
themeIndex,
) {
sharedPreferencesViewModel.updateTheme(parseThemeType(it))
}
Spacer(modifier = HalfVertSpacer)
SettingsRow(
R.string.automatically_load_images_gifs,
R.string.automatically_load_images_gifs_description,
selectedItens,
showImagesIndex,
) {
sharedPreferencesViewModel.updateAutomaticallyShowImages(parseConnectivityType(it))
}
Spacer(modifier = HalfVertSpacer)
SettingsRow(
R.string.automatically_play_videos,
R.string.automatically_play_videos_description,
selectedItens,
videoIndex,
) {
sharedPreferencesViewModel.updateAutomaticallyStartPlayback(parseConnectivityType(it))
}
Spacer(modifier = HalfVertSpacer)
SettingsRow(
R.string.automatically_show_url_preview,
R.string.automatically_show_url_preview_description,
selectedItens,
linkIndex,
) {
sharedPreferencesViewModel.updateAutomaticallyShowUrlPreview(parseConnectivityType(it))
}
SettingsRow(
R.string.automatically_show_profile_picture,
R.string.automatically_show_profile_picture_description,
selectedItens,
profilePictureIndex,
) {
sharedPreferencesViewModel.updateAutomaticallyShowProfilePicture(parseConnectivityType(it))
}
Spacer(modifier = HalfVertSpacer)
SettingsRow(
R.string.automatically_hide_nav_bars,
R.string.automatically_hide_nav_bars_description,
booleanItems,
hideNavBarsIndex,
) {
sharedPreferencesViewModel.updateAutomaticallyHideNavBars(parseBooleanType(it))
}
Spacer(modifier = HalfVertSpacer)
SettingsRow(
R.string.ui_style,
R.string.ui_style_description,
featureItems,
featureSetIndex,
) {
sharedPreferencesViewModel.updateFeatureSetType(parseFeatureSetType(it))
}
Spacer(modifier = HalfVertSpacer)
Spacer(modifier = HalfVertSpacer)
SettingsRow(
R.string.gallery_style,
R.string.gallery_style_description,
galleryItems,
galleryIndex,
) {
sharedPreferencesViewModel.updateGallerySetType(parseGalleryType(it))
}
PushNotificationSettingsRow(sharedPreferencesViewModel)
sharedPrefs.gallerySet.tryEmit(parseGalleryType(it))
}
}
@@ -327,15 +372,15 @@ fun SettingsScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) {
fun SettingsRow(
name: Int,
description: Int,
selectedItens: ImmutableList<TitleExplainer>,
selectedItems: ImmutableList<TitleExplainer>,
selectedIndex: Int,
onSelect: (Int) -> Unit,
) {
SettingsRow(name, description) {
TextSpinner(
label = "",
placeholder = selectedItens[selectedIndex].title,
options = selectedItens,
placeholder = selectedItems[selectedIndex].title,
options = selectedItems,
onSelect = onSelect,
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)),
)
@@ -51,7 +51,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.ClickableBox
@@ -327,7 +326,7 @@ private fun RenderAuthorInformation(
NoteUsernameDisplay(note, Modifier.weight(1f), accountViewModel = accountViewModel)
VideoUserOptionAction(note, accountViewModel, nav)
}
if (accountViewModel.settings.featureSet == FeatureSetType.COMPLETE) {
if (accountViewModel.settings.isCompleteUIMode()) {
Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status(
note.author!!,
@@ -32,15 +32,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.preferences.AccountPreferenceStores.Companion.torSettings
import com.vitorpamplona.amethyst.ui.components.appendLink
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.TorSettingsFlow
@Composable
fun TorSettingsSetup(
torSettings: TorSettings,
onCheckedChange: (TorSettings) -> Unit,
torSettingsFlow: TorSettingsFlow,
onError: (String) -> Unit,
) {
var connectOrbotDialogOpen by remember { mutableStateOf(false) }
@@ -57,11 +57,11 @@ fun TorSettingsSetup(
if (connectOrbotDialogOpen) {
ConnectTorDialog(
torSettings = torSettings,
torSettings = torSettingsFlow.toSettings(),
onClose = { connectOrbotDialogOpen = false },
onPost = { torSettings ->
connectOrbotDialogOpen = false
onCheckedChange(torSettings)
torSettingsFlow.update(torSettings)
},
onError = onError,
)
@@ -74,6 +74,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
@@ -170,15 +171,12 @@ fun LoginPage(
}
}
Spacer(modifier = Modifier.height(10.dp))
PasswordField(loginViewModel)
Spacer(modifier = Modifier.height(10.dp))
TorSettingsSetup(
torSettings = loginViewModel.torSettings,
onCheckedChange = loginViewModel::updateTorSettings,
torSettingsFlow = Amethyst.instance.torPrefs.value,
onError = {
scope.launch {
Toast
@@ -262,6 +260,8 @@ fun OfferTemporaryAccount(
@Composable
private fun PasswordField(loginViewModel: LoginViewModel) {
if (loginViewModel.needsPassword) {
Spacer(modifier = Modifier.height(10.dp))
val passwordFocusRequester = remember { FocusRequester() }
PasswordField(
@@ -28,7 +28,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.tor.TorSettings
class LoginViewModel : ViewModel() {
lateinit var accountStateViewModel: AccountStateViewModel
@@ -39,7 +38,6 @@ class LoginViewModel : ViewModel() {
var acceptedTerms by mutableStateOf(false)
var termsAcceptanceIsRequiredError by mutableStateOf(false)
var torSettings by mutableStateOf(TorSettings())
var offerTemporaryLogin by mutableStateOf(false)
var isTemporary by mutableStateOf(false)
@@ -77,7 +75,6 @@ class LoginViewModel : ViewModel() {
processingLogin = false
isTemporary = false
offerTemporaryLogin = false
torSettings = TorSettings()
isFirstLogin = false
}
@@ -98,10 +95,6 @@ class LoginViewModel : ViewModel() {
errorManager.clearErrors()
}
fun updateTorSettings(newTorSettings: TorSettings) {
torSettings = newTorSettings
}
fun updateAcceptedTerms(newAcceptedTerms: Boolean) {
acceptedTerms = newAcceptedTerms
if (newAcceptedTerms) {
@@ -140,7 +133,6 @@ class LoginViewModel : ViewModel() {
accountStateViewModel.login(
key = key.text,
password = password.text,
torSettings = torSettings,
transientAccount = isTemporary,
) {
processingLogin = false
@@ -158,7 +150,6 @@ class LoginViewModel : ViewModel() {
processingLogin = true
accountStateViewModel.login(
key = key.text,
torSettings = torSettings,
transientAccount = isTemporary,
loginWithExternalSigner = true,
packageName = packageName,
@@ -49,6 +49,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
@@ -165,6 +166,20 @@ fun SignUpPage(
Spacer(modifier = Modifier.height(10.dp))
TorSettingsSetup(
torSettingsFlow = Amethyst.instance.torPrefs.value,
onError = {
scope.launch {
Toast
.makeText(
context,
it,
Toast.LENGTH_LONG,
).show()
}
},
)
AcceptTerms(
checked = signUpViewModel.acceptedTerms,
onCheckedChange = signUpViewModel::updateAcceptedTerms,
@@ -178,21 +193,6 @@ fun SignUpPage(
)
}
TorSettingsSetup(
torSettings = signUpViewModel.torSettings,
onCheckedChange = signUpViewModel::updateTorSettings,
onError = {
scope.launch {
Toast
.makeText(
context,
it,
Toast.LENGTH_LONG,
).show()
}
},
)
Spacer(modifier = Modifier.height(Size10dp))
Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) {
@@ -28,7 +28,6 @@ import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager
import com.vitorpamplona.amethyst.ui.tor.TorSettings
class SignUpViewModel : ViewModel() {
lateinit var accountStateViewModel: AccountStateViewModel
@@ -40,8 +39,6 @@ class SignUpViewModel : ViewModel() {
var acceptedTerms by mutableStateOf(false)
var termsAcceptanceIsRequiredError by mutableStateOf(false)
var torSettings by mutableStateOf(TorSettings())
fun init(accountStateViewModel: AccountStateViewModel) {
this.accountStateViewModel = accountStateViewModel
}
@@ -51,10 +48,6 @@ class SignUpViewModel : ViewModel() {
errorManager.clearErrors()
}
fun updateTorSettings(newTorSettings: TorSettings) {
torSettings = newTorSettings
}
fun updateAcceptedTerms(newAcceptedTerms: Boolean) {
acceptedTerms = newAcceptedTerms
if (newAcceptedTerms) {
@@ -80,7 +73,7 @@ class SignUpViewModel : ViewModel() {
fun signup() {
if (checkCanSignup()) {
accountStateViewModel.newKey(torSettings, displayName.text)
accountStateViewModel.newKey(displayName.text)
}
}
}
@@ -54,10 +54,12 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.resolveDefaults
import com.patrykandpatrick.vico.compose.common.VicoTheme
import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.ThemeType
private val DarkColorPalette =
@@ -482,6 +484,14 @@ val chartDarkColors =
val ColorScheme.chartStyle: VicoTheme
get() = if (isLight) chartLightColors else chartDarkColors
@Composable
fun AmethystTheme(content: @Composable () -> Unit) {
val theme by Amethyst.instance.uiPrefs.value.theme
.collectAsStateWithLifecycle()
AmethystTheme(theme, content)
}
@Composable
fun AmethystTheme(
prefTheme: ThemeType,
@@ -20,12 +20,19 @@
*/
package com.vitorpamplona.amethyst.ui.tor
import android.app.Application
import android.content.Context
import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
/**
* There should be only one instance of the Tor binding per app.
@@ -33,15 +40,41 @@ import kotlinx.coroutines.flow.stateIn
* Tor will connect as soon as status is listened to.
*/
class TorManager(
app: Application,
torPrefs: TorSharedPreferences,
app: Context,
scope: CoroutineScope,
) {
val status: StateFlow<TorServiceStatus> =
TorService(app).status.stateIn(
scope,
SharingStarted.WhileSubscribed(30000),
TorServiceStatus.Off,
)
val service = TorService(app)
@OptIn(ExperimentalCoroutinesApi::class)
val status =
combine(
torPrefs.value.torType,
torPrefs.value.externalSocksPort,
) { torType, externalSocksPort ->
Pair(torType, externalSocksPort)
}.transformLatest { (torType, externalSocksPort) ->
when (torType) {
TorType.INTERNAL -> {
emitAll(service.status)
}
TorType.OFF -> {
emit(TorServiceStatus.Off)
}
TorType.EXTERNAL -> {
if (externalSocksPort > 0) {
emit(TorServiceStatus.Active(externalSocksPort))
} else {
emitAll(service.status)
}
}
}
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.WhileSubscribed(30000),
TorServiceStatus.Off,
)
val activePortOrNull: StateFlow<Int?> =
status
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.tor
import com.vitorpamplona.amethyst.R
class TorSettings(
data class TorSettings(
val torType: TorType = TorType.INTERNAL,
val externalSocksPort: Int = 9050,
val onionRelaysViaTor: Boolean = true,
@@ -94,6 +94,8 @@ class TorSettingsFlow(
fun update(torSettings: TorSettings): Boolean {
var any = false
println("AABBCC updating 1 $torSettings")
if (torType.value != torSettings.torType) {
torType.tryEmit(torSettings.torType)
any = true
@@ -148,6 +150,8 @@ class TorSettingsFlow(
any = true
}
println("AABBCC updating 2 ${toSettings()}")
return any
}
@@ -20,19 +20,36 @@
*/
package com.vitorpamplona.amethyst.ui.components
import android.Manifest
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.checkifItNeedsToRequestNotificationPermission
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.model.UiSettingsFlow
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) {
fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
checkifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel)
val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
if (!notificationPermissionState.status.isGranted) {
val dontAskForNotificationPermissions by sharedPrefs.dontAskForNotificationPermissions.collectAsStateWithLifecycle()
if (!dontAskForNotificationPermissions) {
sharedPrefs.dontAskForNotificationPermissions()
// This will pause the APP, including the connection with relays.
LaunchedEffect(notificationPermissionState) {
notificationPermissionState.launchPermissionRequest()
}
}
}
}
}
@Composable
fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) {}
fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {}
@@ -459,7 +459,7 @@ open class BasicRelayClient(
)
}
socket?.let {
// Log.d(logTag, "Sending: $str")
Log.d(logTag, "Sending: $str")
val result = it.send(str)
listener.onSend(this@BasicRelayClient, str, result)
stats.addBytesSent(str.bytesUsedInMemory())
@@ -24,35 +24,31 @@ import android.util.LruCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils
class VerificationStateCache {
class VerificationStateCache(
val otsResolverBuilder: OtsResolverBuilder,
) {
private val cache = LruCache<HexKey, VerificationState>(200)
fun verify(
event: OtsEvent,
resolverBuilder: OtsResolverBuilder,
): VerificationState {
fun verify(event: OtsEvent): VerificationState {
cache.put(event.id, VerificationState.Verifying)
return event.verifyState(resolverBuilder.build()).also { cache.put(event.id, it) }
return event.verifyState(otsResolverBuilder.build()).also { cache.put(event.id, it) }
}
fun cacheVerify(
event: OtsEvent,
resolverBuilder: OtsResolverBuilder,
): VerificationState =
fun cacheVerify(event: OtsEvent): VerificationState =
when (val verif = cache[event.id]) {
is VerificationState.Verifying -> verif
is VerificationState.Verified -> verif
is VerificationState.NetworkError -> {
// try again in 5 mins
if (verif.time < TimeUtils.fiveMinutesAgo()) {
event.verifyState(resolverBuilder.build()).also { cache.put(event.id, it) }
event.verifyState(otsResolverBuilder.build()).also { cache.put(event.id, it) }
} else {
verify(event, resolverBuilder)
verify(event)
}
}
is VerificationState.Error -> verif
else -> {
verify(event, resolverBuilder)
verify(event)
}
}
}