Новое требование в phone-album-sync о потере/возврате серверной копии у разгруженного превью, требование в android-client о метке потерянного элемента и действии удаления с устройства.
9.8 KiB
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 при первом же проходе синхронизации.