diff --git a/.gitignore b/.gitignore index 7c61607..2a51cfe 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ keystore.properties # Generated data — regenerated on demand, never committed fixtures/ store-assets/raw-screenshots/ +demo-photos/ +store-assets/screenshots/ diff --git a/README.md b/README.md index 33155db..809abf7 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,8 @@ listing, Data Safety, release notes, rollout), [docs/STORE_LISTING.md](docs/STOR The upload-ready graphics and screenshots live in [`store-assets/`](store-assets/). `tools/make-store-assets.sh` and `tools/make-store-screenshots.sh` regenerate them from the app's own icon vectors and from `adb` captures — always inside the repo, never in `$HOME`. +`python3 tools/make-demo-photos.py` draws the synthetic photo library the results screenshot is +captured over, so the listing never shows a real one. User-visible changes are tracked in [CHANGELOG.md](CHANGELOG.md); the Play "What's new" text you type per release is separate and lives only in the Play Console. diff --git a/docs/PLAY_RELEASE_CHECKLIST.md b/docs/PLAY_RELEASE_CHECKLIST.md index 266ea8c..f180de7 100644 --- a/docs/PLAY_RELEASE_CHECKLIST.md +++ b/docs/PLAY_RELEASE_CHECKLIST.md @@ -54,8 +54,10 @@ action except the items marked **[code]**. - [x] **Feature graphic** 1024×500 PNG **[code]** — same script. - [ ] At least 2 phone screenshots (up to 8). Three are captured (empty state, scanning, results), but **re-shoot them after granting photo access on a fresh install**: the - existing ones still show the "Photo access needed" banner — see the caveat in - STORE_LISTING.md. + existing ones still show the "Photo access needed" banner, and the results capture shows + the developer's own photo library. Capture the results screen over the generated demo + library instead (`python3 tools/make-demo-photos.py`, then push and index it) — see the + caveats in STORE_LISTING.md. ⚠️ Play rejects a screenshot whose longer side is more than 2× its shorter side, so a 1080×2400 phone capture has to be cropped to 1080×2160; `tools/make-store-screenshots.sh` does exactly that (status and navigation bars only). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 37ed4d9..8fedc2a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo | M7 | On-device validation & hardening | ✅ | Foreground `dataSync` service, one failure boundary per file, `core/DecodeMath`, hostile fixtures ([EDGE_CASE_TESTING](EDGE_CASE_TESTING.md)), `versionName` → 1.0.0 | | M8 | Localisation groundwork | ✅ | Every user-visible string in `res/values/strings.xml` with real ``, shared `text/TextRes.kt`; **English is the only locale** | | M9 | Monetisation | ⏳ | No billing code. Blocked on a product decision (free forever vs. one-time unlock vs. limited free scans); changes the Data Safety answers | -| M10 | Store assets | ✅ | `tools/make-store-assets.sh` renders the 512×512 icon and the 1024×500 feature graphic from the app's own vectors; `tools/make-store-screenshots.sh` normalises device captures to Play's 2:1 limit (1080×2160); all listing copy is drafted and length-checked in [STORE_LISTING](STORE_LISTING.md). The captured screenshots still show the permission banner — see blockers | +| M10 | Store assets | ✅ | `tools/make-store-assets.sh` renders the 512×512 icon and the 1024×500 feature graphic from the app's own vectors; `tools/make-store-screenshots.sh` normalises device captures to Play's 2:1 limit (1080×2160); `tools/make-demo-photos.py` generates the photo library the results capture is taken over, so no real photos are published; all listing copy is drafted and length-checked in [STORE_LISTING](STORE_LISTING.md). The captures committed today predate the demo library and still show the permission banner — see blockers | ## Release blockers (work that is not a numbered milestone) @@ -31,7 +31,7 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo | ~~Unreleased `BoundedCall` fix~~ ✅ | The provider-call deadline shipped in **1.0.1** (`versionCode` 2) | | **Play Console: foreground-service declaration** | Declaring the `dataSync` type (App content › Foreground service permissions) is release-blocking. **[code]** the manifest already declares it; the ready-to-paste justification is in [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) step 5 — this is a Console action, not a code change | | **Remaining Play paperwork** | Keystore / Play App Signing, Data Safety form, hosted privacy policy and store listing — see [PLAY_RELEASE_CHECKLIST](PLAY_RELEASE_CHECKLIST.md) | -| **Store screenshots still show the permission banner** | The three captures were taken with the debug-only `MANAGE_EXTERNAL_STORAGE` app-op, so the app's own permission check (correctly) still reported *no access* and the banner is in frame. Re-shoot on a clean install after tapping **Grant access** — the same run that closes the permission-flow verification gap. Files, specs and caveats: [STORE_LISTING](STORE_LISTING.md) | +| **Store screenshots need one re-shoot** | Two things are wrong with the committed captures: they were taken with the debug-only `MANAGE_EXTERNAL_STORAGE` app-op, so the app's own permission check (correctly) still reported *no access* and the banner is in frame; and `03-results.png` shows the developer's real photo library. Both clear in a single run after tapping **Grant access**. ⚠️ An app-op cannot stand in for the grant — `appops set --uid android:read_media_images allow` leaves the banner *and* a disabled *Start scan* — and MIUI refuses synthetic taps, so the re-shoot needs two real taps unless *USB debugging (Security settings)* is enabled in the developer options. The demo library that keeps real photos out of the capture is generated, validated and already indexed on the test device; files, specs and caveats: [STORE_LISTING](STORE_LISTING.md) | | **Device-only verifications** | RAW/DNG files, a *real* iPhone HEIC with a non-upright EXIF orientation, permission revoked mid-scan, and the Android 14 “Selected photos” flow. ✅ The **index-reuse** check is done: after a scan the index holds **5,363 rows, 5,357 with a perceptual hash**, and a second scan runs in **11.0 s vs 20.2 s** cold | ## Candidate milestones (proposed, not scheduled) @@ -49,9 +49,10 @@ Legend: ✅ done · 🚧 in progress · ⏳ not started · 💡 candidate (propo (`versionCode` 2). Only the on-device re-check of the 5,363-photo scan remains. 2. Finish the Play Console paperwork and roll out through **internal → closed → production**. 3. ✅ **Done** — **M10 store assets**: the icon and feature graphic are generated from the - app's own vectors, the screenshot normaliser enforces Play's 2:1 limit, and the listing - copy is drafted and length-checked. The only remainder is the screenshot re-shoot after a - real permission grant (see blockers). + app's own vectors, the screenshot normaliser enforces Play's 2:1 limit, the listing copy is + drafted and length-checked, and the demo library that keeps real photos out of the results + capture is generated and validated. The only remainder is the re-shoot after a real + permission grant (see blockers). 4. **Colour signature** — the flat-colour false-positive fix. 5. **M9 — monetisation**, once the product decision is made. diff --git a/docs/STORE_LISTING.md b/docs/STORE_LISTING.md index 34593d1..8462e40 100644 --- a/docs/STORE_LISTING.md +++ b/docs/STORE_LISTING.md @@ -17,6 +17,7 @@ release they belong to. Only the uncropped captures the screenshots are cut from ```bash ./tools/make-store-assets.sh # icon + feature graphic -> store-assets/ ./tools/make-store-screenshots.sh # crop the adb captures -> store-assets/screenshots/ +python3 tools/make-demo-photos.py # synthetic photo library -> demo-photos/ (git-ignored) ``` | Asset | Play's spec | Result | Source | @@ -45,19 +46,81 @@ Captured on the test device (1080×2400, Android 12 / MIUI), cropped to 1080×21 | `02-scanning.png` | Scanning progress — *“Looking for visually similar photos…”* 5341 / 5363 | | `03-results.png` | Results — 15 duplicate groups, 39.8 MB reclaimable, `BEST` badge, selection bar | -⚠️ **Two caveats before these go live.** +⚠️ **Both caveats below are outstanding for the files committed today.** One re-shoot fixes both. 1. All three still show the *“Photo access needed”* banner. The test device (Xiaomi M2101K7AG / MIUI) refuses `pm grant`, `install -g` and synthetic taps, so the runtime permission was never actually granted there — the scan was driven with a debug-only - `MANAGE_EXTERNAL_STORAGE` app-op, which the app's permission check does not honour (see - [EDGE_CASE_TESTING](EDGE_CASE_TESTING.md)). **Re-shoot on a clean install after tapping + `MANAGE_EXTERNAL_STORAGE` app-op. That app-op gets the scan through MediaStore but does + **not** satisfy the app's own `checkSelfPermission`, which is why the banner — and the + disabled *Start scan* — are genuinely in frame. **Re-shoot on a clean install after tapping “Grant access”**: that also closes the one remaining device-only verification gap. -2. The results screenshot shows real photos from the test library. They are the developer's - own, so they can be published, but check that nothing personal is recognisable first. -Screens still worth capturing once a device grants access through the real flow — each needs -one tap, which is exactly what the MIUI test device cannot be driven to do: + Enabling *USB debugging (Security settings)* in the MIUI developer options turns synthetic + taps and `pm grant` back on. That is what would let the capture run from a script instead of + by hand. +2. `03-results.png` currently shows real photos from the test library. They are the developer's + own, so nothing is licensed against us, but the library is not ours to publish. The next + capture must be taken over the generated demo library below. + +### Demo library: generated photos, so none of anyone's real ones are published + +`03-results.png` is a screenshot *of a photo library*, so whatever is on the test device ends up +in the store listing along with it. `tools/make-demo-photos.py` exists so that library can be +generated instead of borrowed: every scene is drawn procedurally — sunset, mountains, beach, +conifers in haze, dunes, city skyline, night sky, bokeh — so there are no people, no third-party +content and nothing to license. It writes to `demo-photos/foto-dup-demo/`, which is git-ignored +and regenerated on demand, exactly like `fixtures/`. + +The duplicate structure is deliberate, because a folder of unrelated pictures demonstrates +nothing: + +- **headline group** — one scene re-saved at falling quality and size until its reclaimable + size reaches `--target-mb` (default 45 MB), +- **exact pair** — two byte-identical files, for the SHA-256 path, +- **singles** — the other scenes, once each, so the library looks lived-in. + +```bash +python3 tools/make-demo-photos.py +adb push demo-photos/foto-dup-demo /sdcard/DCIM/foto-dup-demo +adb shell content call --uri content://media --method scan_file \ + --arg /sdcard/DCIM/foto-dup-demo +``` + +Two non-obvious details, both load-bearing: + +- **Why the headline group has a target size.** Results are ranked by reclaimable bytes, so the + first group on screen is the largest one. A demo group smaller than anything already on the + device would be pushed below the fold and a real photo would be back in frame. 45 MB clears + the test device's own 39.8 MB with room to spare; raise `--target-mb` if you capture on a + library whose biggest group is larger. +- **`scan_file` on the folder, not on the volume.** It indexes just that path (API 30+) instead + of forcing a MediaStore-wide rescan on a 5,000-photo device. It does recurse into the folder, + and re-running it after the folder is deleted drops those rows again — both verified on the + test device. + +Check the grouping *before* pushing, rather than reading it off the phone: the desktop harness +runs the app's real engine over a scaled copy of the library and reports the two intended groups +and nothing else. + +```bash +magick mogrify -resize 1024x -quality 90 -path build/demo-validate-raw demo-photos/foto-dup-demo/*.jpg +python3 tools/normalise-for-local-grouping.py build/demo-validate-raw build/demo-validate +./gradlew :app:testDebugUnitTest --tests '*LocalGroupingTest*' \ + -DfotoLocal.dir="$PWD/build/demo-validate" +# images=22 groups=2 -> PERCEPTUAL 14 copies, EXACT 2 copies; the six singles stay ungrouped +``` + +Cleanup, once the captures are done: + +```bash +adb shell rm -rf /sdcard/DCIM/foto-dup-demo +adb shell content call --uri content://media --method scan_file --arg /sdcard/DCIM/foto-dup-demo +``` + +Screens still worth capturing once a device grants access through the real flow (the capture run +needs two taps on the phone — *Grant access*, then *Scan* — which is the one thing the MIUI test +device will not accept from a script): - the **Options** sheet with the three sensitivity presets, - the staged state (*“All duplicates are ready to go”*) after selecting duplicates, diff --git a/tools/make-demo-photos.py b/tools/make-demo-photos.py new file mode 100644 index 0000000..786665d --- /dev/null +++ b/tools/make-demo-photos.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +"""Generate a synthetic photo library for store screenshots and demo runs. + +Nothing in here is a photograph: every scene is drawn procedurally — gradients, ridges, +silhouettes, grain, vignette — so a screenshot taken over this library can be published +without putting anyone's private pictures in the store listing. + +The duplicate structure is deliberate, which is the second reason to generate it: the app +is meant to *find* something, and a folder of unrelated pictures shows nothing. + + headline group one scene re-saved at falling quality and size, to a size you set with + --target-mb. Groups are ranked by reclaimable bytes, so this has to + out-rank whatever is already in the real library or a real photo would + be the first thing on the results screen. + exact pair two byte-identical files, for the SHA-256 path + singles one copy each of the other scenes, so the library looks lived-in + +Written to demo-photos/foto-dup-demo/ inside the repository (git-ignored, regenerated on +demand): + + python3 tools/make-demo-photos.py + adb push demo-photos/foto-dup-demo /sdcard/DCIM/foto-dup-demo + adb shell content call --uri content://media --method scan_file \\ + --arg /sdcard/DCIM/foto-dup-demo + +`scan_file` is the MediaProvider rescan hook (API 30+); it indexes just that path, so the +rest of the library is untouched. Then open the app, scan, and capture. +""" +from __future__ import annotations + +import argparse +import math +import sys +from pathlib import Path + +import numpy as np +from PIL import Image, ImageChops, ImageDraw, ImageFilter + +REPO = Path(__file__).resolve().parent.parent +OUT = REPO / "demo-photos" / "foto-dup-demo" + +# 12 MP, the shape a phone camera writes, so file sizes land in the range real photos do. +FULL = (4032, 3024) +# Scenes are painted at a third of that and upscaled: cheap, and the softness reads as +# "photograph" rather than "illustration". +RENDER = (1344, 1008) +SEED = 20240718 +GRAIN = 12 + + +def _rng() -> np.random.Generator: + return np.random.default_rng(SEED) + + +def _gradient(size: tuple[int, int], stops: list[tuple[float, tuple[int, int, int]]]) -> np.ndarray: + """Vertical gradient through (position 0..1, (r, g, b)) stops.""" + width, height = size + ys = np.linspace(0.0, 1.0, height) + positions = np.array([stop[0] for stop in stops]) + channels = [ + np.interp(ys, positions, [stop[1][channel] for stop in stops]) + for channel in range(3) + ] + column = np.stack(channels, axis=-1) + return np.repeat(column[:, None, :], width, axis=1) + + +def _to_image(array: np.ndarray) -> Image.Image: + return Image.fromarray(np.clip(array, 0, 255).astype(np.uint8), "RGB") + + +def _ridge(rng: np.random.Generator, width: int, base: float, amplitude: float, roughness: float) -> np.ndarray: + """A horizon line built from summed sine waves — smooth, never jagged.""" + xs = np.linspace(0.0, 1.0, width) + line = np.full(width, base, dtype=np.float64) + for octave in range(4): + line += (amplitude / (2 ** (octave + 1))) * np.sin( + math.tau * roughness * (2**octave) * xs + rng.uniform(0, math.tau) + ) + return line + + +def _fill_ridge(image: Image.Image, line: np.ndarray, colour: tuple[int, int, int]) -> None: + """Fill everything below a horizon line, i.e. draw a hill or dune.""" + width, height = image.size + points = [(x, float(line[x])) for x in range(width)] + points += [(width, height), (0, height)] + ImageDraw.Draw(image).polygon(points, fill=colour) + + +def _snow(image: Image.Image, line: np.ndarray) -> None: + """White caps on the high parts of a ridge.""" + width, _ = image.size + top, bottom = float(line.min()), float(line.max()) + cutoff = top + (bottom - top) * 0.35 + draw = ImageDraw.Draw(image, "RGBA") + for x in range(0, width - 6, 4): + if line[x] <= cutoff: + drop = 8 + (cutoff - line[x]) * 0.6 + draw.polygon( + ((x, line[x]), (x + 6, line[x]), (x + 3, line[x] + drop)), + fill=(250, 252, 255, 235), + ) + + +def _vignette(image: Image.Image, strength: float = 0.24) -> Image.Image: + """Darken the corners. The cheapest way to stop a gradient reading as vector art.""" + width, height = image.size + ys = np.linspace(-1.0, 1.0, height)[:, None] + xs = np.linspace(-1.0, 1.0, width)[None, :] + radius = np.sqrt(xs * xs + ys * ys) / math.sqrt(2.0) + mask = 1.0 - strength * radius**2.2 + return _to_image(np.asarray(image, dtype=np.float32) * mask[..., None]) + + +def _grain(image: Image.Image, sigma: int = GRAIN) -> Image.Image: + """Fine sensor noise. Also, conveniently, what gives a JPEG a realistic file size.""" + noise = Image.effect_noise(image.size, sigma).convert("RGB") + return ImageChops.add(image, noise, scale=1.0, offset=-128) + + +def finish(size: tuple[int, int], painter, rng: np.random.Generator) -> Image.Image: + """Paint a scene small, blow it up, and make it look photographed.""" + scene = painter(RENDER, rng).resize(size, Image.LANCZOS) + scene = scene.filter(ImageFilter.GaussianBlur(0.5)) + return _grain(_vignette(scene)) + + +def save( + image: Image.Image, + path: Path, + quality: int, + scale: float = 1.0, + taken: str | None = None, +) -> int: + if scale != 1.0: + size = (int(image.width * scale) // 2 * 2, int(image.height * scale) // 2 * 2) + image = image.resize(size, Image.LANCZOS) + exif = Image.Exif() + exif[0x0112] = 1 # orientation — "as shot" + if taken: + exif[0x9003] = taken + image.save(path, "JPEG", quality=quality, subsampling=2, exif=exif) + return path.stat().st_size + + +def scene_sunset(size: tuple[int, int], rng: np.random.Generator) -> Image.Image: + width, height = size + horizon = int(height * 0.60) + sky = _gradient((width, horizon), [ + (0.0, (44, 28, 78)), (0.55, (168, 74, 96)), (0.85, (240, 140, 72)), (1.0, (255, 208, 132)), + ]) + water = _gradient((width, height - horizon), [ + (0.0, (252, 178, 96)), (0.30, (156, 84, 108)), (1.0, (30, 20, 52)), + ]) + image = _to_image(np.vstack([sky, water])) + + draw = ImageDraw.Draw(image, "RGBA") + sun_x = int(width * 0.62) + radius = height * 0.07 + draw.ellipse( + (sun_x - radius, horizon - radius * 1.4, sun_x + radius, horizon + radius * 0.6), + fill=(255, 240, 178, 255), + ) + # The sun's reflection: a streak that widens as it comes towards the camera. + draw.polygon( + ((sun_x - radius * 0.5, horizon), (sun_x + radius * 0.5, horizon), + (sun_x + radius * 2.4, height), (sun_x - radius * 2.4, height)), + fill=(255, 214, 150, 60), + ) + image = image.filter(ImageFilter.GaussianBlur(2.5)) + _fill_ridge(image, _ridge(rng, width, horizon + height * 0.015, height * 0.045, 1.1), (26, 18, 42)) + return image + + +def scene_mountains(size: tuple[int, int], rng: np.random.Generator) -> Image.Image: + width, height = size + horizon = height * 0.72 + image = _to_image(_gradient((width, height), [ + (0.0, (58, 108, 180)), (0.45, (150, 190, 226)), (0.72, (226, 232, 231)), (1.0, (198, 208, 208)), + ])) + for index, (base, amplitude, colour) in enumerate([ + (horizon - height * 0.30, height * 0.16, (138, 160, 190)), + (horizon - height * 0.20, height * 0.14, (100, 124, 158)), + (horizon - height * 0.11, height * 0.12, (62, 82, 112)), + ]): + line = _ridge(rng, width, base, amplitude, 1.4 + index * 0.8) + _fill_ridge(image, line, colour) + _snow(image, line) + return image.filter(ImageFilter.GaussianBlur(1.1)) + + +def scene_beach(size: tuple[int, int], rng: np.random.Generator) -> Image.Image: + width, height = size + sea, sand = int(height * 0.55), int(height * 0.74) + image = _to_image(np.vstack([ + _gradient((width, sea), [(0.0, (86, 158, 214)), (0.7, (170, 212, 236)), (1.0, (238, 246, 240))]), + _gradient((width, sand - sea), [(0.0, (34, 110, 140)), (0.6, (58, 156, 176)), (1.0, (150, 208, 208))]), + _gradient((width, height - sand), [(0.0, (238, 220, 186)), (1.0, (206, 178, 138))]), + ])) + + draw = ImageDraw.Draw(image, "RGBA") + for index in range(5): # foam where the water meets the sand + y = sand - 4 + index * 5 + inset = width * rng.uniform(0.0, 0.25) + draw.line((inset, y, width - inset, y + rng.uniform(-3, 3)), fill=(255, 255, 255, 90), width=3) + image = image.filter(ImageFilter.GaussianBlur(1.6)) + # The blur above rebound `image`; the umbrella has to be drawn through a fresh handle. + draw = ImageDraw.Draw(image, "RGBA") + + pole_x = int(width * 0.30) + canopy_y = sand - (height - sand) * 0.20 + apex_y = sand - (height - sand) * 0.44 + half = width * 0.11 + draw.line((pole_x, sand + (height - sand) * 0.85, pole_x, sand - (height - sand) * 0.25), + fill=(228, 226, 220, 255), width=6) + draw.polygon( + ((pole_x - half, canopy_y), (pole_x + half, canopy_y), (pole_x, apex_y)), + fill=(226, 92, 78, 255), + ) + # One pale panel, so it reads as a stripy beach umbrella rather than a red triangle. + draw.polygon( + ((pole_x - half * 0.35, canopy_y), (pole_x + half * 0.05, canopy_y), (pole_x, apex_y)), + fill=(244, 240, 232, 255), + ) + draw.ellipse( + (pole_x - width * 0.07, sand + (height - sand) * 0.80, + pole_x + width * 0.07, sand + (height - sand) * 0.86), + fill=(60, 48, 40, 70), + ) + return image + + +def scene_forest(size: tuple[int, int], rng: np.random.Generator) -> Image.Image: + """Foggy conifer hillside: three bands of trees, each behind a sheet of haze.""" + width, height = size + image = _to_image(_gradient((width, height), [ + (0.0, (198, 216, 208)), (0.45, (152, 180, 170)), (1.0, (86, 112, 100)), + ])) + mist = Image.new("RGB", (width, height), (224, 234, 230)) + for index, (base, tree, colour) in enumerate([ + (height * 0.46, height * 0.09, (120, 146, 136)), + (height * 0.62, height * 0.13, (88, 114, 102)), + (height * 0.80, height * 0.19, (48, 70, 60)), + ]): + if index: + # The haze belongs *in front of* the trees drawn before it. + image = Image.blend(image, mist, 0.28) + draw = ImageDraw.Draw(image) + for _ in range(30): + x = rng.uniform(-width * 0.02, width * 1.02) + top = base - rng.uniform(tree * 0.55, tree * 1.45) + half = rng.uniform(width * 0.010, width * 0.026) + draw.polygon(((x - half, base), (x + half, base), (x, top)), fill=colour) + image = image.filter(ImageFilter.GaussianBlur(1.0)) + _fill_ridge(image, _ridge(rng, width, height * 0.94, height * 0.015, 0.9), (34, 50, 42)) + return image + + +def scene_dunes(size: tuple[int, int], rng: np.random.Generator) -> Image.Image: + width, height = size + image = _to_image(_gradient((width, height), [ + (0.0, (96, 146, 196)), (0.35, (214, 190, 158)), (0.55, (238, 206, 158)), (1.0, (198, 150, 96)), + ])) + for index, colour in enumerate([(240, 214, 170), (226, 190, 138), (204, 162, 108), (176, 132, 84)]): + _fill_ridge( + image, + _ridge(rng, width, height * (0.45 + index * 0.13), height * 0.03, 0.6 + index * 0.4), + colour, + ) + return image.filter(ImageFilter.GaussianBlur(1.0)) + + +def scene_city(size: tuple[int, int], rng: np.random.Generator) -> Image.Image: + width, height = size + horizon = int(height * 0.72) + image = _to_image(_gradient((width, horizon), [ + (0.0, (44, 44, 96)), (0.55, (150, 96, 128)), (0.85, (232, 138, 96)), (1.0, (252, 196, 132)), + ])) + image.paste( + _to_image(_gradient((width, height - horizon), [(0.0, (44, 38, 62)), (1.0, (16, 14, 28))])), + (0, horizon), + ) + + draw = ImageDraw.Draw(image, "RGBA") + draw.ellipse( + (width * 0.70, height * 0.12, width * 0.78, height * 0.12 + width * 0.08), + fill=(250, 244, 220, 255), + ) + x = 0.0 + while x < width: + building = rng.uniform(width * 0.03, width * 0.075) + top = rng.uniform(height * 0.28, height * 0.60) + draw.rectangle((x, top, x + building, horizon), fill=(34, 32, 52, 255)) + for wy in np.arange(top + 8, horizon - 6, 14): # lit windows + for wx in np.arange(x + 6, x + building - 4, 12): + if rng.random() < 0.45: + draw.rectangle((wx, wy, wx + 4, wy + 6), fill=(255, 226, 150, 255)) + x += building + rng.uniform(2, 10) + return image.filter(ImageFilter.GaussianBlur(0.9)) + + +def scene_night(size: tuple[int, int], rng: np.random.Generator) -> Image.Image: + width, height = size + image = _to_image(_gradient((width, height), [ + (0.0, (6, 10, 30)), (0.5, (14, 22, 54)), (0.78, (30, 40, 74)), (1.0, (12, 16, 34)), + ])) + # The milky way: a shallow diagonal band of blurred noise, screened over the sky. Kept dim + # and wide — a narrow bright band reads as a light leak, not a galaxy. + band = np.linspace(0.0, 1.0, width)[None, :] * height * 0.22 + height * 0.12 + distance = np.abs(np.arange(height)[:, None] - band) + cloud = np.clip(1.0 - distance / (height * 0.20), 0, 1) * rng.random((height, width)) + nebula = Image.fromarray((cloud * 120).astype(np.uint8), "L").filter(ImageFilter.GaussianBlur(11)) + image = ImageChops.screen( + image, + Image.merge("RGB", ( + nebula, + nebula.point(lambda v: int(v * 0.85)), + nebula.point(lambda v: min(255, int(v * 1.3))), + )), + ) + + draw = ImageDraw.Draw(image) + for _ in range(320): + x, y = rng.uniform(0, width), rng.uniform(0, height * 0.80) + dot = int(rng.choice([1, 1, 1, 2, 3])) + brightness = int(rng.uniform(140, 255)) + draw.rectangle((x, y, x + dot, y + dot), fill=(brightness, brightness, min(255, brightness + 20))) + image = image.filter(ImageFilter.GaussianBlur(0.6)) + _fill_ridge(image, _ridge(rng, width, height * 0.84, height * 0.05, 1.4), (6, 8, 18)) + return image + + +def scene_bokeh(size: tuple[int, int], rng: np.random.Generator) -> Image.Image: + width, height = size + palette = [(246, 176, 92), (232, 106, 132), (120, 176, 232), (238, 226, 178), (168, 128, 220)] + image = Image.new("RGB", (width, height), (26, 16, 34)) + + draw = ImageDraw.Draw(image, "RGBA") + for _ in range(90): # out-of-focus highlights + x, y = rng.uniform(0, width), rng.uniform(0, height) + radius = rng.uniform(width * 0.015, width * 0.09) + colour = palette[int(rng.integers(len(palette)))] + draw.ellipse((x - radius, y - radius, x + radius, y + radius), + fill=colour + (int(rng.uniform(40, 110)),)) + image = image.filter(ImageFilter.GaussianBlur(9)) + + draw = ImageDraw.Draw(image, "RGBA") + for _ in range(24): # and a few that landed in focus + x, y = rng.uniform(0, width), rng.uniform(0, height) + radius = rng.uniform(width * 0.006, width * 0.03) + colour = palette[int(rng.integers(len(palette)))] + draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=colour + (190,)) + return image.filter(ImageFilter.GaussianBlur(3)) + + +SCENES = { + "sunset": scene_sunset, + "mountains": scene_mountains, + "beach": scene_beach, + "forest": scene_forest, + "dunes": scene_dunes, + "city": scene_city, + "night": scene_night, + "bokeh": scene_bokeh, +} + +# Camera-roll names: what a phone writes, plus a suffix for the copies it keeps. +HEADLINE = "IMG_20240718_204512" +HEADLINE_TAKEN = "2024:07:18 20:45:12" +# Re-saves of one photo, in the order a real camera roll collects them: exports at falling +# quality, then the downscaled shares. The loop stops as soon as --target-mb is reached, so a +# bigger real library just means more copies. +HEADLINE_VARIANTS = [ + (95, 1.0), (92, 1.0), (90, 1.0), (88, 1.0), (85, 1.0), (82, 1.0), (80, 1.0), + (78, 1.0), (75, 1.0), (90, 0.80), (86, 0.66), (82, 0.50), + # ...and the exports a camera roll keeps collecting afterwards. + (94, 1.0), (89, 1.0), (84, 1.0), (81, 1.0), (79, 1.0), (77, 1.0), + (88, 0.90), (84, 0.72), (80, 0.60), (78, 0.44), (76, 0.33), +] +PAIR = ("IMG_20240315_140122", "forest", 88, 0.5, "2024:03:15 14:01:22") +SINGLES = [ + ("IMG_20240405_091243", "mountains", "2024:04:05 09:12:43"), + ("IMG_20240519_134500", "beach", "2024:05:19 13:45:00"), + ("IMG_20240628_182004", "dunes", "2024:06:28 18:20:04"), + ("IMG_20240810_213055", "city", "2024:08:10 21:30:55"), + ("IMG_20240824_231512", "night", "2024:08:24 23:15:12"), + ("IMG_20240909_193044", "bokeh", "2024:09:09 19:30:44"), +] + + +def reclaimable(sizes: list[int]) -> float: + """What the app would report: everything except the largest copy, in MB.""" + return (sum(sizes) - max(sizes)) / 1_000_000 + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--out", type=Path, default=OUT, + help=f"output directory (default: {OUT})") + parser.add_argument("--target-mb", type=float, default=45.0, + help="reclaimable size the headline group must reach (default: 45)") + args = parser.parse_args() + + rng = _rng() + args.out.mkdir(parents=True, exist_ok=True) + for stale in sorted(args.out.glob("*.jpg")): + stale.unlink() + + # 1. The headline group. Results are ranked by reclaimable bytes, so this one has to + # out-rank everything in the real library or a real photo is the first thing on screen. + headline = finish(FULL, SCENES["sunset"], rng) + sizes: list[int] = [] + for index, (quality, scale) in enumerate(HEADLINE_VARIANTS): + name = f"{HEADLINE}.jpg" if index == 0 else f"{HEADLINE}-{index}.jpg" + sizes.append(save(headline, args.out / name, quality, scale, HEADLINE_TAKEN)) + if reclaimable(sizes) >= args.target_mb: + break + print(f"headline group: {len(sizes)} copies, {reclaimable(sizes):.1f} MB reclaimable " + f"(target {args.target_mb:g} MB)") + if reclaimable(sizes) < args.target_mb: + print(f"warning: out of variants at {reclaimable(sizes):.1f} MB — the group may not " + f"out-rank the real library. Add entries to HEADLINE_VARIANTS or lower " + f"--target-mb.", file=sys.stderr) + + # 2. Two byte-identical files, for the SHA-256 path. + name, scene, quality, scale, taken = PAIR + original = args.out / f"{name}.jpg" + save(finish(FULL, SCENES[scene], rng), original, quality, scale, taken) + (args.out / f"{name}-copy.jpg").write_bytes(original.read_bytes()) + + # 3. One of each remaining scene, so the library looks lived-in. + for name, scene, taken in SINGLES: + save(finish(FULL, SCENES[scene], rng), args.out / f"{name}.jpg", 92, 1.0, taken) + + written = sorted(args.out.glob("*.jpg")) + total = sum(path.stat().st_size for path in written) + print(f"wrote {len(written)} demo photos ({total / 1e6:.1f} MB) to {args.out}") + for path in written: + print(f" {path.stat().st_size:>9,} {path.name}") + + shown = args.out.relative_to(REPO) if args.out.is_relative_to(REPO) else args.out + print("\nPush them to the device and index just that folder (API 30+):") + print(f" adb push {shown} /sdcard/DCIM/foto-dup-demo") + print(" adb shell content call --uri content://media --method scan_file \\") + print(" --arg /sdcard/DCIM/foto-dup-demo") + + +if __name__ == "__main__": + main()