chore(openspec): архивировать fix-album-sync-churn-and-degradation и синхронизировать спеки
В спеку phone-album-sync добавлены требования о роли локального файла, подтверждении новой версии до удаления устаревшей и разгрузке без незавершённой загрузки; уточнены синхронизация изменений, провенанс и возврат оригиналов.
This commit is contained in:
+2
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-16
|
||||
@@ -0,0 +1,171 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
Мотивацию и инцидент см. в `proposal.md`; требования — в дельте `specs/phone-album-sync/`.
|
||||
Здесь только текущее состояние, задающее подход.
|
||||
|
||||
- Движок синхронизации (`android/.../data/sync/*`) считает диффом «MediaStore сейчас ↔ индекс
|
||||
Room». Индекс: `SyncedAlbumEntity` (связь и режим), `DeviceAssetEntity` (`assetKey`,
|
||||
`contentHash`, `serverPhotoId`, `state`, `origin`), `deleted_assets` (журнал удалений),
|
||||
`upload_jobs` (очередь загрузок с `deviceAlbumId`, `assetKey`, `contentHash`, `syncMode`).
|
||||
- `SyncPlanner.plan` — чистая функция: сравнивает `LocalAsset.contentHash` с
|
||||
`IndexedAsset.contentHash` и отдаёт `Upload`, `Link`, `DeleteOnServer`, `RenameOnServer`.
|
||||
Роль файла она различает только по `state == PREVIEW`; при расхождении состояния с диском
|
||||
смена роли читается как «содержимое изменилось».
|
||||
- `OffloadManager` пишет в превью метку провенанса (`ProvenanceStamp`:
|
||||
`album`, `photo`, `hash`, `device`, `mode`, `asset`, `origin`), где `hash` — контрольная
|
||||
сумма оригинала на момент разгрузки.
|
||||
- Загрузка асинхронная (`UploadRepository`/`UploadWorker` на WorkManager); в задание
|
||||
записывается `contentHash`, посчитанный на момент планирования, а байты читаются по URI уже
|
||||
во время загрузки. `UploadWorker.recordProvenance` в режиме переноса разгружает оригинал
|
||||
сразу после успешной загрузки — вне мьютекса `SyncCoordinator`.
|
||||
- `LocalAssetProvider.hashCached` переиспользует посчитанный sha256, пока не изменилась пара
|
||||
`(size, dateModified)` **из MediaStore**, которая после записи файла через дескриптор
|
||||
остаётся старой (на инциденте: `size=169637` в приложении против `2 184 212` на диске).
|
||||
- Room: версия 8, миграции объявлены явно (`MIGRATION_6_7`, `MIGRATION_7_8`),
|
||||
`fallbackToDestructiveMigration` включён.
|
||||
- Сервер менять не нужно: `DELETE /api/photos/{id}` (мягко, в корзину),
|
||||
`POST /api/photos/{id}/restore` и `original_checksum` в ответе загрузки уже есть.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Инвариант «контрольная сумма = идентичность оригинала, состояние = роль локального файла»:
|
||||
разгрузка и возврат не меняют содержимое и не запускают загрузку или удаление.
|
||||
- Замена версии безопасна: прежняя серверная копия исчезает только после подтверждения новой.
|
||||
- Расхождение состояния в индексе самоисправляется, а не превращается в разрушительное
|
||||
действие.
|
||||
- Разово вернуть два пострадавших оригинала и убрать деградировавшие копии.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Серверная модель версий и идентичности содержимого; эндпоинт «заменить содержимое фото».
|
||||
- Чистка кэша альбома на телефоне (пустые плитки удалённых фото — отдельная задача).
|
||||
- Доверие к метаданным MediaStore при кэшировании хешей: остаётся как есть, риск закрывается
|
||||
сверкой контрольной суммы (см. решение 4).
|
||||
- Семантика зеркалирования чужих серверных фото и права на удаление.
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Роль и содержимое различаются по метке превью, а не по состоянию индекса
|
||||
|
||||
Идентичность элемента — `IndexedAsset.contentHash` (контрольная сумма оригинала либо
|
||||
последней подтверждённой новой версии). Роль локального файла определяется меткой провенанса:
|
||||
если у файла есть метка, её `hash` совпадает с идентичностью элемента, а фактический хеш файла
|
||||
от неё отличается — это созданное приложением превью, а не новая версия.
|
||||
|
||||
Альтернативы: доверять только `state` индекса — отклонено, потому что расхождение состояния и
|
||||
есть причина инцидента (в `device_assets` элементы с превью на диске стояли как `LOCAL`);
|
||||
сравнивать разрешение с серверной копией — отклонено, потому что законное уменьшение
|
||||
(кроп/экспорт) тоже меньше по размеру, а серверная копия может быть уже деградировавшей.
|
||||
|
||||
Метка читается только тогда, когда хеш содержимого пересчитывался (промах кэша), то есть
|
||||
примерно один раз на изменение файла.
|
||||
|
||||
Метка указывает на элемент, чьим превью является файл, — для фото это сам элемент, для
|
||||
видео это исходное видео, уступившее место постеру под новым `MediaStore`-id. Поэтому
|
||||
распознавание опирается на `asset` из метки, а не на совпадение ключей: постер
|
||||
разгруженного видео связывается с видео, а «исчезнувший» с устройства элемент, который всё
|
||||
ещё представлен постером, не считается удалённым и не удаляет серверную копию. Скачанные
|
||||
(зеркальные) файлы метку-ключ не несут и по этому пути не обрабатываются.
|
||||
|
||||
### 2. Планировщик возвращает действие «выправить роль», а не молчит
|
||||
|
||||
Превью, принятое за оригинал, не просто игнорируется: планировщик отдаёт
|
||||
`SyncAction.MarkPreview(assetKey)`, координатор ставит элементу состояние `PREVIEW`. Это
|
||||
чинит и `GallerySyncPolicy` (иначе он в режиме переноса снова разгружает элемент как
|
||||
`LOCAL`), и `reflectLostCopies` (переход `PREVIEW ↔ ORPHANED`). Альтернатива — чинить роль
|
||||
только в координаторе до планирования — отклонена: тогда знание о метке пришлось бы
|
||||
дублировать в двух местах.
|
||||
|
||||
### 3. Незавершённая загрузка блокирует разгрузку
|
||||
|
||||
`SyncCoordinator.offloadUploaded` пропускает элементы, для которых очередь загрузок знает
|
||||
незавершённое задание (`SyncUploadQueue.isPending`). Так файл не может быть переписан
|
||||
превью в момент, когда задание ещё читает байты по URI. Альтернатива — синхронный offload
|
||||
внутри воркера после загрузки — оставлена, но только для уже подтверждённого фото этого
|
||||
элемента, когда незавершённых заданий по нему нет.
|
||||
|
||||
Побочный эффект: разгрузка в режиме переноса завершается после того, как очередь опустеет,
|
||||
то есть чуть позже, чем сегодня (требование «автоматическая разгрузка после появления копии
|
||||
на сервере» сохраняется).
|
||||
|
||||
### 4. Замена версии: удаление прежней копии — только после подтверждения
|
||||
|
||||
При изменении содержимого планировщик отдаёт `SyncAction.ReplaceUpload(asset, stalePhotoId)`
|
||||
вместо пары «`Upload` + `DeleteOnServer`»:
|
||||
|
||||
- координатор пишет в журнал удалений запись для `stalePhotoId` (надгробие блокирует
|
||||
повторную материализацию прежней копии, пока идёт замена) и ставит задание загрузки с
|
||||
новым полем `replacesPhotoId`;
|
||||
- **`UploadWorker`** после загрузки сверяет `original_checksum` ответа с хешем задания; при
|
||||
совпадении удаляет прежнюю копию (мягко, в корзину), записывает провенанс и разгружает
|
||||
оригинал в режиме переноса;
|
||||
- при ошибке или расхождении контрольной суммы прежняя копия остаётся, провенанс не
|
||||
записывается, замена повторится в следующем проходе (надгробие сохраняется, задание
|
||||
остаётся с ошибкой).
|
||||
|
||||
Сверка различает три исхода, и это принципиально: **совпало** — можно удалять прежнюю копию;
|
||||
**разошлось** — загрузку нельзя принимать за новую версию (принятую копию отбрасываем мягко,
|
||||
элемент не считается загруженным, прежняя копия остаётся); **не сообщено** — загрузка
|
||||
остаётся состоявшейся, но прежняя копия не удаляется. Третий исход понадобился сразу: ответ
|
||||
на загрузку не отдавал `original_checksum` (сервер писал контрольную сумму только в объект,
|
||||
хотя требование способности `photos` «Контрольная сумма оригинала» распространяется на данные
|
||||
фото вообще), и первая версия сверки, считавшая отсутствие контрольной суммы расхождением,
|
||||
отклоняла все синхронные загрузки. Дефект сервера исправлен: `Service.Upload` заполняет
|
||||
`p.OriginalChecksum` до ответа и публикации события; клиент при этом остаётся устойчивым к
|
||||
инстансу, который контрольную сумму не сообщает.
|
||||
|
||||
Схема: `upload_jobs.replacesPhotoId TEXT` (Room 8 → 9). Задание — естественный носитель
|
||||
одноразовой замены: альтернатива с колонкой в `device_assets` требует явной очистки после
|
||||
подтверждения, а привязка надгробий к ассетам всё равно требует миграции. Немедленное
|
||||
удаление как сегодня отклонено: неудачная загрузка оставляет альбом без живой копии.
|
||||
Явное удаление элемента на устройстве (`SyncAction.DeleteOnServer`) сохраняет прежнее
|
||||
поведение — файла уже нет, и намерение выражено пользователем.
|
||||
|
||||
### 5. Возврат оригинала оставляет состояние «оригинал + серверная копия»
|
||||
|
||||
`RestoreManager` после записи оригинала помечает элемент `UPLOADED`, а не `LOCAL`.
|
||||
`LOCAL` остаётся значением для «оригинал есть, на сервер не загружался». Вместе с решением 2
|
||||
это убирает повторную разгрузку вернувшихся оригиналов и петлю «вернул → выгрузил → залил».
|
||||
|
||||
### 6. Разовое восстановление двух пострадавших фото
|
||||
|
||||
Операция, а не постоянное поведение: для `IMG_20261004_163151.jpg` восстановить из корзины
|
||||
оригинал `6d4e1897` в альбом `N2` и удалить деградировавшую копию `b0b29905`; для
|
||||
`IMG_20261009_153136.jpg` — восстановить `991ea4ee` и удалить `1f49e8fb`. Нужны права
|
||||
владельца инстанса; если креды недоступны, пользователю выдаются готовые команды. Синхронизация
|
||||
альбома на телефоне к этому времени уже выключена, поэтому вмешательства в индекс не требуется.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[У файла нет метки провенанса]** (превью записано до появления метки или переписано
|
||||
сторонним редактором) → роль берётся из состояния индекса, как раньше; деградацию
|
||||
ограничивает решение 4: прежняя копия не удаляется, пока новая не подтверждена, поэтому
|
||||
потеря оригиналов невозможна, а лишняя копия заметна и обратима через корзину.
|
||||
- **[Сервер не сообщает контрольную сумму]** → загрузка остаётся состоявшейся, но прежняя
|
||||
копия не удаляется и замена повторяется; так инстанс, отставший от спеки, не может
|
||||
привести к отклонению всех синхронных загрузок (этот исход уже случался на живой проверке).
|
||||
- **[Хеш посчитан в момент перезаписи файла]** (гонка с собственной записью) → возможна
|
||||
загрузка с хешем, не совпадающим с байтами; сверка контрольной суммы (решение 4) отменяет
|
||||
такую замену и не удаляет прежнюю копию.
|
||||
- **[Правка собственного превью пользователем]** → элемент считается превью и правка не
|
||||
уедет на сервер. Осознанный компромисс: файл несёт метку разгруженного превью, а
|
||||
требование «метка утрачена» уже говорит не удалять и не загружать неопознанное повторно.
|
||||
- **[Разгрузка откладывается до опустошения очереди]** → режим переноса освобождает место
|
||||
позже. Освобождение остаётся автоматическим, но перестаёт конкурировать с загрузкой.
|
||||
- **[Миграция Room 8 → 9]** → одна `ALTER TABLE` с nullable-колонкой; откат безопасен
|
||||
(колонка просто игнорируется), `fallbackToDestructiveMigration` остаётся страховкой.
|
||||
- **[Ручная операция по данным инстанса]** → восстановление из корзины обратимо, удаление
|
||||
выполняется мягко; до и после сверяются размеры и контрольные суммы.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Миграция `MIGRATION_8_9` добавляет `upload_jobs.replacesPhotoId`; старые задания читаются
|
||||
как «без замены».
|
||||
2. Сборка и установка APK поверх существующего приложения: индекс и очередь сохраняются.
|
||||
3. После обновления первый проход сам исправляет состояния элементов (решение 2) и
|
||||
прекращает цикл на конкретном инстансе.
|
||||
4. Разовое восстановление двух фото (решение 6) выполняется отдельно и не зависит от выкатки.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Режим переноса (C) может залить на сервер **превью вместо оригинала** и тут же удалить
|
||||
устаревшую серверную копию, то есть молча уничтожить оригинал. Причина — планировщик не
|
||||
отличает содержимое от роли локального файла: когда состояние элемента в локальном индексе
|
||||
расходится с фактической ролью (в индексе «оригинал», на диске превью), смена роли читается
|
||||
как «содержимое изменилось» и запускается цикл «удалил → залил → удалил».
|
||||
|
||||
Реальный инцидент: альбом устройства U2, режим переноса, серверный альбом `N2`. Из 20 фото
|
||||
пять перезаливались по кругу (20:03 → 20:08 → 20:42 → 21:24), а два потеряли оригиналы —
|
||||
`IMG_20261004_163151.jpg` (3 437 694 Б, 3072x4096 → 259 282 Б, 1200x1600) и
|
||||
`IMG_20261009_153136.jpg` (2 226 043 Б → 192 651 Б). Лечиться это будет только сейчас:
|
||||
у первого уцелевший оригинал лежит в корзине сервера, у второго — в корзине и в альбоме
|
||||
«Первый». Побочно тот же цикл оставляет на телефоне элементы, которых на сервере уже нет:
|
||||
в приложении альбом `N2` показывает 26 плиток вместо 20, шесть из них пустые.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Роль не является содержимым.** Приложение SHALL отличать созданное им превью (в том числе
|
||||
по метке провенанса) от нового оригинала: превью никогда не загружается как новая версия и
|
||||
не влечёт удаления серверной копии, а расхождение состояния в индексе выправляется.
|
||||
Настоящее изменение содержимого, как и раньше, синхронизируется новой версией.
|
||||
- **Удаление только после подтверждения.** Устаревшая собственная загрузка удаляется только
|
||||
после того, как новая версия подтверждена сервером: сообщённая им контрольная сумма
|
||||
принятого объекта совпала с отправленной. При расхождении загрузка отклоняется вместе с
|
||||
принятой копией, прежняя серверная копия остаётся в альбоме, а замена повторяется при
|
||||
следующей синхронизации. Если сервер контрольную сумму не сообщил (например, старая версия
|
||||
инстанса), загрузка остаётся состоявшейся, но прежняя копия не удаляется.
|
||||
- **Контрольная сумма в ответе на загрузку.** Сервер SHALL отдавать контрольную сумму
|
||||
оригинала в данных созданного фото — как требует существующее требование способности
|
||||
`photos`; сегодня ответ на загрузку её не содержит (дефект сервера, а не изменение API).
|
||||
- **Разгрузка не гоняется с загрузкой.** Оригинал не заменяется превью, пока по этому
|
||||
элементу есть незавершённая загрузка.
|
||||
- **Возврат оригинала устойчив.** После возврата оригиналов на устройство элемент не
|
||||
разгружается превью повторно, пока альбом снова не переведён в режим переноса.
|
||||
- **Разовое восстановление двух пострадавших фото** из корзины сервера (и альбома «Первый»
|
||||
для второго) с удалением деградировавших копий.
|
||||
- **BREAKING**: нет. Успешные сценарии (загрузка нового, разгрузка, повторная синхронизация,
|
||||
настоящее изменение содержимого) сохраняют поведение; меняются только защитные условия.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_Нет — поведение относится к существующей способности синхронизации альбома устройства._
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `phone-album-sync`: различение роли и содержимого (метка превью против нового оригинала),
|
||||
разгрузка только без незавершённой загрузки, удаление устаревшей загрузки только после
|
||||
подтверждения новой версии, устойчивый возврат оригиналов.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Код (Android):** `data/sync/SyncPlan` (различение роли и содержимого, отложенная замена),
|
||||
`data/sync/SyncCoordinator` (выправление состояния, разгрузка без незавершённых загрузок,
|
||||
постановка замены), `data/sync/LocalAssetProvider` (метка превью рядом с хешем),
|
||||
`data/upload/UploadWorker` (сверка принятой контрольной суммы, удаление устаревшей копии
|
||||
после подтверждения, без разгрузки вне координатора), `data/sync/RestoreManager` (состояние
|
||||
после возврата оригинала), `data/db` (поле «заменяемое фото» в задании загрузки).
|
||||
- **Схема Room:** версия 8 → 9, `ALTER TABLE upload_jobs ADD COLUMN replacesPhotoId TEXT`.
|
||||
- **API и бэкенд:** не затрагиваются — используются существующие `DELETE /api/photos/{id}`
|
||||
(мягко, в корзину), `POST /api/photos/{id}/restore` и `original_checksum` в ответе загрузки.
|
||||
- **Данные инстанса:** разовое восстановление двух оригиналов и удаление деградировавших
|
||||
копий (операция, а не постоянное поведение).
|
||||
- **Не затрагивается:** серверная модель версий/идентичности, дедупликация вложения, чистка
|
||||
кэша альбома на телефоне (пустые плитки удалённых фото — отдельная задача), доверие к
|
||||
метаданным MediaStore при кэшировании хешей (сверка контрольной суммы закрывает риск).
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Роль локального файла не является содержимым
|
||||
|
||||
Приложение SHALL отличать превью, созданное им самим при разгрузке (в том числе по метке
|
||||
провенанса), от новой версии оригинала: разгруженный элемент SHALL NOT загружаться повторно
|
||||
и SHALL NOT вызывать удаление серверной копии, а расхождение состояния в локальном индексе
|
||||
SHALL выправляться. Это SHALL относиться и к постеру, которым в режиме переноса заменяется
|
||||
видео.
|
||||
|
||||
#### Scenario: Разгруженный элемент не создаёт новую версию
|
||||
|
||||
- **WHEN** серверная копия элемента есть в целевом альбоме, а на устройстве лежит созданное приложением превью, в том числе если состояние локального индекса ошибочно указывает оригинал
|
||||
- **THEN** приложение не ставит загрузку и не удаляет серверную копию
|
||||
|
||||
#### Scenario: Состояние выправляется
|
||||
|
||||
- **WHEN** файл на устройстве является созданным приложением превью, а индекс считает его оригиналом
|
||||
- **THEN** приложение помечает элемент как разгруженный и последующие проходы не создают загрузок
|
||||
|
||||
#### Scenario: Постер разгруженного видео
|
||||
|
||||
- **WHEN** видео разгружено (заменено постером под новым элементом MediaStore), а его метка указывает на исходное видео
|
||||
- **THEN** приложение связывает элемент с серверным фото видео и не удаляет это фото на сервере, а постер не загружается как новое фото
|
||||
|
||||
#### Scenario: Настоящее изменение содержимого
|
||||
|
||||
- **WHEN** содержимое медиа заменено содержимым, не созданным приложением при разгрузке
|
||||
- **THEN** приложение загружает новую версию элемента
|
||||
|
||||
### Requirement: Подтверждение новой версии до удаления устаревшей
|
||||
|
||||
Устаревшая собственная загрузка элемента SHALL удаляться только после подтверждения новой
|
||||
версии: сообщённая сервером контрольная сумма принятого объекта SHALL совпадать с
|
||||
отправленной. При расхождении приложение SHALL отклонить загрузку, удалив принятую копию, и
|
||||
SHALL NOT считать элемент загруженным. Если сервер контрольную сумму не сообщил, загрузка
|
||||
SHALL считаться состоявшейся, но устаревшая копия SHALL NOT удаляться, а замена SHALL
|
||||
повториться при следующей синхронизации.
|
||||
|
||||
#### Scenario: Новая версия подтверждена
|
||||
|
||||
- **WHEN** новая версия загружена, а сообщённая сервером контрольная сумма совпала с отправленной
|
||||
- **THEN** приложение удаляет устаревшую собственную загрузку (мягко, в корзину)
|
||||
|
||||
#### Scenario: Контрольная сумма не совпала
|
||||
|
||||
- **WHEN** сервер сообщил для принятого объекта контрольную сумму, отличную от отправленной
|
||||
- **THEN** приложение удаляет принятую копию, не считает элемент загруженным и оставляет прежнюю серверную копию в альбоме
|
||||
|
||||
#### Scenario: Контрольная сумма не сообщена
|
||||
|
||||
- **WHEN** сервер не сообщил контрольную сумму принятого объекта
|
||||
- **THEN** загрузка считается состоявшейся, прежняя серверная копия остаётся в альбоме, а замена повторяется при следующей синхронизации
|
||||
|
||||
#### Scenario: Загрузка не удалась
|
||||
|
||||
- **WHEN** загрузка новой версии завершилась ошибкой
|
||||
- **THEN** прежняя серверная копия остаётся в альбоме
|
||||
|
||||
### Requirement: Разгрузка без незавершённой загрузки
|
||||
|
||||
Приложение SHALL NOT заменять оригинал превью, пока для этого элемента есть незавершённая
|
||||
загрузка (в очереди или выполняющаяся), чтобы загрузка не отправила превью вместо оригинала.
|
||||
|
||||
#### Scenario: Загрузка в очереди
|
||||
|
||||
- **WHEN** для элемента есть задание загрузки в очереди или выполняющееся
|
||||
- **THEN** оригинал на устройстве не заменяется превью
|
||||
|
||||
#### Scenario: Загрузка подтверждена
|
||||
|
||||
- **WHEN** загрузка элемента подтверждена сервером
|
||||
- **THEN** приложение выполняет обычную разгрузку в режиме переноса
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Синхронизация изменений с сервером
|
||||
|
||||
Для каждого отслеженного изменения приложение SHALL применить его к целевому
|
||||
серверному альбому: загрузить новое содержимое, переименовать и перенести через
|
||||
API. Изменение содержимого SHALL трактоваться как новая загрузка, а устаревшая
|
||||
собственная загрузка SHALL удаляться после подтверждения новой версии и SHALL NOT
|
||||
удаляться из-за смены локальной роли файла (оригинал ↔ превью).
|
||||
|
||||
#### Scenario: Новое содержимое
|
||||
|
||||
- **WHEN** в альбоме устройства появилось медиа, которого нет на сервере
|
||||
- **THEN** приложение загружает его в целевой серверный альбом
|
||||
|
||||
#### Scenario: Переименование доходит до сервера
|
||||
|
||||
- **WHEN** медиа переименовано на устройстве и уже синхронизировано
|
||||
- **THEN** приложение переименовывает соответствующее серверное фото
|
||||
|
||||
#### Scenario: Изменение содержимого
|
||||
|
||||
- **WHEN** содержимое медиа изменено на устройстве
|
||||
- **THEN** приложение загружает новую версию и после её подтверждения удаляет устаревшую собственную загрузку
|
||||
|
||||
### Requirement: Провенанс разгруженных превью
|
||||
|
||||
Разгруженные превью, постеры видео и скачанные с сервера элементы SHALL содержать
|
||||
метаданные провенанса, включая признак происхождения (загружено клиентом или скачано с
|
||||
сервера), достаточные для восстановления связи с серверным альбомом и принадлежности
|
||||
загрузок после переустановки приложения. Метка SHALL также служить признаком того, что
|
||||
локальный файл является созданным приложением превью: элемент с такой меткой SHALL NOT
|
||||
считаться содержащим оригинал, даже если состояние локального индекса утверждает обратное.
|
||||
|
||||
#### Scenario: Восстановление после переустановки
|
||||
|
||||
- **WHEN** приложение переустановлено или его данные очищены, а альбом устройства содержит разгруженные превью
|
||||
- **THEN** приложение восстанавливает связь с серверным альбомом и принадлежность своих загрузок из метаданных превью
|
||||
|
||||
#### 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** связь создаётся заново, а восстановление происхождения снова действует по обычным правилам
|
||||
|
||||
#### Scenario: Возврат устойчив
|
||||
|
||||
- **WHEN** оригиналы возвращены на устройство после выхода из режима переноса
|
||||
- **THEN** приложение не разгружает их повторно, пока альбом не переведён в режим переноса снова
|
||||
@@ -0,0 +1,34 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Роль локального файла вместо состояния индекса
|
||||
|
||||
- [x] 1.1 Добавить в `LocalAsset` (`data/sync/SyncPlan.kt`) признаки метки превью (`stampedOriginalHash`, `stampedAssetKey`), а в `DeviceLocalAssetProvider` читать `ProvenanceStamp` в момент пересчёта хеша файла; решение «локальный файл — созданное приложением превью» вынести в чистую функцию с юнит-тестом (`cd android && ./gradlew :app:testDebugUnitTest --tests "*ProvenanceStampTest" --tests "*SyncPlannerTest"`).
|
||||
- [x] 1.2 В `SyncPlanner.plan` не загружать превью и не удалять из-за него серверную копию: использовать метку как признак роли и идентичность оригинала, отдавать новое действие `SyncAction.MarkPreview`; покрыть `SyncPlannerTest` случаями «состояние индекса `LOCAL`, файл — превью с меткой» (ожидается `MarkPreview`), «другое содержимое без метки» (ожидается загрузка) и «превью потерявшего копию элемента» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncPlannerTest"`).
|
||||
- [x] 1.3 В `SyncCoordinator` применять `MarkPreview` (состояние `PREVIEW`) и не разгружать элементы с незавершённой загрузкой (`SyncUploadQueue.isPending`) в `offloadUploaded`; покрыть `SyncCoordinatorTest` случаями «расхождение состояния выправляется» и «загрузка в очереди — разгрузки нет» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncCoordinatorTest"`).
|
||||
- [x] 1.4 Распознавать постер разгруженного видео как превью исходного элемента по метке (`asset` указывает на видео, а не на постер) и не удалять серверную копию видео, уступившего место постеру; покрыть `SyncPlannerTest` случаями «постер связывается с видео, не загружается и не удаляется» и «видео без постера удаляется как раньше» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncPlannerTest"`).
|
||||
|
||||
## 2. Замена версии с подтверждением
|
||||
|
||||
- [x] 2.1 Добавить `replacesPhotoId` в `UploadJobEntity`, миграцию `MIGRATION_8_9` (версия 9) и ветку в `MigrationTest`; проверить `cd android && ./gradlew :app:testDebugUnitTest --tests "*MigrationTest" --tests "*UploadRepositoryTest"`.
|
||||
- [x] 2.2 В `SyncPlanner` заменить пару «`Upload` + `DeleteOnServer`» для изменённого содержимого на `SyncAction.ReplaceUpload(asset, stalePhotoId)`, а в `SyncCoordinator` писать надгробие для прежней копии и ставить задание с полем замены; покрыть `SyncPlannerTest` (ожидается `ReplaceUpload`) и `SyncCoordinatorTest` (надгробие и задание с заменой) (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncPlannerTest" --tests "*SyncCoordinatorTest"`).
|
||||
- [x] 2.3 Вынести сверку принятой контрольной суммы в чистую функцию и подключить её в `UploadWorker`: совпадение `original_checksum` с хешем задания удаляет прежнюю копию и записывает провенанс, расхождение или ошибка — не удаляет, не разгружает и оставляет задание с ошибкой; покрыть `SyncUploadTest` (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncUploadTest"`).
|
||||
- [x] 2.4 Сервер: заполнять `p.OriginalChecksum` в `Service.Upload` до ответа и публикации события (существующее требование `photos` «Контрольная сумма оригинала» распространяется на данные созданного фото, но ответ на загрузку её не отдавал — живая проверка показала, что из-за этого отклонялись все синхронные загрузки); покрыть тестом ответа на загрузку (`cd backend && go test ./internal/app/ -run TestUploadResponseExposesOriginalChecksum -v`).
|
||||
- [x] 2.5 Клиент остаётся устойчивым к инстансу без контрольной суммы: «не сообщено» SHALL NOT считаться расхождением — загрузка принимается, а прежняя копия не удаляется; покрыть `SyncUploadTest` случаями «не сообщено» и «нет заявки на содержимое» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncUploadTest"`).
|
||||
|
||||
## 3. Состояние после возврата оригинала
|
||||
|
||||
- [x] 3.1 В `RestoreManager` помечать восстановленный элемент `UPLOADED` (оригинал и серверная копия) вместо `LOCAL`; покрыть `RestoreManagerTest` и `GallerySyncPolicyTest` случаем «после возврата повторной разгрузки нет» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*RestoreManagerTest" --tests "*GallerySyncPolicyTest"`).
|
||||
|
||||
## 5. Интеграционная проверка
|
||||
|
||||
- [x] 5.1 Прогнать весь Android-набор и собрать debug-APK (`cd android && ./gradlew test :app:assembleDebug`).
|
||||
|
||||
## Workflow follow-up
|
||||
|
||||
- Восстановление пострадавших оригиналов (бывшая задача 4) — ручная процедура владельца инстанса, выполняется вне change, креды в репозиторий и артефакты не попадают:
|
||||
- вернуть из корзины `6d4e1897-3a7e-4cba-9d85-5bbb4b06f5a4` (IMG_20261004_163151.jpg, 3 437 694 Б, 3072x4096) и `991ea4ee-debb-4ecb-bc26-e4d1a2140085` (IMG_20261009_153136.jpg, 2 226 043 Б, 3072x4096) в альбом `N2` (`POST /api/photos/{id}/restore`);
|
||||
- удалить деградировавшие копии `b0b29905-712b-4143-9d8c-0b74aa567ce5` и `1f49e8fb-ea9c-4764-8daa-7b185be107e9` (`DELETE /api/photos/{id}`);
|
||||
- проверить, что в альбоме осталось по одной записи с каждым именем и оригинал у обеих 3072x4096.
|
||||
- Уборка после регрессии первой версии сверки контрольной суммы (живая проверка 2026-10-16): в новом альбоме `U2` (`e7ae31c9-805c-42f5-8fd2-ea2304f0c485`) лежат 19 мягко удалённых копий десяти файлов, которые приложение само же и отклонило. Их список (id и имена) — в отчёте; удаляются точечно `DELETE /api/photos/{id}/permanent`. Корзину целиком очищать нельзя, пока не восстановлены оригиналы `N2`: они лежат там же.
|
||||
- Заархивировать изменение после прохождения ревью проекта.
|
||||
- Проверить результат архивации.
|
||||
Reference in New Issue
Block a user