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

17 KiB
Raw Permalink Blame History

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 для неотредактированной геолокации при локальной генерации превью — можно уточнить позже.