feat(android): статус синхронизации на карточке и безопасная разгрузка

Карточка альбома устройства получает третью строку: метку режима («Копии»/
«Перенос») и ход загрузки. Процент считается как (uploaded + running) / total
для обоих синхронизируемых режимов, пульсирует только при активной загрузке,
показывает очередь, ожидание Wi-Fi и ошибку. В режиме переноса карточка
показывает и карту памяти, и облако — режимы различает метка, а не значки.

Разгрузка оригиналов теперь требует подтверждённой серверной копии в текущем
целевом альбоме: устаревший serverPhotoId перепроверяется по альбому, смена
цели переносит уже загруженные фото и отклоняется, если перенести нельзя.
Потеря серверной копии видна на карточке и на экране альбома, автозатирания
превью нет.

Изменения OpenSpec: android-album-card-sync-status, safe-album-offload.
This commit is contained in:
iu
2026-10-16 18:36:51 +03:00
parent 98aaa3156a
commit 1eb1177273
36 changed files with 1855 additions and 81 deletions
@@ -0,0 +1,92 @@
# Design
## Context
См. `proposal.md` — Why. Ниже — что в коде определяет подход.
- `SyncCoordinator.reconcile` получает `serverChecksums(link.serverAlbumId)` и вычисляет
`livePhotoIds = serverChecksums.values.toSet()`, но `SyncPlanner.plan` и `offloadUploaded`
этот набор не используют: первый смотрит только на `indexed.serverPhotoId != null`, второй —
на `entity.serverPhotoId != null`. `reflectLostCopies` умеет лишь перекрасить
`PREVIEW ↔ ORPHANED` и тоже по `livePhotoIds`.
- Разгрузка вызывается из трёх мест: `SyncCoordinator.offloadUploaded` (режим C),
`UploadWorker.recordProvenance` (сразу после успешной загрузки) и
`DeviceAlbumViewModel.freeSpace` (ручное освобождение по согласию системы).
- `AssetOffloader.offload(asset)` не требует доказательств, что копия жива.
- `SyncRepository.link` перезаписывает `serverAlbumId`; смену цели обслуживают
`DeviceAlbumViewModel.setTarget` и `createAlbum` — оба просто линкуют и запускают reconcile.
- Серверный перенос фото есть: `SyncRemote.movePhotos` → `POST /api/photo-batch/move`,
который пропускает удалённые фото и возвращает число перенесённых, не сообщая об ошибке за
пропуск.
- `LocalAssetState.ORPHANED` уже вычисляется и показывается перечёркнутым облаком на элементе;
счётчика и заметного предупреждения нет.
## Goals / Non-Goals
**Goals:**
- Сделать невозможной разгрузку без подтверждённой копии в текущем целевом альбоме.
- Не терять связь с уже загруженными фото при смене целевого альбома.
- Сделать потерю серверной копии видимой и оставить её устранение явным действием.
**Non-Goals:**
- Восстановление уже потерянных копий из корзины сервера.
- Предупреждение об удалении серверного альбома из веб-клиента: сервер и веб не знают о
зависимости альбома устройства.
- Ручные загрузки в серверный альбом и их режимы.
## Decisions
**Единственный источник доверия — живой список фото целевого альбома.** И `plan`, и разгрузка
опираются на `livePhotoIds`, уже полученный в `reconcile`. Альтернатива — отдельный запрос
«фото существует» — отклонена: лишние round-trip и рассинхрон.
**Планировщик лечит устаревшую запись, а не игнорирует её.** Ветка «записанный `photoId`»
срабатывает только если `photoId ∈ livePhotoIds`; иначе ассет проходит обычный путь: дедуп по
контрольной сумме, а при отсутствии — загрузка. Удаление серверных фото в конце плана тоже
ограничено `livePhotoIds`, чтобы не тронуть чужую копию.
**Разгрузка требует доказательства в подписи.** `AssetOffloader.offload(asset,
liveServerPhotoIds)` и новый исход `OffloadResult.NotOnServer`, если копии нет. Так инвариант
живёт в одном месте, а вызывающий обязан доказать, что копия жива:
- `offloadUploaded` передаёт `livePhotoIds` из `reconcile`;
- `recordProvenance` передаёт `setOf(photo.id)` — только что созданное фото;
- `freeSpace` сначала читает `serverChecksums` текущего линка и передаёт его значения; при
ошибке не разгружает ничего.
**Смена цели — транзакция «перенести, проверить, переключить».** `SyncCoordinator.retarget`
переносит фото провенанса этого альбома в новый альбом, затем перечитывает новый альбом и
убеждается, что все переносимые копии на месте; только тогда переключает связь. Если прежний
альбом недоступен или копии не доехали — связь остаётся прежней, результат возвращается как
отказ. Альтернатива «переключить и попробовать потом» отклонена: именно она дала инцидент.
**Уже потерянные копии не блокируют смену цели, но сообщаются.** Фото, которых нет ни в
прежнем альбоме, ни в новом (например, давно осиротевшие), перенести нельзя; они считаются и
возвращаются в результате, чтобы пользователь знал, но не мешают переключить альбом.
**Потеря копии показывается из `ORPHANED`, а не из проверки существования альбома.**
Существование целевого альбома при офлайне недоказуемо, а `ORPHANED` выставляется только после
успешного чтения альбома. Один новый запрос `GROUP BY deviceAlbumId` по `state = 'ORPHANED'`
даёт счётчик для карточки и баннера; на карточке потеря копии перекрывает прогресс операции.
## Risks / Trade-offs
- [Перенос пропускает удалённые фото без ошибки] → проверяем результат чтением нового
альбома, а не ответом `move`; ненулевое расхождение — отказ.
- [Лишние `serverChecksums` в `freeSpace` и `retarget`] → это редкие явные действия, не цикл;
при ошибке сети ничего не разгружаем и не переключаем.
- [Планировщик начинает перезаливать содержимое, если сервер не отдал контрольную сумму] →
`serverChecksums` падает вместе с `reconcile` (исключение до применения плана), поэтому
«пустой живой список из-за ошибки» не доходит до загрузок.
- [Счётчик `ORPHANED` в карточке устаревает между проходами] → приемлемо: он обновляется тем
же потоком Room, что и всё представление, и означает «на последнем успешном проходе».
## Migration Plan
Схема Room не меняется; миграций нет. Откат — обычная сборка без изменения.
## Open Questions
- Нужен ли отдельный сценарий «перенести потерянные копии из прежнего альбома» как ручное
действие, когда включённый режим уже сменился — отдельным изменением.