Files
ds-galo/openspec/changes/phone-album-two-way-sync/design.md
T
iu d17f62f676 chore(openspec): предложить phone-album-two-way-sync и обновить спеку phone-album-sync
Новое изменение: двусторонний миррор альбома устройства и серверного альбома
(материализация серверных фото, признак происхождения, двусторонние удаления с
явным намерением, устойчивое выключение, первый проход, n:1). Purpose основной
спеки phone-album-sync обновлён под двустороннюю синхронизацию.

Change проходит `openspec validate --strict`.
2026-10-16 23:19:08 +03:00

18 KiB

Design

Context

Мотивацию см. в proposal.md, требования — в дельтах specs/. Здесь текущее состояние, задающее подход.

  • Движок синхронизации (android/.../data/sync/*) построен на локальном индексе Room: SyncedAlbumEntity (связь «альбом устройства → серверный», режим) и DeviceAssetEntity (ассет: assetKey, contentHash, serverPhotoId, state). Изменения считаются диффом «MediaStore сейчас ↔ индекс»; серверные фото читаются как checksum → photoId.
  • «Своя загрузка» сегодня = наличие serverPhotoId в индексе. Иного признака владения нет; провенанс-метка (ProvenanceStamp) пишет photo, hash, mode, asset, но не различает «загрузил я» и «скачал я».
  • Сервер уже отдаёт всё нужное: preview_url/medium_url/original_url, ?since= для дельты, мягкое удаление (SetDeleted) и восстановление (Restore) из корзины. Изменений API не требуется.
  • Файлы, записанные приложением в MediaStore (превью, постеры), ему принадлежат и изменяются/удаляются без системного диалога; чужие файлы (снимки пользователя) требуют createWriteRequest/createDeleteRequest.

Goals / Non-Goals

Goals:

  • Симметричный состав альбома: набор медиа на устройстве и на сервере совпадает, изменения состава ходят в обе стороны, режим задаёт только локальную полноту (B — оригиналы, C — превью).
  • Явное разграничение происхождения элемента, пригодное для политики удалений, разгрузки и восстановления после переустановки.
  • Идемпотентность и возобновляемость: надгробия против повторной материализации и эха, отсутствие дублей при гонках.
  • Безопасность: удаление элемента не приводит к безвозвратной потере единственной копии без системного согласия и без возможности отката через корзину сервера.

Non-Goals:

  • Серверная модель устройств/сессий синхронизации и разрешение конфликтов владения между пользователями; изменения схемы БД сервера.
  • Транскодирование видео на устройстве; версионирование содержимого.
  • Синхронизация вложенных папок/облачных «плейсхолдеров» сверх того, что отдаёт MediaStore.

Decisions

1. Состав зеркалится, режим задаёт локальную полноту

Единица синхронизации — по-прежнему bucket MediaStore; но связанная пара трактуется как один набор медиа. mode перестаёт влиять на состав и влияет только на то, чем представлен элемент на устройстве: B — оригинал, C — превью (видео — постер).

Альтернатива: оставить одностороннее содержимое (устройство — источник) и отражать сервер только метаданными. Отклонено: не даёт пользовательского ожидания «синхронизированный альбом — один и тот же набор». Альтернатива: отдельный «альбом-зеркало» 1:1 вместо связи с существующим. Отклонено: ломает уже принятый выбор цели и n:1.

2. Признак происхождения вместо «есть serverPhotoId»

DeviceAssetEntity получает явный origin (OWN | MIRROR). Политика удалений, разгрузка, дедуп и восстановление читают его, а не наличие серверной ссылки. Провенанс-метка получает тот же признак, чтобы SyncRecovery после переустановки не назвал скачанное «своим».

Альтернатива: выводить владение из uploadedAt != null или из contentHash — ненадёжно после переустановки и при дедупликации. Отклонено.

3. Журнал удалений (надгробия)

Локальное удаление элемента записывается как надгробие (photoId, момент, состояние «ожидает серверного удаления»). Проход не материализует элемент, пока надгробие не снято подтверждением сервера или не истекло. Серверное photo.deleted идемпотентно: повтор ничего не меняет.

Альтернатива: полагаться на дифф без журнала. Отклонено: между локальным удалением и серверным DELETE элемент ещё виден на сервере и был бы скачан заново.

4. Двустороннее удаление с опорой на права и корзину

Удаление элемента → DELETE /api/photos/:id (мягко, в корзину). Серверное удаление → снятие локального элемента. Для файла, снятого на устройстве и не принадлежащего приложению, снятие требует системного согласия (createDeleteRequest); для файлов, записанных приложением, — без диалога. Отказ сервера (403/нет прав) не роняет проход: операция отмечается неуспешной и сообщается пользователю, остальные действия прохода выполняются.

Для материализованного (серверного) элемента намерением удалить считается только явное удаление в приложении: тогда ставится надгробие и фото уходит на сервере. Если локальная копия просто пропала (очистка галереи, перемещение файла), клиент фото на сервере не трогает, а материализует заново — иначе потеря собственной копии приводила к удалению серверных оригиналов (наблюдалось на практике: 101 фото в корзине).

Альтернатива: «удалять только своё» (текущее). Отклонено по решению продукта. Альтернатива: удалять серверное фото при любом отсутствии локальной копии. Отклонено: приводило к потере серверных данных. Альтернатива: беззвучно стирать локальный файл при серверном удалении. Отклонено: для чужих снимков нужно согласие ОС.

5. Конвейер материализации

Новый загрузчик тянет объект по подписанному URL (original_url в B, preview_url/medium_url в C; для видео — постер) и вставляет файл в RELATIVE_PATH бакета альбома устройства с уникальным именем, DATE_TAKEN из taken_at и меткой провенанса. Запись идёт через существующий механизм записи превью (аналогично OffloadManager). Дедуп: перед вставкой проверяется, нет ли содержимого на устройстве (contentHash/серверный checksum) — тогда элемент связывается, а файл не создаётся.

Альтернатива: хранить зеркала только в приватном кэше. Отклонено продуктом: зеркало обязано быть видно в системной галерее.

6. Обнаружение новых серверных фото и первый проход

Дельта состава берётся из списка фото целевого альбома (?since=/курсор) и сопоставляется с индексом/надгробиями. Первый проход ограничен сохранённым watermark-ом: по умолчанию материализуется только новое после включения; «скачать всё» — явный выбор, задающий watermark в начало. Скачивание подчиняется ограничению «только Wi-Fi».

Альтернатива: всегда материализовать весь состав. Отклонено: включение синхронизации на полном серверном альбоме неожиданно заливает гигабайты.

7. Несколько источников в одну цель (n:1)

Серверное фото материализуется ровно в один бакет — выбранный при включении синхронизации (для единственной связи это её бакет). Выбор сохраняется в индексе. Остальные связанные бакеты не получают дубликат.

Альтернатива: материализовать во все связанные бакеты. Отклонено: дубликат в галерее и кратный трафик.

8. Порядок и идемпотентность прохода

Проход сериализован (существующий мьютекс): сначала серверные события/дельты применяются к индексу (включая надгробия и признак происхождения), затем считаются действия, затем выполняются. Материализация — операция с устойчивым ключом (photoId + бакет), чтобы повтор не создавал второй файл.

9. Явное выключение синхронизации устойчиво к восстановлению происхождения

Метка происхождения в разгруженных превью нужна для восстановления связи после переустановки, но она же способна «воскресить» связь после того, как пользователь явно выключил синхронизацию (или отвязал альбом): фоновый проход вызывает восстановление и создаёт связь заново. Поэтому выключение записывается явным признаком в app-only метаданные альбома (device_album_meta.syncDisabledAt). Восстановление происхождения пропускает такой альбом; повторное включение синхронизации очищает признак. После настоящей переустановки данные приложения пусты, признака нет, и восстановление работает как раньше.

Альтернатива: не удалять связь, а держать её в режиме выключено. Отклонено: связанный серверный альбом остаётся скрытым в списке, хотя синхронизация выключена. Альтернатива: стирать метки из превью при выключении. Отклонено: требует перезаписи всех превью и ломает восстановление, если пользователь оставил превью вместо возврата оригиналов.

10. Дренирующая очередь загрузки и стоимость прохода

Доставка на сервер не создаёт задание на каждый файл: одна WorkManager-задача (galo-upload-drain) берёт из Room все ожидающие загрузки и выполняет их последовательно (по одному файлу на HTTP-запрос, как требует сервер). Иначе JobScheduler на большом альбоме перестаёт запускать оставшиеся задания, и заливка не доходит до конца. Foreground-уведомление выставляется best-effort (ОС может запретить его из фона — загрузка всё равно идёт).

Проход синхронизации не должен перечитывать и перехэшировать всё на каждое событие: наблюдатель MediaStore дебаунсится, а sha256 переиспользуется для файлов с неизменными (size, dateModified). Список серверных фото читается целиком (дельта ?since= не сообщает об удалениях, без которых нельзя чистить локальные зеркала), но при неудачном чтении проход пропускается, а не трактует альбом как пустой — иначе удаляются локальные зеркала.

Risks / Trade-offs

  • [В C удаление превью стирает серверный оригинал] → серверное удаление мягкое (корзина); кроме того, файлы превью принадлежат приложению, а чужие — требуют согласия; в UI предусмотреть откат/уведомление.
  • [Сторонняя галерея/чистильщик удаляет превью, и это трактуется как удаление] → надгробие не даёт немедленной материализации заново; откат через корзину сервера.
  • [Материализация загрязняет бакет «Camera»] → осознанный выбор продукта; уникальные имена, метка происхождения, дата съёмки; возможность отвязать и оставить файлы обычными.
  • [Трафик и место при включении на полном альбоме] → watermark «только новое» по умолчанию, «скачать всё» — явно, Wi-Fi-ограничение.
  • [Эхо и гонки удалений/материализации] → надгробия + идемпотентность + сериализация.
  • [После переустановки метка может быть утрачена] → элемент считается неопознанным: не удаляется и не загружается повторно (существующее безопасное поведение).
  • [Отказ сервера при удалении чужого фото] → проход не падает; операция сообщается.
  • [Выключение синхронизации отменяется восстановлением происхождения] → явный признак syncDisabledAt блокирует восстановление до повторного включения; закрыто тестом moveToOffStaysOffAfterRecovery.

Migration Plan

  1. Room-миграция: добавить origin (значение по умолчанию OWN для существующих строк), таблицу надгробий и watermark первого прохода. Существующие связи не меняют поведение.
  2. Загрузчик материализации и расширение провенанс-метки признаком происхождения.
  3. Планировщик: состав/удаления в обе стороны, надгробия, права; тесты на идемпотентность.
  4. UI: сетка альбома устройства включает скачанные элементы, обновляется по проходу.
  5. Откат: перевод связи в режим A останавливает двусторонность; серверные фото и уже скачанные файлы сохраняются, ничего не удаляется.