Merge pull request 'On-device tests for the app over an obfs4 bridge, and what they found' (#192) from test/the-app-over-a-bridge into main
ci / secret-sweep (push) Successful in 13s
ci / vectors-head (push) Successful in 4m48s
ci / unit-tests (push) Successful in 10m35s
ci / image (push) Successful in 11s

This commit was merged in pull request #192.
This commit is contained in:
2026-10-14 22:27:21 +00:00
6 changed files with 549 additions and 11 deletions
@@ -0,0 +1,369 @@
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.performScrollTo
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 androidx.test.rule.GrantPermissionRule
import io.flamenet.obfs4.Obfs4Stats
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* The app's own message, carried by an obfs4 bridge.
*
* 🔴 The claim this exists to back. The answers sent on 2026-09-28 said bridge
* users can reach the relay from anywhere. They could not: the bridge
* forwarded to plain HTTP while the app speaks TLS through the tunnel, so the
* handshake completed and every app session behind it died. The upstream was
* fixed on 2026-10-07 and checked with one GET, and flamenet-e2e's
* `test/bridge-message.mjs` later carried a real message through it from Node.
* None of that exercises THIS code: `Obfs4SocketFactory` under the app's own
* OkHttp, the sign-up, the device registration and the send.
*
* So the bridge goes into force before anything touches the network, and the
* handshake counter is asserted at zero first. A test that only checks the
* message arrived would pass with the bridge quietly unused, which is the
* exact failure the 09-28 answer made in prose.
*
* Needs a live bridge, a reachable relay and a peer account that already has a
* device, so it is opt-in:
*
* ./gradlew :app:connectedDebugAndroidTest \
* -Pandroid.testInstrumentationRunnerArguments.class=io.flamenet.messenger.app.BridgeMessageTest \
* -Pandroid.testInstrumentationRunnerArguments.bridge="obfs4 HOST:PORT FINGERPRINT cert=CERT iat-mode=0" \
* -Pandroid.testInstrumentationRunnerArguments.peer=<handle>
*/
@RunWith(AndroidJUnit4::class)
class BridgeMessageTest {
/**
* Granted BEFORE the activity launches, and that ordering is the point.
*/
@get:Rule(order = 0)
val permissions: GrantPermissionRule =
GrantPermissionRule.grant(
android.Manifest.permission.POST_NOTIFICATIONS,
)
@get:Rule(order = 1)
val compose = createAndroidComposeRule<MainActivity>()
private val args get() = InstrumentationRegistry.getArguments()
private val bridgeLine: String? get() = args.getString("bridge")
private val relay: String get() = args.getString("relay") ?: "relay1.flamenet.io"
/**
* Somebody with a published device, so a deposit has a destination.
*/
private val peerHandle: String? get() = args.getString("peer")
private lateinit var handle: String
/**
* The control. `-e direct true` runs the same path with no bridge, which
* is the only way to tell a flaky bridge from a flaky sign-up.
*/
private val direct: Boolean get() = args.getString("direct") == "true"
@Before
fun putTheBridgeInForceFirst() {
assumeTrue(
"pass -e bridge \"<obfs4 line>\" to run this; it needs a live bridge",
direct || !bridgeLine.isNullOrBlank(),
)
assumeTrue(
"pass -e peer <handle> to run this; the peer needs a published device",
!peerHandle.isNullOrBlank(),
)
handle = "andb" + (System.currentTimeMillis() % 100_000_000)
// The same call the Network screen's own button makes, so this is the
// app's setting path and not a test-only hook.
val settings = Obfs4Settings(InstrumentationRegistry.getInstrumentation().targetContext)
settings.clear()
if (!direct) {
val added = settings.add(bridgeLine!!)
if (added !is Obfs4Settings.AddResult.Added) {
throw AssertionError("the bridge line was refused: ${added.message}")
}
}
signOutIfSignedIn()
}
@Test
fun theAppSendsAMessageThroughTheBridge() {
assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable())
// Zeroed here and not in @Before, because signing out of whatever the
// last run left behind is itself a request, and with the bridge
// already in force that request is a handshake. The first run of this
// test failed on "the counter started at 1" for exactly that reason.
Obfs4Stats.reset()
assert(Obfs4Stats.completedHandshakes() == 0) {
"the counter would not zero: ${Obfs4Stats.completedHandshakes()}"
}
signUp()
val afterSignUp = Obfs4Stats.completedHandshakes()
if (direct) {
assert(afterSignUp == 0) { "the control run went through a bridge anyway" }
} else {
assert(afterSignUp > 0) {
"the sign-up and device registration completed without one obfs4 handshake, " +
"so the bridge was saved and bypassed"
}
}
compose.onNodeWithTag("add-contact").performClick()
compose.onNodeWithTag("new-contact").performTextInput(peerHandle!!)
// Adding is gated on a lookup, so a typo cannot become a contact. The
// lookup is itself a request through the bridge.
compose.onNodeWithTag("lookup-contact").performClick()
awaitTag("lookup-result", 30_000)
compose.onNodeWithTag("confirm-contact").performClick()
awaitNode(hasText(peerHandle!!, substring = true), 60_000)
compose.onNode(hasText(peerHandle!!, substring = true)).performClick()
awaitTag("draft", 20_000)
val text = "through a bridge from the app at ${System.currentTimeMillis() % 100000}"
compose.onNodeWithTag("draft").performTextInput(text)
compose.onNodeWithTag("send").performClick()
// The line appearing is NOT delivery. The app renders an outgoing
// message as soon as it is composed: a run that asserted only this
// reported OK while the relay never saw a POST /e2e/messages at all.
awaitNode(hasText(text, substring = true), 90_000)
compose.onNode(hasText(text, substring = true)).assertIsDisplayed()
// Delivery is the absence of both markers. The row carries `pending`
// while the message is still queued and `undelivered` when it failed,
// so a row with neither is one the relay accepted.
val deadline = System.currentTimeMillis() + 150_000
var queued = true
while (System.currentTimeMillis() < deadline) {
settle(1_000)
if (nodesWithTag("undelivered").isNotEmpty()) {
throw AssertionError("the message is marked undelivered. On screen: ${visibleText()}")
}
if (nodesWithTag("pending").isEmpty()) {
queued = false
break
}
}
assert(!queued) { "the message was still pending after 150 seconds" }
// The counter is the whole evidence, so say what it was at each
// stage rather than only that it moved.
val afterSend = Obfs4Stats.completedHandshakes()
assert(afterSend >= afterSignUp) {
"the handshake count went backwards, $afterSignUp then $afterSend"
}
android.util.Log.i(
"fnbridge",
"sent from $handle to ${peerHandle!!} through the bridge: $text " +
"($afterSend obfs4 handshakes, $afterSignUp of them before the send)",
)
}
/**
* One whole sign-up, driven the way a person drives it.
*/
private fun signUp() {
compose.onNodeWithTag("relay").performTextClearance()
compose.onNodeWithTag("relay").performTextInput(relay)
// The toggle is a toggle, so clicking it blindly is a coin flip, and
// one blind click put this test into sign-in mode: it filled a handle
// and a passphrase, pressed a button labelled "Sign in", and read the
// relay's refusal as "Your session expired". Drive it by what the
// screen says instead, and scroll first, because the keyboard opened
// by the relay field can leave the toggle outside the window where a
// tap lands nowhere and nothing reports it.
var attempts = 0
while (nodesWithText("Create an account").isNotEmpty() && attempts < 4) {
attempts++
compose.onNodeWithTag("toggle-register").performScrollTo()
compose.onNodeWithTag("toggle-register").performClick()
settle(2_000)
}
if (nodesWithText("Create an account").isNotEmpty()) {
throw AssertionError(
"the app stayed in sign-in mode after $attempts taps on the toggle. " +
"On screen: ${visibleText()}",
)
}
// Wait for discovery BEFORE touching the form. The relay says whether
// it keeps its own accounts, and the email field appears or disappears
// on that answer. Filling the form first put text into a field that
// then vanished, left a required field empty, and the submit button
// stayed disabled; a disabled submit swallows the tap in silence, so
// the first run of this test sat on the sign-up screen for its whole
// 180 seconds with nothing to show for it.
awaitTag("identity-mode", 60_000)
// Which fields exist is the relay's decision, so fill what is there.
// relay1 asks for neither an email nor a birth year, and typing into a
// field that is not on screen is an immediate "Failed to perform text
// input" that reads like a broken test.
typeIfPresent("email", "$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
typeIfPresent("birth-year", "1990")
// Scroll first: the app sits inside imePadding(), so an open keyboard
// shortens the viewport and the submit button is laid out below it.
compose.onNodeWithTag("submit").performScrollTo()
compose.onNodeWithTag("submit").performClick()
// Wait for the signed-in screen for the whole window, and read the
// error node only if it never comes.
//
// 🔴 An earlier version stopped at the first "error" node and reported
// a refusal. The relay's own log showed the opposite: signup 200,
// token 200, devices 200, identity 200. The app's first attempt
// through a cold obfs4 tunnel passed the 25-second callTimeout, put
// "The server did not answer" on screen, retried, and finished 97
// seconds later. Treating that message as final called a working
// sign-up a failure.
try {
awaitTag("nav-contacts", 240_000)
} catch (e: AssertionError) {
val shown =
runCatching {
compose.onAllNodes(hasTestTag("error")).fetchSemanticsNodes().size
}.getOrDefault(0)
throw AssertionError(
"sign-up never reached the signed-in screen ($shown error nodes on screen). " +
"On screen: ${visibleText()}",
e,
)
}
}
/** Let recomposition happen, with the clock driven by hand. */
private fun settle(millis: Long) {
compose.mainClock.autoAdvance = false
try {
val deadline = System.currentTimeMillis() + millis
while (System.currentTimeMillis() < deadline) {
compose.mainClock.advanceTimeBy(100)
Thread.sleep(50)
}
} finally {
compose.mainClock.autoAdvance = true
}
}
/** Fill a field the relay may or may not be asking for. */
private fun typeIfPresent(
tag: String,
text: String,
) {
if (nodesWithTag(tag).isNotEmpty()) {
compose.onNodeWithTag(tag).performTextInput(text)
}
}
/**
* Get to a signed-out app.
*/
private fun signOutIfSignedIn() {
awaitAny(listOf("relay", "nav-contacts", "nav-account", "back"), 30_000)
if (nodesWithTag("back").isNotEmpty()) {
compose.onNodeWithTag("back").performClick()
awaitTag("nav-contacts", 20_000)
}
if (nodesWithTag("nav-account").isEmpty()) return
compose.onNodeWithTag("nav-account").performClick()
awaitTag("sign-out", 20_000)
compose.onNodeWithTag("sign-out").performScrollTo()
compose.onNodeWithTag("sign-out").performClick()
awaitTag("relay", 30_000)
}
private fun awaitAny(
tags: List<String>,
timeoutMillis: Long,
) {
compose.mainClock.autoAdvance = false
try {
val deadline = System.currentTimeMillis() + timeoutMillis
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 nodesWithText(text: String): List<Any> =
runCatching {
compose.onAllNodes(hasText(text, substring = true)).fetchSemanticsNodes()
}.getOrDefault(emptyList())
private fun nodesWithTag(tag: String): List<Any> =
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 = 30)
}.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)
}
@@ -0,0 +1,124 @@
package io.flamenet.messenger.app
import io.flamenet.obfs4.BridgeLine
import io.flamenet.obfs4.BridgeLineResult
import io.flamenet.obfs4.Obfs4SocketFactory
import io.flamenet.obfs4.Obfs4Stats
import org.junit.Assume.assumeTrue
import org.junit.Test
import javax.net.ssl.SSLSocketFactory
/**
* Just the transport, so a failure says which layer failed.
*
* The whole-app test goes through the sign-up screen, and a refusal there
* reads as "The server did not answer", which covers a bridge that would not
* handshake and a relay that is down with the same sentence. This opens the
* tunnel by hand and speaks one HTTP request through it.
*/
class BridgeTransportProbeTest {
private val args get() = androidx.test.platform.app.InstrumentationRegistry.getArguments()
@Test
fun theDeviceCanReachTheRelayThroughTheBridge() {
val line = args.getString("bridge")
assumeTrue("pass -e bridge \"<obfs4 line>\" to run this", !line.isNullOrBlank())
val host = args.getString("relay") ?: "relay1.flamenet.io"
val parsed = BridgeLine.parse(line!!)
if (parsed !is BridgeLineResult.Ok) throw AssertionError("the line was refused: $parsed")
Obfs4Stats.reset()
val plain = Obfs4SocketFactory(parsed.bridge).createSocket()
plain.soTimeout = 30_000
plain.connect(java.net.InetSocketAddress(host, 443), 30_000)
assert(Obfs4Stats.completedHandshakes() == 1) {
"the socket connected without an obfs4 handshake"
}
// The app speaks TLS inside the tunnel, so this does too.
val tls =
(SSLSocketFactory.getDefault() as SSLSocketFactory)
.createSocket(plain, host, 443, true) as javax.net.ssl.SSLSocket
tls.soTimeout = 30_000
tls.startHandshake()
val cert = tls.session.peerCertificates.first() as java.security.cert.X509Certificate
assert(cert.subjectX500Principal.name.contains(host.substringAfter('.'))) {
"the certificate inside the tunnel was for ${cert.subjectX500Principal.name}"
}
tls.outputStream.write(
"GET /health HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n".toByteArray(),
)
tls.outputStream.flush()
val status = tls.inputStream.bufferedReader().readLine() ?: ""
tls.close()
assert(status.contains("200")) { "the relay answered: $status" }
android.util.Log.i(
"fnbridge",
"probe: $status through ${parsed.bridge.host}:${parsed.bridge.port}, " +
"cert ${cert.subjectX500Principal.name}",
)
}
/**
* Several tunnels at once, because the app opens several.
*
* 🔴 Why this case exists. One hand-made socket through the bridge is
* reliable: 1.3 seconds, every time. The app's sign-up through the same
* bridge is not. One run carried the whole chain and the relay logged
* signup, token, devices, identity, buddies and groups all 200. The next
* run put nothing on the relay and showed "The server did not answer".
* The app's OkHttp client opens more than one connection and each one
* needs its own obfs4 handshake, so this is the difference worth testing.
*/
@Test
fun fourTunnelsAtOnceAllReachTheRelay() {
val line = args.getString("bridge")
assumeTrue("pass -e bridge \"<obfs4 line>\" to run this", !line.isNullOrBlank())
val host = args.getString("relay") ?: "relay1.flamenet.io"
val parsed = BridgeLine.parse(line!!)
if (parsed !is BridgeLineResult.Ok) throw AssertionError("the line was refused: $parsed")
Obfs4Stats.reset()
val factory = Obfs4SocketFactory(parsed.bridge)
val results = java.util.Collections.synchronizedList(mutableListOf<String>())
val threads =
(1..4).map { n ->
Thread {
results +=
runCatching {
val plain = factory.createSocket()
plain.soTimeout = 40_000
plain.connect(java.net.InetSocketAddress(host, 443), 40_000)
val tls =
(SSLSocketFactory.getDefault() as SSLSocketFactory)
.createSocket(plain, host, 443, true) as javax.net.ssl.SSLSocket
tls.soTimeout = 40_000
tls.startHandshake()
tls.outputStream.write(
"GET /health HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n"
.toByteArray(),
)
tls.outputStream.flush()
val status = tls.inputStream.bufferedReader().readLine() ?: "(no line)"
tls.close()
"$n: $status"
}.getOrElse { "$n: FAILED ${it.javaClass.simpleName}: ${it.message}" }
}
}
threads.forEach { it.start() }
threads.forEach { it.join(90_000) }
android.util.Log.i("fnbridge", "four at once: ${results.joinToString(" | ")}")
val bad = results.filter { !it.contains("200") }
assert(bad.isEmpty()) {
"${bad.size} of 4 concurrent tunnels failed: ${bad.joinToString(" | ")}. " +
"${Obfs4Stats.completedHandshakes()} handshakes completed."
}
assert(Obfs4Stats.completedHandshakes() == 4) {
"4 sockets but ${Obfs4Stats.completedHandshakes()} handshakes"
}
}
}
@@ -33,6 +33,7 @@ import io.flamenet.e2e.Label
import io.flamenet.e2e.Vault
import io.flamenet.e2e.X25519
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -570,15 +571,41 @@ class MessengerViewModel(
_phase.value = Phase.SignedOut(busy = true)
viewModelScope.launch {
// A sign-in that cannot finish has to SAY so (android#19).
val result =
withTimeoutOrNull(SIGN_IN_TIMEOUT_MS) {
withContext(Dispatchers.IO) {
runCatching { authenticate(config, handle, passphrase, register, email) }
}
} ?: Result.failure(
IllegalStateException(str(R.string.core_server_did_not_answer)),
)
// 🔴 The work runs in its own job so the timeout cannot throw away
// a sign-up that actually completed.
//
// It used to be `withTimeoutOrNull { withContext(IO) { ... } }`.
// `authenticate` is blocking socket work, so cancelling the
// coroutine does not stop it: on a slow network the timeout fired,
// the screen said the server did not answer, and the registration
// carried on and finished. One emulator run left the account
// `andb7564513` on relay1 with a published identity and a device,
// and the person looking at the phone was told the server was
// unreachable. The relay's log showed signup 200 in 397 ms and the
// next call 115 seconds later.
val work =
async(Dispatchers.IO) {
runCatching { authenticate(config, handle, passphrase, register, email) }
}
// A sign-in that cannot finish has to SAY so (android#19), so the
// first window still reports, but as "still trying" rather than as
// a failure, and the work keeps running behind it.
var result = withTimeoutOrNull(SIGN_IN_TIMEOUT_MS) { work.await() }
if (result == null) {
_phase.value = Phase.SignedOut(busy = true, error = str(R.string.core_still_trying))
result =
withTimeoutOrNull(SIGN_IN_PATIENCE_MS) { work.await() }
?: run {
// Now it really has not finished. Stop waiting, and
// stop the work too, so a later success cannot
// surprise somebody who has moved on.
work.cancel()
Result.failure(
IllegalStateException(str(R.string.core_server_did_not_answer)),
)
}
}
result.fold(
onSuccess = {
@@ -5242,10 +5269,20 @@ class MessengerViewModel(
companion object {
/**
* How long sign-in may take before it reports failure (android#19).
* How long sign-in may take before it says anything (android#19).
*/
private const val SIGN_IN_TIMEOUT_MS = 60_000L
/**
* How much longer it keeps waiting after saying so.
*
* A sign-up on a slow link, or through an obfs4 bridge on an emulator,
* has taken 115 seconds between the relay accepting the account and
* the app's next call. Giving up at 60 and discarding the result left
* people with an account they were told did not exist.
*/
private const val SIGN_IN_PATIENCE_MS = 180_000L
/** How long to wait for a relay to describe itself before saying it did not. */
private const val RELAY_PROBE_TIMEOUT_MS = 15_000L
@@ -6,6 +6,7 @@
<resources>
<string name="core_signed_in_on_this_device">Sesión iniciada en este dispositivo.</string>
<string name="core_not_a_server_address">Eso no parece la dirección de un servidor.</string>
<string name="core_still_trying">Seguimos esperando al servidor. Puede tardar un minuto con una conexión lenta o a través de un puente.</string>
<string name="core_server_did_not_answer">El servidor no respondió. Puede que esté caído o que la dirección esté mal: revísala e inténtalo de nuevo.</string>
<string name="core_registered_with_prekeys">Registro hecho, con %1$d claves previas de un solo uso publicadas.</string>
<string name="core_look_the_person_up_first">Primero busca a la persona.</string>
+1
View File
@@ -6,6 +6,7 @@
<resources>
<string name="core_signed_in_on_this_device">Signed in on this device.</string>
<string name="core_not_a_server_address">That doesn\'t look like a server address.</string>
<string name="core_still_trying">Still waiting for the server. This can take a minute on a slow connection or through a bridge.</string>
<string name="core_server_did_not_answer">The server did not answer. It may be down, or the address may be wrong, check it and try again.</string>
<string name="core_registered_with_prekeys">Registered with %1$d one-time prekeys published.</string>
<string name="core_look_the_person_up_first">Look the person up first.</string>
@@ -36,8 +36,14 @@ class InstrumentedSuitesCanSignUpTest {
for (f in suites()) {
val body = f.readText()
if (!body.contains("""onNodeWithTag("submit").performClick()""")) continue
// Either the direct call or a helper that names the tag. relay1
// does not ask for a birth year at all, so a suite driving it has
// to fill the field only when the relay put it on screen, and
// `typeIfPresent("birth-year", ...)` is that. The rule is still
// "the age gate is handled", which is what android#21 was about.
assertTrue(
body.contains("""onNodeWithTag("birth-year")"""),
body.contains("""onNodeWithTag("birth-year")""") ||
body.contains("""typeIfPresent("birth-year"""),
"${f.name} taps submit without filling birth-year. Submit is disabled " +
"until the age gate has four digits and performClick on a disabled " +
"node does nothing, so the run fails later looking for a screen the " +