Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions .github/scripts/find_node.py
Original file line number Diff line number Diff line change
@@ -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 <attr> <value> [--contains] [--clickable] [--index N]

<attr> one of: text | desc | id | class
<value> 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 "<cx> <cy>" 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 <attr> <value> [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;|#)", "&amp;", 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())
42 changes: 42 additions & 0 deletions .github/scripts/make_png.py
Original file line number Diff line number Diff line change
@@ -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 <path> [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,
)
21 changes: 15 additions & 6 deletions .github/scripts/publish_screenshots.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<run_id>/, 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
Expand Down Expand Up @@ -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 "<img alt=\"${caption}\" width=\"300\" src=\"${RAW}/${file}\" />"
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)."
174 changes: 174 additions & 0 deletions .github/scripts/seed_data.py
Original file line number Diff line number Diff line change
@@ -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 <out_main_xml> <out_notes_xml> <installed_pkgs_file> \
<now_ms> <image_path> <audio_path>

<image_path> 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' <string name="{escape(name)}">{escape(str(val))}</string>'


def i(name, val):
return f' <int name="{escape(name)}" value="{int(val)}" />'


def b(name, val):
return f' <boolean name="{escape(name)}" value="{"true" if val else "false"}" />'


def build_main(home_apps, theme=2, alignment=8388613, apps_num=None):
if apps_num is None:
apps_num = len(home_apps)
lines = ["<?xml version='1.0' encoding='utf-8' standalone='yes' ?>", "<map>"]
# 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(' <set name="DND_APPS">')
lines.append(f" <string>{escape(home_apps[0][1])}</string>")
lines.append(" </set>")
lines.append("</map>")
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 (
"<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n<map>\n"
f' <string name="NOTES_ENTRIES">{escape(blob)}</string>\n</map>\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()
Loading
Loading