From d921e8c3edb57a675eeacfa0fbf83a34f1365a68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 18:55:22 +0000 Subject: [PATCH 1/4] ci(screenshots): full-coverage UI walkthrough with seeded data Turn the Android UI Screenshot workflow into a verification gallery of every screen in the app, each shown with realistic data. - Pre-seed the app's SharedPreferences before launch (via run-as, SELinux-safe): a curated set of home apps picked from packages actually installed on the emulator, and a rich notes history (text, a done to-do, an urgent reminder, an image and a voice memo). First-run flags are flipped off so no onboarding dialog hides the UI. seed_data.py builds both prefs XML files; find_node.py lets the driver tap real on-screen elements located from a live `uiautomator dump` instead of hard-coded coordinates. - Walk and screenshot 24 states across Home, App drawer (list, live search, per-app long-press menu, inline rename), Settings (home/appearance/DND/ gestures sections plus their sub-selectors), Notes (list, actions menu, inline edit, full-screen image, search, empty state), and a couple of live theme/alignment variations. - Publish step renders the gallery into the run's Job Summary grouped by section (images embed inline via the ci-screenshots branch). - Also run on PRs into main/master so the gallery shows up during review; bump the job timeout to 60m for the longer walkthrough. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PTbv7mVssh3FXvhdVAMnzN --- .github/scripts/find_node.py | 88 +++++ .github/scripts/publish_screenshots.sh | 21 +- .github/scripts/seed_data.py | 167 ++++++++++ .github/scripts/ui_screenshots.sh | 329 ++++++++++++++++--- .github/workflows/android-ui-screenshots.yml | 22 +- 5 files changed, 561 insertions(+), 66 deletions(-) create mode 100755 .github/scripts/find_node.py create mode 100755 .github/scripts/seed_data.py diff --git a/.github/scripts/find_node.py b/.github/scripts/find_node.py new file mode 100755 index 00000000..f6c399a8 --- /dev/null +++ b/.github/scripts/find_node.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +""" +Locate a node in a `uiautomator dump` hierarchy and print the centre x/y of its +bounds, so the screenshot driver can tap real on-screen elements instead of +guessing at hard-coded coordinates (which break across screen sizes/layouts). + +Reads the dumped XML on stdin. Usage: + + find_node.py [--contains] [--clickable] [--index N] + + one of: text | desc | id | class + the value to match (for `id`, a suffix like "appTitle" is enough) + --contains substring match instead of exact (ignored for `id`, which is + always suffix/substring matched) + --clickable only consider nodes whose clickable="true" + --index N pick the Nth match (0-based, negatives count from the end) + +Prints " " on success; exits non-zero (no output) when not found. +""" +import re +import sys +import xml.etree.ElementTree as ET + +ATTR_MAP = {"text": "text", "desc": "content-desc", "id": "resource-id", "class": "class"} + + +def main() -> int: + args = sys.argv[1:] + contains = clickable = False + index = 0 + positional = [] + i = 0 + while i < len(args): + a = args[i] + if a == "--contains": + contains = True + elif a == "--clickable": + clickable = True + elif a == "--index": + i += 1 + index = int(args[i]) + else: + positional.append(a) + i += 1 + + if len(positional) < 2: + sys.stderr.write("usage: find_node.py [opts]\n") + return 2 + attr_key, value = positional[0], positional[1] + xml_attr = ATTR_MAP.get(attr_key, attr_key) + + data = sys.stdin.read() + try: + root = ET.fromstring(data) + except ET.ParseError: + # Loose-escape stray ampersands and retry once. + data = re.sub(r"&(?!amp;|lt;|gt;|quot;|apos;|#)", "&", data) + root = ET.fromstring(data) + + matches = [] + for node in root.iter("node"): + av = node.get(xml_attr, "") + if xml_attr == "resource-id": + ok = av == value or av.endswith("/" + value) or value in av + elif contains: + ok = value in av + else: + ok = av == value + if ok and (not clickable or node.get("clickable") == "true"): + matches.append(node) + + if not matches: + return 1 + if index < 0: + index += len(matches) + if index < 0 or index >= len(matches): + return 1 + + m = re.match(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", matches[index].get("bounds", "")) + if not m: + return 1 + x1, y1, x2, y2 = map(int, m.groups()) + print((x1 + x2) // 2, (y1 + y2) // 2) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/publish_screenshots.sh b/.github/scripts/publish_screenshots.sh index c54efecf..b0645df1 100755 --- a/.github/scripts/publish_screenshots.sh +++ b/.github/scripts/publish_screenshots.sh @@ -6,7 +6,8 @@ # GitHub's job-summary renderer strips base64 `data:` images, so inline images # need a real https URL. This pushes the PNGs to an orphan `ci-screenshots` # branch (kept out of the main history) under runs//, then writes the -# job summary referencing their raw.githubusercontent.com URLs. +# job summary referencing their raw.githubusercontent.com URLs, grouped into the +# sections recorded in the manifest. # # Requires: GH_TOKEN env (the workflow's GITHUB_TOKEN) and `contents: write`. set -euo pipefail @@ -51,18 +52,26 @@ done # ---- job summary ------------------------------------------------------------- RAW="https://raw.githubusercontent.com/${REPO}/${BRANCH}/${DEST}" +COUNT="$(grep -c . "$MANIFEST" || echo 0)" { echo "## Launch0 UI walkthrough" echo "" - echo "Captured on an Android emulator from the debug APK built in this run (commit \`${SHA}\`)." + echo "${COUNT} screens captured on an Android emulator from the debug APK built in this run (commit \`${SHA}\`), seeded with realistic data." echo "" - while IFS=$'\t' read -r file caption; do + last_section="" + while IFS=$'\t' read -r file section caption; do [[ -n "$file" ]] || continue - echo "### ${caption}" - echo "" + if [[ "$section" != "$last_section" ]]; then + echo "" + echo "## ${section}" + echo "" + last_section="$section" + fi echo "\"${caption}\"" echo "" + echo "*${caption}*" + echo "" done < "$MANIFEST" } >> "${GITHUB_STEP_SUMMARY:-/dev/stdout}" -echo "Published ${DEST} to $BRANCH and wrote the job summary." +echo "Published ${DEST} to $BRANCH and wrote the job summary (${COUNT} screens)." diff --git a/.github/scripts/seed_data.py b/.github/scripts/seed_data.py new file mode 100755 index 00000000..92fa4d1a --- /dev/null +++ b/.github/scripts/seed_data.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +""" +Generate the two SharedPreferences XML files that pre-seed Launch0 with +realistic data for the CI screenshot walkthrough: + + * the main prefs file (app.launch0.xml) โ€” home apps, layout, theme, gestures, + DND, and onboarding flags flipped off so no first-run dialogs/nudges hide the + UI under test. + * the notes prefs file (app.launch0.notes.xml) โ€” a chat-like history of notes: + plain text, a done to-do, an urgent reminder, an image and a voice memo. + +Home apps are picked from the packages actually installed on the emulator (passed +in via a newline-delimited file) so every seeded slot resolves to a launchable +activity โ€” otherwise HomeFragment would validate it away and blank the slot. + +Usage: + seed_data.py \ + + + may be empty to skip the image note (e.g. if ImageMagick is absent). +""" +import json +import sys +from xml.sax.saxutils import escape + +# Preferred home-screen apps, best package first. Intersected with what's really +# installed so the home screen looks curated regardless of the system image. +PREFERRED = [ + ("Phone", ["com.google.android.dialer", "com.android.dialer"]), + ("Messages", ["com.google.android.apps.messaging", "com.android.messaging"]), + ("Chrome", ["com.android.chrome"]), + ("Camera", ["com.android.camera2", "com.google.android.GoogleCamera"]), + ("Photos", ["com.google.android.apps.photos", "com.android.gallery3d"]), + ("Calendar", ["com.google.android.calendar", "com.android.calendar"]), + ("Clock", ["com.google.android.deskclock", "com.android.deskclock"]), + ("Calculator", ["com.android.calculator2", "com.google.android.calculator"]), + ("Contacts", ["com.google.android.contacts", "com.android.contacts"]), + ("Files", ["com.android.documentsui"]), + ("Settings", ["com.android.settings"]), + ("Maps", ["com.google.android.apps.maps"]), + ("Gmail", ["com.google.android.gm"]), + ("YouTube", ["com.google.android.youtube"]), +] + + +def select_home_apps(installed, want=6): + iset = set(installed) + chosen, used = [], set() + for label, pkgs in PREFERRED: + for p in pkgs: + if p in iset: + chosen.append([label, p]) + used.add(p) + break + if len(chosen) >= want: + break + # Fall back to other launchable packages if the image is unusually bare. + if len(chosen) < want: + for p in sorted(installed): + if len(chosen) >= want: + break + if p in used or p.startswith("app.launch0"): + continue + label = p.rstrip(".").split(".")[-1].replace("_", " ").title() + chosen.append([label, p]) + used.add(p) + return chosen + + +def s(name, val): + return f' {escape(str(val))}' + + +def i(name, val): + return f' ' + + +def b(name, val): + return f' ' + + +def build_main(home_apps): + lines = ["", ""] + # Onboarding / first-run state: suppress dialogs and the "set default" prompt. + lines += [ + b("FIRST_OPEN", False), b("FIRST_SETTINGS_OPEN", False), b("FIRST_HIDE", False), + s("USER_STATE", "DONE"), + b("KEYBOARD_MESSAGE", True), b("WALLPAPER_MSG_SHOWN", True), + b("PRO_MESSAGE_SHOWN", True), b("HIDE_SET_DEFAULT_LAUNCHER", True), + ] + # Layout & appearance. + lines += [ + i("HOME_APPS_NUM", len(home_apps)), + i("DATE_TIME_VISIBILITY", 1), # On (clock + date) + b("SHOW_YEAR_WIDGET", True), + b("SHOW_APP_ICONS", True), b("SHOW_APP_NAMES", True), + i("ICON_SIZE", 28), i("ICON_SHAPE", 0), + i("HOME_ALIGNMENT", 8388613), # Gravity.END (right-aligned) + b("HOME_BOTTOM_ALIGNMENT", True), + b("AUTO_SHOW_KEYBOARD", False), # keep the drawer list unobscured + i("APP_THEME", 2), # AppCompatDelegate.MODE_NIGHT_YES + ] + # Gestures. + lines += [ + i("SWIPE_LEFT_ACTION", 1), # Notes + i("SWIPE_DOWN_ACTION", 2), # Notifications + b("SWIPE_LEFT_ENABLED", True), b("SWIPE_RIGHT_ENABLED", True), + ] + # Do Not Disturb (so the Settings DND section reads as configured). + lines += [b("DND_ENABLED", True), i("DND_DURATION_MINUTES", 60)] + # Home app slots. + for idx, (name, pkg) in enumerate(home_apps, start=1): + lines += [s(f"APP_NAME_{idx}", name), s(f"APP_PACKAGE_{idx}", pkg), s(f"APP_USER_{idx}", "")] + if home_apps: + lines.append(' ') + lines.append(f" {escape(home_apps[0][1])}") + lines.append(" ") + lines.append("") + return "\n".join(lines) + "\n" + + +def build_notes(now_ms, image_path, audio_path): + minute = 60_000 + + def note(off_min, type_, text="", done=False, urgent=False, media="", dur=0): + ts = now_ms - off_min * minute + return { + "id": ts, "type": type_, "text": text, "imagePath": media, + "timestamp": ts, "done": done, "urgent": urgent, "duration": dur, + } + + entries = [ + note(95, "text", "Welcome to Launch0 โ€” your private, text-first home screen ๐Ÿ‘‹"), + note(80, "text", "Finish the Q3 launch checklist", done=True), + note(64, "text", "Ideas: weekly review, read 20 pages a day, meditation streak"), + note(47, "text", "Call the dentist back โ€” appointment Friday 3pm", urgent=True), + note(33, "text", "Grocery run: milk, eggs, spinach, coffee, oats"), + ] + if image_path: + entries.append(note(22, "image", media=image_path)) + entries += [ + note(14, "text", "Standup: shipped notes search, voice playback next"), + note(8, "audio", media=audio_path, dur=8000), + note(2, "text", "Launch0 docs are live โ†’ launch0.app/docs"), + ] + blob = json.dumps(entries, ensure_ascii=False) + return ( + "\n\n" + f' {escape(blob)}\n\n' + ) + + +def main(): + out_main, out_notes, installed_file, now_ms, image_path, audio_path = sys.argv[1:7] + with open(installed_file) as fh: + installed = [ln.strip() for ln in fh if ln.strip()] + home_apps = select_home_apps(installed) + with open(out_main, "w") as fh: + fh.write(build_main(home_apps)) + with open(out_notes, "w") as fh: + fh.write(build_notes(int(now_ms), image_path, audio_path)) + # Echo the chosen home apps so the CI log records what was seeded. + sys.stderr.write("Seeded home apps: " + ", ".join(f"{n} ({p})" for n, p in home_apps) + "\n") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/ui_screenshots.sh b/.github/scripts/ui_screenshots.sh index b2ebca4e..b450ad31 100755 --- a/.github/scripts/ui_screenshots.sh +++ b/.github/scripts/ui_screenshots.sh @@ -1,109 +1,332 @@ #!/usr/bin/env bash # -# UI screenshot driver for Launch0. +# UI screenshot driver for Launch0 โ€” a verification walkthrough of every screen. # # Runs *inside* a booted Android emulator (invoked by the -# reactivecircus/android-emulator-runner step). It installs the freshly built -# debug APK, makes Launch0 the default home, then walks through a few screens โ€” -# driving real gestures with `adb shell input` โ€” and grabs a screenshot after -# each step. +# reactivecircus/android-emulator-runner step). It: +# 1. installs the freshly built debug APK and makes Launch0 the default home; +# 2. pre-seeds realistic data straight into the app's SharedPreferences โ€” a +# curated set of home apps (picked from packages actually installed on the +# emulator) and a rich notes history (text, a to-do, an urgent reminder, an +# image and a voice memo) โ€” plus flips first-run flags off so no onboarding +# dialog hides the UI; +# 3. drives real gestures with `adb shell input` and taps located against a +# live `uiautomator dump` (resolution-independent), screenshotting every +# screen, sub-menu and a couple of theme/layout variations. # # Output: # $OUT_DIR/NN-slug.png full-resolution screenshots -# $OUT_DIR/manifest.tsv "\t" per line, for the publish -# step that renders them into the run's job summary. -set -euo pipefail +# $OUT_DIR/manifest.tsv "\t
\t" per line, consumed +# by publish_screenshots.sh to build the run's job +# summary gallery. +# +# Note: no `set -e` โ€” the walkthrough is best-effort. A tap that can't find its +# target logs a warning and we still capture whatever is on screen, so one flaky +# step never aborts the whole gallery. Critical setup steps are checked inline. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" APP_ID="app.launch0.debug" MAIN_ACTIVITY="app.launch0.MainActivity" +DATA_DIR="/data/data/${APP_ID}" APK_PATH="${APK_PATH:-app/build/outputs/apk/debug/app-debug.apk}" OUT_DIR="${OUT_DIR:-screenshots}" MANIFEST="$OUT_DIR/manifest.tsv" +IMG_DEST="${DATA_DIR}/files/notes_images/img_seed.png" +AUDIO_DEST="${DATA_DIR}/files/notes_audio/audio_seed.m4a" + mkdir -p "$OUT_DIR" : > "$MANIFEST" # ---- screen geometry --------------------------------------------------------- -# Read the real device size so swipe coordinates are resolution-independent. SIZE_LINE="$(adb shell wm size | tr -d '\r')" W="$(echo "$SIZE_LINE" | grep -oE '[0-9]+x[0-9]+' | tail -1 | cut -dx -f1)" H="$(echo "$SIZE_LINE" | grep -oE '[0-9]+x[0-9]+' | tail -1 | cut -dx -f2)" -W="${W:-1080}" -H="${H:-2340}" +W="${W:-1080}"; H="${H:-2340}" CX=$(( W / 2 )) echo "Emulator screen: ${W}x${H} (center x=${CX})" -# ---- helpers ----------------------------------------------------------------- pct_x() { echo $(( W * $1 / 100 )); } pct_y() { echo $(( H * $1 / 100 )); } settle() { sleep "${1:-2}"; } -current_focus() { - adb shell dumpsys window 2>/dev/null | grep -m1 'mCurrentFocus' | tr -d '\r' -} +current_focus() { adb shell dumpsys window 2>/dev/null | grep -m1 'mCurrentFocus' | tr -d '\r'; } + +SECTION="App" +section() { SECTION="$1"; echo; echo "===== $1 ====="; } # shot shot() { local idx="$1" slug="$2" caption="$3" file file="$(printf '%02d-%s.png' "$idx" "$slug")" adb exec-out screencap -p > "$OUT_DIR/$file" - printf '%s\t%s\n' "$file" "$caption" >> "$MANIFEST" - echo "Captured: $file ($caption) [$(current_focus)]" + printf '%s\t%s\t%s\n' "$file" "$SECTION" "$caption" >> "$MANIFEST" + echo "Captured: $file [$SECTION] $caption [$(current_focus)]" } -# Fast swipes: a slow `input swipe` decelerates at the end, so the terminal -# velocity falls under OnSwipeTouchListener's fling threshold and onSwipe* never +# ---- gestures ---------------------------------------------------------------- +# Fast swipes: a slow `input swipe` decelerates at the end so the terminal +# velocity drops under OnSwipeTouchListener's fling threshold and onSwipe* never # fires. Keep the duration short so the gesture stays fast throughout. -swipe_up() { adb shell input swipe "$CX" "$(pct_y 68)" "$CX" "$(pct_y 22)" 120; } -swipe_left() { adb shell input swipe "$(pct_x 92)" "$(pct_y 45)" "$(pct_x 8)" "$(pct_y 45)" 120; } -# Long-press: onLongPress fires at ~500ms, then a further 500ms delay before -# onLongClick(), so hold well past 1s. -long_press() { adb shell input swipe "$CX" "$(pct_y 38)" "$CX" "$(pct_y 38)" 1800; } +swipe_up() { adb shell input swipe "$CX" "$(pct_y 68)" "$CX" "$(pct_y 22)" 120; } +swipe_left() { adb shell input swipe "$(pct_x 92)" "$(pct_y 45)" "$(pct_x 8)" "$(pct_y 45)" 120; } +long_press() { adb shell input swipe "$CX" "$(pct_y 38)" "$CX" "$(pct_y 38)" 1800; } +# A controlled (non-fling) drag, for scrolling lists/the settings page. +scroll_down() { adb shell input swipe "$CX" "$(pct_y 72)" "$CX" "$(pct_y 30)" 400; settle 1; } # Return to a clean home screen. Launch0 is the default home, so HOME resolves -# straight to it (no chooser) and, because MainActivity is singleTask, delivers -# a new MAIN intent whose onNewIntent() calls backToHomeScreen() โ€” popping back -# to the home fragment from any sub-screen. +# straight to it and (MainActivity being singleTask) onNewIntent() pops back to +# the home fragment from any sub-screen. go_home() { adb shell input keyevent KEYCODE_HOME; settle 2; } +back() { adb shell input keyevent KEYCODE_BACK; settle 1; } -# ---- install ----------------------------------------------------------------- -echo "Installing $APK_PATH ..." -adb install -r -t "$APK_PATH" +# ---- uiautomator-located taps ------------------------------------------------ +UIX="" +ui_dump() { + local i + for i in 1 2 3; do + if adb shell uiautomator dump /sdcard/ui_dump.xml >/dev/null 2>&1; then + UIX="$(adb exec-out cat /sdcard/ui_dump.xml 2>/dev/null)" + [ -n "$UIX" ] && return 0 + fi + sleep 1 + done + return 1 +} +locate() { printf '%s' "$UIX" | python3 "$SCRIPT_DIR/find_node.py" "$@"; } + +# tap โ€” refresh UI, locate, tap. Returns non-zero (logs) on miss. +tap() { + ui_dump || { echo " [tap] ui dump failed for: $*"; return 1; } + local xy; xy="$(locate "$@")" || { echo " [tap] not found: $*"; return 1; } + echo " tap ($*) -> $xy" + adb shell input tap $xy; return 0 +} +# longpress +longpress() { + ui_dump || { echo " [longpress] ui dump failed for: $*"; return 1; } + local xy; xy="$(locate "$@")" || { echo " [longpress] not found: $*"; return 1; } + echo " longpress ($*) -> $xy" + set -- $xy + adb shell input swipe "$1" "$2" "$1" "$2" 1800; return 0 +} +present() { ui_dump || return 1; locate "$@" >/dev/null 2>&1; } + +# Scroll the current (settings) page until appears, then it's tap-able. +scroll_to_text() { + local text="$1" n=0 + while [ $n -lt 8 ]; do + present text "$text" --contains && return 0 + scroll_down + n=$((n+1)) + done + present text "$text" --contains +} +type_text() { adb shell input text "$(echo "$1" | sed 's/ /%s/g')"; } -# Set Launch0 as the default home and give the system a moment to register it, -# so the very first HOME press doesn't pop the "Select a Home app" chooser. +# ============================================================================= +# 1. Install + make default home +# ============================================================================= +echo "Installing $APK_PATH ..." +adb install -r -t "$APK_PATH" || { echo "APK install failed"; exit 1; } echo "set-home-activity: $(adb shell cmd package set-home-activity "${APP_ID}/${MAIN_ACTIVITY}" 2>&1 | tr -d '\r')" -settle 3 +# Usage-access powers the on-home screen-time label and the Settings "Screen +# time" row; granting it exercises that wiring (best-effort). +adb shell appops set "$APP_ID" GET_USAGE_STATS allow >/dev/null 2>&1 || true -# ---- walkthrough ------------------------------------------------------------- +# A tidy, deterministic status bar for the screens that show one. +adb shell settings put global sysui_demo_allowed 1 >/dev/null 2>&1 || true +demo() { adb shell am broadcast -a com.android.systemui.demo "$@" >/dev/null 2>&1 || true; } +demo -e command enter +demo -e command clock -e hhmm 1041 +demo -e command battery -e level 100 -e plugged false +demo -e command network -e wifi show -e level 4 +demo -e command notifications -e visible false -# 1. Home screen -go_home -shot 1 home "Home screen" +# ============================================================================= +# 2. Seed realistic data into the app's private storage +# ============================================================================= +echo "Seeding SharedPreferences ..." +adb shell run-as "$APP_ID" mkdir -p shared_prefs files/notes_images files/notes_audio 2>/dev/null || true -# 2. App drawer (swipe up) -swipe_up -settle 2 -shot 2 app-drawer "App drawer (swipe up)" +# Discover packages that expose a launcher activity, so seeded home apps resolve. +# `--brief` prints flattened pkg/cls components; fall back to the verbose +# `packageName=` form, then to a known-good AOSP set, so we always get something. +INSTALLED="$(mktemp)" +adb shell cmd package query-activities --brief -a android.intent.action.MAIN -c android.intent.category.LAUNCHER 2>/dev/null \ + | tr ' ,' '\n\n' | grep -oE '[a-zA-Z][a-zA-Z0-9_.]+/[a-zA-Z0-9_.$]+' | cut -d/ -f1 | sort -u > "$INSTALLED" +if [ ! -s "$INSTALLED" ]; then + adb shell cmd package query-activities -a android.intent.action.MAIN -c android.intent.category.LAUNCHER 2>/dev/null \ + | grep -oE 'packageName=[a-zA-Z0-9_.]+' | cut -d= -f2 | sort -u > "$INSTALLED" +fi +if [ ! -s "$INSTALLED" ]; then + printf '%s\n' com.android.settings com.android.deskclock com.android.calculator2 \ + com.android.contacts com.android.documentsui com.android.camera2 com.android.dialer > "$INSTALLED" +fi +echo "Launchable packages found: $(wc -l < "$INSTALLED")" +sort "$INSTALLED" | head -40 | tr '\n' ' '; echo -# 3. Search within the app drawer (type a query) -adb shell input text "settings" -settle 2 -shot 3 app-search "App drawer search for \"settings\"" +# A sample image for the image note (ImageMagick is present on GitHub runners; +# fall back to no image note if it isn't). +HOST_IMG="" +if command -v convert >/dev/null 2>&1; then + HOST_IMG="$(mktemp --suffix=.png)" + convert -size 1000x720 gradient:'#0ea5e9'-'#7c3aed' \ + -gravity center -pointsize 54 -fill white \ + -annotate +0-30 'Trailhead' -pointsize 30 -annotate +0+40 'Sat 6:41am ยท 12ยฐC' \ + "$HOST_IMG" 2>/dev/null \ + || convert -size 1000x720 xc:'#334155' "$HOST_IMG" 2>/dev/null \ + || HOST_IMG="" +fi +SEED_IMG_PATH=""; [ -n "$HOST_IMG" ] && SEED_IMG_PATH="$IMG_DEST" -# 4. Settings (long-press on empty area of home) +# Build the two prefs XML files on the host. +NOW_MS="$(date +%s)000" +MAIN_XML="$(mktemp)"; NOTES_XML="$(mktemp)" +python3 "$SCRIPT_DIR/seed_data.py" "$MAIN_XML" "$NOTES_XML" "$INSTALLED" "$NOW_MS" "$SEED_IMG_PATH" "$AUDIO_DEST" + +# Write everything in the app's own security context (piped through run-as) so +# there are no SELinux / ownership surprises. +adb shell run-as "$APP_ID" sh -c 'cat > shared_prefs/app.launch0.xml' < "$MAIN_XML" +adb shell run-as "$APP_ID" sh -c 'cat > shared_prefs/app.launch0.notes.xml' < "$NOTES_XML" +adb shell run-as "$APP_ID" sh -c ': > files/notes_audio/audio_seed.m4a' || true +if [ -n "$HOST_IMG" ]; then + base64 -w0 "$HOST_IMG" | adb shell run-as "$APP_ID" sh -c 'base64 -d > files/notes_images/img_seed.png' || true +fi +echo "Seed files written. shared_prefs now:" +adb shell run-as "$APP_ID" ls -la shared_prefs files/notes_images 2>&1 | tr -d '\r' || true + +# ============================================================================= +# 3. Walkthrough +# ============================================================================= +adb shell am start -n "${APP_ID}/${MAIN_ACTIVITY}" >/dev/null 2>&1 +settle 4 go_home -long_press -settle 2 -shot 4 settings "Settings (long-press home)" -# 5. Notes page (swipe left) +# ---- Home ------------------------------------------------------------------- +section "Home screen" +shot 1 home "Home โ€” clock, date, year-progress widget and your apps (dark theme, right-aligned)" + +# ---- App drawer ------------------------------------------------------------- +section "App drawer" +swipe_up; settle 2 +shot 2 app-drawer "App drawer โ€” every installed app as plain text, with the Aโ€“Z fast-scroll index" + +# Live search: type a query, watch the list filter. +tap id search; settle 1 +type_text "ca"; settle 2 +shot 3 app-search "Search filters the drawer live as you type (\"ca\")" + +# Reopen a fresh, unfiltered drawer for the long-press demo. +go_home; swipe_up; settle 2 + +# Long-press an app row โ†’ the per-app action menu (rename / hide / info / uninstall). +longpress id appTitle --index 1; settle 2 +shot 4 app-menu "Long-press any app for actions: uninstall, rename, hide, app info" +# Open the inline rename editor. +tap text "Rename"; settle 2 +shot 5 app-rename "Rename an app inline, without leaving the drawer" +back; settle 1; back; settle 1 + +# ---- Settings --------------------------------------------------------------- +section "Settings" +go_home; long_press; settle 2 +shot 6 settings-home "Settings โ€” Home screen section (apps count, date/time, widgets, icons)" + +tap text "Apps on home screen" --contains; settle 1 +shot 7 settings-apps-num "Pick how many apps (0โ€“8) appear on the home screen" + +scroll_to_text "Show date time" >/dev/null 2>&1 +tap text "Show date time" --contains; settle 1 +shot 8 settings-datetime "Date & time display: On / Off / Date only" + +scroll_to_text "Icon shape" >/dev/null 2>&1 +tap text "Icon shape" --contains; settle 1 +shot 9 settings-icon-shape "Icon shapes โ€” default, circle, square, squircle, teardrop" + +scroll_to_text "App alignment" >/dev/null 2>&1 +tap text "App alignment" --contains; settle 1 +shot 10 settings-alignment "Home layout alignment โ€” left / center / right and bottom toggle" + +# Appearance section. +scroll_to_text "Theme mode" >/dev/null 2>&1 +shot 11 settings-appearance "Appearance โ€” keyboard, hourly wallpaper, status bar, theme, text size" +tap text "Theme mode" --contains; settle 1 +shot 12 settings-theme "Theme โ€” Light / Dark / System" + +# Do Not Disturb section. +scroll_to_text "Hold duration" >/dev/null 2>&1 +shot 13 settings-dnd "Do Not Disturb โ€” hold notifications and release them on your terms" +tap text "Hold duration" --contains; settle 1 +shot 14 settings-dnd-duration "How long to hold notifications โ€” 30 / 45 / 60 / 90 / 120 / 180 min" + +# Gestures section (revealed by tapping its header). +scroll_to_text "Gestures" >/dev/null 2>&1 +tap text "Gestures"; settle 1 +scroll_to_text "Swipe left for" >/dev/null 2>&1 +shot 15 settings-gestures "Gestures โ€” swipe and double-tap actions" +tap text "Swipe left for" --contains; settle 1 +shot 16 settings-swipe-left "Swipe-left action โ€” open Notes or launch an app" + +# ---- Notes ------------------------------------------------------------------ +section "Notes" +go_home; swipe_left; settle 2 +shot 17 notes "Notes โ€” a private chat with yourself: text, to-dos, an image and a voice memo" + +# Per-note actions menu on a text note. +longpress text "Grocery run" --contains; settle 2 +shot 18 notes-menu "Note actions โ€” copy, share, edit, delete" +# Edit the note: input bar pre-fills and an editing banner appears. +tap text "Edit"; settle 2 +shot 19 notes-edit "Editing a note inline โ€” the banner shows you're editing" +tap desc "Cancel"; settle 1 # clear the editing banner + +# Full-screen image viewer. +tap id notesImage; settle 2 +shot 20 notes-image "Tap an image note to view it full screen" +tap id notesFullImage 2>/dev/null || adb shell input tap "$CX" "$(pct_y 50)"; settle 1 + +# Notes search. +tap desc "Search"; settle 2 +tap id search 2>/dev/null || true # focus the search field if not already +type_text "launch"; settle 2 +shot 21 notes-search "Search your notes (\"launch\")" +back; settle 1 + +# ---- Variations ------------------------------------------------------------- +# Flip the theme to light and re-alignment via the real Settings UI, then show +# the home screen reacting โ€” exercising those controls end to end. +section "Variations" +go_home; long_press; settle 2 +scroll_to_text "Theme mode" >/dev/null 2>&1 +tap text "Theme mode" --contains; settle 1 +tap text "Light"; settle 2 go_home -swipe_left -settle 2 -shot 5 notes "Notes page (swipe left)" +shot 22 home-light "Home โ€” light theme (changed live from Settings)" +go_home; long_press; settle 2 +scroll_to_text "App alignment" >/dev/null 2>&1 +tap text "App alignment" --contains; settle 1 +tap text "Center"; settle 1 +scroll_to_text "Apps on home screen" >/dev/null 2>&1 +tap text "Apps on home screen" --contains; settle 1 +tap text "4"; settle 1 # 4 of the 6 seeded apps โ€” no blank slots go_home +shot 23 home-center "Home โ€” center-aligned app names (light theme)" +# Notes empty-state for completeness: clear seeded notes and reopen. +section "Notes" +adb shell run-as "$APP_ID" sh -c ': > shared_prefs/app.launch0.notes.xml' 2>/dev/null || true +printf "\n\n" \ + | adb shell run-as "$APP_ID" sh -c 'cat > shared_prefs/app.launch0.notes.xml' || true +adb shell am force-stop "$APP_ID" >/dev/null 2>&1 || true +adb shell am start -n "${APP_ID}/${MAIN_ACTIVITY}" >/dev/null 2>&1; settle 3 +go_home; swipe_left; settle 2 +shot 24 notes-empty "Notes โ€” empty state inviting your first jot" + +go_home +echo echo "Done. Screenshots in $OUT_DIR:" ls -la "$OUT_DIR" diff --git a/.github/workflows/android-ui-screenshots.yml b/.github/workflows/android-ui-screenshots.yml index baa6d0b6..c5b4ea5e 100644 --- a/.github/workflows/android-ui-screenshots.yml +++ b/.github/workflows/android-ui-screenshots.yml @@ -3,12 +3,16 @@ name: Android UI Screenshots # On every merge/push to main or master: # 1. Build the debug APK. # 2. Boot an Android emulator (virtual device) on the runner. -# 3. Install the APK, drive a few screens with real gestures, and screenshot -# each step (see .github/scripts/ui_screenshots.sh). +# 3. Install the APK, pre-seed realistic data (home apps + a notes history) +# straight into the app's storage, then drive every screen, sub-menu and a +# couple of theme/layout variations with real gestures, screenshotting each +# step (see .github/scripts/ui_screenshots.sh). This doubles as verification +# testing โ€” the gallery shows each feature actually rendering with data. # 4. Publish the screenshots so they render INLINE on the run page's Job -# Summary (no download needed). Because GitHub strips base64 images from -# summaries, the PNGs are pushed to an orphan `ci-screenshots` branch and -# the summary references their raw.githubusercontent.com URLs. +# Summary (no download needed), grouped by section. Because GitHub strips +# base64 images from summaries, the PNGs are pushed to an orphan +# `ci-screenshots` branch and the summary references their +# raw.githubusercontent.com URLs. # # Note: the plain-text Actions console log cannot render images; the # screenshots appear in the Job Summary on the run page. @@ -16,7 +20,11 @@ name: Android UI Screenshots on: push: branches: [main, master] - # Allow manual runs too, so you can test without merging. + # Run the walkthrough as verification on PRs into main/master too, so the + # gallery shows up on the PR while features are under review. + pull_request: + branches: [main, master] + # Allow manual runs too (any branch), so you can test without merging. workflow_dispatch: # contents: write is needed to push screenshots to the ci-screenshots branch. @@ -26,7 +34,7 @@ permissions: jobs: ui-screenshots: runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 60 steps: - name: Checkout code From 10a9a427ebf2efe43fc2cb2d0605778ba5001f36 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 11:04:43 +0000 Subject: [PATCH 2/4] ci(screenshots): fix data seeding, navigation, and variation shots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first run of the walkthrough captured the home screen for nearly every step. Three root causes, all fixed here: 1. Seeding silently failed. `adb shell run-as PKG sh -c 'cat > file'` has adb flatten argv into one string, so the device shell (uid shell, cwd /) ran the `>` redirect instead of the app-context sh โ€” "No such file or directory", and nothing got seeded. The app ran in first-run state with default apps and no notes. Fixed by passing the whole run-as invocation as one double-quoted arg (new appwrite/appwrite_b64 helpers) so the redirect stays inside the single quotes and runs in the app's data dir. 2. Navigation never happened. `input swipe โ€ฆ 120ms` wasn't detected as a fling, so swipe-up/left didn't open the drawer/notes (long-press worked, hence Settings did). Replaced with open_drawer/open_notes that swipe and then verify via uiautomator, retrying across several durations. 3. Variation + option taps hit off-screen targets. Inline selectors render below low settings rows; reveal_row now scrolls the row up first, and the home theme/alignment variations are produced by re-seeding prefs and restarting (seed_main) instead of driving the off-screen selectors. seed_data.py gains optional theme/alignment/apps-count overrides for the re-seeded variation screenshots. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PTbv7mVssh3FXvhdVAMnzN --- .github/scripts/seed_data.py | 16 +++- .github/scripts/ui_screenshots.sh | 146 +++++++++++++++++++----------- 2 files changed, 105 insertions(+), 57 deletions(-) diff --git a/.github/scripts/seed_data.py b/.github/scripts/seed_data.py index 92fa4d1a..66eb4ffa 100755 --- a/.github/scripts/seed_data.py +++ b/.github/scripts/seed_data.py @@ -79,7 +79,9 @@ def b(name, val): return f' ' -def build_main(home_apps): +def build_main(home_apps, theme=2, alignment=8388613, apps_num=None): + if apps_num is None: + apps_num = len(home_apps) lines = ["", ""] # Onboarding / first-run state: suppress dialogs and the "set default" prompt. lines += [ @@ -90,15 +92,15 @@ def build_main(home_apps): ] # Layout & appearance. lines += [ - i("HOME_APPS_NUM", len(home_apps)), + i("HOME_APPS_NUM", apps_num), i("DATE_TIME_VISIBILITY", 1), # On (clock + date) b("SHOW_YEAR_WIDGET", True), b("SHOW_APP_ICONS", True), b("SHOW_APP_NAMES", True), i("ICON_SIZE", 28), i("ICON_SHAPE", 0), - i("HOME_ALIGNMENT", 8388613), # Gravity.END (right-aligned) + i("HOME_ALIGNMENT", alignment), # Gravity.END=8388613, CENTER=17, START=8388611 b("HOME_BOTTOM_ALIGNMENT", True), b("AUTO_SHOW_KEYBOARD", False), # keep the drawer list unobscured - i("APP_THEME", 2), # AppCompatDelegate.MODE_NIGHT_YES + i("APP_THEME", theme), # MODE_NIGHT_YES=2 (dark), MODE_NIGHT_NO=1 (light) ] # Gestures. lines += [ @@ -152,11 +154,15 @@ def note(off_min, type_, text="", done=False, urgent=False, media="", dur=0): def main(): out_main, out_notes, installed_file, now_ms, image_path, audio_path = sys.argv[1:7] + # Optional appearance overrides (used to re-seed for the variation screenshots). + theme = int(sys.argv[7]) if len(sys.argv) > 7 else 2 + alignment = int(sys.argv[8]) if len(sys.argv) > 8 else 8388613 + apps_num = int(sys.argv[9]) if len(sys.argv) > 9 and sys.argv[9] else None with open(installed_file) as fh: installed = [ln.strip() for ln in fh if ln.strip()] home_apps = select_home_apps(installed) with open(out_main, "w") as fh: - fh.write(build_main(home_apps)) + fh.write(build_main(home_apps, theme, alignment, apps_num)) with open(out_notes, "w") as fh: fh.write(build_notes(int(now_ms), image_path, audio_path)) # Echo the chosen home apps so the CI log records what was seeded. diff --git a/.github/scripts/ui_screenshots.sh b/.github/scripts/ui_screenshots.sh index b450ad31..4bbc0e77 100755 --- a/.github/scripts/ui_screenshots.sh +++ b/.github/scripts/ui_screenshots.sh @@ -66,12 +66,16 @@ shot() { echo "Captured: $file [$SECTION] $caption [$(current_focus)]" } +# ---- writing into the app's private storage ---------------------------------- +# adb flattens argv into one string and the *device* shell re-parses it, so a +# `>` outside single quotes is performed by the device shell (uid shell, cwd /) +# instead of by run-as inside the app's data dir. Passing the whole run-as +# invocation as ONE double-quoted arg keeps the redirect inside the single +# quotes, so the app-context `sh -c` performs it (cwd = the app data dir). +appwrite() { adb shell "run-as $APP_ID sh -c 'cat > $1'"; } # stdin -> file +appwrite_b64() { adb shell "run-as $APP_ID sh -c 'base64 -d > $1'"; } # base64 stdin -> file + # ---- gestures ---------------------------------------------------------------- -# Fast swipes: a slow `input swipe` decelerates at the end so the terminal -# velocity drops under OnSwipeTouchListener's fling threshold and onSwipe* never -# fires. Keep the duration short so the gesture stays fast throughout. -swipe_up() { adb shell input swipe "$CX" "$(pct_y 68)" "$CX" "$(pct_y 22)" 120; } -swipe_left() { adb shell input swipe "$(pct_x 92)" "$(pct_y 45)" "$(pct_x 8)" "$(pct_y 45)" 120; } long_press() { adb shell input swipe "$CX" "$(pct_y 38)" "$CX" "$(pct_y 38)" 1800; } # A controlled (non-fling) drag, for scrolling lists/the settings page. scroll_down() { adb shell input swipe "$CX" "$(pct_y 72)" "$CX" "$(pct_y 30)" 400; settle 1; } @@ -97,14 +101,12 @@ ui_dump() { } locate() { printf '%s' "$UIX" | python3 "$SCRIPT_DIR/find_node.py" "$@"; } -# tap โ€” refresh UI, locate, tap. Returns non-zero (logs) on miss. tap() { ui_dump || { echo " [tap] ui dump failed for: $*"; return 1; } local xy; xy="$(locate "$@")" || { echo " [tap] not found: $*"; return 1; } echo " tap ($*) -> $xy" adb shell input tap $xy; return 0 } -# longpress longpress() { ui_dump || { echo " [longpress] ui dump failed for: $*"; return 1; } local xy; xy="$(locate "$@")" || { echo " [longpress] not found: $*"; return 1; } @@ -113,8 +115,49 @@ longpress() { adb shell input swipe "$1" "$2" "$1" "$2" 1800; return 0 } present() { ui_dump || return 1; locate "$@" >/dev/null 2>&1; } +type_text() { adb shell input text "$(echo "$1" | sed 's/ /%s/g')"; } -# Scroll the current (settings) page until appears, then it's tap-able. +# ---- navigation (fling-detection on this emulator is finicky, so verify+retry). +drawer_is_open() { ui_dump || return 1; locate id appTitle >/dev/null 2>&1 || locate id search >/dev/null 2>&1; } +notes_is_open() { ui_dump || return 1; locate id notesInput >/dev/null 2>&1 || locate id notesTitle >/dev/null 2>&1; } + +open_drawer() { + drawer_is_open && return 0 + local d + for d in 300 180 450 130; do + echo " swipe up -> drawer (dur=${d}ms)" + adb shell input swipe "$CX" "$(pct_y 80)" "$CX" "$(pct_y 20)" "$d" + settle 2 + drawer_is_open && return 0 + done + echo " [open_drawer] drawer did not open"; return 1 +} +open_notes() { + notes_is_open && return 0 + local d + for d in 300 180 450 130; do + echo " swipe left -> notes (dur=${d}ms)" + adb shell input swipe "$(pct_x 90)" "$(pct_y 50)" "$(pct_x 6)" "$(pct_y 50)" "$d" + settle 2 + notes_is_open && return 0 + done + echo " [open_notes] notes did not open"; return 1 +} + +# Scroll the settings page until sits in the upper part, so the inline +# options it expands render on-screen below it (not clipped off the bottom). +reveal_row() { + local text="$1" n cy + scroll_to_text "$text" >/dev/null 2>&1 + for n in 1 2 3 4 5; do + ui_dump || break + cy="$(locate text "$text" --contains 2>/dev/null | awk '{print $2}')" + [ -z "$cy" ] && break + [ "$cy" -lt "$(pct_y 45)" ] && break + scroll_down + done +} +# Scroll the settings page until appears at all, then it's tap-able. scroll_to_text() { local text="$1" n=0 while [ $n -lt 8 ]; do @@ -124,7 +167,6 @@ scroll_to_text() { done present text "$text" --contains } -type_text() { adb shell input text "$(echo "$1" | sed 's/ /%s/g')"; } # ============================================================================= # 1. Install + make default home @@ -132,8 +174,6 @@ type_text() { adb shell input text "$(echo "$1" | sed 's/ /%s/g')"; } echo "Installing $APK_PATH ..." adb install -r -t "$APK_PATH" || { echo "APK install failed"; exit 1; } echo "set-home-activity: $(adb shell cmd package set-home-activity "${APP_ID}/${MAIN_ACTIVITY}" 2>&1 | tr -d '\r')" -# Usage-access powers the on-home screen-time label and the Settings "Screen -# time" row; granting it exercises that wiring (best-effort). adb shell appops set "$APP_ID" GET_USAGE_STATS allow >/dev/null 2>&1 || true # A tidy, deterministic status bar for the screens that show one. @@ -182,21 +222,30 @@ if command -v convert >/dev/null 2>&1; then fi SEED_IMG_PATH=""; [ -n "$HOST_IMG" ] && SEED_IMG_PATH="$IMG_DEST" -# Build the two prefs XML files on the host. +# Build the two prefs XML files on the host, then write them in the app's context. NOW_MS="$(date +%s)000" MAIN_XML="$(mktemp)"; NOTES_XML="$(mktemp)" python3 "$SCRIPT_DIR/seed_data.py" "$MAIN_XML" "$NOTES_XML" "$INSTALLED" "$NOW_MS" "$SEED_IMG_PATH" "$AUDIO_DEST" -# Write everything in the app's own security context (piped through run-as) so -# there are no SELinux / ownership surprises. -adb shell run-as "$APP_ID" sh -c 'cat > shared_prefs/app.launch0.xml' < "$MAIN_XML" -adb shell run-as "$APP_ID" sh -c 'cat > shared_prefs/app.launch0.notes.xml' < "$NOTES_XML" -adb shell run-as "$APP_ID" sh -c ': > files/notes_audio/audio_seed.m4a' || true +appwrite shared_prefs/app.launch0.xml < "$MAIN_XML" +appwrite shared_prefs/app.launch0.notes.xml < "$NOTES_XML" +adb shell "run-as $APP_ID sh -c ': > files/notes_audio/audio_seed.m4a'" >/dev/null 2>&1 || true if [ -n "$HOST_IMG" ]; then - base64 -w0 "$HOST_IMG" | adb shell run-as "$APP_ID" sh -c 'base64 -d > files/notes_images/img_seed.png' || true + base64 -w0 "$HOST_IMG" | appwrite_b64 files/notes_images/img_seed.png || true fi -echo "Seed files written. shared_prefs now:" -adb shell run-as "$APP_ID" ls -la shared_prefs files/notes_images 2>&1 | tr -d '\r' || true +echo "Seed files written. App storage now:" +adb shell run-as "$APP_ID" ls -la shared_prefs files/notes_images files/notes_audio 2>&1 | tr -d '\r' || true + +# Re-seed just the main prefs with appearance overrides, then restart the app. +# seed_main (theme 2=dark 1=light; align END=8388613 CENTER=17) +seed_main() { + local out; out="$(mktemp)" + python3 "$SCRIPT_DIR/seed_data.py" "$out" /dev/null "$INSTALLED" "$NOW_MS" "$SEED_IMG_PATH" "$AUDIO_DEST" "$1" "$2" "${3:-}" + adb shell am force-stop "$APP_ID" >/dev/null 2>&1 || true + appwrite shared_prefs/app.launch0.xml < "$out" + adb shell am start -n "${APP_ID}/${MAIN_ACTIVITY}" >/dev/null 2>&1 + settle 4; rm -f "$out" +} # ============================================================================= # 3. Walkthrough @@ -211,7 +260,7 @@ shot 1 home "Home โ€” clock, date, year-progress widget and your apps (dark them # ---- App drawer ------------------------------------------------------------- section "App drawer" -swipe_up; settle 2 +open_drawer shot 2 app-drawer "App drawer โ€” every installed app as plain text, with the Aโ€“Z fast-scroll index" # Live search: type a query, watch the list filter. @@ -220,12 +269,11 @@ type_text "ca"; settle 2 shot 3 app-search "Search filters the drawer live as you type (\"ca\")" # Reopen a fresh, unfiltered drawer for the long-press demo. -go_home; swipe_up; settle 2 +go_home; open_drawer # Long-press an app row โ†’ the per-app action menu (rename / hide / info / uninstall). longpress id appTitle --index 1; settle 2 shot 4 app-menu "Long-press any app for actions: uninstall, rename, hide, app info" -# Open the inline rename editor. tap text "Rename"; settle 2 shot 5 app-rename "Rename an app inline, without leaving the drawer" back; settle 1; back; settle 1 @@ -235,29 +283,30 @@ section "Settings" go_home; long_press; settle 2 shot 6 settings-home "Settings โ€” Home screen section (apps count, date/time, widgets, icons)" +reveal_row "Apps on home screen" tap text "Apps on home screen" --contains; settle 1 shot 7 settings-apps-num "Pick how many apps (0โ€“8) appear on the home screen" -scroll_to_text "Show date time" >/dev/null 2>&1 +reveal_row "Show date time" tap text "Show date time" --contains; settle 1 shot 8 settings-datetime "Date & time display: On / Off / Date only" -scroll_to_text "Icon shape" >/dev/null 2>&1 +reveal_row "Icon shape" tap text "Icon shape" --contains; settle 1 shot 9 settings-icon-shape "Icon shapes โ€” default, circle, square, squircle, teardrop" -scroll_to_text "App alignment" >/dev/null 2>&1 +reveal_row "App alignment" tap text "App alignment" --contains; settle 1 shot 10 settings-alignment "Home layout alignment โ€” left / center / right and bottom toggle" # Appearance section. -scroll_to_text "Theme mode" >/dev/null 2>&1 +reveal_row "Theme mode" shot 11 settings-appearance "Appearance โ€” keyboard, hourly wallpaper, status bar, theme, text size" tap text "Theme mode" --contains; settle 1 shot 12 settings-theme "Theme โ€” Light / Dark / System" # Do Not Disturb section. -scroll_to_text "Hold duration" >/dev/null 2>&1 +reveal_row "Hold duration" shot 13 settings-dnd "Do Not Disturb โ€” hold notifications and release them on your terms" tap text "Hold duration" --contains; settle 1 shot 14 settings-dnd-duration "How long to hold notifications โ€” 30 / 45 / 60 / 90 / 120 / 180 min" @@ -265,26 +314,29 @@ shot 14 settings-dnd-duration "How long to hold notifications โ€” 30 / 45 / 60 / # Gestures section (revealed by tapping its header). scroll_to_text "Gestures" >/dev/null 2>&1 tap text "Gestures"; settle 1 -scroll_to_text "Swipe left for" >/dev/null 2>&1 +reveal_row "Swipe left for" shot 15 settings-gestures "Gestures โ€” swipe and double-tap actions" tap text "Swipe left for" --contains; settle 1 shot 16 settings-swipe-left "Swipe-left action โ€” open Notes or launch an app" # ---- Notes ------------------------------------------------------------------ section "Notes" -go_home; swipe_left; settle 2 +go_home; open_notes shot 17 notes "Notes โ€” a private chat with yourself: text, to-dos, an image and a voice memo" # Per-note actions menu on a text note. longpress text "Grocery run" --contains; settle 2 shot 18 notes-menu "Note actions โ€” copy, share, edit, delete" -# Edit the note: input bar pre-fills and an editing banner appears. tap text "Edit"; settle 2 shot 19 notes-edit "Editing a note inline โ€” the banner shows you're editing" tap desc "Cancel"; settle 1 # clear the editing banner # Full-screen image viewer. -tap id notesImage; settle 2 +if ! tap id notesImage; then + adb shell input swipe "$CX" "$(pct_y 35)" "$CX" "$(pct_y 70)" 400; settle 1 # reveal older notes + tap id notesImage +fi +settle 2 shot 20 notes-image "Tap an image note to view it full screen" tap id notesFullImage 2>/dev/null || adb shell input tap "$CX" "$(pct_y 50)"; settle 1 @@ -296,34 +348,24 @@ shot 21 notes-search "Search your notes (\"launch\")" back; settle 1 # ---- Variations ------------------------------------------------------------- -# Flip the theme to light and re-alignment via the real Settings UI, then show -# the home screen reacting โ€” exercising those controls end to end. +# Re-seed appearance and restart so the home screen renders the variation cleanly +# (driving the inline selectors is unreliable when options fall below the fold). section "Variations" -go_home; long_press; settle 2 -scroll_to_text "Theme mode" >/dev/null 2>&1 -tap text "Theme mode" --contains; settle 1 -tap text "Light"; settle 2 +seed_main 1 8388613 "" # light theme, right-aligned, default count go_home -shot 22 home-light "Home โ€” light theme (changed live from Settings)" +shot 22 home-light "Home โ€” light theme" -go_home; long_press; settle 2 -scroll_to_text "App alignment" >/dev/null 2>&1 -tap text "App alignment" --contains; settle 1 -tap text "Center"; settle 1 -scroll_to_text "Apps on home screen" >/dev/null 2>&1 -tap text "Apps on home screen" --contains; settle 1 -tap text "4"; settle 1 # 4 of the 6 seeded apps โ€” no blank slots +seed_main 1 17 4 # light theme, centred, 4 apps go_home -shot 23 home-center "Home โ€” center-aligned app names (light theme)" +shot 23 home-center "Home โ€” light theme, centre-aligned, fewer apps" -# Notes empty-state for completeness: clear seeded notes and reopen. +# Notes empty-state: clear the seeded notes and reopen. section "Notes" -adb shell run-as "$APP_ID" sh -c ': > shared_prefs/app.launch0.notes.xml' 2>/dev/null || true -printf "\n\n" \ - | adb shell run-as "$APP_ID" sh -c 'cat > shared_prefs/app.launch0.notes.xml' || true +printf "\n\n" \ + | appwrite shared_prefs/app.launch0.notes.xml || true adb shell am force-stop "$APP_ID" >/dev/null 2>&1 || true adb shell am start -n "${APP_ID}/${MAIN_ACTIVITY}" >/dev/null 2>&1; settle 3 -go_home; swipe_left; settle 2 +go_home; open_notes shot 24 notes-empty "Notes โ€” empty state inviting your first jot" go_home From 7260495a0a5bc047543acf096fecbc877cbf83c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 08:56:48 +0000 Subject: [PATCH 3/4] ci(screenshots): open Notes via share intent, fix settings + search + drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeding works now, but reviewing the captured images showed the walkthrough still misfired in several places. Root causes and fixes: - Notes section (every shot was actually the home screen): swipe-left never fires onSwipeLeft on this emulator. Open the Notes page deterministically via MainActivity's ACTION_SEND handler (`am start -a SEND -t text/plain`), which drops shared text onto the notes page and navigates there โ€” no gesture. - App search launched Google Calendar instead of filtering: AppDrawerAdapter auto-launches when the query narrows to exactly one app. Search "c" (matches several) instead of "ca" (only Calendar). - Settings pickers never expanded: each row's click handler is on the *value* view (homeAppsNum, dateTime, iconShape, alignment, appThemeText, dndDuration, swipeLeftAction, tvGestures), not the label I was tapping. New expand_setting taps the value id; find_node now prefers exact id matches (so "alignment" doesn't hit "alignmentLeft"). - Drawer menu/rename shots were home: the drawer didn't reopen after the search excursion. Restart fresh before reopening (a cold launch is the one state where the swipe-up fling reliably registers). - Home slot 4 showed "App": com.android.camera2 has no launchable activity under -camera-back none. Dropped Camera from the preferred home apps. - Image shot opened the notification shade: the reveal swipe was a home swipe-down; scroll within the notes list instead. Also drop the notes empty-state shot (couldn't be reached without the broken swipe) and add diag() logging on navigation misses. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PTbv7mVssh3FXvhdVAMnzN --- .github/scripts/find_node.py | 13 +++- .github/scripts/seed_data.py | 7 +- .github/scripts/ui_screenshots.sh | 109 ++++++++++++++++-------------- 3 files changed, 74 insertions(+), 55 deletions(-) diff --git a/.github/scripts/find_node.py b/.github/scripts/find_node.py index f6c399a8..f5244b95 100755 --- a/.github/scripts/find_node.py +++ b/.github/scripts/find_node.py @@ -57,17 +57,24 @@ def main() -> int: data = re.sub(r"&(?!amp;|lt;|gt;|quot;|apos;|#)", "&", data) root = ET.fromstring(data) - matches = [] + matches, exact = [], [] for node in root.iter("node"): av = node.get(xml_attr, "") if xml_attr == "resource-id": - ok = av == value or av.endswith("/" + value) or value in av + is_exact = av == value or av.endswith("/" + value) + ok = is_exact or value in av elif contains: + is_exact = av == value ok = value in av else: - ok = av == value + is_exact = ok = av == value if ok and (not clickable or node.get("clickable") == "true"): matches.append(node) + if is_exact: + exact.append(node) + # Prefer exact id/text matches (e.g. id "alignment" over "alignmentLeft"). + if exact: + matches = exact if not matches: return 1 diff --git a/.github/scripts/seed_data.py b/.github/scripts/seed_data.py index 66eb4ffa..4d0e4f5b 100755 --- a/.github/scripts/seed_data.py +++ b/.github/scripts/seed_data.py @@ -29,17 +29,18 @@ ("Phone", ["com.google.android.dialer", "com.android.dialer"]), ("Messages", ["com.google.android.apps.messaging", "com.android.messaging"]), ("Chrome", ["com.android.chrome"]), - ("Camera", ["com.android.camera2", "com.google.android.GoogleCamera"]), ("Photos", ["com.google.android.apps.photos", "com.android.gallery3d"]), ("Calendar", ["com.google.android.calendar", "com.android.calendar"]), ("Clock", ["com.google.android.deskclock", "com.android.deskclock"]), - ("Calculator", ["com.android.calculator2", "com.google.android.calculator"]), ("Contacts", ["com.google.android.contacts", "com.android.contacts"]), ("Files", ["com.android.documentsui"]), - ("Settings", ["com.android.settings"]), ("Maps", ["com.google.android.apps.maps"]), ("Gmail", ["com.google.android.gm"]), + ("Settings", ["com.android.settings"]), + ("Calculator", ["com.android.calculator2", "com.google.android.calculator"]), ("YouTube", ["com.google.android.youtube"]), + # Camera is intentionally omitted: the CI emulator runs with -camera-back none, + # so com.android.camera2 has no launchable activity and would blank its slot. ] diff --git a/.github/scripts/ui_screenshots.sh b/.github/scripts/ui_screenshots.sh index 4bbc0e77..24c58bdf 100755 --- a/.github/scripts/ui_screenshots.sh +++ b/.github/scripts/ui_screenshots.sh @@ -117,10 +117,27 @@ longpress() { present() { ui_dump || return 1; locate "$@" >/dev/null 2>&1; } type_text() { adb shell input text "$(echo "$1" | sed 's/ /%s/g')"; } -# ---- navigation (fling-detection on this emulator is finicky, so verify+retry). +# Log what's actually on screen when navigation doesn't land where expected. +diag() { + echo " [diag] $1 | focus: $(current_focus)" + ui_dump && echo " [diag] ids on screen:$(printf '%s' "$UIX" \ + | grep -oE 'id/(appTitle|recyclerView|notesTitle|notesInput|search|homeApp1|mainLayout)' | sort -u | tr '\n' ' ')" +} + +# Cold-restart the app to a clean home. A fresh launch is the one state where the +# swipe-up-to-drawer fling reliably registers (later swipes after a round-trip do +# not), so we restart before each drawer open. +fresh_home() { + adb shell am force-stop "$APP_ID" >/dev/null 2>&1 || true + adb shell am start -n "${APP_ID}/${MAIN_ACTIVITY}" >/dev/null 2>&1 + settle 4; go_home +} + +# ---- navigation ------------------------------------------------------------- drawer_is_open() { ui_dump || return 1; locate id appTitle >/dev/null 2>&1 || locate id search >/dev/null 2>&1; } notes_is_open() { ui_dump || return 1; locate id notesInput >/dev/null 2>&1 || locate id notesTitle >/dev/null 2>&1; } +# Swipe up to the app drawer. Fling detection is finicky, so verify and retry. open_drawer() { drawer_is_open && return 0 local d @@ -130,18 +147,25 @@ open_drawer() { settle 2 drawer_is_open && return 0 done - echo " [open_drawer] drawer did not open"; return 1 + echo " [open_drawer] drawer did not open"; diag "open_drawer"; return 1 } + +# Open the Notes page deterministically: a swipe-left fling does not register on +# this emulator, but MainActivity's ACTION_SEND handler drops shared text onto the +# notes page and navigates there. This also adds one note (a realistic extra). open_notes() { notes_is_open && return 0 - local d - for d in 300 180 450 130; do - echo " swipe left -> notes (dur=${d}ms)" - adb shell input swipe "$(pct_x 90)" "$(pct_y 50)" "$(pct_x 6)" "$(pct_y 50)" "$d" - settle 2 - notes_is_open && return 0 - done - echo " [open_notes] notes did not open"; return 1 + adb shell "am start -n ${APP_ID}/${MAIN_ACTIVITY} -a android.intent.action.SEND -t text/plain --es android.intent.extra.TEXT 'Reminder: water the plants and refill the bird feeder'" >/dev/null 2>&1 + settle 3 + notes_is_open && return 0 + echo " [open_notes] notes did not open"; diag "open_notes"; return 1 +} + +# Expand a Settings inline selector. The click target is the row's *value* view +# (e.g. id "appThemeText"), not its label โ€” tapping the label does nothing. +expand_setting() { # + reveal_row "$1" + tap id "$2"; settle 1 } # Scroll the settings page until sits in the upper part, so the inline @@ -263,15 +287,14 @@ section "App drawer" open_drawer shot 2 app-drawer "App drawer โ€” every installed app as plain text, with the Aโ€“Z fast-scroll index" -# Live search: type a query, watch the list filter. +# Live search. Use a query that matches several apps: a single match would +# auto-launch that app (AppDrawerAdapter.autoLaunch) and leave the drawer. tap id search; settle 1 -type_text "ca"; settle 2 -shot 3 app-search "Search filters the drawer live as you type (\"ca\")" +type_text "c"; settle 2 +shot 3 app-search "Search filters the drawer live as you type (\"c\")" -# Reopen a fresh, unfiltered drawer for the long-press demo. -go_home; open_drawer - -# Long-press an app row โ†’ the per-app action menu (rename / hide / info / uninstall). +# Fresh restart โ†’ clean unfiltered drawer (keeps the swipe-up fling reliable). +fresh_home; open_drawer longpress id appTitle --index 1; settle 2 shot 4 app-menu "Long-press any app for actions: uninstall, rename, hide, app info" tap text "Rename"; settle 2 @@ -279,44 +302,42 @@ shot 5 app-rename "Rename an app inline, without leaving the drawer" back; settle 1; back; settle 1 # ---- Settings --------------------------------------------------------------- +# Each row's inline selector is opened by tapping the row's *value* view (its id), +# not the label โ€” tapping the label does nothing. section "Settings" go_home; long_press; settle 2 shot 6 settings-home "Settings โ€” Home screen section (apps count, date/time, widgets, icons)" -reveal_row "Apps on home screen" -tap text "Apps on home screen" --contains; settle 1 -shot 7 settings-apps-num "Pick how many apps (0โ€“8) appear on the home screen" +expand_setting "Apps on home screen" homeAppsNum +shot 7 settings-apps-num "Choose how many apps (0โ€“8) show on the home screen" -reveal_row "Show date time" -tap text "Show date time" --contains; settle 1 +expand_setting "Show date time" dateTime shot 8 settings-datetime "Date & time display: On / Off / Date only" -reveal_row "Icon shape" -tap text "Icon shape" --contains; settle 1 +expand_setting "Icon shape" iconShape shot 9 settings-icon-shape "Icon shapes โ€” default, circle, square, squircle, teardrop" -reveal_row "App alignment" -tap text "App alignment" --contains; settle 1 -shot 10 settings-alignment "Home layout alignment โ€” left / center / right and bottom toggle" +expand_setting "App alignment" alignment +shot 10 settings-alignment "Home app alignment โ€” left / center / right, plus a bottom toggle" # Appearance section. reveal_row "Theme mode" shot 11 settings-appearance "Appearance โ€” keyboard, hourly wallpaper, status bar, theme, text size" -tap text "Theme mode" --contains; settle 1 +expand_setting "Theme mode" appThemeText shot 12 settings-theme "Theme โ€” Light / Dark / System" # Do Not Disturb section. reveal_row "Hold duration" shot 13 settings-dnd "Do Not Disturb โ€” hold notifications and release them on your terms" -tap text "Hold duration" --contains; settle 1 +expand_setting "Hold duration" dndDuration shot 14 settings-dnd-duration "How long to hold notifications โ€” 30 / 45 / 60 / 90 / 120 / 180 min" -# Gestures section (revealed by tapping its header). +# Gestures section (its swipe-down row is revealed by tapping the header). scroll_to_text "Gestures" >/dev/null 2>&1 -tap text "Gestures"; settle 1 +tap id tvGestures; settle 1 reveal_row "Swipe left for" shot 15 settings-gestures "Gestures โ€” swipe and double-tap actions" -tap text "Swipe left for" --contains; settle 1 +expand_setting "Swipe left for" swipeLeftAction shot 16 settings-swipe-left "Swipe-left action โ€” open Notes or launch an app" # ---- Notes ------------------------------------------------------------------ @@ -324,23 +345,23 @@ section "Notes" go_home; open_notes shot 17 notes "Notes โ€” a private chat with yourself: text, to-dos, an image and a voice memo" -# Per-note actions menu on a text note. -longpress text "Grocery run" --contains; settle 2 +# Per-note actions menu on a text note near the bottom (guaranteed in view). +longpress text "Standup" --contains; settle 2 shot 18 notes-menu "Note actions โ€” copy, share, edit, delete" tap text "Edit"; settle 2 shot 19 notes-edit "Editing a note inline โ€” the banner shows you're editing" tap desc "Cancel"; settle 1 # clear the editing banner -# Full-screen image viewer. +# Full-screen image viewer (scroll the notes list up if the image note isn't in view). if ! tap id notesImage; then - adb shell input swipe "$CX" "$(pct_y 35)" "$CX" "$(pct_y 70)" 400; settle 1 # reveal older notes + adb shell input swipe "$CX" "$(pct_y 30)" "$CX" "$(pct_y 75)" 500; settle 1 tap id notesImage fi settle 2 shot 20 notes-image "Tap an image note to view it full screen" tap id notesFullImage 2>/dev/null || adb shell input tap "$CX" "$(pct_y 50)"; settle 1 -# Notes search. +# Notes search (opens a dedicated search activity). tap desc "Search"; settle 2 tap id search 2>/dev/null || true # focus the search field if not already type_text "launch"; settle 2 @@ -348,26 +369,16 @@ shot 21 notes-search "Search your notes (\"launch\")" back; settle 1 # ---- Variations ------------------------------------------------------------- -# Re-seed appearance and restart so the home screen renders the variation cleanly -# (driving the inline selectors is unreliable when options fall below the fold). +# Re-seed appearance and restart so the home screen renders the variation cleanly. section "Variations" seed_main 1 8388613 "" # light theme, right-aligned, default count go_home shot 22 home-light "Home โ€” light theme" -seed_main 1 17 4 # light theme, centred, 4 apps +seed_main 1 17 4 # light theme, centred (Gravity.CENTER), 4 apps go_home shot 23 home-center "Home โ€” light theme, centre-aligned, fewer apps" -# Notes empty-state: clear the seeded notes and reopen. -section "Notes" -printf "\n\n" \ - | appwrite shared_prefs/app.launch0.notes.xml || true -adb shell am force-stop "$APP_ID" >/dev/null 2>&1 || true -adb shell am start -n "${APP_ID}/${MAIN_ACTIVITY}" >/dev/null 2>&1; settle 3 -go_home; open_notes -shot 24 notes-empty "Notes โ€” empty state inviting your first jot" - go_home echo echo "Done. Screenshots in $OUT_DIR:" From 1cd634f2bf8a3a2ba0db362e876ec0bcd741a77b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 30 Jun 2026 11:46:57 +0000 Subject: [PATCH 4/4] ci(screenshots): reliable drawer/notes-menu, seed image note without ImageMagick Viewing run #3: home, drawer, search, all Settings pickers, the notes list, notes search and both variations now render correctly. Three things still misfired: - App-menu/rename shots were the home screen: the swipe-up fling won't register after a restart, so reopening the drawer failed. open_drawer now falls back to long-pressing a home app (onLongClick -> showAppList), which opens the same searchable app list reliably and without the single-match auto-launch. - Note action dialog never opened (long-press selected the note text instead): notesText is textIsSelectable while the row's long-click is on the root view. New longpress_note presses the row gap just right of the bubble (find_node gained --bounds) so the root long-click fires -> Copy/Share/Edit/Delete. - Image note was missing ("9 entries", no thumbnail): the runner has no ImageMagick, so the sample PNG was never created and the image entry was skipped. Generate it with pure-stdlib Python (make_png.py) and log the pushed size to confirm it lands. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PTbv7mVssh3FXvhdVAMnzN --- .github/scripts/find_node.py | 9 ++++-- .github/scripts/make_png.py | 42 +++++++++++++++++++++++++++ .github/scripts/ui_screenshots.sh | 48 +++++++++++++++++++------------ 3 files changed, 79 insertions(+), 20 deletions(-) create mode 100644 .github/scripts/make_png.py diff --git a/.github/scripts/find_node.py b/.github/scripts/find_node.py index f5244b95..d69a2f94 100755 --- a/.github/scripts/find_node.py +++ b/.github/scripts/find_node.py @@ -26,7 +26,7 @@ def main() -> int: args = sys.argv[1:] - contains = clickable = False + contains = clickable = want_bounds = False index = 0 positional = [] i = 0 @@ -36,6 +36,8 @@ def main() -> int: contains = True elif a == "--clickable": clickable = True + elif a == "--bounds": + want_bounds = True elif a == "--index": i += 1 index = int(args[i]) @@ -87,7 +89,10 @@ def main() -> int: if not m: return 1 x1, y1, x2, y2 = map(int, m.groups()) - print((x1 + x2) // 2, (y1 + y2) // 2) + if want_bounds: + print(x1, y1, x2, y2) + else: + print((x1 + x2) // 2, (y1 + y2) // 2) return 0 diff --git a/.github/scripts/make_png.py b/.github/scripts/make_png.py new file mode 100644 index 00000000..b19084d3 --- /dev/null +++ b/.github/scripts/make_png.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" +Write a simple vertical-gradient PNG using only the standard library, so the +image note can be seeded without depending on ImageMagick (which isn't reliably +present on the CI runner). Usage: make_png.py [width] [height] +""" +import struct +import sys +import zlib + + +def write_png(path, w=1000, h=700): + c0, c1 = (14, 165, 233), (124, 58, 237) # teal -> purple + + def lerp(a, b, t): + return int(a + (b - a) * t) + + raw = bytearray() + for y in range(h): + t = y / (h - 1) + px = bytes((lerp(c0[0], c1[0], t), lerp(c0[1], c1[1], t), lerp(c0[2], c1[2], t))) + raw.append(0) # filter type 0 for the scanline + raw.extend(px * w) + + def chunk(typ, data): + body = typ + data + return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + + ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0) # 8-bit RGB + with open(path, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n") + f.write(chunk(b"IHDR", ihdr)) + f.write(chunk(b"IDAT", zlib.compress(bytes(raw), 9))) + f.write(chunk(b"IEND", b"")) + + +if __name__ == "__main__": + write_png( + sys.argv[1], + int(sys.argv[2]) if len(sys.argv) > 2 else 1000, + int(sys.argv[3]) if len(sys.argv) > 3 else 700, + ) diff --git a/.github/scripts/ui_screenshots.sh b/.github/scripts/ui_screenshots.sh index 24c58bdf..d1a17ef9 100755 --- a/.github/scripts/ui_screenshots.sh +++ b/.github/scripts/ui_screenshots.sh @@ -137,17 +137,36 @@ fresh_home() { drawer_is_open() { ui_dump || return 1; locate id appTitle >/dev/null 2>&1 || locate id search >/dev/null 2>&1; } notes_is_open() { ui_dump || return 1; locate id notesInput >/dev/null 2>&1 || locate id notesTitle >/dev/null 2>&1; } -# Swipe up to the app drawer. Fling detection is finicky, so verify and retry. +# Open the app list. Try the real swipe-up-to-drawer fling first (finicky, so +# verify+retry); if that won't register, fall back to long-pressing a home app, +# which opens the same searchable app list (in "pick an app" mode) reliably. open_drawer() { drawer_is_open && return 0 local d - for d in 300 180 450 130; do + for d in 300 180 450; do echo " swipe up -> drawer (dur=${d}ms)" adb shell input swipe "$CX" "$(pct_y 80)" "$CX" "$(pct_y 20)" "$d" settle 2 drawer_is_open && return 0 done - echo " [open_drawer] drawer did not open"; diag "open_drawer"; return 1 + echo " swipe-up didn't register; long-pressing a home app to open the app list" + longpress id homeApp1; settle 2 + drawer_is_open && return 0 + echo " [open_drawer] app list did not open"; diag "open_drawer"; return 1 +} + +# Long-press a text note to raise its action dialog (Copy/Share/Edit/Delete). +# The note text is textIsSelectable, so pressing it starts text selection; the +# row's long-click lives on the root view, so press the gap just right of the +# bubble instead. +longpress_note() { # + ui_dump || return 1 + local b; b="$(locate text "$1" --contains --bounds)" || { echo " [longpress_note] not found: $1"; return 1; } + set -- $b # x1 y1 x2 y2 + local x=$(( $3 + 45 )) y=$(( ($2 + $4) / 2 )) + [ "$x" -gt "$(pct_x 82)" ] && x=$(( $3 + 20 )) # stay left of the flag/checkbox + echo " long-press note gap at ($x,$y)" + adb shell input swipe "$x" "$y" "$x" "$y" 1800 } # Open the Notes page deterministically: a swipe-left fling does not register on @@ -232,19 +251,11 @@ fi echo "Launchable packages found: $(wc -l < "$INSTALLED")" sort "$INSTALLED" | head -40 | tr '\n' ' '; echo -# A sample image for the image note (ImageMagick is present on GitHub runners; -# fall back to no image note if it isn't). -HOST_IMG="" -if command -v convert >/dev/null 2>&1; then - HOST_IMG="$(mktemp --suffix=.png)" - convert -size 1000x720 gradient:'#0ea5e9'-'#7c3aed' \ - -gravity center -pointsize 54 -fill white \ - -annotate +0-30 'Trailhead' -pointsize 30 -annotate +0+40 'Sat 6:41am ยท 12ยฐC' \ - "$HOST_IMG" 2>/dev/null \ - || convert -size 1000x720 xc:'#334155' "$HOST_IMG" 2>/dev/null \ - || HOST_IMG="" -fi -SEED_IMG_PATH=""; [ -n "$HOST_IMG" ] && SEED_IMG_PATH="$IMG_DEST" +# A sample image for the image note, generated with pure-stdlib Python (no +# ImageMagick dependency โ€” `convert` isn't reliably present on the runner). +HOST_IMG="$(mktemp --suffix=.png)" +python3 "$SCRIPT_DIR/make_png.py" "$HOST_IMG" 1000 700 || HOST_IMG="" +SEED_IMG_PATH=""; [ -n "$HOST_IMG" ] && [ -s "$HOST_IMG" ] && SEED_IMG_PATH="$IMG_DEST" # Build the two prefs XML files on the host, then write them in the app's context. NOW_MS="$(date +%s)000" @@ -254,8 +265,9 @@ python3 "$SCRIPT_DIR/seed_data.py" "$MAIN_XML" "$NOTES_XML" "$INSTALLED" "$NOW_M appwrite shared_prefs/app.launch0.xml < "$MAIN_XML" appwrite shared_prefs/app.launch0.notes.xml < "$NOTES_XML" adb shell "run-as $APP_ID sh -c ': > files/notes_audio/audio_seed.m4a'" >/dev/null 2>&1 || true -if [ -n "$HOST_IMG" ]; then +if [ -n "$SEED_IMG_PATH" ]; then base64 -w0 "$HOST_IMG" | appwrite_b64 files/notes_images/img_seed.png || true + echo "Pushed image note: $(adb shell run-as "$APP_ID" wc -c files/notes_images/img_seed.png 2>&1 | tr -d '\r') (host $(wc -c < "$HOST_IMG") bytes)" fi echo "Seed files written. App storage now:" adb shell run-as "$APP_ID" ls -la shared_prefs files/notes_images files/notes_audio 2>&1 | tr -d '\r' || true @@ -346,7 +358,7 @@ go_home; open_notes shot 17 notes "Notes โ€” a private chat with yourself: text, to-dos, an image and a voice memo" # Per-note actions menu on a text note near the bottom (guaranteed in view). -longpress text "Standup" --contains; settle 2 +longpress_note "Standup"; settle 2 shot 18 notes-menu "Note actions โ€” copy, share, edit, delete" tap text "Edit"; settle 2 shot 19 notes-edit "Editing a note inline โ€” the banner shows you're editing"