Rebuild UI on the Noo design system
Replaces the Material 3 screens with the Noo design system: warm neutrals, one violet accent, pill controls, Schibsted Grotesk/ Instrument Sans, Lucide icons, no gradients/shadows. - New lib/widgets/noo/ component kit (core, lists, files, media, nav, overlays) plus lib/theme/design_tokens.dart for the color/type/space/ radius/motion tokens. - Rebuilt the app shell (top/bottom bars, drawer, desktop sidebar and toolbar), every tab (Files/Offline, Photos, Favorites, Recent, Activity, Trash, Shares), Settings, the lock and login screens, the media viewer, search, the details/share sheets, and the share-upload and move/copy destination pickers. - Added a design canvas (linked from DESIGN_SYSTEM.md) covering the screens the spec didn't already describe, with matching Android and iOS chrome; wrote up the approved recipes into DESIGN_SYSTEM.md §4. - Removed now-dead legacy widgets (media_grid_tile, swipeable_item, sync_status_badge, selectable_thumbnail) and updated architecture.md/styling.md/standards.md to describe the new structure and component/testing conventions. - Added widget tests for the noo/ component kit. This is a UI-only rework: no provider/model/service behavior changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
# Demo server for Play Store screenshots
|
||||
|
||||
`tool/screenshots.sh` takes the Play Store screenshots against a **throwaway
|
||||
Nextcloud that only contains fake data**, so nothing real can appear in them.
|
||||
This folder is that server:
|
||||
|
||||
- `docker-compose.yml` - a single Nextcloud container (SQLite, nothing
|
||||
persisted; `docker compose down -v` wipes it).
|
||||
- `seed.py` - creates the users and fills them with invented content: landscape
|
||||
"photos" drawn with Pillow, tiny generated PDFs, made-up documents, favorites,
|
||||
shares (to a second user and a public link), trash, a filler file for the
|
||||
storage quota, and edits by a second user so the Activity feed has more than
|
||||
one author. It ends by printing `APP_PASSWORD=...` - an app password for the
|
||||
demo user, so the app can start logged in without the browser login step.
|
||||
|
||||
## Running it
|
||||
|
||||
Most of the time just run the whole pipeline from the repo root:
|
||||
|
||||
```bash
|
||||
bash tool/screenshots.sh
|
||||
```
|
||||
|
||||
(Git Bash is fine on Windows.) You need **Docker**, **Python 3**, **Flutter**
|
||||
and an **Android SDK** (`ANDROID_HOME`). Pillow and `requests` are installed
|
||||
into `tool/demo_server/.venv` automatically if your Python lacks them. The
|
||||
script installs the emulator and a system image through `sdkmanager` on first
|
||||
use. Options (`HEADLESS=1`, `DEMO_PORT`, `DEMO_HOST`, `KEEP_SERVER=1`,
|
||||
`ANDROID_SERIAL`, ...) are documented at the top of the script.
|
||||
|
||||
To poke at the server yourself:
|
||||
|
||||
```bash
|
||||
docker compose -f tool/demo_server/docker-compose.yml up -d
|
||||
python3 tool/demo_server/seed.py # prints APP_PASSWORD=...
|
||||
# browse http://localhost (Alex / alex-demo-pass, admin / demo-admin-password)
|
||||
docker compose -f tool/demo_server/docker-compose.yml down -v
|
||||
```
|
||||
|
||||
`python3 tool/demo_server/seed.py --generate-only DIR` writes the generated
|
||||
files to `DIR` without any server - handy for previewing the fake content.
|
||||
|
||||
## Before you upload
|
||||
|
||||
Look at every image. The known places where real data could still show up:
|
||||
the server address and username (sync header panel, Settings > Accounts), the
|
||||
avatar initial, notification icons in the status bar, and anything left over
|
||||
from a previously used emulator profile (the script always wipes its own).
|
||||
@@ -0,0 +1,16 @@
|
||||
# A throwaway Nextcloud that only ever holds fake data - the backend the Play
|
||||
# Store screenshots are taken against (see tool/screenshots.sh). Nothing is
|
||||
# persisted: `docker compose down -v` wipes it completely.
|
||||
services:
|
||||
nextcloud:
|
||||
image: ${NEXTCLOUD_IMAGE:-nextcloud:apache}
|
||||
ports:
|
||||
- "${DEMO_PORT:-80}:80"
|
||||
environment:
|
||||
# SQLite keeps this to a single container.
|
||||
SQLITE_DATABASE: nextcloud
|
||||
NEXTCLOUD_ADMIN_USER: admin
|
||||
NEXTCLOUD_ADMIN_PASSWORD: demo-admin-password
|
||||
# `localhost` is how the Android emulator reaches this server (via
|
||||
# `adb reverse`); DEMO_HOST is the optional friendlier hostname.
|
||||
NEXTCLOUD_TRUSTED_DOMAINS: "localhost 127.0.0.1 10.0.2.2 ${DEMO_HOST:-demo.example.com}"
|
||||
@@ -0,0 +1,2 @@
|
||||
requests>=2.31
|
||||
Pillow>=10.0
|
||||
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fills a throwaway Nextcloud with fake data for the Play Store screenshots.
|
||||
|
||||
Everything here is invented or procedurally generated (landscape "photos"
|
||||
drawn with Pillow, tiny hand-built PDFs, made-up names), so nothing real -
|
||||
and nothing with a licence or a face in it - can end up in a screenshot.
|
||||
|
||||
python3 seed.py seed http://localhost (DEMO_BASE_URL)
|
||||
python3 seed.py --generate-only D write the fake files under D, no server
|
||||
|
||||
On success the LAST line printed to stdout is `APP_PASSWORD=<token>` - an app
|
||||
password for the demo user, which tool/screenshots.sh hands to the app so it
|
||||
can start logged in without going through the browser login flow. All
|
||||
progress output goes to stderr.
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
BASE = os.environ.get("DEMO_BASE_URL", "http://localhost").rstrip("/")
|
||||
ADMIN = ("admin", os.environ.get("NEXTCLOUD_ADMIN_PASSWORD", "demo-admin-password"))
|
||||
FILLER_MB = int(os.environ.get("DEMO_FILLER_MB", "300"))
|
||||
|
||||
# The account the screenshots are taken as, and a second user to share with.
|
||||
USERS = {
|
||||
"Alex": ("alex-demo-pass", "Alex Morgan", "alex@example.com", "15 GB"),
|
||||
"Sam": ("sam-demo-pass", "Sam Rivera", "sam@example.com", "5 GB"),
|
||||
}
|
||||
|
||||
|
||||
def log(message):
|
||||
print(message, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- content ---
|
||||
|
||||
# (sky top, sky bottom, sun, far hills, mid hills, near hills)
|
||||
PALETTES = [
|
||||
((250, 176, 120), (255, 226, 170), (255, 250, 220), (152, 120, 150), (108, 90, 128), (62, 60, 96)),
|
||||
((70, 130, 200), (190, 225, 245), (255, 255, 240), (110, 150, 170), (70, 118, 130), (40, 84, 90)),
|
||||
((30, 40, 90), (240, 130, 110), (255, 220, 190), (90, 60, 110), (60, 44, 84), (30, 28, 56)),
|
||||
((110, 180, 190), (230, 245, 235), (255, 245, 200), (130, 180, 150), (84, 140, 110), (44, 98, 76)),
|
||||
((240, 150, 90), (255, 214, 150), (255, 240, 200), (200, 130, 90), (160, 96, 70), (110, 66, 56)),
|
||||
((20, 24, 60), (70, 60, 120), (240, 240, 255), (50, 50, 100), (34, 36, 80), (18, 20, 50)),
|
||||
((150, 200, 230), (245, 250, 255), (255, 255, 255), (170, 190, 210), (130, 156, 180), (90, 120, 150)),
|
||||
((200, 120, 160), (250, 200, 190), (255, 235, 210), (170, 100, 140), (126, 78, 116), (80, 54, 90)),
|
||||
]
|
||||
|
||||
|
||||
def _mix(a, b, t):
|
||||
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))
|
||||
|
||||
|
||||
def landscape(seed, size=(1600, 1067)):
|
||||
"""A soft, layered-hills landscape - reads as a photo at thumbnail size."""
|
||||
rnd = random.Random(seed)
|
||||
top, bottom, sun, far, mid, near = PALETTES[seed % len(PALETTES)]
|
||||
w, h = size
|
||||
img = Image.new("RGB", size)
|
||||
draw = ImageDraw.Draw(img)
|
||||
for y in range(h):
|
||||
draw.line([(0, y), (w, y)], fill=_mix(top, bottom, min(1.0, y / (h * 0.75))))
|
||||
sx, sy, sr = rnd.randint(w // 5, 4 * w // 5), rnd.randint(h // 6, h // 3), rnd.randint(h // 12, h // 7)
|
||||
draw.ellipse([sx - sr, sy - sr, sx + sr, sy + sr], fill=sun)
|
||||
for base, amp, color in ((0.62, 0.10, far), (0.72, 0.13, mid), (0.84, 0.10, near)):
|
||||
phase = [rnd.uniform(0, math.tau) for _ in range(3)]
|
||||
freq = [rnd.uniform(1.0, 2.0), rnd.uniform(2.5, 4.0), rnd.uniform(5.0, 8.0)]
|
||||
weight = (1.0, 0.5, 0.2)
|
||||
pts = [(0, h)]
|
||||
for x in range(0, w + 8, 8):
|
||||
v = sum(wt * math.sin(x / w * math.tau * f + p) for wt, f, p in zip(weight, freq, phase))
|
||||
pts.append((x, int(h * base + h * amp * v / 1.7)))
|
||||
pts.append((w, h))
|
||||
draw.polygon(pts, fill=color)
|
||||
img = img.filter(ImageFilter.GaussianBlur(0.8))
|
||||
out = io.BytesIO()
|
||||
img.save(out, "JPEG", quality=86)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def pdf(title, lines):
|
||||
"""A tiny but valid one-page PDF."""
|
||||
|
||||
def esc(s):
|
||||
return s.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
||||
|
||||
body = "BT /F1 26 Tf 72 720 Td (%s) Tj ET\n" % esc(title)
|
||||
for i, line in enumerate(lines):
|
||||
body += "BT /F1 12 Tf 72 %d Td (%s) Tj ET\n" % (680 - 20 * i, esc(line))
|
||||
stream = body.encode("latin-1", "replace")
|
||||
objs = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R "
|
||||
b"/Resources << /Font << /F1 5 0 R >> >> >>",
|
||||
b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
]
|
||||
out = bytearray(b"%PDF-1.4\n")
|
||||
offsets = []
|
||||
for i, obj in enumerate(objs, start=1):
|
||||
offsets.append(len(out))
|
||||
out += b"%d 0 obj\n" % i + obj + b"\nendobj\n"
|
||||
xref = len(out)
|
||||
out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
|
||||
for off in offsets:
|
||||
out += b"%010d 00000 n \n" % off
|
||||
out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def text(body):
|
||||
return body.strip().encode("utf-8") + b"\n"
|
||||
|
||||
|
||||
def video():
|
||||
"""A short test-pattern clip if ffmpeg is around, else None (skipped)."""
|
||||
if not shutil.which("ffmpeg"):
|
||||
return None
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "clip.mp4")
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=duration=6:size=640x360:rate=24",
|
||||
"-pix_fmt", "yuv420p", path],
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
with open(path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def build_content():
|
||||
"""(path, bytes) for everything the demo user owns, plus what to favorite/delete."""
|
||||
files = []
|
||||
|
||||
def add(path, data):
|
||||
if data is not None:
|
||||
files.append((path, data))
|
||||
|
||||
add("/Welcome.md", text("# Welcome\n\nYour files, everywhere you go."))
|
||||
add("/Vacation ideas.txt", text("Lisbon\nKyoto\nPatagonia\nIceland in winter"))
|
||||
|
||||
add("/Documents/Project brief.pdf", pdf("Project brief", ["Goals for the quarter", "Milestones and owners", "Open questions"]))
|
||||
add("/Documents/Quarterly report.pdf", pdf("Quarterly report", ["Revenue up 12 percent", "Churn down 3 percent", "Hiring plan"]))
|
||||
add("/Documents/Meeting notes.md", text("# Meeting notes\n\n- Ship the beta on Friday\n- Review pricing\n- Book the offsite"))
|
||||
add("/Documents/Budget 2026.xlsx", os.urandom(24_000))
|
||||
add("/Documents/Contracts/Lease agreement.pdf", pdf("Lease agreement", ["Term: 12 months", "Rent due on the 1st"]))
|
||||
add("/Documents/Contracts/NDA - template.pdf", pdf("Mutual NDA", ["Standard template"]))
|
||||
|
||||
trips = ["Lake sunrise", "Mountain pass", "Old town", "Beach day", "Desert road", "Forest trail", "City lights"]
|
||||
for i, name in enumerate(trips):
|
||||
add("/Trips/%s.jpg" % name, landscape(i + 1))
|
||||
add("/Trips/Trip itinerary.pdf", pdf("Trip itinerary", ["Day 1 Arrive and settle in", "Day 2 Old town walk", "Day 3 Coast day trip"]))
|
||||
|
||||
for i in range(8):
|
||||
add("/Photos/Summer 2026/IMG_%04d.jpg" % (2041 + i), landscape(20 + i))
|
||||
add("/Photos/Summer 2026/Clip - hike.mp4", video())
|
||||
|
||||
add("/Recipes/Pasta al limone.md", text("# Pasta al limone\n\nSpaghetti, lemon, butter, parmesan, black pepper."))
|
||||
add("/Recipes/Sourdough.md", text("# Sourdough\n\n500g flour, 350g water, 100g starter, 10g salt."))
|
||||
add("/Recipes/Shopping list.txt", text("Lemons\nParmesan\nFlour\nCoffee beans"))
|
||||
|
||||
add("/Projects/Website redesign/Wireframes.pdf", pdf("Wireframes", ["Home", "Pricing", "Sign up"]))
|
||||
add("/Projects/Website redesign/Design tokens.json", text('{"radius": 12, "primary": "#2a6b50"}'))
|
||||
add("/Projects/Website redesign/notes.txt", text("Kickoff next Tuesday.\nCollect brand assets."))
|
||||
|
||||
favorites = ["/Trips/Lake sunrise.jpg", "/Trips/Mountain pass.jpg", "/Documents/Project brief.pdf", "/Recipes/Sourdough.md"]
|
||||
|
||||
# Created, then deleted - they end up in the Trash tab.
|
||||
trashed = [
|
||||
("/Old draft.txt", text("Superseded draft.")),
|
||||
("/Screenshot 2026-03-14.jpg", landscape(40, (1080, 1920))),
|
||||
("/Receipts (old)/March.pdf", pdf("Receipt", ["March"])),
|
||||
]
|
||||
return files, favorites, trashed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- server ---
|
||||
|
||||
|
||||
class ZeroStream:
|
||||
"""`size` zero bytes, streamed - a big filler file without holding it in memory."""
|
||||
|
||||
def __init__(self, size):
|
||||
self.size, self.pos = size, 0
|
||||
|
||||
def __len__(self):
|
||||
return self.size
|
||||
|
||||
def read(self, n=-1):
|
||||
n = self.size - self.pos if n is None or n < 0 else min(n, self.size - self.pos)
|
||||
self.pos += n
|
||||
return b"\0" * n
|
||||
|
||||
|
||||
class Server:
|
||||
def __init__(self):
|
||||
import requests # imported lazily so --generate-only needs no network libs
|
||||
|
||||
self.requests = requests
|
||||
|
||||
def _call(self, method, url, auth, **kw):
|
||||
headers = kw.pop("headers", {})
|
||||
headers.setdefault("OCS-APIRequest", "true")
|
||||
return self.requests.request(method, url, auth=auth, headers=headers, timeout=600, **kw)
|
||||
|
||||
def wait_ready(self):
|
||||
log("Waiting for Nextcloud to finish installing (first start takes a minute or two)...")
|
||||
deadline = time.time() + 600
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
r = self.requests.get(BASE + "/status.php", timeout=5)
|
||||
if r.ok and r.json().get("installed") and not r.json().get("maintenance"):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(3)
|
||||
sys.exit("Nextcloud did not become ready in time")
|
||||
|
||||
def ocs(self, method, path, auth, **kw):
|
||||
r = self._call(method, BASE + path, auth, params={"format": "json"}, **kw)
|
||||
try:
|
||||
return r.json()["ocs"]
|
||||
except Exception:
|
||||
return {"meta": {"statuscode": r.status_code}, "data": {}}
|
||||
|
||||
def create_user(self, user, password, display, email, quota):
|
||||
res = self.ocs(
|
||||
"POST", "/ocs/v1.php/cloud/users", ADMIN,
|
||||
data={"userid": user, "password": password, "displayName": display, "email": email, "quota": quota},
|
||||
)
|
||||
code = res["meta"]["statuscode"]
|
||||
if code not in (100, 200, 102): # 102 = already exists
|
||||
sys.exit("Could not create user %s: %s" % (user, res))
|
||||
|
||||
def dav(self, method, user, path, **kw):
|
||||
auth = (user, USERS[user][0])
|
||||
url = "%s/remote.php/dav/files/%s%s" % (BASE, user, quote(path))
|
||||
return self._call(method, url, auth, **kw)
|
||||
|
||||
def mkdirs(self, user, path):
|
||||
built = ""
|
||||
for part in [p for p in path.split("/") if p]:
|
||||
built += "/" + part
|
||||
self.dav("MKCOL", user, built)
|
||||
|
||||
def put(self, user, path, data):
|
||||
parent = path.rsplit("/", 1)[0]
|
||||
if parent:
|
||||
self.mkdirs(user, parent)
|
||||
r = self.dav("PUT", user, path, data=data)
|
||||
if r.status_code not in (200, 201, 204):
|
||||
sys.exit("PUT %s failed: %s %s" % (path, r.status_code, r.text[:200]))
|
||||
|
||||
def favorite(self, user, path):
|
||||
body = (
|
||||
'<?xml version="1.0"?><d:propertyupdate xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">'
|
||||
"<d:set><d:prop><oc:favorite>1</oc:favorite></d:prop></d:set></d:propertyupdate>"
|
||||
)
|
||||
self.dav("PROPPATCH", user, path, data=body, headers={"Content-Type": "application/xml"})
|
||||
|
||||
def delete(self, user, path):
|
||||
self.dav("DELETE", user, path)
|
||||
|
||||
def share(self, user, path, share_type, share_with=None, permissions=31):
|
||||
data = {"path": path, "shareType": share_type, "permissions": permissions}
|
||||
if share_with:
|
||||
data["shareWith"] = share_with
|
||||
auth = (user, USERS[user][0])
|
||||
self.ocs("POST", "/ocs/v2.php/apps/files_sharing/api/v1/shares", auth, data=data)
|
||||
|
||||
def app_password(self, user):
|
||||
auth = (user, USERS[user][0])
|
||||
res = self.ocs("GET", "/ocs/v2.php/core/getapppassword", auth)
|
||||
token = (res.get("data") or {}).get("apppassword")
|
||||
if not token:
|
||||
sys.exit("Could not get an app password for %s: %s" % (user, res))
|
||||
return token
|
||||
|
||||
|
||||
def seed_server():
|
||||
server = Server()
|
||||
server.wait_ready()
|
||||
|
||||
for user, (password, display, email, quota) in USERS.items():
|
||||
log("Creating user %s" % user)
|
||||
server.create_user(user, password, display, email, quota)
|
||||
|
||||
files, favorites, trashed = build_content()
|
||||
log("Uploading %d files as Alex" % len(files))
|
||||
for path, data in files:
|
||||
server.put("Alex", path, data)
|
||||
if FILLER_MB > 0:
|
||||
log("Uploading a %d MB filler file so the storage quota isn't empty" % FILLER_MB)
|
||||
server.put("Alex", "/Backups/Laptop backup.zip", ZeroStream(FILLER_MB * 1024 * 1024))
|
||||
|
||||
for path in favorites:
|
||||
server.favorite("Alex", path)
|
||||
|
||||
log("Creating shares")
|
||||
server.share("Alex", "/Trips", 0, "Sam", permissions=31)
|
||||
server.share("Alex", "/Documents/Project brief.pdf", 3, permissions=1)
|
||||
server.put("Sam", "/Team notes/Roadmap.md", text("# Roadmap\n\nQ3: beta. Q4: launch."))
|
||||
server.put("Sam", "/Team notes/Standup.md", text("Daily standup at 9:30."))
|
||||
server.share("Sam", "/Team notes", 0, "Alex", permissions=31)
|
||||
# Sam edits inside the folder Alex shared - gives the Activity feed a second author.
|
||||
server.put("Sam", "/Trips/Suggestions.txt", text("Add the coast road stop!"))
|
||||
|
||||
log("Filling the trash")
|
||||
for path, data in trashed:
|
||||
server.put("Alex", path, data)
|
||||
for path, _ in trashed:
|
||||
server.delete("Alex", path)
|
||||
server.delete("Alex", "/Receipts (old)")
|
||||
|
||||
token = server.app_password("Alex")
|
||||
print("APP_PASSWORD=" + token, flush=True)
|
||||
|
||||
|
||||
def generate_only(target):
|
||||
files, favorites, trashed = build_content()
|
||||
for path, data in files + trashed:
|
||||
dest = os.path.join(target, "Alex" + path)
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
with open(dest, "wb") as f:
|
||||
f.write(data)
|
||||
log("Wrote %d files under %s (favorites: %d)" % (len(files) + len(trashed), target, len(favorites)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--generate-only", metavar="DIR", help="write the fake files locally instead of seeding a server")
|
||||
args = parser.parse_args()
|
||||
if args.generate_only:
|
||||
generate_only(args.generate_only)
|
||||
else:
|
||||
seed_server()
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Makes captured screenshots Play Store ready and reports anything that isn't.
|
||||
|
||||
Play wants JPEG or 24-bit PNG (no alpha), each side 320-3840 px, and the long
|
||||
side at most twice the short side. The captured PNGs are 32-bit RGBA, so this
|
||||
flattens them to RGB in place, then checks the dimensions.
|
||||
|
||||
python3 tool/finalize_screenshots.py store_listing/screenshots
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def main(directory):
|
||||
files = sorted(Path(directory).glob("*.png"))
|
||||
if not files:
|
||||
sys.exit("No PNGs found in %s" % directory)
|
||||
|
||||
problems = 0
|
||||
for path in files:
|
||||
with Image.open(path) as im:
|
||||
im.load()
|
||||
rgb = Image.new("RGB", im.size, (255, 255, 255))
|
||||
rgb.paste(im, mask=im.getchannel("A") if "A" in im.getbands() else None)
|
||||
rgb.save(path, "PNG", optimize=True)
|
||||
|
||||
w, h = rgb.size
|
||||
notes = []
|
||||
if min(w, h) < 320 or max(w, h) > 3840:
|
||||
notes.append("side outside 320-3840 px")
|
||||
if max(w, h) > 2 * min(w, h):
|
||||
notes.append("aspect ratio over 2:1")
|
||||
problems += bool(notes)
|
||||
print("%-32s %4dx%-4d %s" % (path.name, w, h, "; ".join(notes) or "ok"))
|
||||
|
||||
print("\n%d screenshots, %d with problems" % (len(files), problems))
|
||||
sys.exit(1 if problems else 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1] if len(sys.argv) > 1 else "store_listing/screenshots")
|
||||
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generates the Play Store screenshots - without any real data.
|
||||
#
|
||||
# What it does, end to end:
|
||||
# 1. starts a throwaway Nextcloud in Docker and fills it with fake data
|
||||
# 2. creates + boots a clean Android emulator (1080x1920, demo-mode status bar)
|
||||
# 3. runs integration_test/store_screenshots_test.dart against it, which
|
||||
# logs in as the fake user and walks through the app's tabs and states
|
||||
# 4. leaves flattened, size-checked PNGs in store_listing/screenshots/
|
||||
#
|
||||
# Works on Linux, macOS and Git Bash on Windows. Needs: docker, python3,
|
||||
# flutter, and an Android SDK (ANDROID_HOME / ANDROID_SDK_ROOT). See
|
||||
# tool/demo_server/README.md.
|
||||
#
|
||||
# Options (environment variables):
|
||||
# SCREENSHOT_DIR output directory (store_listing/screenshots)
|
||||
# ANDROID_SERIAL use this already-running device instead of the emulator
|
||||
# HEADLESS=1 run the emulator without a window
|
||||
# DEMO_PORT host port for the demo server (80; anything else shows
|
||||
# up as ":port" in the app's header)
|
||||
# DEMO_HOST show this hostname instead of "localhost" in the app,
|
||||
# e.g. cloud.example.com (best effort - edits the
|
||||
# emulator's hosts file, which needs a rootable image)
|
||||
# KEEP_SERVER=1 leave the demo server running afterwards
|
||||
# KEEP_EMULATOR=1 leave the emulator running afterwards
|
||||
# SCREEN_W / SCREEN_H / SCREEN_DENSITY emulator screen (1080 / 1920 / 440);
|
||||
# Play needs the long side <= 2x the short side
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
REPO_ROOT="$PWD"
|
||||
|
||||
# Git Bash would otherwise rewrite device paths such as /system/etc/hosts.
|
||||
export MSYS_NO_PATHCONV=1
|
||||
|
||||
AVD_NAME="${AVD_NAME:-noo_screenshots}"
|
||||
API_LEVEL="${SCREENSHOT_API:-36}"
|
||||
SYSTEM_IMAGE="system-images;android-${API_LEVEL};google_apis;x86_64"
|
||||
SCREEN_W="${SCREEN_W:-1080}"
|
||||
SCREEN_H="${SCREEN_H:-1920}"
|
||||
SCREEN_DENSITY="${SCREEN_DENSITY:-440}"
|
||||
OUT_DIR="${SCREENSHOT_DIR:-store_listing/screenshots}"
|
||||
DEMO_PORT="${DEMO_PORT:-80}"
|
||||
DEMO_HOST="${DEMO_HOST:-}"
|
||||
export DEMO_PORT DEMO_HOST
|
||||
|
||||
log() { printf '\n==> %s\n' "$*" >&2; }
|
||||
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# Two easy mistakes worth catching up front rather than as a confusing
|
||||
# "missing prerequisites" list.
|
||||
if [ "$(id -u)" = 0 ]; then
|
||||
die "don't run this as root/with sudo - it would create root-owned files and use root's home for the emulator."
|
||||
fi
|
||||
if grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
die "this is WSL, which can't see your Windows Flutter/Android SDK (and can't run the emulator well). On Windows run it from Git Bash instead."
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- tooling ---
|
||||
|
||||
# Windows env vars hold C:\... paths; convert for bash.
|
||||
to_unix() { if command -v cygpath >/dev/null 2>&1; then cygpath -u "$1"; else printf '%s' "$1"; fi; }
|
||||
|
||||
SDK_ROOT=""
|
||||
for candidate in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Android/Sdk" \
|
||||
"$HOME/Library/Android/sdk" "${LOCALAPPDATA:-}/Android/Sdk"; do
|
||||
if [ -n "$candidate" ] && [ -d "$(to_unix "$candidate")" ]; then
|
||||
SDK_ROOT="$(to_unix "$candidate")"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Finds a tool by name on PATH or in the SDK, including Windows .bat/.exe.
|
||||
find_tool() {
|
||||
local name="$1" dir ext
|
||||
for ext in "" .bat .exe; do
|
||||
if command -v "$name$ext" >/dev/null 2>&1; then command -v "$name$ext"; return 0; fi
|
||||
done
|
||||
for dir in "$SDK_ROOT/cmdline-tools/latest/bin" "$SDK_ROOT/platform-tools" "$SDK_ROOT/emulator"; do
|
||||
for ext in "" .bat .exe; do
|
||||
if [ -x "$dir/$name$ext" ]; then printf '%s' "$dir/$name$ext"; return 0; fi
|
||||
done
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
missing=()
|
||||
require() { # <command> <how to get it>
|
||||
command -v "$1" >/dev/null 2>&1 || missing+=("$1 - $2")
|
||||
}
|
||||
require docker "install Docker (Desktop on Windows/macOS)"
|
||||
require flutter "install Flutter and put it on PATH"
|
||||
PY="$(command -v python3 || command -v python || true)"
|
||||
[ -n "$PY" ] || missing+=("python3 - install Python 3")
|
||||
ADB="$(find_tool adb || true)"
|
||||
[ -n "$ADB" ] || missing+=("adb - install Android platform-tools (or set ANDROID_HOME)")
|
||||
if [ -z "${ANDROID_SERIAL:-}" ]; then
|
||||
SDKMANAGER="$(find_tool sdkmanager || true)"
|
||||
AVDMANAGER="$(find_tool avdmanager || true)"
|
||||
[ -n "$SDKMANAGER" ] && [ -n "$AVDMANAGER" ] ||
|
||||
missing+=("sdkmanager/avdmanager - install the Android cmdline-tools (or set ANDROID_HOME)")
|
||||
fi
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
printf 'Missing prerequisites:\n' >&2
|
||||
printf ' - %s\n' "${missing[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
DC=(docker compose)
|
||||
else
|
||||
DC=(docker-compose)
|
||||
fi
|
||||
DC+=(-f tool/demo_server/docker-compose.yml)
|
||||
adb() { "$ADB" "$@"; }
|
||||
|
||||
# Python with Pillow + requests: use the system one if it has them, else a
|
||||
# local virtualenv (tool/demo_server/.venv, gitignored).
|
||||
if ! "$PY" -c 'import PIL, requests' >/dev/null 2>&1; then
|
||||
log "Setting up a Python virtualenv (Pillow, requests)"
|
||||
"$PY" -m venv tool/demo_server/.venv
|
||||
if [ -x tool/demo_server/.venv/bin/python ]; then
|
||||
PY="$REPO_ROOT/tool/demo_server/.venv/bin/python"
|
||||
else
|
||||
PY="$REPO_ROOT/tool/demo_server/.venv/Scripts/python"
|
||||
fi
|
||||
"$PY" -m pip install -q -r tool/demo_server/requirements.txt
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------- cleanup ---
|
||||
|
||||
STARTED_EMULATOR=0
|
||||
EMULATOR_PID=""
|
||||
cleanup() {
|
||||
local code=$?
|
||||
set +e
|
||||
if [ -n "${SERIAL:-}" ]; then
|
||||
adb -s "$SERIAL" shell am broadcast -a com.android.systemui.demo -e command exit >/dev/null 2>&1
|
||||
adb -s "$SERIAL" reverse --remove-all >/dev/null 2>&1
|
||||
fi
|
||||
if [ "$STARTED_EMULATOR" = 1 ] && [ "${KEEP_EMULATOR:-0}" != 1 ]; then
|
||||
adb -s "$SERIAL" emu kill >/dev/null 2>&1
|
||||
fi
|
||||
if [ "${KEEP_SERVER:-0}" != 1 ]; then
|
||||
"${DC[@]}" down -v >/dev/null 2>&1
|
||||
fi
|
||||
exit "$code"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# ---------------------------------------------------------- demo server ---
|
||||
|
||||
log "Starting a fresh demo Nextcloud (port $DEMO_PORT)"
|
||||
"${DC[@]}" down -v >/dev/null 2>&1 || true
|
||||
"${DC[@]}" up -d
|
||||
|
||||
log "Seeding fake data"
|
||||
APP_PASSWORD="$(DEMO_BASE_URL="http://localhost:$DEMO_PORT" "$PY" tool/demo_server/seed.py | grep '^APP_PASSWORD=' | tail -n1 | cut -d= -f2-)"
|
||||
[ -n "$APP_PASSWORD" ] || die "the seed script did not return an app password"
|
||||
|
||||
# ---------------------------------------------------------------- device ---
|
||||
|
||||
if [ -n "${ANDROID_SERIAL:-}" ]; then
|
||||
SERIAL="$ANDROID_SERIAL"
|
||||
log "Using existing device $SERIAL"
|
||||
else
|
||||
log "Preparing the emulator"
|
||||
[ -n "$SDK_ROOT" ] || die "set ANDROID_HOME to your Android SDK"
|
||||
EMULATOR="$(find_tool emulator || true)"
|
||||
if [ -z "$EMULATOR" ] || ! [ -d "$SDK_ROOT/system-images/android-${API_LEVEL}/google_apis/x86_64" ]; then
|
||||
log "Installing the emulator + system image (one-off, a few GB)"
|
||||
yes | "$SDKMANAGER" --licenses >/dev/null 2>&1 || true
|
||||
"$SDKMANAGER" "emulator" "platform-tools" "$SYSTEM_IMAGE"
|
||||
EMULATOR="$(find_tool emulator)"
|
||||
fi
|
||||
|
||||
# avdmanager also prints a harmless "Could not load devices from
|
||||
# .../devices.xml" error for system images that ship no devices.xml, so its
|
||||
# output is hidden and success is judged by whether the AVD exists.
|
||||
echo no | "$AVDMANAGER" create avd --force -n "$AVD_NAME" -k "$SYSTEM_IMAGE" -d pixel_6 >/dev/null 2>&1 || true
|
||||
AVD_DIR="$(to_unix "${ANDROID_AVD_HOME:-$HOME/.android/avd}")/$AVD_NAME.avd"
|
||||
[ -d "$AVD_DIR" ] || die "could not find the AVD at $AVD_DIR (set ANDROID_AVD_HOME)"
|
||||
set_ini() { # <key> <value>: replace or append in the AVD's config.ini
|
||||
if grep -q "^$1=" "$AVD_DIR/config.ini"; then
|
||||
sed -i.bak "s|^$1=.*|$1=$2|" "$AVD_DIR/config.ini" && rm -f "$AVD_DIR/config.ini.bak"
|
||||
else
|
||||
echo "$1=$2" >> "$AVD_DIR/config.ini"
|
||||
fi
|
||||
}
|
||||
sed -i.bak '/^skin\.path=/d' "$AVD_DIR/config.ini" && rm -f "$AVD_DIR/config.ini.bak"
|
||||
set_ini skin.name "${SCREEN_W}x${SCREEN_H}"
|
||||
set_ini hw.lcd.width "$SCREEN_W"
|
||||
set_ini hw.lcd.height "$SCREEN_H"
|
||||
set_ini hw.lcd.density "$SCREEN_DENSITY"
|
||||
|
||||
log "Booting the emulator"
|
||||
EMULATOR_FLAGS=(-avd "$AVD_NAME" -no-snapshot -wipe-data -no-audio -no-boot-anim -gpu swiftshader_indirect)
|
||||
[ "${HEADLESS:-0}" = 1 ] && EMULATOR_FLAGS+=(-no-window)
|
||||
"$EMULATOR" "${EMULATOR_FLAGS[@]}" >/tmp/noo_emulator.log 2>&1 &
|
||||
EMULATOR_PID=$!
|
||||
STARTED_EMULATOR=1
|
||||
|
||||
adb wait-for-device
|
||||
for _ in $(seq 1 150); do
|
||||
[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ] && break
|
||||
sleep 2
|
||||
done
|
||||
[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ] || die "the emulator did not finish booting (see /tmp/noo_emulator.log)"
|
||||
SERIAL="$(adb devices | awk '/^emulator-/{print $1; exit}')"
|
||||
fi
|
||||
export ANDROID_SERIAL="$SERIAL"
|
||||
|
||||
log "Configuring the device"
|
||||
adb shell input keyevent 82 >/dev/null 2>&1 || true # dismiss keyguard
|
||||
adb shell svc power stayon true >/dev/null 2>&1 || true # never sleep mid-run
|
||||
|
||||
# Make the demo server reachable from the device as localhost. Binding a port
|
||||
# below 1024 (the default 80) on the device needs root - the google_apis
|
||||
# emulator images allow `adb root` - otherwise fall back to a high device
|
||||
# port, which then shows up as ":8080" in the app.
|
||||
DEVICE_PORT="$DEMO_PORT"
|
||||
if [ "$DEMO_PORT" -lt 1024 ]; then
|
||||
adb root >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
adb wait-for-device
|
||||
fi
|
||||
if ! adb reverse "tcp:$DEVICE_PORT" "tcp:$DEMO_PORT" >/dev/null 2>&1; then
|
||||
DEVICE_PORT=8080
|
||||
printf 'note: could not bind port %s on the device (no root); using %s instead\n' "$DEMO_PORT" "$DEVICE_PORT" >&2
|
||||
adb reverse "tcp:$DEVICE_PORT" "tcp:$DEMO_PORT" || die "adb reverse failed"
|
||||
fi
|
||||
|
||||
SERVER_HOST="localhost"
|
||||
if [ -n "$DEMO_HOST" ]; then
|
||||
# Best effort: make DEMO_HOST resolve to the (reverse-forwarded) loopback.
|
||||
if adb root >/dev/null 2>&1 && sleep 2 && adb remount >/dev/null 2>&1 &&
|
||||
adb shell "echo '127.0.0.1 $DEMO_HOST' >> /system/etc/hosts" >/dev/null 2>&1; then
|
||||
SERVER_HOST="$DEMO_HOST"
|
||||
else
|
||||
printf 'warning: could not edit the device hosts file; showing "localhost" instead of %s\n' "$DEMO_HOST" >&2
|
||||
fi
|
||||
fi
|
||||
SERVER_URL="http://$SERVER_HOST"
|
||||
[ "$DEVICE_PORT" = 80 ] || SERVER_URL="$SERVER_URL:$DEVICE_PORT"
|
||||
|
||||
# Clean status bar: 12:00, full battery, full Wi-Fi, no notification icons.
|
||||
adb shell settings put global sysui_demo_allowed 1
|
||||
demo() { adb shell am broadcast -a com.android.systemui.demo -e command "$@" >/dev/null; }
|
||||
demo enter
|
||||
demo clock -e hhmm 1200
|
||||
demo battery -e level 100 -e plugged false
|
||||
demo network -e wifi show -e level 4 -e fully true -e mobile hide
|
||||
demo notifications -e visible false
|
||||
|
||||
# --------------------------------------------------------------- capture ---
|
||||
|
||||
log "Capturing screenshots (this drives the app for a few minutes)"
|
||||
rm -rf "$OUT_DIR"
|
||||
mkdir -p "$OUT_DIR"
|
||||
SCREENSHOT_DIR="$OUT_DIR" flutter drive \
|
||||
--driver=test_driver/integration_test.dart \
|
||||
--target=integration_test/store_screenshots_test.dart \
|
||||
-d "$SERIAL" \
|
||||
--dart-define="DEMO_SERVER_URL=$SERVER_URL" \
|
||||
--dart-define="DEMO_USERNAME=Alex" \
|
||||
--dart-define="DEMO_APP_PASSWORD=$APP_PASSWORD"
|
||||
|
||||
log "Making them Play Store ready"
|
||||
"$PY" tool/finalize_screenshots.py "$OUT_DIR"
|
||||
|
||||
log "Done: $OUT_DIR"
|
||||
printf '\nBefore uploading, LOOK at every image for anything real: the server\n' >&2
|
||||
printf 'address/username in the header and Settings, notification icons, names.\n' >&2
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Uploads an .aab to the Play Console's app bundle library, without
|
||||
touching any release track.
|
||||
|
||||
Play Console itself doesn't expose "just upload, don't release" as an
|
||||
action - every path through the UI attaches a bundle to a track. The Play
|
||||
Developer API does support it though: open an edit, upload the bundle,
|
||||
commit the edit, and stop there. The result shows up under Release > App
|
||||
bundle explorer and nowhere else - nothing goes out to testers or
|
||||
production until it's promoted to a track by hand, whenever that's wanted.
|
||||
|
||||
Needs:
|
||||
GOOGLE_PLAY_PACKAGE_NAME e.g. dev.ayushya.noo
|
||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH path to a service account key file
|
||||
|
||||
The Play Developer API can't create an app's first release - Google
|
||||
requires at least one manual upload through the Play Console web UI (to
|
||||
any track) before it'll accept API calls for that app at all.
|
||||
|
||||
python3 tool/upload_play_bundle.py path/to/app-release.aab
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
SCOPES = ["https://www.googleapis.com/auth/androidpublisher"]
|
||||
|
||||
|
||||
def main(bundle_path):
|
||||
package_name = os.environ["GOOGLE_PLAY_PACKAGE_NAME"]
|
||||
key_path = os.environ["GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH"]
|
||||
creds = service_account.Credentials.from_service_account_file(
|
||||
key_path, scopes=SCOPES
|
||||
)
|
||||
service = build("androidpublisher", "v3", credentials=creds)
|
||||
|
||||
edit_id = service.edits().insert(body={}, packageName=package_name).execute()["id"]
|
||||
|
||||
bundle = (
|
||||
service.edits()
|
||||
.bundles()
|
||||
.upload(editId=edit_id, packageName=package_name, media_body=bundle_path)
|
||||
.execute()
|
||||
)
|
||||
print("Uploaded versionCode %s" % bundle["versionCode"])
|
||||
|
||||
service.edits().commit(editId=edit_id, packageName=package_name).execute()
|
||||
print(
|
||||
"Committed - visible under Release > App bundle explorer in Play "
|
||||
"Console. Not attached to any track, so nothing goes out to "
|
||||
"testers or production until it's promoted by hand."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1])
|
||||
Reference in New Issue
Block a user