diff --git a/.github/scripts/find_node.py b/.github/scripts/find_node.py new file mode 100755 index 00000000..d69a2f94 --- /dev/null +++ b/.github/scripts/find_node.py @@ -0,0 +1,100 @@ +#!/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 = want_bounds = 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 == "--bounds": + want_bounds = 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, exact = [], [] + for node in root.iter("node"): + av = node.get(xml_attr, "") + if xml_attr == "resource-id": + 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: + 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 + 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()) + if want_bounds: + print(x1, y1, x2, y2) + else: + print((x1 + x2) // 2, (y1 + y2) // 2) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) 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/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..4d0e4f5b --- /dev/null +++ b/.github/scripts/seed_data.py @@ -0,0 +1,174 @@ +#!/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"]), + ("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"]), + ("Contacts", ["com.google.android.contacts", "com.android.contacts"]), + ("Files", ["com.android.documentsui"]), + ("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. +] + + +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, 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 += [ + 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", 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", 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", theme), # MODE_NIGHT_YES=2 (dark), MODE_NIGHT_NO=1 (light) + ] + # 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] + # 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, 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. + 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..d1a17ef9 100755 --- a/.github/scripts/ui_screenshots.sh +++ b/.github/scripts/ui_screenshots.sh @@ -1,109 +1,397 @@ #!/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 -# 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; } +# ---- 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 ---------------------------------------------------------------- +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() { + 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() { + 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; } +type_text() { adb shell input text "$(echo "$1" | sed 's/ /%s/g')"; } + +# 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; } + +# 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; 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 " 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 +# 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 + 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 +# 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 + present text "$text" --contains && return 0 + scroll_down + n=$((n+1)) + done + present text "$text" --contains +} -# 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 +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, 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" -# 4. Settings (long-press on empty area of home) +# 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" + +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 "$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 + +# 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 +# ============================================================================= +adb shell am start -n "${APP_ID}/${MAIN_ACTIVITY}" >/dev/null 2>&1 +settle 4 go_home -long_press + +# ---- 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" +open_drawer +shot 2 app-drawer "App drawer — every installed app as plain text, with the A–Z fast-scroll index" + +# 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 "c"; settle 2 +shot 3 app-search "Search filters the drawer live as you type (\"c\")" + +# 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 +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)" + +expand_setting "Apps on home screen" homeAppsNum +shot 7 settings-apps-num "Choose how many apps (0–8) show on the home screen" + +expand_setting "Show date time" dateTime +shot 8 settings-datetime "Date & time display: On / Off / Date only" + +expand_setting "Icon shape" iconShape +shot 9 settings-icon-shape "Icon shapes — default, circle, square, squircle, teardrop" + +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" +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" +expand_setting "Hold duration" dndDuration +shot 14 settings-dnd-duration "How long to hold notifications — 30 / 45 / 60 / 90 / 120 / 180 min" + +# Gestures section (its swipe-down row is revealed by tapping the header). +scroll_to_text "Gestures" >/dev/null 2>&1 +tap id tvGestures; settle 1 +reveal_row "Swipe left for" +shot 15 settings-gestures "Gestures — swipe and double-tap actions" +expand_setting "Swipe left for" swipeLeftAction +shot 16 settings-swipe-left "Swipe-left action — open Notes or launch an app" + +# ---- Notes ------------------------------------------------------------------ +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 near the bottom (guaranteed in view). +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" +tap desc "Cancel"; settle 1 # clear the editing banner + +# 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 30)" "$CX" "$(pct_y 75)" 500; settle 1 + tap id notesImage +fi settle 2 -shot 4 settings "Settings (long-press home)" +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 -# 5. Notes page (swipe left) +# 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 +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. +section "Variations" +seed_main 1 8388613 "" # light theme, right-aligned, default count go_home -swipe_left -settle 2 -shot 5 notes "Notes page (swipe left)" +shot 22 home-light "Home — light theme" +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" +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