fix(multi-account): sequential account save in AddAccountDialog, debounced metadata

Root cause of accounts not persisting:
ensureCurrentAccountInStorage() was fire-and-forget (scope.launch),
so loginWithKey() ran before the old account was saved. The old account
was lost. Now all steps run sequentially in one coroutine.

UI hanging fix:
metadataVersion LaunchedEffect fired on every metadata event, doing
encrypted file I/O each time. Now debounced with 2s delay so it only
writes once after a batch of metadata settles.

Also:
- loadInternalAccount falls back to read-only (test updated)
- bunker/nostrconnect paths also await ensureCurrentAccountInStorage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-04-29 10:04:23 +03:00
parent 156391ec0a
commit 5a1b9d445c
3 changed files with 34 additions and 28 deletions
@@ -673,14 +673,16 @@ fun App(
} }
} }
// Persist display name when metadata arrives from relays // Persist display name when metadata arrives from relays (debounced)
val metadataVersion by localCache.metadataVersion.collectAsState() LaunchedEffect(Unit) {
LaunchedEffect(metadataVersion) { localCache.metadataVersion.collect {
val current = accountManager.currentAccount() ?: return@LaunchedEffect kotlinx.coroutines.delay(2000) // debounce — wait for batch of metadata to settle
val user = localCache.getUserIfExists(current.pubKeyHex) ?: return@LaunchedEffect val current = accountManager.currentAccount() ?: return@collect
val name = user.toBestDisplayName() val user = localCache.getUserIfExists(current.pubKeyHex) ?: return@collect
if (name != user.pubkeyDisplayHex()) { val name = user.toBestDisplayName()
accountManager.updateDisplayName(current.npub, name) if (name != user.pubkeyDisplayHex()) {
accountManager.updateDisplayName(current.npub, name)
}
} }
} }
@@ -88,18 +88,21 @@ fun AddAccountDialog(
) { ) {
LoginCard( LoginCard(
onLogin = { keyInput -> onLogin = { keyInput ->
// Save current account before switching // All steps must be sequential — no fire-and-forget
scope.launch { scope.launch {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
accountManager.ensureCurrentAccountInStorage() accountManager.ensureCurrentAccountInStorage()
} }
} val result = accountManager.loginWithKey(keyInput)
accountManager.loginWithKey(keyInput).map { if (result.isSuccess) {
scope.launch { withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() } accountManager.saveCurrentAccount()
}
onAccountAdded() onAccountAdded()
} }
} }
// Return success to dismiss any error in LoginCard
Result.success(Unit)
}, },
onGenerateNew = { onGenerateNew = {
scope.launch { scope.launch {
@@ -107,29 +110,28 @@ fun AddAccountDialog(
accountManager.ensureCurrentAccountInStorage() accountManager.ensureCurrentAccountInStorage()
} }
accountManager.generateNewAccount() accountManager.generateNewAccount()
withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() } withContext(Dispatchers.IO) {
accountManager.saveCurrentAccount()
}
onAccountAdded() onAccountAdded()
} }
}, },
onLoginBunker = { bunkerUri -> onLoginBunker = { bunkerUri ->
scope.launch { // ensureCurrentAccountInStorage first, then bunker login
withContext(Dispatchers.IO) { accountManager.ensureCurrentAccountInStorage()
accountManager.ensureCurrentAccountInStorage() val result = accountManager.loginWithBunker(bunkerUri)
} if (result.isSuccess) {
}
accountManager.loginWithBunker(bunkerUri).map {
onAccountAdded() onAccountAdded()
} }
result.map { }
}, },
onLoginNostrConnect = { onUriGenerated -> onLoginNostrConnect = { onUriGenerated ->
scope.launch { accountManager.ensureCurrentAccountInStorage()
withContext(Dispatchers.IO) { val result = accountManager.loginWithNostrConnect(onUriGenerated)
accountManager.ensureCurrentAccountInStorage() if (result.isSuccess) {
}
}
accountManager.loginWithNostrConnect(onUriGenerated).map {
onAccountAdded() onAccountAdded()
} }
result.map { }
}, },
loginProgress = loginProgress, loginProgress = loginProgress,
cardWidth = 420.dp, cardWidth = 420.dp,
@@ -85,7 +85,7 @@ class AccountManagerLoadAccountTest {
} }
@Test @Test
fun loadSavedAccountInternalNoPrivkeyReturnsFailure() = fun loadSavedAccountInternalNoPrivkeyFallsBackToReadOnly() =
runTest { runTest {
val keyPair = KeyPair() val keyPair = KeyPair()
val npub = keyPair.pubKey.toNpub() val npub = keyPair.pubKey.toNpub()
@@ -94,7 +94,9 @@ class AccountManagerLoadAccountTest {
coEvery { storage.getPrivateKey(npub) } returns null coEvery { storage.getPrivateKey(npub) } returns null
val result = manager.loadSavedAccount() val result = manager.loadSavedAccount()
assertTrue(result.isFailure) assertTrue(result.isSuccess)
val state = result.getOrThrow()
assertTrue(state.isReadOnly)
} }
@Test @Test