From f22f71f028f07da26b11c2a3a24da6233707b6cb Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 2 Aug 2026 21:11:20 +0200 Subject: [PATCH 1/2] Bring screen-tour to main The tool was written on the redesign branch, where it produced the main-vs- redesign comparison, but nothing in it is about that branch: it walks a build through the screens that carry its design and photographs each one, and collage.py lays two builds' screenshots out side by side as a PDF. One tour walks both designs - each step looks for a toolbar item or a floating button and takes whichever is on screen - so it keeps working across a branch that moves things. Useful against any change that alters what the app looks like, so it should not have to wait for the redesign to land. The files are unchanged from f676ebaa and 56795abf; only the CLAUDE.md entry is rewritten, because that file has a different shape here than on redesign. Also ignore python bytecode. Running the tool leaves a __pycache__ next to it and it kept showing up as untracked. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E1ANEeai87KtnX5pBzgDEJ --- .gitignore | 7 + CLAUDE.md | 16 ++ tools/screen-tour/README.md | 112 ++++++++ tools/screen-tour/collage.py | 312 ++++++++++++++++++++++ tools/screen-tour/screen-tour.py | 444 +++++++++++++++++++++++++++++++ tools/screen-tour/story.json | 122 +++++++++ 6 files changed, 1013 insertions(+) create mode 100644 tools/screen-tour/README.md create mode 100755 tools/screen-tour/collage.py create mode 100755 tools/screen-tour/screen-tour.py create mode 100644 tools/screen-tour/story.json diff --git a/.gitignore b/.gitignore index c381323abd49..e9cebaa9be2f 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,10 @@ infra/scw-transfer/secret.auto.tfvars # Terraform state files *.tfstate *.tfstate.backup + +# python bytecode from the tools +__pycache__/ +*.pyc + +# local agent state +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md index dea736c115ec..e1246f0b178d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ### Clean - `./gradlew clean` - Clean build artifacts +## Tools + +Two of them, both under `tools/`, both for *looking* at the app rather than testing it - +nothing in either asserts anything or fails a build. Each has a README next to it. + +- **`render-sweep`** opens a whole corpus of documents one at a time and records what the app + made of each: a screenshot, the text the WebView showed, and logcat. For "does this format + still render", across far more files than the instrumented tests touch. +- **`screen-tour`** walks one build through the six screens that carry its design, then lays + two builds' screenshots side by side as a PDF. For "what does this change actually look + like next to main". One tour walks both designs - the steps look for a toolbar item *or* a + floating button - so it keeps working across a branch that moves things. + +Reach for `screen-tour` before hand-driving an emulator with `adb shell input tap`. That is +what it replaced, and the second round of tapping is always the expensive one. + ## Architecture Overview ### Core Components diff --git a/tools/screen-tour/README.md b/tools/screen-tour/README.md new file mode 100644 index 000000000000..429b283678b2 --- /dev/null +++ b/tools/screen-tour/README.md @@ -0,0 +1,112 @@ +# screen-tour + +Walks a build through the handful of screens that carry its design, photographs each one, +and lays two builds' photographs out side by side as a PDF. + +This is what produced `main-vs-redesign.pdf`. It exists because the comparison was worth +having twice: the first round of it was a pile of `adb shell input tap 1148 2652` typed by +hand, and every follow-up question - *does the logo stay?*, *is the shadow clipped?* - meant +typing all of it again, against a screen whose buttons had moved. + +Like [render-sweep](../render-sweep), it is a **looking** tool. Nothing asserts, nothing +fails a build. It hands you pictures and you decide what you think of them. + +## Running it + +You need a device or emulator on `adb`, and Pillow for the PDF (`pip install pillow`). + +```sh +# the branch you are on +./gradlew assembleProDebug +tools/screen-tour/screen-tour.py --install app/build/outputs/apk/pro/debug/app-pro-debug.apk \ + --out build/screen-tour/redesign + +# what you are comparing it against, from a worktree of the other branch +git worktree add ../odr-main main +(cd ../odr-main && ./gradlew assembleProDebug) +tools/screen-tour/screen-tour.py \ + --install ../odr-main/app/build/outputs/apk/pro/debug/app-pro-debug.apk \ + --out build/screen-tour/main + +# and the lite flavour, if the page about ads is wanted - a different application id, +# so it sits on the device next to the other one +./gradlew assembleLiteDebug +tools/screen-tour/screen-tour.py --package at.tomtasche.reader \ + --install app/build/outputs/apk/lite/debug/app-lite-debug.apk \ + --out build/screen-tour/lite + +tools/screen-tour/collage.py --out build/screen-tour/main-vs-redesign.pdf \ + --set main=build/screen-tour/main \ + --set redesign=build/screen-tour/redesign \ + --set lite=build/screen-tour/lite +``` + +A tour takes about two minutes. Put the emulator somewhere you can see it: watching it drive +itself is how you notice a step that landed somewhere unintended. + +## The screens + +Six, and the names are what the collage looks for: + +| shot | what it is | +| --- | --- | +| `01-first-launch.png` | a fresh install, nothing opened yet | +| `02-open.png` | whatever the Open action puts on screen first | +| `03-document.png` | `test.odt` rendered, no chrome touched | +| `04-menu.png` | every action the document offers, unfolded | +| `05-search.png` | the find bar, with a term typed and entered | +| `06-recents.png` | where the app keeps what has been opened | + +Three documents are opened, in the order `--document` gives them, so the last is the one on +screen for shots 3 to 5 and the recents list has something to sort. They come from +`app/src/androidTest/assets`, which is small, in the repo, and covers three formats. + +## What the story file is for + +`collage.py` draws no prose of its own. Page titles and the caption under each screenshot +live in `story.json`, which is the file to edit when the design changes - and the file to +copy when the comparison is between two other things entirely (`--story mine.json`). + +A page names a `shot` and one or two `columns`, each naming a `--set`. Two columns is the +side-by-side layout; one column puts the screenshot on the left and gives the caption the +rest of the page, which is what the last page of the ads comparison uses. + +## Why it works the way it does + +**Documents go in through `adb push` to shared storage.** The opposite of what render-sweep +does, deliberately: that tool launches an intent at the app directly, and the app - which +declares no storage permission - cannot read a pushed file. Here every document is opened +through the system picker, and the picker is what grants the app the uri. Photographing a +document the app reached a way no user can would defeat the point. + +**Nothing is seeded into the app's own storage.** Writing three entries into +`recent_documents.json` would be quicker than opening three documents through a picker, and +would produce a screenshot of a list the app then throws away: the uris carry no grant, and +`LandingViewModel.reload()` drops what it cannot resolve. + +**Each step looks for several things.** The two designs put their actions in different +places - a toolbar and a floating button - so `MORE_BUTTON`, `OPEN_BUTTON` and the rest are +lists, tried in order, first one on screen wins. That is what lets one tour walk both +branches. When a branch grows a screen these do not cover, add to the list rather than +forking the tour. + +**Steps wait for what they need instead of sleeping a fixed time.** The exception that +proves it: the lite flavour's consent sheet arrives whenever the ad sdk finishes starting, +sometimes after the landing screen is already drawn, and the first version of this shot the +app with a consent dialog over it. + +**The recents shot taps a dead corner first.** A row keeps the pressed highlight of the tap +that opened it, and it photographs as a selected row that nothing selected. + +## The PDF is a raster, at 300 dpi + +Every page is an image - there are no text objects in the output, so nothing in it is +selectable or searchable, and zooming far enough in will always find pixels. It was 150 dpi +to begin with and looked it, which is the whole reason the resolution is a flag. + +`--dpi 300` is the default and is about 4 MB for eight pages. `--dpi 150` is a quarter of +that and fine on a screen at a glance; `--dpi 600` is for printing, and slow. The layout is +written in points and scaled, so the pages look the same at any of them. + +Making it vector would mean a PDF library this repo does not otherwise need. Pillow is +already the only dependency, and 300 dpi was cheaper than the argument. diff --git a/tools/screen-tour/collage.py b/tools/screen-tour/collage.py new file mode 100755 index 000000000000..2ef6a7d21b37 --- /dev/null +++ b/tools/screen-tour/collage.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +# +# Lays sets of screen-tour shots out as a PDF: a contact sheet, then one page per +# screen with the same screen from each set beside itself. +# +# What each page says is prose, and prose does not belong in code - it lives in a +# story file (story.json next to this one by default), which is the thing to edit +# when the design changes. +# +# Usage: tools/screen-tour/collage.py --set main=DIR --set redesign=DIR +# See README.md next to this file. + +import argparse +import json +import os +import sys + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: + sys.exit("this needs Pillow: pip install pillow") + +HERE = os.path.dirname(os.path.abspath(__file__)) + +# A4, in points. Everything below is written in points and multiplied by the +# scale the requested dpi asks for, so the layout does not change when the +# resolution does - see the note about pixelation in README.md. +PAGE_W, PAGE_H = 595, 842 +MARGIN = 38 + +BG = (255, 255, 255) +INK = (24, 24, 27) +MUTED = (110, 110, 118) +RULE = (222, 222, 228) +ACCENT = (0, 105, 137) + +FONT_CANDIDATES = { + "regular": ["/System/Library/Fonts/Supplemental/Arial.ttf", "/Library/Fonts/Arial.ttf"], + "bold": ["/System/Library/Fonts/Supplemental/Arial Bold.ttf", "/Library/Fonts/Arial Bold.ttf"], +} +FALLBACK = "/System/Library/Fonts/Helvetica.ttc" + + +class Sheet: + """A page being drawn, in points, at whatever scale the dpi asked for.""" + + def __init__(self, dpi): + self.scale = dpi / 72.0 + self.page = Image.new("RGB", (self.px(PAGE_W), self.px(PAGE_H)), BG) + self.draw = ImageDraw.Draw(self.page) + + def px(self, points): + return int(round(points * self.scale)) + + def font(self, weight, size): + for path in FONT_CANDIDATES[weight] + [FALLBACK]: + if os.path.exists(path): + return ImageFont.truetype(path, self.px(size)) + + return ImageFont.load_default() + + def text(self, x, y, message, font, fill): + self.draw.text((self.px(x), self.px(y)), message, font=font, fill=fill) + + def rule(self, x, y, width): + self.draw.line( + (self.px(x), self.px(y), self.px(x + width), self.px(y)), + fill=RULE, + width=max(1, self.px(1)), + ) + + def paste(self, image, x, y): + self.page.paste(image, (self.px(x), self.px(y))) + + def wrap(self, message, font, width): + """[message] broken into lines that fit [width] points.""" + limit = self.px(width) + words, lines, current = message.split(), [], "" + for word in words: + trial = (current + " " + word).strip() + if self.draw.textlength(trial, font=font) <= limit: + current = trial + else: + if current: + lines.append(current) + current = word + if current: + lines.append(current) + + return lines + + def paragraph(self, x, y, message, font, fill, width, leading): + for line in self.wrap(message, font, width): + self.text(x, y, line, font, fill) + y += leading + + return y + + def framed(self, path, height): + """A screenshot scaled to [height] points, with a hairline around it.""" + image = Image.open(path) + target_h = self.px(height) + target_w = int(image.width * target_h / image.height) + scaled = image.resize((target_w, target_h), Image.LANCZOS).convert("RGB") + + edge = max(1, self.px(0.75)) + framed = Image.new("RGB", (target_w + edge * 2, target_h + edge * 2), (200, 200, 206)) + framed.paste(scaled, (edge, edge)) + + return framed + + +def shot_path(sets, name, shot): + path = os.path.join(sets[name], f"{shot}.png") + if not os.path.exists(path): + sys.exit(f"no {shot}.png in the {name} set ({sets[name]})") + + return path + + +def cover(story, sets, dpi): + sheet = Sheet(dpi) + title = sheet.font("bold", 30) + heading = sheet.font("bold", 12) + body = sheet.font("regular", 13) + small = sheet.font("regular", 10) + tiny = sheet.font("regular", 9) + + sheet.text(MARGIN, MARGIN + 8, story["title"], title, INK) + sheet.text(MARGIN, MARGIN + 46, story["subtitle"], title, ACCENT) + sheet.text(MARGIN, MARGIN + 92, story["note"], body, MUTED) + sheet.rule(MARGIN, MARGIN + 122, PAGE_W - MARGIN * 2) + + # the contact sheet: every page that compares two sets, one column each + pairs = [page for page in story["pages"] if len(page["columns"]) == 2] + gap = 8 + tile_w = (PAGE_W - MARGIN * 2 - gap * (len(pairs) - 1)) / len(pairs) + + sample = Image.open(shot_path(sets, pairs[0]["columns"][0]["set"], pairs[0]["shot"])) + tile_h = tile_w * sample.height / sample.width + + rows = [pairs[0]["columns"][0]["set"], pairs[0]["columns"][1]["set"]] + y_top = MARGIN + 172 + tops = [y_top, y_top + tile_h + 34] + + for name, top in zip(rows, tops): + sheet.text(MARGIN, top - 16, name.upper(), heading, ACCENT) + + for index, page in enumerate(pairs): + x = MARGIN + index * (tile_w + gap) + for name, top in zip(rows, tops): + image = Image.open(shot_path(sets, name, page["shot"])) + image = image.resize( + (sheet.px(tile_w), sheet.px(tile_h)), Image.LANCZOS + ).convert("RGB") + sheet.paste(image, x, top) + sheet.draw.rectangle( + ( + sheet.px(x), + sheet.px(top), + sheet.px(x + tile_w) - 1, + sheet.px(top + tile_h) - 1, + ), + outline=(205, 205, 212), + ) + + caption = page["title"].split("·", 1)[-1].strip() + sheet.paragraph(x, tops[1] + tile_h + 6, caption, tiny, MUTED, tile_w, 11) + + y = tops[1] + tile_h + 52 + y = sheet.paragraph(MARGIN, y, story["footer"], small, MUTED, PAGE_W - MARGIN * 2, 14) + + summary = story.get("summary") + if summary: + y += 8 + sheet.rule(MARGIN, y, PAGE_W - MARGIN * 2) + y += 10 + sheet.text(MARGIN, y, summary["heading"], heading, ACCENT) + y += 20 + for line in summary["lines"]: + indented = line.startswith(" ") + sheet.text( + MARGIN + (10 if indented else 0), + y, + line.strip() if indented else "• " + line, + small, + INK, + ) + y += 14 + + return sheet.page + + +def pair_page(page, sets, dpi): + sheet = Sheet(dpi) + title = sheet.font("bold", 22) + subtitle = sheet.font("regular", 13) + label = sheet.font("bold", 14) + caption = sheet.font("regular", 11) + + sheet.text(MARGIN, MARGIN, page["title"], title, INK) + sheet.text(MARGIN, MARGIN + 31, page["subtitle"], subtitle, MUTED) + sheet.rule(MARGIN, MARGIN + 59, PAGE_W - MARGIN * 2) + + gutter = 29 + column_w = (PAGE_W - MARGIN * 2 - gutter) / 2 + shot_h = 566 + + images = [ + sheet.framed(shot_path(sets, column["set"], page["shot"]), shot_h) + for column in page["columns"] + ] + shot_w = images[0].width / sheet.scale + xs = [ + MARGIN + (column_w - shot_w) / 2, + MARGIN + column_w + gutter + (column_w - shot_w) / 2, + ] + + for x, column in zip(xs, page["columns"]): + sheet.text(x, MARGIN + 76, column["set"], label, ACCENT) + + top = MARGIN + 100 + for x, image in zip(xs, images): + sheet.paste(image, x, top) + + for x, column in zip(xs, page["columns"]): + sheet.paragraph(x, top + shot_h + 14, column["caption"], caption, INK, column_w, 15) + + return sheet.page + + +def solo_page(page, sets, dpi): + """One screen, with room beside it for more than a caption.""" + sheet = Sheet(dpi) + title = sheet.font("bold", 22) + subtitle = sheet.font("regular", 13) + label = sheet.font("bold", 14) + body = sheet.font("regular", 11) + + sheet.text(MARGIN, MARGIN, page["title"], title, INK) + sheet.text(MARGIN, MARGIN + 31, page["subtitle"], subtitle, MUTED) + sheet.rule(MARGIN, MARGIN + 59, PAGE_W - MARGIN * 2) + + column = page["columns"][0] + image = sheet.framed(shot_path(sets, column["set"], page["shot"]), 624) + sheet.text(MARGIN, MARGIN + 76, column["set"], label, ACCENT) + sheet.paste(image, MARGIN, MARGIN + 100) + + x = MARGIN + image.width / sheet.scale + 29 + width = PAGE_W - MARGIN - x + y = MARGIN + 100 + for paragraph in column["caption"].split("\n\n"): + y = sheet.paragraph(x, y, paragraph, body, INK, width, 16) + 10 + + return sheet.page + + +def main(): + parser = argparse.ArgumentParser(description="Lay screen-tour shots out as a PDF.") + parser.add_argument( + "--set", + dest="sets", + action="append", + required=True, + metavar="NAME=DIR", + help="a named set of shots, repeatable, e.g. --set main=build/tour/main", + ) + parser.add_argument( + "--story", + default=os.path.join(HERE, "story.json"), + help="what each page says (default: story.json next to this script)", + ) + parser.add_argument("--out", required=True, help="the pdf to write") + parser.add_argument( + "--dpi", + type=int, + default=300, + help="how finely the pages are rendered (default: 300)", + ) + parser.add_argument("--pngs", help="also write each page as a png into this directory") + args = parser.parse_args() + + sets = {} + for entry in args.sets: + if "=" not in entry: + sys.exit(f"--set wants NAME=DIR, got {entry}") + name, path = entry.split("=", 1) + sets[name] = path + + with open(args.story, encoding="utf-8") as handle: + story = json.load(handle) + + pages = [cover(story, sets, args.dpi)] + for page in story["pages"]: + builder = pair_page if len(page["columns"]) == 2 else solo_page + pages.append(builder(page, sets, args.dpi)) + + os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True) + pages[0].save( + args.out, save_all=True, append_images=pages[1:], resolution=float(args.dpi) + ) + + if args.pngs: + os.makedirs(args.pngs, exist_ok=True) + for index, page in enumerate(pages): + page.save(os.path.join(args.pngs, f"page-{index:02d}.png")) + + print(f"{args.out} ({len(pages)} pages at {args.dpi} dpi)") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/screen-tour/screen-tour.py b/tools/screen-tour/screen-tour.py new file mode 100755 index 000000000000..af3b6211ef76 --- /dev/null +++ b/tools/screen-tour/screen-tour.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +# +# Walks a build through the handful of screens that carry its design and +# photographs each one, so two branches can be put side by side. +# +# Everything here goes through the app the way a user does - the system picker, +# the app's own buttons - because the point is to photograph what a user sees. +# Nothing is seeded straight into the app's storage: a recent document written +# by hand would carry a uri the app holds no grant for, and would be dropped on +# the next launch anyway. +# +# Usage: tools/screen-tour/screen-tour.py [--out DIR] [--install APK] ... +# See README.md next to this file. + +import argparse +import os +import re +import subprocess +import sys +import time +import xml.etree.ElementTree as ET + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")) + +# the documents the tour opens, in the order it opens them - so the last one is +# the one on screen for the document, menu and search shots, and the recents +# list reads bottom up. they come from the instrumented tests' assets, which are +# small, in the repo, and cover three different formats. +DOCUMENTS = ["spreadsheet-test.ods", "style-various-1.docx", "test.odt"] + +# where the tour drops them. shared storage rather than the app's own directory: +# these are opened through the system picker, which is what grants the app the +# uri - see the note in README.md about how this differs from render-sweep. +DEVICE_DIR = "/sdcard/Download" + +SEARCH_TERM = "document" + + +class Device: + """adb plus enough of uiautomator to find something and tap it.""" + + def __init__(self, serial, verbose): + self.adb = [os.path.join(sdk_root(), "platform-tools", "adb")] + if serial: + self.adb += ["-s", serial] + self.verbose = verbose + self.size = None + + def run(self, *args, **kwargs): + return subprocess.run( + self.adb + list(args), capture_output=True, text=True, **kwargs + ).stdout + + def shell(self, *args): + return self.run("shell", *args) + + def say(self, message): + if self.verbose: + print(f" {message}", flush=True) + + def screenshot(self, path): + with open(path, "wb") as handle: + handle.write( + subprocess.run( + self.adb + ["exec-out", "screencap", "-p"], capture_output=True + ).stdout + ) + + if os.path.getsize(path) < 1024: + raise Tourfail(f"screencap wrote nothing to {path}") + + def nodes(self): + """Every node of the current window, newest dump.""" + for _ in range(6): + self.shell("uiautomator", "dump", "/sdcard/screen-tour.xml") + xml = self.shell("cat", "/sdcard/screen-tour.xml") + if " 0: + device.tap(OPEN_BUTTON, what="the open button") + device.settle(3) + + # only the older design asks which file manager to use + chooser, _ = device.find_any(FILE_MANAGER_CHOICE) + if chooser is not None: + device.say("choosing Files in the file manager list") + device.tap_node(chooser) + device.settle(3) + + open_document(device, document) + device.wait_for(DOCUMENT_ON_SCREEN, timeout=args.load_timeout, what="the document") + device.settle(args.load_pause) + + if index < len(args.documents) - 1: + device.back() + device.settle(3) + + shoot("03-document") + + device.tap(MORE_BUTTON, what="the overflow") + device.settle(2) + shoot("04-menu") + + # the floating design closes its own way; a popup menu closes on back + closer, _ = device.find_any(CLOSE_MORE) + if closer is not None: + device.tap_node(closer) + else: + device.back() + device.settle(2) + + device.tap(SEARCH_BUTTON, what="search") + device.settle(2) + device.shell("input", "text", args.search) + device.settle(1) + device.shell("input", "keyevent", "KEYCODE_ENTER") + device.settle(2) + shoot("05-search") + + device.back() # leaves the find bar + device.settle(1) + device.back() # leaves the document + device.settle(3) + + # where the app keeps what was opened: a screen of its own on one branch, a + # dialog two taps deep on the other + device.wait_for(LANDING_LIST, what="the landing screen") + if device.find(r"landing_list", "resource-id") is None: + device.tap(OPEN_BUTTON, what="the open button") + device.settle(2) + device.tap(RECENTS_CHOICE, what="the recent documents entry") + device.settle(2) + else: + # a row keeps the pressed look of the tap that opened it, which + # photographs as a selected row that nothing selected + device.shell("input", "tap", "10", "10") + device.settle(2) + + shoot("06-recents") + + +def open_document(device, name): + """ + Finds [name] in the picker and taps it. + + The picker opens wherever it was left, or in Recent files on a device that has no answer to + that - and neither of them need hold the documents this pushed. So it is browsed to rather + than assumed: the roots drawer, then Downloads, then down the list if it is long. + """ + pattern = re.escape(name) + + for attempt in range(4): + if device.find(pattern) is not None: + device.tap([(pattern, "text")], what=name) + + return + + if attempt == 0: + device.say(f"{name} is not on screen, opening Downloads") + device.tap(ROOTS_DRAWER, what="the roots drawer") + device.settle(2) + device.tap(DOWNLOADS_ROOT, what="the Downloads root") + else: + device.swipe_up() + + device.settle(3) + + raise Tourfail(f"{name} is not in the picker") + + +def launch(device, args, fresh): + if fresh: + device.say(f"clearing {args.package}") + device.shell("pm", "clear", args.package) + + device.shell( + "monkey", "-p", args.package, "-c", "android.intent.category.LAUNCHER", "1" + ) + device.settle(4) + + dismiss_consent(device) + + +# The lite flavour asks for advertising consent before anything else. The dialog is +# a web view that arrives whenever the ad sdk has finished starting up - a second or +# ten after the landing screen is already drawn - so this cannot be a fixed wait: it +# watches for either the dialog or a quiet screen, and photographing the app with a +# consent sheet over it is exactly what a fixed wait gets you. +# +# Its buttons carry a description and no text, being web content, and a mistimed tap +# lands on "Manage options" and opens the vendor list - so "Accept all", which is the +# way out of that list, is one of the things looked for. +CONSENT_BUTTONS = [ + (r"^Accept all$", "text"), + (r"^Accept all$", "content-desc"), + (r"^Consent$", "text"), + (r"^Consent$", "content-desc"), +] + + +def dismiss_consent(device, timeout=25): + deadline = time.time() + timeout + quiet = 0 + + while time.time() < deadline: + consent, pattern = device.find_any(CONSENT_BUTTONS) + if consent is not None: + # the sheet is still sliding in when it first appears, and a tap sent at + # a button that has not stopped moving lands on the one below it + device.settle(2) + consent, pattern = device.find_any(CONSENT_BUTTONS) + if consent is None: + continue + + device.say(f"accepting the consent dialog ({pattern})") + device.tap_node(consent) + device.settle(5) + + quiet = 0 + continue + + # the app's own screen, with nothing over it, twice in a row - the dialog + # can still be on its way while the landing screen is up behind it + if device.find_any(OPEN_BUTTON)[0] is not None: + quiet += 1 + if quiet >= 2: + return + + device.settle(3) + + +def push_documents(device, args): + for document in args.documents: + source = os.path.join(args.assets, document) + if not os.path.exists(source): + raise Tourfail(f"no such document: {source}") + + device.run("push", source, f"{DEVICE_DIR}/{document}") + + # the picker lists what MediaStore knows about, not what is on the card + device.shell( + "am", "broadcast", "-a", "android.intent.action.MEDIA_SCANNER_SCAN_FILE", + "-d", f"file://{DEVICE_DIR}", + ) + + +def main(): + parser = argparse.ArgumentParser( + description="Photograph the screens that carry the app's design.", + ) + parser.add_argument("--serial", help="adb serial, when more than one device is attached") + parser.add_argument( + "--package", + default="at.tomtasche.reader.pro", + help="application id to drive (default: at.tomtasche.reader.pro)", + ) + parser.add_argument( + "--out", + default=os.path.join(REPO_ROOT, "build", "screen-tour", "shots"), + help="where the pngs go (default: build/screen-tour/shots)", + ) + parser.add_argument("--install", help="apk to install before starting") + parser.add_argument( + "--assets", + default=os.path.join(REPO_ROOT, "app", "src", "androidTest", "assets"), + help="where the documents to open come from", + ) + parser.add_argument( + "--document", + dest="documents", + action="append", + help="a document to open, repeatable; the last one stays on screen", + ) + parser.add_argument("--search", default=SEARCH_TERM, help="what to search the document for") + parser.add_argument( + "--keep-state", + action="store_true", + help="do not clear the app first - the first shot will not be a fresh install", + ) + parser.add_argument("--load-timeout", type=int, default=40, help="seconds to wait for a load") + parser.add_argument( + "--load-pause", + type=int, + default=4, + help="extra seconds after a document appears, for the WebView to finish drawing", + ) + parser.add_argument("--quiet", action="store_true") + args = parser.parse_args() + + args.documents = args.documents or DOCUMENTS + + device = Device(args.serial, not args.quiet) + os.makedirs(args.out, exist_ok=True) + + if device.shell("getprop", "sys.boot_completed").strip() != "1": + print("no booted device on adb", file=sys.stderr) + return 2 + + if args.install: + print(f"installing {args.install}", flush=True) + device.run("install", "-r", "-g", args.install) + + push_documents(device, args) + + print(f"touring {args.package} into {args.out}", flush=True) + try: + tour(device, args, args.out) + except Tourfail as failure: + print(f"\nthe tour stopped: {failure}", file=sys.stderr) + print("the shots taken before it are still in place", file=sys.stderr) + return 1 + + print("done", flush=True) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/screen-tour/story.json b/tools/screen-tour/story.json new file mode 100644 index 000000000000..7cb201a188ec --- /dev/null +++ b/tools/screen-tour/story.json @@ -0,0 +1,122 @@ +{ + "title": "OpenDocument Reader", + "subtitle": "main vs redesign", + "note": "Pixel 9 Pro, pro debug build, from a fresh install onwards", + "footer": "Each page that follows shows one screen, full size: main left, redesign right. The last one is the lite build.", + "summary": { + "heading": "What the redesign changed", + "lines": [ + "The landing screen is the recently opened documents, not a page of prose.", + "The logo and name sit above the list and stay there once documents fill it.", + "Recent rows are one line each, with the time opened at the far end.", + "What the app is for keeps a section of its own, which folds away once there are documents.", + "The settings fold too, and start folded.", + "Open goes straight to the system picker, filtered to documents; no folder browser beside it.", + "The toolbar is gone: the document's actions float over it, where the thumb is.", + "The document runs to the bottom edge, under the gesture bar." + ] + }, + "pages": [ + { + "title": "1 · First launch", + "subtitle": "The very first time the app opens, with no data of its own", + "shot": "01-first-launch", + "columns": [ + { + "set": "main", + "caption": "A welcome page of prose inside the WebView, a lone (white on white) Open icon in the toolbar and a grey FAB." + }, + { + "set": "redesign", + "caption": "The same three lines of copy, under a heading of their own and above the settings, with a logo that stays put and Open a file at the top. The settings start folded." + } + ] + }, + { + "title": "2 · Opening a file", + "subtitle": "What the Open action does", + "shot": "02-open", + "columns": [ + { + "set": "main", + "caption": "The app asks first, with its own dialog listing every file manager plus its recents." + }, + { + "set": "redesign", + "caption": "Straight into the system picker - one tap fewer, and its own search and roots come along. Asked for document types only, so the image, audio and video filters are not on it." + } + ] + }, + { + "title": "3 · A document on screen", + "subtitle": "test.odt rendered by the core", + "shot": "03-document", + "columns": [ + { + "set": "main", + "caption": "An action bar takes the top of the screen whether or not the document needs it." + }, + { + "set": "redesign", + "caption": "The bar is gone; search and the overflow float over the page, down where the thumb is. The page itself runs to the bottom edge, under the gesture bar." + } + ] + }, + { + "title": "4 · Menu unfolded", + "subtitle": "Every action the document offers", + "shot": "04-menu", + "columns": [ + { + "set": "main", + "caption": "A stock overflow popup, top right, text only - Fullscreen, Open with, Save, Share, Print, TTS." + }, + { + "set": "redesign", + "caption": "A labelled speed dial from the bottom right, icons included, with Edit alongside the rest." + } + ] + }, + { + "title": "5 · Search in use", + "subtitle": "Searching the open document for \"document\"", + "shot": "05-search", + "columns": [ + { + "set": "main", + "caption": "The find action mode replaces the toolbar; hits highlight in the WebView." + }, + { + "set": "redesign", + "caption": "The same find bar, reached from the floating button instead of the toolbar." + } + ] + }, + { + "title": "6 · Recents, after three documents", + "subtitle": "test.odt, style-various-1.docx and spreadsheet-test.ods have been opened", + "shot": "06-recents", + "columns": [ + { + "set": "main", + "caption": "Recents are a dialog, two taps deep behind Open, names only. The home screen itself never changes." + }, + { + "set": "redesign", + "caption": "Recents are the home screen: one line each, the time opened on the right, the logo still above them, and swipe to remove. What the app does has folded itself away by now, and the settings under it were never open." + } + ] + }, + { + "title": "7 · Where the ad removal went", + "subtitle": "The lite flavour, which is the only one that has ads to remove", + "shot": "06-recents", + "columns": [ + { + "set": "lite", + "caption": "The other pages were shot from the pro build, which offers no ad removal by design - the purchase is implied - so the row was never on screen to photograph. It has not gone anywhere.\n\nIn lite it is the last row of the settings section, under the catch-all switch, where the landing screen asks MainActivity.offersAdRemoval() for it. On main the same purchase was an item in the toolbar overflow, on a screen that had no settings of its own." + } + ] + } + ] +} From d6143988987ab543a8fba2d6291d57acb73eacd2 Mon Sep 17 00:00:00 2001 From: Andreas Stefl Date: Sun, 2 Aug 2026 21:28:01 +0200 Subject: [PATCH 2/2] make the tour run off a mac Two things it had baked in from the machine it was written on. The system picker row is called "Documents" on some devices and "Files" on others, and the tour only knew "Files" - on those devices it never leaves the chooser, then hunts for filenames in a chooser that is still open. The instrumented tests already match both names, so match the same pair. collage.py only listed macOS font paths and fell back to ImageFont.load_default(), which ignores the requested size: at 300 dpi every title and caption would come out as specks, quietly, in a PDF whose whole job is to be looked at. Add the usual linux paths and fail with something actionable if none of them are there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01E1ANEeai87KtnX5pBzgDEJ --- tools/screen-tour/README.md | 2 ++ tools/screen-tour/collage.py | 25 ++++++++++++++++++++++--- tools/screen-tour/screen-tour.py | 4 +++- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/tools/screen-tour/README.md b/tools/screen-tour/README.md index 429b283678b2..a55a79946c32 100644 --- a/tools/screen-tour/README.md +++ b/tools/screen-tour/README.md @@ -14,6 +14,8 @@ fails a build. It hands you pictures and you decide what you think of them. ## Running it You need a device or emulator on `adb`, and Pillow for the PDF (`pip install pillow`). +`collage.py` also needs a TrueType font it can find - macOS has one, on debian it is +`apt install fonts-dejavu-core` - and says so rather than rendering unreadable labels. ```sh # the branch you are on diff --git a/tools/screen-tour/collage.py b/tools/screen-tour/collage.py index 2ef6a7d21b37..5126d3b4f9ac 100755 --- a/tools/screen-tour/collage.py +++ b/tools/screen-tour/collage.py @@ -35,8 +35,20 @@ ACCENT = (0, 105, 137) FONT_CANDIDATES = { - "regular": ["/System/Library/Fonts/Supplemental/Arial.ttf", "/Library/Fonts/Arial.ttf"], - "bold": ["/System/Library/Fonts/Supplemental/Arial Bold.ttf", "/Library/Fonts/Arial Bold.ttf"], + "regular": [ + "/System/Library/Fonts/Supplemental/Arial.ttf", + "/Library/Fonts/Arial.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/usr/share/fonts/TTF/DejaVuSans.ttf", + ], + "bold": [ + "/System/Library/Fonts/Supplemental/Arial Bold.ttf", + "/Library/Fonts/Arial Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + ], } FALLBACK = "/System/Library/Fonts/Helvetica.ttc" @@ -57,7 +69,14 @@ def font(self, weight, size): if os.path.exists(path): return ImageFont.truetype(path, self.px(size)) - return ImageFont.load_default() + # load_default() ignores the size, so at 300 dpi every label would come out as + # unreadable specks. Better to say so than to hand back a PDF nobody can read. + raise SystemExit( + f"no {weight} font found. Tried:\n " + + "\n ".join(FONT_CANDIDATES[weight] + [FALLBACK]) + + "\nInstall one (on debian: apt install fonts-dejavu-core) or add its path " + "to FONT_CANDIDATES." + ) def text(self, x, y, message, font, fill): self.draw.text((self.px(x), self.px(y)), message, font=font, fill=fill) diff --git a/tools/screen-tour/screen-tour.py b/tools/screen-tour/screen-tour.py index af3b6211ef76..d004edaae054 100755 --- a/tools/screen-tour/screen-tour.py +++ b/tools/screen-tour/screen-tour.py @@ -170,7 +170,9 @@ def sdk_root(): (r"landing_open_fab", "resource-id"), (r"^Open document$", "content-desc"), ] -FILE_MANAGER_CHOICE = [(r"^Files$", "text")] +# the system picker answers to either name depending on the device - the instrumented +# tests match the same pair, see MainActivityTests.openDocumentThroughPicker +FILE_MANAGER_CHOICE = [(r"^Files$", "text"), (r"^Documents$", "text")] ROOTS_DRAWER = [(r"^Show roots$", "content-desc")] DOWNLOADS_ROOT = [(r"^Downloads$", "text")] RECENTS_CHOICE = [(r"[Rr]ecently opened", "text")]