# 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` при первом же проходе синхронизации.