Screenshots: demo-library generator, per-form-factor targets, listing notes

This commit is contained in:
2026-10-14 17:52:40 +02:00
parent d845a7646e
commit 67c2cd61db
3 changed files with 234 additions and 2 deletions
+25
View File
@@ -24,6 +24,31 @@ FORM_FACTOR=tablet-7in ./tools/make-store-screenshots.sh
FORM_FACTOR=tablet-10in ./tools/make-store-screenshots.sh
```
## Screenshots
Play's slots, and what each needs:
| Slot | How many | Target here | Notes |
|---|---|---|---|
| Phone | **2–8** | `screenshots/phone/` — 1080×2160 | A phone at its native 1080×2400 is 2.22:1 and is **rejected**; the normaliser crops chrome and clamps to 2:1 |
| 7-inch tablet | 4+ recommended | `screenshots/tablet-7in/` — 1200×1920 | A tablet frame is already inside the 2:1 cap, so 0 px of crop |
| 10-inch tablet | 4+ recommended | `screenshots/tablet-10in/` — 2560×1600 | Landscape profile (matches the `tablet10` AVD); Play accepts either orientation |
### The library the shots are taken over
**Never shoot over a real device's storage.** Published screenshots are public, and a file
list exposes real folder names and document titles. The library is generated instead:
```bash
python3 tools/make-demo-library.py # -> demo-files/ (git-ignored, ~161 MB)
adb push demo-files /sdcard/Download/PlainFiles-Demo
```
`tools/make-demo-library.py` builds eight distinct sample images (so the grid view and
thumbnails have something to show), a handful of documents, sized stand-ins for video and
audio so the storage-usage screen has something worth ranking, and a real ZIP so the extract
action can be exercised. Every name and every pixel is fake by construction.
| Asset | Play's spec | Result | Source |
|---|---|---|---|
| App icon | 512×512, 32-bit PNG with alpha, ≤ 1 MB | `store-assets/icon-512.png` — 512×512, **RGBA**, ~5 KB | `tools/store-assets/icon.svg`, mirroring `ic_launcher_background.xml` + `ic_launcher_foreground.xml`. Background, paths and opacities are identical, so **the store icon is the launcher icon** rather than a lookalike that drifts. |
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""
Builds the synthetic file library the store screenshots are taken over.
python3 tools/make-demo-library.py [output-dir] # default: ./demo-files
Why this exists: published screenshots are public. Shooting them over a real device's
storage would put someone's real file names, folder names and document titles on the Play
listing. Everything here is generated, so every pixel of every screenshot is fake by
construction.
Output lands in ./demo-files/ *inside* the repo and is git-ignored: it is regenerated on
demand and never committed. Push it to a device with:
adb push demo-files /sdcard/Download/PlainFiles-Demo
"""
import os
import shutil
import sys
import zipfile
from PIL import Image, ImageDraw, ImageFont
FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
# Distinct hues so the grid view reads as eight different pictures rather than a wall of one
# colour. (name, background hue, accent hue, motif)
SCENES = [
("dawn-ridge", (28, 78, 150), (255, 214, 140), "peaks"),
("pine-lake", (18, 96, 92), (198, 240, 200), "waves"),
("desert-road", (196, 120, 48), (255, 236, 196), "peaks"),
("neon-city", (78, 40, 130), (240, 120, 220), "bars"),
("quiet-harbour", (36, 62, 110), (170, 220, 255), "waves"),
("autumn-field", (150, 84, 36), (255, 208, 120), "bars"),
("snow-pass", (110, 124, 148), (245, 250, 255), "peaks"),
("orchard", (52, 108, 60), (214, 246, 170), "bars"),
]
def font(path, size):
try:
return ImageFont.truetype(path, size)
except OSError:
return ImageFont.load_default()
def vertical_gradient(width, height, top, bottom):
image = Image.new("RGB", (width, height), top)
draw = ImageDraw.Draw(image)
for y in range(height):
blend = y / max(height - 1, 1)
draw.line(
[(0, y), (width, y)],
fill=tuple(int(top[i] + (bottom[i] - top[i]) * blend) for i in range(3)),
)
return image
def draw_motif(draw, width, height, kind, colour):
if kind == "peaks":
base = height * 0.78
for offset, scale in ((0.05, 0.42), (0.42, 0.58), (0.74, 0.34)):
apex = base - height * scale
draw.polygon(
[
(width * offset, base),
(width * (offset + 0.20), apex),
(width * (offset + 0.40), base),
],
fill=colour,
)
elif kind == "waves":
for index in range(5):
y = height * (0.55 + index * 0.08)
amplitude = height * 0.03
points = [
(x, y + amplitude * ((x // 40) % 2 * 2 - 1)) for x in range(0, width + 40, 40)
]
draw.line(points, fill=colour, width=6)
else: # bars
for index in range(7):
x = width * (0.10 + index * 0.12)
bar = height * (0.20 + (index % 3) * 0.14)
draw.rounded_rectangle(
[x, height * 0.80 - bar, x + width * 0.06, height * 0.80],
radius=8,
fill=colour,
)
def make_photos(root):
folder = os.path.join(root, "Photos")
os.makedirs(folder, exist_ok=True)
label_font = font(FONT_BOLD, 54)
for index, (name, top, accent, motif) in enumerate(SCENES, start=1):
width, height = 1600, 1200
image = vertical_gradient(width, height, top, tuple(int(c * 0.45) for c in top))
draw = ImageDraw.Draw(image)
draw.ellipse([width * 0.72, height * 0.12, width * 0.92, height * 0.32], fill=accent)
draw_motif(draw, width, height, motif, accent)
draw.text((60, height - 120), f"Sample {index:02d} - {name}", font=label_font, fill=accent)
image.save(os.path.join(folder, f"sample-{index:02d}-{name}.png"), "PNG")
def write(path, text):
with open(path, "w", encoding="utf-8") as handle:
handle.write(text)
def make_text_files(root):
folder = os.path.join(root, "Documents")
os.makedirs(folder, exist_ok=True)
write(
os.path.join(folder, "offsite-notes.txt"),
"Team offsite - notes\n"
"====================\n\n"
"Nothing in this file is real: the whole library the screenshots are taken over\n"
"is generated by tools/make-demo-library.py, so publishing them leaks nobody's\n"
"data.\n\n"
"1. Agenda\n2. Travel\n3. Follow-ups\n",
)
write(
os.path.join(folder, "budget.csv"),
"item,quantity,unit_cost\n"
"laptop stand,2,39.00\n"
"usb-c hub,3,54.50\n"
"headset,1,229.00\n"
"markers,12,2.15\n",
)
write(
os.path.join(folder, "itinerary.md"),
"# Itinerary\n\n| Day | Item |\n| --- | --- |\n"
"| 1 | Arrival |\n| 2 | Workshops |\n| 3 | Departure |\n",
)
# Stand-ins for binary formats: what matters for a screenshot is the icon and the name
# in the list, and a real PDF or DOCX would add a build dependency for no visible gain.
write(os.path.join(folder, "quarterly-report.pdf"), "%PDF-1.4\n% demo placeholder\n")
write(os.path.join(folder, "service-agreement.docx"), "PK demo placeholder\n")
def make_sized_file(path, megabytes):
"""A file of a given size, so the usage screen has something worth ranking."""
chunk = b"\x00" * (1024 * 1024)
with open(path, "wb") as handle:
for _ in range(megabytes):
handle.write(chunk)
def make_media(root):
video = os.path.join(root, "Video")
os.makedirs(video, exist_ok=True)
for index in range(1, 4):
make_sized_file(os.path.join(video, f"clip-{index:02d}.mp4"), 24 + index * 6)
music = os.path.join(root, "Music")
os.makedirs(music, exist_ok=True)
for index in range(1, 6):
make_sized_file(os.path.join(music, f"track-{index:02d}.mp3"), 4 + index)
def make_archive(root):
folder = os.path.join(root, "Backups")
os.makedirs(folder, exist_ok=True)
with zipfile.ZipFile(os.path.join(folder, "config-backup.zip"), "w", zipfile.ZIP_DEFLATED) as bundle:
bundle.writestr("settings.json", '{\n "theme": "system",\n "sort": "name"\n}\n')
bundle.writestr("bookmarks.txt", "Photos\nDocuments\nBackups\n")
bundle.writestr("notes/readme.txt", "Synthetic contents.\n")
def make_downloads(root):
folder = os.path.join(root, "Download")
os.makedirs(folder, exist_ok=True)
write(os.path.join(folder, "welcome.txt"), "Generated by tools/make-demo-library.py\n")
make_sized_file(os.path.join(folder, "installer.bin"), 18)
def main():
repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
root = sys.argv[1] if len(sys.argv) > 1 else os.path.join(repo, "demo-files")
if os.path.isdir(root):
shutil.rmtree(root)
os.makedirs(root)
make_photos(root)
make_text_files(root)
make_media(root)
make_archive(root)
make_downloads(root)
total = 0
for base, _, names in os.walk(root):
for name in names:
total += os.path.getsize(os.path.join(base, name))
print(f"Wrote {root} ({total / (1024 * 1024):.1f} MB)")
if __name__ == "__main__":
main()
+6 -2
View File
@@ -4,7 +4,7 @@
#
# ./tools/make-store-screenshots.sh # phone -> 1080x2160
# FORM_FACTOR=tablet-7in ./tools/make-store-screenshots.sh # 7-inch -> 1200x1920
# FORM_FACTOR=tablet-10in ./tools/make-store-screenshots.sh # 10-inch -> 1600x2560
# FORM_FACTOR=tablet-10in ./tools/make-store-screenshots.sh # 10-inch -> 2560x1600
#
# store-assets/raw-screenshots/<form>/ the raw captures (git-ignored)
# store-assets/screenshots/<form>/ what gets uploaded (committed)
@@ -14,6 +14,10 @@
# 1080x2400 is 2.22:1 and is therefore REJECTED, which is why the phone path crops system
# chrome (status + navigation bars) before clamping to 2:1. A tablet frame is already inside
# the 2:1 cap on every profile the SDK ships, so its crop defaults to 0 and is opt-in.
#
# Play accepts portrait or landscape for the tablet slots; the targets below match the AVDs
# this repo is tested against (the 10-inch one is a landscape profile), and the extent step
# normalises to them either way.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
@@ -23,7 +27,7 @@ form="${FORM_FACTOR:-phone}"
case "$form" in
phone) target_w=1080; target_h=2160; default_status=93; default_nav=130 ;;
tablet-7in) target_w=1200; target_h=1920; default_status=0; default_nav=0 ;;
tablet-10in) target_w=1600; target_h=2560; default_status=0; default_nav=0 ;;
tablet-10in) target_w=2560; target_h=1600; default_status=0; default_nav=0 ;;
*) echo "Unknown FORM_FACTOR '$form' (phone|tablet-7in|tablet-10in)" >&2; exit 1 ;;
esac