refactor: replace Amethyst.instance reads in screens with app services and host ports

- AppServices gains appStores (the preference DataStores) and
  torBootstrapped; the chess view-model factory, search history and the
  relay-group Tor banner read them instead of Amethyst.instance.
- The relay-group banner reads Tor routing from the host; ClinkOfferPayer
  takes the host's MoneyOpRelayRouting; the napplet manifest lookup reads
  LocalCache directly.
- AccountViewModelHost gains hasBackedUpKeys/setHasBackedUpKeys for the
  backup nudge, over LocalPreferences on Android.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-30 03:51:43 +00:00
parent cbb18379f0
commit 05bbe21f87
17 changed files with 73 additions and 30 deletions
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
@@ -38,7 +38,7 @@ fun resolveNappletMeta(
untitled: String,
): Pair<String, String?> {
val events =
Amethyst.instance.cache
LocalCache
.filter(
Filter(
kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND, RootSiteEvent.KIND, NamedSiteEvent.KIND),
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.tor.MoneyOpRelayRouting
import com.vitorpamplona.quartz.experimental.clink.client.OfferClient
import com.vitorpamplona.quartz.experimental.clink.offers.OfferEvent
import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse
@@ -57,6 +57,7 @@ object ClinkOfferPayer {
*/
suspend fun requestInvoice(
account: Account,
moneyOpRelays: MoneyOpRelayRouting,
offer: NOffer,
amountSats: Long? = null,
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
@@ -103,7 +104,7 @@ object ClinkOfferPayer {
// pushing the payment through Tor (and failing on services that block Tor exits) even when
// the user disabled Tor for money operations. The subscribe() below triggers a reconnect, and
// BasicRelayClient rebuilds any socket left on the now-wrong (Tor) transport onto clearnet.
val torState = Amethyst.instance.torEvaluatorFlow
val torState = moneyOpRelays
torState.registerMoneyOpRelays(relays)
account.client.subscribe(subId, filters, listener)
try {
@@ -182,7 +182,7 @@ fun ClinkOfferPreview(
) {
val amount = if (amountRequired) amountInput.toLongOrNull() else useOffer.price
val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, useOffer, amountSats = amount)
val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, accountViewModel.host.moneyOpRelays, useOffer, amountSats = amount)
val bolt11 = response?.bolt11
val movedTo =
@@ -59,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.commons.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory
@@ -127,7 +128,7 @@ fun RenderLiveChessChallenge(
val chessViewModel: ChessViewModel =
viewModel(
key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account),
factory = ChessViewModelFactory(accountViewModel.account, LocalAppServices.current.appStores),
)
val isOpenChallenge = event.opponentPubkey() == null
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.service.AppServices
import com.vitorpamplona.amethyst.commons.service.BlossomServerFinder
@@ -33,6 +34,8 @@ import com.vitorpamplona.amethyst.commons.service.ai.AltTextSuggester
import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow
import com.vitorpamplona.amethyst.service.ai.MLKitImageLabelService
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* [AppServices] over the main process's app modules. Every member is a getter, so installing
@@ -52,6 +55,12 @@ object AndroidAppServices : AppServices {
override val torSettings: TorSettingsFlow get() = Amethyst.instance.torPrefs.value
override val torBootstrapped: Flow<Boolean> get() =
Amethyst.instance.torManager.status
.map { it.isFullyBootstrapped }
override val appStores: AppPreferenceStores get() = Amethyst.instance.appStores
override val namecoinResolver: NamecoinNameResolver get() = Amethyst.instance.namecoinResolver
override val blossomServerFinder: BlossomServerFinder = AndroidBlossomServerFinder
@@ -86,6 +86,13 @@ class AndroidAccountViewModelHost(
override val scheduledPostStore: ScheduledPostStore get() = modules.scheduledPostStore
override suspend fun hasBackedUpKeys(npub: String): StateFlow<Boolean> = LocalPreferences.hasBackedUpKeys(npub)
override suspend fun setHasBackedUpKeys(
npub: String,
value: Boolean,
) = LocalPreferences.setHasBackedUpKeys(value, npub)
override suspend fun anonymizeVoice(
input: Path,
presetName: String,
@@ -62,7 +62,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.buzz.ui.PresenceDot
import com.vitorpamplona.amethyst.commons.chats.publicChannels.relayGroup.newestTimelineNote
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -107,6 +106,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeAgoLabels
import com.vitorpamplona.amethyst.commons.ui.note.timeAgoShort
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.buzz.BuzzDmListViewModel
@@ -366,7 +366,7 @@ fun RelayGroupChannelListScreen(
// was never consulted, and neither was whether this relay is Tor-routed at all (Tor being *on*
// doesn't mean this relay goes through it; the per-role presets decide).
val torEvaluation =
Amethyst.instance.torEvaluatorFlow.flow
accountViewModel.host.torRelayEvaluation
.collectAsStateWithLifecycle()
// The same predicate the relay pool itself dials with, so the banner can't claim Tor for a relay
// the app is reaching over clearnet (onion / localhost / trusted-off-Tor are all folded in here).
@@ -374,9 +374,8 @@ fun RelayGroupChannelListScreen(
// While Tor is bootstrapping every Tor-routed relay is silent — that's Tor's own failure (and its
// own dialog), so don't let it read as "this relay blocks Tor exits".
val torStatus by Amethyst.instance.torManager.status
.collectAsStateWithLifecycle()
val torIsUp = torStatus.isFullyBootstrapped
val torIsUp by LocalAppServices.current.torBootstrapped
.collectAsStateWithLifecycle(false)
// The offer adds the relay to the kind-10089 Trusted list, which only moves it to clearnet while
// trusted relays are *off* Tor. Under the Small-Payloads / Full-Privacy presets they are on Tor,
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores
/**
* Where Android keeps the dismissed-chess-games store.
@@ -32,5 +32,4 @@ import com.vitorpamplona.amethyst.Amethyst
* data over is not worth the code. Anyone who had dismissed a game sees it once
* more and dismisses it again.
*/
internal val chessDismissedGamesData: DataStore<Preferences>
get() = Amethyst.instance.appStores.getDataStore("chess_dismissed_games_v2")
internal fun chessDismissedGamesData(stores: AppPreferenceStores): DataStore<Preferences> = stores.getDataStore("chess_dismissed_games_v2")
@@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.chess_loading_game
import com.vitorpamplona.amethyst.commons.resources.go_back
import com.vitorpamplona.amethyst.commons.resources.relay_settings
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Height4dpModifier
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -104,7 +105,7 @@ fun ChessGameScreen(
viewModel(
viewModelStoreOwner = activity,
key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account),
factory = ChessViewModelFactory(accountViewModel.account, LocalAppServices.current.appStores),
)
val activeGames by chessViewModel.activeGames.collectAsState()
@@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.commons.resources.route_chess
import com.vitorpamplona.amethyst.commons.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.commons.ui.navigation.bottombars.FabBottomBarPadded
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessSubscription
@@ -102,7 +103,7 @@ fun ChessLobbyScreen(
viewModel(
viewModelStoreOwner = activity,
key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account),
factory = ChessViewModelFactory(accountViewModel.account, LocalAppServices.current.appStores),
)
// Subscribe to chess events when screen is visible
@@ -24,6 +24,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import com.vitorpamplona.amethyst.commons.model.Account
import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessDismissedGamesStore
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessPollingDefaults
import com.vitorpamplona.amethyst.commons.nip64Chess.ChessViewModel
@@ -34,6 +35,7 @@ import kotlin.reflect.KClass
*/
class ChessViewModelFactory(
private val account: Account,
private val appStores: AppPreferenceStores,
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
@@ -47,7 +49,7 @@ class ChessViewModelFactory(
fetcher = AndroidRelayFetcher(account),
metadataProvider = AndroidMetadataProvider(),
pollingConfig = ChessPollingDefaults.android,
dismissedStorage = ChessDismissedGamesStore(chessDismissedGamesData),
dismissedStorage = ChessDismissedGamesStore(chessDismissedGamesData(appStores)),
) as T
}
throw IllegalArgumentException("Unknown ViewModel class")
@@ -37,6 +37,7 @@ import com.vitorpamplona.amethyst.commons.nip64Chess.ui.NewChessGameDialog
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.new_chess_game
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.Size55Modifier
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
@@ -56,7 +57,7 @@ fun NewChessGameButton(
val chessViewModel: ChessViewModel =
viewModel(
key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account),
factory = ChessViewModelFactory(accountViewModel.account, LocalAppServices.current.appStores),
)
FloatingActionButton(
@@ -41,7 +41,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.navigation.Route
@@ -58,7 +57,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
@@ -66,7 +65,7 @@ import kotlinx.coroutines.launch
* generated accounts that haven't saved their secret key yet. It never blocks
* navigation: the user either backs up (navigates to [Route.AccountBackup]) or
* confirms they already saved the key. Both actions flip the per-account
* [LocalPreferences.setHasBackedUpKeys] flag so the nudge stops appearing.
* backed-up flag so the nudge stops appearing.
*/
@Composable
fun BackupKeysNudge(
@@ -81,19 +80,20 @@ fun BackupKeysNudge(
// Seed the reactive flag off a background read. Rendering only proceeds once the
// flow resolves, so the observing composable never conditionally calls hooks.
val flow by produceState<MutableStateFlow<Boolean>?>(initialValue = null, key1 = npub) {
value = LocalPreferences.hasBackedUpKeys(npub)
val flow by produceState<StateFlow<Boolean>?>(initialValue = null, key1 = npub) {
value = accountViewModel.host.hasBackedUpKeys(npub)
}
flow?.let { stateFlow ->
WatchBackupKeysNudge(stateFlow, npub, nav, modifier)
WatchBackupKeysNudge(stateFlow, npub, accountViewModel, nav, modifier)
}
}
@Composable
private fun WatchBackupKeysNudge(
stateFlow: MutableStateFlow<Boolean>,
stateFlow: StateFlow<Boolean>,
npub: String,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier,
) {
@@ -106,11 +106,11 @@ private fun WatchBackupKeysNudge(
onBackupNow = {
// Best-effort: opening the backup screen counts as backing up so the
// nudge doesn't linger after the user follows through.
scope.launch { LocalPreferences.setHasBackedUpKeys(true, npub) }
scope.launch { accountViewModel.host.setHasBackedUpKeys(npub, true) }
nav.nav(Route.AccountBackup)
},
onAlreadySaved = {
scope.launch { LocalPreferences.setHasBackedUpKeys(true, npub) }
scope.launch { accountViewModel.host.setHasBackedUpKeys(npub, true) }
},
)
}
@@ -488,7 +488,7 @@ private fun SendPaymentLoaded(
amount: Long?,
followMoved: Boolean,
) {
val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, useOffer, amountSats = amount)
val response = ClinkOfferPayer.requestInvoice(accountViewModel.account, accountViewModel.host.moneyOpRelays, useOffer, amountSats = amount)
val bolt11 = response?.bolt11
val movedTo =
@@ -79,7 +79,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
@@ -130,6 +129,7 @@ import com.vitorpamplona.amethyst.commons.ui.note.UserCompose
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.commons.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.commons.ui.platform.AppBottomBar
import com.vitorpamplona.amethyst.commons.ui.platform.LocalAppServices
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.chats.rooms.ChannelName
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoClickableRow
@@ -168,7 +168,8 @@ fun SearchScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val historyStorage = remember { DataStoreSearchHistoryStorage(Amethyst.instance.appStores.getDataStore(DataStoreSearchHistoryStorage.FILE_NAME)) }
val appStores = LocalAppServices.current.appStores
val historyStorage = remember { DataStoreSearchHistoryStorage(appStores.getDataStore(DataStoreSearchHistoryStorage.FILE_NAME)) }
val searchBarViewModel: SearchBarViewModel =
viewModel(
// Keyed on the seed: navigating from one screen's search button to another's has to
@@ -25,10 +25,12 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserIconRegistry
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.commons.model.location.DeviceLocation
import com.vitorpamplona.amethyst.commons.model.preferences.AppPreferenceStores
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.service.ai.AltTextSuggester
import com.vitorpamplona.amethyst.commons.tor.TorSettingsFlow
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import kotlinx.coroutines.flow.Flow
/**
* App-wide stores that outlive any one account and that screens read directly: the favorites
@@ -53,6 +55,12 @@ interface AppServices {
val torSettings: TorSettingsFlow
/** Whether the embedded Tor service has fully bootstrapped, so Tor-routed relays can answer. */
val torBootstrapped: Flow<Boolean>
/** The app's preference files, one DataStore per name. */
val appStores: AppPreferenceStores
/** Resolves `.bit` names and `d/`/`id/` identifiers over the configured ElectrumX servers. */
val namecoinResolver: NamecoinNameResolver
@@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import kotlinx.collections.immutable.PersistentMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import okio.Path
@@ -93,6 +94,18 @@ interface AccountViewModelHost {
/** A new on-device writing assistant, or null where the platform has none. */
fun createWritingAssistant(): WritingAssistant? = null
/**
* Whether the account [npub] has saved its secret key, as a live flag. Absent reads as true,
* so a platform that does not track it never shows the backup nudge.
*/
suspend fun hasBackedUpKeys(npub: String): StateFlow<Boolean> = MutableStateFlow(true)
/** Records whether the account [npub] has saved its secret key. */
suspend fun setHasBackedUpKeys(
npub: String,
value: Boolean,
) = Unit
/** Public keys of every account saved on this device. */
val savedAccounts: Flow<Set<HexKey>>