From d17f62f676fae155f4334e311579eb7047c92083 Mon Sep 17 00:00:00 2001 From: Iu Date: Fri, 16 Oct 2026 23:19:08 +0300 Subject: [PATCH] =?UTF-8?q?chore(openspec):=20=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=BB=D0=BE=D0=B6=D0=B8=D1=82=D1=8C=20phone-album-two-way-sync?= =?UTF-8?q?=20=D0=B8=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D1=81=D0=BF=D0=B5=D0=BA=D1=83=20phone-album-sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новое изменение: двусторонний миррор альбома устройства и серверного альбома (материализация серверных фото, признак происхождения, двусторонние удаления с явным намерением, устойчивое выключение, первый проход, n:1). Purpose основной спеки phone-album-sync обновлён под двустороннюю синхронизацию. Change проходит `openspec validate --strict`. --- .../phone-album-two-way-sync/.openspec.yaml | 2 + .../phone-album-two-way-sync/design.md | 188 ++++++++++++ .../phone-album-two-way-sync/proposal.md | 59 ++++ .../specs/android-client/spec.md | 58 ++++ .../specs/phone-album-sync/spec.md | 286 ++++++++++++++++++ .../changes/phone-album-two-way-sync/tasks.md | 46 +++ openspec/specs/phone-album-sync/spec.md | 7 +- 7 files changed, 643 insertions(+), 3 deletions(-) create mode 100644 openspec/changes/phone-album-two-way-sync/.openspec.yaml create mode 100644 openspec/changes/phone-album-two-way-sync/design.md create mode 100644 openspec/changes/phone-album-two-way-sync/proposal.md create mode 100644 openspec/changes/phone-album-two-way-sync/specs/android-client/spec.md create mode 100644 openspec/changes/phone-album-two-way-sync/specs/phone-album-sync/spec.md create mode 100644 openspec/changes/phone-album-two-way-sync/tasks.md diff --git a/openspec/changes/phone-album-two-way-sync/.openspec.yaml b/openspec/changes/phone-album-two-way-sync/.openspec.yaml new file mode 100644 index 0000000..0cb4765 --- /dev/null +++ b/openspec/changes/phone-album-two-way-sync/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-16 diff --git a/openspec/changes/phone-album-two-way-sync/design.md b/openspec/changes/phone-album-two-way-sync/design.md new file mode 100644 index 0000000..74d9c91 --- /dev/null +++ b/openspec/changes/phone-album-two-way-sync/design.md @@ -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 останавливает двусторонность; серверные фото и уже + скачанные файлы сохраняются, ничего не удаляется. diff --git a/openspec/changes/phone-album-two-way-sync/proposal.md b/openspec/changes/phone-album-two-way-sync/proposal.md new file mode 100644 index 0000000..15c4162 --- /dev/null +++ b/openspec/changes/phone-album-two-way-sync/proposal.md @@ -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`. diff --git a/openspec/changes/phone-album-two-way-sync/specs/android-client/spec.md b/openspec/changes/phone-album-two-way-sync/specs/android-client/spec.md new file mode 100644 index 0000000..2a34f3e --- /dev/null +++ b/openspec/changes/phone-album-two-way-sync/specs/android-client/spec.md @@ -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** сетка обновляется без ручного действия пользователя diff --git a/openspec/changes/phone-album-two-way-sync/specs/phone-album-sync/spec.md b/openspec/changes/phone-album-two-way-sync/specs/phone-album-sync/spec.md new file mode 100644 index 0000000..12112c4 --- /dev/null +++ b/openspec/changes/phone-album-two-way-sync/specs/phone-album-sync/spec.md @@ -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** приложение связывает элемент с серверным фото, не создавая второй файл diff --git a/openspec/changes/phone-album-two-way-sync/tasks.md b/openspec/changes/phone-album-two-way-sync/tasks.md new file mode 100644 index 0000000..1c80612 --- /dev/null +++ b/openspec/changes/phone-album-two-way-sync/tasks.md @@ -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. diff --git a/openspec/specs/phone-album-sync/spec.md b/openspec/specs/phone-album-sync/spec.md index 7bcc305..484bba8 100644 --- a/openspec/specs/phone-album-sync/spec.md +++ b/openspec/specs/phone-album-sync/spec.md @@ -2,9 +2,10 @@ ## Purpose -Синхронизация альбома устройства с существующим серверным альбомом: режимы A/B/C, -отслеживание изменений, идентичность и дедупликация, политика удалений, разгрузка -места и провенанс разгруженных превью. +Двусторонняя синхронизация альбома устройства с существующим серверным альбомом +(миррор состава): режимы A/B/C, материализация серверных фото на устройстве, +отслеживание изменений, идентичность и дедупликация, удаления в обе стороны, +разгрузка места и провенанс разгруженных превью. ## Requirements