Files
iu 98aaa3156a chore(openspec): архивировать add-orphaned-preview-handling и синхронизировать спеки
Новое требование в phone-album-sync о потере/возврате серверной копии у
разгруженного превью, требование в android-client о метке потерянного элемента
и действии удаления с устройства.
2026-10-16 14:13:01 +03:00

9.8 KiB
Raw Permalink Blame History

Design

Context

См. proposal.md — Why. Ограничения, важные для подхода:

  • Источник правды о связи «элемент устройства → серверное фото» — локальный индекс device_assets (serverPhotoId, contentHash) плюс метка провенанса в EXIF разгруженного превью.
  • SyncCoordinator.reconcile уже получает живой набор фото целевого альбома (serverChecksums → livePhotoIds) на каждый проход; отдельный запрос не нужен.
  • SyncPlanner.plan сейчас намеренно пропускает записи PREVIEW в цикле загрузки, чтобы превью не уехало как оригинал.
  • LocalAssetState персистится строкой в Room; GallerySyncPolicy.decide — when по этому enum, поэтому новое значение компилятор заставит обработать везде.
  • Фото события серверного альбома есть (AlbumEvent.PhotoDeleted), но экран альбома устройства на поток серверного альбома не подписан.

Goals / Non-Goals

Goals:

  • Замечать, что у разгруженного превью пропала серверная копия, и отражать это состоянием и меткой.
  • Дать пользователю убрать такой элемент с устройства.
  • Восстанавливать связь/заливать заново, когда содержимое возвращается.

Non-Goals:

  • Не менять поведение зеркального «серверного представления» альбома и других устройств (см. требование «Отражение серверных изменений на устройстве»).
  • Не различать «удалили из альбома» и «переместили в другой альбом сервера» (см. решение 3).
  • Не трогать серверный API, схему БД и серверные миграции.

Decisions

1. Отдельное состояние ORPHANED, а не флаг

Решение: добавить LocalAssetState.ORPHANED — локально превью, серверного фото в целевом альбоме нет.

Почему: состояние уже модель переходов (LOCAL/UPLOADED/AWAITING_FREE/PREVIEW/ SERVER_ONLY), а GallerySyncPolicy.decide — исчерпывающий when, так что новый случай нельзя забыть. Флаг пришлось бы сверять во всех тех же точках вручную.

Альтернатива: булев serverCopyLost рядом с state = PREVIEW — меньше правок enum, но плодит комбинации состояния и флага и не защищён компилятором.

2. Детекция при проверке синхронизации, а не по SSE

Решение: в reconcile сверять index.serverPhotoId с livePhotoIds: отсутствие → ORPHANED, присутствие → снять пометку. Тот же код исполняется при открытии альбома, старте и фоне (SyncCheck), поэтому покрывает оффлайн и пропущенные события.

Альтернатива: подписать экран альбома устройства на поток серверного альбома и реагировать на photo.deleted сразу. Даёт мгновенность, но только при открытом экране и живом SSE, требует новой связи между потоком и device_assets, и всё равно не снимает необходимость reconcile-проверки. Можно добавить позже поверх.

3. Не различать «удалили» и «переместили» (v1)

Решение: если фото нет в целевом альбоме — элемент считается потерявшим копию, даже если на сервере то же содержимое лежит в другом альбоме. При возврате оригинала заливка выполняется.

Почему: serverChecksums ограничен целевым альбомом; различение требует кросс-альбомного поиска по контрольной сумме на сервере. Для v1 простота и предсказуемость важнее; цена — возможный дубликат, если фото именно переместили, а оригинал вернулся на устройство.

Альтернатива: перед заливкой искать hash по всем альбомам сервера и связывать вместо загрузки. Дороже и усложняет reconcile; оставлено на будущее.

4. Планировщик: ORPHANED пропускается как PREVIEW

Решение: в SyncPlanner.plan (цикл загрузки) пропускать и ORPHANED, и PREVIEW. GallerySyncPolicy.decide для ORPHANED возвращает NONE во всех режимах, RestoreManager.restoreTargets его не берёт.

Почему: превью — не оригинал, его нельзя ни заливать как оригинал, ни восстанавливать с сервера (там пусто). Иначе получаем ложную загрузку или бесконечный RESTORE.

Альтернатива: хранить ORPHANED как PREVIEW с нулевым serverPhotoId — теряется originalHash/serverPhotoId, нужные для возврата и обратной пометки.

5. Возврат оригинала — по исходной контрольной сумме

Решение: повторная заливка срабатывает, когда hash из индекса/метки (originalHash) снова присутствует среди локальных ассетов альбома или в другом альбоме устройства (hashesOutside). Заливка идёт обычным путём (новое серверное фото), в режиме C элемент снова разгружается.

Почему: hash уже хранится и переживает переустановку (метка EXIF), а hashesOutside уже считает содержимое в других альбомах.

Альтернатива: реагировать только на новый файл в том же альбоме — не поймает копию, лежащую в другом альбоме устройства.

6. Удаление с устройства — через системное согласие

Решение: действие «Удалить с устройства» удаляет превью-файл и запись индекса, переиспользуя существующий путь delete-consent (SyncRepository.deleteConsent, MediaStore.createDeleteRequest), к серверу не обращается.

Почему: превью записано поверх элемента галереи, которым приложение не владеет, поэтому тихое удаление невозможно; это тот же механизм, что и у удаления альбома.

Risks / Trade-offs

  • [Дубликат при перемещении фото на сервере] → принято в v1 (решение 3); при необходимости добавляется кросс-альбомная проверка hash перед заливкой.
  • [Возврат оригинала не ловится, если hash не посчитался] → contentHash может быть null; тогда пометка и возврат опираются на провенанс-метку, а элемент остаётся ORPHANED до ручного удаления.
  • [Ошибка детекции при частичной загрузке списка сервера] → serverChecksums следует за пагинацией; если запрос неполный, возможна ложная пометка. Митигация: пометка снимается при следующем успешном проходе, файл не удаляется автоматически.
  • [Забытые when по enum] → компилятор ловит исчерпывающие when; остальные сравнения со строкой состояния — ручные, их покрывают тесты.

Migration Plan

Схема БД и серверный API не меняются. Значение ORPHANED появляется только для целевого набора условий; ранее помеченных как PREVIEW элементов это не касается, пока серверное фото живо. Откат — удаление значения из enum и обработчиков; данные в Room (строки состояний) не мигрируют. Существующие разгруженные превью без серверной копии получат ORPHANED при первом же проходе синхронизации.