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 }