Merge branch 'tooling/android-signin-driver' into 'main'

An Android sign-in driver (ios#7)

See merge request paulhitt/flamenet-messenger-ios!125
This commit was merged in pull request #203.
This commit is contained in:
Paul Hitt
2026-09-05 13:36:24 +00:00
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env bash
# Sign IN on a booted Android emulator, through the app's own UI.
#
# FN_PASS=… Scripts/android-signin.sh relay.flamenet.io fnreviewbeta
#
# The sibling of `android-signup.sh`, and it exists for the same reason: the
# Android half of a cross-platform check has to be driven by something, and
# driving it by hand is where the mistakes live. Sign-IN specifically, because
# signing up on the live relay leaves a real account behind and spends the
# account service's three-per-hour-per-IP budget.
#
# 🔴 **Three traps, all of which cost me an hour on 2026-09-05, all encoded
# below. None is obvious and every one fails SILENTLY.**
#
# 1. `input text` drops characters on longer strings. A 21-character
# passphrase arrived as 11. Nothing reports it; the sign-in simply fails
# as though the password were wrong. So: chunks, and read back the count.
#
# 2. The soft keyboard covers the button. A tap at the button's coordinates
# lands on a KEY instead — which types a character into the field you just
# filled, so the passphrase grows by one every time you "press" sign in.
# So: dismiss the keyboard first, and verify the field afterwards.
#
# 3. Dismissing the keyboard MOVES the button. Coordinates read before the
# dismissal are stale by the time you tap them, and the tap lands on
# nothing at all — no error, no network, no clue. So: re-dump immediately
# before every tap, never reuse a position across an interaction.
#
# ⚠️ Prints FAIL with a reason, never a bare non-zero. A driver that fails
# quietly is worse than no driver: the run continues against a screen nobody
# looked at.
set -uo pipefail
ADB=~/Library/Android/sdk/platform-tools/adb
HOST="${1:?usage: android-signin.sh <host> <handle>}"
HANDLE="${2:?usage: android-signin.sh <host> <handle>}"
# 🔴 Not in the repo, and not defaulted. This repository is public, and a
# default here would be a live passphrase anybody can read (ios#62).
PASS="${FN_PASS:?set FN_PASS}"
PKG=io.flamenet.messenger
dump() { $ADB shell uiautomator dump /sdcard/s.xml >/dev/null 2>&1; $ADB shell cat /sdcard/s.xml 2>/dev/null; }
# Centre of the first node whose text matches, from a FRESH dump. Trap 3.
tap_text() {
local want="$1" d b x y
d=$(dump)
b=$(printf '%s' "$d" | tr '<' '\n<' | grep "text=\"$want\"" \
| grep -oE 'bounds="\[[0-9]+,[0-9]+\]\[[0-9]+,[0-9]+\]"' | head -1)
[ -z "$b" ] && return 1
x=$(printf '%s' "$b" | sed -E 's/.*\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\].*/\1 \3/' | awk '{print int(($1+$2)/2)}')
y=$(printf '%s' "$b" | sed -E 's/.*\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\].*/\2 \4/' | awk '{print int(($1+$2)/2)}')
$ADB shell input tap "$x" "$y" >/dev/null 2>&1
sleep 2
}
# Tap the control carrying a given label, and refuse to tap a moving target.
#
# 🔴 **Trap 3, in its worst form.** A fresh dump is not enough on a slow
# emulator: the layout can still be settling when the tap is issued, and the
# tap then lands on whatever slid into that position. That is not theoretical —
# it put this driver on the SIGN-UP form, one field away from creating an
# account on the production relay, while every coordinate it had read was
# correct at the moment it read them.
#
# So: read the bounds twice, a second apart, and only tap when they agree.
# A control that is still moving is a control this driver refuses to touch.
tap_label() {
local want="$1" a b x y
bounds_of() {
dump | tr '<' '\n<' | grep "text=\"$1\"" \
| grep -oE 'bounds="\[[0-9]+,[0-9]+\]\[[0-9]+,[0-9]+\]"' | head -1
}
a=$(bounds_of "$want"); sleep 1; b=$(bounds_of "$want")
[ -z "$a" ] && { echo "FAIL '$want' is not on screen"; return 1; }
[ "$a" != "$b" ] && { echo "FAIL '$want' is still moving — refusing to tap"; return 1; }
x=$(printf '%s' "$a" | sed -E 's/.*\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\].*/\1 \3/' | awk '{print int(($1+$2)/2)}')
y=$(printf '%s' "$a" | sed -E 's/.*\[([0-9]+),([0-9]+)\]\[([0-9]+),([0-9]+)\].*/\2 \4/' | awk '{print int(($1+$2)/2)}')
$ADB shell input tap "$x" "$y" >/dev/null 2>&1
sleep 2
}
# Kept for screens whose control has no label of its own.
tap_button() {
local index="$1" d
d=$(dump)
printf '%s' "$d" > /tmp/fn-andui.xml
local xy
xy=$(python3 - "$index" <<'PY'
import re, sys
idx = int(sys.argv[1])
s = open('/tmp/fn-andui.xml', encoding='utf-8', errors='replace').read()
hits = []
for n in re.findall(r'<node[^>]*>', s):
if 'clickable="true"' not in n or 'EditText' in n:
continue
b = re.search(r'bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', n)
if b:
hits.append(((int(b.group(1)) + int(b.group(3))) // 2,
(int(b.group(2)) + int(b.group(4))) // 2))
print(f"{hits[idx][0]} {hits[idx][1]}" if idx < len(hits) else "")
PY
)
[ -z "$xy" ] && return 1
$ADB shell input tap $xy >/dev/null 2>&1
sleep 2
}
# How many characters are actually in the passphrase field. Trap 1.
dots() {
dump > /tmp/fn-andui.xml
python3 -c "
import re
s = open('/tmp/fn-andui.xml', encoding='utf-8', errors='replace').read()
m = re.findall(r'text=\"(•+)\"', s)
print(len(m[0]) if m else 0)"
}
# Type into the field at a text label, in chunks, verified. Traps 1 and 3.
type_into() { # label, text, verify-substring
local label="$1" text="$2" want="$3"
tap_text "$label" || { echo "FAIL no '$label' field on screen"; exit 1; }
$ADB shell input keyevent KEYCODE_MOVE_END >/dev/null 2>&1
for _ in $(seq 1 40); do $ADB shell input keyevent 67 >/dev/null 2>&1; done
local i=0 len=${#text}
while [ "$i" -lt "$len" ]; do
$ADB shell input text "${text:$i:7}" >/dev/null 2>&1
i=$((i + 7)); sleep 1
done
sleep 1
dump | grep -q "$want" || { echo "FAIL '$label' did not read back as expected"; exit 1; }
}
$ADB shell am force-stop "$PKG" >/dev/null 2>&1
$ADB shell pm clear "$PKG" >/dev/null 2>&1
$ADB shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1
sleep 12
# Runtime permission prompts, if the image asks.
for _ in 1 2 3; do tap_text "Allow" || break; done
D=$(dump)
printf '%s' "$D" | grep -q 'text="Handle"' || { echo "FAIL no sign-in screen"; exit 1; }
type_into "Server" "$HOST" "$HOST"
type_into "Handle" "$HANDLE" "$HANDLE"
# The passphrase, verified by LENGTH — its text is bullets, so there is
# nothing else to compare against.
tap_text "Passphrase" || { echo "FAIL no passphrase field"; exit 1; }
$ADB shell input keyevent KEYCODE_MOVE_END >/dev/null 2>&1
for _ in $(seq 1 40); do $ADB shell input keyevent 67 >/dev/null 2>&1; done
i=0
while [ "$i" -lt "${#PASS}" ]; do
$ADB shell input text "${PASS:$i:7}" >/dev/null 2>&1
i=$((i + 7)); sleep 1
done
sleep 1
got=$(dots)
[ "$got" = "${#PASS}" ] || { echo "FAIL passphrase arrived as $got of ${#PASS} characters"; exit 1; }
# Trap 2, then trap 3: put the keyboard away, let the layout settle, and only
# then find the button.
$ADB shell input keyevent 4 >/dev/null 2>&1
sleep 3
after=$(dots)
[ "$after" = "${#PASS}" ] || { echo "FAIL dismissing the keyboard changed the field ($after chars)"; exit 1; }
# ⚠️ By LABEL, never by index. An index picked "Create an account" here and
# walked onto the sign-up form on the live relay.
tap_label "Sign in" || exit 1
for _ in $(seq 1 30); do
D=$(dump)
printf '%s' "$D" | grep -qE 'text="(Contacts|Messages|Chats)"' && { echo "$HOST $HANDLE OK"; exit 0; }
printf '%s' "$D" | grep -qi "too many" && { echo "$HOST $HANDLE SKIP throttled"; exit 0; }
printf '%s' "$D" | grep -qi "check your" && { echo "$HOST $HANDLE FAIL credentials refused"; exit 1; }
sleep 3
done
echo "$HOST $HANDLE FAIL did-not-complete"
exit 1