feat(android): add native Android client
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.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user