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
This commit was merged in pull request #192.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
Reference in New Issue
Block a user