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
val metadataVersion by localCache.metadataVersion.collectAsState()
LaunchedEffect(metadataVersion) {
val current = accountManager.currentAccount() ?: return@LaunchedEffect
val user = localCache.getUserIfExists(current.pubKeyHex) ?: return@LaunchedEffect
val name = user.toBestDisplayName()
if (name != user.pubkeyDisplayHex()) {
accountManager.updateDisplayName(current.npub, name)
// Persist display name when metadata arrives from relays (debounced)
LaunchedEffect(Unit) {
localCache.metadataVersion.collect {
kotlinx.coroutines.delay(2000) // debounce — wait for batch of metadata to settle
val current = accountManager.currentAccount() ?: return@collect
val user = localCache.getUserIfExists(current.pubKeyHex) ?: return@collect
val name = user.toBestDisplayName()
if (name != user.pubkeyDisplayHex()) {
accountManager.updateDisplayName(current.npub, name)
}
}
}
@@ -88,18 +88,21 @@ fun AddAccountDialog(
) {
LoginCard(
onLogin = { keyInput ->
// Save current account before switching
// All steps must be sequential — no fire-and-forget
scope.launch {
withContext(Dispatchers.IO) {
accountManager.ensureCurrentAccountInStorage()
}
}
accountManager.loginWithKey(keyInput).map {
scope.launch {
withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() }
val result = accountManager.loginWithKey(keyInput)
if (result.isSuccess) {
withContext(Dispatchers.IO) {
accountManager.saveCurrentAccount()
}
onAccountAdded()
}
}
// Return success to dismiss any error in LoginCard
Result.success(Unit)
},
onGenerateNew = {
scope.launch {
@@ -107,29 +110,28 @@ fun AddAccountDialog(
accountManager.ensureCurrentAccountInStorage()
}
accountManager.generateNewAccount()
withContext(Dispatchers.IO) { accountManager.saveCurrentAccount() }
withContext(Dispatchers.IO) {
accountManager.saveCurrentAccount()
}
onAccountAdded()
}
},
onLoginBunker = { bunkerUri ->
scope.launch {
withContext(Dispatchers.IO) {
accountManager.ensureCurrentAccountInStorage()
}
}
accountManager.loginWithBunker(bunkerUri).map {
// ensureCurrentAccountInStorage first, then bunker login
accountManager.ensureCurrentAccountInStorage()
val result = accountManager.loginWithBunker(bunkerUri)
if (result.isSuccess) {
onAccountAdded()
}
result.map { }
},
onLoginNostrConnect = { onUriGenerated ->
scope.launch {
withContext(Dispatchers.IO) {
accountManager.ensureCurrentAccountInStorage()
}
}
accountManager.loginWithNostrConnect(onUriGenerated).map {
accountManager.ensureCurrentAccountInStorage()
val result = accountManager.loginWithNostrConnect(onUriGenerated)
if (result.isSuccess) {
onAccountAdded()
}
result.map { }
},
loginProgress = loginProgress,
cardWidth = 420.dp,
@@ -85,7 +85,7 @@ class AccountManagerLoadAccountTest {
}
@Test
fun loadSavedAccountInternalNoPrivkeyReturnsFailure() =
fun loadSavedAccountInternalNoPrivkeyFallsBackToReadOnly() =
runTest {
val keyPair = KeyPair()
val npub = keyPair.pubKey.toNpub()
@@ -94,7 +94,9 @@ class AccountManagerLoadAccountTest {
coEvery { storage.getPrivateKey(npub) } returns null
val result = manager.loadSavedAccount()
assertTrue(result.isFailure)
assertTrue(result.isSuccess)
val state = result.getOrThrow()
assertTrue(state.isReadOnly)
}
@Test