From c03f17488cca7d272705303a547d7ba730c16b4c Mon Sep 17 00:00:00 2001 From: Frank Schwenk Date: Thu, 27 Aug 2026 21:38:50 +0200 Subject: [PATCH] feat: organizer Android app and token-gated upload API Replace Syncthing as the product upload path with a share-target app and POST /api/jobs. Web UI live-polls job progress. Co-authored-by: Cursor --- .env.example | 5 + .gitignore | 5 + ARCHITECTURE.md | 57 ++-- NOTES.md | 26 ++ README.md | 71 ++--- SOUL.md | 63 +++-- android/.gitignore | 9 + android/README.md | 27 ++ android/app/build.gradle.kts | 65 +++++ android/app/proguard-rules.pro | 3 + android/app/src/main/AndroidManifest.xml | 44 +++ .../main/java/rocks/f12/live/LiveF12App.kt | 11 + .../main/java/rocks/f12/live/MainActivity.kt | 79 ++++++ .../java/rocks/f12/live/data/LiveF12Api.kt | 105 +++++++ .../main/java/rocks/f12/live/data/Models.kt | 80 ++++++ .../rocks/f12/live/data/SettingsRepository.kt | 38 +++ .../java/rocks/f12/live/image/ImagePrep.kt | 95 +++++++ .../java/rocks/f12/live/media/GalleryStore.kt | 65 +++++ .../rocks/f12/live/notify/AppNotifications.kt | 77 ++++++ .../java/rocks/f12/live/ui/LiveF12Root.kt | 103 +++++++ .../rocks/f12/live/ui/screens/HomeScreen.kt | 163 +++++++++++ .../rocks/f12/live/ui/screens/JobScreen.kt | 146 ++++++++++ .../f12/live/ui/screens/SettingsScreen.kt | 96 +++++++ .../rocks/f12/live/ui/screens/UploadScreen.kt | 60 ++++ .../rocks/f12/live/ui/screens/ViewModels.kt | 256 ++++++++++++++++++ .../java/rocks/f12/live/ui/theme/Theme.kt | 38 +++ .../java/rocks/f12/live/work/JobSyncWorker.kt | 113 ++++++++ .../res/drawable/ic_launcher_foreground.xml | 16 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + android/app/src/main/res/values/colors.xml | 5 + android/app/src/main/res/values/strings.xml | 3 + android/app/src/main/res/values/themes.xml | 9 + android/build.gradle.kts | 6 + android/gradle.properties | 4 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43504 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 252 +++++++++++++++++ android/gradlew.bat | 94 +++++++ android/settings.gradle.kts | 18 ++ app/api.py | 143 ++++++++++ app/config.py | 4 + app/images.py | 4 +- app/jobs.py | 180 ++++++++++++ app/main.py | 70 ++--- app/pipeline.py | 37 ++- app/static/app.js | 160 +++++++++++ app/static/favicon.svg | 6 + app/static/style.css | 94 +++++++ app/templates/base.html | 53 +--- app/templates/index.html | 18 +- app/templates/job.html | 18 +- app/templates/remix.html | 2 +- compose.yml | 11 +- pyproject.toml | 2 +- tests/test_api.py | 135 +++++++++ tests/test_jobs.py | 54 ++++ tests/test_main.py | 5 +- 58 files changed, 3134 insertions(+), 186 deletions(-) create mode 100644 android/.gitignore create mode 100644 android/README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/rocks/f12/live/LiveF12App.kt create mode 100644 android/app/src/main/java/rocks/f12/live/MainActivity.kt create mode 100644 android/app/src/main/java/rocks/f12/live/data/LiveF12Api.kt create mode 100644 android/app/src/main/java/rocks/f12/live/data/Models.kt create mode 100644 android/app/src/main/java/rocks/f12/live/data/SettingsRepository.kt create mode 100644 android/app/src/main/java/rocks/f12/live/image/ImagePrep.kt create mode 100644 android/app/src/main/java/rocks/f12/live/media/GalleryStore.kt create mode 100644 android/app/src/main/java/rocks/f12/live/notify/AppNotifications.kt create mode 100644 android/app/src/main/java/rocks/f12/live/ui/LiveF12Root.kt create mode 100644 android/app/src/main/java/rocks/f12/live/ui/screens/HomeScreen.kt create mode 100644 android/app/src/main/java/rocks/f12/live/ui/screens/JobScreen.kt create mode 100644 android/app/src/main/java/rocks/f12/live/ui/screens/SettingsScreen.kt create mode 100644 android/app/src/main/java/rocks/f12/live/ui/screens/UploadScreen.kt create mode 100644 android/app/src/main/java/rocks/f12/live/ui/screens/ViewModels.kt create mode 100644 android/app/src/main/java/rocks/f12/live/ui/theme/Theme.kt create mode 100644 android/app/src/main/java/rocks/f12/live/work/JobSyncWorker.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 app/api.py create mode 100644 app/jobs.py create mode 100644 app/static/app.js create mode 100644 app/static/favicon.svg create mode 100644 tests/test_api.py create mode 100644 tests/test_jobs.py diff --git a/.env.example b/.env.example index 40b8d40..eac7e8c 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,8 @@ PREPROCESS_TIMEOUT=120 REMBG_MODEL=u2net REMBG_ALPHA=1 NICE_LEVEL=18 + +# --- Upload API (organizer Android app) ---------------------------------- +# Shared secret for POST /api/jobs. Leave empty to disable uploads. +# Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))" +UPLOAD_TOKEN= diff --git a/.gitignore b/.gitignore index 5e4e33b..3ea97d7 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,8 @@ htmlcov/ *~ .idea/ .vscode/ + +# Android +android/local.properties +android/.gradle/ +android/**/build/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e4b5f87..3ada715 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -3,21 +3,24 @@ ## Overview ``` -Phone/Syncthing Docker host -─────────────── ─────────── -incoming/ ──watch──► worker ──► variants/ + intermediates/ - │ │ - └── rembg + gmic pipeline - │ -meta/{stem}.json ◄──────────────────────┘ +Organizer phone Docker host +────────────── ─────────── +Share / pick + → JPEG 2000px + → POST /api/jobs ──► incoming/ ──watch──► worker + token │ + └── rembg + gmic pipeline + │ +meta/{stem}.json ◄───────────────────────────────────┘ │ - ▼ - web (FastAPI) ──► browser UI + remix POST + ├── web (HTML) ──► guest browsers + remix + └── GET /api/jobs/{id} ──► app polls, local notification, + save variants + intermediates ``` Two containers share one image (`Dockerfile`) and one bind-mounted data -directory (`DATA_HOST_DIR` → `/data`). Syncthing runs on the host, not in -compose. +directory (`DATA_HOST_DIR` → `/data`). The organizer app is in `android/`. +Syncthing is an optional fallback writer for `incoming/`, not required. ## Python modules @@ -26,24 +29,42 @@ compose. | `app/worker.py` | Poll `incoming/`, copy to `variants/`, run `process_job()` | | `app/pipeline.py` | gmic/rembg orchestration, meta/manifest I/O, variant compose | | `app/remix.py` | User-selected remix variant (one-shot compose + manifest append) | -| `app/main.py` | FastAPI routes, Jinja templates, static files | -| `app/config.py` | Environment → constants (paths, timeouts, tuning) | +| `app/main.py` | FastAPI HTML routes, Jinja templates, static files | +| `app/api.py` | JSON API: health, list, detail, authenticated upload | +| `app/jobs.py` | Shared job payloads, labels, inbox filenames | +| `app/config.py` | Environment → constants (paths, timeouts, `UPLOAD_TOKEN`) | | `app/rembg_cli.py` | Subprocess entry for rembg (avoids heavy `rembg[cli]` extra) | | `app/io_utils.py` | Atomic JSON read/write | | `app/env_utils.py` | Shared bool env parsing | | `app/logging_config.py` | Entry-point logging setup | +| `app/images.py` | On-demand WebP srcset cache | ## Data model -One **job** = one sanitized stem (from incoming filename). Flat files: +One **job** = one sanitized stem (unique upload name). Flat files: - `variants/{stem}_original.jpg` — preprocessed copy - `variants/{stem}_rembg.png` — background removal - `variants/{stem}_vN.png` — final composed images - `intermediates/{stem}_vN_*.png` — step images (kept for debugging) -- `meta/{stem}.json` — status + manifest (variants list, filter metadata) +- `meta/{stem}.json` — status + stage + progress + manifest - `processed.json` — worker bookkeeping (incoming path → handled) +Status values: `queued` → `processing` (stages `preprocess` / `rembg` / +`variant`) → `done` | `error`. + +## API + +| Method | Path | Auth | Notes | +|--------|------|------|-------| +| GET | `/api/health` | no | `{ok, upload}` | +| GET | `/api/jobs` | no | list + `active` flag | +| GET | `/api/jobs/{id}` | no | full payload + file URLs | +| POST | `/api/jobs` | Bearer / `X-Upload-Token` | multipart `file`, 202 + job summary | +| GET | `/jobs/{id}/files/{name}` | no | original file or `?w=` WebP | + +Empty `UPLOAD_TOKEN` → POST returns 503. Wrong token → 401. + ## Pipeline origin The gmic filter/blend chain is ported from an external reference script @@ -57,7 +78,8 @@ Production uses `u2net` + alpha matting via `app/rembg_cli.py` ## Known limitations (intentional) -- **No web auth** — anyone with the link sees all jobs (event tool). +- **No web login** — anyone with the link sees all jobs (event tool). +- **Upload token** — organizer app only; not an account system. - **Sequential worker** — one photo at a time; burst uploads queue. - **Meta JSON** — worker and web share `meta/*.json` via locked read-modify-write (`fcntl.flock`); variants are merged by id so @@ -73,7 +95,8 @@ ruff check app/ tests/ pytest -q ``` -Full stack (gmic/rembg): `docker compose up --build` with `DATA_HOST_DIR=./data`. +Android: see `android/README.md`. Full stack (gmic/rembg): +`docker compose up --build` with `DATA_HOST_DIR=./data`. Python **3.11** matches the Docker image; host 3.14+ cannot install pinned `onnxruntime` — use Docker or a 3.11 venv for parity. diff --git a/NOTES.md b/NOTES.md index 6a42a06..ac70d85 100644 --- a/NOTES.md +++ b/NOTES.md @@ -16,3 +16,29 @@ - Old path `/home/frank/live.f12.rocks/data` no longer used by compose (migration/cleanup manual) - Host port 12121 can be closed after cutover + +## Android upload (2026-08-27) + +- Syncthing is fallback only. Product path: organizer Android app + (`android/`) → `POST /api/jobs` with `UPLOAD_TOKEN` → worker as before. +- App polls JSON, local notification, saves to `Pictures/livef12/{job_id}/`. +- After deploy: set `UPLOAD_TOKEN` in server `.env`, recreate `web`. +- Sideload debug APK; no Play Store / ntfy. +- Leftover branch `fix/concurrency-and-cleanup` deleted (already in main). + +## Deploy 2026-07-16 + +- Live on boka: `/home/frank/live.f12.rocks` +- Web: https://live.f12.rocks +- Password / secrets in server `.env` only (`chmod 600`) +- First rembg job downloads ~176 MB model into docker volume `rembg_cache` + +## Syncthing cutover (2026-07-18) + +- SFTPGo removed; drop path is Syncthing +- Data share: `/home/frank/sync.schwenk.online/data/livef12` + (`incoming/` + `variants/` + `intermediates/` + `meta/` + `processed.json`) +- Flat phone-friendly names: `{stem}_original.jpg`, `{stem}_v1.png`, … +- Old path `/home/frank/live.f12.rocks/data` no longer used by compose + (migration/cleanup manual) +- Host port 12121 can be closed after cutover diff --git a/README.md b/README.md index cba0370..9b3d1f8 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,31 @@ # live.f12.rocks -Event photo pep pipeline: guests drop a photo into a Syncthing folder, a -worker runs it through `rembg` (background removal) + `gmic` (random -filter/blend compositing, ported from `make_random.py`), and a small web -UI shows the results with a "remix" option to try different filters on -the same photo. Finished jobs sync back via the same share. +Event photo pep pipeline: the organizer shares a photo into the Android +app, a worker runs it through `rembg` (background removal) + `gmic` +(random filter/blend compositing, ported from `make_random.py`), and a +small web UI shows the results with a "remix" option. Finished jobs +land on the phone (gallery + local notification) and on the public page. -No web auth. This is an event tool, not a DAM. +No web login. Upload uses a shared token in the organizer app. This is +an event tool, not a DAM. ## Services (`compose.yml`) | Service | What | Port | |----------|--------------------------------------------------|--------------------------| | `worker` | Watches `incoming/`, runs the compose pipeline | none published | -| `web` | FastAPI + Jinja UI, browse jobs, remix | via Traefik only | +| `web` | FastAPI HTML + JSON API, remix | via Traefik only | Both share one host directory (`DATA_HOST_DIR`) mounted at `/data`. -Syncthing runs on the host — not in this compose. +The organizer app lives in `android/`. Syncthing can still write +`incoming/` as a fallback — it is not in this compose. -## Data layout (Syncthing share) +## Data layout Prod path: `/home/frank/sync.schwenk.online/data/livef12` ``` -incoming/ # phone drop — worker only ever reads/copies from here +incoming/ # HTTP upload (and optional Syncthing drop) variants/ {stem}_original.jpg # preprocessed private copy {stem}_rembg.png @@ -32,28 +34,38 @@ intermediates/ {stem}_v1_bg_filtered.png {stem}_v1_post_0.png # … meta/ - {stem}.json # status + manifest for the web UI -processed.json # worker bookkeeping: which incoming files were handled + {stem}.json # status + stage + manifest +processed.json # worker bookkeeping ``` -Phone tip: `.stignore` can drop `meta/` and `processed.json` if you only -want images on the device. - `assets/` (filter lists + trimmed `filters.json`) lives in the repo and is bind-mounted read-only into `worker`/`web` at `/data/assets`. +## API + +| Method | Path | Auth | +|--------|------|------| +| `GET /api/health` | `{ok, upload}` | no | +| `GET /api/jobs` | job list | no | +| `GET /api/jobs/{id}` | status, variants, intermediates | no | +| `POST /api/jobs` | multipart `file` | `Authorization: Bearer ` | + +Empty `UPLOAD_TOKEN` disables uploads (fail closed). File download stays +on `GET /jobs/{id}/files/{name}`. + ## Running it ```bash cp .env.example .env +# set UPLOAD_TOKEN (python3 -c "import secrets; print(secrets.token_urlsafe(32))") # local: set DATA_HOST_DIR=./data -# prod: leave unset (defaults to the Syncthing share path) docker compose up -d --build ``` -Drop a photo into `incoming/` (via Syncthing or locally). After a few -seconds (poll interval + processing time) it shows up on the web UI; flat -files land in `variants/` and `intermediates/` for the phone. +Share a photo into the Android app, or drop one into `incoming/` locally. +After a few seconds it shows up on the web UI. + +Android build: [`android/README.md`](android/README.md). ## Development @@ -71,13 +83,14 @@ pytest -q # unit tests (no gmic/rembg) CI (`.gitea/workflows/ci.yml`): ruff + pytest on push/PR. -Architecture notes: `ARCHITECTURE.md`. Cleanup log: `CLEANUP_PLAN.md`. +Architecture notes: `ARCHITECTURE.md`. ## Configuration (`.env`, see `.env.example`) | Var | Default | Notes | |-----|---------|-------| -| `DATA_HOST_DIR` | `/home/frank/sync.schwenk.online/data/livef12` | Syncthing share root; use `./data` locally | +| `DATA_HOST_DIR` | `/home/frank/sync.schwenk.online/data/livef12` | data root; use `./data` locally | +| `UPLOAD_TOKEN` | empty | required for `POST /api/jobs`; empty disables upload | | `OUTPUT_COUNT` | `3` | Variants generated per uploaded photo | | `BLEND_OPACITY` | `30%` | Default blend opacity for auto-generated variants | | `FILTER_TIMEOUT` | `120` | Seconds before a single gmic call is killed | @@ -90,17 +103,13 @@ Architecture notes: `ARCHITECTURE.md`. Cleanup log: `CLEANUP_PLAN.md`. - **Firewall:** Traefik must already route `live.f12.rocks` — this repo only adds the router labels; it assumes the external `traefik` docker - network exists. No SFTP port needed. + network exists. - **rembg model download:** first background-removal call downloads the - `u2net` ONNX model (~176 MB) from GitHub. This needs outbound internet - on first run and can take a minute or two depending on the link; the - model is cached in the `rembg_cache` named volume afterwards, so - restarts don't re-download it. + `u2net` ONNX model (~176 MB) from GitHub. Cached in `rembg_cache`. - **Incoming is append-only:** the worker only ever copies out of - `incoming/` and never deletes or moves anything there — plan disk space - accordingly, or clean up `incoming/` manually between events. + `incoming/` and never deletes or moves anything there. - **Sequential processing:** the worker handles one photo at a time - (`cpus: "1.0"`, no concurrency) — fine for an event pace, but a burst of - uploads will just queue up and get processed in order. + (`cpus: "1.0"`, no concurrency). - Deploy itself (`docker compose up -d` on the server) is on you — this - repo doesn't run it automatically. + repo doesn't run it automatically. Remember `UPLOAD_TOKEN` in the + server `.env` or the app cannot upload. diff --git a/SOUL.md b/SOUL.md index 8addb9b..fbbde7c 100644 --- a/SOUL.md +++ b/SOUL.md @@ -6,31 +6,33 @@ ## One-Liner -Event photo pep pipeline: Syncthing drop -> gmic/rembg -> web. Upload a -photo at the event, get back weird/fun filtered variants seconds later. +Event photo pep pipeline: organizer Android app -> gmic/rembg -> web. +Upload a photo at the event, get back weird/fun filtered variants seconds +later. ## Vision -At an event (f12 meetup/party), someone drops a photo into a Syncthing -folder from a phone/camera rig. A worker rips the background off, runs it -through a random gmic filter/blend chain, and a handful of variants show -up on a shared web page almost immediately — no app install, no login, no -waiting for someone to "process the photos later". If a variant is fun, -remix it with different filters right there. Finished jobs sync back to -the phone via the same Syncthing share. +At an event (f12 meetup/party), the organizer shares a photo into the +Android app. The phone converts it to a 2000px JPEG, uploads it, a worker +rips the background off, runs it through a random gmic filter/blend chain, +and a handful of variants show up on a shared web page almost immediately — +guests need no app, no login, no "process the photos later". If a variant +is fun, remix it with different filters right there. Finished jobs land on +the organizer's phone (gallery + local notification) and stay on the web +for everyone with the link. ## Audience -Event organizers and attendees at f12 events, on their phones, often on -bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link -(and whoever's syncing photos in) is the whole audience. +Event organizers (upload via the Android app) and attendees on their +phones, often on bad venue wifi. Nobody signs up, nobody logs in. Guests +with the link see everything; only the organizer's app can upload. ## Tone & Wording - **Voice:** direct, casual, no explaining-the-obvious. It's a fun tool for an event, not enterprise software. - **Formality:** du. Short German labels in the UI (Ansehen, Remix, - Download, Varianten, Zwischenschritte). + Download, Varianten, Zwischenschritte, Warteschlange, Fertig). - **Error messages:** say what broke, don't blame the visitor — most errors here are gmic filter flakiness, not user error. - **Forbidden words/phrases:** no corporate buzzwords, no "Erlebnis", @@ -42,7 +44,7 @@ bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link |---------|------|-----| | Success | `Remix erzeugt.` | `Dein Kunstwerk ist fertig! 🎉` | | Error | `Foreground-Filter fehlgeschlagen: Timeout nach 120s` | `Etwas ist schiefgelaufen.` | -| Empty state | `Noch keine Jobs. Bild in den Sync-Ordner legen, dann kurz warten.` | `Hier ist noch nichts los... lade doch was hoch! 😊` | +| Empty state | `Noch keine Jobs. Foto in der App teilen, dann kurz warten.` | `Hier ist noch nichts los... lade doch was hoch! 😊` | ## Design @@ -54,46 +56,51 @@ bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link ## Non-Goals -- No web auth / accounts / login product — anyone with the link sees - everything, by design, for this event tool. -- No camera-to-phone client in this repo — upload path is Syncthing - (phone/folder setup is out of band). +- No web auth / accounts / login product — anyone with the link **sees** + everything, by design. Upload is a separate shared token in the + organizer app, not a user account. +- No Play Store / FCM / ntfy dependency. Notifications are local Android. - No beamer/projector product built into this repo. - Not a DAM (digital asset manager) — no albums, tagging, search, retention policies. `incoming/`, `variants/`, and `intermediates/` are the whole data model. - No auto-deletion of incoming uploads. Ever. The worker only reads/copies. -- No Syncthing container in this compose — Syncthing runs on the host. +- No Syncthing container in this compose. A host Syncthing share can + still write `incoming/` as a fallback; it is not the product path. ## Infrastructure (project-local) - Workdir on server: `/home/frank/live.f12.rocks` -- Data volume (Syncthing share): `${DATA_HOST_DIR}` — prod default +- Data volume: `${DATA_HOST_DIR}` — prod default `/home/frank/sync.schwenk.online/data/livef12`, local override `./data`. Bind-mounted into `worker` and `web` at `/data` (see `compose.yml`). -- Layout under the share: - - `incoming/` — phone drop +- Layout: + - `incoming/` — HTTP upload (and optional Syncthing drop) - `variants/` — `{stem}_original.jpg`, `{stem}_rembg.png`, `{stem}_vN.png` - `intermediates/` — `{stem}_vN_*.png` (incl. `_post_0` …) - - `meta/` — web bookkeeping JSON (optional `.stignore` on phone) + - `meta/` — status + manifest JSON - `processed.json` - Domain: `live.f12.rocks`, routed via the shared external `traefik` network, TLS via `myresolver` (see `INFRASTRUCTURE.md`). -- rembg model cache lives in the named Docker volume `rembg_cache`, not - under the Syncthing share — container state, not event data. +- rembg model cache lives in the named Docker volume `rembg_cache`. +- Organizer Android app lives in `android/` (sideload APK). +- `UPLOAD_TOKEN` in server `.env` — required for `POST /api/jobs`. ## Project-Specific Rules ```markdown -- Never delete or move files under incoming/ from worker code — Syncthing - is the writer there. (reason: uploads are the one copy of the original - that exists outside a phone's camera roll) +- Never delete or move files under incoming/ from worker code — the + upload API (and optional Syncthing) is the writer there. (reason: + uploads are the one copy of the original that exists outside a + phone's camera roll) - Keep every intermediate image under intermediates/ — don't clean them up automatically. (reason: useful for debugging bad filter picks, disk is cheap compared to re-running gmic) - gmic/rembg subprocess calls always run under `nice` (see NICE_LEVEL) — the worker box needs to stay responsive for other things during an event. (reason: explicit requirement, avoid CPU starvation) +- POST /api/jobs is fail-closed: empty UPLOAD_TOKEN disables uploads. + The public web UI stays read-only. ``` --- diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..afb9dac --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +**/build/ +local.properties +*.apk +*.aab +*.iml +.idea/ +captures/ +.cxx/ diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..766f968 --- /dev/null +++ b/android/README.md @@ -0,0 +1,27 @@ +# Android organizer app + +Share a photo into **live.f12** (or pick from the gallery). The app resizes +to JPEG, longest edge 2000px, uploads to `POST /api/jobs`, then polls until +the worker is done. Variants and intermediates land in +`Pictures/livef12/{job_id}/`. A local notification fires when the job +finishes — no ntfy, no Play Services. + +## Build + +JDK 17, Android SDK 35. On this machine: + +```bash +export JAVA_HOME=/usr/lib/jvm/java-17-openjdk +export ANDROID_HOME=$HOME/Android/Sdk +cd android +./gradlew assembleDebug +adb install -r app/build/outputs/apk/debug/app-debug.apk +``` + +First launch: Settings → paste the `UPLOAD_TOKEN` from the server `.env`. +Default server URL is `https://live.f12.rocks`. + +## Share target + +Android share sheet → live.f12. That's the replacement for dropping a +file into a Syncthing folder. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..1a72a11 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + namespace = "rocks.f12.live" + compileSdk = 35 + + defaultConfig { + applicationId = "rocks.f12.live" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + debugImplementation("androidx.compose.ui:ui-tooling") + + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.navigation:navigation-compose:2.8.5") + implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation("androidx.work:work-runtime-ktx:2.10.0") + implementation("androidx.exifinterface:exifinterface:1.3.7") + implementation("androidx.core:core-ktx:1.15.0") + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation("io.coil-kt:coil-compose:2.7.0") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..d077f12 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# Keep rules unused while minify is off; ready if we ever shrink release. +-dontwarn okhttp3.** +-dontwarn okio.** diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c7c9afc --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/rocks/f12/live/LiveF12App.kt b/android/app/src/main/java/rocks/f12/live/LiveF12App.kt new file mode 100644 index 0000000..4d1b9ef --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/LiveF12App.kt @@ -0,0 +1,11 @@ +package rocks.f12.live + +import android.app.Application +import rocks.f12.live.notify.AppNotifications + +class LiveF12App : Application() { + override fun onCreate() { + super.onCreate() + AppNotifications.ensureChannels(this) + } +} diff --git a/android/app/src/main/java/rocks/f12/live/MainActivity.kt b/android/app/src/main/java/rocks/f12/live/MainActivity.kt new file mode 100644 index 0000000..3115bff --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/MainActivity.kt @@ -0,0 +1,79 @@ +package rocks.f12.live + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat +import rocks.f12.live.ui.LiveF12Root + +class MainActivity : ComponentActivity() { + private var shareUri by mutableStateOf(null) + private var openJobId by mutableStateOf(null) + + private val pickImage = + registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + if (uri != null) shareUri = uri + } + + private val askNotifications = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + requestNotificationPermission() + consumeIntent(intent) + setContent { + LiveF12Root( + shareUri = shareUri, + openJobId = openJobId, + onPickPhoto = { pickImage.launch("image/*") }, + onShareConsumed = { shareUri = null }, + onOpenJobConsumed = { openJobId = null }, + ) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + consumeIntent(intent) + } + + private fun consumeIntent(intent: Intent?) { + if (intent == null) return + intent.getStringExtra(EXTRA_JOB_ID)?.let { openJobId = it } + if (intent.action == Intent.ACTION_SEND) { + val uri = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + if (uri != null) shareUri = uri + } + } + + private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + val granted = + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + if (!granted) askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + companion object { + const val EXTRA_JOB_ID = "job_id" + } +} diff --git a/android/app/src/main/java/rocks/f12/live/data/LiveF12Api.kt b/android/app/src/main/java/rocks/f12/live/data/LiveF12Api.kt new file mode 100644 index 0000000..e156417 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/data/LiveF12Api.kt @@ -0,0 +1,105 @@ +package rocks.f12.live.data + +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit + +class ApiException(val code: Int, override val message: String) : IOException(message) + +class LiveF12Api( + private val client: OkHttpClient = defaultClient(), +) { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun health(baseUrl: String): HealthResponse { + val request = Request.Builder().url("${baseUrl.trimEnd('/')}/api/health").get().build() + return parse(call(request), HealthResponse.serializer()) + } + + fun listJobs(baseUrl: String): JobListResponse { + val request = Request.Builder().url("${baseUrl.trimEnd('/')}/api/jobs").get().build() + return parse(call(request), JobListResponse.serializer()) + } + + fun job(baseUrl: String, jobId: String): JobDetail { + val request = + Request.Builder() + .url("${baseUrl.trimEnd('/')}/api/jobs/${encode(jobId)}") + .get() + .build() + return parse(call(request), JobDetail.serializer()) + } + + fun upload(baseUrl: String, token: String, jpeg: File, filename: String = jpeg.name): JobSummary { + val body = + MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "file", + filename, + jpeg.asRequestBody("image/jpeg".toMediaType()), + ) + .build() + val request = + Request.Builder() + .url("${baseUrl.trimEnd('/')}/api/jobs") + .header("Authorization", "Bearer $token") + .post(body) + .build() + return parse(call(request), JobSummary.serializer()) + } + + fun download(baseUrl: String, relativeOrAbsolute: String): ByteArray { + val url = resolve(baseUrl, relativeOrAbsolute) + val request = Request.Builder().url(url).get().build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw ApiException(response.code, "Download fehlgeschlagen (${response.code})") + } + return response.body?.bytes() ?: throw ApiException(response.code, "Leere Antwort") + } + } + + private fun parse(raw: String, serializer: kotlinx.serialization.DeserializationStrategy): T = + json.decodeFromString(serializer, raw) + + private fun call(request: Request): String { + client.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) { + val detail = runCatching { json.decodeFromString(body).detail }.getOrNull() + throw ApiException(response.code, detail ?: "HTTP ${response.code}") + } + return body + } + } + + companion object { + fun defaultClient(): OkHttpClient = + OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + + fun resolve(baseUrl: String, path: String): String { + if (path.startsWith("http://") || path.startsWith("https://")) return path + return baseUrl.trimEnd('/') + if (path.startsWith("/")) path else "/$path" + } + + private fun encode(jobId: String): String = java.net.URLEncoder.encode(jobId, Charsets.UTF_8.name()) + } +} + +@kotlinx.serialization.Serializable +private data class ErrorBody(val detail: String = "") diff --git a/android/app/src/main/java/rocks/f12/live/data/Models.kt b/android/app/src/main/java/rocks/f12/live/data/Models.kt new file mode 100644 index 0000000..96120ce --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/data/Models.kt @@ -0,0 +1,80 @@ +package rocks.f12.live.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class JobListResponse( + val jobs: List = emptyList(), + val active: Boolean = false, +) + +@Serializable +data class JobSummary( + @SerialName("job_id") val jobId: String, + val status: String, + @SerialName("status_label") val statusLabel: String = status, + val stage: String? = null, + @SerialName("stage_label") val stageLabel: String? = null, + val progress: JobProgress? = null, + @SerialName("created_at") val createdAt: String = "", + @SerialName("updated_at") val updatedAt: String = "", + @SerialName("variant_count") val variantCount: Int = 0, + val thumbnail: String? = null, + @SerialName("thumbnail_url") val thumbnailUrl: String? = null, + val error: String? = null, +) + +@Serializable +data class JobProgress( + val current: Int = 0, + val total: Int = 0, +) + +@Serializable +data class FileRef( + val file: String, + val url: String, +) + +@Serializable +data class JobDetail( + @SerialName("job_id") val jobId: String, + val status: String, + @SerialName("status_label") val statusLabel: String = status, + val stage: String? = null, + @SerialName("stage_label") val stageLabel: String? = null, + val progress: JobProgress? = null, + @SerialName("created_at") val createdAt: String = "", + @SerialName("updated_at") val updatedAt: String = "", + @SerialName("variant_count") val variantCount: Int = 0, + val thumbnail: String? = null, + @SerialName("thumbnail_url") val thumbnailUrl: String? = null, + val error: String? = null, + val original: FileRef? = null, + val rembg: FileRef? = null, + @SerialName("has_rembg") val hasRembg: Boolean = false, + val variants: List = emptyList(), + val intermediates: List = emptyList(), + @SerialName("variant_errors") val variantErrors: List = emptyList(), + @SerialName("source_file") val sourceFile: String? = null, +) { + val isActive: Boolean get() = status == "queued" || status == "processing" || status == "pending" + val isTerminal: Boolean get() = status == "done" || status == "error" +} + +@Serializable +data class VariantRef( + val id: String? = null, + val file: String, + val url: String? = null, + val source: String? = null, + @SerialName("background_filter") val backgroundFilter: String? = null, + @SerialName("foreground_filter") val foregroundFilter: String? = null, +) + +@Serializable +data class HealthResponse( + val ok: Boolean = false, + val upload: Boolean = false, +) diff --git a/android/app/src/main/java/rocks/f12/live/data/SettingsRepository.kt b/android/app/src/main/java/rocks/f12/live/data/SettingsRepository.kt new file mode 100644 index 0000000..c06a9ac --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/data/SettingsRepository.kt @@ -0,0 +1,38 @@ +package rocks.f12.live.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore by preferencesDataStore(name = "settings") + +data class AppSettings( + val baseUrl: String, + val token: String, +) + +class SettingsRepository(private val context: Context) { + val settings: Flow = + context.dataStore.data.map { prefs -> + AppSettings( + baseUrl = prefs[KEY_BASE_URL]?.ifBlank { DEFAULT_BASE_URL } ?: DEFAULT_BASE_URL, + token = prefs[KEY_TOKEN].orEmpty(), + ) + } + + suspend fun update(baseUrl: String, token: String) { + context.dataStore.edit { prefs -> + prefs[KEY_BASE_URL] = baseUrl.trim().trimEnd('/') + prefs[KEY_TOKEN] = token.trim() + } + } + + companion object { + const val DEFAULT_BASE_URL = "https://live.f12.rocks" + private val KEY_BASE_URL = stringPreferencesKey("base_url") + private val KEY_TOKEN = stringPreferencesKey("upload_token") + } +} diff --git a/android/app/src/main/java/rocks/f12/live/image/ImagePrep.kt b/android/app/src/main/java/rocks/f12/live/image/ImagePrep.kt new file mode 100644 index 0000000..778cd82 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/image/ImagePrep.kt @@ -0,0 +1,95 @@ +package rocks.f12.live.image + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import androidx.exifinterface.media.ExifInterface +import java.io.File +import java.io.FileOutputStream +import kotlin.math.max +import kotlin.math.roundToInt + +object ImagePrep { + const val MAX_EDGE = 2000 + const val JPEG_QUALITY = 92 + + fun prepare(context: Context, uri: Uri): File { + val resolver = context.contentResolver + val bounds = + resolver.openInputStream(uri)?.use { input -> + BitmapFactory.Options().also { + it.inJustDecodeBounds = true + BitmapFactory.decodeStream(input, null, it) + } + } ?: throw IllegalArgumentException("Bild nicht lesbar") + + val longEdge = max(bounds.outWidth, bounds.outHeight).coerceAtLeast(1) + val sample = sampleSize(longEdge, MAX_EDGE) + + val bitmap = + resolver.openInputStream(uri)?.use { input -> + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + BitmapFactory.decodeStream(input, null, opts) + } ?: throw IllegalArgumentException("Bild nicht dekodierbar") + + val oriented = applyExif(resolver.openInputStream(uri), bitmap) + val scaled = scaleToMaxEdge(oriented, MAX_EDGE) + if (scaled !== oriented && oriented !== bitmap) { + oriented.recycle() + } + if (oriented !== bitmap && scaled !== bitmap) { + bitmap.recycle() + } + + val out = File(context.cacheDir, "upload-${System.currentTimeMillis()}.jpg") + FileOutputStream(out).use { stream -> + if (!scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, stream)) { + throw IllegalStateException("JPEG-Encode fehlgeschlagen") + } + } + if (scaled !== bitmap) scaled.recycle() + return out + } + + private fun sampleSize(longEdge: Int, maxEdge: Int): Int { + var sample = 1 + var size = longEdge + while (size / 2 >= maxEdge) { + sample *= 2 + size /= 2 + } + return sample + } + + private fun scaleToMaxEdge(src: Bitmap, maxEdge: Int): Bitmap { + val longEdge = max(src.width, src.height) + if (longEdge <= maxEdge) return src + val scale = maxEdge.toFloat() / longEdge + val w = (src.width * scale).roundToInt().coerceAtLeast(1) + val h = (src.height * scale).roundToInt().coerceAtLeast(1) + return Bitmap.createScaledBitmap(src, w, h, true) + } + + private fun applyExif(stream: java.io.InputStream?, bitmap: Bitmap): Bitmap { + if (stream == null) return bitmap + val orientation = + stream.use { input -> + ExifInterface(input).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + } + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f) + else -> return bitmap + } + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } +} diff --git a/android/app/src/main/java/rocks/f12/live/media/GalleryStore.kt b/android/app/src/main/java/rocks/f12/live/media/GalleryStore.kt new file mode 100644 index 0000000..3d030b1 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/media/GalleryStore.kt @@ -0,0 +1,65 @@ +package rocks.f12.live.media + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import rocks.f12.live.data.FileRef +import rocks.f12.live.data.JobDetail +import rocks.f12.live.data.LiveF12Api +import rocks.f12.live.data.VariantRef + +object GalleryStore { + fun saveJob(context: Context, api: LiveF12Api, baseUrl: String, job: JobDetail): Int { + val refs = mutableListOf() + job.original?.let(refs::add) + job.rembg?.let(refs::add) + job.variants.forEach { variant -> + refs.add(FileRef(variant.file, variant.url ?: "/jobs/${job.jobId}/files/${variant.file}")) + } + job.intermediates.forEach(refs::add) + var saved = 0 + refs.distinctBy { it.file }.forEach { ref -> + val bytes = api.download(baseUrl, ref.url) + write(context, job.jobId, ref.file, bytes) + saved += 1 + } + return saved + } + + private fun write(context: Context, jobId: String, filename: String, bytes: ByteArray) { + val mime = + when { + filename.endsWith(".png", ignoreCase = true) -> "image/png" + filename.endsWith(".webp", ignoreCase = true) -> "image/webp" + else -> "image/jpeg" + } + val values = + ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, filename) + put(MediaStore.Images.Media.MIME_TYPE, mime) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put( + MediaStore.Images.Media.RELATIVE_PATH, + "${Environment.DIRECTORY_PICTURES}/livef12/$jobId", + ) + put(MediaStore.Images.Media.IS_PENDING, 1) + } + } + val resolver = context.contentResolver + val uri = + resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + ?: throw IllegalStateException("MediaStore insert fehlgeschlagen") + resolver.openOutputStream(uri)?.use { it.write(bytes) } + ?: throw IllegalStateException("MediaStore write fehlgeschlagen") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } + } + + fun variantUrl(jobId: String, variant: VariantRef): String = + variant.url ?: "/jobs/$jobId/files/${variant.file}" +} diff --git a/android/app/src/main/java/rocks/f12/live/notify/AppNotifications.kt b/android/app/src/main/java/rocks/f12/live/notify/AppNotifications.kt new file mode 100644 index 0000000..f14be4e --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/notify/AppNotifications.kt @@ -0,0 +1,77 @@ +package rocks.f12.live.notify + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import rocks.f12.live.MainActivity +import rocks.f12.live.R + +object AppNotifications { + const val CHANNEL_PROGRESS = "progress" + const val CHANNEL_RESULT = "result" + const val PROGRESS_ID = 42 + + fun ensureChannels(context: Context) { + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_PROGRESS, + "Jobs laufen", + NotificationManager.IMPORTANCE_LOW, + ).apply { setShowBadge(false) }, + ) + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_RESULT, + "Job fertig", + NotificationManager.IMPORTANCE_DEFAULT, + ), + ) + } + + fun progress(context: Context, jobId: String, text: String): android.app.Notification { + return NotificationCompat.Builder(context, CHANNEL_PROGRESS) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("Job läuft") + .setContentText(text.ifBlank { jobId }) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setContentIntent(openJob(context, jobId)) + .build() + } + + fun result(context: Context, jobId: String, title: String, text: String, success: Boolean) { + val notification = + NotificationCompat.Builder(context, CHANNEL_RESULT) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(title) + .setContentText(text) + .setAutoCancel(true) + .setContentIntent(openJob(context, jobId)) + .build() + try { + NotificationManagerCompat.from(context).notify(jobId.hashCode(), notification) + } catch (_: SecurityException) { + /* POST_NOTIFICATIONS denied */ + } + NotificationManagerCompat.from(context).cancel(PROGRESS_ID) + } + + private fun openJob(context: Context, jobId: String): PendingIntent { + val intent = + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(MainActivity.EXTRA_JOB_ID, jobId) + } + return PendingIntent.getActivity( + context, + jobId.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } +} diff --git a/android/app/src/main/java/rocks/f12/live/ui/LiveF12Root.kt b/android/app/src/main/java/rocks/f12/live/ui/LiveF12Root.kt new file mode 100644 index 0000000..9d631eb --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/ui/LiveF12Root.kt @@ -0,0 +1,103 @@ +package rocks.f12.live.ui + +import android.net.Uri +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import rocks.f12.live.ui.screens.HomeScreen +import rocks.f12.live.ui.screens.HomeViewModel +import rocks.f12.live.ui.screens.JobScreen +import rocks.f12.live.ui.screens.JobViewModel +import rocks.f12.live.ui.screens.SettingsScreen +import rocks.f12.live.ui.screens.SettingsViewModel +import rocks.f12.live.ui.screens.UploadScreen +import rocks.f12.live.ui.screens.UploadViewModel +import rocks.f12.live.ui.theme.Bg +import rocks.f12.live.ui.theme.LiveF12Theme +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +@Composable +fun LiveF12Root( + shareUri: Uri?, + openJobId: String?, + onPickPhoto: () -> Unit, + onShareConsumed: () -> Unit, + onOpenJobConsumed: () -> Unit, +) { + LiveF12Theme { + Surface(Modifier.fillMaxSize(), color = Bg) { + val nav = rememberNavController() + val homeVm: HomeViewModel = viewModel() + val settingsVm: SettingsViewModel = viewModel() + val jobVm: JobViewModel = viewModel() + val uploadVm: UploadViewModel = viewModel() + val homeState by homeVm.state.collectAsStateWithLifecycle() + + LaunchedEffect(shareUri) { + val uri = shareUri ?: return@LaunchedEffect + val encoded = URLEncoder.encode(uri.toString(), StandardCharsets.UTF_8.name()) + nav.navigate("upload?uri=$encoded") + onShareConsumed() + } + LaunchedEffect(openJobId) { + val id = openJobId ?: return@LaunchedEffect + nav.navigate("job/$id") + onOpenJobConsumed() + } + + NavHost(navController = nav, startDestination = "home") { + composable("home") { + HomeScreen( + viewModel = homeVm, + onOpenJob = { nav.navigate("job/$it") }, + onSettings = { nav.navigate("settings") }, + onPickPhoto = onPickPhoto, + ) + } + composable("settings") { + SettingsScreen(viewModel = settingsVm, onBack = { nav.popBackStack() }) + } + composable( + "job/{jobId}", + arguments = listOf(navArgument("jobId") { type = NavType.StringType }), + ) { entry -> + val jobId = entry.arguments?.getString("jobId").orEmpty() + JobScreen( + jobId = jobId, + viewModel = jobVm, + baseUrl = homeState.settings.baseUrl, + onBack = { nav.popBackStack() }, + ) + } + composable( + "upload?uri={uri}", + arguments = listOf(navArgument("uri") { type = NavType.StringType }), + ) { entry -> + val raw = entry.arguments?.getString("uri").orEmpty() + val uri = Uri.parse(java.net.URLDecoder.decode(raw, StandardCharsets.UTF_8.name())) + UploadScreen( + uri = uri, + viewModel = uploadVm, + onDone = { jobId -> + nav.navigate("job/$jobId") { + popUpTo("home") + } + }, + onErrorBack = { nav.popBackStack() }, + ) + } + } + } + } +} diff --git a/android/app/src/main/java/rocks/f12/live/ui/screens/HomeScreen.kt b/android/app/src/main/java/rocks/f12/live/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..bd94ef8 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/ui/screens/HomeScreen.kt @@ -0,0 +1,163 @@ +package rocks.f12.live.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddPhotoAlternate +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import rocks.f12.live.data.JobSummary +import rocks.f12.live.data.LiveF12Api +import rocks.f12.live.ui.theme.Accent +import rocks.f12.live.ui.theme.AccentOn +import rocks.f12.live.ui.theme.Bg +import rocks.f12.live.ui.theme.BgElevated +import rocks.f12.live.ui.theme.Err +import rocks.f12.live.ui.theme.Ok +import rocks.f12.live.ui.theme.Pending +import rocks.f12.live.ui.theme.Text +import rocks.f12.live.ui.theme.TextDim + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + viewModel: HomeViewModel, + onOpenJob: (String) -> Unit, + onSettings: () -> Unit, + onPickPhoto: () -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + Scaffold( + containerColor = Bg, + topBar = { + TopAppBar( + title = { Text("live.f12.rocks") }, + actions = { + IconButton(onClick = onSettings) { + Icon(Icons.Outlined.Settings, contentDescription = "Einstellungen") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = Bg, + titleContentColor = Text, + actionIconContentColor = Text, + ), + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = onPickPhoto, containerColor = Accent, contentColor = AccentOn) { + Icon(Icons.Outlined.AddPhotoAlternate, contentDescription = "Foto wählen") + } + }, + ) { padding -> + when { + state.settings.token.isBlank() -> { + Column(Modifier.padding(padding).padding(16.dp)) { + Text("Kein Token. In den Einstellungen den Upload-Token setzen.", color = Pending) + } + } + state.error != null && state.jobs.isEmpty() -> { + Column(Modifier.padding(padding).padding(16.dp)) { + Text(state.error ?: "", color = Err) + Text("Ziehen zum Neu laden passiert beim nächsten Poll.", color = TextDim, modifier = Modifier.padding(top = 8.dp)) + } + } + state.jobs.isEmpty() && !state.loading -> { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text("Noch keine Jobs. Foto teilen oder + tippen.", color = TextDim) + } + } + else -> { + LazyVerticalGrid( + columns = GridCells.Adaptive(160.dp), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(padding).fillMaxSize(), + ) { + items(state.jobs, key = { it.jobId }) { job -> + JobCard( + job = job, + baseUrl = state.settings.baseUrl, + onClick = { onOpenJob(job.jobId) }, + ) + } + } + } + } + } +} + +@Composable +private fun JobCard(job: JobSummary, baseUrl: String, onClick: () -> Unit) { + val statusColor = + when (job.status) { + "done" -> Ok + "error" -> Err + else -> Pending + } + Column( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(BgElevated) + .clickable(onClick = onClick), + ) { + val thumb = job.thumbnailUrl?.let { LiveF12Api.resolve(baseUrl, it) } + if (thumb != null) { + AsyncImage( + model = thumb, + contentDescription = job.jobId, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(androidx.compose.ui.graphics.Color.Black), + ) + } else { + Box( + Modifier.fillMaxWidth().aspectRatio(1f).background(androidx.compose.ui.graphics.Color.Black), + contentAlignment = Alignment.Center, + ) { + Text(job.statusLabel, color = statusColor) + } + } + Column(Modifier.padding(10.dp)) { + Text(job.jobId, color = Text, maxLines = 1) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(job.statusLabel, color = statusColor) + Text( + job.stageLabel?.takeIf { job.status != "done" } ?: "${job.variantCount} Varianten", + color = TextDim, + ) + } + } + } +} diff --git a/android/app/src/main/java/rocks/f12/live/ui/screens/JobScreen.kt b/android/app/src/main/java/rocks/f12/live/ui/screens/JobScreen.kt new file mode 100644 index 0000000..d744256 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/ui/screens/JobScreen.kt @@ -0,0 +1,146 @@ +package rocks.f12.live.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import rocks.f12.live.data.FileRef +import rocks.f12.live.data.LiveF12Api +import rocks.f12.live.ui.theme.Accent +import rocks.f12.live.ui.theme.Bg +import rocks.f12.live.ui.theme.BgElevated +import rocks.f12.live.ui.theme.Err +import rocks.f12.live.ui.theme.Ok +import rocks.f12.live.ui.theme.Pending +import rocks.f12.live.ui.theme.Text +import rocks.f12.live.ui.theme.TextDim + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JobScreen( + jobId: String, + viewModel: JobViewModel, + baseUrl: String, + onBack: () -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(jobId) { viewModel.start(jobId) } + val job = state.job + val statusColor = + when (job?.status) { + "done" -> Ok + "error" -> Err + else -> Pending + } + + Scaffold( + containerColor = Bg, + topBar = { + TopAppBar( + title = { Text(jobId, maxLines = 1) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Zurück") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = Bg, + titleContentColor = Text, + navigationIconContentColor = Text, + ), + ) + }, + ) { padding -> + if (job == null) { + Text( + state.error ?: "Lädt…", + color = if (state.error != null) Err else TextDim, + modifier = Modifier.padding(padding).padding(16.dp), + ) + return@Scaffold + } + + val files = buildList { + job.original?.let { add("Original" to it) } + job.rembg?.let { add("Rembg" to it) } + job.variants.forEach { v -> + add((v.id ?: "Variante") to FileRef(v.file, v.url ?: "/jobs/${job.jobId}/files/${v.file}")) + } + job.intermediates.forEach { add(it.file to it) } + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(140.dp), + modifier = Modifier.padding(padding).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("${job.statusLabel} · ${job.stageLabel ?: ""}", color = statusColor) + if (job.isActive && job.progress != null && job.progress.total > 0) { + LinearProgressIndicator( + progress = { job.progress.current.toFloat() / job.progress.total }, + modifier = Modifier.fillMaxWidth(), + color = Accent, + ) + } + job.error?.let { Text(it, color = Err) } + Button( + onClick = viewModel::saveToGallery, + enabled = !state.saving && job.variants.isNotEmpty(), + ) { + Text( + when { + state.saving -> "Speichert…" + state.savedCount != null -> "${state.savedCount} Dateien gespeichert" + else -> "Aufs Handy speichern" + }, + ) + } + } + } + items(files, key = { it.second.file }) { (label, ref) -> + Column( + Modifier.clip(RoundedCornerShape(8.dp)).background(BgElevated), + ) { + AsyncImage( + model = LiveF12Api.resolve(baseUrl, ref.url), + contentDescription = label, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().aspectRatio(1f), + ) + Text(label, color = TextDim, modifier = Modifier.padding(8.dp), maxLines = 2) + } + } + } + } +} diff --git a/android/app/src/main/java/rocks/f12/live/ui/screens/SettingsScreen.kt b/android/app/src/main/java/rocks/f12/live/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..43c7579 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/ui/screens/SettingsScreen.kt @@ -0,0 +1,96 @@ +package rocks.f12.live.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import rocks.f12.live.ui.theme.Accent +import rocks.f12.live.ui.theme.Bg +import rocks.f12.live.ui.theme.Ok +import rocks.f12.live.ui.theme.Text +import rocks.f12.live.ui.theme.TextDim + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + Scaffold( + containerColor = Bg, + topBar = { + TopAppBar( + title = { Text("Einstellungen") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Zurück") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = Bg, + titleContentColor = Text, + navigationIconContentColor = Text, + ), + ) + }, + ) { padding -> + Column( + Modifier + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text("Server und Token nur auf diesem Handy. Gäste brauchen das nicht.", color = TextDim) + OutlinedTextField( + value = state.baseUrl, + onValueChange = viewModel::setBaseUrl, + label = { Text("Server-URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + ) + OutlinedTextField( + value = state.token, + onValueChange = viewModel::setToken, + label = { Text("Upload-Token") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) + Button( + onClick = viewModel::save, + modifier = Modifier.fillMaxWidth().padding(top = 20.dp), + ) { + Text("Speichern") + } + OutlinedButton( + onClick = viewModel::probe, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { + Text("Server prüfen") + } + if (state.saved) { + Text("Gespeichert.", color = Ok, modifier = Modifier.padding(top = 12.dp)) + } + state.probe?.let { Text(it, color = Accent, modifier = Modifier.padding(top = 8.dp)) } + } + } +} diff --git a/android/app/src/main/java/rocks/f12/live/ui/screens/UploadScreen.kt b/android/app/src/main/java/rocks/f12/live/ui/screens/UploadScreen.kt new file mode 100644 index 0000000..153698d --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/ui/screens/UploadScreen.kt @@ -0,0 +1,60 @@ +package rocks.f12.live.ui.screens + +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import rocks.f12.live.ui.theme.Accent +import rocks.f12.live.ui.theme.Err +import rocks.f12.live.ui.theme.Text + +@Composable +fun UploadScreen( + uri: Uri, + viewModel: UploadViewModel, + onDone: (String) -> Unit, + onErrorBack: () -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(uri) { viewModel.start(uri) } + LaunchedEffect(state.jobId) { + state.jobId?.let(onDone) + } + + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + when (state.phase) { + "error" -> + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(24.dp)) { + Text(state.error ?: "Fehler", color = Err) + Text( + "Zurück", + color = Accent, + modifier = Modifier.padding(top = 16.dp).clickable(onClick = onErrorBack), + ) + } + else -> { + CircularProgressIndicator(color = Accent) + Text( + when (state.phase) { + "prepare" -> "JPEG, längste Seite 2000px…" + "upload" -> "Lädt hoch…" + else -> "Job angelegt…" + }, + color = Text, + modifier = Modifier.padding(top = 96.dp), + ) + } + } + } +} diff --git a/android/app/src/main/java/rocks/f12/live/ui/screens/ViewModels.kt b/android/app/src/main/java/rocks/f12/live/ui/screens/ViewModels.kt new file mode 100644 index 0000000..5110585 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/ui/screens/ViewModels.kt @@ -0,0 +1,256 @@ +package rocks.f12.live.ui.screens + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import rocks.f12.live.data.AppSettings +import rocks.f12.live.data.JobDetail +import rocks.f12.live.data.JobSummary +import rocks.f12.live.data.LiveF12Api +import rocks.f12.live.data.SettingsRepository +import rocks.f12.live.image.ImagePrep +import rocks.f12.live.media.GalleryStore +import rocks.f12.live.work.JobSyncWorker + +data class HomeState( + val settings: AppSettings = AppSettings(SettingsRepository.DEFAULT_BASE_URL, ""), + val jobs: List = emptyList(), + val loading: Boolean = true, + val error: String? = null, +) + +class HomeViewModel(application: Application) : AndroidViewModel(application) { + private val settingsRepo = SettingsRepository(application) + private val api = LiveF12Api() + private val _state = MutableStateFlow(HomeState()) + val state: StateFlow = _state.asStateFlow() + private var poll: Job? = null + + init { + viewModelScope.launch { + settingsRepo.settings.collect { settings -> + _state.value = _state.value.copy(settings = settings) + } + } + refresh() + startPoll() + } + + fun refresh() { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, error = null) + runCatching { + withContext(Dispatchers.IO) { api.listJobs(_state.value.settings.baseUrl) } + }.onSuccess { response -> + _state.value = _state.value.copy(jobs = response.jobs, loading = false, error = null) + }.onFailure { err -> + _state.value = + _state.value.copy( + loading = false, + error = err.message ?: "Jobs konnten nicht geladen werden", + ) + } + } + } + + private fun startPoll() { + poll?.cancel() + poll = + viewModelScope.launch { + while (isActive) { + delay(4000) + val base = _state.value.settings.baseUrl + runCatching { withContext(Dispatchers.IO) { api.listJobs(base) } } + .onSuccess { response -> + _state.value = _state.value.copy(jobs = response.jobs, error = null) + } + } + } + } +} + +data class SettingsState( + val baseUrl: String = SettingsRepository.DEFAULT_BASE_URL, + val token: String = "", + val saved: Boolean = false, + val probe: String? = null, +) + +class SettingsViewModel(application: Application) : AndroidViewModel(application) { + private val repo = SettingsRepository(application) + private val api = LiveF12Api() + private val _state = MutableStateFlow(SettingsState()) + val state: StateFlow = _state.asStateFlow() + + init { + viewModelScope.launch { + repo.settings.collect { settings -> + _state.value = + _state.value.copy( + baseUrl = settings.baseUrl, + token = settings.token, + ) + } + } + } + + fun setBaseUrl(value: String) { + _state.value = _state.value.copy(baseUrl = value, saved = false) + } + + fun setToken(value: String) { + _state.value = _state.value.copy(token = value, saved = false) + } + + fun save() { + viewModelScope.launch { + repo.update(_state.value.baseUrl, _state.value.token) + _state.value = _state.value.copy(saved = true, probe = null) + } + } + + fun probe() { + viewModelScope.launch { + val url = _state.value.baseUrl.trim().trimEnd('/') + val result = + runCatching { withContext(Dispatchers.IO) { api.health(url) } } + .fold( + onSuccess = { health -> + if (health.upload) "Server ok, Upload aktiv." else "Server erreichbar, Upload nicht konfiguriert." + }, + onFailure = { err -> "Nicht erreichbar: ${err.message}" }, + ) + _state.value = _state.value.copy(probe = result) + } + } +} + +data class JobUiState( + val job: JobDetail? = null, + val loading: Boolean = true, + val error: String? = null, + val saving: Boolean = false, + val savedCount: Int? = null, +) + +class JobViewModel(application: Application) : AndroidViewModel(application) { + private val settingsRepo = SettingsRepository(application) + private val api = LiveF12Api() + private val _state = MutableStateFlow(JobUiState()) + val state: StateFlow = _state.asStateFlow() + private var poll: Job? = null + private var jobId: String? = null + + fun start(id: String) { + if (jobId == id && poll?.isActive == true) return + jobId = id + poll?.cancel() + _state.value = JobUiState(loading = true) + loadOnce(id) + poll = + viewModelScope.launch { + while (isActive) { + delay(4000) + val current = _state.value.job + if (current?.isTerminal == true) break + loadOnce(id) + } + } + } + + private fun loadOnce(id: String) { + viewModelScope.launch { + val base = settingsRepo.settings.first().baseUrl + runCatching { withContext(Dispatchers.IO) { api.job(base, id) } } + .onSuccess { job -> + _state.value = _state.value.copy(job = job, loading = false, error = null) + } + .onFailure { err -> + _state.value = + _state.value.copy( + loading = false, + error = err.message ?: "Job nicht geladen", + ) + } + } + } + + fun saveToGallery() { + val job = _state.value.job ?: return + viewModelScope.launch { + _state.value = _state.value.copy(saving = true) + val base = settingsRepo.settings.first().baseUrl + runCatching { + withContext(Dispatchers.IO) { GalleryStore.saveJob(getApplication(), api, base, job) } + }.onSuccess { count -> + _state.value = _state.value.copy(saving = false, savedCount = count, error = null) + }.onFailure { err -> + _state.value = + _state.value.copy( + saving = false, + error = err.message ?: "Speichern fehlgeschlagen", + ) + } + } + } +} + +data class UploadUiState( + val phase: String = "prepare", + val error: String? = null, + val jobId: String? = null, +) + +class UploadViewModel(application: Application) : AndroidViewModel(application) { + private val settingsRepo = SettingsRepository(application) + private val api = LiveF12Api() + private val _state = MutableStateFlow(UploadUiState()) + val state: StateFlow = _state.asStateFlow() + + private var startedFor: Uri? = null + + fun start(uri: Uri) { + if (startedFor == uri && _state.value.phase != "error") return + startedFor = uri + viewModelScope.launch { + _state.value = UploadUiState(phase = "prepare") + val settings = settingsRepo.settings.first() + if (settings.token.isBlank()) { + _state.value = UploadUiState(phase = "error", error = "Kein Upload-Token. Erst Einstellungen.") + return@launch + } + val file = + runCatching { + withContext(Dispatchers.IO) { ImagePrep.prepare(getApplication(), uri) } + }.onFailure { err -> + _state.value = UploadUiState(phase = "error", error = err.message ?: "Konvertierung fehlgeschlagen") + }.getOrNull() ?: return@launch + + _state.value = UploadUiState(phase = "upload") + val uploaded = + runCatching { + withContext(Dispatchers.IO) { + api.upload(settings.baseUrl, settings.token, file, file.name) + } + }.onFailure { err -> + _state.value = UploadUiState(phase = "error", error = err.message ?: "Upload fehlgeschlagen") + }.getOrNull() + file.delete() + if (uploaded == null) return@launch + + JobSyncWorker.enqueue(getApplication(), uploaded.jobId, settings.baseUrl) + _state.value = UploadUiState(phase = "done", jobId = uploaded.jobId) + } + } +} diff --git a/android/app/src/main/java/rocks/f12/live/ui/theme/Theme.kt b/android/app/src/main/java/rocks/f12/live/ui/theme/Theme.kt new file mode 100644 index 0000000..5f0e6ef --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/ui/theme/Theme.kt @@ -0,0 +1,38 @@ +package rocks.f12.live.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +val Bg = Color(0xFF14161A) +val BgElevated = Color(0xFF1D2025) +val Border = Color(0xFF2C3038) +val Text = Color(0xFFE7E9EC) +val TextDim = Color(0xFF9AA0AA) +val Accent = Color(0xFFFF5A1F) +val AccentOn = Color(0xFF14161A) +val Ok = Color(0xFF4CAF6A) +val Err = Color(0xFFE05A4A) +val Pending = Color(0xFFD9A441) + +private val Colors: ColorScheme = + darkColorScheme( + primary = Accent, + onPrimary = AccentOn, + background = Bg, + onBackground = Text, + surface = BgElevated, + onSurface = Text, + surfaceVariant = Border, + onSurfaceVariant = TextDim, + error = Err, + onError = Text, + outline = Border, + ) + +@Composable +fun LiveF12Theme(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = Colors, content = content) +} diff --git a/android/app/src/main/java/rocks/f12/live/work/JobSyncWorker.kt b/android/app/src/main/java/rocks/f12/live/work/JobSyncWorker.kt new file mode 100644 index 0000000..a36f858 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/work/JobSyncWorker.kt @@ -0,0 +1,113 @@ +package rocks.f12.live.work + +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import kotlinx.coroutines.delay +import rocks.f12.live.data.LiveF12Api +import rocks.f12.live.media.GalleryStore +import rocks.f12.live.notify.AppNotifications + +class JobSyncWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + private val api = LiveF12Api() + + override suspend fun doWork(): Result { + val jobId = inputData.getString(KEY_JOB_ID) ?: return Result.failure() + val baseUrl = inputData.getString(KEY_BASE_URL) ?: return Result.failure() + + setForeground(foreground("Wartet auf Worker…", jobId)) + + var lastLabel = "" + repeat(MAX_POLLS) { _ -> + val job = + try { + api.job(baseUrl, jobId) + } catch (_: Exception) { + delay(POLL_MS) + return@repeat + } + val label = job.stageLabel ?: job.statusLabel + if (label != lastLabel) { + setForeground(foreground(label, jobId)) + lastLabel = label + } + when { + job.status == "done" -> { + val count = GalleryStore.saveJob(applicationContext, api, baseUrl, job) + AppNotifications.result( + applicationContext, + jobId, + "Job fertig", + "$count Dateien in Pictures/livef12", + success = true, + ) + return Result.success() + } + job.status == "error" -> { + AppNotifications.result( + applicationContext, + jobId, + "Job fehlgeschlagen", + job.error ?: "Pipeline-Fehler", + success = false, + ) + return Result.failure() + } + else -> delay(POLL_MS) + } + } + AppNotifications.result( + applicationContext, + jobId, + "Job hängt", + "Timeout nach Poll. In der App nachschauen.", + success = false, + ) + return Result.retry() + } + + private fun foreground(text: String, jobId: String): ForegroundInfo { + val notification = AppNotifications.progress(applicationContext, jobId, text) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + AppNotifications.PROGRESS_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } else { + ForegroundInfo(AppNotifications.PROGRESS_ID, notification) + } + } + + companion object { + private const val KEY_JOB_ID = "job_id" + private const val KEY_BASE_URL = "base_url" + private const val POLL_MS = 4000L + private const val MAX_POLLS = 450 // ~30 min + + fun enqueue(context: Context, jobId: String, baseUrl: String) { + val request = + OneTimeWorkRequestBuilder() + .setInputData(workDataOf(KEY_JOB_ID to jobId, KEY_BASE_URL to baseUrl)) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .addTag("job-$jobId") + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + "job-$jobId", + ExistingWorkPolicy.KEEP, + request, + ) + } + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..85c7686 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5bd3d56 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..5bd3d56 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..901c5b7 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #14161A + #FF5A1F + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3ca26d3 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + live.f12 + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..fbc1b95 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..70dfb63 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..2318707 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..2c3521197d7c4586c843d1d3e9090525f1898cde GIT binary patch literal 43504 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 TZiJi*F`$tkWLn) literal 0 HcmV?d00001 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d22acce --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=60000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..d95bf61 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..640d686 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..3f62c68 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "livef12" +include(":app") diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..37335a9 --- /dev/null +++ b/app/api.py @@ -0,0 +1,143 @@ +"""JSON API for the organizer Android app (upload + job status).""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import uuid +from pathlib import Path + +from fastapi import APIRouter, File, Header, HTTPException, Request, UploadFile +from fastapi.responses import JSONResponse + +from . import config, jobs, pipeline + +logger = logging.getLogger("livef12.api") + +router = APIRouter(prefix="/api") + +_CHUNK = 1024 * 1024 +_JSON_HEADERS = {"Cache-Control": "no-store"} + + +def _provided_token( + authorization: str | None, + x_upload_token: str | None, +) -> str: + if x_upload_token and x_upload_token.strip(): + return x_upload_token.strip() + if authorization: + scheme, _, rest = authorization.partition(" ") + if scheme.lower() == "bearer" and rest.strip(): + return rest.strip() + return "" + + +def _token_ok(provided: str) -> bool: + expected = config.UPLOAD_TOKEN + if not expected or not provided: + return False + # Hash first so compare_digest never sees unequal lengths. + left = hashlib.sha256(provided.encode("utf-8")).digest() + right = hashlib.sha256(expected.encode("utf-8")).digest() + return hmac.compare_digest(left, right) + + +def require_upload_token( + authorization: str | None, + x_upload_token: str | None, +) -> None: + if not config.UPLOAD_TOKEN: + raise HTTPException(status_code=503, detail="Upload ist nicht konfiguriert") + if not _token_ok(_provided_token(authorization, x_upload_token)): + raise HTTPException(status_code=401, detail="Ungültiger Upload-Token") + + +@router.get("/health") +def health() -> JSONResponse: + return JSONResponse( + {"ok": True, "upload": bool(config.UPLOAD_TOKEN)}, + headers=_JSON_HEADERS, + ) + + +@router.get("/jobs") +def api_list_jobs() -> JSONResponse: + items = jobs.list_jobs() + return JSONResponse( + { + "jobs": items, + "active": any(j["status"] in jobs.ACTIVE_STATUSES for j in items), + }, + headers=_JSON_HEADERS, + ) + + +@router.get("/jobs/{job_id}") +def api_job_detail(job_id: str) -> JSONResponse: + return JSONResponse(jobs.job_detail_payload(job_id), headers=_JSON_HEADERS) + + +@router.post("/jobs", status_code=202) +async def api_upload_job( + request: Request, + file: UploadFile = File(...), + authorization: str | None = Header(default=None), + x_upload_token: str | None = Header(default=None), +) -> JSONResponse: + require_upload_token(authorization, x_upload_token) + + original_name = file.filename or "photo.jpg" + dest_name = jobs.make_inbox_filename(original_name) + suffix = Path(dest_name).suffix.lower() + if suffix not in config.SUPPORTED_EXTENSIONS: + raise HTTPException(status_code=400, detail="Dateityp nicht unterstützt") + + config.INBOX_DIR.mkdir(parents=True, exist_ok=True) + config.META_DIR.mkdir(parents=True, exist_ok=True) + tmp = config.INBOX_DIR / f".upload_{uuid.uuid4().hex}" + dest = config.INBOX_DIR / dest_name + written = 0 + try: + with tmp.open("wb") as out: + while True: + chunk = await file.read(_CHUNK) + if not chunk: + break + written += len(chunk) + if written > config.MAX_UPLOAD_BYTES: + raise HTTPException( + status_code=413, + detail=f"Datei zu groß (max {config.MAX_UPLOAD_BYTES // (1024 * 1024)} MB)", + ) + out.write(chunk) + if written == 0: + raise HTTPException(status_code=400, detail="Leere Datei") + tmp.replace(dest) + except HTTPException: + tmp.unlink(missing_ok=True) + raise + except OSError as exc: + tmp.unlink(missing_ok=True) + logger.exception("upload write failed") + raise HTTPException(status_code=500, detail="Upload konnte nicht gespeichert werden") from exc + finally: + await file.close() + + stem = pipeline.sanitize_stem(dest.stem) + paths = pipeline.job_paths(stem, suffix) + pipeline.write_status( + paths, + "queued", + source_file=dest.name, + source_stem=stem, + stage="queued", + progress={"current": 0, "total": config.OUTPUT_COUNT}, + error=None, + uploaded_from=request.client.host if request.client else None, + ) + logger.info("queued upload %s -> job %s (%d bytes)", dest.name, stem, written) + payload = jobs.summarize_job(stem) + payload["status"] = "queued" + return JSONResponse(payload, status_code=202, headers=_JSON_HEADERS) diff --git a/app/config.py b/app/config.py index c816677..4c60a0c 100644 --- a/app/config.py +++ b/app/config.py @@ -107,3 +107,7 @@ STABLE_WAIT_SECONDS = _float_env("STABLE_WAIT_SECONDS", 2) # --- Web --------------------------------------------------------------- SITE_TITLE = os.environ.get("SITE_TITLE", "live.f12.rocks") +# Shared secret for POST /api/jobs. Empty = upload API disabled (fail closed). +UPLOAD_TOKEN = os.environ.get("UPLOAD_TOKEN", "") +# Hard cap after client-side 2000px JPEG; 25 MB is generous for a bad encode. +MAX_UPLOAD_BYTES = _int_env("MAX_UPLOAD_BYTES", 25 * 1024 * 1024) diff --git a/app/images.py b/app/images.py index ca9ba4f..022046b 100644 --- a/app/images.py +++ b/app/images.py @@ -76,9 +76,7 @@ def _write_resized(source: Path, dest: Path, width: int) -> None: img = img.resize((width, new_h), Image.Resampling.LANCZOS) dest.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile( - dir=dest.parent, suffix=".webp", delete=False - ) as tmp: + with tempfile.NamedTemporaryFile(dir=dest.parent, suffix=".webp", delete=False) as tmp: tmp_path = Path(tmp.name) try: img.save(tmp_path, format="WEBP", quality=78, method=4) diff --git a/app/jobs.py b/app/jobs.py new file mode 100644 index 0000000..71e27e8 --- /dev/null +++ b/app/jobs.py @@ -0,0 +1,180 @@ +"""Job listing, labels, and JSON payloads shared by the HTML UI and /api.""" + +from __future__ import annotations + +import re +import uuid +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from fastapi import HTTPException + +from . import config, images, pipeline + +_JOB_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$") + +STATUS_LABELS: dict[str, str] = { + "queued": "Warteschlange", + "processing": "Läuft", + "done": "Fertig", + "error": "Fehler", + "unknown": "unbekannt", +} + +ACTIVE_STATUSES = frozenset({"queued", "processing", "pending"}) + + +class JobIdError(ValueError): + """Invalid or missing job id.""" + + +def validate_job_id(job_id: str) -> str: + raw = job_id or "" + if not raw.strip() or "/" in raw or "\\" in raw or ".." in raw: + raise JobIdError("Ungültige Job-ID") + stem = pipeline.sanitize_stem(raw) + if not _JOB_ID_RE.match(stem): + raise JobIdError("Ungültige Job-ID") + return stem + + +def require_job_id(job_id: str) -> str: + try: + stem = validate_job_id(job_id) + except JobIdError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if pipeline.read_status(stem) is None and not pipeline.job_paths(stem).original.exists(): + raise HTTPException(status_code=404, detail="Job nicht gefunden") + return stem + + +def status_label(status: str | None) -> str: + key = (status or "unknown").strip() or "unknown" + return STATUS_LABELS.get(key, key) + + +def stage_label(status: str | None, stage: str | None, progress: dict[str, Any] | None) -> str: + if status == "queued": + return "Wartet auf Worker" + if status == "done": + return "Fertig" + if status == "error": + return "Fehler" + if stage == "preprocess": + return "Vorverarbeitung" + if stage == "rembg": + return "Freistellen" + if stage == "variant": + current = (progress or {}).get("current") + total = (progress or {}).get("total") + if current and total: + return f"Variante {current}/{total}" + return "Varianten" + if status == "processing": + return "Läuft" + return status_label(status) + + +def file_url(job_id: str, filename: str | None) -> str | None: + if not filename: + return None + return images.file_url(job_id, filename) + + +def list_intermediates(stem: str) -> list[str]: + paths = pipeline.job_paths(stem) + if not paths.intermediates.exists(): + return [] + return sorted(p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file()) + + +def _progress(data: dict[str, Any]) -> dict[str, int] | None: + raw = data.get("progress") + if not isinstance(raw, dict): + return None + try: + current = int(raw.get("current", 0)) + total = int(raw.get("total", 0)) + except (TypeError, ValueError): + return None + if total <= 0: + return None + return {"current": current, "total": total} + + +def _file_entry(job_id: str, filename: str | None) -> dict[str, str] | None: + if not filename: + return None + return {"file": filename, "url": images.file_url(job_id, filename)} + + +def summarize_job(job_id: str, data: dict[str, Any] | None = None) -> dict[str, Any]: + meta = data if data is not None else (pipeline.read_meta(job_id) or {}) + variants = meta.get("variants") or [] + status = meta.get("status", "unknown") + progress = _progress(meta) + stage = meta.get("stage") + thumb = variants[-1].get("file") if variants else None + return { + "job_id": job_id, + "status": status, + "status_label": status_label(status), + "stage": stage, + "stage_label": stage_label(status, stage if isinstance(stage, str) else None, progress), + "progress": progress, + "created_at": meta.get("created_at") or "", + "updated_at": meta.get("updated_at") or "", + "variant_count": len(variants), + "thumbnail": thumb, + "thumbnail_url": file_url(job_id, thumb), + "error": meta.get("error"), + } + + +def list_jobs() -> list[dict[str, Any]]: + jobs = [summarize_job(job_id) for job_id in pipeline.list_job_ids()] + jobs.sort(key=lambda j: j["created_at"] or j["job_id"], reverse=True) + return jobs + + +def job_detail_payload(job_id: str) -> dict[str, Any]: + stem = require_job_id(job_id) + meta = pipeline.read_meta(stem) or {} + paths = pipeline.job_paths(stem) + summary = summarize_job(stem, meta) + variants = [] + for item in meta.get("variants") or []: + if not isinstance(item, dict) or not item.get("file"): + continue + entry = dict(item) + entry["url"] = images.file_url(stem, item["file"]) + variants.append(entry) + intermediates = [_file_entry(stem, name) for name in list_intermediates(stem)] + original_name = paths.original.name if paths.original.exists() else meta.get("original_file") + rembg_name = paths.rembg.name if paths.rembg.exists() else meta.get("rembg_file") + summary.update( + { + "original": _file_entry(stem, original_name if original_name else None), + "rembg": _file_entry(stem, rembg_name if rembg_name else None), + "has_rembg": paths.rembg.exists(), + "variants": variants, + "intermediates": [item for item in intermediates if item], + "variant_errors": meta.get("variant_errors") or [], + "source_file": meta.get("source_file"), + } + ) + return summary + + +def make_inbox_filename(original_name: str) -> str: + """Unique inbox name so two party.jpg uploads don't clobber each other.""" + stem = pipeline.sanitize_stem(original_name) + suffix = Path(original_name).suffix.lower() + if suffix not in config.SUPPORTED_EXTENSIONS: + suffix = ".jpg" + if suffix == ".jpeg": + suffix = ".jpg" + stamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + short = uuid.uuid4().hex[:6] + return f"{stamp}_{short}_{stem}{suffix}" diff --git a/app/main.py b/app/main.py index f35e356..ad094d4 100644 --- a/app/main.py +++ b/app/main.py @@ -3,9 +3,7 @@ from __future__ import annotations import logging -import re from pathlib import Path -from typing import Any from urllib.parse import quote from fastapi import FastAPI, Form, HTTPException, Query, Request @@ -13,40 +11,35 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from . import config, images, pipeline, remix +from . import api, config, images, jobs, pipeline, remix from .logging_config import configure_logging configure_logging() logger = logging.getLogger("livef12.web") app = FastAPI(title=config.SITE_TITLE) +app.include_router(api.router) BASE_DIR = Path(__file__).resolve().parent templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates.env.globals["img_attrs"] = images.img_attrs templates.env.globals["PAIR_SIZES"] = images.PAIR_SIZES templates.env.globals["GRID_SIZES"] = images.GRID_SIZES +templates.env.globals["status_label"] = jobs.status_label +templates.env.globals["stage_label"] = jobs.stage_label +templates.env.globals["ACTIVE_STATUSES"] = jobs.ACTIVE_STATUSES app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") -# Job id = sanitized source stem (may include dots). -_JOB_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$") - def _validate_job_id(job_id: str) -> str: - raw = job_id or "" - if not raw.strip() or "/" in raw or "\\" in raw or ".." in raw: - raise HTTPException(status_code=400, detail="Ungueltige Job-ID") - stem = pipeline.sanitize_stem(raw) - if not _JOB_ID_RE.match(stem): - raise HTTPException(status_code=400, detail="Ungueltige Job-ID") - return stem + try: + return jobs.validate_job_id(job_id) + except jobs.JobIdError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc def _job_exists(job_id: str) -> str: - stem = _validate_job_id(job_id) - if pipeline.read_status(stem) is None and not pipeline.job_paths(stem).original.exists(): - raise HTTPException(status_code=404, detail="Job nicht gefunden") - return stem + return jobs.require_job_id(job_id) def _safe_job_file(job_id: str, filename: str) -> Path: @@ -54,9 +47,9 @@ def _safe_job_file(job_id: str, filename: str) -> Path: stem = _validate_job_id(job_id) name = Path(filename).name if name != filename or "/" in filename or "\\" in filename or name.startswith("."): - raise HTTPException(status_code=400, detail="Ungueltiger Pfad") + raise HTTPException(status_code=400, detail="Ungültiger Pfad") if not name.startswith(f"{stem}_"): - raise HTTPException(status_code=400, detail="Ungueltiger Pfad") + raise HTTPException(status_code=400, detail="Ungültiger Pfad") for directory in (config.VARIANTS_DIR, config.INTERMEDIATES_DIR): candidate = (directory / name).resolve() @@ -67,30 +60,17 @@ def _safe_job_file(job_id: str, filename: str) -> Path: raise HTTPException(status_code=404, detail="Datei nicht gefunden") -def list_jobs() -> list[dict[str, Any]]: - jobs = [] - for job_id in pipeline.list_job_ids(): - data = pipeline.read_meta(job_id) or {} - variants = data.get("variants") or [] - jobs.append( - { - "job_id": job_id, - "status": data.get("status", "unknown"), - "created_at": data.get("created_at") or "", - "variant_count": len(variants), - "thumbnail": variants[-1].get("file") if variants else None, - } - ) - jobs.sort(key=lambda j: j["created_at"] or j["job_id"], reverse=True) - return jobs - - @app.get("/", response_class=HTMLResponse) def index(request: Request) -> HTMLResponse: + job_list = jobs.list_jobs() return templates.TemplateResponse( request, "index.html", - {"jobs": list_jobs(), "site_title": config.SITE_TITLE}, + { + "jobs": job_list, + "site_title": config.SITE_TITLE, + "has_active": any(j["status"] in jobs.ACTIVE_STATUSES for j in job_list), + }, ) @@ -100,11 +80,7 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse: paths = pipeline.job_paths(stem) status = pipeline.read_status(stem) or {} manifest = pipeline.read_manifest(stem) or {"variants": []} - intermediates = ( - sorted(p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file()) - if paths.intermediates.exists() - else [] - ) + intermediates = jobs.list_intermediates(stem) return templates.TemplateResponse( request, @@ -118,6 +94,7 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse: "rembg_name": paths.rembg.name if paths.rembg.exists() else None, "has_rembg": paths.rembg.exists(), "intermediates": intermediates, + "live": (status.get("status") or "") in jobs.ACTIVE_STATUSES, }, ) @@ -134,7 +111,7 @@ def job_file( if w not in images.SRCSET_WIDTHS: raise HTTPException( status_code=400, - detail=f"Ungueltige Breite (erlaubt: {', '.join(map(str, images.SRCSET_WIDTHS))})", + detail=f"Ungültige Breite (erlaubt: {', '.join(map(str, images.SRCSET_WIDTHS))})", ) try: cached = images.get_or_create_resized(path, w) @@ -154,7 +131,10 @@ def remix_form( stem = _job_exists(job_id) paths = pipeline.job_paths(stem) if not paths.rembg.exists(): - raise HTTPException(status_code=409, detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht moeglich.") + raise HTTPException( + status_code=409, + detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht möglich.", + ) assets = pipeline.load_assets() options = remix.build_remix_options(assets) diff --git a/app/pipeline.py b/app/pipeline.py index 576706a..fb1d6a1 100644 --- a/app/pipeline.py +++ b/app/pipeline.py @@ -396,6 +396,8 @@ def read_status(job_id: str) -> dict[str, Any] | None: return None return { "status": data.get("status", "unknown"), + "stage": data.get("stage"), + "progress": data.get("progress"), "created_at": data.get("created_at"), "updated_at": data.get("updated_at"), "error": data.get("error"), @@ -628,7 +630,15 @@ def process_job( paths.intermediates.mkdir(parents=True, exist_ok=True) paths.meta.parent.mkdir(parents=True, exist_ok=True) - write_status(paths, "processing", source_file=str(source_path.name), source_stem=stem) + write_status( + paths, + "processing", + source_file=str(source_path.name), + source_stem=stem, + stage="preprocess", + progress={"current": 0, "total": config.OUTPUT_COUNT}, + error=None, + ) try: paths = preprocess_original(paths) @@ -643,6 +653,12 @@ def process_job( } write_manifest(paths, manifest) + write_status( + paths, + "processing", + stage="rembg", + progress={"current": 0, "total": config.OUTPUT_COUNT}, + ) ok, err = run_rembg(paths.original, paths.rembg) if not ok: raise PipelineError(f"rembg fehlgeschlagen: {err}") @@ -654,6 +670,12 @@ def process_job( for i in range(1, config.OUTPUT_COUNT + 1): variant_id = f"v{i}" + write_status( + paths, + "processing", + stage="variant", + progress={"current": i, "total": config.OUTPUT_COUNT}, + ) try: entry = compose_variant( paths, @@ -700,10 +722,17 @@ def process_job( if not manifest["variants"]: raise PipelineError("Keine Variante erfolgreich erzeugt: " + "; ".join(variant_errors)) - write_status(paths, "done", variant_errors=variant_errors) + done_count = len(manifest["variants"]) + write_status( + paths, + "done", + stage="done", + progress={"current": done_count, "total": config.OUTPUT_COUNT}, + variant_errors=variant_errors, + ) except PipelineError as exc: logger.error("[%s] job failed: %s", stem, exc) - write_status(paths, "error", error=str(exc)) + write_status(paths, "error", stage="error", error=str(exc)) except Exception as exc: # noqa: BLE001 - keep the worker loop alive logger.exception("[%s] unexpected error", stem) - write_status(paths, "error", error=f"Unerwarteter Fehler: {exc}") + write_status(paths, "error", stage="error", error=f"Unerwarteter Fehler: {exc}") diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..0c43978 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,160 @@ +/* live.f12.rocks — live poll + lightbox gallery. No framework. */ +(function () { + "use strict"; + + var POLL_MS = 4000; + + function $(id) { + return document.getElementById(id); + } + + /* --- live reload while jobs run --- */ + + function fingerprint(payload) { + if (!payload) return ""; + if (payload.job_id) { + return [ + payload.status, + payload.stage, + payload.updated_at, + payload.variant_count, + (payload.intermediates || []).length, + payload.error || "", + ].join("|"); + } + var jobs = payload.jobs || []; + return jobs + .map(function (j) { + return [j.job_id, j.status, j.updated_at, j.variant_count, j.stage].join(":"); + }) + .join(","); + } + + function startLivePoll() { + if (!document.body.hasAttribute("data-live")) return; + var jobId = document.body.getAttribute("data-job-id"); + var url = jobId ? "/api/jobs/" + encodeURIComponent(jobId) : "/api/jobs"; + var last = ""; + var first = true; + + function tick() { + fetch(url, { headers: { Accept: "application/json" } }) + .then(function (r) { + if (!r.ok) throw new Error("poll " + r.status); + return r.json(); + }) + .then(function (data) { + var next = fingerprint(data); + if (first) { + last = next; + first = false; + return; + } + if (next !== last) { + window.location.reload(); + } + }) + .catch(function () { + /* venue wifi: try again next tick */ + }); + } + + tick(); + setInterval(tick, POLL_MS); + } + + /* --- lightbox with prev/next --- */ + + function startLightbox() { + var box = $("lightbox"); + var img = $("lightbox-img"); + var caption = $("lightbox-caption"); + if (!box || !img) return; + + var items = []; + var index = 0; + + function collect() { + items = Array.prototype.slice + .call(document.querySelectorAll("img.lightboxable")) + .map(function (el) { + return { + src: el.getAttribute("data-full-src") || el.currentSrc || el.src, + alt: el.alt || "", + }; + }); + } + + function show(i) { + if (!items.length) return; + index = (i + items.length) % items.length; + img.src = items[index].src; + img.alt = items[index].alt; + if (caption) caption.textContent = items[index].alt; + box.hidden = false; + document.body.classList.add("lightbox-open"); + } + + function close() { + box.hidden = true; + img.removeAttribute("src"); + document.body.classList.remove("lightbox-open"); + } + + document.addEventListener("click", function (e) { + var target = e.target; + if (!(target instanceof HTMLImageElement)) return; + if (!target.classList.contains("lightboxable")) return; + e.preventDefault(); + e.stopPropagation(); + collect(); + var full = target.getAttribute("data-full-src") || target.currentSrc || target.src; + var found = items.findIndex(function (it) { + return it.src === full; + }); + show(found >= 0 ? found : 0); + }); + + box.addEventListener("click", function (e) { + if (e.target === box || (e.target && e.target.classList.contains("lightbox-close"))) { + close(); + return; + } + if (e.target && e.target.classList.contains("lightbox-next")) { + show(index + 1); + } + if (e.target && e.target.classList.contains("lightbox-prev")) { + show(index - 1); + } + }); + + document.addEventListener("keydown", function (e) { + if (box.hidden) return; + if (e.key === "Escape") close(); + if (e.key === "ArrowRight") show(index + 1); + if (e.key === "ArrowLeft") show(index - 1); + }); + + var startX = 0; + box.addEventListener( + "touchstart", + function (e) { + if (e.changedTouches && e.changedTouches[0]) startX = e.changedTouches[0].screenX; + }, + { passive: true } + ); + box.addEventListener( + "touchend", + function (e) { + if (!e.changedTouches || !e.changedTouches[0]) return; + var dx = e.changedTouches[0].screenX - startX; + if (Math.abs(dx) < 40) return; + show(index + (dx < 0 ? 1 : -1)); + }, + { passive: true } + ); + } + + startLivePoll(); + startLightbox(); +})(); diff --git a/app/static/favicon.svg b/app/static/favicon.svg new file mode 100644 index 0000000..cd60fbd --- /dev/null +++ b/app/static/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/static/style.css b/app/static/style.css index c67164c..4613846 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -52,6 +52,17 @@ a:focus-visible { .site-header { padding: 1rem var(--gap); border-bottom: 1px solid var(--border); + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; +} + +.header-note { + color: var(--text-dim); + font-size: 0.8rem; + letter-spacing: 0.04em; + text-transform: uppercase; } .brand { @@ -147,6 +158,33 @@ h2 { } } +.job-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + margin: 0.5rem 0 1rem; +} + +.job-heading h1 { + margin: 0; +} + +.progress { + height: 4px; + background: var(--border); + border-radius: 999px; + overflow: hidden; + margin: -0.5rem 0 1rem; +} + +.progress span { + display: block; + height: 100%; + background: var(--accent); + min-width: 0; +} + .job-card { background: var(--bg-elevated); border: 1px solid var(--border); @@ -154,6 +192,27 @@ h2 { overflow: hidden; } +.job-card.is-live { + border-color: var(--pending); + animation: live-pulse 1.8s ease-in-out infinite; +} + +@keyframes live-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(217, 164, 65, 0); + } + 50% { + box-shadow: 0 0 0 3px rgba(217, 164, 65, 0.35); + } +} + +@media (prefers-reduced-motion: reduce) { + .job-card.is-live { + animation: none; + } +} + .job-card a { color: var(--text); display: block; @@ -390,4 +449,39 @@ body.lightbox-open { padding: 0.25rem 0.5rem; min-height: 2.75rem; min-width: 2.75rem; + z-index: 1; +} + +.lightbox-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + border: 0; + background: transparent; + color: var(--text); + font-size: 2.4rem; + line-height: 1; + cursor: pointer; + padding: 0.5rem 0.75rem; + min-height: 2.75rem; + min-width: 2.75rem; +} + +.lightbox-prev { + left: 0.25rem; +} + +.lightbox-next { + right: 0.25rem; +} + +.lightbox-caption { + position: absolute; + bottom: 0.75rem; + left: 1rem; + right: 1rem; + margin: 0; + text-align: center; + color: var(--text-dim); + font-size: 0.85rem; } diff --git a/app/templates/base.html b/app/templates/base.html index b1864c3..046f6d9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -3,61 +3,30 @@ + + {% block title %}{{ site_title }}{% endblock %} + + - +
{% block content %}{% endblock %}
- + diff --git a/app/templates/index.html b/app/templates/index.html index 5bd9d50..b4b2fbc 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -2,28 +2,34 @@ {% block title %}{{ site_title }}{% endblock %} {% block content %} {% if not jobs %} -

