#!/usr/bin/env bash # GOA (and optionally stage) GUI chain on Android — adapted from # taler-android branch **dev/hernani-inference/gui-workflows** # (`scripts/goa-chain-emu.sh`, docs/gui-workflows.md). # # Linux + macOS: uses adb + uiautomator only (no Homebrew hard dependency). # Upstream scripts are documented as macOS-only for SDK install paths. # # Flow: # 1) API mint withdraw (explorer) → VIEW taler://withdraw → GUI taps # 2) Merchant template POST → VIEW taler://pay → GUI taps # # Usage: # ./run-goa-gui-chain.sh # STACK=stage ./run-goa-gui-chain.sh # WITHDRAW_AMOUNTS='GOA:10' PAY_LIMIT=2 ./run-goa-gui-chain.sh # PKG=net.taler.wallet.fdroid.debug ./run-goa-gui-chain.sh --no-clear # # Env: ANDROID_SERIAL, PKG, EXP_PW_FILE, BANK, MERCHANT, INSTANCE, # WITHDRAW_AMOUNTS, PRODUCTS, PAY_LIMIT, SHOTDIR, PAUSE # # See GUI-AUTOMATION-NOTES.md # set -euo pipefail ROOT=$(cd "$(dirname "$0")" && pwd) CLEAR=1 for a in "$@"; do case "$a" in --no-clear) CLEAR=0 ;; -h|--help) sed -n '2,28p' "$0"; exit 0 ;; esac done die() { echo "ERROR: $*" >&2; exit 1; } step() { echo; echo "======== $* ========"; } info() { echo " $*"; } pause() { sleep "${1:-${PAUSE:-0.8}}"; } # Platform flags + headless emulator defaults (see GUI-AUTOMATION-NOTES.md) # shellcheck source=lib_android_env.sh . "$ROOT/lib_android_env.sh" [ "${AUTO_ANDROID}" = "1" ] || { echo "skipped: AUTO_ANDROID=${AUTO_ANDROID}"; exit 0; } [ "${AUTO_GUI}" = "1" ] || { echo "skipped: AUTO_GUI=${AUTO_GUI} (chain is GUI)"; exit 0; } command -v adb >/dev/null || die "adb not found" command -v python3 >/dev/null || die "python3 required" STACK="${STACK:-goa}" case "$STACK" in goa|hacktivism) PKG="${PKG:-net.taler.wallet.fdroid.debug}" BANK="${BANK:-https://bank.hacktivism.ch}" MERCHANT="${MERCHANT:-https://taler.hacktivism.ch}" INSTANCE=$(printf '%s' "${INSTANCE:-goa-shop}" | tr '[:upper:]' '[:lower:]') DONATE_INSTANCE=$(printf '%s' "${DONATE_INSTANCE:-goa-demo-cp4zqk}" | tr '[:upper:]' '[:lower:]') DONATE_TEMPLATE="${DONATE_TEMPLATE:-goa-free}" DEFAULT_AMOUNTS=("GOA:10" "GOA:20") DEFAULT_PAYS=("product:orbit-sticker" "product:nebula-coffee" "product:voidwave-playlist" "donate:GOA:12") CUR_HINT=GOA ;; stage|testpaysan) PKG="${PKG:-net.taler.wallet.fdroid.debug}" BANK="${BANK:-https://stage.bank.lefrancpaysan.ch}" MERCHANT="${MERCHANT:-https://stage.monnaie.lefrancpaysan.ch}" INSTANCE=$(printf '%s' "${INSTANCE:-fermes-des-collines}" | tr '[:upper:]' '[:lower:]') DONATE_INSTANCE=$(printf '%s' "${DONATE_INSTANCE:-fermes-des-collines}" | tr '[:upper:]' '[:lower:]') DONATE_TEMPLATE="${DONATE_TEMPLATE:-don-panier-libre}" DEFAULT_AMOUNTS=("TESTPAYSAN:10" "TESTPAYSAN:20") DEFAULT_PAYS=("product:panier-legumes" "product:fromage-chevre" "product:oeufs-6") CUR_HINT=TESTPAYSAN ;; *) die "STACK must be goa|stage (got $STACK)" ;; esac ACT="${ACT:-$PKG/net.taler.wallet.main.MainActivity}" EXP_USER="${EXP_USER:-explorer}" PAY_LIMIT="${PAY_LIMIT:-4}" PAUSE="${PAUSE:-0.8}" SHOTDIR="${SHOTDIR:-$ROOT/out-gui-chain/$(date +%Y%m%d-%H%M%S)-$STACK}" mkdir -p "$SHOTDIR" # Explorer password (same search as taler-monitoring ladder) if [[ -z "${EXP_PW_FILE:-}" ]]; then for cand in \ "${HOME}/src/koopa/koopa-admin-secrets/koopa/host-root/taler-bank/bank-explorer-password.txt" \ "${HOME}/.config/taler-landing/bank-explorer-password.txt" do [[ -f "$cand" ]] && EXP_PW_FILE="$cand" && break done fi # stage: try stagepaysan secret via ssh if missing if [[ ! -f "${EXP_PW_FILE:-}" && "$STACK" = "stage" ]]; then for host in francpaysan-stage-user francpaysan-host; do tmp=$(mktemp) if ssh -o BatchMode=yes -o ConnectTimeout=10 "$host" \ 'tr -d "\n\r" "$tmp" 2>/dev/null && [[ -s "$tmp" ]]; then EXP_PW_FILE="$tmp" info "explorer password via $host" break fi rm -f "$tmp" done fi [[ -n "${EXP_PW_FILE:-}" && -f "$EXP_PW_FILE" ]] || die "set EXP_PW_FILE (explorer password)" # Headless AVD by default (no host window). WINDOWED=1 for a visible emulator. SERIAL="${SERIAL:-${ANDROID_SERIAL:-}}" android_ensure_device || die "no adb device (./start-android-emulator.sh --wait)" export ANDROID_SERIAL="$SERIAL" info "ANDROID_SERIAL=$ANDROID_SERIAL STACK=$STACK PKG=$PKG SHOTDIR=$SHOTDIR headless=$EMULATOR_HEADLESS gpu=$EMULATOR_GPU" ADB=(adb -s "$ANDROID_SERIAL") adb_sh() { "${ADB[@]}" shell "$@"; } shot() { local n="$1" adb_sh screencap -p "/sdcard/demo-$n.png" 2>/dev/null || true "${ADB[@]}" pull "/sdcard/demo-$n.png" "$SHOTDIR/$n.png" >/dev/null 2>&1 || true info "[shot] $SHOTDIR/$n.png" } tap_text() { local label="$1" adb_sh uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || return 1 "${ADB[@]}" pull /sdcard/ui.xml "$SHOTDIR/ui.xml" >/dev/null 2>&1 || return 1 SERIAL="$ANDROID_SERIAL" python3 - "$label" "$SHOTDIR/ui.xml" <<'PY' import os, re, subprocess, sys from pathlib import Path label, path = sys.argv[1], sys.argv[2] serial = os.environ.get("SERIAL") or os.environ.get("ANDROID_SERIAL") or "" xml = Path(path).read_text(errors="replace") # ANR dismiss if "isn't responding" in xml or "reagiert nicht" in xml.lower(): for lab in ("Wait", "Warten"): for m in re.finditer(rf'text="{lab}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml): x = (int(m.group(1))+int(m.group(3)))//2 y = (int(m.group(2))+int(m.group(4)))//2 subprocess.run(["adb","-s",serial,"shell","input","tap",str(x),str(y)], check=False) print(f"anr-wait @{x},{y}") sys.exit(0) for pat in [ rf'text="{re.escape(label)}"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', rf'text="[^"]*{re.escape(label)}[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', rf'content-desc="[^"]*{re.escape(label)}[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', ]: for m in re.finditer(pat, xml, re.I): chunk = xml[max(0, m.start()-120):m.end()] if 'enabled="false"' in chunk: continue x = (int(m.group(1))+int(m.group(3)))//2 y = (int(m.group(2))+int(m.group(4)))//2 print(f"tap {label!r} @{x},{y}") cmd = ["adb","shell","input","tap",str(x),str(y)] if serial: cmd = ["adb","-s",serial,"shell","input","tap",str(x),str(y)] subprocess.run(cmd, check=False) sys.exit(0) print("miss", label) sys.exit(1) PY } mint_withdraw() { local amount="$1" EXP_USER="$EXP_USER" EXP_PW_FILE="$EXP_PW_FILE" BANK="$BANK" \ python3 - "$amount" <<'PY' import base64, json, os, ssl, sys, urllib.request from pathlib import Path amount = sys.argv[1] bank = os.environ["BANK"].rstrip("/") user = os.environ["EXP_USER"] pw = Path(os.environ["EXP_PW_FILE"]).read_text().strip() auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode() ctx = ssl.create_default_context() def req(method, url, data=None, headers=None): h = dict(headers or {}) body = None if data is not None: body = json.dumps(data).encode() h["Content-Type"] = "application/json" r = urllib.request.Request(url, data=body, headers=h, method=method) with urllib.request.urlopen(r, context=ctx, timeout=25) as resp: return json.load(resp) tok = req( "POST", f"{bank}/accounts/{user}/token", {"scope": "readwrite", "duration": {"d_us": 3600_000_000}}, {"Authorization": auth}, )["access_token"] wd = req( "POST", f"{bank}/accounts/{user}/withdrawals", {"amount": amount}, {"Authorization": f"Bearer {tok}"}, ) print(wd.get("taler_withdraw_uri") or wd.get("talerWithdrawUri") or "") PY } mint_pay() { local mode="$1" MERCHANT="$MERCHANT" INSTANCE="$INSTANCE" \ DONATE_INSTANCE="$DONATE_INSTANCE" DONATE_TEMPLATE="$DONATE_TEMPLATE" \ python3 - "$mode" <<'PY' import json, os, ssl, sys, urllib.error, urllib.request mode = sys.argv[1] merchant = os.environ["MERCHANT"].rstrip("/") inst = os.environ["INSTANCE"] d_inst = os.environ["DONATE_INSTANCE"] d_tmpl = os.environ["DONATE_TEMPLATE"] ctx = ssl.create_default_context() def post(url, data): body = json.dumps(data).encode() r = urllib.request.Request(url, data=body, method="POST", headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(r, context=ctx, timeout=25) as resp: return resp.status, json.load(resp) except urllib.error.HTTPError as e: raw = e.read() if hasattr(e, "read") else b"" try: return getattr(e, "code", None), json.loads(raw) except Exception as err: raise SystemExit(f"POST {url} failed: {e} body={raw[:200]!r}") from err if mode.startswith("product:"): pid = mode.split(":", 1)[1] st, d = post(f"{merchant}/instances/{inst}/templates/{pid}", {}) use_inst = inst elif mode.startswith("donate:"): amt = mode.split(":", 1)[1] st, d = post(f"{merchant}/instances/{d_inst}/templates/{d_tmpl}", {"amount": amt}) use_inst = d_inst else: raise SystemExit(f"bad mode {mode!r}") if "taler_pay_uri" in d: print(d["taler_pay_uri"]); raise SystemExit(0) oid, tok = d.get("order_id"), d.get("token") if not oid or not tok: raise SystemExit(f"no order: {d!r}"[:200]) host = merchant.replace("https://","").replace("http://","").split("/")[0] print(f"taler://pay/{host}/instances/{use_inst}/{oid}/?c={tok}") PY } click_through() { local labels=("$@") local i lab misses=0 # Prefer dismissing ANR before hunting Confirm (low-RAM hosts thrash System UI) for i in 1 2 3 4 5 6; do if tap_text "Wait" 2>/dev/null || tap_text "Warten" 2>/dev/null; then info "dismissed ANR (Wait)" pause 2.0 misses=0 continue fi local hit=0 for lab in "${labels[@]}"; do if tap_text "$lab" 2>/dev/null; then hit=1 misses=0 pause 0.9 break fi done if [[ "$hit" -eq 0 ]]; then misses=$((misses + 1)) # stop early if hierarchy has nothing useful (avoid burning order deadlines) [[ "$misses" -ge 3 ]] && break fi pause 0.5 done } if [[ -n "${WITHDRAW_AMOUNTS:-}" ]]; then # shellcheck disable=SC2206 AMOUNTS=($WITHDRAW_AMOUNTS) else AMOUNTS=("${DEFAULT_AMOUNTS[@]}") fi if [[ -n "${PRODUCTS:-}" ]]; then PAYS=() for p in $PRODUCTS; do PAYS+=("product:$p"); done else PAYS=("${DEFAULT_PAYS[@]}") fi ############################ step "0 device + wallet ($STACK / $CUR_HINT)" adb_sh input keyevent KEYCODE_WAKEUP 2>/dev/null || true adb_sh wm dismiss-keyguard 2>/dev/null || true if [[ "$CLEAR" -eq 1 ]]; then info "pm clear $PKG" adb_sh pm clear "$PKG" >/dev/null 2>&1 || true fi adb_sh am force-stop "$PKG" 2>/dev/null || true pause 0.4 # Prefer explicit MainActivity (from gui-workflows); fall back to VIEW launcher if ! adb_sh am start -n "$ACT" >/dev/null 2>&1; then info "MainActivity path failed — monkey launch" adb_sh monkey -p "$PKG" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true fi pause 2.0 shot "00-start" ############################ step "2 withdraw chain" idx=0 for AMT in "${AMOUNTS[@]}"; do idx=$((idx + 1)) step "2.$idx withdraw $AMT" URI="$(mint_withdraw "$AMT" | head -1)" [[ -n "$URI" ]] || die "mint failed for $AMT" # wallet / monitoring QR rules: strip default ports URI=$(printf '%s' "$URI" | sed 's/:443\//\//g; s/:443?/?/g; s/:80\//\//g') info "URI=$URI" echo "$URI" >>"$SHOTDIR/withdraw-uris.txt" adb_sh am start -a android.intent.action.VIEW -d "$URI" "$PKG" >/dev/null pause 1.5 shot "w${idx}-open" click_through "Accept" "I accept" "Accept terms" "Confirm" "Continue" "Withdraw" "Next" "OK" "Agree" "Bestätigen" "Abheben" "Akzeptieren" pause 3.0 shot "w${idx}-after" adb_sh am start -n "$ACT" >/dev/null 2>&1 || true pause 0.6 tap_text "Assets" 2>/dev/null || true pause 0.4 shot "w${idx}-assets" done ############################ step "3 pay chain (limit=$PAY_LIMIT)" pidx=0 for PAY in "${PAYS[@]}"; do pidx=$((pidx + 1)) [[ "$pidx" -gt "$PAY_LIMIT" ]] && break step "3.$pidx pay $PAY" if ! PAYURI="$(mint_pay "$PAY" | head -1)"; then info "mint_pay failed for $PAY — skip" continue fi [[ -n "$PAYURI" ]] || { info "empty pay uri — skip"; continue; } PAYURI=$(printf '%s' "$PAYURI" | sed 's/:443\//\//g; s/:443?/?/g; s/:80\//\//g') info "PAYURI=$PAYURI" echo "$PAYURI" >>"$SHOTDIR/pay-uris.txt" adb_sh am start -a android.intent.action.VIEW -d "$PAYURI" "$PKG" >/dev/null pause 1.5 shot "p${pidx}-open" click_through "Pay" "Confirm" "Accept" "Continue" "OK" "Next" "Bezahlen" "Bestätigen" "Payer" pause 1.5 shot "p${pidx}-after" adb_sh am start -n "$ACT" >/dev/null 2>&1 || true pause 0.5 done step "4 done → $SHOTDIR" ls -la "$SHOTDIR" | tail -40 adb_sh dumpsys window 2>/dev/null | grep mCurrentFocus | head -1 || true info "PKG=$PKG amounts=${AMOUNTS[*]} pays(limit)=$PAY_LIMIT" echo "See GUI-AUTOMATION-NOTES.md (ported from taler-android dev/hernani-inference/gui-workflows)"