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

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.