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:
iu
2026-10-16 23:20:04 +03:00
parent d17f62f676
commit cc85d792ea
8 changed files with 214 additions and 35 deletions
@@ -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 останавливает двусторонность; серверные фото и уже
скачанные файлы сохраняются, ничего не удаляется.