messenger-android

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

commit 2f85060ea131efbf6b9f4468430d4d99f04fc506
parent 3990ad83e3b90f8a75f4d2d3febff161c955e65d
Author: t3sserakt <t3ss@posteo.de>
Date:   Wed,  6 May 2026 15:35:27 +0200

WIP: fixing the text message stays, works on initial text

Diffstat:
AGNUnetMessenger/app/src/androidTest/java/org/gnunet/gnunetmessenger/ipc/GnunetChatLobbyTwoHandlesTest.kt | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AGNUnetMessenger/app/src/androidTest/java/org/gnunet/gnunetmessenger/perf/ThreeAccountPerformanceTest.kt | 348+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MGNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/MainActivity.kt | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
MGNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ipc/ChatContextDto.kt | 7++++++-
MGNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ipc/DtoMappers.kt | 5++++-
MGNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/model/ChatContext.kt | 5++++-
MGNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/service/boundimpl/GnunetChatBoundService.kt | 8+++++++-
MGNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ui/account/AccountListFragment.kt | 42++++++++++--------------------------------
MGNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ui/chat/ChatFragment.kt | 6++++++
MGNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ui/contact/LobbyJoinFragment.kt | 1-
MGNUnetMessenger/build.gradle.kts | 2+-
MGNUnetMessenger/gradle.properties | 14++++++++++++--
MGNUnetMessenger/gradle/libs.versions.toml | 4++--
MGNUnetMessenger/gradle/wrapper/gradle-wrapper.properties | 2+-
MGNUnetMessenger/settings.gradle.kts | 2+-
15 files changed, 746 insertions(+), 50 deletions(-)

