chore(openspec): архивировать add-orphaned-preview-handling и синхронизировать спеки

Новое требование в phone-album-sync о потере/возврате серверной копии у
разгруженного превью, требование в android-client о метке потерянного элемента
и действии удаления с устройства.
This commit is contained in:
iu
2026-10-16 14:13:01 +03:00
parent 5cfa045171
commit 98aaa3156a
8 changed files with 406 additions and 0 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-16
@@ -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` при первом же проходе синхронизации.
@@ -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 и схема БД не меняются.
@@ -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** доступно действие «Удалить с устройства», а серверные действия отсутствуют
@@ -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** элемент снова проходит обычную разгрузку в превью
@@ -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 после выполнения задач и прохождения ревью по правилам проекта.
- Проверить итоговый результат после архивации.
+15
View File
@@ -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** доступно действие «Удалить с устройства», а серверные действия отсутствуют
+63
View File
@@ -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** элемент снова проходит обычную разгрузку в превью