# 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 останавливает двусторонность; серверные фото и уже скачанные файлы сохраняются, ничего не удаляется.