diff --git a/GNUnetMessenger/app/src/androidTest/java/org/gnunet/gnunetmessenger/ipc/GnunetChatLobbyTwoHandlesTest.kt b/GNUnetMessenger/app/src/androidTest/java/org/gnunet/gnunetmessenger/ipc/GnunetChatLobbyTwoHandlesTest.kt @@ -0,0 +1,198 @@ +package org.gnunet.gnunetmessenger.ipc + +import android.util.Log +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.gnunet.gnunetmessenger.model.ChatAccount +import org.gnunet.gnunetmessenger.model.ChatContext +import org.gnunet.gnunetmessenger.model.ChatHandle +import org.gnunet.gnunetmessenger.model.ChatMessage +import org.gnunet.gnunetmessenger.model.GnunetReturnValue +import org.gnunet.gnunetmessenger.model.MessageKind +import org.gnunet.gnunetmessenger.model.MessengerApp +import org.gnunet.gnunetmessenger.service.boundimpl.GnunetChatBoundService +import org.junit.After +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Validates the architectural hypothesis: lobby pairing succeeds when both + * accounts are connected on parallel chat handles and neither side disconnects. + */ +@RunWith(AndroidJUnit4::class) +class GnunetChatLobbyTwoHandlesTest { + + private val tag = "TwoHandles" + private val appContext = ApplicationProvider.getApplicationContext<android.content.Context>() + private val svcA = GnunetChatBoundService(appContext) + private val svcB = GnunetChatBoundService(appContext) + private val logA = mutableListOf<Pair<ChatContext, ChatMessage>>() + private val logB = mutableListOf<Pair<ChatContext, ChatMessage>>() + + // libgnunetchat stores account names lower-cased; keep them lower-case here + // so equality checks against iterateAccounts results match. + private val nameA = "twohandlea-${System.currentTimeMillis()}" + private val nameB = "twohandleb-${System.currentTimeMillis()}" + + @After + fun tearDown() = runTest { + runCatching { svcA.unbind() } + runCatching { svcB.unbind() } + delay(1000) + } + + private suspend fun waitForHandle(label: String, handle: ChatHandle, timeoutMs: Long = 30_000) { + Log.i(tag, "$label: waitForHandle (timeout=${timeoutMs}ms)") + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(timeoutMs) { + while (handle.pointer == 0L) delay(100) + } + } + Log.i(tag, "$label: handle ready -> ${handle.pointer}") + } + + private suspend fun waitForRefresh( + label: String, + log: List<Pair<ChatContext, ChatMessage>>, + timeoutMs: Long = 30_000 + ) { + Log.i(tag, "$label: waitForRefresh") + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(timeoutMs) { + while (log.none { it.second.kind == MessageKind.REFRESH }) delay(100) + } + } + Log.i(tag, "$label: REFRESH received") + } + + private suspend fun snapshotAccounts( + svc: GnunetChatBoundService, + handle: ChatHandle + ): List<ChatAccount> { + val acc = mutableListOf<ChatAccount>() + svc.iterateAccounts(handle) { acc += it } + // iterateAccounts is one-shot; give the binder callback a moment to drain. + delay(400) + return acc.toList() + } + + private suspend fun connectAccount( + label: String, + svc: GnunetChatBoundService, + handle: ChatHandle, + name: String, + log: List<Pair<ChatContext, ChatMessage>> + ): ChatAccount { + Log.i(tag, "$label: createAccount('$name')") + val rc = svc.createAccount(handle, name) + Log.i(tag, "$label: createAccount returned $rc") + + Log.i(tag, "$label: polling iterateAccounts until '$name' appears") + var found: ChatAccount? = null + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(45_000) { + while (found == null) { + val snapshot = snapshotAccounts(svc, handle) + Log.d(tag, "$label: snapshot=${snapshot.map { it.name }}") + found = snapshot.firstOrNull { it.name.equals(name, ignoreCase = true) } + if (found == null) delay(500) + } + } + } + val account = found!! + Log.i(tag, "$label: account '${account.name}' visible") + + Log.i(tag, "$label: connect('$name')") + svc.connect(handle, account) + + Log.i(tag, "$label: waiting for LOGIN") + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(20_000) { + while (log.none { it.second.kind == MessageKind.LOGIN }) delay(100) + } + } + Log.i(tag, "$label: LOGIN received") + return account + } + + @Test + fun pairingWithTwoSimultaneousHandlesProducesContactsOnBothSides() = runTest { + Log.i(tag, "=== START: pairingWithTwoSimultaneousHandles ===") + + val handleA = svcA.startChat(MessengerApp()) { ctx, msg -> + logA += ctx to msg + Log.d(tag, "A onMessage: kind=${msg.kind} sender=${msg.sender?.name}") + } + val handleB = svcB.startChat(MessengerApp()) { ctx, msg -> + logB += ctx to msg + Log.d(tag, "B onMessage: kind=${msg.kind} sender=${msg.sender?.name}") + } + + waitForHandle("A", handleA) + waitForHandle("B", handleB) + assertTrue(handleA.pointer != 0L) + assertTrue(handleB.pointer != 0L) + assertNotEquals( + "Two startChat calls must produce distinct sessions", + handleA.pointer, + handleB.pointer + ) + + waitForRefresh("A", logA) + waitForRefresh("B", logB) + + connectAccount("A", svcA, handleA, nameA, logA) + connectAccount("B", svcB, handleB, nameB, logB) + + Log.i(tag, "Opening lobby on handleA") + var lobbyUri = "" + svcA.lobbyOpen(handleA) { uri -> + Log.i(tag, "A: onLobbyUri received (${uri.length} chars)") + lobbyUri = uri + } + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(30_000) { + while (lobbyUri.isEmpty()) delay(200) + } + } + assertTrue("Lobby URI must be delivered", lobbyUri.isNotEmpty()) + Log.i(tag, "Lobby URI: ${lobbyUri.take(80)}...") + + Log.i(tag, "B: lobbyJoin") + svcB.lobbyJoin(handleB, lobbyUri) + + Log.i(tag, "Waiting for both sides to converge on >=2 contacts each") + withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(60_000) { + while (true) { + val a = svcA.listContacts(handleA) + val b = svcB.listContacts(handleB) + Log.d(tag, "poll: A.contacts=${a.size} B.contacts=${b.size}") + if (a.size >= 2 && b.size >= 2) break + delay(500) + } + } + } + + val contactsA = svcA.listContacts(handleA) + val contactsB = svcB.listContacts(handleB) + Log.i(tag, "A's contacts: ${contactsA.map { it.name }}") + Log.i(tag, "B's contacts: ${contactsB.map { it.name }}") + + assertTrue( + "A should have B as contact (got: ${contactsA.map { it.name }})", + contactsA.any { it.name == nameB } + ) + assertTrue( + "B should have A as contact (got: ${contactsB.map { it.name }})", + contactsB.any { it.name == nameA } + ) + } +} diff --git a/GNUnetMessenger/app/src/androidTest/java/org/gnunet/gnunetmessenger/perf/ThreeAccountPerformanceTest.kt b/GNUnetMessenger/app/src/androidTest/java/org/gnunet/gnunetmessenger/perf/ThreeAccountPerformanceTest.kt @@ -0,0 +1,348 @@ +package org.gnunet.gnunetmessenger.perf + +import android.util.Log +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.minutes +import org.gnunet.gnunetmessenger.model.ChatAccount +import org.gnunet.gnunetmessenger.model.ChatContext +import org.gnunet.gnunetmessenger.model.ChatHandle +import org.gnunet.gnunetmessenger.model.GnunetReturnValue +import org.gnunet.gnunetmessenger.model.MessageKind +import org.gnunet.gnunetmessenger.model.MessengerApp +import org.gnunet.gnunetmessenger.service.boundimpl.GnunetChatBoundService +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Phase 2.5: three parallel sessions on the same daemon. + * + * Sole purpose: tell whether the daemon's serialization point (revealed by + * Phase 2 — combined throughput stayed flat at ~100 msg/s with 2 sessions + * instead of doubling) is a single bottleneck or a contention scaling effect. + * + * - If 1/2/3-session combined throughput all sit near ~100 msg/s, that's + * a single serialization point (the GNUnet monolithic scheduler is the + * prime suspect — single-threaded, all sessions queue onto its loop). + * - If 3-session throughput drops below 2-session, contention scales + * super-linearly — likely lock contention rather than a clean queue. + * + * Design mirrors TwoAccountPerformanceTest: three independent + * GnunetChatBoundService instances → three native sessions via g_sessions → + * three accounts each with its own group, all firing concurrently with + * disjoint seq ranges into a shared LatencyRecorder. + */ +@RunWith(AndroidJUnit4::class) +class ThreeAccountPerformanceTest { + + private val appContext = ApplicationProvider.getApplicationContext<android.content.Context>() + private val clientAlpha = GnunetChatBoundService(appContext) + private val clientBeta = GnunetChatBoundService(appContext) + private val clientGamma = GnunetChatBoundService(appContext) + + private val recorder = LatencyRecorder() + + private val warmupPerClient = 10 + private val measuredPerClient = 80 + + @Before + fun bootServerDaemon() { + val launch = appContext.packageManager.getLaunchIntentForPackage(SERVER_PACKAGE) + if (launch != null) { + launch.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + appContext.startActivity(launch) + Thread.sleep(15_000) + } + } + + @After + fun tearDown() = runTest(timeout = 1.minutes) { + runCatching { clientAlpha.unbind() } + runCatching { clientBeta.unbind() } + runCatching { clientGamma.unbind() } + delay(500) + } + + @Test + fun threeSessionParallelLoopback_measuresLatencyAndThroughput() = runTest(timeout = 5.minutes) { + val pm = appContext.packageManager + val serverInstalled = runCatching { + pm.getPackageInfo(SERVER_PACKAGE, 0); true + }.getOrDefault(false) + assumeTrue( + "GNUnet IPC server package '$SERVER_PACKAGE' is not installed.", + serverInstalled, + ) + + val perClientTotal = warmupPerClient + measuredPerClient + val sessions = listOf( + SessionFixture( + "PerfAccountAlpha", clientAlpha, + seqStart = 0L * perClientTotal, probeSeq = Long.MAX_VALUE, + ), + SessionFixture( + "PerfAccountBeta", clientBeta, + seqStart = 1L * perClientTotal, probeSeq = Long.MAX_VALUE - 1, + ), + SessionFixture( + "PerfAccountGamma", clientGamma, + seqStart = 2L * perClientTotal, probeSeq = Long.MAX_VALUE - 2, + ), + ) + + // Wire up callbacks + handles. Each callback only counts seqs in its own range. + for (s in sessions) { + s.handle = s.client.startChat(MessengerApp()) { _, msg -> + if (msg.kind != MessageKind.TEXT) { + Log.d(TAG, "[${s.label}] kind=${msg.kind} text='${msg.text?.take(40)}'") + return@startChat + } + val seq = extractPerfSeq(msg.text) ?: return@startChat + if (seq == s.probeSeq) { + if (!s.probe.isCompleted) s.probe.complete(Unit) + return@startChat + } + if (seq < s.seqStart || seq >= s.seqStart + perClientTotal) return@startChat + + val now = System.nanoTime() + if (s.firstRecvNs == 0L) s.firstRecvNs = now + s.lastRecvNs = now + + val warmupCutoff = s.seqStart + warmupPerClient + if (seq >= warmupCutoff) recorder.markReceive(seq) + s.seen++ + if (s.seen >= perClientTotal && !s.done.isCompleted) s.done.complete(Unit) + } + } + + // Wait for all three sessions to be live. + try { + withContext(Dispatchers.Default.limitedParallelism(sessions.size)) { + withTimeout(30_000) { + coroutineScope { + sessions.map { s -> + async { s.client.awaitReady(s.handle) } + }.awaitAll() + } + } + } + } catch (t: Throwable) { + Log.e(TAG, "awaitReady failed for one of the three sessions", t) + throw AssertionError( + "Three-session startChat did not produce live handles within 30s. " + + "Underlying cause: ${t.javaClass.simpleName}: ${t.message}", + t, + ) + } + + // Create + connect one account per client. + for (s in sessions) { + s.account = createAndConnectAccount(s.client, s.handle, s.label) + } + + // Each client gets its own loopback group. + for (s in sessions) { + val g = s.client.createGroup( + s.handle, "perf-${s.label.lowercase()}-${System.currentTimeMillis()}", + ) + s.ctx = s.client.getGroupContext(g) + } + withContext(Dispatchers.Default.limitedParallelism(1)) { delay(2_000) } + + // Connectivity probe per session. + Log.i(TAG, "Sending loopback probes…") + withContext(Dispatchers.IO) { + for (s in sessions) { + s.client.sendText(s.ctx!!, "${perfTag(s.probeSeq)} probe") + } + } + try { + withContext(Dispatchers.Default.limitedParallelism(sessions.size)) { + withTimeout(45_000) { + coroutineScope { + sessions.map { s -> async { s.probe.await() } }.awaitAll() + } + } + } + Log.i(TAG, "All ${sessions.size} probes round-tripped — daemon loopback is alive.") + } catch (t: Throwable) { + throw AssertionError( + "Loopback probe did not return for one or more sessions within 45s. " + + sessions.joinToString(" ") { "${it.label}=${it.probe.isCompleted}" } + + ". Try `adb shell am force-stop $SERVER_PACKAGE && " + + "adb shell pm clear $SERVER_PACKAGE` and re-run.", + t, + ) + } + + Log.i( + TAG, + "Starting three-session perf run: warmup=$warmupPerClient measured=$measuredPerClient per session", + ) + + val wallStart = System.nanoTime() + withContext(Dispatchers.Default.limitedParallelism(sessions.size)) { + coroutineScope { + sessions.map { s -> + async { + sendBatch( + client = s.client, + ctx = s.ctx!!, + seqStart = s.seqStart, + total = perClientTotal, + warmup = warmupPerClient, + ) + } + }.awaitAll() + } + } + + // Drain. 3× the work of Phase 2, so 3× the timeout. + try { + withContext(Dispatchers.Default.limitedParallelism(sessions.size)) { + withTimeout(180_000) { + coroutineScope { + sessions.map { s -> async { s.done.await() } }.awaitAll() + } + } + } + } catch (t: Throwable) { + Log.w( + TAG, + "Timed out waiting for parallel loopback: " + + sessions.joinToString(" ") { "${it.label}=${it.seen}/$perClientTotal" } + + " received=${recorder.receivedCount()} outstanding=${recorder.outstanding()}", + ) + } + + val wallEnd = sessions.maxOf { it.lastRecvNs } + .takeIf { it != 0L } ?: System.nanoTime() + val totalMeasured = measuredPerClient * sessions.size + + val summary = recorder.summarize(totalWallNs = wallEnd - wallStart) + val rendered = summary.render( + "ThreeAccountPerformanceTest / parallel three-session loopback " + + "(3 × $perClientTotal msgs, $totalMeasured measured)", + ) + Log.i(TAG, "\n$rendered") + println(rendered) + Log.i( + TAG, + "per-session counts: " + + sessions.joinToString(" ") { "${it.label}=${it.seen}/$perClientTotal" }, + ) + + assertTrue( + "Should have received >= 95% of measured messages " + + "(got ${summary.received}/$totalMeasured)", + summary.received >= (totalMeasured * 0.95).toInt(), + ) + } + + // ---- helpers ---- + + private suspend fun sendBatch( + client: GnunetChatBoundService, + ctx: ChatContext, + seqStart: Long, + total: Int, + warmup: Int, + ) { + for (i in 0 until total) { + val seq = seqStart + i + if (i >= warmup) recorder.markSend(seq) + val text = "${perfTag(seq)} hello" + withContext(Dispatchers.IO) { client.sendText(ctx, text) } + } + } + + private suspend fun createAndConnectAccount( + client: GnunetChatBoundService, + handle: ChatHandle, + name: String, + ): ChatAccount { + val existing = runCatching { client.listAccounts(handle) } + .getOrDefault(emptyList()) + Log.i( + TAG, "[$name] listAccounts (pre-create): ${existing.size} accounts " + + existing.joinToString(",") { "'${it.name}'" }, + ) + val pre = existing.firstOrNull { it.name.equals(name, ignoreCase = true) } + if (pre != null) { + Log.i(TAG, "[$name] account already exists; reusing.") + client.connect(handle, pre) + withContext(Dispatchers.Default.limitedParallelism(1)) { delay(5_000) } + return pre + } + + val res = client.createAccount(handle, name) + Log.i(TAG, "[$name] createAccount -> $res") + assertEquals("createAccount('$name')", GnunetReturnValue.OK, res) + + val account = withContext(Dispatchers.Default.limitedParallelism(1)) { + withTimeout(120_000) { + var found: ChatAccount? = null + var attempt = 0 + val acceptAnyAfterMs = 30_000L + val started = System.currentTimeMillis() + while (found == null) { + val accounts = runCatching { client.listAccounts(handle) } + .getOrDefault(emptyList()) + Log.i( + TAG, + "[$name] listAccounts attempt=$attempt size=${accounts.size} " + + accounts.joinToString(",") { "'${it.name}'" }, + ) + found = accounts.firstOrNull { it.name.equals(name, ignoreCase = true) } + if (found == null && accounts.isNotEmpty() && + System.currentTimeMillis() - started > acceptAnyAfterMs + ) { + Log.w(TAG, "[$name] Falling back to '${accounts[0].name}'.") + found = accounts[0] + } + if (found == null) delay(500) + attempt++ + } + found + } + } + client.connect(handle, account) + withContext(Dispatchers.Default.limitedParallelism(1)) { delay(5_000) } + return account + } + + private class SessionFixture( + val label: String, + val client: GnunetChatBoundService, + val seqStart: Long, + val probeSeq: Long, + ) { + lateinit var handle: ChatHandle + var account: ChatAccount? = null + var ctx: ChatContext? = null + val done = CompletableDeferred<Unit>() + val probe = CompletableDeferred<Unit>() + var seen = 0 + var firstRecvNs = 0L + var lastRecvNs = 0L + } + + companion object { + private const val TAG = "ThreeAcctPerfTest" + private const val SERVER_PACKAGE = "org.gnu.gnunet" + } +} 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,19 @@ import org.gnunet.gnunetmessenger.viewmodel.ChatOverviewViewModel import org.gnunet.gnunetmessenger.viewmodel.ChatViewModel import org.gnunet.gnunetmessenger.viewmodel.ContactListViewModel +/** + * One connected account's full state: its dedicated bound-service instance, + * its chat handle on the daemon, and the user-facing account itself. Multi- + * handle architecture: each account the user activates gets its own session, + * all kept connected for the lifetime of the process. Mirrors how + * messenger-gtk runs two processes against one daemon. + */ +data class AccountSession( + val account: ChatAccount, + val handle: ChatHandle, + val gnunetChat: GnunetChat +) + class MainActivity : AppCompatActivity() { private lateinit var gnunetChat: GnunetChat @@ -70,6 +84,9 @@ 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>() private val accountRefreshEvents = MutableSharedFlow<Unit>(extraBufferCapacity = 1) @@ -180,6 +197,18 @@ 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} " + + "ctxPtr=${chatContext.userPointer} " + + "sender=${chatMessage.sender?.name}/${chatMessage.sender?.key?.take(8)}" + ) + when (chatMessage.kind) { MessageKind.WARNING -> { } @@ -275,6 +304,8 @@ class MainActivity : AppCompatActivity() { MessageKind.CONTACT, MessageKind.SHARED_ATTRIBUTES -> { + Log.d(TAG, "Received ${chatMessage.kind} — reloading chats so new contact appears") + loadChats() } MessageKind.INVITATION -> { @@ -331,6 +362,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() } @@ -375,6 +407,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>() @@ -570,16 +607,119 @@ class MainActivity : AppCompatActivity() { super.onSupportNavigateUp() } - fun getGnunetChatInstance(): GnunetChat { - return gnunetChat - } + fun getGnunetChatInstance(): GnunetChat = getCurrentService() - fun getChatHandle(): ChatHandle { - return handle - } + 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 + + /** + * 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 messenger-gtk's two-process layout. + */ + 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 still receive their events at + * 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() + if (current != null && + current.account.name.equals(session.account.name, ignoreCase = true) + ) { + processChatMessage(chatContext, chatMessage) + } else { + Log.d( + TAG, + "background-session ${session.account.name}: kind=${chatMessage.kind} " + + "(deferred — will re-render on next switch)" + ) + } + } + + /** + * 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/ipc/ChatContextDto.kt b/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ipc/ChatContextDto.kt @@ -8,5 +8,10 @@ data class ChatContextDto( var chatContextType: Int = 0, var userPointer: String? = null, var isGroup: Boolean = false, - var isPlatform: Boolean = false + var isPlatform: Boolean = false, + // Stable pointer to the native GNUNET_CHAT_Context*, kept separate from + // userPointer so the client can overwrite userPointer with a UUID for its + // own chat-keying without breaking IPC ops that need the native context + // handle (sendText, iterateContextMessages, etc.). + var nativeContextPointer: String? = null ) : Parcelable diff --git a/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ipc/DtoMappers.kt b/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ipc/DtoMappers.kt @@ -35,7 +35,9 @@ fun ChatContextDto.toLocal(): ChatContext { chatContextType = type, userPointer = userPointer?.takeIf { it.isNotEmpty() }, isGroup = isGroup, - isPlatform = isPlatform + isPlatform = isPlatform, + nativeContextPointer = nativeContextPointer?.takeIf { it.isNotEmpty() } + ?: userPointer?.takeIf { it.isNotEmpty() } ) } @@ -96,6 +98,7 @@ fun ChatContext.toDto(): ChatContextDto = userPointer = this@toDto.userPointer isGroup = this@toDto.isGroup isPlatform = this@toDto.isPlatform + nativeContextPointer = this@toDto.nativeContextPointer } fun ChatMessage.toDto(): ChatMessageDto = diff --git a/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/model/ChatContext.kt b/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/model/ChatContext.kt @@ -32,6 +32,9 @@ data class ChatContext ( val chatContextType: ChatContextType?, var userPointer: String?, val isGroup: Boolean, - val isPlatform: Boolean + val isPlatform: Boolean, + // Native GNUNET_CHAT_Context* (as decimal string) provided by the daemon. + // Stays stable even if [userPointer] is rewritten client-side for keying. + var nativeContextPointer: String? = null ): Parcelable diff --git a/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/service/boundimpl/GnunetChatBoundService.kt b/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/service/boundimpl/GnunetChatBoundService.kt @@ -810,9 +810,15 @@ class GnunetChatBoundService( } override fun sendText(chatContext: ChatContext, text: String) { + val dto = chatContext.toDto() + Log.d( + TAG, + "sendText[client]: nativeCtxPtr=${dto.nativeContextPointer} " + + "userPtr=${dto.userPointer} textLen=${text.length}" + ) runBlocking { withReadyRemote(lastHandle) { remote, _ -> - remote.sendText(chatContext.toDto(), text) + remote.sendText(dto, text) } } } 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 @@ -68,7 +68,6 @@ class AccountListFragment : Fragment() { ): View { val view = inflater.inflate(R.layout.fragment_account_list, container, false) val activity = requireActivity() as MainActivity - val gnunetChat = activity.getGnunetChatInstance() recycler = view.findViewById(R.id.account_recycler) createButton = view.findViewById(R.id.btn_create_account) @@ -76,38 +75,17 @@ class AccountListFragment : Fragment() { statusText = view.findViewById(R.id.account_status_text) adapter = AccountAdapter { selectedAccount -> - val handle = activity.getChatHandle() - viewLifecycleOwner.lifecycleScope.launch { - // If user re-selects the already-connected account, skip the - // disconnect/reconnect cycle so we don't wipe in-memory messages. - val current = activity.currentAccount - val isSameAccount = current != null && current.name == selectedAccount.name - - if (!isSameAccount) { - try { - if (current != null) { - activity.clearChatState() - runCatching { gnunetChat.disconnect(handle) } - .onFailure { - Log.w(TAG, "disconnect before connect failed", it) - } - } - - gnunetChat.connect(handle, selectedAccount) - selectedAccount.key = gnunetChat.getProfileKey(handle) - activity.setCurrentAccount(selectedAccount) - } catch (t: Throwable) { - Log.e(TAG, "Connecting account failed", t) - showError(getString(R.string.account_connect_failed)) - return@launch - } - - try { - activity.loadChatsAndWait() - } catch (t: Throwable) { - Log.w(TAG, "Initial chat load failed, LOGIN will retry", t) - } + try { + // 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, "switchToSession failed", t) + showError(getString(R.string.account_connect_failed)) + return@launch } val action = diff --git a/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ui/chat/ChatFragment.kt b/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ui/chat/ChatFragment.kt @@ -223,6 +223,12 @@ class ChatFragment : Fragment(R.layout.fragment_chat) { type = ChatMessageType.OWN ) chatViewModel.addMessage(newMessage) + Log.d( + "ChatFragment", + "send: nativeCtxPtr=${chatContext.nativeContextPointer} " + + "userPtr=${chatContext.userPointer} isGroup=${chatContext.isGroup} " + + "textLen=${text.length}" + ) gnunetChat.sendText(chatContext, text) input.text.clear() } diff --git a/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ui/contact/LobbyJoinFragment.kt b/GNUnetMessenger/app/src/main/java/org/gnunet/gnunetmessenger/ui/contact/LobbyJoinFragment.kt @@ -76,7 +76,6 @@ class LobbyJoinFragment : Fragment() { viewLifecycleOwner.lifecycleScope.launch { try { gnunetChat.lobbyJoin(handle, lobbyId) - activity.loadChats() findNavController().popBackStack() } catch (t: Throwable) { android.util.Log.e("LobbyJoinFragment", "lobbyJoin failed", t) diff --git a/GNUnetMessenger/build.gradle.kts b/GNUnetMessenger/build.gradle.kts @@ -2,6 +2,6 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.android) apply false - id("androidx.navigation.safeargs") version "2.7.7" apply false + id("androidx.navigation.safeargs") version "2.9.6" apply false id("com.google.gms.google-services") version "4.4.2" apply false } \ No newline at end of file diff --git a/GNUnetMessenger/gradle.properties b/GNUnetMessenger/gradle.properties @@ -20,4 +20,14 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true -\ No newline at end of file +android.nonTransitiveRClass=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false +\ No newline at end of file diff --git a/GNUnetMessenger/gradle/libs.versions.toml b/GNUnetMessenger/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] -agp = "8.13.2" +agp = "9.2.0" cardview = "1.0.0" -kotlin = "2.2.0" +kotlin = "2.2.10" coreKtx = "1.10.1" junit = "4.13.2" junitVersion = "1.1.5" diff --git a/GNUnetMessenger/gradle/wrapper/gradle-wrapper.properties b/GNUnetMessenger/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Mar 25 19:27:57 CET 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/GNUnetMessenger/settings.gradle.kts b/GNUnetMessenger/settings.gradle.kts @@ -11,7 +11,7 @@ pluginManagement { gradlePluginPortal() } plugins { - id("androidx.navigation.safeargs.kotlin") version "2.7.7" + id("androidx.navigation.safeargs.kotlin") version "2.9.6" } } plugins {