chore(openspec): архивировать add-orphaned-preview-handling и синхронизировать спеки
Новое требование в phone-album-sync о потере/возврате серверной копии у разгруженного превью, требование в android-client о метке потерянного элемента и действии удаления с устройства.
This commit is contained in:
@@ -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 и схема БД не меняются.
|
||||
+41
@@ -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** доступно действие «Удалить с устройства», а серверные действия отсутствуют
|
||||
+66
@@ -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 после выполнения задач и прохождения ревью по правилам проекта.
|
||||
- Проверить итоговый результат после архивации.
|
||||
Reference in New Issue
Block a user