messenger-android

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

MainActivity.kt (33566B)


      1 /*
      2    This file is part of GNUnet.
      3    Copyright (C) 2021--2025 GNUnet e.V.
      4 
      5    GNUnet is free software: you can redistribute it and/or modify it
      6    under the terms of the GNU Affero General Public License as published
      7    by the Free Software Foundation, either version 3 of the License,
      8    or (at your option) any later version.
      9 
     10    GNUnet is distributed in the hope that it will be useful, but
     11    WITHOUT ANY WARRANTY; without even the implied warranty of
     12    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
     13    Affero General Public License for more details.
     14 
     15    You should have received a copy of the GNU Affero General Public License
     16    along with this program.  If not, see <http://www.gnu.org/licenses/>.
     17 
     18    SPDX-License-Identifier: AGPL3.0-or-later
     19  */
     20 /*
     21  * @author t3sserakt
     22  * @file GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/MainActivity.kt
     23  */
     24 
     25 package org.gnunet.gnunetmessenger
     26 
     27 import android.os.Bundle
     28 import android.util.Log
     29 import android.view.Menu
     30 import android.view.MenuItem
     31 import androidx.activity.viewModels
     32 import androidx.appcompat.app.AppCompatActivity
     33 import androidx.appcompat.widget.Toolbar
     34 import androidx.lifecycle.lifecycleScope
     35 import androidx.navigation.NavController
     36 import androidx.navigation.fragment.NavHostFragment
     37 import androidx.navigation.ui.AppBarConfiguration
     38 import androidx.navigation.ui.NavigationUI
     39 import kotlinx.coroutines.CompletableDeferred
     40 import kotlinx.coroutines.delay
     41 import kotlinx.coroutines.flow.MutableSharedFlow
     42 import kotlinx.coroutines.flow.SharedFlow
     43 import kotlinx.coroutines.isActive
     44 import kotlinx.coroutines.Dispatchers
     45 import kotlinx.coroutines.launch
     46 import kotlinx.coroutines.sync.Mutex
     47 import kotlinx.coroutines.sync.withLock
     48 import kotlinx.coroutines.withContext
     49 import kotlinx.coroutines.withTimeout
     50 import java.util.concurrent.ConcurrentHashMap
     51 import org.gnunet.gnunetmessenger.model.ChatAccount
     52 import org.gnunet.gnunetmessenger.model.ChatContact
     53 import org.gnunet.gnunetmessenger.model.ChatContext
     54 import org.gnunet.gnunetmessenger.model.ChatHandle
     55 import org.gnunet.gnunetmessenger.model.ChatMessage
     56 import org.gnunet.gnunetmessenger.model.ChatMessageType
     57 import org.gnunet.gnunetmessenger.model.ChatSummary
     58 import org.gnunet.gnunetmessenger.model.MessageKind
     59 import org.gnunet.gnunetmessenger.model.MessengerApp
     60 import org.gnunet.gnunetmessenger.service.GnunetChat
     61 import org.gnunet.gnunetmessenger.service.ServiceFactory
     62 import org.gnunet.gnunetmessenger.viewmodel.ChatMenuViewModel
     63 import org.gnunet.gnunetmessenger.viewmodel.ChatOverviewViewModel
     64 import org.gnunet.gnunetmessenger.viewmodel.ChatViewModel
     65 import org.gnunet.gnunetmessenger.viewmodel.ContactListViewModel
     66 
     67 /**
     68  * One activated account's full state: its own bound-service instance, its
     69  * own chat handle on the daemon, and the user-facing account itself.
     70  *
     71  * **Multi-handle architecture (confirmed by upstream maintainer
     72  * Florian, 2026-05-14).** Each account the user activates gets its own
     73  * session, and all sessions stay live for the process lifetime — no
     74  * disconnect on switch. This mirrors how messenger-gtk runs multiple
     75  * client processes against one daemon. The previously observed group
     76  * crash correlates with a GNS-record serialization bug in GNUnet itself
     77  * (`gnsrecord_serialization.c:286` — "External protocol violation
     78  * detected") that Florian is fixing at the GNUnet layer.
     79  */
     80 data class AccountSession(
     81     val account: ChatAccount,
     82     val handle: ChatHandle,
     83     val gnunetChat: GnunetChat
     84 )
     85 
     86 class MainActivity : AppCompatActivity() {
     87 
     88     private lateinit var gnunetChat: GnunetChat
     89     private lateinit var navController: NavController
     90     private lateinit var appBarConfiguration: AppBarConfiguration
     91     private lateinit var handle: ChatHandle
     92 
     93     /** Session registry keyed by lower-cased account name. */
     94     private val sessions = mutableMapOf<String, AccountSession>()
     95 
     96     private val chatReady = CompletableDeferred<ChatHandle>()
     97     private val initialRefreshReady = CompletableDeferred<Unit>()
     98     private val accountRefreshEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
     99 
    100     private val chatOverviewViewModel: ChatOverviewViewModel by viewModels()
    101     val contactListViewModel: ContactListViewModel by viewModels()
    102 
    103     private val chatMenuViewModels = mutableMapOf<String, ChatMenuViewModel>()
    104     private val chatViewModels = mutableMapOf<String, ChatViewModel>()
    105     private val chats = ConcurrentHashMap<String, ChatContext>()
    106     private val loadChatsMutex = Mutex()
    107 
    108     var currentAccount: ChatAccount? = null
    109         private set
    110 
    111     companion object {
    112         private const val TAG = "MainActivity"
    113         private const val CHAT_READY_POLL_MS = 50L
    114     }
    115 
    116     override fun onCreate(savedInstanceState: Bundle?) {
    117         super.onCreate(savedInstanceState)
    118         setContentView(R.layout.activity_main)
    119 
    120         gnunetChat = ServiceFactory.create(this, useMock = false)
    121 
    122         val app = MessengerApp()
    123         handle = gnunetChat.startChat(app) { chatContext, chatMessage ->
    124             processChatMessage(chatContext, chatMessage)
    125         }
    126 
    127         lifecycleScope.launch {
    128             try {
    129                 awaitHandlePointerReady()
    130                 if (!chatReady.isCompleted) {
    131                     chatReady.complete(handle)
    132                 }
    133                 Log.d(TAG, "Chat handle ready: ${handle.pointer}")
    134             } catch (t: Throwable) {
    135                 Log.e(TAG, "Chat initialization failed before first refresh", t)
    136                 if (!chatReady.isCompleted) {
    137                     chatReady.completeExceptionally(t)
    138                 }
    139                 if (!initialRefreshReady.isCompleted) {
    140                     initialRefreshReady.completeExceptionally(t)
    141                 }
    142             }
    143         }
    144 
    145         val navHostFragment =
    146             supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
    147         navController = navHostFragment.navController
    148 
    149         val toolbar = findViewById<Toolbar>(R.id.nav_bar)
    150         setSupportActionBar(toolbar)
    151 
    152         appBarConfiguration = AppBarConfiguration(
    153             setOf(R.id.accountListFragment),
    154             null
    155         )
    156 
    157         NavigationUI.setupActionBarWithNavController(this, navController, appBarConfiguration)
    158     }
    159 
    160     override fun onStop() {
    161         super.onStop()
    162         
    163         lifecycleScope.launch {
    164             try {
    165                 if (::handle.isInitialized && handle.pointer != 0L) {
    166                     Log.i(TAG, "Stopping chat session on onStop")
    167                     gnunetChat.stopChat(handle)
    168                 }
    169             } catch (t: Throwable) {
    170                 Log.e(TAG, "Failed to stop chat session in onStop", t)
    171             }
    172         }
    173     }
    174 
    175     private suspend fun awaitHandlePointerReady(): ChatHandle {
    176         while (lifecycleScope.coroutineContext.isActive && handle.pointer == 0L) {
    177             delay(CHAT_READY_POLL_MS)
    178         }
    179         check(handle.pointer != 0L) { "Chat handle was not initialized" }
    180         return handle
    181     }
    182 
    183     suspend fun awaitChatReady(): ChatHandle {
    184         return chatReady.await()
    185     }
    186 
    187     suspend fun awaitInitialDataReady(): ChatHandle {
    188         val readyHandle = chatReady.await()
    189         initialRefreshReady.await()
    190         return readyHandle
    191     }
    192 
    193     fun accountRefreshFlow(): SharedFlow<Unit> = accountRefreshEvents
    194 
    195     fun isChatReady(): Boolean {
    196         return chatReady.isCompleted && handle.pointer != 0L
    197     }
    198 
    199     fun hasInitialRefresh(): Boolean {
    200         return initialRefreshReady.isCompleted
    201     }
    202 
    203     private fun processChatMessage(chatContext: ChatContext, chatMessage: ChatMessage) {
    204         requireNotNull(chatMessage)
    205 
    206         // Shadow the singleton fields with the foreground session's instances
    207         // so the body of this handler always operates against the live account.
    208         val gnunetChat = getCurrentService()
    209         val handle = getCurrentHandle()
    210 
    211         Log.d(
    212             TAG,
    213             "processChatMessage: kind=${chatMessage.kind} " +
    214                 "ctxPtr=${chatContext.userPointer} " +
    215                 "sender=${chatMessage.sender?.name}/${chatMessage.sender?.key?.take(8)}"
    216         )
    217 
    218         when (chatMessage.kind) {
    219             MessageKind.WARNING -> {
    220             }
    221 
    222             MessageKind.REFRESH -> {
    223                 Log.d(TAG, "Received REFRESH")
    224                 if (!initialRefreshReady.isCompleted) {
    225                     initialRefreshReady.complete(Unit)
    226                     Log.d(TAG, "Initial refresh barrier released")
    227                 }
    228                 accountRefreshEvents.tryEmit(Unit)
    229             }
    230 
    231             MessageKind.LOGIN -> {
    232                 loadChats()
    233             }
    234 
    235             MessageKind.LOGOUT -> {
    236                 Log.d(TAG, "Received LOGOUT")
    237                 // During account switch, clearChatState() is called explicitly
    238                 // before disconnect(). Skip clearing here to avoid wiping data
    239                 // that loadChats() (triggered by the subsequent LOGIN) has loaded.
    240                 if (currentAccount == null) {
    241                     contactListViewModel.clearModel()
    242                     chatOverviewViewModel.clearModel()
    243                     chatViewModels.values.forEach { it.clearModel() }
    244                     chats.clear()
    245                 }
    246             }
    247 
    248             MessageKind.CREATED_ACCOUNT,
    249             MessageKind.UPDATE_ACCOUNT -> {
    250             }
    251 
    252             MessageKind.UPDATE_CONTEXT -> {
    253             }
    254 
    255             MessageKind.JOIN,
    256             MessageKind.LEAVE -> {
    257                 var uuid = gnunetChat.getUserPointerForContext(chatContext)
    258                 val chatContact = chatMessage.sender
    259                     ?: gnunetChat.getSenderFromMessage(chatMessage)
    260                 val contactName = if (chatContact.name.isNotBlank()) {
    261                     chatContact.name
    262                 } else {
    263                     "A user"
    264                 }
    265                 val lastMessagePreview = if (chatMessage.kind == MessageKind.JOIN) {
    266                     "$contactName joined the chat"
    267                 } else {
    268                     "$contactName left the chat"
    269                 }
    270                 val viewModel = getChatViewModel(chatContext)
    271 
    272                 val displayTimestamp = if (chatMessage.timestamp == 0L) {
    273                     System.currentTimeMillis()
    274                 } else {
    275                     chatMessage.timestamp
    276                 }
    277                 val displayMessage = chatMessage.copy(
    278                     text = lastMessagePreview,
    279                     type = ChatMessageType.SYSTEM,
    280                     timestamp = displayTimestamp
    281                 )
    282 
    283                 if (uuid == null) {
    284                     uuid = gnunetChat.randomUUID()
    285                     gnunetChat.setUserPointerForContext(chatContext, uuid)
    286                     chatContext.userPointer = uuid
    287                     chats[uuid] = chatContext
    288                     chatOverviewViewModel.addOrUpdateChat(
    289                         ChatSummary(chatContext, contactName, lastMessagePreview)
    290                     )
    291                 } else {
    292                     chatContext.userPointer = uuid
    293                     if (chats[uuid] == null) {
    294                         chats[uuid] = chatContext
    295                     }
    296                     val displayContext = chats[uuid] ?: chatContext
    297                     val group = gnunetChat.getGroupFromContext(chatContext)
    298                     val displayName = if (group != null && group.name.isNotBlank()) {
    299                         group.name
    300                     } else {
    301                         contactName
    302                     }
    303 
    304                     chatOverviewViewModel.addOrUpdateChat(
    305                         ChatSummary(displayContext, displayName, lastMessagePreview)
    306                     )
    307                 }
    308                 viewModel?.addMessage(displayMessage)
    309             }
    310 
    311             MessageKind.CONTACT,
    312             MessageKind.SHARED_ATTRIBUTES -> {
    313                 Log.d(TAG, "Received ${chatMessage.kind} — reloading chats so new contact appears")
    314                 loadChats()
    315             }
    316 
    317             MessageKind.INVITATION -> {
    318                 // Invitation is auto-accepted at the native layer.
    319                 // Reload chats so the newly joined group appears in the overview.
    320                 Log.d(TAG, "Received INVITATION — reloading chats to show new group")
    321                 loadChats()
    322             }
    323 
    324             MessageKind.TEXT,
    325             MessageKind.FILE -> {
    326                 val senderKey = chatMessage.sender?.key ?: ""
    327                 val profileKey = gnunetChat.getProfileKey(handle)
    328                 val ownEchoSkip = senderKey.isNotEmpty() && senderKey == profileKey
    329                 Log.d(
    330                     TAG,
    331                     "TEXT/FILE foreground: senderKey='${senderKey.take(16)}...' " +
    332                         "(len=${senderKey.length}) profileKey='${profileKey.take(16)}...' " +
    333                         "(len=${profileKey.length}) match=$ownEchoSkip text='${chatMessage.text}'"
    334                 )
    335                 if (ownEchoSkip) {
    336                     // Already added this message to the view when we sent it,
    337                     // so skip the echo to avoid a duplicate.
    338                     return
    339                 }
    340 
    341                 val viewModel = getChatViewModel(chatContext)
    342                 val uuid = gnunetChat.getUserPointerForContext(chatContext)
    343                 val localChatContext = chats[uuid]
    344                 chatMessage.type = ChatMessageType.OTHER
    345 
    346                 if (localChatContext != null) {
    347                     viewModel?.addMessage(chatMessage)
    348                     chatOverviewViewModel.updateChatMessage(localChatContext, chatMessage)
    349                 }
    350             }
    351 
    352             MessageKind.DELETION -> {
    353             }
    354 
    355             MessageKind.TAG -> {
    356             }
    357 
    358             MessageKind.ATTRIBUTES -> {
    359             }
    360 
    361             MessageKind.DISCOURSE -> {
    362             }
    363 
    364             MessageKind.DATA -> {
    365             }
    366 
    367             else -> {
    368                 error("Unexpected message kind: ${chatMessage.kind}")
    369             }
    370         }
    371     }
    372 
    373     // Stable identifier for a chat context, independent of the native
    374     // pointer (which can drift between listGroups/listContacts refreshes
    375     // and is blank for newly-built contexts). This is what we key the
    376     // per-chat ViewModels by so that messages survive navigation.
    377     private fun stableChatKey(chatContext: ChatContext): String? {
    378         val gnunetChat = getCurrentService()
    379         runCatching { gnunetChat.getGroupFromContext(chatContext) }
    380             .getOrNull()
    381             ?.takeIf { it.name.isNotBlank() }
    382             ?.let { return "group:${it.name}" }
    383 
    384         runCatching { gnunetChat.getContextContact(chatContext) }
    385             .getOrNull()
    386             ?.key
    387             ?.takeIf { it.isNotBlank() }
    388             ?.let { return "contact:$it" }
    389 
    390         return chatContext.userPointer?.takeIf { it.isNotBlank() }
    391     }
    392 
    393     fun getChatViewModel(chatContext: ChatContext): ChatViewModel? {
    394         val base = stableChatKey(chatContext) ?: return null
    395         // Key the per-chat ViewModel by the *foreground* account too. OWN/OTHER
    396         // is viewer-relative: the same group message is "mine" for the account
    397         // that sent it and "theirs" for everyone else. A shared (account-blind)
    398         // ViewModel bakes one account's perspective into the stored message and
    399         // shows it wrong after a switch — the cause of "messages on the wrong
    400         // side for both accounts". One ViewModel per (account, chat) keeps each
    401         // account's perspective independent.
    402         val acct = currentAccount?.name?.lowercase() ?: "?"
    403         val key = "$acct|$base"
    404         val isNew = key !in chatViewModels
    405         val vm = chatViewModels.getOrPut(key) { ChatViewModel(chatContext) }
    406         Log.d(TAG, "getChatViewModel key=$key isNew=$isNew msgCount=${vm.messages.value?.size ?: 0}")
    407         return vm
    408     }
    409 
    410     fun getChatMenuViewModel(chatContext: ChatContext): ChatMenuViewModel? {
    411         val key = stableChatKey(chatContext) ?: return null
    412         return chatMenuViewModels.getOrPut(key) {
    413             ChatMenuViewModel(chatContext)
    414         }
    415     }
    416 
    417     fun loadChats() {
    418         lifecycleScope.launch {
    419             try {
    420                 loadChatsSuspend()
    421             } catch (t: Throwable) {
    422                 Log.e(TAG, "loadChats failed", t)
    423             }
    424         }
    425     }
    426 
    427     suspend fun loadChatsAndWait() {
    428         loadChatsSuspend()
    429     }
    430 
    431     private suspend fun loadChatsSuspend() = loadChatsMutex.withLock {
    432         // Capture the foreground session's instances; without this we'd
    433         // iterate against the bootstrap handle (no connected account → SYSERR).
    434         val gnunetChat = getCurrentService()
    435         val handle = getCurrentHandle()
    436 
    437         withContext(Dispatchers.IO) {
    438             val summaries = mutableListOf<ChatSummary>()
    439             val contacts = mutableListOf<ChatContact>()
    440 
    441             val contactList = gnunetChat.listContacts(handle)
    442             for (contact in contactList) {
    443                 val chatContext = gnunetChat.getContactContext(contact)
    444                 var uuid = gnunetChat.getUserPointerForContext(chatContext)
    445                 if (uuid == null) {
    446                     uuid = gnunetChat.randomUUID()
    447                     gnunetChat.setUserPointerForContext(chatContext, uuid)
    448                 }
    449                 chatContext.userPointer = uuid
    450                 contacts.add(contact)
    451 
    452                 chats[uuid] = chatContext
    453                 contact.blocked = gnunetChat.isContactBlocked(contact)
    454                 val contactDisplayName = if (contact.name.isNotBlank()) {
    455                     contact.name
    456                 } else {
    457                     val key = gnunetChat.getContactKey(contact)
    458                     if (key.isNotBlank()) "Contact (${key.take(8)}...)" else "Unknown contact"
    459                 }
    460                 summaries.add(
    461                     ChatSummary(
    462                         chatContext = chatContext,
    463                         displayName = contactDisplayName,
    464                         contact = contact
    465                     )
    466                 )
    467             }
    468 
    469             val groupList = gnunetChat.listGroups(handle)
    470             val profileKey = gnunetChat.getProfileKey(handle)
    471             Log.d(TAG, "loadChats: profileKey=${profileKey.take(12)}, found ${groupList.size} groups: ${groupList.map { "${it.name}(ptr=${it.userPointer})" }}")
    472             for (group in groupList) {
    473                 val chatContext = gnunetChat.getGroupContext(group)
    474                 var uuid = gnunetChat.getUserPointerForContext(chatContext)
    475                 if (uuid == null) {
    476                     uuid = gnunetChat.randomUUID()
    477                     gnunetChat.setUserPointerForContext(chatContext, uuid)
    478                 }
    479                 chatContext.userPointer = uuid
    480 
    481                 chats[uuid] = chatContext
    482 
    483                 val members = try {
    484                     gnunetChat.listGroupContacts(group)
    485                 } catch (t: Throwable) {
    486                     Log.w(TAG, "listGroupContacts failed for ${group.name}", t)
    487                     emptyList()
    488                 }
    489 
    490                 // Log membership info for debugging.
    491                 // NOTE: We no longer filter groups by membership here because
    492                 // invited accounts may not yet appear in listGroupContacts()
    493                 // until they send a JOIN message. The monolithic daemon exposes
    494                 // all groups to all handles — a proper fix requires native-level
    495                 // per-account context filtering.
    496                 if (members.isNotEmpty()) {
    497                     Log.d(TAG, "Group '${group.name}' members: ${members.map { "${it.name}(${it.key.take(8)})" }}, profileKey=${profileKey.take(8)}")
    498                 } else {
    499                     Log.d(TAG, "Group '${group.name}' has no members listed yet")
    500                 }
    501 
    502                 val namedMembers = members.filter { it.name.isNotBlank() }
    503                 val memberPreview = if (namedMembers.isNotEmpty()) {
    504                     namedMembers.joinToString(", ") { it.name } + " joined the chat"
    505                 } else if (members.isNotEmpty()) {
    506                     "${members.size} member(s) in the chat"
    507                 } else {
    508                     null
    509                 }
    510 
    511                 val groupDisplayName = if (group.name.isNotBlank()) {
    512                     group.name
    513                 } else {
    514                     "Lobby"
    515                 }
    516                 summaries.add(
    517                     ChatSummary(
    518                         chatContext = chatContext,
    519                         displayName = groupDisplayName,
    520                         lastMessagePreview = memberPreview,
    521                         group = group
    522                     )
    523                 )
    524             }
    525 
    526             withContext(Dispatchers.Main) {
    527                 contactListViewModel.setContacts(contacts)
    528                 val existing = chatOverviewViewModel.chats.value.orEmpty()
    529                 val merged = summaries.map { summary ->
    530                     val prev = existing.find {
    531                         it.chatContext.userPointer == summary.chatContext.userPointer
    532                     }
    533                     if (prev?.lastMessagePreview != null && summary.lastMessagePreview == null) {
    534                         summary.copy(lastMessagePreview = prev.lastMessagePreview)
    535                     } else {
    536                         summary
    537                     }
    538                 }
    539                 chatOverviewViewModel.setChats(merged)
    540             }
    541             Log.d(TAG, "loadChats: loaded ${contacts.size} contacts, ${groupList.size} groups")
    542         }
    543     }
    544 
    545     fun clearChatState() {
    546         chatOverviewViewModel.clearModel()
    547         contactListViewModel.clearModel()
    548         // Intentionally keep chatViewModels and chatMenuViewModels across
    549         // account switches. The per-chat message log is in-memory only, and
    550         // the daemon does not replay history on (re)connect. Wiping them
    551         // here makes the messages vanish the moment we switch accounts.
    552         // ViewModels are keyed per (account, chat) (see getChatViewModel),
    553         // so each account keeps its own correctly-sided copy of the
    554         // conversation; switching back restores that account's view intact.
    555         chats.clear()
    556         Log.d(TAG, "clearChatState: chat overview/contacts cleared, message history preserved")
    557     }
    558 
    559     override fun onCreateOptionsMenu(menu: Menu?): Boolean {
    560         menuInflater.inflate(R.menu.main_menu, menu)
    561         val current = currentAccount
    562         menu?.findItem(R.id.menu_current_account)?.title =
    563             "Account: ${current?.name ?: "No active account!"}"
    564         return true
    565     }
    566 
    567     override fun onPrepareOptionsMenu(menu: Menu?): Boolean {
    568         val current = currentAccount
    569         menu?.findItem(R.id.menu_current_account)?.title =
    570             "Account: ${current?.name ?: "No active account!"}"
    571         return super.onPrepareOptionsMenu(menu)
    572     }
    573 
    574     override fun onOptionsItemSelected(item: MenuItem): Boolean {
    575         return when (item.itemId) {
    576             R.id.menu_current_account -> {
    577                 if (currentAccount != null) {
    578                     val action = NavGraphDirections.actionGlobalAccountDetailsFragment()
    579                     navController.navigate(action)
    580                 }
    581                 true
    582             }
    583 
    584             R.id.menu_create_lobby -> {
    585                 val action = NavGraphDirections.actionGlobalLobbyCreateFragment()
    586                 navController.navigate(action)
    587                 true
    588             }
    589 
    590             R.id.menu_join_lobby -> {
    591                 val action = NavGraphDirections.actionGlobalLobbyJoinFragment()
    592                 navController.navigate(action)
    593                 true
    594             }
    595 
    596             R.id.menu_new_group -> {
    597                 val action =
    598                     NavGraphDirections.actionGlobalCreateGroupOrPlatformFragment(isGroup = true)
    599                 navController.navigate(action)
    600                 true
    601             }
    602 
    603             R.id.menu_new_platform -> {
    604                 val action =
    605                     NavGraphDirections.actionGlobalCreateGroupOrPlatformFragment(isGroup = false)
    606                 navController.navigate(action)
    607                 true
    608             }
    609 
    610             R.id.menu_contact_list -> {
    611                 val action = NavGraphDirections.actionGlobalContactListFragment()
    612                 navController.navigate(action)
    613                 true
    614             }
    615 
    616             R.id.menu_settings -> {
    617                 val action = NavGraphDirections.actionGlobalSettingsFragment()
    618                 navController.navigate(action)
    619                 true
    620             }
    621 
    622             R.id.menu_about -> true
    623             else -> super.onOptionsItemSelected(item)
    624         }
    625     }
    626 
    627     override fun onSupportNavigateUp(): Boolean {
    628         return NavigationUI.navigateUp(navController, appBarConfiguration) ||
    629                 super.onSupportNavigateUp()
    630     }
    631 
    632     fun getGnunetChatInstance(): GnunetChat = getCurrentService()
    633 
    634     fun getChatHandle(): ChatHandle = getCurrentHandle()
    635 
    636     /** The session for the currently selected account, or null when none. */
    637     fun currentSession(): AccountSession? =
    638         currentAccount?.name?.lowercase()?.let { sessions[it] }
    639 
    640     /**
    641      * The bound-service instance the UI should use right now. Falls back
    642      * to the bootstrap singleton when no per-account session is registered.
    643      */
    644     fun getCurrentService(): GnunetChat = currentSession()?.gnunetChat ?: gnunetChat
    645 
    646     /**
    647      * The chat handle the UI should use right now. Falls back to the
    648      * bootstrap singleton handle when no per-account session is registered.
    649      */
    650     fun getCurrentHandle(): ChatHandle = currentSession()?.handle ?: handle
    651 
    652     fun setCurrentAccount(account: ChatAccount) {
    653         currentAccount = account
    654         invalidateOptionsMenu()
    655     }
    656 
    657     /**
    658      * Spawns a new bound-service + chat handle for [account], connects the
    659      * account on it, and registers the resulting [AccountSession] under
    660      * the lower-cased name. Idempotent — returns the existing session if
    661      * one is already registered. Both accounts stay live in libgnunetchat
    662      * simultaneously, mirroring how messenger-gtk runs two processes
    663      * against one daemon.
    664      */
    665     suspend fun spawnSessionFor(account: ChatAccount): AccountSession {
    666         val key = account.name.lowercase()
    667         sessions[key]?.let { return it }
    668 
    669         Log.d(TAG, "spawnSessionFor: starting session for '${account.name}'")
    670         val svc = ServiceFactory.create(applicationContext, useMock = false)
    671         val refreshSeen = CompletableDeferred<Unit>()
    672         val loginSeen = CompletableDeferred<Unit>()
    673 
    674         val newHandle = svc.startChat(MessengerApp()) { ctx, msg ->
    675             if (msg.kind == MessageKind.REFRESH && !refreshSeen.isCompleted) {
    676                 refreshSeen.complete(Unit)
    677             }
    678             if (msg.kind == MessageKind.LOGIN && !loginSeen.isCompleted) {
    679                 loginSeen.complete(Unit)
    680             }
    681             sessions[key]?.let { existing ->
    682                 processChatMessageRouted(existing, ctx, msg)
    683             }
    684         }
    685 
    686         withTimeout(30_000) { while (newHandle.pointer == 0L) delay(50) }
    687         withTimeout(30_000) { refreshSeen.await() }
    688 
    689         Log.d(TAG, "spawnSessionFor: handle ready for '${account.name}', connecting")
    690         svc.connect(newHandle, account)
    691         withTimeout(20_000) { loginSeen.await() }
    692 
    693         val session = AccountSession(account, newHandle, svc)
    694         sessions[key] = session
    695         Log.d(TAG, "spawnSessionFor: '${account.name}' live (handle=${newHandle.pointer})")
    696         return session
    697     }
    698 
    699     /**
    700      * Routes a daemon message to [processChatMessage] only when [session]
    701      * is the foreground one. Background sessions receive events at the
    702      * libgnunetchat layer; switching to a background session triggers a
    703      * loadChats() that reads the up-to-date state from the daemon.
    704      */
    705     private fun processChatMessageRouted(
    706         session: AccountSession,
    707         chatContext: ChatContext,
    708         chatMessage: ChatMessage
    709     ) {
    710         val current = currentSession()
    711         val isForeground = current != null &&
    712             current.account.name.equals(session.account.name, ignoreCase = true)
    713 
    714         if (isForeground) {
    715             processChatMessage(chatContext, chatMessage)
    716             return
    717         }
    718 
    719         // Background session: route TEXT/FILE messages into the global chat
    720         // viewmodel using the session's own service so they're visible when
    721         // the user later switches to this session. Other event kinds will
    722         // be re-rendered via loadChats() on switch.
    723         when (chatMessage.kind) {
    724             MessageKind.TEXT, MessageKind.FILE -> {
    725                 val senderKey = chatMessage.sender?.key ?: ""
    726 
    727                 // Skip only THIS background session's own echo. ViewModels are
    728                 // keyed per (account, chat), so each account holds its own copy
    729                 // of the conversation. When account A (foreground) sends a group
    730                 // message, B's background handler must record it as OTHER in B's
    731                 // own ViewModel — that's how B sees A's message on the left after
    732                 // switching. (The earlier "skip if sender is ANY of our sessions"
    733                 // logic was for a single shared ViewModel and made B drop A's
    734                 // messages entirely; with per-account ViewModels there's no cross-
    735                 // account duplication to guard against.) The only echo to skip is
    736                 // a message whose sender is this very session's account — B already
    737                 // added that optimistically while B was foreground.
    738                 val ownKey = runCatching {
    739                     session.gnunetChat.getProfileKey(session.handle)
    740                 }.getOrDefault("")
    741                 if (senderKey.isNotEmpty() && senderKey == ownKey) {
    742                     Log.d(
    743                         TAG,
    744                         "background-session ${session.account.name}: kind=${chatMessage.kind} " +
    745                             "(own echo, skip)"
    746                     )
    747                     return
    748                 }
    749 
    750                 val base = stableChatKeyFor(session, chatContext)
    751                 if (base == null) {
    752                     Log.w(
    753                         TAG,
    754                         "background-session ${session.account.name}: kind=${chatMessage.kind} " +
    755                             "no stableChatKey — dropping"
    756                     )
    757                     return
    758                 }
    759                 // Same per-account keying scheme as getChatViewModel(), but using
    760                 // this background session's account (not the foreground one).
    761                 val key = "${session.account.name.lowercase()}|$base"
    762                 val vm = chatViewModels.getOrPut(key) { ChatViewModel(chatContext) }
    763                 chatMessage.type = ChatMessageType.OTHER
    764                 vm.addMessage(chatMessage)
    765                 Log.d(
    766                     TAG,
    767                     "background-session ${session.account.name}: kind=${chatMessage.kind} " +
    768                         "added to viewmodel '$key' (msgCount=${vm.messages.value?.size ?: 0})"
    769                 )
    770             }
    771             else -> {
    772                 Log.d(
    773                     TAG,
    774                     "background-session ${session.account.name}: kind=${chatMessage.kind} " +
    775                         "(deferred — will re-render on next switch)"
    776                 )
    777             }
    778         }
    779     }
    780 
    781     /**
    782      * Same as [stableChatKey] but resolves group/contact info via the
    783      * given [session]'s service. Required when the message arrived on a
    784      * background session whose native context pointer is in that session's
    785      * address space — the foreground service can't dereference it.
    786      */
    787     private fun stableChatKeyFor(
    788         session: AccountSession,
    789         chatContext: ChatContext
    790     ): String? {
    791         val gnunetChat = session.gnunetChat
    792         runCatching { gnunetChat.getGroupFromContext(chatContext) }
    793             .getOrNull()
    794             ?.takeIf { it.name.isNotBlank() }
    795             ?.let { return "group:${it.name}" }
    796 
    797         runCatching { gnunetChat.getContextContact(chatContext) }
    798             .getOrNull()
    799             ?.key
    800             ?.takeIf { it.isNotBlank() }
    801             ?.let { return "contact:$it" }
    802 
    803         return chatContext.userPointer?.takeIf { it.isNotBlank() }
    804     }
    805 
    806     /**
    807      * Switches the foreground UI to [account]. Spawns a new session if
    808      * one doesn't exist. **Never disconnects existing sessions** — both
    809      * the lobby host and the joiner can stay live across the switch,
    810      * which is what makes lobby pairing succeed for both sides on a
    811      * single daemon.
    812      */
    813     suspend fun switchToSession(account: ChatAccount) {
    814         val key = account.name.lowercase()
    815         val previous = currentAccount
    816         val isSame = previous != null &&
    817             previous.name.equals(account.name, ignoreCase = true)
    818         if (isSame) return
    819 
    820         val session = sessions[key] ?: spawnSessionFor(account)
    821 
    822         runCatching {
    823             account.key = session.gnunetChat.getProfileKey(session.handle)
    824         }.onFailure { Log.w(TAG, "switchToSession: getProfileKey failed", it) }
    825 
    826         clearChatState()
    827         setCurrentAccount(account)
    828         runCatching { loadChatsAndWait() }
    829             .onFailure { Log.w(TAG, "switchToSession: loadChatsAndWait failed", it) }
    830     }
    831 }