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:
iu
2026-10-12 12:14:54 +03:00
parent e4975aafb5
commit aa2f7552bc
63 changed files with 4575 additions and 2 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
+114
View File
@@ -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.
@@ -0,0 +1,47 @@
# Proposal
## Why
Self-hosted galo сейчас доступен только как адаптивный веб, а `project.md` §17 явно
исключает мобильные приложения. Но главный сценарий фотоальбома — снимать и загружать
с телефона — на вебе ограничен: нет системного пикера, фоновой загрузки и офлайн-кэша.
Нативный Android-клиент закрывает именно этот разрыв поверх уже существующего HTTP API.
## What Changes
- Новое Android-приложение (Kotlin + Jetpack Compose, Android-only), подключаемое к
произвольному self-hosted инстансу galo по базовому URL.
- Онбординг: ручной ввод URL сервера, проверка `GET /api/health`, вход в аккаунт;
режим небезопасного соединения для LAN, iOS/mDNS/доверие self-signed — вне объёма.
- Просмотр: список альбомов, сетка фото, viewer фото/видео/pano, live-обновления по SSE.
- Загрузка: системный Photo Picker, очередь в WorkManager, по одному файлу на запрос,
прогресс в уведомлении, повторы, опция «только Wi-Fi», работа после закрытия приложения.
- HEIC/HEIF конвертируется на клиенте в JPEG с сохранением EXIF перед отправкой.
- Кэш метаданных (Room) и объектов (Coil, ключ — путь без подписи) для офлайн-браузинга.
- Без изменений серверного API: используются существующие `/api/auth/*`, `/api/albums`,
`/api/albums/:id/photos` (list + multipart upload), `/api/sse/*`, `/api/objects/*`.
- Не входит в v1: редактирование тегов/комментариев/лайков, управление участниками и
инвайтами, share-ссылки, push-уведомления через FCM, iOS.
- **BREAKING**: нет.
## Capabilities
### New Capabilities
- `android-client`: поведение Android-клиента — настройка сервера и онбординг, сессия и
авторизация, просмотр альбомов и медиа, очередь загрузки и офлайн-кэш.
### Modified Capabilities
Нет. Серверные требования не меняются: клиент потребляет существующий API без правок.
Правка `project.md` §17 («только адаптивный веб») — документация, не spec.
## Impact
- Новый верхнеуровневый модуль `android/` (Gradle-проект), не затрагивающий `backend/`
и `frontend/`; отдельная сборка и подпись, опциональная цель в `Makefile`.
- Ключевые зависимости клиента: Compose, OkHttp (+ okhttp-sse), Coil, Room,
WorkManager, AndroidX ExifInterface, Media3/ExoPlayer.
- Интеграционные ограничения сервера, учтённые в дизайне: origin-relative подписанные
URL объектов с окном подписи (`host-agnostic-object-urls`), fail-fast batch-загрузка
(`photos/handlers.go` Upload), отсутствие декодирования HEIC на сервере.
- Требуется пересмотр `project.md` §17 как отдельная правка документации.
@@ -0,0 +1,232 @@
# Spec Delta
## Purpose
Android-клиент для self-hosted galo: настройка подключения к серверу, авторизация,
просмотр альбомов и медиа, а также фоновая загрузка фотографий и видео с телефона
с офлайн-кэшем.
## ADDED Requirements
### Requirement: Настройка подключения к серверу
Приложение SHALL требовать явного указания базового URL инстанса galo при первом
запуске и SHALL проверять доступность сервера перед переходом к работе.
#### Scenario: Успешная настройка
- **WHEN** пользователь вводит достижимый базовый URL и подтверждает
- **THEN** приложение проверяет `GET /api/health` и при успехе переходит к входу
#### Scenario: Сервер недоступен
- **WHEN** сервер недоступен или health вернул ошибку
- **THEN** приложение показывает ошибку и остаётся на экране настройки, сохраняя введённый URL
### Requirement: Небезопасное соединение по LAN
Приложение SHALL по умолчанию запрещать незашифрованный HTTP и SHALL разрешать его
только после явного включения пользователем режима небезопасного соединения.
#### Scenario: HTTP без разрешения
- **WHEN** пользователь указывает `http`-URL и режим небезопасного соединения выключен
- **THEN** приложение блокирует подключение и предлагает включить режим
#### Scenario: Режим включён
- **WHEN** пользователь включил режим небезопасного соединения
- **THEN** HTTP-подключение разрешено, а интерфейс явно отмечает небезопасное соединение
### Requirement: Разрешение относительных URL объектов
Приложение SHALL трактовать относительные URL объектов из REST-ответов и SSE как пути
на выбранном базовом URL сервера.
#### Scenario: Отдача объекта
- **WHEN** API возвращает `thumb_url` вида `/api/objects/<key>?...`
- **THEN** приложение запрашивает объект с того же инстанса, что и базовый URL
### Requirement: Вход и хранение сессии
Приложение SHALL позволять вход и регистрацию по email и паролю и SHALL хранить токены
только в защищённом хранилище платформы.
#### Scenario: Успешный вход
- **WHEN** вход проходит успешно
- **THEN** токены сохраняются в защищённом хранилище, пользователь видит список альбомов
#### Scenario: Перезапуск с сессией
- **WHEN** приложение перезапущено с сохранённой сессией
- **THEN** пользователь остаётся авторизованным без повторного ввода пароля
### Requirement: Автоматическое обновление токена
Приложение SHALL автоматически обновлять access-токен при ответе 401, пока refresh-токен
действителен, и SHALL завершать сессию при неудачном обновлении.
#### Scenario: Прозрачное обновление
- **WHEN** запрос получает 401 и refresh-токен действителен
- **THEN** приложение обновляет токен и повторяет исходный запрос без участия пользователя
#### Scenario: Обновление не удалось
- **WHEN** обновление токена завершилось ошибкой
- **THEN** сессия очищается и пользователь возвращается на экран входа
### Requirement: Список альбомов
Приложение SHALL показывать доступные пользователю альбомы с обложкой и счётчиками,
беря данные из API, а при отсутствии сети — из локального кэша.
#### Scenario: Онлайн
- **WHEN** авторизованный пользователь открывает приложение
- **THEN** отображается список альбомов с обложками или плейсхолдером
#### Scenario: Офлайн из кэша
- **WHEN** сеть недоступна, но кэш существует
- **THEN** список альбомов отображается из кэша с признаком офлайн-режима
### Requirement: Постраничный просмотр альбома
Приложение SHALL показывать сетку медиа альбома с постраничной загрузкой по курсору и
SHALL подгружать следующую страницу при достижении конца списка.
#### Scenario: Догрузка страницы
- **WHEN** пользователь прокручивает сетку до конца
- **THEN** приложение запрашивает следующую страницу по курсору и добавляет её к сетке
#### Scenario: Пустой альбом
- **WHEN** в альбоме нет медиа
- **THEN** отображается пустое состояние
### Requirement: Просмотр медиа на весь экран
Полноэкранный просмотр SHALL поддерживать изображение, видео с постером и управлением
воспроизведением, а также панораму 360; навигация между элементами альбома SHALL
выполняться свайпом.
#### Scenario: Свайп между элементами
- **WHEN** пользователь свайпает в просмотре
- **THEN** показывается следующий или предыдущий элемент альбома без выхода из просмотра
#### Scenario: Видео
- **WHEN** открыт видеоэлемент
- **THEN** показывается постер и доступно воспроизведение с перемоткой
### Requirement: Обновления в реальном времени
Пока экран альбома активен, приложение SHALL отображать изменения по SSE (новые,
обновлённые и удалённые медиа, социальные события) и SHALL догонять пропущенное при
переподключении.
#### Scenario: Новое фото с другого устройства
- **WHEN** другое устройство добавляет фото в открытый альбом
- **THEN** новое фото появляется в сетке без ручного обновления
#### Scenario: Переподключение
- **WHEN** SSE-соединение разорвалось и восстановилось
- **THEN** приложение дозагружает изменения, произошедшие за время разрыва
### Requirement: Выбор медиа и альбома для загрузки
Приложение SHALL предоставлять выбор одного или нескольких фото и видео из системной
галереи и SHALL требовать выбора альбома назначения перед постановкой в очередь.
#### Scenario: Постановка в очередь
- **WHEN** пользователь выбрал файлы и альбом назначения
- **THEN** задания загрузки ставятся в очередь и пользователь видит их статус
#### Scenario: Отказ сервера
- **WHEN** сервер отклоняет загрузку (например, из-за прав или размера)
- **THEN** задание помечается ошибкой с причиной от сервера
### Requirement: Фоновая очередь загрузки
Загрузка SHALL выполняться по одному файлу за HTTP-запрос и SHALL продолжаться при
уходе приложения из foreground, показывая прогресс в уведомлении; при ошибке SHALL
выполняться повтор с нарастающей задержкой.
#### Scenario: Уход в фон
- **WHEN** приложение уходит в фон во время загрузки
- **THEN** загрузка продолжается и прогресс отображается в уведомлении
#### Scenario: Повтор при ошибке
- **WHEN** загрузка файла завершилась сетевой ошибкой
- **THEN** задание повторяется, а после исчерпания попыток помечается ошибкой
### Requirement: Условия сети для загрузки
Приложение SHALL позволять ограничить загрузку сетью Wi-Fi; при ожидании подходящей
сети задание остаётся в очереди и не выполняется.
#### Scenario: Только Wi-Fi
- **WHEN** ограничение «только Wi-Fi» включено, а доступна только сотовая сеть
- **THEN** загрузка не начинается до появления Wi-Fi, а задание остаётся в очереди
### Requirement: Сохранность очереди между запусками
Очередь загрузки SHALL сохраняться на устройстве и SHALL продолжаться после перезапуска
приложения или устройства.
#### Scenario: Возобновление после перезапуска
- **WHEN** приложение или устройство перезапускается при незавершённой очереди
- **THEN** незавершённые задания возобновляются
### Requirement: Конвертация HEIC в JPEG
Файлы HEIC/HEIF SHALL конвертироваться на устройстве в JPEG перед загрузкой с
сохранением даты съёмки и геолокации.
#### Scenario: Загрузка HEIC
- **WHEN** пользователь ставит в очередь HEIC-файл
- **THEN** загружается JPEG-версия, а дата съёмки и координаты из EXIF сохраняются
#### Scenario: Ошибка конвертации
- **WHEN** конвертация завершилась ошибкой
- **THEN** задание помечается ошибкой, повреждённый файл не загружается
### Requirement: Кэш медиа по устойчивому ключу
Приложение SHALL кэшировать полученные превью и оригиналы по устойчивому идентификатору
объекта, не зависящему от подписи и срока действия URL.
#### Scenario: Смена подписи
- **WHEN** URL одного и того же объекта со временем получает новую подпись
- **THEN** приложение переиспользует закэшированный объект вместо повторной загрузки
#### Scenario: Офлайн-отдача из кэша
- **WHEN** сеть недоступна, а объект есть в кэше
- **THEN** объект отображается из кэша
### Requirement: Единый формат ошибок API
Приложение SHALL распознавать ошибки API формата `{error:{code,message}}` и SHALL
показывать пользователю понятное сообщение.
#### Scenario: Ошибка с кодом и сообщением
- **WHEN** сервер возвращает ошибку с `code` и `message`
- **THEN** приложение показывает сообщение, при необходимости адаптированное под язык интерфейса
+55
View File
@@ -0,0 +1,55 @@
# Tasks
## 1. Скелет проекта и сборка
- [x] 1.1 Создать верхнеуровневый Gradle-проект `android/` (Kotlin, Jetpack Compose, version catalog, `minSdk 28`) и проверить, что `./gradlew :app:assembleDebug` собирает APK
- [x] 1.2 Задать структуру пакетов (app/data/domain/ui) и проверить, что `./gradlew lint` и сборка проходят без ошибок
- [x] 1.3 Добавить цель `android` в `Makefile` и проверить, что `make android` собирает APK
- [x] 1.4 Настроить debug- и release-подпись (release keystore вне репозитория) и проверить, что release-конфигурация резолвится без секретов в репозитории
## 2. Сеть и настройка сервера
- [x] 2.1 Экран ввода базового URL, проверка `GET /api/health`, хранение в DataStore — проверить против запущенного galo
- [x] 2.2 Шлюз «небезопасное соединение» для `http` — тест: HTTP заблокирован по умолчанию и разрешён после включения тумблера
- [x] 2.3 OkHttp-клиент, резолвящий относительные URL объектов против базового адреса — unit-тест на `/api/objects/<key>?...`
- [x] 2.4 Маппинг ошибок `{error:{code,message}}` и сообщений для пользователя — unit-тест на разборе ошибки
- [x] 2.5 Auth API (register/login/refresh/logout), защищённое хранение токенов (Keystore) и прозрачный refresh на 401 — тест: сессия переживает перезапуск, 401 приводит к обновлению и повтору
## 3. Браузинг и кэш
- [x] 3.1 Схема Room для альбомов/фото и репозиторий с фолбэком на кэш — unit-тест репозитория в онлайн- и офлайн-ветках
- [x] 3.2 Экран списка альбомов с обложками, счётчиками и признаком офлайн — UI-тест/ручная проверка
- [x] 3.3 Сетка альбома с постраничной загрузкой по курсору — тест слияния страниц
- [x] 3.4 Кэш Coil по пути объекта без query — unit-тест ключа и проверка переиспользования при смене подписи
- [x] 3.5 Открытие альбома из кэша офлайн — проверить в режиме полёта на прогретом кэше
## 4. Просмотр медиа
- [x] 4.1 Полноэкранный просмотр изображений (preview/medium) со свайп-навигацией — UI-тест
- [x] 4.2 Воспроизведение видео (Media3) с постером и перемоткой — проверить Range/seek против сервера
- [x] 4.3 Просмотр панорамы 360 — проверить поведение перетаскивания на паритет с веб-реализацией
## 5. Real-time
- [x] 5.1 SSE-клиент (okhttp-sse) на активном экране альбома с токеном — проверить обновление сетки по событию
- [x] 5.2 Переподключение с backoff и догон через `?since=` — тест сверки пропущенных событий
## 6. Конвейер загрузки
- [x] 6.1 Системный Photo Picker (мультивыбор), выбор альбома и постановка задания в Room — проверить создание queued-заданий
- [x] 6.2 WorkManager-воркер: один файл на multipart POST, per-file статус — интеграционный тест против сервера
- [x] 6.3 Конвертация HEIC/HEIF→JPEG с сохранением даты/геолокации и имени файла — unit-тест EXIF и проверка приёма сервером
- [x] 6.4 Foreground-уведомление прогресса для крупных/видео-загрузок и retry с backoff — проверить продолжение загрузки в фоне
- [x] 6.5 Сетевые ограничения (Wi-Fi-only) и сохранность очереди между запусками — тест возобновления после перезапуска процесса
- [x] 6.6 UI статусов загрузки с per-file статусом и ошибками сервера — UI-тест
## 7. Интеграция и документация
- [x] 7.1 End-to-end прогон: онбординг → вход → браузинг → загрузка → появление фото через SSE на реальном инстансе galo
- [x] 7.2 README Android-клиента (настройка URL, заметка про HEIC, LAN/cleartext, сборка и подпись) — проверить, что команды выполняются как описано
- [x] 7.3 Обновить `project.md` §17, чтобы он больше не исключал мобильный клиент — проверить, что формулировка отражает Android-приложение
## Workflow follow-up
- Заархивировать изменение после выполнения требований ревью проекта.
- Проверить результат архивации.