210ce69e18
Native single-photo uploader for POST /upload. Lives in android/ with its own flake, so the root flake stays Go-only and NixOS consumers of nixosModules.default do not pull the Android SDK into their lock. The server is untouched, so the app closes every gap client side: - The slug is the enqueue-time timestamp, persisted in the work input. server.go derives the stored slug from the uploaded filename, so reusing that filename across WorkManager retries overwrites the same post rather than creating a duplicate. That stands in for an idempotency key. - EXIF is stripped before upload. The gallery publishes the uploaded file verbatim as its full-size download, and phone photos carry GPS. JPEG and PNG drop metadata at marker and chunk level with the compressed pixels untouched; HEIC is decoded to JPEG because the extension check rejects it. - Photos flagged for rotation are rotated into the pixels instead of relying on the orientation tag, which cannot survive the strip and which the thumbnailer ignores regardless. - Anything over 18 MiB steps quality, then resolution, to stay inside nginx's client_max_body_size 20M. - Credentials are sealed with an AES-GCM key in the Android Keystore. The key deliberately does not require user authentication, or background retries could not read it. Basic auth is attached by an interceptor rather than an Authenticator, which reacts to a 401 by replaying a multipart body that is not reliably replayable. - The connection test uses GET /upload. /health is unreachable from outside because the nginx vhost only proxies location /upload. WorkManager's SystemForegroundService needs foregroundServiceType declared on the service entry, not just as a permission, or setForeground throws on Android 14+. Only lintVitalRelease catches that, never a debug build. Also anchors the .gitignore patterns. Unanchored, "photogallery" matched the Kotlin package directory ws/inflo/photogallery/ and silently swallowed every source file on git add.
68 lines
2.4 KiB
Markdown
68 lines
2.4 KiB
Markdown
# Gallery Upload — Android client
|
|
|
|
Native uploader for the photogallery `POST /upload` endpoint. Pick or share a
|
|
photo, add a caption and date, post. Credentials are entered once and sealed
|
|
with a key held in the Android Keystore.
|
|
|
|
The server is not modified by this app. Everything it needs, it does client
|
|
side.
|
|
|
|
## Build
|
|
|
|
```bash
|
|
cd android
|
|
nix develop # Android SDK 36, build-tools 36.0.0, JDK 17, gradle
|
|
gradle wrapper # first time only, generates ./gradlew
|
|
./gradlew assembleDebug
|
|
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
|
```
|
|
|
|
The dev shell exports `ANDROID_HOME`, `JAVA_HOME` and a `GRADLE_OPTS` that
|
|
points AGP at the nix-provided `aapt2`. Without that last one AGP downloads a
|
|
generic-linux `aapt2` from Maven which will not run on NixOS.
|
|
|
|
## Release signing
|
|
|
|
`app/build.gradle.kts` wires up a release signing config only when
|
|
`android/keystore.properties` exists. Both that file and `*.jks` are
|
|
gitignored — keep the keystore outside the repo and back it up somewhere you
|
|
will still have in five years, because losing it means you can never upgrade an
|
|
installed build in place.
|
|
|
|
```bash
|
|
mkdir -p ~/keys
|
|
keytool -genkeypair -v \
|
|
-keystore ~/keys/photogallery-release.jks \
|
|
-alias photogallery \
|
|
-keyalg RSA -keysize 4096 -validity 10000
|
|
```
|
|
|
|
Then write `android/keystore.properties`:
|
|
|
|
```properties
|
|
storeFile=/home/you/keys/photogallery-release.jks
|
|
storePassword=…
|
|
keyAlias=photogallery
|
|
keyPassword=…
|
|
```
|
|
|
|
```bash
|
|
./gradlew assembleRelease
|
|
```
|
|
|
|
Without `keystore.properties` the release build still runs, it just comes out
|
|
unsigned.
|
|
|
|
## How it maps onto the server
|
|
|
|
| Server behaviour | What the app does about it |
|
|
|---|---|
|
|
| Slug derived from the uploaded filename | Sends `yyyy-MM-dd-HHmmss.jpg`, generated once at enqueue |
|
|
| Same filename overwrites the same post | Reused across retries, which makes retries idempotent |
|
|
| Uploaded file published verbatim as "full size" | Strips EXIF, including GPS, before upload |
|
|
| Extension check rejects HEIC | Converts HEIC to JPEG; JPEG and PNG pass through losslessly |
|
|
| nginx `client_max_body_size 20M` | Steps quality, then resolution, if the result exceeds 18 MiB |
|
|
| Thumbnailer ignores EXIF orientation | Bakes rotation into the pixels rather than relying on the tag |
|
|
| nginx only proxies `/upload`, so `/health` 404s | Tests credentials with `GET /upload` instead |
|
|
| Replies `uploaded <slug>.jpg` in plain text | Parses that into `<base>/photo/<slug>/` for the "Open" link |
|