Files
2026-10-13 17:10:50 +03:00

207 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` для неотредактированной геолокации при локальной
генерации превью — можно уточнить позже.