Kotlin + Jetpack Compose client for a self-hosted instance: manual server setup and health check, auth with Keystore-backed tokens, album and photo browsing with SSE live updates, upload queue in WorkManager with HEIC-to-JPEG conversion, and Room/Coil offline cache. Add an android Makefile target and revise project.md §17.
115 lines
9.4 KiB
Markdown
115 lines
9.4 KiB
Markdown
# Design
|
|
|
|
## Context
|
|
|
|
См. `proposal.md` — Why. Существенное текущее состояние и ограничения:
|
|
|
|
- API уже достаточен: `POST /api/auth/{register,login,refresh,logout}`, `GET /api/albums`,
|
|
`GET /api/albums/:id/photos?cursor&since&limit`, `POST /api/albums/:id/photos` (multipart),
|
|
`GET /api/sse/albums/:id?token=`, `GET /api/objects/<key>?...`.
|
|
- Объекты отдаются **origin-relative** подписанными URL (`storage/signer.go:57`), подпись
|
|
(`e`,`s`) квантована окном `OBJECT_URL_SIGN_WINDOW` (по умолчанию 10 мин) и меняется со
|
|
временем. `host-agnostic-object-urls/design.md:57` прямо предполагает не-браузерного
|
|
потребителя, резолвящего эти URL против базового адреса инстанса.
|
|
- Batch-загрузка **fail-fast**: обработчик возвращает ошибку на первом неудачном файле, а
|
|
`MaxBytesReader` ограничивает **общий** размер тела (`photos/handlers.go:40`).
|
|
- Сервер декодирует только JPEG/PNG/GIF/BMP/TIFF (`photos/pipeline.go:125`), HEIC/HEIF/AVIF
|
|
отклоняются на `inspect()` (`photos/service.go:168`).
|
|
- `?since=` уже поддержан для догона (`photos/handlers.go:24`).
|
|
|
|
## Goals / Non-Goals
|
|
|
|
**Goals:**
|
|
|
|
- Нативный Android-клиент (смотреть + загружать) как отдельный модуль, независимый от
|
|
`backend/` и `frontend/`.
|
|
- Работа с произвольным self-hosted инстансом без изменений серверного API.
|
|
- Надёжная фоновая загрузка и офлайн-просмотр при ограничениях платформы Android.
|
|
|
|
**Non-Goals:**
|
|
|
|
- iOS, share-ссылки, управление участниками/инвайтами, редактирование тегов/комментариев.
|
|
- Push через FCM; resumable-загрузка; mDNS/auto-discovery; доверие self-signed CA.
|
|
- Работа при развёртывании в подпути (`https://host/galo/`): как и веб, клиент
|
|
предполагает корень `/api/...`.
|
|
|
|
## Decisions
|
|
|
|
### Нативный Kotlin + Compose, отдельный `android/`
|
|
Клиент живёт в новом верхнеуровневом Gradle-проекте, отдельно от `backend/`/`frontend/`.
|
|
Даёт системный пикер, WorkManager, Media3, Coil, Keystore без прослоек.
|
|
Альтернативы: Flutter (лишний тяжёлый тулчейн в проекте «без Docker»), TWA-обёртка
|
|
(нет нативной очереди/пикера — теряется смысл), KMP (дороже на старте без цели на iOS).
|
|
|
|
### Ручной базовый URL + явный режим небезопасного соединения
|
|
Первый запуск требует ввести URL инстанса и подтверждает его через `GET /api/health`.
|
|
HTTP по умолчанию запрещён (Android блокирует cleartext); пользователь может включить
|
|
режим «небезопасное соединение (только LAN)». Относительные URL объектов резолвятся
|
|
против этого базового адреса.
|
|
Альтернативы: mDNS/auto-discovery (нужен серверный responder, нестабильно на OEM),
|
|
`APP_BASE_URL` (удалён в `host-agnostic-object-urls`).
|
|
|
|
### Сессия: Keystore + автоматический refresh
|
|
Токены — только в защищённом хранилище платформы (Keystore-backed
|
|
EncryptedSharedPreferences/DataStore), не в открытых prefs. Refresh выполняется
|
|
прозрачно на 401 (retry исходного запроса); неудача очищает сессию.
|
|
Альтернатива: HttpOnly-cookie (неприменимо для нативного HTTP-клиента без браузерного
|
|
cookie-jar и CSRF-защиты).
|
|
|
|
### Загрузка: WorkManager, один файл на запрос, очередь в Room
|
|
Каждое задание — один файл и один multipart POST: batch на сервере fail-fast и
|
|
ограничивает общий размер тела, поэтому per-file статус и гранулярный retry возможны
|
|
только по одному файлу. Очередь персистится в Room, переживает перезапуск; крупные и
|
|
видео-загрузки идут с foreground-уведомлением прогресса; retry — экспоненциальный
|
|
backoff; сеть ограничивается по желанию (Wi-Fi-only).
|
|
Альтернативы: batch (теряет per-file статус), upload только в foreground (нет главной
|
|
ценности клиента).
|
|
|
|
### HEIC/HEIF → JPEG на клиенте с сохранением EXIF
|
|
Перед постановкой в очередь HEIC/HEIF декодируется на устройстве и перекодируется в
|
|
JPEG; дата съёмки и координаты переносятся через AndroidX ExifInterface, имя файла
|
|
сохраняется. Так сервер принимает файл без изменений и сохраняет сортировку по дате
|
|
(`taken_at` выводится из EXIF, поля для клиентской метадачи нет).
|
|
Альтернативы: libheif на сервере (CGO ломает статический `CGO_ENABLED=0` бинарник),
|
|
сайдкар-оригинал (изменение API). Цена: для HEIC-источников «оригиналом» становится JPEG.
|
|
|
|
### Кэш по устойчивому ключу объекта
|
|
Кэш медиа (Coil) использует ключ по пути объекта без query (`/api/objects/<id>/thumb.webp`),
|
|
т.к. подпись меняется каждое окно. Метаданные альбомов и фото кэшируются в Room, чтобы
|
|
альбомы и сетки открывались офлайн.
|
|
Альтернатива: ключ по полному URL — промах кэша на каждой смене подписи.
|
|
|
|
### Live-обновления: SSE в foreground, без FCM
|
|
Пока экран альбома активен, клиент держит `GET /api/sse/albums/:id?token=` (okhttp-sse)
|
|
и догоняет разрыв через `GET /api/albums/:id/photos?since=`. Push-уведомления вне
|
|
foreground не входят в v1 (несовместимо с self-hosted духом без Google-инфраструктуры).
|
|
|
|
### Сборка и репозиторий
|
|
Опциональная цель `make android` в существующем `Makefile`; debug-подпись для сборок,
|
|
отдельный release-keystore вне репозитория. Серверная сборка не меняется.
|
|
|
|
## Risks / Trade-offs
|
|
|
|
- [Сервер не декодирует HEIC] → клиент конвертирует в JPEG; для HEIC-источников хранится
|
|
JPEG-оригинал — задокументировать в README/справке приложения.
|
|
- [Нет resumable-загрузки, видео до 2 ГБ] → повтор файла целиком при сбое; предупреждение
|
|
о мобильном трафике; resumable — отдельное будущее изменение API.
|
|
- [Ротация подписи URL] → кэш по ключу объекта; риск при смене схемы ключей сервером.
|
|
- [Ограничения фоновой работы Android 14+] → foreground-уведомление для длинных загрузок,
|
|
небольшие батчи, соблюдение правил старта FGS.
|
|
- [SSE и lifecycle] → соединение только пока экран активен; backoff + догон через `since`.
|
|
- [Cleartext по LAN] → только по явному включению, с визуальным предупреждением.
|
|
- [Отсутствие push] → события замечаются при открытии приложения; отложено осознанно.
|
|
|
|
## Migration Plan
|
|
|
|
Изменение аддитивно и сервер не затрагивает: существующий инстанс galo продолжает
|
|
работать без обновления. Откат — удаление `android/` и цели `make android`. Отдельный
|
|
пересмотр `project.md` §17 («только адаптивный веб») — правка документации.
|
|
|
|
## Open Questions
|
|
|
|
- Дистрибуция: sideload/F-Droid против публикации в Play (влияет на подпись и release-пайплайн).
|
|
- `minSdk`/`targetSdk`: насколько широкую базу устройств поддерживать.
|
|
- Показывать ли в первом релизе read-only социальные данные (лайки/комментарии/теги) из API.
|