chore(openspec): предложить phone-album-two-way-sync и обновить спеку phone-album-sync

Новое изменение: двусторонний миррор альбома устройства и серверного альбома
(материализация серверных фото, признак происхождения, двусторонние удаления с
явным намерением, устойчивое выключение, первый проход, n:1). Purpose основной
спеки phone-album-sync обновлён под двустороннюю синхронизацию.

Change проходит `openspec validate --strict`.
This commit is contained in:
iu
2026-10-16 23:19:08 +03:00
parent cd326c0ae6
commit d17f62f676
7 changed files with 643 additions and 3 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-16
@@ -0,0 +1,188 @@
# Design
## Context
Мотивацию см. в `proposal.md`, требования — в дельтах `specs/`. Здесь текущее состояние,
задающее подход.
- Движок синхронизации (`android/.../data/sync/*`) построен на локальном индексе Room:
`SyncedAlbumEntity` (связь «альбом устройства → серверный», режим) и `DeviceAssetEntity`
(ассет: `assetKey`, `contentHash`, `serverPhotoId`, `state`). Изменения считаются диффом
«MediaStore сейчас ↔ индекс»; серверные фото читаются как `checksum → photoId`.
- «Своя загрузка» сегодня = наличие `serverPhotoId` в индексе. Иного признака владения нет;
провенанс-метка (`ProvenanceStamp`) пишет `photo`, `hash`, `mode`, `asset`, но не различает
«загрузил я» и «скачал я».
- Сервер уже отдаёт всё нужное: `preview_url`/`medium_url`/`original_url`, `?since=` для
дельты, мягкое удаление (`SetDeleted`) и восстановление (`Restore`) из корзины. Изменений
API не требуется.
- Файлы, записанные приложением в MediaStore (превью, постеры), ему принадлежат и
изменяются/удаляются без системного диалога; чужие файлы (снимки пользователя) требуют
`createWriteRequest`/`createDeleteRequest`.
## Goals / Non-Goals
**Goals:**
- Симметричный состав альбома: набор медиа на устройстве и на сервере совпадает, изменения
состава ходят в обе стороны, режим задаёт только локальную полноту (B — оригиналы, C —
превью).
- Явное разграничение происхождения элемента, пригодное для политики удалений, разгрузки и
восстановления после переустановки.
- Идемпотентность и возобновляемость: надгробия против повторной материализации и эха,
отсутствие дублей при гонках.
- Безопасность: удаление элемента не приводит к безвозвратной потере единственной копии без
системного согласия и без возможности отката через корзину сервера.
**Non-Goals:**
- Серверная модель устройств/сессий синхронизации и разрешение конфликтов владения между
пользователями; изменения схемы БД сервера.
- Транскодирование видео на устройстве; версионирование содержимого.
- Синхронизация вложенных папок/облачных «плейсхолдеров» сверх того, что отдаёт MediaStore.
## Decisions
### 1. Состав зеркалится, режим задаёт локальную полноту
Единица синхронизации — по-прежнему bucket MediaStore; но связанная пара трактуется как один
набор медиа. `mode` перестаёт влиять на состав и влияет только на то, чем представлен элемент
на устройстве: B — оригинал, C — превью (видео — постер).
*Альтернатива:* оставить одностороннее содержимое (устройство — источник) и отражать сервер
только метаданными. Отклонено: не даёт пользовательского ожидания «синхронизированный альбом
— один и тот же набор». *Альтернатива:* отдельный «альбом-зеркало» 1:1 вместо связи с
существующим. Отклонено: ломает уже принятый выбор цели и n:1.
### 2. Признак происхождения вместо «есть serverPhotoId»
`DeviceAssetEntity` получает явный `origin` (`OWN` | `MIRROR`). Политика удалений, разгрузка,
дедуп и восстановление читают его, а не наличие серверной ссылки. Провенанс-метка получает
тот же признак, чтобы `SyncRecovery` после переустановки не назвал скачанное «своим».
*Альтернатива:* выводить владение из `uploadedAt != null` или из `contentHash` — ненадёжно
после переустановки и при дедупликации. Отклонено.
### 3. Журнал удалений (надгробия)
Локальное удаление элемента записывается как надгробие (`photoId`, момент, состояние
«ожидает серверного удаления»). Проход не материализует элемент, пока надгробие не снято
подтверждением сервера или не истекло. Серверное `photo.deleted` идемпотентно: повтор
ничего не меняет.
*Альтернатива:* полагаться на дифф без журнала. Отклонено: между локальным удалением и
серверным DELETE элемент ещё виден на сервере и был бы скачан заново.
### 4. Двустороннее удаление с опорой на права и корзину
Удаление элемента → `DELETE /api/photos/:id` (мягко, в корзину). Серверное удаление → снятие
локального элемента. Для файла, снятого на устройстве и не принадлежащего приложению,
снятие требует системного согласия (`createDeleteRequest`); для файлов, записанных
приложением, — без диалога. Отказ сервера (403/нет прав) не роняет проход: операция
отмечается неуспешной и сообщается пользователю, остальные действия прохода выполняются.
Для **материализованного** (серверного) элемента намерением удалить считается только явное
удаление в приложении: тогда ставится надгробие и фото уходит на сервере. Если локальная
копия просто пропала (очистка галереи, перемещение файла), клиент фото на сервере не
трогает, а материализует заново — иначе потеря собственной копии приводила к удалению
серверных оригиналов (наблюдалось на практике: 101 фото в корзине).
*Альтернатива:* «удалять только своё» (текущее). Отклонено по решению продукта.
*Альтернатива:* удалять серверное фото при любом отсутствии локальной копии. Отклонено:
приводило к потере серверных данных. *Альтернатива:* беззвучно стирать локальный файл при
серверном удалении. Отклонено: для чужих снимков нужно согласие ОС.
### 5. Конвейер материализации
Новый загрузчик тянет объект по подписанному URL (`original_url` в B, `preview_url`/`medium_url`
в C; для видео — постер) и вставляет файл в `RELATIVE_PATH` бакета альбома устройства с
уникальным именем, `DATE_TAKEN` из `taken_at` и меткой провенанса. Запись идёт через
существующий механизм записи превью (аналогично `OffloadManager`). Дедуп: перед вставкой
проверяется, нет ли содержимого на устройстве (`contentHash`/серверный checksum) — тогда
элемент связывается, а файл не создаётся.
*Альтернатива:* хранить зеркала только в приватном кэше. Отклонено продуктом: зеркало
обязано быть видно в системной галерее.
### 6. Обнаружение новых серверных фото и первый проход
Дельта состава берётся из списка фото целевого альбома (`?since=`/курсор) и сопоставляется с
индексом/надгробиями. Первый проход ограничен сохранённым watermark-ом: по умолчанию
материализуется только новое после включения; «скачать всё» — явный выбор, задающий watermark
в начало. Скачивание подчиняется ограничению «только Wi-Fi».
*Альтернатива:* всегда материализовать весь состав. Отклонено: включение синхронизации на
полном серверном альбоме неожиданно заливает гигабайты.
### 7. Несколько источников в одну цель (n:1)
Серверное фото материализуется ровно в один бакет — выбранный при включении синхронизации
(для единственной связи это её бакет). Выбор сохраняется в индексе. Остальные связанные
бакеты не получают дубликат.
*Альтернатива:* материализовать во все связанные бакеты. Отклонено: дубликат в галерее и
кратный трафик.
### 8. Порядок и идемпотентность прохода
Проход сериализован (существующий мьютекс): сначала серверные события/дельты применяются к
индексу (включая надгробия и признак происхождения), затем считаются действия, затем
выполняются. Материализация — операция с устойчивым ключом (photoId + бакет), чтобы повтор
не создавал второй файл.
### 9. Явное выключение синхронизации устойчиво к восстановлению происхождения
Метка происхождения в разгруженных превью нужна для восстановления связи после переустановки,
но она же способна «воскресить» связь после того, как пользователь явно выключил
синхронизацию (или отвязал альбом): фоновый проход вызывает восстановление и создаёт связь
заново. Поэтому выключение записывается явным признаком в app-only метаданные альбома
(`device_album_meta.syncDisabledAt`). Восстановление происхождения пропускает такой альбом;
повторное включение синхронизации очищает признак. После настоящей переустановки данные
приложения пусты, признака нет, и восстановление работает как раньше.
*Альтернатива:* не удалять связь, а держать её в режиме выключено. Отклонено: связанный
серверный альбом остаётся скрытым в списке, хотя синхронизация выключена. *Альтернатива:*
стирать метки из превью при выключении. Отклонено: требует перезаписи всех превью и ломает
восстановление, если пользователь оставил превью вместо возврата оригиналов.
### 10. Дренирующая очередь загрузки и стоимость прохода
Доставка на сервер не создаёт задание на каждый файл: одна WorkManager-задача
(`galo-upload-drain`) берёт из Room все ожидающие загрузки и выполняет их последовательно
(по одному файлу на HTTP-запрос, как требует сервер). Иначе JobScheduler на большом альбоме
перестаёт запускать оставшиеся задания, и заливка не доходит до конца. Foreground-уведомление
выставляется best-effort (ОС может запретить его из фона — загрузка всё равно идёт).
Проход синхронизации не должен перечитывать и перехэшировать всё на каждое событие:
наблюдатель MediaStore дебаунсится, а `sha256` переиспользуется для файлов с неизменными
`(size, dateModified)`. Список серверных фото читается целиком (дельта `?since=` не сообщает
об удалениях, без которых нельзя чистить локальные зеркала), но при **неудачном** чтении
проход пропускается, а не трактует альбом как пустой — иначе удаляются локальные зеркала.
## Risks / Trade-offs
- [В C удаление превью стирает серверный оригинал] → серверное удаление мягкое (корзина);
кроме того, файлы превью принадлежат приложению, а чужие — требуют согласия; в UI
предусмотреть откат/уведомление.
- [Сторонняя галерея/чистильщик удаляет превью, и это трактуется как удаление] → надгробие
не даёт немедленной материализации заново; откат через корзину сервера.
- [Материализация загрязняет бакет «Camera»] → осознанный выбор продукта; уникальные имена,
метка происхождения, дата съёмки; возможность отвязать и оставить файлы обычными.
- [Трафик и место при включении на полном альбоме] → watermark «только новое» по умолчанию,
«скачать всё» — явно, Wi-Fi-ограничение.
- [Эхо и гонки удалений/материализации] → надгробия + идемпотентность + сериализация.
- [После переустановки метка может быть утрачена] → элемент считается неопознанным: не
удаляется и не загружается повторно (существующее безопасное поведение).
- [Отказ сервера при удалении чужого фото] → проход не падает; операция сообщается.
- [Выключение синхронизации отменяется восстановлением происхождения] → явный признак
`syncDisabledAt` блокирует восстановление до повторного включения; закрыто тестом
`moveToOffStaysOffAfterRecovery`.
## Migration Plan
1. Room-миграция: добавить `origin` (значение по умолчанию `OWN` для существующих строк),
таблицу надгробий и watermark первого прохода. Существующие связи не меняют поведение.
2. Загрузчик материализации и расширение провенанс-метки признаком происхождения.
3. Планировщик: состав/удаления в обе стороны, надгробия, права; тесты на идемпотентность.
4. UI: сетка альбома устройства включает скачанные элементы, обновляется по проходу.
5. Откат: перевод связи в режим A останавливает двусторонность; серверные фото и уже
скачанные файлы сохраняются, ничего не удаляется.
@@ -0,0 +1,59 @@
# Proposal
## Why
Синхронизация альбома устройства сегодня односторонняя по содержимому: устройство отдаёт
байты, сервер возвращает только метаданные. Фото, добавленное в целевой серверный альбом из
веба или с другого устройства, на телефон не приезжает, а связанный серверный альбом ещё и
скрыт из списка — увидеть его в приложении нельзя. Пользователь ожидает, что
синхронизированный альбом — один и тот же набор медиа на телефоне и на сервере.
## What Changes
- Связанная пара «альбом устройства → серверный альбом» становится **двусторонним миррором**:
состав медиа совпадает, добавление и удаление ходят в обе стороны.
- **Материализация серверных фото на устройстве**: в режиме копий (B) скачивается оригинал, в
режиме переноса (C) — превью (для видео — постер). Файлы пишутся в галерею (MediaStore)
бакета альбома устройства и видны системной галерее.
- Вводится явный **признак происхождения** элемента (`own` — загружено этим клиентом,
`mirror` — скачано с сервера). Политика удалений, разгрузка и восстановление после
переустановки опираются на него, а не на наличие `serverPhotoId`.
- **Удаление в обе стороны**: удаление элемента на устройстве удаляет соответствующее фото на
сервере (мягко, в корзину); серверное удаление убирает локальный элемент. Удаление
принадлежащего иному источнику элемента на устройстве подчиняется правам сервера.
- **Журнал удалений (надгробия)**: локально удалённый элемент не скачивается заново, пока
серверное удаление не подтверждено; серверные события не дают эха.
- Восстановление серверного фото из корзины заново материализует элемент на устройстве.
- **Отвязка и выключение синхронизации** остаются неразрушающими: фото на сервере сохраняются,
уже скачанные файлы остаются в галерее как обычные.
- **Выключение синхронизации устойчиво**: восстановление происхождения по меткам превью не
возобновляет связь, пока пользователь не включит синхронизацию снова.
- **Первый проход**: выбор «скачать всё содержимое альбома» или «только новое с этого момента»;
ограничение «только Wi-Fi» распространяется и на скачивание.
- **BREAKING**: нет — поведение аддитивное, существующие связи продолжают работать.
## Capabilities
### New Capabilities
Нет — поведение относится к существующей способности синхронизации альбома устройства.
### Modified Capabilities
- `phone-album-sync`: двусторонний миррор — материализация серверных фото по режимам, признак
происхождения, надгробия, политика удалений в обе стороны, восстановление из корзины,
поведение при отвязке.
- `android-client`: сетка альбома устройства включает скачанные (серверные) элементы, а
связанный альбом читается как один двусторонний альбом.
## Impact
- `android/`: движок синхронизации (`data/sync/*` — `SyncPlan`, `SyncCoordinator`, `SyncCheck`,
`SyncRecovery`, `ProvenanceStamp`, `OffloadManager`, `RestoreManager`, `LocalAssetProvider`),
новый загрузчик серверных фото, запись в MediaStore без диалога согласия (файлы приложения),
Room-схема (признак происхождения, надгробия, watermark первого прохода), UI
(`DeviceAlbumScreen`/`DeviceAlbumViewModel`, `AlbumsScreen`/`AlbumListBuilder`,
`SyncRepository`).
- `backend/`: изменений API не требуется — `preview_url`/`medium_url`/`original_url`, `?since=`,
мягкое удаление и восстановление из корзины уже есть.
- Спеки: `phone-album-sync`, `android-client`.
@@ -0,0 +1,58 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Экран альбома устройства и настройки синхронизации
Приложение SHALL открывать альбом устройства с сеткой медиа и SHALL предоставлять
настройки синхронизации этого альбома по свайпу вправо. Настройки SHALL содержать
только выбор режима синхронизации. Сетка SHALL включать как локальные элементы, так и
скачанные с сервера при двусторонней синхронизации.
#### Scenario: Открытие альбома устройства
- **WHEN** пользователь выбирает альбом устройства в списке
- **THEN** открывается сетка медиа этого альбома
#### Scenario: Настройки по свайпу
- **WHEN** пользователь свайпает вправо в открытом альбоме устройства
- **THEN** открываются настройки синхронизации с выбором режима и только им
#### Scenario: Метка перенесённого элемента
- **WHEN** оригинал элемента разгружен на сервер в режиме переноса
- **THEN** в сетке на элементе показывается значок облака
#### Scenario: Метка потерянного элемента
- **WHEN** серверной копии разгруженного элемента больше нет в целевом альбоме
- **THEN** в сетке на элементе показывается перечёркнутый значок облака
#### Scenario: Скачанный элемент в сетке
- **WHEN** серверное фото материализовано на устройстве
- **THEN** элемент показывается в сетке альбома устройства наряду с локальными
## ADDED Requirements
### Requirement: Серверный состав в сетке альбома устройства
Сетка альбома устройства SHALL отражать состав целевого серверного альбома: новые
серверные фото SHALL появляться, а удалённые SHALL исчезать по мере синхронизации, без
ручного обновления.
#### Scenario: Новое фото с сервера
- **WHEN** в целевом серверном альбоме появилось новое фото, и синхронизация прошла
- **THEN** материализованный элемент появляется в сетке альбома устройства
#### Scenario: Фото удалено на сервере
- **WHEN** серверное фото удалено на сервере, и синхронизация прошла
- **THEN** соответствующий элемент исчезает из сетки альбома устройства
#### Scenario: Обновление открытого экрана
- **WHEN** экран альбома устройства открыт, и синхронизация изменила состав
- **THEN** сетка обновляется без ручного действия пользователя
@@ -0,0 +1,286 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Отражение серверных изменений на устройстве
Приложение SHALL приводить локальное представление целевого серверного альбома в
соответствие с его составом: новое серверное фото SHALL материализоваться на устройстве
(оригинал в режиме копий, превью в режиме переноса), переименования, теги и подписи SHALL
отражаться, а серверное удаление SHALL убирать локальный элемент. Удаление снятого на
устройстве оригинала SHALL учитывать права сервера и системное согласие.
#### Scenario: Новое с другого устройства
- **WHEN** другое устройство добавляет фото в целевой серверный альбом
- **THEN** приложение материализует его в альбоме устройства — оригинал в режиме копий, превью в режиме переноса
#### Scenario: Удалено на сервере
- **WHEN** серверное фото удалено на сервере
- **THEN** приложение убирает соответствующий локальный элемент, а для снятого на устройстве оригинала запрашивается системное согласие на удаление
### Requirement: Разгрузка оригиналов в режиме переноса
В режиме C после того, как оригинал элемента оказывается на сервере (загружен этим
клиентом или скачан при двусторонней синхронизации), приложение SHALL автоматически
заменять оригинал на устройстве превью, без отдельного действия на экране; видео SHALL
заменяться постером-кадром. При включении режима C приложение SHALL запрашивать широкий
доступ к файлам, чтобы замена выполнялась без системного диалога согласия.
#### Scenario: Разгрузка фото
- **WHEN** оригинал фото успешно загружен
- **THEN** файл в галерее автоматически заменяется превью с сохранением даты съёмки и места в альбоме
#### Scenario: Разгрузка скачанного оригинала
- **WHEN** альбом переключён в режим переноса, а оригинал элемента был скачан с сервера
- **THEN** оригинал автоматически заменяется превью
#### Scenario: Разгрузка видео
- **WHEN** оригинал видео успешно загружен
- **THEN** видео в галерее автоматически заменяется статичным постером-кадром, а элемент в приложении остаётся видео с оригиналом на сервере
#### Scenario: Доступ к файлам для разгрузки
- **WHEN** пользователь включает режим переноса
- **THEN** приложение запрашивает широкий доступ к файлам, и разгрузка выполняется без системного диалога согласия
### Requirement: Провенанс разгруженных превью
Разгруженные превью, постеры видео и скачанные с сервера элементы SHALL содержать
метаданные провенанса, включая признак происхождения (загружено клиентом или скачано с
сервера), достаточные для восстановления связи с серверным альбомом и принадлежности
загрузок после переустановки приложения.
#### Scenario: Восстановление после переустановки
- **WHEN** приложение переустановлено или его данные очищены, а альбом устройства содержит разгруженные превью
- **THEN** приложение восстанавливает связь с серверным альбомом и принадлежность своих загрузок из метаданных превью
#### Scenario: Метка скачанного элемента
- **WHEN** метка провенанса восстанавливается у элемента, скачанного с сервера
- **THEN** элемент восстанавливается как скачанный, а не как собственная загрузка клиента
#### Scenario: Метка утрачена
- **WHEN** метаданные провенанса недоступны (например, файл перезаписан сторонним редактором)
- **THEN** элемент считается неопознанным, не удаляется и не загружается повторно
### Requirement: Возврат оригиналов и отвязка
При выходе из режима C приложение SHALL предложить вернуть оригиналы на устройство
и SHALL предупредить, если пользователь отказывается. Отвязка альбома и выключение
синхронизации SHALL NOT удалять фото с сервера, а скачанные с сервера файлы SHALL
оставаться в галерее как обычные. Выключение синхронизации SHALL быть явным и
устойчивым: восстановление происхождения SHALL NOT возобновлять связь, пока
пользователь не включит синхронизацию снова.
#### Scenario: Возврат оригиналов
- **WHEN** пользователь выходит из режима C и соглашается вернуть оригиналы
- **THEN** приложение возвращает оригиналы на устройство вместо превью
#### Scenario: Отказ от возврата
- **WHEN** пользователь выходит из режима C и отказывается возвращать оригиналы
- **THEN** приложение предупреждает, что на устройстве останутся только превью
#### Scenario: Отвязка
- **WHEN** пользователь отвязывает альбом устройства от серверного
- **THEN** фото на сервере сохраняются, а скачанные файлы остаются в галерее как обычные
#### Scenario: Выключение синхронизации
- **WHEN** пользователь выключает синхронизацию альбома устройства
- **THEN** связь с серверным альбомом снимается, а фото на сервере сохраняются
#### Scenario: Выключение не отменяется восстановлением
- **WHEN** пользователь выключил синхронизацию (или отвязал альбом), и затем выполняется проверка с восстановлением происхождения по меткам превью
- **THEN** связь не восстанавливается, а альбом остаётся локальным с выбранным режимом выключено
#### Scenario: Повторное включение
- **WHEN** пользователь снова включает синхронизацию после выключения
- **THEN** связь создаётся заново, а восстановление происхождения снова действует по обычным правилам
### Requirement: Автоматическая фоновая синхронизация
Синхронизация SHALL выполняться автоматически в обе стороны: при открытом приложении —
по событиям изменения, в фоне — периодически, с учётом ограничений сети, выбранных
пользователем. В режиме C разгрузка оригиналов SHALL выполняться автоматически после
того, как копия оказалась на сервере.
#### Scenario: Фоновая синхронизация
- **WHEN** приложение не открыто, а в альбоме устройства появились изменения
- **THEN** синхронизация выполняется в фоне при ближайшей возможности
#### Scenario: Фоновая загрузка серверных фото
- **WHEN** приложение не открыто, а в целевом серверном альбоме появилось новое фото
- **THEN** приложение материализует его на устройстве при ближайшей возможности
#### Scenario: Ограничение сети
- **WHEN** включено ограничение «только Wi-Fi», а доступна только сотовая сеть
- **THEN** синхронизация, включая скачивание серверных фото, не начинается до появления Wi-Fi
#### Scenario: Автоматическая разгрузка
- **WHEN** оригиналы загружены в режиме C
- **THEN** замена оригиналов превью выполняется автоматически, без отдельного подтверждения
## REMOVED Requirements
### Requirement: Удаление только собственных загрузок
**Reason**: двусторонний миррор заменяет одностороннюю политику: удаление элемента на
устройстве теперь доводится до сервера, а серверное удаление убирает локальный элемент;
разграничение идёт по правам сервера и признаку происхождения, а не по «своё/чужое».
**Migration**: поведение описано требованиями «Двустороннее удаление элементов»,
«Признак происхождения элемента» и «Журнал удалений и защита от эха».
## ADDED Requirements
### Requirement: Материализация серверных фото на устройстве
При появлении фото в целевом серверном альбоме приложение SHALL материализовать его на
устройстве, записав файл в галерею (MediaStore) бакета альбома устройства: в режиме копий —
оригинал, в режиме переноса — превью, для видео — постер. Файл SHALL содержать метку
происхождения и дату съёмки серверного фото.
#### Scenario: Материализация в режиме копий
- **WHEN** в целевом серверном альбоме появилось новое фото, а альбом устройства в режиме копий
- **THEN** приложение скачивает оригинал и создаёт файл в бакете альбома устройства
#### Scenario: Материализация в режиме переноса
- **WHEN** в целевом серверном альбоме появилось новое фото, а альбом устройства в режиме переноса
- **THEN** приложение скачивает превью и создаёт файл в бакете альбома устройства
#### Scenario: Материализация видео
- **WHEN** в целевом серверном альбоме появилось видео
- **THEN** приложение создаёт в бакете постер-кадр, а оригинал остаётся на сервере
#### Scenario: Дата съёмки
- **WHEN** приложение материализует серверное фото
- **THEN** файл получает дату съёмки из серверных метаданных фото
### Requirement: Признак происхождения элемента
Каждый элемент альбома устройства SHALL иметь явный признак происхождения: загружено этим
клиентом или скачано с сервера. Политика удалений, разгрузка и восстановление SHALL
опираться на признак, а не на наличие серверной ссылки.
#### Scenario: Скачанный элемент не считается своей загрузкой
- **WHEN** элемент скачан с сервера при двусторонней синхронизации
- **THEN** он помечается как скачанный, а не как загруженный этим клиентом
### Requirement: Двустороннее удаление элементов
Удаление элемента в альбоме устройства SHALL удалять соответствующее фото на сервере
(мягко, в корзину), а серверное удаление SHALL убирать локальный элемент. Для
материализованного (серверного) элемента намерением считается только явное удаление в
приложении: пропавшая локальная копия SHALL NOT удалять серверное фото, а SHALL
материализоваться заново. Операция SHALL подчиняться правам сервера, а удаление файла,
снятого на устройстве, SHALL требовать системного согласия.
#### Scenario: Удаление на устройстве
- **WHEN** пользователь удаляет элемент синхронизированного альбома устройства
- **THEN** приложение помещает соответствующее серверное фото в корзину
#### Scenario: Удаление на сервере
- **WHEN** серверное фото помещено в корзину
- **THEN** приложение убирает соответствующий локальный элемент
#### Scenario: Пропала локальная копия серверного фото
- **WHEN** локальная копия материализованного серверного фото исчезла (очистка галереи, перемещение файла)
- **THEN** приложение материализует фото заново и SHALL NOT удалять его на сервере
#### Scenario: Явное удаление в приложении
- **WHEN** пользователь удаляет материализованное серверное фото из приложения
- **THEN** приложение помечает намерение и удаляет фото на сервере
#### Scenario: Нет прав на серверное удаление
- **WHEN** пользователь удаляет элемент, а роль на сервере не позволяет удалить это фото
- **THEN** серверное удаление отклоняется, приложение не считает операцию успешной и сообщает об этом
### Requirement: Журнал удалений и защита от эха
Локально удалённый элемент SHALL помечаться в журнале удалений, чтобы приложение SHALL NOT
материализовать его повторно до подтверждения серверного удаления. Повторные и эхо-события
удаления SHALL быть идемпотентны.
#### Scenario: Удаление ещё не подтверждено сервером
- **WHEN** элемент удалён на устройстве, а серверное удаление ещё не выполнено
- **THEN** при следующем проходе приложение не материализует этот элемент заново
#### Scenario: Эхо серверного события
- **WHEN** серверное событие удаления относится к уже удалённому локальному элементу
- **THEN** приложение ничего не меняет повторно
### Requirement: Восстановление элемента из корзины
Если серверное фото, удалённое с устройства, восстановлено из корзины сервера, приложение
SHALL снова материализовать соответствующий элемент на устройстве.
#### Scenario: Фото вернулось из корзины
- **WHEN** пользователь восстанавливает серверное фото из корзины
- **THEN** приложение материализует соответствующий элемент в альбоме устройства
### Requirement: Первый проход и ограничение скачивания
При включении синхронизации приложение SHALL предложить выбрать: скачать всё содержимое
целевого серверного альбома или только новое с этого момента. Скачивание SHALL подчиняться
ограничению «только Wi-Fi»; при ожидании подходящей сети материализация не начинается.
#### Scenario: Только новое
- **WHEN** пользователь выбирает «только новое» при включении синхронизации
- **THEN** уже существующее содержимое серверного альбома не скачивается, а материализуются только последующие появления
#### Scenario: Скачать всё
- **WHEN** пользователь выбирает «скачать всё» при включении синхронизации
- **THEN** приложение материализует весь текущий состав серверного альбома
#### Scenario: Ожидание WiFi
- **WHEN** включено ограничение «только Wi-Fi», а доступна только сотовая сеть
- **THEN** скачивание не начинается до появления Wi-Fi
### Requirement: Материализация при нескольких источниках
Когда несколько альбомов устройства указывают на один серверный альбом, приложение SHALL
материализовать серверное фото ровно в один из них, чтобы не создавать дубликат в галерее.
Выбор целевого бакета SHALL сохраняться. Содержимое, уже присутствующее на устройстве,
SHALL NOT дублироваться.
#### Scenario: Несколько источников в одну цель
- **WHEN** два альбома устройства синхронизируются с одним серверным альбомом, и в нём появляется фото
- **THEN** приложение создаёт файл ровно в одном из бакетов
#### Scenario: Содержимое уже на устройстве
- **WHEN** появившееся серверное фото по содержимому уже присутствует на устройстве
- **THEN** приложение связывает элемент с серверным фото, не создавая второй файл
@@ -0,0 +1,46 @@
# Tasks
## 1. Модель индекса и провенанс
- [x] 1.1 Добавить в `DeviceAssetEntity` признак происхождения (`OWN` | `MIRROR`) и Room-миграцию с `OWN` для существующих строк; проверить миграционным тестом `android/app/src/test` (или instrumented), что старая база открывается без потери строк
- [x] 1.2 Добавить таблицу надгробий (photoId, момент, состояние) и watermark первого прохода в Room с миграцией; проверить тестом DAO на вставку/чтение/снятие надгробия
- [x] 1.3 Расширить `ProvenanceStamp` признаком происхождения; проверить юнит-тестом encode/decode и тестом `SyncRecovery`, что скачанный элемент восстанавливается как `MIRROR`, а не как `OWN`
## 2. Конвейер материализации
- [x] 2.1 Реализовать загрузчик объекта по подписанному URL (`original_url` для B, `preview_url`/`medium_url` для C, постер для видео); проверить юнит-тестом на выбор URL и обработку ошибок сети
- [x] 2.2 Реализовать запись файла в бакет альбома устройства (уникальное имя, `DATE_TAKEN` из `taken_at`, метка происхождения); проверить instrumented-тестом, что файл появляется в MediaStore нужного бакета с датой
- [x] 2.3 Реализовать дедуп перед вставкой: если содержимое уже на устройстве — связать элемент, не создавая файл; проверить тестом планировщика, что второго файла нет
- [x] 2.4 Реализовать правило n:1 — материализация ровно в один выбранный бакет; проверить тестом, что при двух связанных бакетах файл создаётся в одном
## 3. Двусторонний планировщик
- [x] 3.1 Реализовать дельту серверных фото (`?since=`/курсор) с учётом watermark и ограничения «только Wi-Fi»; проверить тестом, что при «только новое» существующий состав не скачивается, а новое — скачивается
- [x] 3.2 Реализовать планирование материализации по режиму (B — оригинал, C — превью); проверить тестами `SyncPlanner`: режим копий → оригинал, режим переноса → превью
- [x] 3.3 Реализовать двустороннее удаление с учётом прав и надгробий (локальное → в корзину сервера, серверное → снятие локального, 403 не роняет проход); проверить тестами: идемпотентность, эхо, отказ прав
- [x] 3.4 Реализовать рематериализацию при восстановлении серверного фото из корзины; проверить тестом, что элемент возвращается в альбом устройства
- [x] 3.5 Реализовать отвязку и выключение синхронизации так, чтобы серверные фото и скачанные файлы сохранялись; проверить тестом, что после отвязки файлы остаются и сервер не затронут
## 4. Интерфейс
- [x] 4.1 Включить скачанные элементы в сетку альбома устройства и обновлять её по завершении прохода; проверить UI-тестом, что новое серверное фото появляется, а удалённое исчезает без ручного действия
- [x] 4.2 Добавить при включении синхронизации выбор «только новое» / «скачать всё» с сохранением выбора; проверить UI-тестом, что выбор влияет на первый проход
## 5. Интеграция и документация
- [x] 5.1 Проверить сквозной сценарий: фото, добавленное в веб-интерфейсе, материализуется на устройстве; удаление с устройства убирает его на сервере; удаление на сервере убирает локальный элемент; проверить e2e-тестом на эмуляторе
- [x] 5.2 Обновить Purpose спеки `phone-album-sync` (двусторонний миррор вместо односторонней синхронизации) и убедиться, что `openspec validate phone-album-two-way-sync --strict` проходит
## 6. Устойчивость выключения синхронизации
- [x] 6.1 Записать явный признак «синхронизация выключена» при отвязке/выключении и блокировать им восстановление происхождения; повторное включение снимает признак; проверить unit-тестами `SyncRepositoryTest` и `SyncRecoveryTest`
- [x] 6.2 Добавить instrumented-тест всех режимов и переключений (A/B/C, B→C, C→B, C→A, B→A, A→B, восстановление) и прогнать на эмуляторе
- [x] 6.3 Вынести объёмную нагрузку (100 фото + 20 видео на тест) в отдельный `SyncVolumeE2ETest`, а функциональные тесты держать малыми; проверить на эмуляторе
- [x] 6.4 Очередь загрузки: одно дренирующее WorkManager-задание вместо задания на каждый файл (иначе JobScheduler перестаёт запускать оставшиеся задания); проверить заливкой 120 файлов на эмуляторе
- [x] 6.5 Производительность: дебаунс `DeviceMediaObserver` и кэш sha256 по `(size, dateModified)`; проверить unit-набором и малыми наборами
- [x] 6.6 Корректность: неудачное чтение серверного списка не считается пустым альбомом (иначе удалялись локальные зеркала); проверить unit-тестом `a failed album read does not delete local mirrors`
- [x] 6.7 Удаление зеркал: намерением считается только явное удаление в приложении (надгробие → удаление на сервере); пропавшая локальная копия материализуется заново; проверить unit-тестами и instrumented-тестом `explicitDeleteOfAMirrorRemovesTheServerPhoto`
## Workflow follow-up
- Archive the change after the project's review requirements are satisfied.
+4 -3
View File
@@ -2,9 +2,10 @@
## Purpose
Синхронизация альбома устройства с существующим серверным альбомом: режимы A/B/C,
отслеживание изменений, идентичность и дедупликация, политика удалений, разгрузка
места и провенанс разгруженных превью.
Двусторонняя синхронизация альбома устройства с существующим серверным альбомом
(миррор состава): режимы A/B/C, материализация серверных фото на устройстве,
отслеживание изменений, идентичность и дедупликация, удаления в обе стороны,
разгрузка места и провенанс разгруженных превью.
## Requirements