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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ bun run dev

You need Flutter and [bun](https://bun.sh). The dev build talks to a server on `http://localhost:5033`. The rest is in [the app docs](https://docs.schuly.dev/Schuly/).

The app screenshots for the website and store listings come from [`tools/store-screenshots`](tools/store-screenshots).

## License

[PolyForm Noncommercial 1.0.0](LICENSE)
Expand Down
1 change: 1 addition & 0 deletions tools/store-screenshots/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.sh text eol=lf
2 changes: 2 additions & 0 deletions tools/store-screenshots/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.venv/
out/
65 changes: 65 additions & 0 deletions tools/store-screenshots/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Store screenshots

Takes the app screenshots on an Android emulator and frames them in a Pixel phone. The website uses the five framed images: `public/assets/schuly-{start,agenda,grades,absences,account}-page-v2.{png,webp}` in SchulyWebsite. The same raw shots can go into store listings.

The app runs against a fake API with made-up data (Mia Muster, Berufsfachschule Beispielstadt), so no real account or school is involved.

| File | What it does |
|---|---|
| `fake-api/server.ts` | Bun server that answers the app's `/auth` and `/api` calls with fixed data on port 5591 (`PORT=` to change). It accepts any sign-in. |
| `fake-api/avatar.py` | Draws the made-up profile picture `fake-api/static/mia.jpg` (already committed). |
| `capture.sh` | Emulator, build, sign-in, capture and framing steps. |
| `ui.sh` | Lists the on-screen elements and their bounds, for when the tap coordinates in `capture.sh` need updating. |
| `frame.py` | Puts a 1080×2400 screenshot into the Pixel 9 Pro XL device art and scales it to 660×1394. |
| `deviceart/` | The device art (`back.webp`, 100 kB) and its licence. |

## Prerequisites

- Bun.
- Flutter, as for building the app.
- The Android SDK with `sdkmanager`, `avdmanager`, `emulator` and `adb` on `PATH`, and hardware acceleration for the emulator.
- Python 3 with a virtual environment for the framing:

```sh
cd tools/store-screenshots
python -m venv .venv
.venv/Scripts/pip install -r requirements.txt # on macOS/Linux: .venv/bin/pip
```

- A bash shell. On Windows, use Git Bash.

## Regenerate

Run these from the repo root. Start the fake API in its own terminal and leave it running:

```sh
bun tools/store-screenshots/fake-api/server.ts
```

Then run the steps, all at once or one by one:

```sh
tools/store-screenshots/capture.sh all # avd boot build prepare login shoot frame
tools/store-screenshots/capture.sh cleanup # stop the emulator and delete the AVD
```

| Step | What it does |
|---|---|
| `avd` | Creates `SchulyShots_Pixel8_API35`: a Pixel 8 (1080×2400) with `system-images;android-35;google_apis;x86_64`, 2 GB RAM. The Google APIs image is needed because `adb root` must work to set the clock. |
| `boot` | Starts it on port 5580 (`emulator-5580`) with `-no-snapshot -gpu host -memory 2048` and waits for the boot to finish. |
| `build` | `flutter build apk --flavor dev --release --target-platform android-x64 --dart-define=BACKEND_BASE_URL=http://10.0.2.2:5591`. `10.0.2.2` is how the emulator reaches the host. |
| `prepare` | Installs the APK, turns off automatic time, sets the timezone to Europe/Zurich, the clock to Fri 6.11.2026 09:41 and 24-hour format, turns dark mode on and enters the status bar demo mode. |
| `login` | Taps through onboarding and signs in as `mia.muster` in account mode. |
| `shoot` | Restarts the app, then saves home, grades, absences, account and the timetable on Tue 10.11. to `out/raw/*.png` with `adb exec-out screencap`. Before each shot it resets the clock to 09:41, full Wi-Fi, no mobile signal and no notifications. |
| `frame` | Writes `out/framed/schuly-<page>-page-v2.png` and `.webp`. |

Copy `out/framed/*` to SchulyWebsite's `public/assets/`. If the images change, give them a new name suffix (`-v3`) and update the references, so that caches load the new ones. The hero phone image on the website is `out/raw/home-dark.png`; see SchulyWebsite's `tools/hero-images`.

Settings you can override with environment variables: `AVD`, `IMAGE`, `EMU_PORT`, `API_PORT`, `CLOCK` (`MMDDhhmmYYYY.ss`), `OUT` and `PY`. `DRY_RUN=1` prints every command without running it.

The taps in `login` and `shoot` are screen coordinates for the Pixel 8. If the app's layout changes, run `tools/store-screenshots/ui.sh` on the screen you're stuck at, and update the coordinates. To frame a single screenshot by hand, run `python frame.py <shot.png> <out.png> <out.webp>`.

## Licences

- `deviceart/back.webp` is the Pixel 9 Pro XL device art from the Android Open Source Project / Android Studio, under the Apache License 2.0 (`deviceart/LICENSE.txt`). `frame.py` shortens the frame to the screenshot's aspect ratio. SchulyWebsite credits it in `CREDITS.txt`.
- The fake data and `static/mia.jpg` are made up for these screenshots.
117 changes: 117 additions & 0 deletions tools/store-screenshots/capture.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# Captures the app screenshots on an Android emulator against the fake API, then frames them.
# Usage: tools/store-screenshots/capture.sh <step>... (run from the repo root; steps: avd boot build prepare login shoot frame cleanup, or all)
# DRY_RUN=1 prints the commands instead of running them.
set -euo pipefail

HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
OUT="${OUT:-$HERE/out}"
AVD="${AVD:-SchulyShots_Pixel8_API35}"
IMAGE="${IMAGE:-system-images;android-35;google_apis;x86_64}"
PORT="${EMU_PORT:-5580}"
SERIAL="emulator-$PORT"
API_PORT="${API_PORT:-5591}"
PKG=com.schuly.app.dev
CLOCK="${CLOCK:-110609412026.00}" # MMDDhhmmYYYY.ss: Fri 6.11.2026, 09:41
PY="${PY:-$HERE/.venv/Scripts/python}"; [ -x "$PY" ] || PY="$HERE/.venv/bin/python"

run() { if [ -n "${DRY_RUN:-}" ]; then echo "+ $*"; else "$@"; fi; }
# MSYS_NO_PATHCONV stops Git Bash on Windows from rewriting device paths such as /sdcard/... for adb.
adbs() { if [ -n "${DRY_RUN:-}" ]; then echo "+ adb -s $SERIAL $*"; else MSYS_NO_PATHCONV=1 adb -s "$SERIAL" "$@"; fi; }
quiet() { if [ -n "${DRY_RUN:-}" ]; then adbs "$@"; else adbs "$@" >/dev/null; fi; }
demo() { quiet shell am broadcast -a com.android.systemui.demo -e command "$@"; }
tap() { adbs shell input tap "$1" "$2"; sleep "${3:-1.5}"; }
tab() { tap "$1" 2256; } # bottom navigation: 118 home, 330 timetable, 540 grades, 750 absences, 961 account

# Resets the clock and status bar, then saves a 1080x2400 PNG.
shot() {
quiet shell date "$CLOCK"
demo network -e wifi show -e level 4 -e fully true
demo network -e mobile hide
demo clock -e hhmm 0941
demo notifications -e visible false
sleep "${WAIT:-1}"
mkdir -p "$OUT/raw"
if [ -n "${DRY_RUN:-}" ]; then echo "+ adb -s $SERIAL exec-out screencap -p > $OUT/raw/$1.png"; else MSYS_NO_PATHCONV=1 adb -s "$SERIAL" exec-out screencap -p > "$OUT/raw/$1.png"; fi
echo "saved $1"
}

step_avd() {
# Pixel 8 (1080x2400, 420 dpi) on API 35 with Google APIs, so adb root works for setting the clock.
run sdkmanager "$IMAGE"
if [ -n "${DRY_RUN:-}" ]; then echo "+ echo no | avdmanager create avd -n $AVD -k $IMAGE -d pixel_8"; else echo no | avdmanager create avd -n "$AVD" -k "$IMAGE" -d pixel_8; fi
local cfg="$HOME/.android/avd/$AVD.avd/config.ini"
run sed -i 's/^hw.ramSize=.*/hw.ramSize=2048/; s/^hw.keyboard=.*/hw.keyboard=yes/; s/^hw.gpu.enabled=.*/hw.gpu.enabled=yes/' "$cfg"
}

step_boot() {
if [ -n "${DRY_RUN:-}" ]; then echo "+ emulator -avd $AVD -port $PORT -no-snapshot -no-boot-anim -gpu host -memory 2048 &"; else emulator -avd "$AVD" -port "$PORT" -no-snapshot -no-boot-anim -gpu host -memory 2048 >"$OUT/emulator.log" 2>&1 & fi
adbs wait-for-device
[ -n "${DRY_RUN:-}" ] && return
until [ "$(adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; do sleep 3; done
echo booted
}

step_build() {
# Dev flavor, release mode, pointed at the fake API through the emulator's host alias.
(cd "$ROOT" && run flutter build apk --flavor dev --release --target-platform android-x64 --dart-define=BACKEND_BASE_URL="http://10.0.2.2:$API_PORT")
}

step_prepare() {
quiet root; sleep 3
adbs install -r "$ROOT/build/app/outputs/flutter-apk/app-dev-release.apk"
adbs shell settings put global auto_time 0
adbs shell settings put global auto_time_zone 0
adbs shell cmd alarm set-timezone Europe/Zurich
adbs shell setprop persist.sys.timezone Europe/Zurich
adbs shell date "$CLOCK"
adbs shell settings put system time_12_24 24
adbs shell cmd uimode night yes
adbs shell settings put global sysui_demo_allowed 1
demo enter
demo battery -e level 100 -e plugged false
}

step_login() {
# Coordinates are for the Pixel 8 screen. If the app's onboarding changed, find the new ones with ui.sh.
quiet shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1; sleep 10
for _ in 1 2 3 4; do tap 540 2238 1.2; done # through the onboarding pages
tap 540 850 0.5; tap 540 2240 # account mode, continue
tap 540 1764 # pick the school system
tap 540 790 0.5; adbs shell input text mia.muster
tap 540 990 0.5; adbs shell input text Beispiel2026
adbs shell input keyevent 111; sleep 0.5 # hide the keyboard
tap 540 1147 5 # sign in (the fake API accepts any login)
}

step_shoot() {
adbs shell am force-stop "$PKG"
quiet shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1; sleep 7
shot home-dark
tab 540; shot grades-dark
tab 750; shot absences-dark
tab 961; WAIT=2 shot account-dark
# Timetable on Tue 10.11.: swipe the date strip one week on, then tap the day.
tab 330
adbs shell input swipe 900 430 300 430 300; sleep 1.5
tap 326 430 1.2; shot agenda-dark
}

step_frame() {
mkdir -p "$OUT/framed"
for p in "home-dark start" "agenda-dark agenda" "grades-dark grades" "absences-dark absences" "account-dark account"; do
set -- $p
run "$PY" "$HERE/frame.py" "$OUT/raw/$1.png" "$OUT/framed/schuly-$2-page-v2.png" "$OUT/framed/schuly-$2-page-v2.webp"
done
}

step_cleanup() {
adbs emu kill || true
run avdmanager delete avd -n "$AVD"
}

[ $# -gt 0 ] || { sed -n '2,4p' "$0"; exit 1; }
[ "$1" = all ] && set -- avd boot build prepare login shoot frame
mkdir -p "$OUT"
for s in "$@"; do "step_$s"; done
Loading
Loading