Noch keine Jobs. Bild in den Sync-Ordner legen, dann kurz warten.

+

Noch keine Jobs. Foto in der App teilen, dann kurz warten.

{% else %}
    {% for job in jobs %} -
  • +
  • {% if job.thumbnail %} {% set img = img_attrs(job.job_id, job.thumbnail) %} - Variante von Job {{ job.job_id }} {% else %} - {{ job.status }} + {{ job.status_label }} {% endif %} {{ job.job_id }} - {{ job.status }} · {{ job.variant_count }} Varianten + + {{ job.status_label }} + {% if job.stage_label and job.status in ACTIVE_STATUSES %} + · {{ job.stage_label }} + {% else %} + · {{ job.variant_count }} Varianten + {% endif %} +
  • diff --git a/app/templates/job.html b/app/templates/job.html index 4fa8aa7..7be0689 100644 --- a/app/templates/job.html +++ b/app/templates/job.html @@ -3,8 +3,20 @@ {% block content %} -

    Job {{ job_id }}

    -

    Status: {{ status.status or "unbekannt" }}

    +
    +

    Job {{ job_id }}

    +

    + {{ status_label(status.status) }} + {% if status.status in ACTIVE_STATUSES %} + · {{ stage_label(status.status, status.stage, status.progress) }} + {% endif %} +

    +
    + {% if status.status in ACTIVE_STATUSES and status.progress and status.progress.total %} + + {% endif %} {% if status.error %}

    Fehler: {{ status.error }}

    {% endif %} @@ -28,6 +40,8 @@ Download + {% else %} +

    Original kommt, sobald der Worker greift.

    {% endif %} {% if has_rembg and rembg_name %} {% set img = img_attrs(job_id, rembg_name, sizes=PAIR_SIZES) %} diff --git a/app/templates/remix.html b/app/templates/remix.html index 027127d..c774871 100644 --- a/app/templates/remix.html +++ b/app/templates/remix.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block title %}Remix {{ job_id }} — {{ site_title }}{% endblock %} {% block content %} - +

    Remix · Job {{ job_id }}

    {% if from_variant %} diff --git a/compose.yml b/compose.yml index c84e3af..21cc56c 100644 --- a/compose.yml +++ b/compose.yml @@ -1,12 +1,12 @@ -# live.f12.rocks — event photo pipeline: Syncthing drop -> gmic/rembg -> web. +# live.f12.rocks — event photo pipeline: Android upload -> gmic/rembg -> web. # -# worker + web share one host directory (Syncthing share root): +# worker + web share one host directory: # ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12} -> /data # worker watches /data/incoming, writes /data/variants + /data/intermediates -# web reads meta/ + image dirs, writes remix variants +# web serves HTML + /api (POST /api/jobs needs UPLOAD_TOKEN) # -# Syncthing itself runs on the host (not in this compose). Copy .env.example -# to .env for local overrides (e.g. DATA_HOST_DIR=./data). Never commit .env. +# Copy .env.example to .env for local overrides (DATA_HOST_DIR=./data, +# UPLOAD_TOKEN=...). Never commit .env. x-app-env: &app-env DATA_DIR: /data @@ -53,6 +53,7 @@ services: environment: <<: *app-env SITE_TITLE: live.f12.rocks + UPLOAD_TOKEN: ${UPLOAD_TOKEN:-} volumes: - ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data - ./assets:/data/assets:ro diff --git a/pyproject.toml b/pyproject.toml index 61cc886..c1c91f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "livef12rocks" version = "0.1.0" -description = "Event photo pep pipeline: Syncthing drop -> gmic/rembg -> web" +description = "Event photo pep pipeline: Android upload -> gmic/rembg -> web" readme = "README.md" requires-python = ">=3.11,<3.12" diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..4ba574c --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,135 @@ +"""Tests for JSON API upload and job payloads.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from app import config, pipeline +from app.main import app +from fastapi.testclient import TestClient +from PIL import Image + + +@pytest.fixture +def client(data_dir: Path) -> TestClient: + return TestClient(app) + + +@pytest.fixture +def upload_token(monkeypatch: pytest.MonkeyPatch) -> str: + token = "test-upload-token" + monkeypatch.setattr(config, "UPLOAD_TOKEN", token) + return token + + +def _jpeg_bytes(size: tuple[int, int] = (80, 60)) -> bytes: + from io import BytesIO + + buf = BytesIO() + Image.new("RGB", size, color=(12, 34, 56)).save(buf, format="JPEG", quality=85) + return buf.getvalue() + + +def test_health_reports_upload_disabled(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config, "UPLOAD_TOKEN", "") + response = client.get("/api/health") + assert response.status_code == 200 + assert response.json() == {"ok": True, "upload": False} + + +def test_upload_disabled_without_token(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config, "UPLOAD_TOKEN", "") + response = client.post( + "/api/jobs", + files={"file": ("party.jpg", _jpeg_bytes(), "image/jpeg")}, + ) + assert response.status_code == 503 + + +def test_upload_rejects_bad_token(client: TestClient, upload_token: str) -> None: + response = client.post( + "/api/jobs", + files={"file": ("party.jpg", _jpeg_bytes(), "image/jpeg")}, + headers={"Authorization": "Bearer nope"}, + ) + assert response.status_code == 401 + + +def test_upload_accepts_bearer_and_queues(client: TestClient, upload_token: str, data_dir: Path) -> None: + response = client.post( + "/api/jobs", + files={"file": ("party.jpg", _jpeg_bytes(), "image/jpeg")}, + headers={"Authorization": f"Bearer {upload_token}"}, + ) + assert response.status_code == 202 + body = response.json() + assert body["status"] == "queued" + job_id = body["job_id"] + assert job_id.endswith("_party") + inbox = list(config.INBOX_DIR.glob("*_party.jpg")) + assert len(inbox) == 1 + assert not inbox[0].name.startswith(".") + meta = pipeline.read_meta(job_id) + assert meta is not None + assert meta["status"] == "queued" + + +def test_upload_accepts_x_upload_token_header(client: TestClient, upload_token: str) -> None: + response = client.post( + "/api/jobs", + files={"file": ("cam.png", _jpeg_bytes(), "image/png")}, + headers={"X-Upload-Token": upload_token}, + ) + assert response.status_code == 202 + assert response.json()["status"] == "queued" + + +def test_upload_rejects_empty_file(client: TestClient, upload_token: str) -> None: + response = client.post( + "/api/jobs", + files={"file": ("empty.jpg", b"", "image/jpeg")}, + headers={"Authorization": f"Bearer {upload_token}"}, + ) + assert response.status_code == 400 + + +def test_api_job_detail_and_list(client: TestClient, data_dir: Path) -> None: + stem = "photo01" + paths = pipeline.job_paths(stem) + paths.variants.mkdir(parents=True, exist_ok=True) + png = paths.variants / f"{stem}_v1.png" + Image.new("RGB", (32, 32), color=(1, 2, 3)).save(png, format="PNG") + pipeline.write_status( + paths, + "processing", + stage="variant", + progress={"current": 2, "total": 3}, + created_at="2026-01-01T00:00:00", + ) + pipeline.write_manifest( + paths, + { + "variants": [{"id": "v1", "file": png.name}], + "created_at": "2026-01-01T00:00:00", + }, + ) + + listed = client.get("/api/jobs") + assert listed.status_code == 200 + payload = listed.json() + assert payload["active"] is True + assert payload["jobs"][0]["job_id"] == stem + assert payload["jobs"][0]["status_label"] == "Läuft" + assert payload["jobs"][0]["stage_label"] == "Variante 2/3" + + detail = client.get(f"/api/jobs/{stem}") + assert detail.status_code == 200 + body = detail.json() + assert body["has_rembg"] is False + assert body["variants"][0]["url"].endswith(f"/jobs/{stem}/files/{png.name}") + assert body["progress"] == {"current": 2, "total": 3} + + +def test_api_job_missing(client: TestClient) -> None: + assert client.get("/api/jobs/does-not-exist").status_code == 404 diff --git a/tests/test_jobs.py b/tests/test_jobs.py new file mode 100644 index 0000000..562758e --- /dev/null +++ b/tests/test_jobs.py @@ -0,0 +1,54 @@ +"""Tests for job helpers (labels, inbox names, payloads).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from app import jobs, pipeline + + +def test_status_label_known_and_passthrough() -> None: + assert jobs.status_label("done") == "Fertig" + assert jobs.status_label("queued") == "Warteschlange" + assert jobs.status_label("weird") == "weird" + + +def test_stage_label_variant_progress() -> None: + label = jobs.stage_label("processing", "variant", {"current": 1, "total": 3}) + assert label == "Variante 1/3" + assert jobs.stage_label("queued", None, None) == "Wartet auf Worker" + assert jobs.stage_label("processing", "rembg", None) == "Freistellen" + + +def test_make_inbox_filename_unique_and_safe() -> None: + a = jobs.make_inbox_filename("party.jpg") + b = jobs.make_inbox_filename("party.jpg") + assert a != b + assert a.endswith("_party.jpg") + assert "/" not in a + jpeg = jobs.make_inbox_filename("IMG_0001.JPEG") + assert jpeg.lower().endswith(".jpg") + fallback = jobs.make_inbox_filename("noext") + assert fallback.endswith("_noext.jpg") + + +def test_validate_job_id_rejects_paths() -> None: + with pytest.raises(jobs.JobIdError): + jobs.validate_job_id("../evil") + assert jobs.validate_job_id("foo@bar") == "foo_bar" + + +def test_summarize_job_includes_labels(data_dir: Path) -> None: + paths = pipeline.job_paths("event01") + pipeline.write_status( + paths, + "processing", + stage="rembg", + progress={"current": 0, "total": 3}, + created_at="2026-01-01T00:00:00", + ) + summary = jobs.summarize_job("event01") + assert summary["status_label"] == "Läuft" + assert summary["stage_label"] == "Freistellen" + assert summary["variant_count"] == 0 diff --git a/tests/test_main.py b/tests/test_main.py index c583173..31dab8d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -93,7 +93,10 @@ def test_job_file_srcset_resize(client: TestClient, data_dir: Path) -> None: assert index.status_code == 200 assert f"/jobs/{stem}/files/{png.name}?w=640" in index.text assert "srcset=" in index.text - assert "data-full-src=" in index.text + + detail = client.get(f"/jobs/{stem}") + assert detail.status_code == 200 + assert "data-full-src=" in detail.text def test_img_attrs_builds_srcset() -> None: