commit 9dfd0b39b39bf105fce9df902e71c4a4fa4d92fb
parent 9702a7f2dc897cfd42607afc0e677c63c87383ca
Author: t3sserakt <t3ss@posteo.de>
Date: Fri, 15 May 2026 09:06:51 +0200
revert to multi handled
Diffstat:
2 files changed, 216 insertions(+), 11 deletions(-)
diff --git a/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/MainActivity.kt b/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/MainActivity.kt
@@ -46,6 +46,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
import java.util.concurrent.ConcurrentHashMap
import org.gnunet.gnunetmessenger.model.ChatAccount
import org.gnunet.gnunetmessenger.model.ChatContact
@@ -63,6 +64,25 @@ import org.gnunet.gnunetmessenger.viewmodel.ChatOverviewViewModel
import org.gnunet.gnunetmessenger.viewmodel.ChatViewModel
import org.gnunet.gnunetmessenger.viewmodel.ContactListViewModel
+/**
+ * One activated account's full state: its own bound-service instance, its
+ * own chat handle on the daemon, and the user-facing account itself.
+ *
+ * **Multi-handle architecture (confirmed by upstream maintainer
+ * Florian, 2026-05-14).** Each account the user activates gets its own
+ * session, and all sessions stay live for the process lifetime — no
+ * disconnect on switch. This mirrors how messenger-gtk runs multiple
+ * client processes against one daemon. The previously observed group
+ * crash correlates with a GNS-record serialization bug in GNUnet itself
+ * (`gnsrecord_serialization.c:286` — "External protocol violation
+ * detected") that Florian is fixing at the GNUnet layer.
+ */
+data class AccountSession(
+ val account: ChatAccount,
+ val handle: ChatHandle,
+ val gnunetChat: GnunetChat
+)
+
class MainActivity : AppCompatActivity() {
private lateinit var gnunetChat: GnunetChat
@@ -70,6 +90,8 @@ class MainActivity : AppCompatActivity() {
private lateinit var appBarConfiguration: AppBarConfiguration
private lateinit var handle: ChatHandle
+ /** Session registry keyed by lower-cased account name. */
+ private val sessions = mutableMapOf<String, AccountSession>()
private val chatReady = CompletableDeferred<ChatHandle>()
private val initialRefreshReady = CompletableDeferred<Unit>()
@@ -181,6 +203,11 @@ class MainActivity : AppCompatActivity() {
private fun processChatMessage(chatContext: ChatContext, chatMessage: ChatMessage) {
requireNotNull(chatMessage)
+ // Shadow the singleton fields with the foreground session's instances
+ // so the body of this handler always operates against the live account.
+ val gnunetChat = getCurrentService()
+ val handle = getCurrentHandle()
+
Log.d(
TAG,
"processChatMessage: kind=${chatMessage.kind} " +
@@ -341,6 +368,7 @@ class MainActivity : AppCompatActivity() {
// and is blank for newly-built contexts). This is what we key the
// per-chat ViewModels by so that messages survive navigation.
private fun stableChatKey(chatContext: ChatContext): String? {
+ val gnunetChat = getCurrentService()
runCatching { gnunetChat.getGroupFromContext(chatContext) }
.getOrNull()
?.takeIf { it.name.isNotBlank() }
@@ -385,6 +413,11 @@ class MainActivity : AppCompatActivity() {
}
private suspend fun loadChatsSuspend() = loadChatsMutex.withLock {
+ // Capture the foreground session's instances; without this we'd
+ // iterate against the bootstrap handle (no connected account → SYSERR).
+ val gnunetChat = getCurrentService()
+ val handle = getCurrentHandle()
+
withContext(Dispatchers.IO) {
val summaries = mutableListOf<ChatSummary>()
val contacts = mutableListOf<ChatContact>()
@@ -580,12 +613,187 @@ class MainActivity : AppCompatActivity() {
super.onSupportNavigateUp()
}
- fun getGnunetChatInstance(): GnunetChat = gnunetChat
+ fun getGnunetChatInstance(): GnunetChat = getCurrentService()
+
+ fun getChatHandle(): ChatHandle = getCurrentHandle()
+
+ /** The session for the currently selected account, or null when none. */
+ fun currentSession(): AccountSession? =
+ currentAccount?.name?.lowercase()?.let { sessions[it] }
+
+ /**
+ * The bound-service instance the UI should use right now. Falls back
+ * to the bootstrap singleton when no per-account session is registered.
+ */
+ fun getCurrentService(): GnunetChat = currentSession()?.gnunetChat ?: gnunetChat
- fun getChatHandle(): ChatHandle = handle
+ /**
+ * The chat handle the UI should use right now. Falls back to the
+ * bootstrap singleton handle when no per-account session is registered.
+ */
+ fun getCurrentHandle(): ChatHandle = currentSession()?.handle ?: handle
fun setCurrentAccount(account: ChatAccount) {
currentAccount = account
invalidateOptionsMenu()
}
+
+ /**
+ * Spawns a new bound-service + chat handle for [account], connects the
+ * account on it, and registers the resulting [AccountSession] under
+ * the lower-cased name. Idempotent — returns the existing session if
+ * one is already registered. Both accounts stay live in libgnunetchat
+ * simultaneously, mirroring how messenger-gtk runs two processes
+ * against one daemon.
+ */
+ suspend fun spawnSessionFor(account: ChatAccount): AccountSession {
+ val key = account.name.lowercase()
+ sessions[key]?.let { return it }
+
+ Log.d(TAG, "spawnSessionFor: starting session for '${account.name}'")
+ val svc = ServiceFactory.create(applicationContext, useMock = false)
+ val refreshSeen = CompletableDeferred<Unit>()
+ val loginSeen = CompletableDeferred<Unit>()
+
+ val newHandle = svc.startChat(MessengerApp()) { ctx, msg ->
+ if (msg.kind == MessageKind.REFRESH && !refreshSeen.isCompleted) {
+ refreshSeen.complete(Unit)
+ }
+ if (msg.kind == MessageKind.LOGIN && !loginSeen.isCompleted) {
+ loginSeen.complete(Unit)
+ }
+ sessions[key]?.let { existing ->
+ processChatMessageRouted(existing, ctx, msg)
+ }
+ }
+
+ withTimeout(30_000) { while (newHandle.pointer == 0L) delay(50) }
+ withTimeout(30_000) { refreshSeen.await() }
+
+ Log.d(TAG, "spawnSessionFor: handle ready for '${account.name}', connecting")
+ svc.connect(newHandle, account)
+ withTimeout(20_000) { loginSeen.await() }
+
+ val session = AccountSession(account, newHandle, svc)
+ sessions[key] = session
+ Log.d(TAG, "spawnSessionFor: '${account.name}' live (handle=${newHandle.pointer})")
+ return session
+ }
+
+ /**
+ * Routes a daemon message to [processChatMessage] only when [session]
+ * is the foreground one. Background sessions receive events at the
+ * libgnunetchat layer; switching to a background session triggers a
+ * loadChats() that reads the up-to-date state from the daemon.
+ */
+ private fun processChatMessageRouted(
+ session: AccountSession,
+ chatContext: ChatContext,
+ chatMessage: ChatMessage
+ ) {
+ val current = currentSession()
+ val isForeground = current != null &&
+ current.account.name.equals(session.account.name, ignoreCase = true)
+
+ if (isForeground) {
+ processChatMessage(chatContext, chatMessage)
+ return
+ }
+
+ // Background session: route TEXT/FILE messages into the global chat
+ // viewmodel using the session's own service so they're visible when
+ // the user later switches to this session. Other event kinds will
+ // be re-rendered via loadChats() on switch.
+ when (chatMessage.kind) {
+ MessageKind.TEXT, MessageKind.FILE -> {
+ val senderKey = chatMessage.sender?.key ?: ""
+ val ownKey = runCatching {
+ session.gnunetChat.getProfileKey(session.handle)
+ }.getOrDefault("")
+ if (senderKey.isNotEmpty() && senderKey == ownKey) {
+ Log.d(
+ TAG,
+ "background-session ${session.account.name}: kind=${chatMessage.kind} " +
+ "(own echo, skip)"
+ )
+ return
+ }
+ val key = stableChatKeyFor(session, chatContext)
+ if (key == null) {
+ Log.w(
+ TAG,
+ "background-session ${session.account.name}: kind=${chatMessage.kind} " +
+ "no stableChatKey — dropping"
+ )
+ return
+ }
+ val vm = chatViewModels.getOrPut(key) { ChatViewModel(chatContext) }
+ chatMessage.type = ChatMessageType.OTHER
+ vm.addMessage(chatMessage)
+ Log.d(
+ TAG,
+ "background-session ${session.account.name}: kind=${chatMessage.kind} " +
+ "added to viewmodel '$key' (msgCount=${vm.messages.value?.size ?: 0})"
+ )
+ }
+ else -> {
+ Log.d(
+ TAG,
+ "background-session ${session.account.name}: kind=${chatMessage.kind} " +
+ "(deferred — will re-render on next switch)"
+ )
+ }
+ }
+ }
+
+ /**
+ * Same as [stableChatKey] but resolves group/contact info via the
+ * given [session]'s service. Required when the message arrived on a
+ * background session whose native context pointer is in that session's
+ * address space — the foreground service can't dereference it.
+ */
+ private fun stableChatKeyFor(
+ session: AccountSession,
+ chatContext: ChatContext
+ ): String? {
+ val gnunetChat = session.gnunetChat
+ runCatching { gnunetChat.getGroupFromContext(chatContext) }
+ .getOrNull()
+ ?.takeIf { it.name.isNotBlank() }
+ ?.let { return "group:${it.name}" }
+
+ runCatching { gnunetChat.getContextContact(chatContext) }
+ .getOrNull()
+ ?.key
+ ?.takeIf { it.isNotBlank() }
+ ?.let { return "contact:$it" }
+
+ return chatContext.userPointer?.takeIf { it.isNotBlank() }
+ }
+
+ /**
+ * Switches the foreground UI to [account]. Spawns a new session if
+ * one doesn't exist. **Never disconnects existing sessions** — both
+ * the lobby host and the joiner can stay live across the switch,
+ * which is what makes lobby pairing succeed for both sides on a
+ * single daemon.
+ */
+ suspend fun switchToSession(account: ChatAccount) {
+ val key = account.name.lowercase()
+ val previous = currentAccount
+ val isSame = previous != null &&
+ previous.name.equals(account.name, ignoreCase = true)
+ if (isSame) return
+
+ val session = sessions[key] ?: spawnSessionFor(account)
+
+ runCatching {
+ account.key = session.gnunetChat.getProfileKey(session.handle)
+ }.onFailure { Log.w(TAG, "switchToSession: getProfileKey failed", it) }
+
+ clearChatState()
+ setCurrentAccount(account)
+ runCatching { loadChatsAndWait() }
+ .onFailure { Log.w(TAG, "switchToSession: loadChatsAndWait failed", it) }
+ }
}
\ No newline at end of file
diff --git a/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ui/account/AccountListFragment.kt b/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ui/account/AccountListFragment.kt
@@ -77,16 +77,13 @@ class AccountListFragment : Fragment() {
adapter = AccountAdapter { selectedAccount ->
viewLifecycleOwner.lifecycleScope.launch {
try {
- // Single-handle: connect this account on the shared chat
- // handle. libgnunetchat handles the implicit disconnect of
- // any previously connected account; the LOGIN message
- // arriving via processChatMessage triggers loadChats().
- val gnunetChat = activity.getGnunetChatInstance()
- val handle = activity.getChatHandle()
- activity.setCurrentAccount(selectedAccount)
- gnunetChat.connect(handle, selectedAccount)
+ // Multi-handle: spawn-or-reuse a per-account session.
+ // Old sessions stay live in the background so a lobby
+ // host survives the switch and can complete the pairing
+ // handshake when the joiner arrives.
+ activity.switchToSession(selectedAccount)
} catch (t: Throwable) {
- Log.e(TAG, "connect failed", t)
+ Log.e(TAG, "switchToSession failed", t)
showError(getString(R.string.account_connect_failed))
return@launch
}