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:
Kgothatso Ngako
2026-09-13 00:27:06 +02:00
parent d6d87ed208
commit 84ac84ccaf
9 changed files with 490 additions and 31 deletions

View File

@@ -13,6 +13,10 @@ actual fun updateBusinessActiveInUI(walletId: WalletId) {
BusinessManager.updateBusinessActiveInUI(walletId)
}
actual fun stopPlatformBusiness(walletId: WalletId) {
BusinessManager.stopBusiness(walletId)
}
actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult {
return SeedManager.loadAndDecrypt(
phoenixGlobal

View File

@@ -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 {

View File

@@ -135,7 +135,7 @@ fun SovereignWalletStartupScreen(
walletsMetadata = availableWalletMetadata,
activeWalletId = null,
onWalletClick = {
sovereignWalletViewModel.switchToWallet(
sovereignWalletViewModel.switchToIdentity(
it.id
); loadingIdentity = it
},

View File

@@ -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
) {

View File

@@ -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. */

View File

@@ -15,6 +15,10 @@ actual fun updateBusinessActiveInUI(walletId: WalletId) {
BusinessManager.updateBusinessActiveInUI(walletId)
}
actual fun stopPlatformBusiness(walletId: WalletId) {
BusinessManager.stopBusiness(walletId)
}
actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult {
return SeedManager.loadAndDecrypt(
phoenixGlobal

View File

@@ -23,6 +23,10 @@ actual fun updateBusinessActiveInUI(walletId: WalletId) {
BusinessManager.updateBusinessActiveInUI(walletId)
}
actual fun stopPlatformBusiness(walletId: WalletId) {
BusinessManager.stopBusiness(walletId)
}
actual fun loadAndDecryptSeed(phoenixGlobal: PhoenixGlobal): DecryptSeedResult {
return SeedManager.loadAndDecrypt(
phoenixGlobal

View File

@@ -0,0 +1,229 @@
package press.mantra.compose.identity
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.room3.Room
import co.touchlab.kermit.Logger
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.lightning.Lightning
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.jvm.BusinessManager
import fr.acinq.phoenix.managers.DataStoreManager
import fr.acinq.phoenix.managers.NodeParamsManager
import fr.acinq.phoenix.managers.computePreferencePath
import fr.acinq.phoenix.managers.nostrPublicKeyHex
import fr.acinq.phoenix.security.JvmKeyStore
import fr.acinq.phoenix.utils.PlatformContext
import fr.acinq.phoenix.utils.preferences.GlobalPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import press.mantra.compose.database.MantraDatabase
import press.mantra.compose.database.builder.getRoomDatabase
import press.mantra.compose.database.model.UnsignedNostrEvent
import press.mantra.compose.database.repository.DatabaseChatRepository
import press.mantra.compose.database.repository.DatabaseMarmotRepository
import press.mantra.compose.database.repository.DatabaseNostrRepository
import press.mantra.compose.ui.view.model.NavigationViewModel
import press.mantra.compose.ui.view.model.NotaryViewModel
import press.mantra.compose.ui.view.model.SovereignWalletViewModel
import press.mantra.compose.ui.view.state.NavigationUIState
import java.io.File
import java.nio.file.Files
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Clock
/**
* A switch, end to end -- Phase 2 of docs/multiple-profiles.md -- through the real
* `SovereignWalletViewModel`, with a real notary and a real navigation machine on an
* in-memory database.
*
* The assertion a switch is for: the notary signs as whoever is open and nobody else.
* Two bare keys, A and B; A active, 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 a kind 1 queued for A now waits. Switch back, and it goes out.
*
* And the one effect a switch has outside the identity flow: the node of the profile
* being left is stopped, once, and only when there was one. The manager is injected as
* a recorder, since a node cannot be started here; a `PhoenixBusiness` can be built
* without one, because everything in it is lazy, and that is enough to make the branch
* true.
*/
class IdentitySwitchJvmTest {
private lateinit var storeDir: File
private lateinit var appDir: File
private lateinit var phoenixGlobal: PhoenixGlobal
private lateinit var globalPrefs: GlobalPrefs
private val db: MantraDatabase = getRoomDatabase(Room.inMemoryDatabaseBuilder<MantraDatabase>())
private val scope = CoroutineScope(Job() + Dispatchers.IO)
private val nostrRepository = DatabaseNostrRepository(db, scope)
private val chatRepository = DatabaseChatRepository(db, scope)
private val marmotRepository = DatabaseMarmotRepository(db, scope)
private val log = Logger.withTag("IdentitySwitchJvmTest")
private val keyA = PrivateKey(Lightning.randomBytes(32))
private val keyB = PrivateKey(Lightning.randomBytes(32))
private val stopped = mutableListOf<WalletId>()
@BeforeTest
fun setUp() {
storeDir = Files.createTempDirectory("mantra-switch-store").toFile()
appDir = Files.createTempDirectory("mantra-switch-app").toFile()
JvmKeyStore.lock()
JvmKeyStore.unlock("correct horse battery staple".toCharArray(), storeDir)
phoenixGlobal = PhoenixGlobal(PlatformContext(applicationDir = appDir))
globalPrefs = GlobalPrefs(
PreferenceDataStoreFactory.createWithPath {
computePreferencePath(phoenixGlobal.ctx, "globalprefs.preferences_pb")
}
)
}
@AfterTest
fun tearDown() {
scope.cancel()
db.close()
JvmKeyStore.lock()
storeDir.deleteRecursively()
appDir.deleteRecursively()
}
private suspend fun signIn(key: PrivateKey): WalletId {
val written = assertIs<IdentityWriter.WriteNostrCredentialResult.Written>(
IdentityWriter.writeNostrKey(log, phoenixGlobal, globalPrefs, key, isTorEnabled = false, customElectrumServer = null)
)
nostrRepository.signInToProfile(key.nostrPublicKeyHex())
return written.id
}
/** What the startup screen's bare-key branch builds. */
private fun activated(stored: StoredIdentity.NostrSecret): Identity {
val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain)
return Identity.signing(
id = stored.id,
kind = IdentityKind.NostrSecret,
nostrPrivateKey = stored.privateKey,
userPrefs = dataStoreManager.loadUserPrefsForWallet(stored.id),
internalPrefs = dataStoreManager.loadInternalPrefsForWallet(stored.id),
business = null,
)
}
private suspend fun queueNote(key: PrivateKey, content: String) {
val now = Clock.System.now()
db.unsignedNostrEventDao().upsert(
UnsignedNostrEvent(pubKey = key.nostrPublicKeyHex(), kind = 1, tags = emptyArray(), content = content, createdAt = now, updatedAt = now, savedAt = now)
)
}
private suspend fun unsignedFor(key: PrivateKey) =
nostrRepository.observeUnsignedNostrEvents(key.nostrPublicKeyHex()).first()
@Test
fun `the notary signs as whoever is open and nobody else`() = runBlocking<Unit> {
val idA = signIn(keyA)
val idB = signIn(keyB)
val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it })
withTimeout(15_000) { sovereign.availableIdentities.first { it.size == 2 } }
val storedA = assertIs<StoredIdentity.NostrSecret>(sovereign.availableIdentities.value[idA])
val storedB = assertIs<StoredIdentity.NostrSecret>(sovereign.availableIdentities.value[idB])
val navigation = NavigationViewModel(
activeIdentityStateFlow = sovereign.activeIdentity,
initialNavigationUIState = NavigationUIState.Loading(""),
nostrRepository = nostrRepository,
scope = scope,
)
NotaryViewModel(
activeIdentityStateFlow = sovereign.activeIdentity,
nostrRepository = nostrRepository,
chatRepository = chatRepository,
marmotRepository = marmotRepository,
scope = scope,
)
// A is open, and what A queues goes out.
sovereign.setActiveIdentity(activated(storedA))
queueNote(keyA, "first, as A")
withTimeout(15_000) { nostrRepository.observeUnsignedNostrEvents(keyA.nostrPublicKeyHex()).first { it.isEmpty() } }
// The switch: the identity clears, the machine goes to startup, and startup opens B.
sovereign.switchToIdentity(idB)
assertEquals(idB, sovereign.desiredWalletId.value)
assertNull(sovereign.activeIdentity.value)
assertTrue(sovereign.startWalletImmediately.value, "a switch is the user saying which one")
withTimeout(15_000) { navigation.navigationUIState.first { it is NavigationUIState.StartupPhoenix } }
sovereign.setActiveIdentity(activated(storedB))
val routed = withTimeout(15_000) {
navigation.navigationUIState.first { it is NavigationUIState.UnqueuedProfileSynchronization }
}
assertEquals(keyB.nostrPublicKeyHex(), (routed as NavigationUIState.UnqueuedProfileSynchronization).unsignedNostrEvent.pubKey)
// What A queues while B is open waits for A.
queueNote(keyA, "second, while B is open")
delay(3_000)
assertEquals(1, unsignedFor(keyA).size, "A's note is not B's to sign")
assertNull(unsignedFor(keyA).single().signedAt)
// And goes out when A is back.
sovereign.switchToIdentity(idA)
sovereign.setActiveIdentity(activated(storedA))
withTimeout(15_000) { nostrRepository.observeUnsignedNostrEvents(keyA.nostrPublicKeyHex()).first { it.isEmpty() } }
assertEquals(emptyList(), stopped, "bare keys have no node to stop")
assertTrue(BusinessManager.businessFlow.value.isEmpty())
}
@Test
fun `leaving a profile with a node behind it stops that node, once`() = runBlocking<Unit> {
val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it })
val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain)
val walletId = WalletId("ab".repeat(20))
val withNode = Identity.signing(
id = walletId,
kind = IdentityKind.Mnemonic,
nostrPrivateKey = keyA,
userPrefs = dataStoreManager.loadUserPrefsForWallet(walletId),
internalPrefs = dataStoreManager.loadInternalPrefsForWallet(walletId),
business = PhoenixBusiness(phoenixGlobal),
)
val other = StoredIdentity.nostrSecret(keyB).id
sovereign.setActiveIdentity(withNode)
sovereign.switchToIdentity(other)
withTimeout(5_000) { while (stopped.isEmpty()) delay(20) }
assertEquals(listOf(walletId), stopped)
assertNull(sovereign.activeIdentity.value)
assertEquals(other, sovereign.desiredWalletId.value)
}
@Test
fun `switching from nothing, or from a bare key, stops nothing`() = runBlocking<Unit> {
val sovereign = SovereignWalletViewModel(phoenixGlobal, stopBusiness = { stopped += it })
val target = StoredIdentity.nostrSecret(keyB).id
sovereign.switchToIdentity(target)
sovereign.setActiveIdentity(activated(StoredIdentity.nostrSecret(keyA)))
sovereign.switchToIdentity(target)
delay(500)
assertEquals(emptyList(), stopped)
}
}

View File

@@ -0,0 +1,179 @@
package press.mantra.compose.network.relays
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.phoenix.managers.nostrPublicKeyHex
import fr.acinq.phoenix.utils.preferences.InternalPrefs
import fr.acinq.phoenix.utils.preferences.UserPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.JsonObject
import okio.Path.Companion.toPath
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import press.mantra.compose.database.model.Relay
import press.mantra.compose.identity.Identity
import press.mantra.compose.identity.IdentityKind
import press.mantra.compose.identity.toWalletId
import press.mantra.compose.network.sockets.NostrIncomingMessage
import press.mantra.compose.network.sockets.NostrSocketClient
import press.mantra.compose.network.sockets.NostrSocketClientFactory
import press.mantra.compose.network.sockets.SocketConnectionClosedCallback
import press.mantra.compose.network.sockets.SocketConnectionOpenedCallback
import press.mantra.compose.network.sockets.SocketConnectionReopenedCallback
import press.mantra.compose.repository.CachingImportRepository
import press.mantra.compose.repository.RelayRepository
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The relay observer follows the identity that is open, and stops following the one
* that was.
*
* Before Phase 2 of docs/multiple-profiles.md it kept one observer per pubkey and only
* ever cancelled the one for the pubkey being started, so after a switch the previous
* identity's observer was still feeding the pool -- and the pool followed whichever
* relay list emitted last. The regression this pins is exactly that: after switching
* from A to B, a re-emission of A's relay list changes nothing.
*/
class RelaysSocketManagerSwitchJvmTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private val keyA = PrivateKey(ByteVector32("0a".repeat(32)))
private val keyB = PrivateKey(ByteVector32("0b".repeat(32)))
/** Each identity's relay list, as the database would emit it. */
private val relayLists = mutableMapOf<String, MutableStateFlow<List<Relay>>>()
private val relayRepository = object : RelayRepository {
override suspend fun observePublicKeyRelays(publicKey: String): Flow<List<Relay>> =
relayLists.getOrPut(publicKey) { MutableStateFlow(emptyList()) }
}
private fun prefsStore(name: String) = PreferenceDataStoreFactory.createWithPath {
temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath()
}
private fun identity(key: PrivateKey) = Identity.signing(
id = key.publicKey().xOnly().toWalletId(),
kind = IdentityKind.NostrSecret,
nostrPrivateKey = key,
userPrefs = UserPrefs(prefsStore("user")),
internalPrefs = InternalPrefs(prefsStore("internal")),
business = null,
)
private fun relays(key: PrivateKey, vararg urls: String) =
urls.map { Relay(publicKey = key.nostrPublicKeyHex(), type = "user", url = it, read = true, write = true) }
private fun emit(key: PrivateKey, vararg urls: String) {
relayLists.getOrPut(key.nostrPublicKeyHex()) { MutableStateFlow(emptyList()) }.value = relays(key, *urls)
}
@AfterTest
fun tearDown() {
scope.cancel()
}
private suspend fun RelaysSocketManager.awaitRelays(expected: Set<String>) = withTimeout(5_000) {
while (relayUrls != expected) delay(20)
}
@Test
fun `after a switch the pool follows the new identity, and the old one's list no longer moves it`() = runBlocking<Unit> {
val activeIdentity = MutableStateFlow<Identity?>(null)
emit(keyA, "wss://a-one.example", "wss://a-two.example")
emit(keyB, "wss://b.example")
val manager = RelaysSocketManager(
activeIdentityStateFlow = activeIdentity,
nostrSocketClientFactory = SwitchFakeSocketClientFactory(),
cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY,
relayRepository = relayRepository,
scope = scope,
)
activeIdentity.value = identity(keyA)
manager.awaitRelays(setOf("wss://a-one.example", "wss://a-two.example"))
// The switch: cleared, then the next one.
activeIdentity.value = null
activeIdentity.value = identity(keyB)
manager.awaitRelays(setOf("wss://b.example"))
// A's list moves on -- as it would when a sync for A's rows lands -- and the pool
// must not care: A is not open.
emit(keyA, "wss://a-three.example")
delay(500)
assertEquals(setOf("wss://b.example"), manager.relayUrls)
// B's list moves on, and the pool does follow.
emit(keyB, "wss://b.example", "wss://b-two.example")
manager.awaitRelays(setOf("wss://b.example", "wss://b-two.example"))
}
@Test
fun `an identity whose list is still empty leaves the pool as it was`() = runBlocking<Unit> {
val activeIdentity = MutableStateFlow<Identity?>(null)
emit(keyA, "wss://a.example")
val manager = RelaysSocketManager(
activeIdentityStateFlow = activeIdentity,
nostrSocketClientFactory = SwitchFakeSocketClientFactory(),
cachingImportRepository = CachingImportRepository.NO_OP_CACHING_IMPORT_REPOSITORY,
relayRepository = relayRepository,
scope = scope,
)
activeIdentity.value = identity(keyA)
manager.awaitRelays(setOf("wss://a.example"))
// B has no relay list yet: nothing is known to replace A's with, and the pool
// keeps what it had -- the behaviour `updateRelayPools` has always had for an
// empty list, unchanged by the switch.
activeIdentity.value = null
activeIdentity.value = identity(keyB)
delay(500)
assertEquals(setOf("wss://a.example"), manager.relayUrls)
emit(keyB, "wss://b.example")
manager.awaitRelays(setOf("wss://b.example"))
}
}
private class SwitchFakeSocketClientFactory : NostrSocketClientFactory {
override fun create(
wssUrl: String,
incomingCompressionEnabled: Boolean,
onSocketConnectionOpened: SocketConnectionOpenedCallback?,
onSocketConnectionClosed: SocketConnectionClosedCallback?,
onSocketConnectionReopened: SocketConnectionReopenedCallback?,
): NostrSocketClient = SwitchFakeNostrSocketClient(wssUrl)
}
private class SwitchFakeNostrSocketClient(override val socketUrl: String) : NostrSocketClient {
private val _incomingMessages = MutableSharedFlow<NostrIncomingMessage>(extraBufferCapacity = 8)
override val incomingMessages: SharedFlow<NostrIncomingMessage> = _incomingMessages.asSharedFlow()
override var autoReconnect: Boolean = false
override suspend fun close() = Unit
override suspend fun ensureSocketConnectionOrThrow() = Unit
override suspend fun sendMESSAGE(text: String, ensureSessionBeforeSend: Boolean) = Unit
override suspend fun sendAUTH(signedEvent: JsonObject) = Unit
override suspend fun sendCLOSE(subscriptionId: String) = Unit
override suspend fun sendCOUNT(data: JsonObject): String = "unused"
override suspend fun sendEVENT(signedEvent: JsonObject) = Unit
override suspend fun sendREQ(subscriptionId: String, data: JsonObject) = Unit
}