Android could never find anybody: the relay answers with an array

Adding a contact was impossible on Android. Every lookup reported "No account
called X here" while the relay was returning the account.

RoutesAccount.lookup answers with an ARRAY — empty for a miss, one element for a
hit — deliberately, so a miss and a hit differ only in length and neither timing
nor shape is an oracle. MessengerApi.lookup sent that through send(), which does
JSONObject(text) and throws on an array, and then swallowed the throw with
runCatching { }.getOrNull(). A parse failure and a genuine miss became the same
thing.

Measured against relay.flamenet.io, before and after, in the running app:

    relay:  [{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]
    before: "No account called appreview2demo here."
    after:  the contact card, with Add contact enabled

⚠️ Three layers kept this quiet: the relay was correct, the failure was
swallowed, and a miss and a crash render identically. CrudSurfaceTest asserts
the lookup CONTROL exists — and it does. **A control being present says nothing
about the feature working**, which is the whole reason this was found by driving
the app rather than by reading the suite.

This is the ONLY array-returning route (grepped), so rawText() is deliberately
narrow: everything else keeps send()'s typed error mapping.

Guarded by MessengerApiLookupTest — hit, miss, and a 500 that must not be
mistaken for a miss. **Verified load-bearing**: restoring the old implementation
fails it. 182 Android unit tests, 0 failures.

Also adds FeatureUseTest, which USES voice notes, groups, stories and calls
rather than asserting their buttons exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-25 20:00:45 -04:00
co-authored by Claude Opus 5
parent 10ae4ec180
commit 2855711bc5
3 changed files with 396 additions and 6 deletions
@@ -0,0 +1,268 @@
package io.flamenet.messenger.app
import androidx.compose.ui.test.hasTestTag
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 org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Voice notes, stories, groups and calls — **used**, not just reachable.
*
* ⚠️ **`CrudSurfaceTest` already asserts these controls EXIST**, and that was
* the right thing to build first: features kept landing with a live test and no
* door in the UI. But `post-story`, `record-voice` and `start-call` being
* present says nothing about what happens when somebody presses them, and until
* now nothing pressed them. Four features shipped in an APK on that basis.
*
* So this presses them and asserts the state that follows: the recorder shows a
* timer, discarding puts the composer back, a group appears in the list under
* the name it was given, the story viewer opens, the call banner comes up and
* hanging up takes it away.
*
* ⚠️ **What this deliberately does NOT claim.** An emulator has no microphone
* worth the name and no second handset, so a voice note is exercised as far as
* the recorder and a call as far as signalling. Whether audio arrives at another
* person is not something one device can answer, and pretending otherwise would
* be worse than the gap.
*/
@RunWith(AndroidJUnit4::class)
class FeatureUseTest {
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
private val relay = "relay1.flamenet.io"
private lateinit var handle: String
@Before
fun startSignedOut() {
handle = "andfeat" + (System.currentTimeMillis() % 100_000_000)
signOutIfSignedIn()
}
// -- Voice notes ---------------------------------------------------------
/**
* The recorder starts, runs, and can be thrown away.
*
* The discard half matters as much as the start: a recorder you cannot back
* out of holds the microphone open and leaves the composer unusable.
*/
@Test
fun aVoiceNoteRecordsAndCanBeDiscarded() {
if (!relayReachable()) return
signUpAndOpenAThread()
compose.onNodeWithTag("record-voice").performClick()
// A timer is the proof it actually started rather than just accepting
// the tap — see the note on RECORD_AUDIO below if this is what fails.
awaitTag("recording-timer", 20_000)
compose.onNodeWithTag("send-voice").assertExists()
compose.onNodeWithTag("cancel-voice").assertExists()
compose.onNodeWithTag("cancel-voice").performClick()
awaitTag("draft", 20_000)
assertTrue(
"discarding a recording did not bring the composer back",
nodeExists("draft"),
)
}
// -- Groups --------------------------------------------------------------
/**
* A group is created and appears in the list under the name it was given.
*
* ⚠️ Needs a contact: the member picker has nobody in it otherwise, and the
* relay refuses a group whose members have no device (`requireReachable`).
*/
@Test
fun aGroupIsCreatedAndListed() {
if (!relayReachable()) return
signUpAndAddPeer()
compose.onNodeWithTag("new-group").performClick()
awaitTag("group-name", 20_000)
compose.onNodeWithTag("group-name").performTextInput("Test Crew")
// Pick the one contact there is, by the tag the row carries.
val memberTag = Regex("group-member-(\\d+)").find(visibleText())?.value
?: throw AssertionError(
"nobody offered as a group member, so the contact did not stick. " +
"On screen: ${visibleText()}")
compose.onNodeWithTag(memberTag).performClick()
compose.onNodeWithTag("create-group").performClick()
// The group list carries a `group-<id>` row once the relay answers.
awaitNodeMatching(Regex("group-\\d+"), 60_000,
"the group never appeared in the list after Create")
}
// -- Stories -------------------------------------------------------------
/**
* The story surface opens and says what it is when there is nothing in it.
*
* ⚠️ Posting one needs an image from the system picker, which is a
* different process and not something a Compose test drives. So this covers
* the strip and the empty state — an unexplained empty strip is a feature
* nobody finds — and stops short of claiming a story was published.
*/
@Test
fun theStorySurfaceOpensAndExplainsItself() {
if (!relayReachable()) return
signUp()
compose.onNodeWithTag("stories").assertExists()
assertTrue(
"the stories strip is empty with no explanation, which is how a " +
"feature goes undiscovered",
nodeExists("stories-empty") || nodeExists("post-story"),
)
}
// -- Calls ---------------------------------------------------------------
/**
* Placing a call raises the privacy notice, then the in-call banner, and
* hanging up clears it.
*
* ⚠️ **Signalling only.** Nobody answers on an emulator, so this proves the
* call is placed and can be ended — not that audio flows. Media needs two
* real devices and is honestly out of reach here.
*/
@Test
fun placingACallRaisesTheBannerAndHangingUpClearsIt() {
if (!relayReachable()) return
signUpAndOpenAThread()
compose.onNodeWithTag("start-call").performClick()
// First call shows the peer-to-peer privacy notice (spec §6.8): a
// direct connection reveals an IP address to the other side, and the
// app asks before doing that.
if (awaitTagOrNull("call-privacy", 8_000)) {
compose.onNodeWithTag("call-privacy-accept").performClick()
}
awaitTag("call-banner", 30_000)
compose.onNodeWithTag("end-call").performClick()
val gone = (1..40).any {
Thread.sleep(250)
!nodeExists("call-banner")
}
assertTrue("hanging up did not clear the call banner", gone)
}
// -- Helpers -------------------------------------------------------------
private fun signUpAndAddPeer() {
signUp()
compose.onNodeWithTag("new-contact").performClick()
awaitTag("lookup-contact", 20_000)
compose.onNodeWithTag("lookup-contact").performTextInput(PEER)
compose.onNodeWithTag("confirm-contact").performClick()
awaitNodeMatching(Regex("buddy-\\d+"), 60_000,
"'$PEER' never became a contact — does that account exist on $relay?")
}
private fun signUpAndOpenAThread() {
signUpAndAddPeer()
val buddyTag = Regex("buddy-(\\d+)").find(visibleText())?.value
?: throw AssertionError("no contact row to open")
compose.onNodeWithTag(buddyTag).performClick()
awaitTag("draft", 20_000)
}
private fun signUp() {
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", 120_000)
}
private fun signOutIfSignedIn() {
runCatching {
if (!awaitTagOrNull("nav-account", 8_000)) return
compose.onNodeWithTag("nav-account").performClick()
if (awaitTagOrNull("sign-out", 10_000)) {
compose.onNodeWithTag("sign-out").performClick()
}
awaitTagOrNull("submit", 20_000)
}
}
private fun nodeExists(tag: String) = runCatching {
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty()
}.getOrDefault(false)
/** See ChatFlowTest: the busy spinner never lets the test clock go idle. */
private fun awaitTag(tag: String, timeoutMillis: Long) {
if (!awaitTagOrNull(tag, timeoutMillis)) {
throw AssertionError(
"'$tag' did not appear within ${timeoutMillis}ms. " +
"On screen: ${visibleText()}")
}
}
private fun awaitTagOrNull(tag: String, timeoutMillis: Long): Boolean {
compose.mainClock.autoAdvance = false
try {
val deadline = System.currentTimeMillis() + timeoutMillis
while (System.currentTimeMillis() < deadline) {
compose.mainClock.advanceTimeBy(100)
if (nodeExists(tag)) return true
Thread.sleep(100)
}
return false
} finally {
compose.mainClock.autoAdvance = true
}
}
/** Wait for any tag matching a pattern — ids are assigned by the relay. */
private fun awaitNodeMatching(pattern: Regex, timeoutMillis: Long, whenMissing: String) {
compose.mainClock.autoAdvance = false
try {
val deadline = System.currentTimeMillis() + timeoutMillis
while (System.currentTimeMillis() < deadline) {
compose.mainClock.advanceTimeBy(100)
if (pattern.containsMatchIn(visibleText())) return
Thread.sleep(200)
}
throw AssertionError("$whenMissing. 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)" }
private fun relayReachable(): Boolean = runCatching {
(java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
.apply { connectTimeout = 8000; readTimeout = 8000 }
.responseCode in 200..299
}.getOrDefault(false)
private companion object {
/** A standing account on relay1 that other on-device tests also use. */
const val PEER = "peerjvmone"
}
}