Files
ds-galo/openspec/changes/archive/2026-10-12-add-android-app/design.md
T

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.