chore(openspec): архивировать phone-album-two-way-sync и синхронизировать спеки
Дельта-спеки перенесены в основные: phone-album-sync (+8 требований, 6 изменено, 1 удалено — «Удаление только собственных загрузок»), android-client. Change перемещён в openspec/changes/archive/2026-10-16-phone-album-two-way-sync.
This commit is contained in:
@@ -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`.
|
||||
+58
@@ -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** сетка обновляется без ручного действия пользователя
|
||||
+286
@@ -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.
|
||||
Reference in New Issue
Block a user