- 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 package com.vitorpamplona.amethyst.ui.components
import android.Manifest
import android.os.Build
import android.util.Log import android.util.Log
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -36,6 +38,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -44,8 +47,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser import com.halilibo.richtext.commonmark.CommonmarkAstNodeParser
import com.halilibo.richtext.commonmark.MarkdownParseOptions import com.halilibo.richtext.commonmark.MarkdownParseOptions
import com.halilibo.richtext.markdown.BasicMarkdown 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.material3.RichText
import com.halilibo.richtext.ui.resolveDefaults import com.halilibo.richtext.ui.resolveDefaults
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler import com.vitorpamplona.amethyst.service.notifications.PushDistributorHandler
import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog
import com.vitorpamplona.amethyst.ui.components.TitleExplainer 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.screen.loggedIn.settings.SettingsRow
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -65,12 +69,31 @@ import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) { fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
val notificationPermissionState = val notificationPermissionGranted =
checkifItNeedsToRequestNotificationPermission(sharedPreferencesViewModel) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val notificationPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
if (notificationPermissionState.status.isGranted) { if (!notificationPermissionState.status.isGranted) {
if (!sharedPreferencesViewModel.sharedPrefs.dontShowPushNotificationSelector) { 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 val context = LocalContext.current
var distributorPresent by remember { var distributorPresent by remember {
mutableStateOf(PushDistributorHandler.savedDistributorExists()) mutableStateOf(PushDistributorHandler.savedDistributorExists())
@@ -84,8 +107,8 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
onSelect = { index -> onSelect = { index ->
if (list[index] == "None") { if (list[index] == "None") {
PushDistributorHandler.forceRemoveDistributor(context) PushDistributorHandler.forceRemoveDistributor(context)
sharedPreferencesViewModel.dontAskForNotificationPermissions() sharedPrefs.dontAskForNotificationPermissions()
sharedPreferencesViewModel.dontShowPushNotificationSelector() sharedPrefs.dontShowPushNotificationSelector()
} else { } else {
val fullDistributorName = list[index] val fullDistributorName = list[index]
PushDistributorHandler.saveDistributor(fullDistributorName) PushDistributorHandler.saveDistributor(fullDistributorName)
@@ -125,7 +148,7 @@ fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesView
TextButton( TextButton(
onClick = { onClick = {
distributorPresent = true distributorPresent = true
sharedPreferencesViewModel.dontShowPushNotificationSelector() sharedPrefs.dontShowPushNotificationSelector()
}, },
) { ) {
Text(stringRes(R.string.quick_action_dont_show_again_button)) Text(stringRes(R.string.quick_action_dont_show_again_button))
@@ -187,7 +210,7 @@ fun LoadDistributors(onInner: @Composable (String, ImmutableList<String>, Immuta
} }
@Composable @Composable
fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) { fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {
val context = LocalContext.current val context = LocalContext.current
LoadDistributors { currentDistributor, list, readableListWithExplainer -> LoadDistributors { currentDistributor, list, readableListWithExplainer ->
@@ -198,8 +221,8 @@ fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesVie
selectedIndex = list.indexOf(currentDistributor), selectedIndex = list.indexOf(currentDistributor),
) { index -> ) { index ->
if (list[index] == "None") { if (list[index] == "None") {
sharedPreferencesViewModel.dontAskForNotificationPermissions() sharedPrefs.dontAskForNotificationPermissions()
sharedPreferencesViewModel.dontShowPushNotificationSelector() sharedPrefs.dontShowPushNotificationSelector()
PushDistributorHandler.forceRemoveDistributor(context) PushDistributorHandler.forceRemoveDistributor(context)
} else { } else {
PushDistributorHandler.saveDistributor(list[index]) PushDistributorHandler.saveDistributor(list[index])
@@ -21,210 +21,33 @@
package com.vitorpamplona.amethyst package com.vitorpamplona.amethyst
import android.app.Application import android.app.Application
import android.content.ContentResolver
import android.util.Log 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.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() { class Amethyst : Application() {
val appAgent = "Amethyst/${BuildConfig.VERSION_NAME}" companion object {
lateinit var instance: AppModules
// Exists to avoid exceptions stopping the coroutine private set
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() }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.d("AmethystApp", "onCreate $this") Log.d("AmethystApp", "onCreate $this")
instance = this instance = AppModules(this)
Thread.setDefaultUncaughtExceptionHandler(UnexpectedCrashSaver(crashReportCache, applicationIOScope))
if (isDebug) { if (isDebug) {
Logging.setup() Logging.setup()
} }
// initializes diskcache on an IO thread. instance.initiate(this)
applicationIOScope.launch {
diskCache
videoCache
}
// registers to receive events
pokeyReceiver.register(this)
} }
override fun onTerminate() { override fun onTerminate() {
super.onTerminate() super.onTerminate()
Log.d("AmethystApp", "onTerminate $this") Log.d("AmethystApp", "onTerminate $this")
instance.terminate(this)
pokeyReceiver.unregister(this)
applicationIOScope.cancel("Application onTerminate $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. * 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) { override fun onTrimMemory(level: Int) {
super.onTrimMemory(level) super.onTrimMemory(level)
Log.d("AmethystApp", "onTrimMemory $level") Log.d("AmethystApp", "onTrimMemory $level")
applicationIOScope.launch(Dispatchers.Default) { instance.trim()
trimmingService.run(null, LocalPreferences.allSavedAccounts())
}
}
companion object {
lateinit var instance: Amethyst
private set
} }
} }
@@ -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.ALL_FOLLOWS
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS 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.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName 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.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -210,7 +208,7 @@ object LocalPreferences {
} }
private val prefsDirPath: String 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) { private suspend fun addAccount(accInfo: AccountInfo) {
val accounts = savedAccounts().filter { it.npub != accInfo.npub }.plus(accInfo) val accounts = savedAccounts().filter { it.npub != accInfo.npub }.plus(accInfo)
@@ -255,7 +253,7 @@ object LocalPreferences {
return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) { return if (BuildConfig.DEBUG && DEBUG_PLAINTEXT_PREFERENCES) {
val preferenceFile = val preferenceFile =
if (npub == null) DEBUG_PREFERENCES_NAME else "${DEBUG_PREFERENCES_NAME}_$npub" 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 { } else {
Amethyst.instance.encryptedStorage(npub) Amethyst.instance.encryptedStorage(npub)
} }
@@ -475,8 +473,6 @@ object LocalPreferences {
remove(PrefKeys.USE_PROXY) remove(PrefKeys.USE_PROXY)
remove(PrefKeys.PROXY_PORT) remove(PrefKeys.PROXY_PORT)
putString(PrefKeys.TOR_SETTINGS, JsonMapper.mapper.writeValueAsString(settings.torSettings.toSettings()))
val regularMap = val regularMap =
settings.lastReadPerRoute.value.mapValues { settings.lastReadPerRoute.value.mapValues {
it.value.value it.value.value
@@ -501,7 +497,7 @@ object LocalPreferences {
suspend fun loadAccountConfigFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadAccountConfigFromEncryptedStorage(it) } suspend fun loadAccountConfigFromEncryptedStorage(): AccountSettings? = currentAccount()?.let { loadAccountConfigFromEncryptedStorage(it) }
suspend fun saveSharedSettings( suspend fun saveSharedSettings(
sharedSettings: Settings, sharedSettings: UiSettings,
prefs: SharedPreferences = encryptedPreferences(), prefs: SharedPreferences = encryptedPreferences(),
) { ) {
Log.d("LocalPreferences", "Saving to shared settings") 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") Log.d("LocalPreferences", "Load shared settings")
with(prefs) { with(prefs) {
return try { 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) { } catch (e: Throwable) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
Log.w( Log.w(
@@ -598,8 +596,6 @@ object LocalPreferences {
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val torSettings = parseOrNull<TorSettings>(PrefKeys.TOR_SETTINGS) ?: TorSettings()
val lastReadPerRoute = val lastReadPerRoute =
parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues { parseOrNull<Map<String, Long>>(PrefKeys.LAST_READ_PER_ROUTE)?.mapValues {
MutableStateFlow(it.value) MutableStateFlow(it.value)
@@ -637,7 +633,6 @@ object LocalPreferences {
backupHashtagList = latestHashtagList, backupHashtagList = latestHashtagList,
backupGeohashList = latestGeohashList, backupGeohashList = latestGeohashList,
backupEphemeralChatList = latestEphemeralList, backupEphemeralChatList = latestEphemeralList,
torSettings = TorSettingsFlow.build(torSettings),
lastReadPerRoute = MutableStateFlow(lastReadPerRoute), lastReadPerRoute = MutableStateFlow(lastReadPerRoute),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion), hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
pendingAttestations = MutableStateFlow(pendingAttestations), pendingAttestations = MutableStateFlow(pendingAttestations),
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.model
import android.util.Log import android.util.Log
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser 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.nip78AppSpecific.AppSpecificState
import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState 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.MergedFollowListsState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState
import com.vitorpamplona.amethyst.model.serverList.MergedServerListState 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.FeedTopNavFilterState
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxLoaderState 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.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.FileHeader 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.hasAnyTaggedUser
import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUserIds
import com.vitorpamplona.quartz.nip01Core.tags.references.references import com.vitorpamplona.quartz.nip01Core.tags.references.references
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip03Timestamp.OtsResolverBuilder
import com.vitorpamplona.quartz.nip03Timestamp.ots.okhttp.OkHttpOtsResolverBuilder
import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache import com.vitorpamplona.quartz.nip04Dm.PrivateDMCache
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
@@ -226,7 +222,7 @@ class Account(
val signer: NostrSigner, val signer: NostrSigner,
val geolocationFlow: StateFlow<LocationState.LocationResult>, val geolocationFlow: StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: NWCPaymentFilterAssembler, val nwcFilterAssembler: NWCPaymentFilterAssembler,
val otsVerifCache: VerificationStateCache, val otsResolverBuilder: OtsResolverBuilder,
val cache: LocalCache, val cache: LocalCache,
val client: INostrClient, val client: INostrClient,
val scope: CoroutineScope, val scope: CoroutineScope,
@@ -323,17 +319,6 @@ class Account(
val chatroomList = cache.getOrCreateChatroomList(signer.pubKey) 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 newNotesPreProcessor = EventProcessor(this, cache)
val otsState = OtsState(signer, cache, otsResolverBuilder, scope, settings) 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.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition 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.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -132,7 +130,6 @@ class AccountSettings(
var backupHashtagList: HashtagListEvent? = null, var backupHashtagList: HashtagListEvent? = null,
var backupGeohashList: GeohashListEvent? = null, var backupGeohashList: GeohashListEvent? = null,
var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupEphemeralChatList: EphemeralChatListEvent? = null,
val torSettings: TorSettingsFlow = TorSettingsFlow(),
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()), val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()), var hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf<String>()),
val pendingAttestations: MutableStateFlow<Map<HexKey, String>> = MutableStateFlow<Map<HexKey, String>>(mapOf()), 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 // language services
// --- // ---
@@ -24,7 +24,7 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
@Stable @Stable
data class Settings( data class UiSettings(
val theme: ThemeType = ThemeType.SYSTEM, val theme: ThemeType = ThemeType.SYSTEM,
val preferredLanguage: String? = null, val preferredLanguage: String? = null,
val automaticallyShowImages: ConnectivityType = ConnectivityType.ALWAYS, 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.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal 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 com.vitorpamplona.quartz.nip55AndroidSigner.client.NostrSignerExternal
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -47,6 +47,7 @@ class AccountCacheState(
val geolocationFlow: StateFlow<LocationState.LocationResult>, val geolocationFlow: StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: NWCPaymentFilterAssembler, val nwcFilterAssembler: NWCPaymentFilterAssembler,
val contentResolverFn: () -> ContentResolver, val contentResolverFn: () -> ContentResolver,
val otsResolverBuilder: OtsResolverBuilder,
val cache: LocalCache, val cache: LocalCache,
val client: INostrClient, val client: INostrClient,
) { ) {
@@ -91,7 +92,7 @@ class AccountCacheState(
signer = signer, signer = signer,
geolocationFlow = geolocationFlow, geolocationFlow = geolocationFlow,
nwcFilterAssembler = nwcFilterAssembler, nwcFilterAssembler = nwcFilterAssembler,
otsVerifCache = VerificationStateCache(), otsResolverBuilder = otsResolverBuilder,
cache = cache, cache = cache,
client = client, client = client,
scope = 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 * 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. * 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 android.util.LruCache
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation 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.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 relayInformationEmptyCache = LruCache<NormalizedRelayUrl, Nip11RelayInformation>(1000)
private val relayInformationDocumentCache = LruCache<NormalizedRelayUrl, RetrieveResult?>(1000) private val relayInformationDocumentCache = LruCache<NormalizedRelayUrl, RetrieveResult?>(1000)
private val retriever = Nip11Retriever() private val retriever = Nip11Retriever(okHttpClient)
fun getEmpty(relay: NormalizedRelayUrl): Nip11RelayInformation { fun getEmpty(relay: NormalizedRelayUrl): Nip11RelayInformation {
relayInformationEmptyCache.get(relay)?.let { return it } relayInformationEmptyCache.get(relay)?.let { return it }
@@ -98,7 +68,6 @@ object Nip11CachedRetriever {
suspend fun loadRelayInfo( suspend fun loadRelayInfo(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
okHttpClient: (String) -> OkHttpClient,
onInfo: (Nip11RelayInformation) -> Unit, onInfo: (Nip11RelayInformation) -> Unit,
onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit, onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit,
) { ) {
@@ -110,33 +79,31 @@ object Nip11CachedRetriever {
if (doc.isValid()) { if (doc.isValid()) {
// just wait. // just wait.
} else { } else {
retrieve(relay, okHttpClient, onInfo, onError) retrieve(relay, onInfo, onError)
} }
} }
is RetrieveResult.Error -> { is RetrieveResult.Error -> {
if (doc.isValid()) { if (doc.isValid()) {
onError(relay, doc.error, null) onError(relay, doc.error, null)
} else { } 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 { } else {
retrieve(relay, okHttpClient, onInfo, onError) retrieve(relay, onInfo, onError)
} }
} }
private suspend fun retrieve( private suspend fun retrieve(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
okHttpClient: (String) -> OkHttpClient,
onInfo: (Nip11RelayInformation) -> Unit, onInfo: (Nip11RelayInformation) -> Unit,
onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit, onError: (NormalizedRelayUrl, Nip11Retriever.ErrorCode, String?) -> Unit,
) { ) {
relayInformationDocumentCache.put(relay, RetrieveResult.Loading(getEmpty(relay))) relayInformationDocumentCache.put(relay, RetrieveResult.Loading(getEmpty(relay)))
retriever.loadRelayInfo( retriever.loadRelayInfo(
relay = relay, relay = relay,
okHttpClient = okHttpClient,
onInfo = { onInfo = {
relayInformationDocumentCache.put(relay, RetrieveResult.Success(it)) relayInformationDocumentCache.put(relay, RetrieveResult.Success(it))
relayInformationEmptyCache.remove(relay) 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 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.amethyst.ui.tor.TorType
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import okhttp3.OkHttpClient
class PrivacyState( class RoleBasedHttpClientBuilder(
val settings: AccountSettings, val okHttpClient: DualHttpClientManager,
) { val torSettings: TorSettingsFlow,
) : IRoleBasedHttpClientBuilder {
fun shouldUseTorForImageDownload(url: String) = fun shouldUseTorForImageDownload(url: String) =
shouldUseTorFor( shouldUseTorFor(
url, url,
settings.torSettings.torType.value, torSettings.torType.value,
settings.torSettings.imagesViaTor.value, torSettings.imagesViaTor.value,
) )
fun shouldUseTorFor( fun shouldUseTorFor(
@@ -57,37 +60,37 @@ class PrivacyState(
} }
fun shouldUseTorForVideoDownload() = fun shouldUseTorForVideoDownload() =
when (settings.torSettings.torType.value) { when (torSettings.torType.value) {
TorType.OFF -> false TorType.OFF -> false
TorType.INTERNAL -> settings.torSettings.videosViaTor.value TorType.INTERNAL -> torSettings.videosViaTor.value
TorType.EXTERNAL -> settings.torSettings.videosViaTor.value TorType.EXTERNAL -> torSettings.videosViaTor.value
} }
fun shouldUseTorForVideoDownload(url: String) = fun shouldUseTorForVideoDownload(url: String) =
when (settings.torSettings.torType.value) { when (torSettings.torType.value) {
TorType.OFF -> false TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.videosViaTor.value) TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.videosViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.videosViaTor.value) TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.videosViaTor.value)
} }
fun shouldUseTorForPreviewUrl(url: String) = fun shouldUseTorForPreviewUrl(url: String) =
when (settings.torSettings.torType.value) { when (torSettings.torType.value) {
TorType.OFF -> false TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.urlPreviewsViaTor.value) TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.urlPreviewsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.urlPreviewsViaTor.value) TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.urlPreviewsViaTor.value)
} }
fun shouldUseTorForTrustedRelays() = fun shouldUseTorForTrustedRelays() =
when (settings.torSettings.torType.value) { when (torSettings.torType.value) {
TorType.OFF -> false TorType.OFF -> false
TorType.INTERNAL -> settings.torSettings.trustedRelaysViaTor.value TorType.INTERNAL -> torSettings.trustedRelaysViaTor.value
TorType.EXTERNAL -> settings.torSettings.trustedRelaysViaTor.value TorType.EXTERNAL -> torSettings.trustedRelaysViaTor.value
} }
private fun checkLocalHostOnionAndThen( private fun checkLocalHostOnionAndThen(
url: String, url: String,
final: Boolean, final: Boolean,
): Boolean = checkLocalHostOnionAndThen(url, settings.torSettings.onionRelaysViaTor.value, final) ): Boolean = checkLocalHostOnionAndThen(url, torSettings.onionRelaysViaTor.value, final)
private fun checkLocalHostOnionAndThen( private fun checkLocalHostOnionAndThen(
normalizedUrl: String, normalizedUrl: String,
@@ -103,23 +106,39 @@ class PrivacyState(
} }
fun shouldUseTorForMoneyOperations(url: String) = fun shouldUseTorForMoneyOperations(url: String) =
when (settings.torSettings.torType.value) { when (torSettings.torType.value) {
TorType.OFF -> false TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.moneyOperationsViaTor.value) TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.moneyOperationsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.moneyOperationsViaTor.value) TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.moneyOperationsViaTor.value)
} }
fun shouldUseTorForNIP05(url: String) = fun shouldUseTorForNIP05(url: String) =
when (settings.torSettings.torType.value) { when (torSettings.torType.value) {
TorType.OFF -> false TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip05VerificationsViaTor.value) TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip05VerificationsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip05VerificationsViaTor.value) TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip05VerificationsViaTor.value)
} }
fun shouldUseTorForUploads(url: String) = fun shouldUseTorForUploads(url: String) =
when (settings.torSettings.torType.value) { when (torSettings.torType.value) {
TorType.OFF -> false TorType.OFF -> false
TorType.INTERNAL -> checkLocalHostOnionAndThen(url, settings.torSettings.nip96UploadsViaTor.value) TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip96UploadsViaTor.value)
TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, settings.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 package com.vitorpamplona.amethyst.model.torState
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.model.serverList.TrustedRelayListsState
import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.amethyst.ui.tor.TorType
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.combineTransform
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import okhttp3.OkHttpClient
class TorRelayState( class TorRelayState(
val trustedRelayState: TrustedRelayListsState, val okHttpClient: DualHttpClientManager,
val dmRelayState: DmRelayListState, val torSettingsFlow: TorSettingsFlow,
val settings: AccountSettings,
val scope: CoroutineScope, val scope: CoroutineScope,
) { ) {
val dmRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
val trustedRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
val torSettings = val torSettings =
combine( combine(
settings.torSettings.torType, torSettingsFlow.torType,
settings.torSettings.onionRelaysViaTor, torSettingsFlow.onionRelaysViaTor,
settings.torSettings.dmRelaysViaTor, torSettingsFlow.dmRelaysViaTor,
settings.torSettings.trustedRelaysViaTor, torSettingsFlow.trustedRelaysViaTor,
settings.torSettings.newRelaysViaTor, torSettingsFlow.newRelaysViaTor,
) { ) {
torType: TorType, torType: TorType,
onionRelaysViaTor: Boolean, onionRelaysViaTor: Boolean,
@@ -64,11 +67,11 @@ class TorRelayState(
}.onStart { }.onStart {
emit( emit(
TorRelaySettings( TorRelaySettings(
torType = settings.torSettings.torType.value, torType = torSettingsFlow.torType.value,
onionRelaysViaTor = settings.torSettings.onionRelaysViaTor.value, onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value,
dmRelaysViaTor = settings.torSettings.dmRelaysViaTor.value, dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value,
trustedRelaysViaTor = settings.torSettings.trustedRelaysViaTor.value, trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value,
newRelaysViaTor = settings.torSettings.newRelaysViaTor.value, newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value,
), ),
) )
}.flowOn(Dispatchers.Default) }.flowOn(Dispatchers.Default)
@@ -76,20 +79,21 @@ class TorRelayState(
scope, scope,
SharingStarted.Eagerly, SharingStarted.Eagerly,
TorRelaySettings( TorRelaySettings(
torType = settings.torSettings.torType.value, torType = torSettingsFlow.torType.value,
onionRelaysViaTor = settings.torSettings.onionRelaysViaTor.value, onionRelaysViaTor = torSettingsFlow.onionRelaysViaTor.value,
dmRelaysViaTor = settings.torSettings.dmRelaysViaTor.value, dmRelaysViaTor = torSettingsFlow.dmRelaysViaTor.value,
trustedRelaysViaTor = settings.torSettings.trustedRelaysViaTor.value, trustedRelaysViaTor = torSettingsFlow.trustedRelaysViaTor.value,
newRelaysViaTor = settings.torSettings.newRelaysViaTor.value, newRelaysViaTor = torSettingsFlow.newRelaysViaTor.value,
), ),
) )
val flow = val flow =
combineTransform( combineTransform(
torSettings, torSettings,
trustedRelayState.flow, trustedRelays,
dmRelayState.flow, dmRelays,
) { torSettings: TorRelaySettings, trustedRelayList: Set<NormalizedRelayUrl>, dmRelayList: Set<NormalizedRelayUrl> -> ) { torSettings: TorRelaySettings, trustedRelayList: Set<NormalizedRelayUrl>, dmRelayList: Set<NormalizedRelayUrl> ->
println("AABBCC New tor evaluation: $torSettings $trustedRelayList $dmRelayList")
emit( emit(
TorRelayEvaluation( TorRelayEvaluation(
torSettings = torSettings, torSettings = torSettings,
@@ -101,8 +105,8 @@ class TorRelayState(
emit( emit(
TorRelayEvaluation( TorRelayEvaluation(
torSettings = torSettings.value, torSettings = torSettings.value,
trustedRelayList = trustedRelayState.flow.value, trustedRelayList = trustedRelays.value,
dmRelayList = dmRelayState.flow.value, dmRelayList = dmRelays.value,
), ),
) )
}.flowOn(Dispatchers.Default) }.flowOn(Dispatchers.Default)
@@ -111,10 +115,12 @@ class TorRelayState(
SharingStarted.Eagerly, SharingStarted.Eagerly,
TorRelayEvaluation( TorRelayEvaluation(
torSettings = torSettings.value, torSettings = torSettings.value,
trustedRelayList = trustedRelayState.flow.value, trustedRelayList = trustedRelays.value,
dmRelayList = dmRelayState.flow.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 package com.vitorpamplona.amethyst.service
import android.app.Application import android.content.Context
import java.io.File import java.io.File
fun Application.safeCacheDir(): File { fun Context.safeCacheDir(): File {
val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" } val cacheDir = checkNotNull(cacheDir) { "cacheDir == null" }
return cacheDir.apply { mkdirs() } return cacheDir.apply { mkdirs() }
} }
@@ -39,7 +39,7 @@ class ConnectivityFlow(
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
val status = val status =
callbackFlow { callbackFlow {
trySend(ConnectivityStatus.Connecting) trySend(ConnectivityStatus.StartingService)
val connectivityManager = context.getConnectivityManager() val connectivityManager = context.getConnectivityManager()
@@ -41,7 +41,7 @@ class ConnectivityManager(
ConnectivityFlow(context).status.distinctUntilChanged().stateIn( ConnectivityFlow(context).status.distinctUntilChanged().stateIn(
scope, scope,
SharingStarted.WhileSubscribed(30000), SharingStarted.WhileSubscribed(30000),
ConnectivityStatus.Off, ConnectivityStatus.StartingService,
) )
val isMobileOrNull: StateFlow<Boolean?> = val isMobileOrNull: StateFlow<Boolean?> =
@@ -28,5 +28,5 @@ sealed class ConnectivityStatus {
object Off : ConnectivityStatus() object Off : ConnectivityStatus()
object Connecting : ConnectivityStatus() object StartingService : ConnectivityStatus()
} }
@@ -20,7 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.service.notifications package com.vitorpamplona.amethyst.service.notifications
import android.app.Application
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Context.RECEIVER_EXPORTED import android.content.Context.RECEIVER_EXPORTED
@@ -28,8 +27,12 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.nip01Core.core.Event 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 import kotlinx.coroutines.launch
class PokeyReceiver : BroadcastReceiver() { class PokeyReceiver : BroadcastReceiver() {
@@ -39,7 +42,14 @@ class PokeyReceiver : BroadcastReceiver() {
val INTENT = IntentFilter(POKEY_ACTION) 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
app.registerReceiver(this, INTENT, RECEIVER_EXPORTED) app.registerReceiver(this, INTENT, RECEIVER_EXPORTED)
} else { } else {
@@ -48,8 +58,9 @@ class PokeyReceiver : BroadcastReceiver() {
} }
} }
fun unregister(app: Application) { fun unregister(app: Context) {
app.unregisterReceiver(this) app.unregisterReceiver(this)
scope.cancel()
} }
override fun onReceive( override fun onReceive(
@@ -62,13 +73,9 @@ class PokeyReceiver : BroadcastReceiver() {
if (eventStr == null) return if (eventStr == null) return
val app = context.applicationContext as Amethyst scope.launch {
app.applicationIOScope.launch {
try { try {
EventNotificationConsumer(app).findAccountAndConsume( EventNotificationConsumer(context.applicationContext).findAccountAndConsume(Event.fromJson(eventStr))
Event.fromJson(eventStr),
)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to parse Pokey Event", e) Log.e(TAG, "Failed to parse Pokey Event", e)
} }
@@ -62,7 +62,7 @@ fun VideoViewInner(
callbackUri = nostrUriCallback, callbackUri = nostrUriCallback,
mimeType = mimeType, mimeType = mimeType,
aspectRatio = aspectRatio, aspectRatio = aspectRatio,
proxyPort = accountViewModel.proxyPortForVideo(videoUri), proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(videoUri),
keepPlaying = true, keepPlaying = true,
waveformData = waveform, waveformData = waveform,
) { mediaItem -> ) { 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.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager 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.TorManager
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -45,7 +44,7 @@ import net.freehaven.tor.control.TorControlCommands
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
class RelayProxyClientConnector( class RelayProxyClientConnector(
val torProxySettingsAnchor: ProxySettingsAnchor, val torEvaluator: StateFlow<TorRelayEvaluation>,
val okHttpClients: DualHttpClientManager, val okHttpClients: DualHttpClientManager,
val connManager: ConnectivityManager, val connManager: ConnectivityManager,
val client: INostrClient, val client: INostrClient,
@@ -53,7 +52,7 @@ class RelayProxyClientConnector(
val scope: CoroutineScope, val scope: CoroutineScope,
) { ) {
data class RelayServiceInfra( data class RelayServiceInfra(
val torSettings: StateFlow<TorRelayEvaluation>, val evaluator: TorRelayEvaluation,
val torConnection: OkHttpClient, val torConnection: OkHttpClient,
val clearConnection: OkHttpClient, val clearConnection: OkHttpClient,
val connectivity: ConnectivityStatus, val connectivity: ConnectivityStatus,
@@ -62,7 +61,7 @@ class RelayProxyClientConnector(
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
val relayServices = val relayServices =
combine( combine(
torProxySettingsAnchor.flow, torEvaluator,
okHttpClients.defaultHttpClient, okHttpClients.defaultHttpClient,
okHttpClients.defaultHttpClientWithoutProxy, okHttpClients.defaultHttpClientWithoutProxy,
connManager.status, connManager.status,
@@ -70,7 +69,9 @@ class RelayProxyClientConnector(
RelayServiceInfra(torSettings, torConnection, clearConnection, connectivity) RelayServiceInfra(torSettings, torConnection, clearConnection, connectivity)
}.debounce(100) }.debounce(100)
.onEach { .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}") Log.d("ManageRelayServices", "Pausing Relay Services ${it.connectivity}")
if (client.isActive()) { if (client.isActive()) {
client.disconnect() client.disconnect()
@@ -152,7 +152,7 @@ class UploadOrchestrator {
alt = alt, alt = alt,
sensitiveContent = contentWarningReason, sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl, serverBaseUrl = serverBaseUrl,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = { percent: Float -> onProgress = { percent: Float ->
updateState(0.2 + (0.2 * percent), UploadingState.Uploading) updateState(0.2 + (0.2 * percent), UploadingState.Uploading)
}, },
@@ -165,7 +165,7 @@ class UploadOrchestrator {
localContentType = contentType, localContentType = contentType,
originalContentType = contentTypeForResult, originalContentType = contentTypeForResult,
originalHash = originalHash, originalHash = originalHash,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
) )
} catch (_: SignerExceptions.ReadOnlyException) { } catch (_: SignerExceptions.ReadOnlyException) {
error(R.string.login_with_a_private_key_to_be_able_to_upload) error(R.string.login_with_a_private_key_to_be_able_to_upload)
@@ -198,7 +198,7 @@ class UploadOrchestrator {
alt = alt, alt = alt,
sensitiveContent = contentWarningReason, sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl, serverBaseUrl = serverBaseUrl,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth, httpAuth = account::createBlossomUploadAuth,
context = context, context = context,
) )
@@ -206,7 +206,7 @@ class UploadOrchestrator {
verifyHeader( verifyHeader(
uploadResult = result, uploadResult = result,
localContentType = contentType, localContentType = contentType,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
originalHash = originalHash, originalHash = originalHash,
originalContentType = contentTypeForResult, originalContentType = contentTypeForResult,
) )
@@ -28,6 +28,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.debugState 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.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.screen.AccountScreen import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.prepareSharedViewModel
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
@@ -67,16 +67,14 @@ class MainActivity : AppCompatActivity() {
setContent { setContent {
StringResSetup() StringResSetup()
AmethystTheme {
val sharedPreferencesViewModel = prepareSharedViewModel()
AmethystTheme(sharedPreferencesViewModel.sharedPrefs.theme) {
val accountStateViewModel: AccountStateViewModel = viewModel() val accountStateViewModel: AccountStateViewModel = viewModel()
LaunchedEffect(key1 = Unit) { LaunchedEffect(key1 = Unit) {
accountStateViewModel.loginWithDefaultAccountIfLoggedOff() 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.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastForEach
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable @Composable
@@ -50,7 +49,7 @@ fun <T> CrossfadeIfEnabled(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
content: @Composable (T) -> Unit, content: @Composable (T) -> Unit,
) { ) {
if (accountViewModel.settings.featureSet == FeatureSetType.PERFORMANCE) { if (accountViewModel.settings.isPerformanceMode()) {
Box(modifier, contentAlignment) { Box(modifier, contentAlignment) {
content(targetState) content(targetState)
} }
@@ -193,7 +193,7 @@ class NewUserMetadataViewModel : ViewModel() {
alt = null, alt = null,
sensitiveContent = null, sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl, serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {}, onProgress = {},
httpAuth = account::createHTTPAuthorization, httpAuth = account::createHTTPAuthorization,
context = context, context = context,
@@ -206,7 +206,7 @@ class NewUserMetadataViewModel : ViewModel() {
alt = null, alt = null,
sensitiveContent = null, sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl, serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth, httpAuth = account::createBlossomUploadAuth,
context = context, context = context,
) )
@@ -56,6 +56,6 @@ tailrec fun Context.getActivity(): ComponentActivity =
} }
@Composable @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, mimeType = content.mimeType,
okHttpClient = { okHttpClient = {
if (isImage) { if (isImage) {
accountViewModel.okHttpClientForImage(it) accountViewModel.httpClientBuilder.okHttpClientForImage(it)
} else { } else {
accountViewModel.okHttpClientForVideo(it) accountViewModel.httpClientBuilder.okHttpClientForVideo(it)
} }
}, },
localContext, localContext,
@@ -52,7 +52,6 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import kotlin.math.abs import kotlin.math.abs
@@ -70,7 +69,7 @@ fun DisappearingScaffold(
val shouldShow = remember { mutableStateOf(isActive()) } val shouldShow = remember { mutableStateOf(isActive()) }
val modifier = val modifier =
if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) { if (accountViewModel.settings.isImmersiveScrollingActive()) {
val bottomBarHeightPx = with(LocalDensity.current) { 50.dp.roundToPx().toFloat() } val bottomBarHeightPx = with(LocalDensity.current) { 50.dp.roundToPx().toFloat() }
val bottomBarOffsetHeightPx = remember { mutableFloatStateOf(0f) } val bottomBarOffsetHeightPx = remember { mutableFloatStateOf(0f) }
@@ -90,7 +89,7 @@ fun DisappearingScaffold(
val newOffset = bottomBarOffsetHeightPx.floatValue + available.y val newOffset = bottomBarOffsetHeightPx.floatValue + available.y
if (accountViewModel.settings.automaticallyHideNavigationBars == BooleanType.ALWAYS) { if (accountViewModel.settings.isImmersiveScrollingActive()) {
val newBottomBarOffset = val newBottomBarOffset =
if (!isInvertedLayout) { if (!isInvertedLayout) {
newOffset.coerceIn(-bottomBarHeightPx, 0f) newOffset.coerceIn(-bottomBarHeightPx, 0f)
@@ -39,6 +39,7 @@ import androidx.core.net.toUri
import androidx.core.util.Consumer import androidx.core.util.Consumer
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages 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.navigation.routes.isSameRoute
import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel 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.AccountSwitcherAndLeftDrawerLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarks.BookmarkListScreen
@@ -106,7 +106,6 @@ import java.net.URI
fun AppNavigation( fun AppNavigation(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
accountStateViewModel: AccountStateViewModel, accountStateViewModel: AccountStateViewModel,
sharedPreferencesViewModel: SharedPreferencesViewModel,
) { ) {
val nav = rememberNav() val nav = rememberNav()
@@ -121,16 +120,16 @@ fun AppNavigation(
composable<Route.Message> { MessagesScreen(accountViewModel, nav) } composable<Route.Message> { MessagesScreen(accountViewModel, nav) }
composable<Route.Video> { VideoScreen(accountViewModel, nav) } composable<Route.Video> { VideoScreen(accountViewModel, nav) }
composable<Route.Discover> { DiscoverScreen(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.EditProfile> { NewUserMetadataScreen(nav, accountViewModel) }
composable<Route.Search> { SearchScreen(accountViewModel, nav) } composable<Route.Search> { SearchScreen(accountViewModel, nav) }
composableFromEnd<Route.SecurityFilters> { SecurityFiltersScreen(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.Bookmarks> { BookmarkListScreen(accountViewModel, nav) }
composableFromEnd<Route.Drafts> { DraftListScreen(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) } composableFromEnd<Route.UserSettings> { UserSettingsScreen(accountViewModel, nav) }
composableFromBottomArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) } composableFromBottomArgs<Route.Nip47NWCSetup> { NIP47SetupScreen(accountViewModel, nav, it.nip47) }
composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) } composableFromEndArgs<Route.EditRelays> { AllRelayListScreen(accountViewModel, nav) }
@@ -57,7 +57,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.AccountInfo import com.vitorpamplona.amethyst.AccountInfo
import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
@@ -218,7 +217,7 @@ private fun AccountPicture(
contentDescription = stringRes(R.string.profile_image), contentDescription = stringRes(R.string.profile_image),
modifier = AccountPictureModifier, modifier = AccountPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, 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.BuildConfig
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowerCount import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserFollowerCount
@@ -247,7 +246,7 @@ fun ProfileContentTemplate(
.border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape) .border(3.dp, MaterialTheme.colorScheme.onBackground, CircleShape)
.clickable(onClick = onClick), .clickable(onClick = onClick),
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
) )
if (bestDisplayName != null) { if (bestDisplayName != null) {
@@ -32,7 +32,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
@@ -87,7 +86,7 @@ private fun LoggedInUserPictureDrawer(
modifier = HeaderPictureModifier, modifier = HeaderPictureModifier,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, 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 androidx.compose.ui.window.PopupProperties
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.NoteState import com.vitorpamplona.amethyst.model.NoteState
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
@@ -605,7 +604,7 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture(
modifier = MaterialTheme.colorScheme.profile35dpModifier, modifier = MaterialTheme.colorScheme.profile35dpModifier,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, 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.R
import com.vitorpamplona.amethyst.commons.compose.produceCachedStateAsync import com.vitorpamplona.amethyst.commons.compose.produceCachedStateAsync
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannelPicture
@@ -544,7 +543,7 @@ fun InnerNoteWithReactions(
baseNote.event !is GenericRepostEvent && baseNote.event !is GenericRepostEvent &&
!isBoostedNote && !isBoostedNote &&
!isQuotedNote && !isQuotedNote &&
accountViewModel.settings.featureSet == FeatureSetType.COMPLETE accountViewModel.settings.isCompleteUIMode()
NoteBody( NoteBody(
baseNote = baseNote, baseNote = baseNote,
showAuthorPicture = isQuotedNote, showAuthorPicture = isQuotedNote,
@@ -1256,7 +1255,7 @@ fun BadgeBox(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
if (accountViewModel.settings.featureSet == FeatureSetType.COMPLETE) { if (accountViewModel.settings.isCompleteUIMode()) {
if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) { if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) {
baseNote.replyTo?.lastOrNull()?.let { RelayBadges(it, accountViewModel, nav) } baseNote.replyTo?.lastOrNull()?.let { RelayBadges(it, accountViewModel, nav) }
} else { } else {
@@ -1309,7 +1308,7 @@ private fun ChannelNotePicture(
contentDescription = stringRes(R.string.group_picture), contentDescription = stringRes(R.string.group_picture),
modifier = MaterialTheme.colorScheme.channelNotePictureModifier, modifier = MaterialTheme.colorScheme.channelNotePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, 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.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.ZapPaymentHandler
@@ -670,7 +669,7 @@ private fun SlidingAnimationCount(
textColor: Color, textColor: Color,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
if (accountViewModel.settings.featureSet == FeatureSetType.PERFORMANCE) { if (accountViewModel.settings.isPerformanceMode()) {
TextCount(baseCount, textColor) TextCount(baseCount, textColor)
} else { } else {
AnimatedContent<Int>( AnimatedContent<Int>(
@@ -719,7 +718,7 @@ fun SlidingAnimationAmount(
textColor: Color, textColor: Color,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
if (accountViewModel.settings.featureSet == FeatureSetType.PERFORMANCE) { if (accountViewModel.settings.isPerformanceMode()) {
Text( Text(
text = amount, text = amount,
fontSize = Font14SP, fontSize = Font14SP,
@@ -50,14 +50,13 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note 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.ClickableBox
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier import com.vitorpamplona.amethyst.ui.theme.LargeRelayIconModifier
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
@@ -126,7 +125,7 @@ fun RenderRelay(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
val relayInfo by loadRelayInfo(relay, accountViewModel) val relayInfo by loadRelayInfo(relay)
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val clickableModifier = val clickableModifier =
@@ -152,7 +151,7 @@ fun RenderRelay(
iconUrl = relayInfo.icon, iconUrl = relayInfo.icon,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
pingInMs = 0, 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.compose.ui.unit.Dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
@@ -94,7 +93,7 @@ fun DisplayBlankAuthor(
robot = "authornotfound", robot = "authornotfound",
contentDescription = stringRes(R.string.unknown_author), contentDescription = stringRes(R.string.unknown_author),
modifier = nullModifier, modifier = nullModifier,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
) )
} }
@@ -360,7 +359,7 @@ fun InnerUserPicture(
modifier = myImageModifier, modifier = myImageModifier,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, 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) { viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link.url) { iMetaAttachments.downloadAndPrepare(item.link.url) {
Amethyst.Companion.instance.okHttpClients Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link.url)
.getHttpClient(
accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url),
)
} }
} }
@@ -61,7 +61,6 @@ import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
@@ -328,7 +327,7 @@ fun ShortCommunityHeader(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier, modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
) )
} }
@@ -378,7 +377,7 @@ fun ShortCommunityHeaderNoActions(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier, modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
) )
} }
@@ -121,7 +121,7 @@ fun VoiceHeader(
callbackUri = callbackUri, callbackUri = callbackUri,
mimeType = null, mimeType = null,
aspectRatio = null, aspectRatio = null,
proxyPort = accountViewModel.proxyPortForVideo(media), proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(media),
keepPlaying = false, keepPlaying = false,
waveformData = waveform, waveformData = waveform,
) { mediaItem -> ) { mediaItem ->
@@ -34,16 +34,17 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage import com.vitorpamplona.amethyst.ui.screen.loggedIn.LoggedInPage
import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen import com.vitorpamplona.amethyst.ui.screen.loggedOff.LoginOrSignupScreen
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@Composable @Composable
fun AccountScreen( fun AccountScreen(accountStateViewModel: AccountStateViewModel) {
accountStateViewModel: AccountStateViewModel, // Pauses relay services when the app pauses
sharedPreferencesViewModel: SharedPreferencesViewModel, ManageRelayServices()
) {
val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle() val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle()
Log.d("ActivityLifecycle", "AccountScreen $accountState $accountStateViewModel") Log.d("ActivityLifecycle", "AccountScreen $accountState $accountStateViewModel")
@@ -55,11 +56,17 @@ fun AccountScreen(
when (state) { when (state) {
is AccountState.Loading -> LoadingSetup() is AccountState.Loading -> LoadingSetup()
is AccountState.LoggedOff -> LoggedOffSetup(accountStateViewModel) 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 @Composable
fun LoadingSetup() { fun LoadingSetup() {
// A surface container using the 'background' color from the theme // A surface container using the 'background' color from the theme
@@ -91,14 +98,12 @@ fun LoggedOffSetup(accountStateViewModel: AccountStateViewModel) {
fun LoggedInSetup( fun LoggedInSetup(
state: AccountState.LoggedIn, state: AccountState.LoggedIn,
accountStateViewModel: AccountStateViewModel, accountStateViewModel: AccountStateViewModel,
sharedPreferencesViewModel: SharedPreferencesViewModel,
) { ) {
SetAccountCentricViewModelStore(state) { SetAccountCentricViewModelStore(state) {
LoggedInPage( LoggedInPage(
state.account, account = state.account,
state.route, route = state.route,
accountStateViewModel, accountStateViewModel = accountStateViewModel,
sharedPreferencesViewModel,
) )
} }
} }
@@ -35,8 +35,6 @@ import com.vitorpamplona.amethyst.model.DefaultNIP65RelaySet
import com.vitorpamplona.amethyst.model.DefaultSearchRelayList import com.vitorpamplona.amethyst.model.DefaultSearchRelayList
import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier import com.vitorpamplona.amethyst.service.Nip05NostrAddressVerifier
import com.vitorpamplona.amethyst.ui.navigation.routes.Route 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.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -108,7 +106,6 @@ class AccountStateViewModel : ViewModel() {
suspend fun loginAndStartUI( suspend fun loginAndStartUI(
key: String, key: String,
torSettings: TorSettings,
transientAccount: Boolean, transientAccount: Boolean,
loginWithExternalSigner: Boolean = false, loginWithExternalSigner: Boolean = false,
packageName: String = "", packageName: String = "",
@@ -142,31 +139,26 @@ class AccountStateViewModel : ViewModel() {
keyPair = KeyPair(pubKey = pubKeyParsed), keyPair = KeyPair(pubKey = pubKeyParsed),
transientAccount = transientAccount, transientAccount = transientAccount,
externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" }, externalSignerPackageName = packageName.ifBlank { "com.greenart7c3.nostrsigner" },
torSettings = TorSettingsFlow.build(torSettings),
) )
} else if (key.startsWith("nsec")) { } else if (key.startsWith("nsec")) {
AccountSettings( AccountSettings(
keyPair = KeyPair(privKey = key.bechToBytes()), keyPair = KeyPair(privKey = key.bechToBytes()),
transientAccount = transientAccount, transientAccount = transientAccount,
torSettings = TorSettingsFlow.build(torSettings),
) )
} else if (key.contains(" ") && Nip06().isValidMnemonic(key)) { } else if (key.contains(" ") && Nip06().isValidMnemonic(key)) {
AccountSettings( AccountSettings(
keyPair = KeyPair(privKey = Nip06().privateKeyFromMnemonic(key)), keyPair = KeyPair(privKey = Nip06().privateKeyFromMnemonic(key)),
transientAccount = transientAccount, transientAccount = transientAccount,
torSettings = TorSettingsFlow.build(torSettings),
) )
} else if (pubKeyParsed != null) { } else if (pubKeyParsed != null) {
AccountSettings( AccountSettings(
keyPair = KeyPair(pubKey = pubKeyParsed), keyPair = KeyPair(pubKey = pubKeyParsed),
transientAccount = transientAccount, transientAccount = transientAccount,
torSettings = TorSettingsFlow.build(torSettings),
) )
} else { } else {
AccountSettings( AccountSettings(
keyPair = KeyPair(Hex.decode(key)), keyPair = KeyPair(Hex.decode(key)),
transientAccount = transientAccount, transientAccount = transientAccount,
torSettings = TorSettingsFlow.build(torSettings),
) )
} }
@@ -197,7 +189,6 @@ class AccountStateViewModel : ViewModel() {
fun login( fun login(
key: String, key: String,
password: String, password: String,
torSettings: TorSettings,
transientAccount: Boolean, transientAccount: Boolean,
loginWithExternalSigner: Boolean = false, loginWithExternalSigner: Boolean = false,
packageName: String = "", packageName: String = "",
@@ -222,41 +213,39 @@ class AccountStateViewModel : ViewModel() {
onError("Could not decrypt key with provided password") onError("Could not decrypt key with provided password")
Log.e("Login", "Could not decrypt ncryptsec") Log.e("Login", "Could not decrypt ncryptsec")
} else { } else {
loginSync(newKey, torSettings, transientAccount, loginWithExternalSigner, packageName, onError) loginSync(newKey, transientAccount, loginWithExternalSigner, packageName, onError)
} }
} else if (EMAIL_PATTERN.matcher(key).matches()) { } else if (EMAIL_PATTERN.matcher(key).matches()) {
Nip05NostrAddressVerifier().verifyNip05( Nip05NostrAddressVerifier().verifyNip05(
key, key,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(false) }, okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(false) },
onSuccess = { publicKey -> onSuccess = { publicKey ->
loginSync(Hex.decode(publicKey).toNpub(), torSettings, transientAccount, loginWithExternalSigner, packageName, onError) loginSync(Hex.decode(publicKey).toNpub(), transientAccount, loginWithExternalSigner, packageName, onError)
}, },
onError = { onError = {
onError(it) onError(it)
}, },
) )
} else { } else {
loginSync(key, torSettings, transientAccount, loginWithExternalSigner, packageName, onError) loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError)
} }
} }
} }
fun login( fun login(
key: String, key: String,
torSettings: TorSettings,
transientAccount: Boolean, transientAccount: Boolean,
loginWithExternalSigner: Boolean = false, loginWithExternalSigner: Boolean = false,
packageName: String = "", packageName: String = "",
onError: (String) -> Unit, onError: (String) -> Unit,
) { ) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
loginSync(key, torSettings, transientAccount, loginWithExternalSigner, packageName, onError) loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError)
} }
} }
suspend fun loginSync( suspend fun loginSync(
key: String, key: String,
torSettings: TorSettings,
transientAccount: Boolean, transientAccount: Boolean,
loginWithExternalSigner: Boolean = false, loginWithExternalSigner: Boolean = false,
packageName: String = "", packageName: String = "",
@@ -266,7 +255,7 @@ class AccountStateViewModel : ViewModel() {
if (_accountContent.value is AccountState.LoggedIn) { if (_accountContent.value is AccountState.LoggedIn) {
prepareLogoutOrSwitch() prepareLogoutOrSwitch()
} }
loginAndStartUI(key, torSettings, transientAccount, loginWithExternalSigner, packageName) loginAndStartUI(key, transientAccount, loginWithExternalSigner, packageName)
} catch (e: Exception) { } catch (e: Exception) {
if (e is CancellationException) throw e if (e is CancellationException) throw e
Log.e("Login", "Could not sign in", e) Log.e("Login", "Could not sign in", e)
@@ -274,10 +263,7 @@ class AccountStateViewModel : ViewModel() {
} }
} }
fun newKey( fun newKey(name: String? = null) {
torSettings: TorSettings,
name: String? = null,
) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
if (_accountContent.value is AccountState.LoggedIn) { if (_accountContent.value is AccountState.LoggedIn) {
prepareLogoutOrSwitch() prepareLogoutOrSwitch()
@@ -285,7 +271,7 @@ class AccountStateViewModel : ViewModel() {
_accountContent.update { AccountState.Loading } _accountContent.update { AccountState.Loading }
val accountSettings = createNewAccount(torSettings, name) val accountSettings = createNewAccount(name)
LocalPreferences.setDefaultAccount(accountSettings) LocalPreferences.setDefaultAccount(accountSettings)
@@ -306,10 +292,7 @@ class AccountStateViewModel : ViewModel() {
} }
} }
fun createNewAccount( fun createNewAccount(name: String? = null): AccountSettings {
torSettings: TorSettings,
name: String? = null,
): AccountSettings {
val keyPair = KeyPair() val keyPair = KeyPair()
val tempSigner = NostrSignerSync(keyPair) val tempSigner = NostrSignerSync(keyPair)
@@ -327,7 +310,6 @@ class AccountStateViewModel : ViewModel() {
backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner), backupDMRelayList = ChatMessageRelayListEvent.create(DefaultDMRelayList, tempSigner),
backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), tempSigner), backupSearchRelayList = SearchRelayListEvent.create(DefaultSearchRelayList.toList(), tempSigner),
backupChannelList = ChannelListEvent.create(emptyList(), DefaultChannels, 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.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.asDrawable import coil3.asDrawable
import coil3.imageLoader import coil3.imageLoader
import coil3.request.ImageRequest 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.Channel
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel 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.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.observables.CreatedAtComparator 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.Nip05NostrAddressVerifier
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.Nip11Retriever
import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.cashu.CashuToken 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.checkNotInMainThread
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.service.location.LocationState 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.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler
import com.vitorpamplona.amethyst.service.uploads.CompressorQuality 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.ZapraiserStatus
import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.note.showAmountInteger import com.vitorpamplona.amethyst.ui.note.showAmountInteger
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.vitorpamplona.amethyst.ui.screen.UiSettingsState
import com.vitorpamplona.amethyst.ui.screen.SharedSettingsState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedState import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CardFeedState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.CombinedZap
import com.vitorpamplona.amethyst.ui.stringRes 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.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent 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.addressables.Address
import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag import com.vitorpamplona.quartz.nip01Core.tags.people.PubKeyReferenceTag
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache import com.vitorpamplona.quartz.nip03Timestamp.DefaultOtsResolverBuilder
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKeyable
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
@@ -160,15 +158,15 @@ import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.util.Locale import java.util.Locale
@Stable @Stable
class AccountViewModel( class AccountViewModel(
val account: Account, val account: Account,
val settings: SharedSettingsState, val settings: UiSettingsState,
val torSettings: TorSettingsFlow,
val dataSources: RelaySubscriptionsCoordinator, val dataSources: RelaySubscriptionsCoordinator,
val okHttpClient: IHttpClientManager, val httpClientBuilder: IRoleBasedHttpClientBuilder,
) : ViewModel(), ) : ViewModel(),
Dao { Dao {
var firstRoute: Route? = null var firstRoute: Route? = null
@@ -650,7 +648,7 @@ class AccountViewModel(
message = message, message = message,
context = context, context = context,
showErrorIfNoLnAddress = showErrorIfNoLnAddress, showErrorIfNoLnAddress = showErrorIfNoLnAddress,
okHttpClient = ::okHttpClientForMoney, okHttpClient = httpClientBuilder::okHttpClientForMoney,
onError = onError, onError = onError,
onProgress = onProgress, onProgress = onProgress,
onPayViaIntent = onPayViaIntent, onPayViaIntent = onPayViaIntent,
@@ -886,7 +884,7 @@ class AccountViewModel(
onResult: suspend (UrlPreviewState) -> Unit, onResult: suspend (UrlPreviewState) -> Unit,
) { ) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
UrlCachedPreviewer.previewInfo(url, ::okHttpClientForPreview, onResult) UrlCachedPreviewer.previewInfo(url, httpClientBuilder::okHttpClientForPreview, onResult)
} }
} }
@@ -907,9 +905,7 @@ class AccountViewModel(
Nip05NostrAddressVerifier() Nip05NostrAddressVerifier()
.verifyNip05( .verifyNip05(
nip05, nip05,
okHttpClient = { okHttpClient = httpClientBuilder::okHttpClientForNip05,
okHttpClient.getHttpClient(account.privacyState.shouldUseTorForNIP05(it))
},
onSuccess = { onSuccess = {
// Marks user as verified // Marks user as verified
if (it == pubkeyHex) { 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) { fun runOnIO(runOnIO: () -> Unit) {
viewModelScope.launch(Dispatchers.IO) { runOnIO() } viewModelScope.launch(Dispatchers.IO) { runOnIO() }
} }
@@ -1073,7 +1054,7 @@ class AccountViewModel(
suspend fun checkVideoIsOnline(videoUrl: String): Boolean = suspend fun checkVideoIsOnline(videoUrl: String): Boolean =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
OnlineChecker.isOnline(videoUrl, ::okHttpClientForVideo) OnlineChecker.isOnline(videoUrl, httpClientBuilder::okHttpClientForVideo)
} }
fun loadAndMarkAsRead( fun loadAndMarkAsRead(
@@ -1108,19 +1089,19 @@ class AccountViewModel(
} }
} }
fun setTorSettings(newTorSettings: TorSettings) = runIOCatching { account.settings.setTorSettings(newTorSettings) }
class Factory( class Factory(
val account: Account, val account: Account,
val settings: SharedSettingsState, val settings: UiSettingsState,
val torSettings: TorSettingsFlow,
val dataSources: RelaySubscriptionsCoordinator, val dataSources: RelaySubscriptionsCoordinator,
val okHttpClient: IHttpClientManager, val okHttpClient: RoleBasedHttpClientBuilder,
) : ViewModelProvider.Factory { ) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T = override fun <T : ViewModel> create(modelClass: Class<T>): T =
AccountViewModel( AccountViewModel(
account, account,
settings, settings,
torSettings,
dataSources, dataSources,
okHttpClient, okHttpClient,
) as T ) as T
@@ -1278,7 +1259,7 @@ class AccountViewModel(
if (lud16 != null) { if (lud16 != null) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val meltResult = MeltProcessor().melt(token, lud16, ::okHttpClientForMoney, context) val meltResult = MeltProcessor().melt(token, lud16, httpClientBuilder::okHttpClientForMoney, context)
onDone( onDone(
stringRes(context, R.string.cashu_successful_redemption), stringRes(context, R.string.cashu_successful_redemption),
stringRes( 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 fun dataSources() = dataSources
suspend fun createTempDraftNote(noteEvent: DraftWrapEvent): Note? = draftNoteCache.update(noteEvent) suspend fun createTempDraftNote(noteEvent: DraftWrapEvent): Note? = draftNoteCache.update(noteEvent)
@@ -1526,7 +1491,7 @@ class AccountViewModel(
milliSats = milliSats, milliSats = milliSats,
message = message, message = message,
nostrRequest = zapRequest, nostrRequest = zapRequest,
okHttpClient = ::okHttpClientForMoney, okHttpClient = httpClientBuilder::okHttpClientForMoney,
onProgress = onProgress, onProgress = onProgress,
context = context, context = context,
) )
@@ -1549,7 +1514,7 @@ class AccountViewModel(
viewModelScope.launch { viewModelScope.launch {
MediaSaverToDisk.saveDownloadingIfNeeded( MediaSaverToDisk.saveDownloadingIfNeeded(
videoUri = videoUri, videoUri = videoUri,
okHttpClient = ::okHttpClientForVideo, okHttpClient = httpClientBuilder::okHttpClientForVideo,
mimeType = mimeType, mimeType = mimeType,
localContext = localContext, localContext = localContext,
onSuccess = { onSuccess = {
@@ -1668,8 +1633,17 @@ var mockedCache: AccountViewModel? = null
fun mockAccountViewModel(): AccountViewModel { fun mockAccountViewModel(): AccountViewModel {
mockedCache?.let { return it } mockedCache?.let { return it }
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() val otsResolver = DefaultOtsResolverBuilder()
sharedPreferencesViewModel.init()
val scope = rememberCoroutineScope()
val uiState =
UiSettingsState(
uiSettingsFlow = UiSettingsFlow(),
isMobileOrMeteredConnection = MutableStateFlow(false),
scope = scope,
)
val keyPair = val keyPair =
KeyPair( KeyPair(
privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"), privKey = Hex.decode("0f761f8a5a481e26f06605a1d9b3e9eba7a107d351f43c43a57469b788274499"),
@@ -1677,8 +1651,6 @@ fun mockAccountViewModel(): AccountViewModel {
forceReplacePubkey = false, forceReplacePubkey = false,
) )
val scope = rememberCoroutineScope()
val client = EmptyNostrClient val client = EmptyNostrClient
val nwcFilters = NWCPaymentFilterAssembler(client) val nwcFilters = NWCPaymentFilterAssembler(client)
@@ -1689,7 +1661,7 @@ fun mockAccountViewModel(): AccountViewModel {
signer = NostrSignerInternal(keyPair), signer = NostrSignerInternal(keyPair),
geolocationFlow = MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading), geolocationFlow = MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading),
nwcFilterAssembler = nwcFilters, nwcFilterAssembler = nwcFilters,
otsVerifCache = VerificationStateCache(), otsResolverBuilder = otsResolver,
cache = LocalCache, cache = LocalCache,
client = client, client = client,
scope = scope, scope = scope,
@@ -1697,8 +1669,9 @@ fun mockAccountViewModel(): AccountViewModel {
return AccountViewModel( return AccountViewModel(
account = account, account = account,
settings = sharedPreferencesViewModel.sharedPrefs, settings = uiState,
okHttpClient = EmptyHttpClientManager, torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)),
httpClientBuilder = EmptyRoleBasedHttpClientBuilder(),
dataSources = RelaySubscriptionsCoordinator(LocalCache, client, scope), dataSources = RelaySubscriptionsCoordinator(LocalCache, client, scope),
).also { ).also {
mockedCache = it mockedCache = it
@@ -1712,8 +1685,17 @@ var vitorCache: AccountViewModel? = null
fun mockVitorAccountViewModel(): AccountViewModel { fun mockVitorAccountViewModel(): AccountViewModel {
mockedCache?.let { return it } mockedCache?.let { return it }
val sharedPreferencesViewModel: SharedPreferencesViewModel = viewModel() val scope = rememberCoroutineScope()
sharedPreferencesViewModel.init()
val otsResolver = DefaultOtsResolverBuilder()
val uiState =
UiSettingsState(
uiSettingsFlow = UiSettingsFlow(),
isMobileOrMeteredConnection = MutableStateFlow(false),
scope = scope,
)
val keyPair = val keyPair =
KeyPair( KeyPair(
pubKey = Hex.decode("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), pubKey = Hex.decode("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"),
@@ -1723,15 +1705,13 @@ fun mockVitorAccountViewModel(): AccountViewModel {
val nwcFilters = NWCPaymentFilterAssembler(client) val nwcFilters = NWCPaymentFilterAssembler(client)
val scope = rememberCoroutineScope()
val account = val account =
Account( Account(
settings = AccountSettings(keyPair), settings = AccountSettings(keyPair),
signer = NostrSignerInternal(keyPair), signer = NostrSignerInternal(keyPair),
geolocationFlow = MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading), geolocationFlow = MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading),
nwcFilterAssembler = nwcFilters, nwcFilterAssembler = nwcFilters,
otsVerifCache = VerificationStateCache(), otsResolverBuilder = otsResolver,
cache = LocalCache, cache = LocalCache,
client = EmptyNostrClient, client = EmptyNostrClient,
scope = scope, scope = scope,
@@ -1739,8 +1719,9 @@ fun mockVitorAccountViewModel(): AccountViewModel {
return AccountViewModel( return AccountViewModel(
account = account, account = account,
settings = sharedPreferencesViewModel.sharedPrefs, settings = uiState,
okHttpClient = EmptyHttpClientManager, torSettings = TorSettingsFlow(torType = MutableStateFlow(TorType.OFF)),
httpClientBuilder = EmptyRoleBasedHttpClientBuilder(),
dataSources = RelaySubscriptionsCoordinator(LocalCache, client, scope), dataSources = RelaySubscriptionsCoordinator(LocalCache, client, scope),
).also { ).also {
vitorCache = it vitorCache = it
@@ -30,7 +30,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.lifecycle.compose.LifecycleResumeEffect 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.AppNavigation
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel 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.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription
@@ -62,7 +60,6 @@ fun LoggedInPage(
account: Account, account: Account,
route: Route?, route: Route?,
accountStateViewModel: AccountStateViewModel, accountStateViewModel: AccountStateViewModel,
sharedPreferencesViewModel: SharedPreferencesViewModel,
) { ) {
val accountViewModel: AccountViewModel = val accountViewModel: AccountViewModel =
viewModel( viewModel(
@@ -70,9 +67,10 @@ fun LoggedInPage(
factory = factory =
AccountViewModel.Factory( AccountViewModel.Factory(
account = account, account = account,
settings = sharedPreferencesViewModel.sharedPrefs, settings = Amethyst.instance.uiState,
torSettings = Amethyst.instance.torPrefs.value,
dataSources = Amethyst.instance.sources, 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. // Adds this account to the authentication procedures for relays.
RelayAuthSubscription(accountViewModel) 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. // Loads account information + DMs and Notifications from Relays.
AccountFilterAssemblerSubscription(accountViewModel) AccountFilterAssemblerSubscription(accountViewModel)
@@ -99,9 +91,6 @@ fun LoggedInPage(
// Updates local cache of the anti-spam filter choice of this user. // Updates local cache of the anti-spam filter choice of this user.
ObserveAntiSpamFilterSettings(accountViewModel) ObserveAntiSpamFilterSettings(accountViewModel)
// Pauses relay services when the app pauses
ManageRelayServices(accountViewModel)
// Listens to Amber // Listens to Amber
ListenToExternalSignerIfNeeded(accountViewModel) ListenToExternalSignerIfNeeded(accountViewModel)
@@ -111,7 +100,6 @@ fun LoggedInPage(
AppNavigation( AppNavigation(
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
accountStateViewModel = accountStateViewModel, accountStateViewModel = accountStateViewModel,
sharedPreferencesViewModel = sharedPreferencesViewModel,
) )
} }
@@ -123,27 +111,6 @@ fun ObserveAntiSpamFilterSettings(accountViewModel: AccountViewModel) {
Amethyst.instance.cache.antiSpam.active = isSpamActive 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) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun NotificationRegistration(accountViewModel: AccountViewModel) { fun NotificationRegistration(accountViewModel: AccountViewModel) {
@@ -158,7 +125,7 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) {
scope.launch { scope.launch {
PushNotificationUtils.checkAndInit( PushNotificationUtils.checkAndInit(
LocalPreferences.allSavedAccounts(), LocalPreferences.allSavedAccounts(),
accountViewModel::okHttpClientForPushRegistration, accountViewModel.httpClientBuilder::okHttpClientForPushRegistration,
) )
} }
@@ -173,7 +140,7 @@ fun NotificationRegistration(accountViewModel: AccountViewModel) {
scope.launch { scope.launch {
PushNotificationUtils.checkAndInit( PushNotificationUtils.checkAndInit(
LocalPreferences.allSavedAccounts(), 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.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
@@ -164,7 +163,7 @@ fun NormalChatNote(
isLoggedInUser = isLoggedInUser, isLoggedInUser = isLoggedInUser,
isDraft = note.event is DraftWrapEvent, isDraft = note.event is DraftWrapEvent,
innerQuote = innerQuote, innerQuote = innerQuote,
isComplete = accountViewModel.settings.featureSet == FeatureSetType.COMPLETE, isComplete = accountViewModel.settings.isCompleteUIMode(),
hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(), hasDetailsToShow = note.zaps.isNotEmpty() || note.zapPayments.isNotEmpty() || note.reactions.isNotEmpty(),
drawAuthorInfo = drawAuthorInfo, drawAuthorInfo = drawAuthorInfo,
parentBackgroundColor = parentBackgroundColor, parentBackgroundColor = parentBackgroundColor,
@@ -49,8 +49,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Constants import com.vitorpamplona.amethyst.model.Constants
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note 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.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer 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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
@@ -160,7 +159,7 @@ fun RenderChannelData(
contentDescription = stringRes(R.string.channel_image), contentDescription = stringRes(R.string.channel_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier, modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
) )
} }
} }
@@ -237,7 +236,7 @@ fun RenderRelayLinePublicChat(
nav: INav, nav: INav,
) { ) {
@Suppress("ProduceStateDoesNotAssignValue") @Suppress("ProduceStateDoesNotAssignValue")
val relayInfo by loadRelayInfo(relay, accountViewModel) val relayInfo by loadRelayInfo(relay)
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
val clickableModifier = val clickableModifier =
@@ -255,7 +254,7 @@ fun RenderRelayLinePublicChat(
relayInfo.icon, relayInfo.icon,
clickableModifier, clickableModifier,
showPicture = accountViewModel.settings.showProfilePictures.value, 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) { viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link.url) { 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 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.foundation.layout.padding
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel 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.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier import com.vitorpamplona.amethyst.ui.theme.HeaderPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size35dp 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 @Composable
fun ShortEphemeralChatChannelHeader( fun ShortEphemeralChatChannelHeader(
@@ -119,16 +93,16 @@ private fun DrawRelayIcon(
channel: EphemeralChatChannel, channel: EphemeralChatChannel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
val relayInfo by loadRelayInfo(channel.roomId.relayUrl, accountViewModel) val relayInfo by loadRelayInfo(channel.roomId.relayUrl)
RobohashFallbackAsyncImage( RobohashFallbackAsyncImage(
robot = channel.roomId.toKey(), robot = channel.roomId.toKey(),
model = relayInfo?.icon, model = relayInfo.icon,
contentDescription = stringRes(R.string.profile_image), contentDescription = stringRes(R.string.profile_image),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier, modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
@@ -89,7 +88,7 @@ fun LongPublicChatChannelHeader(
contentDescription = stringRes(R.string.channel_image), contentDescription = stringRes(R.string.channel_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier, modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingChannel
@@ -72,7 +71,7 @@ fun ShortPublicChatChannelHeader(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier, modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
) )
} }
@@ -210,7 +210,7 @@ class ChannelMetadataViewModel : ViewModel() {
alt = null, alt = null,
sensitiveContent = null, sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl, serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
onProgress = {}, onProgress = {},
httpAuth = account::createHTTPAuthorization, httpAuth = account::createHTTPAuthorization,
context = context, context = context,
@@ -223,7 +223,7 @@ class ChannelMetadataViewModel : ViewModel() {
alt = null, alt = null,
sensitiveContent = null, sensitiveContent = null,
serverBaseUrl = account.settings.defaultFileServer.baseUrl, serverBaseUrl = account.settings.defaultFileServer.baseUrl,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.privacyState.shouldUseTorForUploads(it)) }, okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
httpAuth = account::createBlossomUploadAuth, httpAuth = account::createBlossomUploadAuth,
context = context, context = context,
) )
@@ -34,7 +34,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
@@ -74,7 +73,7 @@ fun ChannelFileUploadDialog(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = HeaderPictureModifier, modifier = HeaderPictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, 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) { viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link.url) { iMetaAttachments.downloadAndPrepare(item.link.url) {
Amethyst.instance.okHttpClients.getHttpClient( Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link.url)
accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url),
)
} }
} }
@@ -52,10 +52,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.emphChat.EphemeralChatChannel 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.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteHasEvent 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.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RoomNameDisplay 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.LoadEphemeralChatChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.header.loadRelayInfo
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier import com.vitorpamplona.amethyst.ui.theme.AccountPictureModifier
import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.Size55dp
@@ -197,7 +196,7 @@ private fun ChannelRoomCompose(
channelLastContent = "$authorName: $description", channelLastContent = "$authorName: $description",
hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(channel)) }, onClick = { nav.nav(routeFor(channel)) },
) )
} }
@@ -214,7 +213,7 @@ private fun ChannelRoomCompose(
val channel = channelState?.channel as? EphemeralChatChannel ?: return 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 noteEvent = lastMessage.event
val description = noteEvent?.content?.take(200) val description = noteEvent?.content?.take(200)
@@ -229,7 +228,7 @@ private fun ChannelRoomCompose(
channelLastContent = "$authorName: $description", channelLastContent = "$authorName: $description",
hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(channel)) }, onClick = { nav.nav(routeFor(channel)) },
) )
} }
@@ -541,10 +541,9 @@ open class NewProductViewModel :
val wordToInsert = item.link.url + " " val wordToInsert = item.link.url + " "
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
iMetaDescription.downloadAndPrepare( iMetaDescription.downloadAndPrepare(item.link.url) {
item.link.url, Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link.url)
{ Amethyst.instance.okHttpClients.getHttpClient(accountViewModel?.account?.privacyState?.shouldUseTorForImageDownload(item.link.url) ?: false) }, }
)
} }
message = message.replaceCurrentWord(wordToInsert) message = message.replaceCurrentWord(wordToInsert)
@@ -815,12 +815,8 @@ open class ShortNotePostViewModel :
val wordToInsert = item.link.url + " " val wordToInsert = item.link.url + " "
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare( iMetaAttachments.downloadAndPrepare(item.link.url) {
item.link.url, Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForImage(item.link.url)
) {
Amethyst.instance.okHttpClients.getHttpClient(
accountViewModel.account.privacyState.shouldUseTorForImageDownload(item.link.url),
)
} }
} }
@@ -20,9 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications 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.Column
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -31,29 +28,24 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.google.accompanist.permissions.PermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.ui.components.SelectNotificationProvider import com.vitorpamplona.amethyst.ui.components.SelectNotificationProvider
import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys import com.vitorpamplona.amethyst.ui.feeds.ScrollStateKeys
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable @Composable
fun NotificationScreen( fun NotificationScreen(
sharedPreferencesViewModel: SharedPreferencesViewModel,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
NotificationScreen( NotificationScreen(
notifFeedContentState = accountViewModel.feedStates.notifications, notifFeedContentState = accountViewModel.feedStates.notifications,
notifSummaryState = accountViewModel.feedStates.notificationSummary, notifSummaryState = accountViewModel.feedStates.notificationSummary,
sharedPreferencesViewModel = sharedPreferencesViewModel, sharedPrefs = accountViewModel.settings.uiSettingsFlow,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
nav = nav, nav = nav,
) )
@@ -63,11 +55,11 @@ fun NotificationScreen(
fun NotificationScreen( fun NotificationScreen(
notifFeedContentState: CardFeedContentState, notifFeedContentState: CardFeedContentState,
notifSummaryState: NotificationSummaryState, notifSummaryState: NotificationSummaryState,
sharedPreferencesViewModel: SharedPreferencesViewModel, sharedPrefs: UiSettingsFlow,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: INav, nav: INav,
) { ) {
SelectNotificationProvider(sharedPreferencesViewModel) SelectNotificationProvider(sharedPrefs)
WatchAccountForNotifications(notifFeedContentState, accountViewModel) 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 @Composable
fun WatchAccountForNotifications( fun WatchAccountForNotifications(
notifFeedContentState: CardFeedContentState, notifFeedContentState: CardFeedContentState,
@@ -585,7 +585,7 @@ class NewPublicMessageViewModel :
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
iMetaAttachments.downloadAndPrepare(item.link.url) { 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.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R 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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar 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.PrivacySettingsBody
import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel import com.vitorpamplona.amethyst.ui.tor.TorDialogViewModel
import com.vitorpamplona.amethyst.ui.tor.TorSettings
import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PrivacyOptionsScreen( fun PrivacyOptionsScreen(
accountViewModel: AccountViewModel, torSettingsFlow: TorSettingsFlow,
nav: INav, nav: INav,
) { ) {
val dialogViewModel = viewModel<TorDialogViewModel>() val dialogViewModel = viewModel<TorDialogViewModel>()
LaunchedEffect(dialogViewModel, accountViewModel) { // runs only once and before the rest of the screen is build
dialogViewModel.reset( // to avoid blinking and animations from the default/previous
accountViewModel.account.settings.torSettings // state to the current state
.toSettings(), 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( Scaffold(
topBar = { topBar = {
SavingTopBar( SavingTopBar(
@@ -62,7 +77,7 @@ fun PrivacyOptionsScreen(
nav.popBack() nav.popBack()
}, },
onPost = { onPost = {
accountViewModel.setTorSettings(dialogViewModel.save()) onPost(dialogViewModel.save())
nav.popBack() nav.popBack()
}, },
) )
@@ -297,7 +297,7 @@ fun UrlVideoView(
callbackUri = content.uri, callbackUri = content.uri,
mimeType = content.mimeType, mimeType = content.mimeType,
aspectRatio = ratio, aspectRatio = ratio,
proxyPort = accountViewModel.proxyPortForVideo(content.url), proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(content.url),
) { mediaItem -> ) { mediaItem ->
GetVideoController( GetVideoController(
mediaItem = mediaItem, mediaItem = mediaItem,
@@ -89,7 +89,7 @@ private fun GalleryFeedLoaded(
val items by loaded.feed.collectAsStateWithLifecycle() val items by loaded.feed.collectAsStateWithLifecycle()
val ratio = val ratio =
if (accountViewModel.settings.modernGalleryStyle.value) { if (accountViewModel.settings.modernGalleryStyle()) {
0.8f 0.8f
} else { } else {
1.0f 1.0f
@@ -34,7 +34,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
@@ -194,7 +193,7 @@ private fun RenderBadgeImage(
robot = "badgenotfound", robot = "badgenotfound",
contentDescription = description, contentDescription = description,
modifier = BadgePictureModifier, modifier = BadgePictureModifier,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
) )
} else { } else {
RobohashFallbackAsyncImage( RobohashFallbackAsyncImage(
@@ -203,7 +202,7 @@ private fun RenderBadgeImage(
contentDescription = description, contentDescription = description,
modifier = BadgePictureModifier, modifier = BadgePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, 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.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.DisplayNIP05 import com.vitorpamplona.amethyst.ui.components.DisplayNIP05
@@ -166,7 +165,7 @@ fun ShowQRDialog(
contentDescription = stringRes(R.string.profile_image), contentDescription = stringRes(R.string.profile_image),
modifier = MaterialTheme.colorScheme.largeProfilePictureModifier, modifier = MaterialTheme.colorScheme.largeProfilePictureModifier,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
) )
} }
Row( Row(
@@ -51,7 +51,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R 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.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.ClickableEmail import com.vitorpamplona.amethyst.ui.components.ClickableEmail
import com.vitorpamplona.amethyst.ui.components.ClickableUrl 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.UserCompose
import com.vitorpamplona.amethyst.ui.note.timeAgo import com.vitorpamplona.amethyst.ui.note.timeAgo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -126,7 +125,7 @@ fun RelayInformationScreen(
) )
}, },
) { pad -> ) { pad ->
val relayInfo by loadRelayInfo(relay, accountViewModel) val relayInfo by loadRelayInfo(relay)
val messages = val messages =
remember(relay) { remember(relay) {
@@ -158,7 +157,7 @@ fun RelayInformationScreen(
displayUrl = relay.displayUrl(), displayUrl = relay.displayUrl(),
iconUrl = relayInfo.icon, iconUrl = relayInfo.icon,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
RelayStats.get(relay).pingInMs, RelayStats.get(relay).pingInMs,
iconModifier = LargeRelayIconModifier, iconModifier = LargeRelayIconModifier,
) )
@@ -37,11 +37,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import com.vitorpamplona.amethyst.R 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.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding import com.vitorpamplona.amethyst.ui.theme.HalfHalfVertPadding
@@ -80,7 +80,7 @@ fun BasicRelaySetupInfoClickableRow(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = HalfVertPadding, modifier = HalfVertPadding,
) { ) {
val iconUrlFromRelayInfoDoc by loadRelayInfo(item.relay, accountViewModel) val iconUrlFromRelayInfoDoc by loadRelayInfo(item.relay)
RenderRelayIcon( RenderRelayIcon(
iconUrlFromRelayInfoDoc.id ?: item.relay.displayUrl(), 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.Composable
import androidx.compose.runtime.getValue 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.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -37,7 +36,7 @@ fun BasicRelaySetupInfoDialog(
BasicRelaySetupInfoClickableRow( BasicRelaySetupInfoClickableRow(
item = item, item = item,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onDelete = onDelete, onDelete = onDelete,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onClick = { nav.nav(Route.RelayInfo(item.relay.url)) }, onClick = { nav.nav(Route.RelayInfo(item.relay.url)) },
@@ -24,7 +24,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.service.replace
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -69,11 +68,8 @@ abstract class BasicRelaySetupInfoModel : ViewModel() {
fun loadRelayDocuments() { fun loadRelayDocuments() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_relays.value.forEach { item -> _relays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo( Amethyst.instance.nip11Cache.loadRelayInfo(
relay = item.relay, relay = item.relay,
okHttpClient = {
Amethyst.instance.okHttpClients.getHttpClient(account.torRelayState.shouldUseTorForClean(item.relay))
},
onInfo = { onInfo = {
togglePaidRelay(item, it.limitation?.payment_required ?: false) togglePaidRelay(item, it.limitation?.payment_required ?: false)
}, },
@@ -91,7 +87,7 @@ abstract class BasicRelaySetupInfoModel : ViewModel() {
relaySetupInfoBuilder( relaySetupInfoBuilder(
normalized = it, normalized = it,
forcesTor = forcesTor =
account.torRelayState.flow.value Amethyst.instance.torEvaluatorFlow.flow.value
.useTor(it), .useTor(it),
) )
}.distinctBy { it.relay } }.distinctBy { it.relay }
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.LocalCache 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.BasicRelaySetupInfo
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel
@@ -36,7 +37,7 @@ class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() {
relay = it, relay = it,
relayStat = RelayStats.get(it), relayStat = RelayStats.get(it),
forcesTor = forcesTor =
account.torRelayState.flow.value Amethyst.instance.torEvaluatorFlow.flow.value
.useTor(it), .useTor(it),
users = users =
account.followsPerRelay.value[it]?.mapNotNull { hex -> account.followsPerRelay.value[it]?.mapNotNull { hex ->
@@ -25,7 +25,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.Nip11CachedRetriever
import com.vitorpamplona.amethyst.service.replace import com.vitorpamplona.amethyst.service.replace
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo
@@ -91,9 +90,8 @@ class Nip65RelayListViewModel : ViewModel() {
fun loadRelayDocuments() { fun loadRelayDocuments() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
_homeRelays.value.forEach { item -> _homeRelays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo( Amethyst.instance.nip11Cache.loadRelayInfo(
relay = item.relay, relay = item.relay,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.torRelayState.shouldUseTorForClean(item.relay)) },
onInfo = { onInfo = {
toggleHomePaidRelay(item, it.limitation?.payment_required ?: false) toggleHomePaidRelay(item, it.limitation?.payment_required ?: false)
}, },
@@ -102,9 +100,8 @@ class Nip65RelayListViewModel : ViewModel() {
} }
_notificationRelays.value.forEach { item -> _notificationRelays.value.forEach { item ->
Nip11CachedRetriever.loadRelayInfo( Amethyst.instance.nip11Cache.loadRelayInfo(
relay = item.relay, relay = item.relay,
okHttpClient = { Amethyst.instance.okHttpClients.getHttpClient(account.torRelayState.shouldUseTorForClean(item.relay)) },
onInfo = { onInfo = {
toggleNotifPaidRelay(item, it.limitation?.payment_required ?: false) 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.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.LocalCache 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.service.relayClient.searchCommand.TextSearchDataSourceSubscription
import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold 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.SearchIcon
import com.vitorpamplona.amethyst.ui.note.UserCompose import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel 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.screen.loggedIn.chats.rooms.ChannelName
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DividerThickness
@@ -294,7 +293,7 @@ private fun DisplaySearchResults(
channelLastContent = item.summary(), channelLastContent = item.summary(),
hasNewMessages = false, hasNewMessages = false,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(item)) }, onClick = { nav.nav(routeFor(item)) },
) )
@@ -308,7 +307,7 @@ private fun DisplaySearchResults(
ephemeralChannels, ephemeralChannels,
key = { _, item -> "ephem" + item.roomId.toKey() }, key = { _, item -> "ephem" + item.roomId.toKey() },
) { _, item -> ) { _, item ->
val relayInfo by loadRelayInfo(item.roomId.relayUrl, accountViewModel) val relayInfo by loadRelayInfo(item.roomId.relayUrl)
ChannelName( ChannelName(
channelIdHex = item.roomId.toKey(), channelIdHex = item.roomId.toKey(),
@@ -323,7 +322,7 @@ private fun DisplaySearchResults(
channelLastContent = stringRes(R.string.ephemeral_relay_chat), channelLastContent = stringRes(R.string.ephemeral_relay_chat),
hasNewMessages = false, hasNewMessages = false,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(item)) }, onClick = { nav.nav(routeFor(item)) },
) )
@@ -350,7 +349,7 @@ private fun DisplaySearchResults(
channelLastContent = item.summary(), channelLastContent = item.summary(),
hasNewMessages = false, hasNewMessages = false,
loadProfilePicture = accountViewModel.settings.showProfilePictures.value, loadProfilePicture = accountViewModel.settings.showProfilePictures.value,
loadRobohash = accountViewModel.settings.featureSet != FeatureSetType.PERFORMANCE, loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
onClick = { nav.nav(routeFor(item)) }, onClick = { nav.nav(routeFor(item)) },
) )
@@ -24,7 +24,6 @@ import android.content.Context
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -35,6 +34,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.FeatureSetType
import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.model.parseBooleanType import com.vitorpamplona.amethyst.model.parseBooleanType
import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseConnectivityType
import com.vitorpamplona.amethyst.model.parseFeatureSetType 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.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton 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.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.mockSharedPreferencesViewModel
import com.vitorpamplona.amethyst.ui.stringRes 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.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow
@@ -78,6 +78,55 @@ import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserException import org.xmlpull.v1.XmlPullParserException
import java.io.IOException 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 { fun Context.getLocaleListFromXml(): LocaleListCompat {
val tagsList = mutableListOf<CharSequence>() val tagsList = mutableListOf<CharSequence>()
try { try {
@@ -116,9 +165,8 @@ fun Context.getLangPreferenceDropdownEntries(): ImmutableMap<String, String> {
fun getLanguageIndex( fun getLanguageIndex(
languageEntries: ImmutableMap<String, String>, languageEntries: ImmutableMap<String, String>,
sharedPreferencesViewModel: SharedPreferencesViewModel, language: String?,
): Int { ): Int {
val language = sharedPreferencesViewModel.sharedPrefs.language
var languageIndex: Int var languageIndex: Int
languageIndex = languageIndex =
if (language != null) { if (language != null) {
@@ -134,55 +182,153 @@ fun getLanguageIndex(
} }
@Composable @Composable
fun SettingsScreen( fun ShowLanguageChoice(sharedPrefs: UiSettingsFlow) {
sharedPreferencesViewModel: SharedPreferencesViewModel, val context = LocalContext.current
accountViewModel: AccountViewModel,
nav: INav, val languageEntries = remember { context.getLangPreferenceDropdownEntries() }
) { val languageList = remember { languageEntries.keys.map { TitleExplainer(it) }.toImmutableList() }
DisappearingScaffold(
isInvertedLayout = false, val language by sharedPrefs.preferredLanguage.collectAsState()
topBar = {
TopBarWithBackButton(stringRes(id = R.string.application_preferences), nav::popBack) val languageIndex = getLanguageIndex(languageEntries, language)
},
accountViewModel = accountViewModel, SettingsRow(
R.string.language,
R.string.language_description,
languageList,
languageIndex,
) { ) {
Column(Modifier.padding(it)) { sharedPrefs.preferredLanguage.tryEmit(languageEntries[languageList[it].title])
SettingsScreen(sharedPreferencesViewModel)
}
}
}
@Preview(device = "spec:width=2160px,height=2340px,dpi=440")
@Composable
fun SettingsScreenPreview() {
val sharedPreferencesViewModel = mockSharedPreferencesViewModel()
ThemeComparisonRow {
SettingsScreen(sharedPreferencesViewModel)
} }
} }
@Composable @Composable
fun SettingsScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) { fun ShowThemeChoice(sharedPrefs: UiSettingsFlow) {
val selectedItens = 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( persistentListOf(
TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)), TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)),
TitleExplainer(stringRes(ConnectivityType.WIFI_ONLY.resourceId)), TitleExplainer(stringRes(ConnectivityType.WIFI_ONLY.resourceId)),
TitleExplainer(stringRes(ConnectivityType.NEVER.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( persistentListOf(
TitleExplainer(stringRes(ThemeType.SYSTEM.resourceId)), TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)),
TitleExplainer(stringRes(ThemeType.LIGHT.resourceId)), TitleExplainer(stringRes(ConnectivityType.WIFI_ONLY.resourceId)),
TitleExplainer(stringRes(ThemeType.DARK.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 = val booleanItems =
persistentListOf( persistentListOf(
TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)), TitleExplainer(stringRes(ConnectivityType.ALWAYS.resourceId)),
TitleExplainer(stringRes(ConnectivityType.NEVER.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 = val featureItems =
persistentListOf( persistentListOf(
TitleExplainer(stringRes(FeatureSetType.COMPLETE.resourceId)), TitleExplainer(stringRes(FeatureSetType.COMPLETE.resourceId)),
@@ -190,136 +336,35 @@ fun SettingsScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) {
TitleExplainer(stringRes(FeatureSetType.PERFORMANCE.resourceId)), 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 = val galleryItems =
persistentListOf( persistentListOf(
TitleExplainer(stringRes(ProfileGalleryType.CLASSIC.resourceId)), TitleExplainer(stringRes(ProfileGalleryType.CLASSIC.resourceId)),
TitleExplainer(stringRes(ProfileGalleryType.MODERN.resourceId)), TitleExplainer(stringRes(ProfileGalleryType.MODERN.resourceId)),
) )
val showImagesIndex = sharedPreferencesViewModel.sharedPrefs.automaticallyShowImages.screenCode val galleryIndex by sharedPrefs.gallerySet.collectAsState()
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 context = LocalContext.current SettingsRow(
R.string.gallery_style,
val languageEntries = remember { context.getLangPreferenceDropdownEntries() } R.string.gallery_style_description,
val languageList = remember { languageEntries.keys.map { TitleExplainer(it) }.toImmutableList() } galleryItems,
val languageIndex = getLanguageIndex(languageEntries, sharedPreferencesViewModel) galleryIndex.screenCode,
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( sharedPrefs.gallerySet.tryEmit(parseGalleryType(it))
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)
} }
} }
@@ -327,15 +372,15 @@ fun SettingsScreen(sharedPreferencesViewModel: SharedPreferencesViewModel) {
fun SettingsRow( fun SettingsRow(
name: Int, name: Int,
description: Int, description: Int,
selectedItens: ImmutableList<TitleExplainer>, selectedItems: ImmutableList<TitleExplainer>,
selectedIndex: Int, selectedIndex: Int,
onSelect: (Int) -> Unit, onSelect: (Int) -> Unit,
) { ) {
SettingsRow(name, description) { SettingsRow(name, description) {
TextSpinner( TextSpinner(
label = "", label = "",
placeholder = selectedItens[selectedIndex].title, placeholder = selectedItems[selectedIndex].title,
options = selectedItens, options = selectedItems,
onSelect = onSelect, onSelect = onSelect,
modifier = Modifier.windowInsetsPadding(WindowInsets(0.dp, 0.dp, 0.dp, 0.dp)), 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.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.ui.components.ClickableBox import com.vitorpamplona.amethyst.ui.components.ClickableBox
@@ -327,7 +326,7 @@ private fun RenderAuthorInformation(
NoteUsernameDisplay(note, Modifier.weight(1f), accountViewModel = accountViewModel) NoteUsernameDisplay(note, Modifier.weight(1f), accountViewModel = accountViewModel)
VideoUserOptionAction(note, accountViewModel, nav) VideoUserOptionAction(note, accountViewModel, nav)
} }
if (accountViewModel.settings.featureSet == FeatureSetType.COMPLETE) { if (accountViewModel.settings.isCompleteUIMode()) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
ObserveDisplayNip05Status( ObserveDisplayNip05Status(
note.author!!, note.author!!,
@@ -32,15 +32,15 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R 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.components.appendLink
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.tor.ConnectTorDialog import com.vitorpamplona.amethyst.ui.tor.ConnectTorDialog
import com.vitorpamplona.amethyst.ui.tor.TorSettings import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
@Composable @Composable
fun TorSettingsSetup( fun TorSettingsSetup(
torSettings: TorSettings, torSettingsFlow: TorSettingsFlow,
onCheckedChange: (TorSettings) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
) { ) {
var connectOrbotDialogOpen by remember { mutableStateOf(false) } var connectOrbotDialogOpen by remember { mutableStateOf(false) }
@@ -57,11 +57,11 @@ fun TorSettingsSetup(
if (connectOrbotDialogOpen) { if (connectOrbotDialogOpen) {
ConnectTorDialog( ConnectTorDialog(
torSettings = torSettings, torSettings = torSettingsFlow.toSettings(),
onClose = { connectOrbotDialogOpen = false }, onClose = { connectOrbotDialogOpen = false },
onPost = { torSettings -> onPost = { torSettings ->
connectOrbotDialogOpen = false connectOrbotDialogOpen = false
onCheckedChange(torSettings) torSettingsFlow.update(torSettings)
}, },
onError = onError, onError = onError,
) )
@@ -74,6 +74,7 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
@@ -170,15 +171,12 @@ fun LoginPage(
} }
} }
Spacer(modifier = Modifier.height(10.dp))
PasswordField(loginViewModel) PasswordField(loginViewModel)
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
TorSettingsSetup( TorSettingsSetup(
torSettings = loginViewModel.torSettings, torSettingsFlow = Amethyst.instance.torPrefs.value,
onCheckedChange = loginViewModel::updateTorSettings,
onError = { onError = {
scope.launch { scope.launch {
Toast Toast
@@ -262,6 +260,8 @@ fun OfferTemporaryAccount(
@Composable @Composable
private fun PasswordField(loginViewModel: LoginViewModel) { private fun PasswordField(loginViewModel: LoginViewModel) {
if (loginViewModel.needsPassword) { if (loginViewModel.needsPassword) {
Spacer(modifier = Modifier.height(10.dp))
val passwordFocusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() }
PasswordField( PasswordField(
@@ -28,7 +28,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.tor.TorSettings
class LoginViewModel : ViewModel() { class LoginViewModel : ViewModel() {
lateinit var accountStateViewModel: AccountStateViewModel lateinit var accountStateViewModel: AccountStateViewModel
@@ -39,7 +38,6 @@ class LoginViewModel : ViewModel() {
var acceptedTerms by mutableStateOf(false) var acceptedTerms by mutableStateOf(false)
var termsAcceptanceIsRequiredError by mutableStateOf(false) var termsAcceptanceIsRequiredError by mutableStateOf(false)
var torSettings by mutableStateOf(TorSettings())
var offerTemporaryLogin by mutableStateOf(false) var offerTemporaryLogin by mutableStateOf(false)
var isTemporary by mutableStateOf(false) var isTemporary by mutableStateOf(false)
@@ -77,7 +75,6 @@ class LoginViewModel : ViewModel() {
processingLogin = false processingLogin = false
isTemporary = false isTemporary = false
offerTemporaryLogin = false offerTemporaryLogin = false
torSettings = TorSettings()
isFirstLogin = false isFirstLogin = false
} }
@@ -98,10 +95,6 @@ class LoginViewModel : ViewModel() {
errorManager.clearErrors() errorManager.clearErrors()
} }
fun updateTorSettings(newTorSettings: TorSettings) {
torSettings = newTorSettings
}
fun updateAcceptedTerms(newAcceptedTerms: Boolean) { fun updateAcceptedTerms(newAcceptedTerms: Boolean) {
acceptedTerms = newAcceptedTerms acceptedTerms = newAcceptedTerms
if (newAcceptedTerms) { if (newAcceptedTerms) {
@@ -140,7 +133,6 @@ class LoginViewModel : ViewModel() {
accountStateViewModel.login( accountStateViewModel.login(
key = key.text, key = key.text,
password = password.text, password = password.text,
torSettings = torSettings,
transientAccount = isTemporary, transientAccount = isTemporary,
) { ) {
processingLogin = false processingLogin = false
@@ -158,7 +150,6 @@ class LoginViewModel : ViewModel() {
processingLogin = true processingLogin = true
accountStateViewModel.login( accountStateViewModel.login(
key = key.text, key = key.text,
torSettings = torSettings,
transientAccount = isTemporary, transientAccount = isTemporary,
loginWithExternalSigner = true, loginWithExternalSigner = true,
packageName = packageName, packageName = packageName,
@@ -49,6 +49,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.hashtags.Amethyst import com.vitorpamplona.amethyst.commons.hashtags.Amethyst
import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons
@@ -165,6 +166,20 @@ fun SignUpPage(
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
TorSettingsSetup(
torSettingsFlow = Amethyst.instance.torPrefs.value,
onError = {
scope.launch {
Toast
.makeText(
context,
it,
Toast.LENGTH_LONG,
).show()
}
},
)
AcceptTerms( AcceptTerms(
checked = signUpViewModel.acceptedTerms, checked = signUpViewModel.acceptedTerms,
onCheckedChange = signUpViewModel::updateAcceptedTerms, 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)) Spacer(modifier = Modifier.height(Size10dp))
Box(modifier = Modifier.padding(Size40dp, 0.dp, Size40dp, 0.dp)) { 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.R
import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager
import com.vitorpamplona.amethyst.ui.tor.TorSettings
class SignUpViewModel : ViewModel() { class SignUpViewModel : ViewModel() {
lateinit var accountStateViewModel: AccountStateViewModel lateinit var accountStateViewModel: AccountStateViewModel
@@ -40,8 +39,6 @@ class SignUpViewModel : ViewModel() {
var acceptedTerms by mutableStateOf(false) var acceptedTerms by mutableStateOf(false)
var termsAcceptanceIsRequiredError by mutableStateOf(false) var termsAcceptanceIsRequiredError by mutableStateOf(false)
var torSettings by mutableStateOf(TorSettings())
fun init(accountStateViewModel: AccountStateViewModel) { fun init(accountStateViewModel: AccountStateViewModel) {
this.accountStateViewModel = accountStateViewModel this.accountStateViewModel = accountStateViewModel
} }
@@ -51,10 +48,6 @@ class SignUpViewModel : ViewModel() {
errorManager.clearErrors() errorManager.clearErrors()
} }
fun updateTorSettings(newTorSettings: TorSettings) {
torSettings = newTorSettings
}
fun updateAcceptedTerms(newAcceptedTerms: Boolean) { fun updateAcceptedTerms(newAcceptedTerms: Boolean) {
acceptedTerms = newAcceptedTerms acceptedTerms = newAcceptedTerms
if (newAcceptedTerms) { if (newAcceptedTerms) {
@@ -80,7 +73,7 @@ class SignUpViewModel : ViewModel() {
fun signup() { fun signup() {
if (checkCanSignup()) { 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.halilibo.richtext.ui.RichTextStyle import com.halilibo.richtext.ui.RichTextStyle
import com.halilibo.richtext.ui.resolveDefaults import com.halilibo.richtext.ui.resolveDefaults
import com.patrykandpatrick.vico.compose.common.VicoTheme import com.patrykandpatrick.vico.compose.common.VicoTheme
import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.ThemeType
private val DarkColorPalette = private val DarkColorPalette =
@@ -482,6 +484,14 @@ val chartDarkColors =
val ColorScheme.chartStyle: VicoTheme val ColorScheme.chartStyle: VicoTheme
get() = if (isLight) chartLightColors else chartDarkColors 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 @Composable
fun AmethystTheme( fun AmethystTheme(
prefTheme: ThemeType, prefTheme: ThemeType,
@@ -20,12 +20,19 @@
*/ */
package com.vitorpamplona.amethyst.ui.tor 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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow 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.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
/** /**
* There should be only one instance of the Tor binding per app. * 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. * Tor will connect as soon as status is listened to.
*/ */
class TorManager( class TorManager(
app: Application, torPrefs: TorSharedPreferences,
app: Context,
scope: CoroutineScope, scope: CoroutineScope,
) { ) {
val status: StateFlow<TorServiceStatus> = val service = TorService(app)
TorService(app).status.stateIn(
scope, @OptIn(ExperimentalCoroutinesApi::class)
SharingStarted.WhileSubscribed(30000), val status =
TorServiceStatus.Off, 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?> = val activePortOrNull: StateFlow<Int?> =
status status
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.tor
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
class TorSettings( data class TorSettings(
val torType: TorType = TorType.INTERNAL, val torType: TorType = TorType.INTERNAL,
val externalSocksPort: Int = 9050, val externalSocksPort: Int = 9050,
val onionRelaysViaTor: Boolean = true, val onionRelaysViaTor: Boolean = true,
@@ -94,6 +94,8 @@ class TorSettingsFlow(
fun update(torSettings: TorSettings): Boolean { fun update(torSettings: TorSettings): Boolean {
var any = false var any = false
println("AABBCC updating 1 $torSettings")
if (torType.value != torSettings.torType) { if (torType.value != torSettings.torType) {
torType.tryEmit(torSettings.torType) torType.tryEmit(torSettings.torType)
any = true any = true
@@ -148,6 +150,8 @@ class TorSettingsFlow(
any = true any = true
} }
println("AABBCC updating 2 ${toSettings()}")
return any return any
} }
@@ -20,19 +20,36 @@
*/ */
package com.vitorpamplona.amethyst.ui.components package com.vitorpamplona.amethyst.ui.components
import android.Manifest
import android.os.Build import android.os.Build
import androidx.compose.runtime.Composable 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.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.ui.screen.SharedPreferencesViewModel import com.google.accompanist.permissions.isGranted
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.checkifItNeedsToRequestNotificationPermission import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.model.UiSettingsFlow
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun SelectNotificationProvider(sharedPreferencesViewModel: SharedPreferencesViewModel) { fun SelectNotificationProvider(sharedPrefs: UiSettingsFlow) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 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 @Composable
fun PushNotificationSettingsRow(sharedPreferencesViewModel: SharedPreferencesViewModel) {} fun PushNotificationSettingsRow(sharedPrefs: UiSettingsFlow) {}
@@ -459,7 +459,7 @@ open class BasicRelayClient(
) )
} }
socket?.let { socket?.let {
// Log.d(logTag, "Sending: $str") Log.d(logTag, "Sending: $str")
val result = it.send(str) val result = it.send(str)
listener.onSend(this@BasicRelayClient, str, result) listener.onSend(this@BasicRelayClient, str, result)
stats.addBytesSent(str.bytesUsedInMemory()) stats.addBytesSent(str.bytesUsedInMemory())
@@ -24,35 +24,31 @@ import android.util.LruCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
class VerificationStateCache { class VerificationStateCache(
val otsResolverBuilder: OtsResolverBuilder,
) {
private val cache = LruCache<HexKey, VerificationState>(200) private val cache = LruCache<HexKey, VerificationState>(200)
fun verify( fun verify(event: OtsEvent): VerificationState {
event: OtsEvent,
resolverBuilder: OtsResolverBuilder,
): VerificationState {
cache.put(event.id, VerificationState.Verifying) 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( fun cacheVerify(event: OtsEvent): VerificationState =
event: OtsEvent,
resolverBuilder: OtsResolverBuilder,
): VerificationState =
when (val verif = cache[event.id]) { when (val verif = cache[event.id]) {
is VerificationState.Verifying -> verif is VerificationState.Verifying -> verif
is VerificationState.Verified -> verif is VerificationState.Verified -> verif
is VerificationState.NetworkError -> { is VerificationState.NetworkError -> {
// try again in 5 mins // try again in 5 mins
if (verif.time < TimeUtils.fiveMinutesAgo()) { 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 { } else {
verify(event, resolverBuilder) verify(event)
} }
} }
is VerificationState.Error -> verif is VerificationState.Error -> verif
else -> { else -> {
verify(event, resolverBuilder) verify(event)
} }
} }
} }