diff --git a/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt new file mode 100644 index 0000000..f34c7d2 --- /dev/null +++ b/app/src/androidTest/kotlin/io/flamenet/messenger/app/CrossPlatformTest.kt @@ -0,0 +1,153 @@ +package io.flamenet.messenger.app + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.printToString +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The Android half of a conversation with a client that is not Android. + * + * ⚠️ **Driven by arguments, and deliberately NOT self-contained.** Each step is + * its own test so the two platforms can be interleaved from a shell: + * + * -e handle andcross123 → sign up under a name iOS can address + * -e peer ioscross456 -e expect "hello from iOS" → wait for their message + * -e peer ioscross456 -e reply "hello back" → answer + * + * ⚠️ Run with `adb shell am instrument`, **not** `connectedAndroidTest`, which + * uninstalls the app afterwards and takes the vault — and therefore the account + * — with it. The whole point is that the account survives between steps. + */ +@RunWith(AndroidJUnit4::class) +class CrossPlatformTest { + + @get:Rule val compose = createAndroidComposeRule() + + private val args get() = InstrumentationRegistry.getArguments() + private val relay: String get() = args.getString("relay") ?: "relay1.flamenet.io" + + @Test + fun signUpUnderAKnownHandle() { + val handle = args.getString("handle") ?: return + settle() + if (nodesWithTag("nav-contacts").isNotEmpty()) return // already signed in + + compose.onNodeWithTag("relay").performTextClearance() + compose.onNodeWithTag("relay").performTextInput(relay) + compose.onNodeWithTag("toggle-register").performClick() + compose.onNodeWithTag("email").performTextInput("$handle@example.invalid") + compose.onNodeWithTag("handle").performTextInput(handle) + compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase") + compose.onNodeWithTag("submit").performClick() + awaitTag("nav-contacts", 150_000) + android.util.Log.i("fnxplat", "ANDROID_HANDLE=$handle") + } + + /** Wait for a message from a named peer to appear in their thread. */ + @Test + fun expectAMessage() { + val peer = args.getString("peer") ?: return + val expected = args.getString("expect") ?: return + settle() + openThreadWith(peer) + awaitNode(hasText(expected, substring = true), 180_000) + compose.onNode(hasText(expected, substring = true)).assertIsDisplayed() + android.util.Log.i("fnxplat", "ANDROID_RECEIVED=$expected") + } + + /** Reply to a named peer. */ + @Test + fun replyToPeer() { + val peer = args.getString("peer") ?: return + val body = args.getString("reply") ?: return + settle() + openThreadWith(peer) + compose.onNodeWithTag("draft").performTextInput(body) + compose.onNodeWithTag("send").performClick() + awaitNode(hasText(body, substring = true), 60_000) + android.util.Log.i("fnxplat", "ANDROID_SENT=$body") + } + + // -- Helpers ------------------------------------------------------------- + + /** + * Open the thread with `peer`, adding them if they are not a contact yet. + * + * ⚠️ The peer may already be a contact from an earlier step — this whole + * class is designed to be run repeatedly against a surviving account, so + * "add" has to be conditional rather than assumed. + */ + private fun openThreadWith(peer: String) { + if (nodesWithTag("back").isNotEmpty()) return // already in a thread + if (compose.onAllNodes(hasText(peer, substring = true)).fetchSemanticsNodes().isEmpty()) { + compose.onNodeWithTag("add-contact").performClick() + compose.onNodeWithTag("new-contact").performTextInput(peer) + compose.onNodeWithTag("confirm-contact").performClick() + awaitNode(hasText(peer, substring = true), 90_000) + } + compose.onAllNodes(hasText(peer, substring = true)).fetchSemanticsNodes() + compose.onNode(hasText(peer, substring = true)).performClick() + awaitTag("draft", 30_000) + } + + /** The app reads its vault behind a spinner on launch; decide nothing before that. */ + private fun settle() { + val tags = listOf("relay", "nav-contacts", "nav-account", "back") + compose.mainClock.autoAdvance = false + try { + val deadline = System.currentTimeMillis() + 30_000 + while (System.currentTimeMillis() < deadline) { + compose.mainClock.advanceTimeBy(100) + if (tags.any { nodesWithTag(it).isNotEmpty() }) return + Thread.sleep(100) + } + } finally { + compose.mainClock.autoAdvance = true + } + } + + private fun nodesWithTag(tag: String): List = runCatching { + compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes() + }.getOrDefault(emptyList()) + + private fun awaitTag(tag: String, timeoutMillis: Long) = + awaitNode(hasTestTag(tag), timeoutMillis) + + private fun awaitNode( + matcher: androidx.compose.ui.test.SemanticsMatcher, + timeoutMillis: Long, + ) { + compose.mainClock.autoAdvance = false + try { + val deadline = System.currentTimeMillis() + timeoutMillis + while (System.currentTimeMillis() < deadline) { + compose.mainClock.advanceTimeBy(100) + val found = runCatching { + compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() + }.getOrDefault(false) + if (found) return + Thread.sleep(100) + } + throw AssertionError( + "nothing matched within ${timeoutMillis}ms. On screen: " + visibleText()) + } finally { + compose.mainClock.autoAdvance = true + } + } + + private fun visibleText(): String = runCatching { + compose.onRoot().printToString(maxDepth = 40) + }.getOrElse { "(could not read the screen: $it)" } +} diff --git a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt index ce6cc6b..3f8b5df 100644 --- a/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt +++ b/client/src/main/kotlin/io/flamenet/client/MessengerApi.kt @@ -118,12 +118,12 @@ class MessengerApi( data class Me(val userId: Long, val displayName: String, val handle: String) - fun me(): Me = raw("api/wp/v2/users/me", "GET", null).let { + fun me(): Me = raw("api/flamenet/v1/messenger/me", "GET", null).let { Me(it.getLong("id"), it.optString("name"), it.optString("slug")) } fun updateDisplayName(name: String) { - raw("api/wp/v2/users/me", "POST", JSONObject().put("name", name)) + raw("api/flamenet/v1/messenger/me", "PUT", JSONObject().put("name", name)) } /** @@ -133,7 +133,7 @@ class MessengerApi( * request 401s for no visible reason. */ fun updatePassword(password: String) { - raw("api/wp/v2/users/me", "POST", JSONObject().put("password", password)) + raw("api/flamenet/v1/messenger/me", "PUT", JSONObject().put("password", password)) } /** Irreversible, and on the relay side only — the vault is erased separately. */ @@ -143,7 +143,7 @@ class MessengerApi( /** Exact-username lookup. Deliberately not a directory: no listing exists. */ fun lookup(username: String): Me? = runCatching { - raw("api/wp/v2/users", "GET", null, query = mapOf("slug" to username)).let { + raw("api/flamenet/v1/messenger/users", "GET", null, query = mapOf("slug" to username)).let { Me(it.getLong("id"), it.optString("name"), it.optString("slug")) } }.getOrNull() @@ -166,6 +166,7 @@ class MessengerApi( when (method) { "GET" -> builder.get() "DELETE" -> builder.delete() + "PUT" -> builder.put((body ?: JSONObject()).toString().toRequestBody(JSON)) else -> builder.post((body ?: JSONObject()).toString().toRequestBody(JSON)) } return send(builder) diff --git a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt index d26f86d..05a15d0 100644 --- a/client/src/main/kotlin/io/flamenet/client/RelayClient.kt +++ b/client/src/main/kotlin/io/flamenet/client/RelayClient.kt @@ -25,7 +25,7 @@ import java.util.concurrent.TimeUnit * HTTP server rather than a fake that agrees with this file by construction. * * Two authentication modes, discovered rather than configured, so one build - * talks to a Swift relay and to an older WordPress deployment. See [RelayAuth]. + * talks to a relay that mints tokens and to one that does not. See [RelayAuth]. */ class RelayClient( baseUrl: String,