diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt index 3a90eb28..35c8a017 100644 --- a/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.android.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt index 7d796b02..c79e7f7c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt @@ -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() - + /** + * 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 get() = relayPool.relays.map { it.url }.toSet() private suspend fun updateRelayPools(regularRelays: List?) { relayPoolsMutex.withLock { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt index 39c7db66..3846d9bc 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt @@ -135,7 +135,7 @@ fun SovereignWalletStartupScreen( walletsMetadata = availableWalletMetadata, activeWalletId = null, onWalletClick = { - sovereignWalletViewModel.switchToWallet( + sovereignWalletViewModel.switchToIdentity( it.id ); loadingIdentity = it }, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 5b6a684f..e4ec030a 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -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 ) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt index 1ab201d7..bf514615 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt @@ -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> @@ -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. */ diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt index 6d4e242c..e604d416 100644 --- a/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.ios.kt @@ -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 diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt index e076aae7..acea8fb4 100644 --- a/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.jvm.kt @@ -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 diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt new file mode 100644 index 00000000..ad4017a0 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/identity/IdentitySwitchJvmTest.kt @@ -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()) + 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() + + @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.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 { + 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(sovereign.availableIdentities.value[idA]) + val storedB = assertIs(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 { + 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 { + 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) + } +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/network/relays/RelaysSocketManagerSwitchJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/network/relays/RelaysSocketManagerSwitchJvmTest.kt new file mode 100644 index 00000000..ad29f97c --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/network/relays/RelaysSocketManagerSwitchJvmTest.kt @@ -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>>() + + private val relayRepository = object : RelayRepository { + override suspend fun observePublicKeyRelays(publicKey: String): Flow> = + 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) = 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 { + val activeIdentity = MutableStateFlow(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 { + val activeIdentity = MutableStateFlow(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(extraBufferCapacity = 8) + override val incomingMessages: SharedFlow = _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 +}