Дельта-спеки перенесены в основные: phone-album-sync (+8 требований, 6 изменено, 1 удалено — «Удаление только собственных загрузок»), android-client. Change перемещён в openspec/changes/archive/2026-10-16-phone-album-two-way-sync.
189 lines
18 KiB
Markdown
189 lines
18 KiB
Markdown
# 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 останавливает двусторонность; серверные фото и уже
|
|
скачанные файлы сохраняются, ничего не удаляется.
|