207 lines
17 KiB
Markdown
207 lines
17 KiB
Markdown
# Design
|
||
|
||
## Context
|
||
|
||
Мотивацию см. в `proposal.md`. Здесь — текущее состояние и ограничения, задающие подход.
|
||
|
||
- Android-клиент (`android/`) построен вокруг серверных альбомов: `AlbumsScreen` →
|
||
`AlbumScreen` (`GET /api/albums/:id/photos`), SSE, viewer. Слой данных
|
||
(`com.galo.android.data.*`) уже содержит `UploadRepository`/`UploadWorker`
|
||
(по файлу на multipart POST, повторы, «только Wi-Fi», переживает перезапуск),
|
||
Room-кэш альбомов/фото, DataStore-настройки, `HeicConverter`. Понятий
|
||
«альбом устройства», «синхронизация», «устройство» нет.
|
||
- Сервер (`backend/`) хранит `objects.checksum` (sha256 оригинала), но **не отдаёт
|
||
его в API**. Фото неизменяемы по контенту: есть `PATCH /photos/:id`
|
||
(переименование), `POST /photo-batch/move|copy`, удаление/восстановление. Есть
|
||
`POST /api/albums` (создание). Загрузка — один файл на запрос, fail-fast,
|
||
ограничение размера тела.
|
||
- Платформа Android накладывает рамки: изменение/удаление медиа, созданного не
|
||
приложением, требует согласия пользователя (`MediaStore.createWriteRequest` /
|
||
`createDeleteRequest` / `RecoverableSecurityException`); фоновая работа —
|
||
WorkManager (периодические задачи не чаще ~15 мин); широкий доступ к медиа —
|
||
`READ_MEDIA_IMAGES`/`READ_MEDIA_VIDEO`, который ограничивается политикой Google
|
||
Play. Текущее приложение живёт на scoped Photo Picker без широких прав.
|
||
- `AndroidManifest.xml` задаёт `allowBackup="false"`, поэтому авто-бэкап данных
|
||
приложения недоступен.
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
|
||
- Единый конвейер для трёх режимов (A/B/C) поверх существующей инфраструктуры
|
||
загрузки, с идемпотентностью и корректным возобновлением после сбоя/перезапуска.
|
||
- Надёжная идентичность «локальный ассет ↔ серверное фото», устойчивая к
|
||
переустановке в режиме C.
|
||
- Безопасная политика удалений при общей цели (n:1) и общем серверном альбоме.
|
||
|
||
**Non-Goals:**
|
||
|
||
- Серверная модель устройств/сессий синхронизации; изменения схемы БД.
|
||
- Транскодирование видео на устройстве, редактирование изображений.
|
||
- Разрешение конфликтов владения между пользователями; версионирование контента.
|
||
- Работа с облачными «плейсхолдерами» (Google Photos) как с локальными ассетами
|
||
сверх того, что отдаёт MediaStore.
|
||
|
||
## Decisions
|
||
|
||
### 1. Единица синхронизации и цель: альбом устройства → существующий серверный альбом
|
||
|
||
Единица — bucket MediaStore (телефонный альбом). Пользователь выбирает целевой
|
||
**существующий** серверный альбом; допускается n:1 (несколько телефонных в один
|
||
серверный). Связь хранится в локальном индексе и дублируется в EXIF-метке
|
||
разгруженных превью.
|
||
|
||
*Альтернатива:* серверный альбом-зеркало 1:1 (автосоздание). Отклонено: лишает
|
||
пользователя контроля над тем, куда попадают фото, и хуже переиспользует уже
|
||
наполненные альбомы. *Альтернатива:* смешанный список без явной связи.
|
||
Отклонено: теряется понимание, куда льётся альбом.
|
||
|
||
### 2. Идентичность и дедуп: content-hash ↔ checksum объекта
|
||
|
||
Клиент хэширует **байты, которые реально уходят** (после HEIC-конвертации), и
|
||
сопоставляет с `checksum` объекта на сервере. Сервер начинает отдавать `checksum`
|
||
в данных фото (аддитивная правка, без миграции).
|
||
|
||
*Альтернатива:* локальная таблица `asset ↔ photo_id` — не переживает
|
||
переустановку. *Альтернатива:* полноценный серверный провенанс (device+asset) —
|
||
надёжнее, но заметно расширяет объём backend/API. Отклонено в пользу минимальной
|
||
правки; провенанс после переустановки закрывается EXIF-меткой (решение 5).
|
||
|
||
### 3. Политика изменений и удалений
|
||
|
||
- Телефон → сервер: добавление, удаление, переименование, перенос, правка контента
|
||
(как «новый upload + удалить старый, если он наш»).
|
||
- Удаление на сервере допускается **только для «наших» загрузок**: фото, которое
|
||
залил этот клиент, и чей hash исчез из всех локальных альбомов, льющихся в этот
|
||
серверный. Чужие фото (веб, другие устройства, другие участники) не трогаются.
|
||
- Сервер → телефон: отражаем метаданные и новые элементы; **оригиналы на телефоне
|
||
не трогаем** (удаление на сервере убирает только локальное представление).
|
||
- Отвязка и выключение синхронизации **никогда не удаляют** фото с сервера.
|
||
|
||
*Альтернатива:* полное зеркало — разрушительно при общей цели. *Альтернатива:*
|
||
только добавление — не покрывает «синхронизировать изменения».
|
||
|
||
### 4. Три режима как состояния одного конвейера
|
||
|
||
- **A** — индекс + генерация превью на телефоне; сервера не касается.
|
||
- **B** — A + загрузка копий; оригинал остаётся.
|
||
- **C** — B + разгрузка: после успешной загрузки оригинал на телефоне заменяется
|
||
превью.
|
||
|
||
Разгрузка в C реализуется записью превью **обратно в MediaStore** тем же `_ID`:
|
||
`openFileDescriptor(uri, "rwt")` и запись JPEG с сохранением `DATE_TAKEN`. Видео
|
||
заменяется JPEG-постером: это смена типа, поэтому выполняется как «удалить
|
||
видео-запись + вставить постер-картинку в ту же папку». Разгрузка выполняется
|
||
**автоматически** после успешной загрузки, без отдельного действия на экране; чтобы
|
||
замена не сопровождалась системным диалогом согласия, при включении режима C
|
||
приложение запрашивает широкий доступ к файлам (`MANAGE_EXTERNAL_STORAGE`, «доступ
|
||
ко всем файлам»). Загрузка и дедуп идут тихо.
|
||
|
||
*Альтернатива:* удалять оригинал без замены — теряется присутствие в системной
|
||
галерее. *Альтернатива:* транскодировать видео — дорого/нестабильно. Отклонено:
|
||
постер в v1.
|
||
|
||
### 5. Провенанс, переживающий переустановку: EXIF-метка на разгруженных превью
|
||
|
||
Файлы, которые мы пишем сами (превью в C и постеры видео), содержат EXIF-метку
|
||
(например, `UserComment`/XMP) с `server_album`, `server_photo`, `original_hash`,
|
||
`device`, `mode`. После переустановки обход галереи читает метку и восстанавливает
|
||
и связь альбомов, и провенанс наших загрузок. В A/B оригиналы не помечаются — там
|
||
дедуп идёт через content-hash.
|
||
|
||
*Альтернатива:* расширить серверное поле-источник — расширяет объём API.
|
||
*Альтернатива:* Android Auto Backup метаданных — не гарантирован, ломается при
|
||
переносе на другое устройство. Метка выбрана: провенанс путешествует с файлом.
|
||
|
||
### 6. Движок синхронизации и триггеры
|
||
|
||
- Локальный индекс содержимого каждого синхронизируемого альбома — в Room
|
||
(снапшот + провенанс). Внешние удаления не имеют «надгробий», поэтому diff —
|
||
единственный способ их заметить.
|
||
- Обнаружение изменений: `MediaStore.getGeneration()` / `ContentObserver` в
|
||
foreground; периодический WorkManager в фоне с ограничениями «только Wi-Fi» /
|
||
«только на зарядке» (переиспользуя `SettingsRepository`).
|
||
- Очередь и выполнение — на `UploadRepository`/`UploadWorker`: по файлу, повторы с
|
||
backoff, продолжение после перезапуска.
|
||
- Серверные изменения — SSE, пока приложение открыто, плюс догон `?since=`.
|
||
- Объём изменений v1 — расширенный (add/delete/rename/move/edit).
|
||
|
||
*Альтернатива:* только ручной запуск — противоречит «отслеживать изменения».
|
||
*Альтернатива:* авто лишь в foreground — не догоняет фон.
|
||
|
||
### 7. Интерфейс
|
||
|
||
Один смешанный список альбомов (устройство + сервер). Связанная пара показывается
|
||
**одной карточкой** альбома устройства с указателем связи (`→ <серверный>`) и меткой
|
||
режима; связанный серверный альбом из списка скрывается, чтобы синхронизируемый
|
||
альбом читался как один объект. Экран альбома устройства даёт настройки
|
||
синхронизации по свайпу вправо — **только выбор режима**. Разгруженные элементы в
|
||
сетке помечены значком облака; статусы элементов: локально / загружено / только
|
||
превью (разгружено).
|
||
|
||
### 8. Параметры превью
|
||
|
||
Крупное превью — длинная сторона ~1600 px, JPEG q80; мелкое — для сетки. Превью
|
||
генерируются на устройстве и лежат в приватном кэше приложения; в C крупное превью
|
||
дополнительно пишется в галерею. Дата съёмки (EXIF `DateTimeOriginal`/`DATE_TAKEN`)
|
||
сохраняется при замене.
|
||
|
||
### 9. Права
|
||
|
||
`READ_MEDIA_IMAGES` и `READ_MEDIA_VIDEO` (плюс legacy `READ_EXTERNAL_STORAGE` для
|
||
API до 33). Запись в MediaStore — через системные запросы согласия.
|
||
`ACCESS_MEDIA_LOCATION` — не требуется для метки (геолокации в ней нет).
|
||
|
||
### 10. Предпосылка: `.gitignore`
|
||
|
||
Правило `data/` в `.gitignore` игнорирует `android/app/src/main/java/com/galo/android/data/`
|
||
(весь слой данных, отсутствует и в git-истории). Устранить **до** реализации, иначе
|
||
новые файлы синхронизации не будут отслеживаться git. Отдельная правка, не часть
|
||
поведения синхронизации.
|
||
|
||
## Risks / Trade-offs
|
||
|
||
- [Замена файлов в MediaStore требует особого доступа на Android 11+] → при
|
||
включении режима C запрашивается широкий доступ к файлам, чтобы разгрузка шла
|
||
автоматически и без системного диалога; без доступа разгрузка пропускается.
|
||
- [EXIF-метка теряется, если сторонний редактор перезапишет превью без EXIF] →
|
||
деградация безопасная: элемент становится «неопознанным» (без дублей и без
|
||
удаления).
|
||
- [Общая цель (n:1) и наполненный серверный альбом] → удаляем только «наши»
|
||
загрузки; чужие источники не затрагиваются.
|
||
- [Content-hash не переживает переустановку в C сам по себе] → закрывается
|
||
EXIF-меткой; в B переустановка безопасна (оригиналы на месте, дедуп сработает
|
||
после повторного выбора цели).
|
||
- [Высокий расход на хэширование видео] → `sha256` считается лениво, только для
|
||
сравнения/загрузки; дешёвая первичная детекция изменения — по `DATE_MODIFIED`/`SIZE`.
|
||
- [Замена видео = delete+insert: место освобождается с задержкой (корзина ОС на 30
|
||
дней), коротко нужно место под постер] → сбойный элемент пропускается, проход не
|
||
падает; ограничение известно и принято.
|
||
- [Широкие `READ_MEDIA_*` и политика Google Play] → риск только при публикации в
|
||
Play; для self-hosted/сайдлоада не блокирует. Отмечено как внешнее ограничение.
|
||
- [Разгрузка C требует свободного места под возврат оригиналов при откате] →
|
||
предлагать возврат с проверкой свободного места.
|
||
- [Новые файлы синхронизации не попадут в git из-за `.gitignore` `data/`] →
|
||
устранить правило до начала реализации (см. Decision 10).
|
||
|
||
## Migration Plan
|
||
|
||
1. Предпосылка: поправить `.gitignore` (убрать `data/`), убедиться, что слой данных
|
||
попал в git.
|
||
2. Сервер: отдать `checksum` в моделях/запросах/ответах фото. Аддитивно, без
|
||
миграции БД; откат — убрать поле.
|
||
3. Клиент, этапами: (a) источник MediaStore + смешанный список + режим A;
|
||
(b) загрузка/дедуп + режим B; (c) разгрузка + EXIF-метка + режим C; (d) возврат
|
||
оригиналов и переходы режимов.
|
||
4. Room-миграция под индекс синхронизации и провенанс.
|
||
5. Откат: перевод альбомов в режим A не разрушителен (серверное остаётся);
|
||
серверное изменение аддитивно.
|
||
|
||
## Open Questions
|
||
|
||
- Конкретное поле для метки (`UserComment` vs XMP) и формат значения — решается при
|
||
реализации, не влияет на поведение.
|
||
- Частота периодической фоновой синхронизации и правила пробуждения — тюнинг.
|
||
- Нужен ли `ACCESS_MEDIA_LOCATION` для неотредактированной геолокации при локальной
|
||
генерации превью — можно уточнить позже.
|