diff options
Diffstat (limited to 'bin')
| -rwxr-xr-x | bin/android-ui | 98 | ||||
| -rwxr-xr-x | bin/gradle-report | 110 | ||||
| -rwxr-xr-x | bin/publish-apk | 91 |
3 files changed, 299 insertions, 0 deletions
diff --git a/bin/android-ui b/bin/android-ui new file mode 100755 index 0000000..25257f1 --- /dev/null +++ b/bin/android-ui @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# android-ui <package> <command> [args] +# +# dump list the on-screen text and content-descriptions +# tap <description> tap the element whose content-desc or text matches, exactly once +# shot <file> screenshot, but only after confirming <package> is foreground +# foreground print the foreground package and exit non-zero if it isn't <package> +# +# Exists because a blind `adb shell input tap X Y` during this project landed on a completely +# different app's settings screen, and the screenshot taken straight afterwards was reported as +# the app under test. Coordinates are guesses about a layout; element descriptions are what the +# app actually published. Every command here verifies the foreground package first, so a tap can +# never silently drive something else. +set -uo pipefail + +ADB=${ADB:-/opt/android-sdk/platform-tools/adb} +LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)" +die() { echo "android-ui: $1" >&2; exit 2; } + +[ -x "$ADB" ] || die "adb not found at $ADB (set ADB=...)" +[ -x "$LIB/uinodes.py" ] || die "missing $LIB/uinodes.py" +[ $# -ge 2 ] || die "usage: android-ui <package> <dump|tap|shot|foreground> [args]" + +PKG="$1"; CMD="$2"; shift 2 + +foreground_pkg() { + "$ADB" shell dumpsys activity activities 2>/dev/null \ + | grep -oE 'topResumedActivity=ActivityRecord\{[^}]*' \ + | head -1 \ + | grep -oE '[a-zA-Z0-9_.]+/[a-zA-Z0-9_.]+' \ + | head -1 \ + | cut -d/ -f1 +} + +require_foreground() { + local actual + actual=$(foreground_pkg) + if [ "$actual" != "$PKG" ]; then + echo "android-ui: foreground is '${actual:-unknown}', expected '$PKG'" >&2 + echo " Nothing was done. Bring the app forward first; a tap here would have driven" >&2 + echo " whatever else is on screen." >&2 + exit 1 + fi +} + +dump_xml() { + "$ADB" shell uiautomator dump /sdcard/android-ui.xml >/dev/null 2>&1 \ + || die "uiautomator dump failed" + "$ADB" shell cat /sdcard/android-ui.xml 2>/dev/null +} + +case "$CMD" in + foreground) + actual=$(foreground_pkg) + echo "foreground: ${actual:-unknown}" + echo "expected : $PKG" + [ "$actual" = "$PKG" ] && { echo "result : MATCH"; exit 0; } + echo "result : MISMATCH"; exit 1 + ;; + + dump) + require_foreground + dump_xml | "$LIB/uinodes.py" list + ;; + + tap) + [ $# -ge 1 ] || die "tap needs a label" + require_foreground + TARGET="$1" + COORDS=$(dump_xml | "$LIB/uinodes.py" find "$TARGET") + + case "$COORDS" in + NONE) + echo "android-ui: nothing on screen is labelled '$TARGET'" >&2 + echo " Run: android-ui $PKG dump" >&2 + exit 1 ;; + AMBIGUOUS*) + # Tapping the first of several is how you act on the wrong row. + echo "android-ui: '$TARGET' matches ${COORDS#AMBIGUOUS } places; refusing to guess" >&2 + echo " Run: android-ui $PKG dump" >&2 + exit 1 ;; + esac + + "$ADB" shell input tap $COORDS >/dev/null 2>&1 || die "tap failed" + echo "tapped '$TARGET' at ($COORDS)" + ;; + + shot) + [ $# -ge 1 ] || die "shot needs an output path" + require_foreground + "$ADB" exec-out screencap -p > "$1" || die "screencap failed" + SIZE=$(stat -c%s "$1" 2>/dev/null || echo 0) + [ "$SIZE" -gt 1000 ] || die "screenshot is $SIZE bytes; capture failed" + echo "captured $1 ($SIZE bytes) with $PKG in the foreground" + ;; + + *) die "unknown command '$CMD'" ;; +esac diff --git a/bin/gradle-report b/bin/gradle-report new file mode 100755 index 0000000..c59945a --- /dev/null +++ b/bin/gradle-report @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# gradle-report <project-dir> <detekt|lint> -- [gradle args] +# +# Runs a static-analysis task and reports from its XML report, refusing to speak if the report +# is older than the run. +# +# Exists because detekt has a failure mode that looks exactly like findings: an invalid config +# property makes it abort BEFORE analysing anything, leaving the previous run's XML on disk. +# Three times in one session that stale file was read as the current result, and the line +# numbers never moved because the analysis never happened. The only honest signal was the exit +# code, which said "failed" while the report said "here are your 8 findings". +set -uo pipefail + +die() { echo "gradle-report: $1" >&2; exit 2; } + +[ $# -ge 2 ] || die "usage: gradle-report <project-dir> <detekt|lint> [-- <gradle args>]" + +PROJECT_DIR=$(cd "$1" 2>/dev/null && pwd) || die "no such project directory: $1" +KIND="$2" +shift 2 +[ "${1:-}" = "--" ] && shift +EXTRA=("$@") + +case "$KIND" in + detekt) TASK=":app:detekt"; REPORT="$PROJECT_DIR/app/build/reports/detekt/detekt.xml" ;; + lint) TASK=":app:lintDebug"; REPORT="$PROJECT_DIR/app/build/lint-results-debug.xml" ;; + *) die "kind must be 'detekt' or 'lint', got '$KIND'" ;; +esac + +[ -x "$PROJECT_DIR/gradlew" ] || die "no gradlew in $PROJECT_DIR" + +# Marker predates the run, so any report not newer than it is stale. +MARKER=$(mktemp) +trap 'rm -f "$MARKER"' EXIT +sleep 1 + +LOG=$(mktemp) +( cd "$PROJECT_DIR" && ./gradlew "$TASK" "${EXTRA[@]}" ) >"$LOG" 2>&1 +STATUS=$? + +echo "=== gradle-report ($KIND) ===" +echo "project : $PROJECT_DIR" +echo "task : $TASK" +echo "exit : $STATUS" + +# An invalid config aborts detekt before it analyses anything. Name it, because the report on +# disk will be the previous run's and will look perfectly reasonable. +if grep -qiE "is misspelled or does not exist|configuration file is invalid|invalid config" "$LOG"; then + echo + echo "CONFIG INVALID — analysis never ran. Offending properties:" + grep -iE "is misspelled or does not exist|configuration file is invalid|invalid config" "$LOG" | sed 's/^/ /' | sort -u + echo + echo "result : FAIL (config rejected; any report on disk is from an earlier run)" + rm -f "$LOG" + exit 1 +fi + +if [ ! -f "$REPORT" ]; then + echo "report : MISSING ($REPORT)" + echo "result : $([ $STATUS -eq 0 ] && echo 'PASS (no report produced, nothing to read)' || echo 'FAIL')" + rm -f "$LOG" + exit $STATUS +fi + +if [ "$REPORT" -ot "$MARKER" ]; then + echo "report : STALE — $REPORT predates this run" + echo "result : FAIL — refusing to report numbers that describe older code" + rm -f "$LOG" + exit 1 +fi + +FINDINGS=$(python3 - "$REPORT" "$KIND" <<'PY' +import sys, xml.etree.ElementTree as ET +path, kind = sys.argv[1], sys.argv[2] +try: + root = ET.parse(path).getroot() +except Exception as e: + print(f"UNREADABLE {e}") + raise SystemExit +rows = [] +if kind == "detekt": + for f in root.iter("file"): + for err in f.iter("error"): + rows.append(f"{f.get('name','').split('/')[-1]}:{err.get('line')} " + f"[{(err.get('source') or '').split('.')[-1]}] {(err.get('message') or '')[:90]}") +else: + for issue in root.iter("issue"): + if issue.get("severity", "").lower() not in ("error", "fatal"): + continue + loc = next(issue.iter("location"), None) + where = f"{(loc.get('file','') if loc is not None else '').split('/')[-1]}:{loc.get('line') if loc is not None else '?'}" + rows.append(f"{where} [{issue.get('id')}] {(issue.get('message') or '')[:90]}") +print(f"COUNT {len(rows)}") +for r in rows: + print(f" {r}") +PY +) + +echo "report : fresh" +echo "$FINDINGS" | head -1 | sed 's/^COUNT /findings: /' +echo "$FINDINGS" | tail -n +2 + +COUNT=$(echo "$FINDINGS" | head -1 | awk '{print $2}') +if [ "$COUNT" = "0" ] && [ $STATUS -eq 0 ]; then + echo "result : PASS (0 findings, gradle exit 0)" +else + echo "result : FAIL (${COUNT:-?} findings, gradle exit $STATUS)" +fi +rm -f "$LOG" +[ $STATUS -eq 0 ] && [ "$COUNT" = "0" ] diff --git a/bin/publish-apk b/bin/publish-apk new file mode 100755 index 0000000..91b4705 --- /dev/null +++ b/bin/publish-apk @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# publish-apk <apk> <published-name> [--host tungsten] [--dir /site/static.terst.org/public/files] +# +# Publishes a built APK to the static site, verifying every step that has gone wrong before: +# the host, that the file is really an APK, that what landed is byte-identical to what was +# tested, and that the web server actually serves it. +# +# Exists because publishing is where a silent mistake is most expensive. A stale published APK +# once sat for eight weeks while the repo moved on; installing it over current data threw +# "A migration from 11 to 6 was required but not found" and the app refused to start. The cost +# wasn't the bug, it was that nothing in the publish step had ever checked. +set -uo pipefail + +die() { echo "publish-apk: $1" >&2; exit 2; } + +EXPECT_HOST="tungsten" +DEST_DIR="/site/static.terst.org/public/files" + +[ $# -ge 2 ] || die "usage: publish-apk <apk> <published-name> [--host H] [--dir D]" +APK="$1"; NAME="$2"; shift 2 +while [ $# -gt 0 ]; do + case "$1" in + --host) EXPECT_HOST="$2"; shift 2 ;; + --dir) DEST_DIR="$2"; shift 2 ;; + *) die "unknown option '$1'" ;; + esac +done + +case "$NAME" in + */*) die "published name must be a bare filename, got '$NAME'" ;; + *.apk) ;; + *) die "published name should end in .apk, got '$NAME'" ;; +esac + +# Publishing from a box that merely resembles the deploy host is how a previous session +# "deployed" to a sandbox and had to unwind it. +ACTUAL_HOST=$(hostname) +[ "$ACTUAL_HOST" = "$EXPECT_HOST" ] \ + || die "host is '$ACTUAL_HOST', expected '$EXPECT_HOST'. Build and test here, publish there." + +[ -f "$APK" ] || die "no such file: $APK" +# An .apk is a zip; an HTML error page or a truncated copy is not. +head -c 2 "$APK" | grep -q "PK" || die "$APK is not a zip archive — not a real APK" +unzip -l "$APK" 2>/dev/null | grep -q "AndroidManifest.xml" \ + || die "$APK has no AndroidManifest.xml — not an APK" + +[ -d "$DEST_DIR" ] || die "no such directory: $DEST_DIR" +DEST="$DEST_DIR/$NAME" + +echo "=== publish-apk ===" +echo "host : $ACTUAL_HOST" +echo "source : $APK ($(stat -c%s "$APK") bytes)" +echo "dest : $DEST" + +if [ -f "$DEST" ]; then + BACKUP="$DEST.$(date +%Y%m%d-%H%M%S).bak" + cp "$DEST" "$BACKUP" || die "could not back up the existing file" + echo "backup : $BACKUP" +else + echo "backup : none needed (new file)" +fi + +cp "$APK" "$DEST" || die "copy failed" +chmod 644 "$DEST" || die "chmod failed" +chown root:root "$DEST" 2>/dev/null || true + +# The whole point: what is served must be what was tested. +if cmp -s "$APK" "$DEST"; then + echo "identity: published copy is byte-identical to the source" +else + die "published copy differs from the source — do not trust it" +fi + +URL="https://static.terst.org/files/$NAME" +REMOTE_LEN=$(curl -sI "$URL" 2>/dev/null | tr -d '\r' | awk 'tolower($1)=="content-length:"{print $2}') +LOCAL_LEN=$(stat -c%s "$DEST") + +if [ -z "$REMOTE_LEN" ]; then + echo "serving : COULD NOT VERIFY (no content-length from $URL)" + echo "result : PARTIAL — the file is in place but the web server did not confirm it" + exit 1 +fi + +echo "url : $URL" +echo "serving : $REMOTE_LEN bytes" +if [ "$REMOTE_LEN" = "$LOCAL_LEN" ]; then + echo "result : PASS — served bytes match the tested build" +else + echo "result : FAIL — server reports $REMOTE_LEN, on disk $LOCAL_LEN" + exit 1 +fi |
