messenger-android

Android graphical user interfaces for GNUnet Messenger
Log | Files | Refs | README | LICENSE

commit 9dfd0b39b39bf105fce9df902e71c4a4fa4d92fb
parent 9702a7f2dc897cfd42607afc0e677c63c87383ca
Author: t3sserakt <t3ss@posteo.de>
Date:   Fri, 15 May 2026 09:06:51 +0200

revert to multi handled

Diffstat:
MGNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/MainActivity.kt | 212++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MGNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ui/account/AccountListFragment.kt | 15++++++---------
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 }