diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt index 0efdbc7..b40a6c8 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt @@ -339,10 +339,23 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { describeJob = viewModelScope.launch { kotlinx.coroutines.delay(400) val config = SiteConfig.from(value) ?: return@launch - val found = withContext(Dispatchers.IO) { - io.flamenet.client.RelayDiscovery.describe(config.baseUrl) + // ⚠️ **Bounded.** `describe` has no deadline of its own, and a + // host that accepts a connection and never answers would leave the + // sign-up form on "Checking what this server uses…" indefinitely. + // Fifteen seconds is far longer than any healthy relay needs. + val found = withTimeoutOrNull(RELAY_PROBE_TIMEOUT_MS) { + withContext(Dispatchers.IO) { + io.flamenet.client.RelayDiscovery.describe(config.baseUrl) + } } if (_relayInput.value != value) return@launch + if (found == null) { + // Say so rather than leaving the spinner-in-words up. The + // commonest cause is an http:// address, which Android refuses + // outright, so the message names it. + _site.value = config.copy(probeFailed = true) + return@launch + } // copy(), not a mutation: SiteConfig is a data class and the flow // conflates on equality, so an in-place write is indistinguishable // from the value already there and never reaches the UI. @@ -3570,6 +3583,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) { */ private const val SIGN_IN_TIMEOUT_MS = 60_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 + /** * The presence chips, in order. * diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/SiteConfig.kt b/app/src/main/kotlin/io/flamenet/messenger/app/SiteConfig.kt index 54e29e1..b46dbff 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/SiteConfig.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/SiteConfig.kt @@ -29,6 +29,18 @@ data class SiteConfig( * it with `copy(...)`. */ val discovered: io.flamenet.client.RelayDiscovery.Descriptor? = null, + /** + * The probe was made and came back with nothing. + * + * 🔴 **Without this, "still asking" and "asked and could not reach it" are + * the same state.** `discovered` is null in both, so a relay that never + * answers leaves "Checking what this server uses…" on screen forever and + * the form permanently unusable — no error, no retry, nothing to act on. + * Seen 2026-09-05 pointing the app at a plain-HTTP relay, which Android + * blocks outright from API 28: the request never left, and the screen said + * it was still thinking about it. + */ + val probeFailed: Boolean = false, ) { enum class AccountProvider { @@ -95,6 +107,9 @@ data class SiteConfig( /** What to tell the user this relay does about identity, in their words. */ val identityDescription: String get() = when { + probeFailed -> + "Couldn't reach that server. Check the address — and note it has " + + "to be https, not http." discovered == null -> "Checking what this server uses…" accountProvider == AccountProvider.SHARED -> "One Flamenet account, shared with Flamenet Files." diff --git a/scripts/instrumented.sh b/scripts/instrumented.sh new file mode 100755 index 0000000..785c825 --- /dev/null +++ b/scripts/instrumented.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Run the on-device suites against a relay you named, on an emulator with no +# history. +# +# 🔴 **Two ways an instrumented run silently points at PRODUCTION, and this +# script closes both.** +# +# 1. The suites DEFAULT to a live host when no relay argument is passed — +# `FeatureUseTest` to relay1.flamenet.io, `LiveSignUpFleetTest` to +# relay.flamenet.io. A plain `./gradlew connectedAndroidTest` therefore +# signs accounts up on somebody's real server, and nothing in the output +# says so. +# 2. A SAVED EMULATOR SNAPSHOT restores whatever the AVD was last doing. +# Observed 2026-09-05: a snapshot signed in as `fnreviewbeta` against +# relay.flamenet.io, restored under a test run that had been told to use a +# local relay. Every one of the ten tests failed — they could not find the +# sign-up screen, because the app was already signed in — and the reason +# was invisible until the failure's screen dump was read. +# +# Usage: scripts/instrumented.sh 10.0.2.2:18810 [ClassName ...] +set -euo pipefail + +RELAY="${1:-}" +[ -n "$RELAY" ] || { + cat >&2 <<'MSG' +usage: scripts/instrumented.sh [TestClass ...] + +Name the relay. There is deliberately no default: the suites have their own, +and theirs are live servers. + + 10.0.2.2:18810 the host machine's local relay, as seen from the emulator +MSG + exit 2 +} +shift || true + +# 🔴 Refused rather than warned about. A run that creates accounts on the +# production relay cannot be undone by noticing afterwards. +case "$RELAY" in + *relay.flamenet.io*|*relay1.flamenet.io*) + echo "REFUSED: $RELAY is a live relay. These suites create accounts." >&2 + echo "Point them at a local relay, or edit this check if you truly mean it." >&2 + exit 1;; +esac + +CLASSES="${*:-io.flamenet.messenger.app.FeatureUseTest,io.flamenet.messenger.app.CrudSurfaceTest,io.flamenet.messenger.app.ChatFlowTest}" +CLASSES="${CLASSES// /,}" + +export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}" +export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@21}" +ADB="$ANDROID_HOME/platform-tools/adb" +AVD="${FN_AVD:-fnmsg-android-test}" + +# ⚠️ `-no-snapshot-load` is the point of this block, and `-no-snapshot-save` +# keeps the next run clean too. A warm boot is faster and carries the last +# session's account with it. +if ! "$ADB" shell getprop sys.boot_completed 2>/dev/null | grep -q 1; then + echo "### cold-booting $AVD (no snapshot)" + nohup "$ANDROID_HOME/emulator/emulator" -avd "$AVD" \ + -no-snapshot-load -no-snapshot-save -no-boot-anim \ + -netdelay none -netspeed full >/tmp/fnmsg-emu.log 2>&1 & + until "$ADB" shell getprop sys.boot_completed 2>/dev/null | grep -q 1; do sleep 5; done +else + echo "### emulator already up — clearing the app rather than trusting it" + "$ADB" uninstall io.flamenet.messenger >/dev/null 2>&1 || true + "$ADB" uninstall io.flamenet.messenger.test >/dev/null 2>&1 || true +fi + +echo "### signed-in account on the device before we start:" +"$ADB" shell pm list packages 2>/dev/null | grep -i flamenet || echo " (nothing installed — good)" + +echo "### relay $RELAY · classes $CLASSES" +set +e +./gradlew --no-daemon :app:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.relay="$RELAY" \ + -Pandroid.testInstrumentationRunnerArguments.class="$CLASSES" 2>&1 \ + | tee /tmp/fnmsg-instrumented.log \ + | grep -E "FAILED|PASSED|SKIPPED|BUILD|Finished [0-9]+ test|Tests on" +STATUS=${PIPESTATUS[0]} +set -e + +# 🔴 **A skip is not a pass.** Every one of these suites opens with +# `assumeTrue(relayReachable())`, so an unreachable relay produces a green +# build that exercised nothing. Say which happened. +SKIPS=$(grep -c "SKIPPED" /tmp/fnmsg-instrumented.log || true) +RAN=$(grep -oE "Finished [0-9]+ test" /tmp/fnmsg-instrumented.log | tail -1 || echo "") +echo +echo "### $RAN, $SKIPS skipped" +if [ "$SKIPS" != "0" ] && [ "$STATUS" = "0" ]; then + echo "### VERDICT: SKIPPED — the relay was unreachable. This is NOT a pass." + exit 3 +elif [ "$STATUS" = "0" ]; then + echo "### VERDICT: PASSED on device" +else + echo "### VERDICT: FAILED — see /tmp/fnmsg-instrumented.log" + echo "### and app/build/reports/androidTests/connected/debug/index.html" +fi +exit $STATUS