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

131 lines
9.8 KiB
Markdown
Raw 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` — 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` при первом же проходе синхронизации.