feat(identity): a switch that tears down what it should
Phase 2 of docs/multiple-profiles.md. The transition, made correct, with nothing yet calling it from a screen: the view model, one manager, and three one-line actuals. switchToWallet becomes switchToIdentity, and does what a switch is: remember which one, set startWalletImmediately back to true -- a switch is the user saying which one, and the flag was only ever the user saying "show me the list"; nothing set it back before because nothing could switch -- clear the active identity, and stop the node of the profile being left, if it had one. The identity is cleared before the node is stopped, so that every collector that could reach the business is cancelled before the stop runs. The test is business != null, not the kind: whether the profile being left has a node behind it is the fact, and the kind is how it currently comes to be true. stopPlatformBusiness is an expect beside updateBusinessActiveInUI, for the reason that one is: BusinessManager is a per-platform object. Its three actuals call stopBusiness, which existed on every platform and nothing called. The manager is injected into the view model as a function so that the branch can be pinned without a node. RelaysSocketManager.observeActiveUserId becomes a child of collectLatest -- the shape the pumps and the notary already had. It used to keep one job per pubkey on its own scope and cancel only the job for the pubkey being started, which meant a switch left the previous identity's observer running: two observers feeding updateRelayPools, and the one pool following whichever relay list emitted last. The map goes; a null identity closes nothing, since the pool is shared by pumps a null identity has already cancelled, and the next identity's list replaces it through changeRelays as it always did. The scope is injectable and relayUrls is exposed, both for the test. The sign-in and create tails keep their own navigate beside the observer's, now with a comment saying why: the navigation state is a StateFlow, an update to a state equal to the current one emits nothing, and the explicit navigation is what guarantees the stack moves even on the day the state does not. Tests: IdentitySwitchJvmTest, through the real SovereignWalletViewModel with a real notary and navigation machine on an in-memory database -- A open and a kind 1 queued for A is signed; switch to B, the identity clears, the machine goes to startup, B is activated the way startup does and routed from its account; a kind 1 queued for A now waits three seconds unsigned, and goes out when A is back. Leaving a profile with a PhoenixBusiness behind it -- constructible without a node, since everything in it is lazy -- stops that node once; leaving nothing, or a bare key, stops nothing. RelaysSocketManagerSwitchJvmTest over a fake relay repository and fake sockets: after the switch the pool holds B's relays, a re-emission of A's list changes nothing -- the line that fails against the old observer -- and an identity whose list is still empty leaves the pool as it was, which is the behaviour updateRelayPools has always had for an empty list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@9b3a19278f
This commit is contained in:
@@ -2,7 +2,6 @@ package press.mantra.compose.network.relays
|
||||
|
||||
import press.mantra.compose.network.dto.mapToRelayDTO
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip77Negentropy.NegCloseCmd
|
||||
@@ -13,7 +12,6 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
@@ -32,9 +30,10 @@ class RelaysSocketManager(
|
||||
private val nostrSocketClientFactory: press.mantra.compose.network.sockets.NostrSocketClientFactory,
|
||||
private val cachingImportRepository: press.mantra.compose.repository.CachingImportRepository,
|
||||
private val relayRepository: press.mantra.compose.repository.RelayRepository,
|
||||
/** Injectable so that a test can drive the identity observer on a dispatcher it controls. */
|
||||
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
|
||||
) : LiveSubscriptionTransport, EventPublishTransport {
|
||||
val logger = Logger.withTag("RelaysSocketManager")
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
private val relayPoolsMutex = Mutex()
|
||||
|
||||
/**
|
||||
@@ -56,35 +55,36 @@ class RelaysSocketManager(
|
||||
observeActiveUserId()
|
||||
}
|
||||
|
||||
private val observeRelayJobs = mutableMapOf<HexKey, Job>()
|
||||
|
||||
/**
|
||||
* Follows the active identity's relay list into the pool, as a child of `collectLatest`
|
||||
* so that the next identity cancels it -- the shape the pumps and the notary already
|
||||
* have.
|
||||
*
|
||||
* It used to be a job per pubkey on [scope], replaced only when the *same* pubkey came
|
||||
* round again, which meant a switch left the previous identity's observer running:
|
||||
* two observers feeding [updateRelayPools], and the one pool following whichever relay
|
||||
* list emitted last. A null identity closes nothing -- the pool is shared by pumps a
|
||||
* null identity has already cancelled -- and the next identity's list replaces it
|
||||
* through `changeRelays`, as it always did.
|
||||
*/
|
||||
private fun observeActiveUserId() =
|
||||
scope.launch {
|
||||
activeIdentityStateFlow.collectLatest { identity ->
|
||||
if (identity == null) {
|
||||
// TODO: Cancel all pending jobs?
|
||||
}
|
||||
// The identity carries its pubkey, so this no longer waits on the node's key
|
||||
// manager to fill in. The relay observer is still a job on `scope` keyed by
|
||||
// pubkey and replaced per pubkey, exactly as before.
|
||||
identity?.nostrPublicKey?.let { pubkey ->
|
||||
observeRelayJobs[pubkey]?.cancel()
|
||||
observeRelayJobs[pubkey] = observeRelays(pubkey)
|
||||
val publicKey = identity?.nostrPublicKey ?: return@collectLatest
|
||||
try {
|
||||
relayRepository.observePublicKeyRelays(publicKey = publicKey).collect { relays ->
|
||||
val userRelays = relays.filter { it.type == "user" }.map { it.mapToRelayDTO() }
|
||||
updateRelayPools(regularRelays = userRelays)
|
||||
}
|
||||
} catch (error: CancellationException) {
|
||||
logger.d { "Relay observation for $publicKey cancelled" }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeRelays(publicKey: String): Job =
|
||||
scope.launch {
|
||||
try {
|
||||
relayRepository.observePublicKeyRelays(publicKey = publicKey).collect { relays ->
|
||||
val userRelays = relays.filter { it.type == "user" }.map { it.mapToRelayDTO() }
|
||||
updateRelayPools(regularRelays = userRelays)
|
||||
}
|
||||
} catch (error: CancellationException) {
|
||||
logger.w(throwable = error) { "Relay observation cancelled" }
|
||||
}
|
||||
}
|
||||
/** The urls the pool currently holds. For the switch test; nothing in the app reads it. */
|
||||
val relayUrls: Set<String> get() = relayPool.relays.map { it.url }.toSet()
|
||||
|
||||
private suspend fun updateRelayPools(regularRelays: List<press.mantra.compose.network.dto.RelayDTO>?) {
|
||||
relayPoolsMutex.withLock {
|
||||
|
||||
@@ -135,7 +135,7 @@ fun SovereignWalletStartupScreen(
|
||||
walletsMetadata = availableWalletMetadata,
|
||||
activeWalletId = null,
|
||||
onWalletClick = {
|
||||
sovereignWalletViewModel.switchToWallet(
|
||||
sovereignWalletViewModel.switchToIdentity(
|
||||
it.id
|
||||
); loadingIdentity = it
|
||||
},
|
||||
|
||||
@@ -523,7 +523,7 @@ fun MantraNavHost(
|
||||
|
||||
sovereignWalletViewModel.loadSovereignData(walletId)
|
||||
sovereignWalletViewModel.listIdentities {
|
||||
sovereignWalletViewModel.switchToWallet(walletId)
|
||||
sovereignWalletViewModel.switchToIdentity(walletId)
|
||||
navController.navigate(
|
||||
route = SovereignWalletStartupRoute
|
||||
)
|
||||
@@ -719,10 +719,17 @@ fun MantraNavHost(
|
||||
// startup. Startup finds the new identity, activates it, and the navigation
|
||||
// machine takes it from the placeholder account the sign-in planted. The
|
||||
// form is popped so that back does not return to a field holding a secret.
|
||||
//
|
||||
// The navigate here looks redundant with the observer's -- switching clears
|
||||
// the identity, and a null identity is StartupPhoenix, which the observer
|
||||
// above navigates to with the same popUpTo(0). It is not: the navigation
|
||||
// state is a StateFlow, and an update to a state equal to the current one
|
||||
// emits nothing. This is what guarantees the stack moves even on the day
|
||||
// the state does not. Leave both.
|
||||
onSignedIn = { walletId ->
|
||||
sovereignWalletViewModel.loadSovereignData(walletId)
|
||||
sovereignWalletViewModel.listIdentities {
|
||||
sovereignWalletViewModel.switchToWallet(walletId)
|
||||
sovereignWalletViewModel.switchToIdentity(walletId)
|
||||
navController.navigate(
|
||||
route = SovereignWalletStartupRoute
|
||||
) {
|
||||
|
||||
@@ -57,6 +57,13 @@ sealed class WritingSeedState {
|
||||
}
|
||||
|
||||
expect fun updateBusinessActiveInUI(walletId: WalletId)
|
||||
|
||||
/**
|
||||
* Stops the node running for [walletId], if one is. The inverse of what the startup
|
||||
* screen's mnemonic branch starts; an `expect` for the reason [updateBusinessActiveInUI]
|
||||
* is, which is that `BusinessManager` is a per-platform object.
|
||||
*/
|
||||
expect fun stopPlatformBusiness(walletId: WalletId)
|
||||
expect fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult
|
||||
|
||||
expect fun getAvailableWalletsMeta(phoenixGlobal: PhoenixGlobal): Flow<Map<WalletId, UserWalletMetadata>>
|
||||
@@ -74,6 +81,8 @@ expect suspend fun saveAvailableWalletMeta(
|
||||
class SovereignWalletViewModel(
|
||||
val phoenixGlobal: PhoenixGlobal,
|
||||
// We might end up only using the machankuraWalletRepository in the future where we send the walletId in each request.
|
||||
/** Injected so that a switch can be pinned in a test without a node to stop. */
|
||||
private val stopBusiness: (WalletId) -> Unit = ::stopPlatformBusiness,
|
||||
): ViewModel() {
|
||||
private val log = Logger.withTag("SovereignWalletViewModel")
|
||||
|
||||
@@ -331,10 +340,33 @@ class SovereignWalletViewModel(
|
||||
// }
|
||||
// }
|
||||
|
||||
/** Clears the active identity and signals the startup screen to load the given [walletId]. */
|
||||
fun switchToWallet(walletId: WalletId) {
|
||||
_desiredWalletId.value = walletId
|
||||
/**
|
||||
* Makes [id] the identity to open next and clears the active one, which is the whole
|
||||
* of a switch: the navigation observer sends a null identity to startup, startup
|
||||
* opens [id] through the lock gate, and the machine routes it from its account.
|
||||
* Everything reading [activeIdentity] is cancelled by the null and rebuilt by the
|
||||
* activation -- the notary, the pumps, the live subscriptions, the relay observer.
|
||||
* See docs/multiple-profiles.md, Phase 2.
|
||||
*
|
||||
* [startWalletImmediately] goes back to true: a switch is the user saying which one,
|
||||
* and the flag was only ever the user saying *show me the list*. Nothing set it back
|
||||
* before because nothing could switch.
|
||||
*
|
||||
* The identity is cleared **before** the node is stopped: clearing cancels every
|
||||
* collector that could reach the business, so the stop finds nothing reading it.
|
||||
* The test is `business != null`, not the kind -- whether the profile being left has
|
||||
* a node behind it is the fact, and the kind is how it currently comes to be true.
|
||||
* `previous.id` is the wallet's id when a wallet is attached, which is why the
|
||||
* manager can take it.
|
||||
*/
|
||||
fun switchToIdentity(id: WalletId) {
|
||||
val previous = _activeIdentity.value
|
||||
_desiredWalletId.value = id
|
||||
startWalletImmediately.value = true
|
||||
_activeIdentity.value = null
|
||||
if (previous?.business != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) { stopBusiness(previous.id) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the active identity. It does not affect [desiredWalletId]. The UI may still auto-open a specific wallet, if [desiredWalletId] is not null. */
|
||||
|
||||
Reference in New Issue
Block a user