# 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/?...`. - Объекты отдаются **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//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.