Карточка альбома устройства получает третью строку: метку режима («Копии»/ «Перенос») и ход загрузки. Процент считается как (uploaded + running) / total для обоих синхронизируемых режимов, пульсирует только при активной загрузке, показывает очередь, ожидание Wi-Fi и ошибку. В режиме переноса карточка показывает и карту памяти, и облако — режимы различает метка, а не значки. Разгрузка оригиналов теперь требует подтверждённой серверной копии в текущем целевом альбоме: устаревший serverPhotoId перепроверяется по альбому, смена цели переносит уже загруженные фото и отклоняется, если перенести нельзя. Потеря серверной копии видна на карточке и на экране альбома, автозатирания превью нет. Изменения OpenSpec: android-album-card-sync-status, safe-album-offload.
7.9 KiB
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
- Нужен ли отдельный сценарий «перенести потерянные копии из прежнего альбома» как ручное действие, когда включённый режим уже сменился — отдельным изменением.