17 KiB
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 из-за
.gitignoredata/] → устранить правило до начала реализации (см. Decision 10).
Migration Plan
- Предпосылка: поправить
.gitignore(убратьdata/), убедиться, что слой данных попал в git. - Сервер: отдать
checksumв моделях/запросах/ответах фото. Аддитивно, без миграции БД; откат — убрать поле. - Клиент, этапами: (a) источник MediaStore + смешанный список + режим A; (b) загрузка/дедуп + режим B; (c) разгрузка + EXIF-метка + режим C; (d) возврат оригиналов и переходы режимов.
- Room-миграция под индекс синхронизации и провенанс.
- Откат: перевод альбомов в режим A не разрушителен (серверное остаётся); серверное изменение аддитивно.
Open Questions
- Конкретное поле для метки (
UserCommentvs XMP) и формат значения — решается при реализации, не влияет на поведение. - Частота периодической фоновой синхронизации и правила пробуждения — тюнинг.
- Нужен ли
ACCESS_MEDIA_LOCATIONдля неотредактированной геолокации при локальной генерации превью — можно уточнить позже.