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,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** приложение показывает сообщение, при необходимости адаптированное под язык интерфейса