diff --git a/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/.openspec.yaml b/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/.openspec.yaml new file mode 100644 index 0000000..0cb4765 --- /dev/null +++ b/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-16 diff --git a/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/design.md b/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/design.md new file mode 100644 index 0000000..c49a71e --- /dev/null +++ b/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/design.md @@ -0,0 +1,130 @@ +# 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` при первом же проходе синхронизации. diff --git a/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/proposal.md b/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/proposal.md new file mode 100644 index 0000000..d04c49e --- /dev/null +++ b/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/proposal.md @@ -0,0 +1,55 @@ +# Proposal + +## Why + +В режиме переноса (C) на устройстве остаётся только превью, а оригинал живёт на +сервере. Если серверное фото исчезает из целевого альбома (удалено с другого +устройства, из web или из экрана серверного альбома), приложение этого не +замечает: локальный индекс продолжает держать `serverPhotoId`, элемент +показывается с обычным значком облака, а при открытии оригинал бесконечно не +загружается. Пользователь не видит, что связи с сервером больше нет, и не может +этот элемент убрать или перезалить. + +## What Changes + +- Новое локальное состояние ассета **ORPHANED**: превью на устройстве есть, а + серверного фото в целевом альбоме больше нет. +- Определение состояния при проверке синхронизации: `serverPhotoId` элемента + сверяется с живыми фото альбома; отсутствие переводит элемент в `ORPHANED`, + повторное появление — обратно в `PREVIEW`. +- Метка элемента в сетке: для `ORPHANED` показывается перечёркнутое облако + вместо обычного. +- Действие `ORPHANED`-элемента — «Удалить с устройства»: удаляет превью-файл и + запись индекса, сервер не затрагивается (его копии уже нет). Серверные + атрибуты (теги, обложка, серверное удаление) для такого элемента недоступны. +- Возврат оригинала: если содержимое с исходным hash снова появляется на + телефоне (в этом же альбоме или в другом альбоме устройства), элемент + заливается заново; в режиме C он снова проходит обычный цикл разгрузки. +- Автоматическое поведение остаётся консервативным: файл-превью не удаляется и + не перезаливается без участия пользователя, превью не выдаётся за оригинал. + +## Capabilities + +### New Capabilities + +Нет. + +### Modified Capabilities + +- `phone-album-sync`: отражение серверных изменений распространяется на + разгруженные превью (элемент становится `ORPHANED`, файл сохраняется), добавляется + действие удаления с устройства и повторная заливка вернувшегося оригинала. +- `android-client`: метка перенесённого элемента получает состояние + «перечёркнутое облако», а шит элемента — действие «Удалить с устройства» для + потерявших серверную копию. + +## Impact + +- Android-клиент: `domain/model/Sync.kt` (`LocalAssetState`), + `domain/sync/GallerySyncPolicy.kt`, `data/sync/SyncPlan.kt`, + `data/sync/SyncCoordinator.kt`, `data/sync/SyncCheck.kt`, + `ui/devicealbum/DeviceAlbumViewModel.kt`, `ui/devicealbum/DeviceAlbumScreen.kt`, + `domain/attributes/PhotoAttributeSource.kt`. +- Удаление превью — через MediaStore delete-consent (элемент галереи не + принадлежит приложению), переиспользуется существующий путь согласия. +- Серверный API и схема БД не меняются. diff --git a/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/specs/android-client/spec.md b/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/specs/android-client/spec.md new file mode 100644 index 0000000..e58eda1 --- /dev/null +++ b/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/specs/android-client/spec.md @@ -0,0 +1,41 @@ +# Spec Delta + +## MODIFIED Requirements + +### Requirement: Экран альбома устройства и настройки синхронизации + +Приложение SHALL открывать альбом устройства с сеткой медиа и SHALL предоставлять +настройки синхронизации этого альбома по свайпу вправо. Настройки SHALL содержать +только выбор режима синхронизации. + +#### Scenario: Открытие альбома устройства + +- **WHEN** пользователь выбирает альбом устройства в списке +- **THEN** открывается сетка медиа этого альбома + +#### Scenario: Настройки по свайпу + +- **WHEN** пользователь свайпает вправо в открытом альбоме устройства +- **THEN** открываются настройки синхронизации с выбором режима и только им + +#### Scenario: Метка перенесённого элемента + +- **WHEN** оригинал элемента разгружен на сервер в режиме переноса +- **THEN** в сетке на элементе показывается значок облака + +#### Scenario: Метка потерянного элемента + +- **WHEN** серверной копии разгруженного элемента больше нет в целевом альбоме +- **THEN** в сетке на элементе показывается перечёркнутый значок облака + +## ADDED Requirements + +### Requirement: Действия потерявшего серверную копию превью + +Шит потерявшего серверную копию элемента SHALL предлагать действие «Удалить с +устройства» и SHALL NOT показывать серверные атрибуты и действия. + +#### Scenario: Только удаление с устройства + +- **WHEN** пользователь открывает атрибуты потерявшего серверную копию элемента +- **THEN** доступно действие «Удалить с устройства», а серверные действия отсутствуют diff --git a/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/specs/phone-album-sync/spec.md b/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/specs/phone-album-sync/spec.md new file mode 100644 index 0000000..b30a7b7 --- /dev/null +++ b/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/specs/phone-album-sync/spec.md @@ -0,0 +1,66 @@ +# Spec Delta + +## ADDED Requirements + +### Requirement: Обнаружение потери серверной копии превью + +При проверке синхронизации приложение SHALL сопоставлять серверное фото каждого +разгруженного превью с фото целевого альбома и при его отсутствии SHALL помечать +элемент как потерявший серверную копию, сохраняя превью и провенанс. + +#### Scenario: Копии больше нет + +- **WHEN** серверное фото разгруженного превью отсутствует в целевом альбоме +- **THEN** приложение помечает элемент как потерявший серверную копию + +#### Scenario: Превью не трогается + +- **WHEN** элемент помечен как потерявший серверную копию +- **THEN** превью-файл на устройстве сохраняется и не загружается повторно как оригинал + +### Requirement: Восстановление связи при возврате серверного фото + +При проверке синхронизации приложение SHALL снимать пометку с элемента, если его +серверное фото снова присутствует в целевом альбоме. + +#### Scenario: Фото вернулось на сервер + +- **WHEN** серверное фото помеченного элемента снова присутствует в целевом альбоме +- **THEN** элемент возвращается к обычному разгруженному состоянию с серверной копией + +### Requirement: Удаление потерянного превью с устройства + +Приложение SHALL позволять удалить потерявшее серверную копию превью с устройства; +удаление SHALL удалять превью-файл и запись локального индекса и SHALL NOT +обращаться к серверу, поскольку копии там уже нет. + +#### Scenario: Удаление с устройства + +- **WHEN** пользователь удаляет потерявшее серверную копию превью с устройства +- **THEN** превью-файл и запись локального индекса удаляются, а сервер не затрагивается + +#### Scenario: Согласие системы + +- **WHEN** превью-файл не принадлежит приложению +- **THEN** удаление выполняется после системного согласия на удаление + +### Requirement: Повторная заливка вернувшегося оригинала + +Если содержимое с исходной контрольной суммой потерявшего копию элемента снова +присутствует на устройстве, приложение SHALL загрузить его на сервер как новую +загрузку этого элемента. + +#### Scenario: Вернулся в тот же альбом + +- **WHEN** оригинал с исходной контрольной суммой снова появился в альбоме устройства +- **THEN** приложение загружает его в целевой серверный альбом + +#### Scenario: Вернулся в другой альбом + +- **WHEN** содержимое с исходной контрольной суммой присутствует в другом альбоме устройства +- **THEN** приложение загружает его в целевой серверный альбом + +#### Scenario: Режим переноса + +- **WHEN** загрузка вернувшегося оригинала завершена в режиме переноса +- **THEN** элемент снова проходит обычную разгрузку в превью diff --git a/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/tasks.md b/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/tasks.md new file mode 100644 index 0000000..174b25a --- /dev/null +++ b/openspec/changes/archive/2026-10-16-add-orphaned-preview-handling/tasks.md @@ -0,0 +1,34 @@ +# Tasks + +## 1. Доменное состояние и политика + +- [x] 1.1 Добавить `LocalAssetState.ORPHANED` в `android/.../domain/model/Sync.kt` и проверить, что `LocalAssetState.from`/Room-строка корректно читают значение (unit-тест на round-trip состояния) +- [x] 1.2 Обработать `ORPHANED` в `GallerySyncPolicy.decide`: `NONE` во всех режимах, без `RESTORE`; проверить покрытием всех сочетаний mode × state в `GallerySyncPolicyTest` +- [x] 1.3 Пропускать `ORPHANED` в цикле загрузки `SyncPlanner.plan` наравне с `PREVIEW` (не заливать превью как оригинал); проверить unit-тестом `SyncPlannerTest` +- [x] 1.4 Исключить `ORPHANED` из `RestoreManager.restoreTargets`; проверить unit-тестом `RestoreManagerTest` + +## 2. Детекция и переходы синхронизации + +- [x] 2.1 В `SyncCoordinator.reconcile` помечать `ORPHANED` записи индекса, чей `serverPhotoId` отсутствует в `livePhotoIds`; проверить unit-тестом `SyncCoordinatorTest` +- [x] 2.2 В `reconcile` снимать пометку `ORPHANED`, если `serverPhotoId` снова в `livePhotoIds`; проверить unit-тестом `SyncCoordinatorTest` +- [x] 2.3 Реализовать повторную заливку вернувшегося оригинала: считать `ORPHANED`, если hash из индекса/`hashesOutside` снова присутствует на устройстве, и ставить обычную загрузку; проверить unit-тестом планировщика/координатора +- [x] 2.4 Убедиться, что в режиме C загруженный обратно оригинал снова разгружается обычным проходом; проверить unit-тестом координатора + +## 3. UI: метка и действия + +- [x] 3.1 В `DeviceAlbumViewModel` отдавать набор `orphanedMediaIds`, а `serverOriginalsByMediaId`/`transferredMediaIds` должны исключать `ORPHANED`; проверить unit-тестом helper-функции +- [x] 3.2 Показать перечёркнутое облако (`Icons.Filled.CloudOff`) для `ORPHANED` в сетке `DeviceAlbumScreen` с отдельным `testTag`; проверить Compose-тестом маркера +- [x] 3.3 Добавить источник `PhotoAttributeSource.Orphaned` в `PhotoAttributeResolver.resolve`; проверить unit-тестом резолвера (приоритет над `Server` при отсутствии копии) +- [x] 3.4 Для `ORPHANED`-шита показать только действие «Удалить с устройства» без серверных атрибутов; проверить Compose-тестом шита +- [x] 3.5 Реализовать удаление превью с устройства через существующий delete-consent и удаление записи индекса, без запросов к серверу; проверить instrumented-тестом (файл и индекс удалены) + +## 4. Интеграционная проверка + +- [ ] 4.1 E2E: разгрузить в режиме C, удалить серверное фото, прогнать `reconcile` и убедиться, что элемент стал `ORPHANED` с перечёркнутым облаком; проверить прогоном `DeviceSyncE2ETest` +- [ ] 4.2 E2E: вернуть оригинал на устройство и убедиться, что элемент заливается заново и снова разгружается в C; проверить прогоном `DeviceSyncE2ETest` +- [x] 4.3 Прогнать `cd android && ./gradlew test` и `cd android && ./gradlew :app:assembleDebug`; убедиться, что сборка и unit-тесты зелёные + +## Workflow follow-up + +- Архивировать change после выполнения задач и прохождения ревью по правилам проекта. +- Проверить итоговый результат после архивации. diff --git a/openspec/specs/android-client/spec.md b/openspec/specs/android-client/spec.md index bf56509..4f6fa39 100644 --- a/openspec/specs/android-client/spec.md +++ b/openspec/specs/android-client/spec.md @@ -127,6 +127,11 @@ Android-клиент для self-hosted galo: настройка подключ - **WHEN** оригинал элемента разгружен на сервер в режиме переноса - **THEN** в сетке на элементе показывается значок облака +#### Scenario: Метка потерянного элемента + +- **WHEN** серверной копии разгруженного элемента больше нет в целевом альбоме +- **THEN** в сетке на элементе показывается перечёркнутый значок облака + ### Requirement: Доступ к альбомам устройства Приложение SHALL запрашивать доступ к медиа устройства, нужный для перечисления @@ -698,3 +703,13 @@ SHALL показывать значками расположение содер #### Scenario: Сохранение выбора - **WHEN** пользователь перезапускает приложение - **THEN** список открывается с сохранёнными вариантом и направлением + +### Requirement: Действия потерявшего серверную копию превью + +Шит потерявшего серверную копию элемента SHALL предлагать действие «Удалить с +устройства» и SHALL NOT показывать серверные атрибуты и действия. + +#### Scenario: Только удаление с устройства + +- **WHEN** пользователь открывает атрибуты потерявшего серверную копию элемента +- **THEN** доступно действие «Удалить с устройства», а серверные действия отсутствуют diff --git a/openspec/specs/phone-album-sync/spec.md b/openspec/specs/phone-album-sync/spec.md index 035d454..c3094dc 100644 --- a/openspec/specs/phone-album-sync/spec.md +++ b/openspec/specs/phone-album-sync/spec.md @@ -240,3 +240,66 @@ API, а изменение содержимого SHALL трактовать к - **WHEN** пользователь открывает элемент, чей оригинал разгружен на сервер - **THEN** приложение подгружает оригинал с сервера для полноэкранного просмотра + +### Requirement: Обнаружение потери серверной копии превью + +При проверке синхронизации приложение SHALL сопоставлять серверное фото каждого +разгруженного превью с фото целевого альбома и при его отсутствии SHALL помечать +элемент как потерявший серверную копию, сохраняя превью и провенанс. + +#### Scenario: Копии больше нет + +- **WHEN** серверное фото разгруженного превью отсутствует в целевом альбоме +- **THEN** приложение помечает элемент как потерявший серверную копию + +#### Scenario: Превью не трогается + +- **WHEN** элемент помечен как потерявший серверную копию +- **THEN** превью-файл на устройстве сохраняется и не загружается повторно как оригинал + +### Requirement: Восстановление связи при возврате серверного фото + +При проверке синхронизации приложение SHALL снимать пометку с элемента, если его +серверное фото снова присутствует в целевом альбоме. + +#### Scenario: Фото вернулось на сервер + +- **WHEN** серверное фото помеченного элемента снова присутствует в целевом альбоме +- **THEN** элемент возвращается к обычному разгруженному состоянию с серверной копией + +### Requirement: Удаление потерянного превью с устройства + +Приложение SHALL позволять удалить потерявшее серверную копию превью с устройства; +удаление SHALL удалять превью-файл и запись локального индекса и SHALL NOT +обращаться к серверу, поскольку копии там уже нет. + +#### Scenario: Удаление с устройства + +- **WHEN** пользователь удаляет потерявшее серверную копию превью с устройства +- **THEN** превью-файл и запись локального индекса удаляются, а сервер не затрагивается + +#### Scenario: Согласие системы + +- **WHEN** превью-файл не принадлежит приложению +- **THEN** удаление выполняется после системного согласия на удаление + +### Requirement: Повторная заливка вернувшегося оригинала + +Если содержимое с исходной контрольной суммой потерявшего копию элемента снова +присутствует на устройстве, приложение SHALL загрузить его на сервер как новую +загрузку этого элемента. + +#### Scenario: Вернулся в тот же альбом + +- **WHEN** оригинал с исходной контрольной суммой снова появился в альбоме устройства +- **THEN** приложение загружает его в целевой серверный альбом + +#### Scenario: Вернулся в другой альбом + +- **WHEN** содержимое с исходной контрольной суммой присутствует в другом альбоме устройства +- **THEN** приложение загружает его в целевой серверный альбом + +#### Scenario: Режим переноса + +- **WHEN** загрузка вернувшегося оригинала завершена в режиме переноса +- **THEN** элемент снова проходит обычную разгрузку в превью