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.
9.4 KiB
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.