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