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,2 @@
schema: spec-driven
created: 2026-10-16
@@ -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
- Нужен ли отдельный сценарий «перенести потерянные копии из прежнего альбома» как ручное
действие, когда включённый режим уже сменился — отдельным изменением.
@@ -0,0 +1,61 @@
# Proposal
## Why
Реальный инцидент: альбом устройства U2 синхронизировался в режиме переноса в серверный
альбом A (фото загружены, локальные оригиналы заменены превью), затем альбом A удалили на
сервере, а альбом устройства перепривязали к новому серверному альбому U2. В U2 уехало всего
1 фото: остальные на телефоне уже были превью, и загрузить их заново было нечем. Оригиналы
остались только в корзине сервера, а приложение молча оставило телефон с превью.
Три причины:
- решение о разгрузке принимается по `serverPhotoId` в локальном индексе, без проверки, что
это фото вообще есть в **текущем** целевом альбоме;
- смена целевого альбома только перезаписывает связь: уже загруженные фото никто не переносит
на сервере, а из превью повторно не загрузить;
- потеря серверной копии — тихий тупик: `ORPHANED` виден, но нигде не сказано, что оригинал
утрачен, и нет явного предупреждения.
## What Changes
- **Доверие только к живой копии.** Записанный `serverPhotoId` считается действительным,
только если это фото присутствует в целевом альбоме. Если копии нет: при локальном
оригинале ассет загружается заново, при одном превью элемент помечается потерявшим копию.
- **Разгрузка только под защитой.** Замена оригинала превью выполняется, только если копия
подтверждена в целевом альбоме; разгрузка «по памяти индекса» запрещена. Автоматическая
разгрузка в режиме переноса сохраняется.
- **Смена цели переносит копии.** При смене целевого альбома приложение сначала переносит на
сервере уже загруженные этим альбомом устройства фото в новый альбом и переключает связь
только если перенос удался; иначе связь не меняется и пользователь получает отказ.
- **Потеря копии видна.** Экран альбома устройства и карточка альбома в списке показывают,
сколько фото осталось без серверной копии; никакого автоматического удаления превью.
- **BREAKING**: нет. Меняются только защитные условия; успешные сценарии (загрузка,
разгрузка, повторная синхронизация) сохраняют поведение.
## Capabilities
### New Capabilities
_Нет — поведение относится к существующим способностям._
### Modified Capabilities
- `phone-album-sync`: разгрузка требует живой копии в текущем целевом альбоме; записанный
`serverPhotoId` перепроверяется по целевому альбому; смена целевого альбома переносит уже
загруженные фото и отклоняется, если перенести нельзя.
- `android-client`: потеря серверной копии показывается на экране альбома устройства и на
карточке в списке; автоматического удаления превью нет.
## Impact
- **Код (Android):** `data/sync/SyncPlan` (доверие к живой копии), `data/sync/SyncCoordinator`
(защищённая разгрузка, перенос при смене цели), `data/sync/OffloadManager` (условие разгрузки),
`data/upload/UploadWorker` (передача живой копии после загрузки), `data/db/dao/SyncDao` и
`data/repo/SyncRepository` (счёт потерянных копий), `ui/devicealbum/DeviceAlbumViewModel`
(смена цели, разгрузка, предупреждение), `ui/devicealbum/DeviceAlbumScreen` (баннер),
`domain/sync/AlbumSyncProgress` и `ui/albums/*` (сигнал на карточке).
- **Схема Room:** без изменений; добавляются только запросы.
- **API и бэкенд:** не затрагиваются (используется существующий `/api/photo-batch/move`).
- **Веб-клиент:** не затрагивается.
- **Не затрагивается:** ручные загрузки в серверный альбом; восстановление уже потерянных
копий из корзины сервера (по решению пользователя это отдельная задача).
@@ -0,0 +1,24 @@
# Spec Delta
## ADDED Requirements
### Requirement: Видимость потерянной серверной копии
Приложение SHALL явно показывать, сколько фото альбома устройства остались без серверной
копии: на экране альбома устройства и на его карточке в списке. Приложение SHALL NOT
удалять такие превью автоматически.
#### Scenario: Экран альбома устройства
- **WHEN** хотя бы одно фото альбома устройства потеряло серверную копию
- **THEN** экран альбома показывает предупреждение с числом таких фото
#### Scenario: Карточка в списке
- **WHEN** у альбома устройства есть фото без серверной копии
- **THEN** карточка показывает это в третьей строке выделенным цветом
#### Scenario: Автоматического удаления нет
- **WHEN** элемент помечен как потерявший серверную копию
- **THEN** превью остаётся на устройстве, пока пользователь сам его не удалит
@@ -0,0 +1,82 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Разгрузка оригиналов в режиме переноса
В режиме C после успешной загрузки приложение SHALL автоматически заменять оригинал
на устройстве превью, без отдельного действия на экране, но SHALL делать это только если
серверная копия подтверждена в текущем целевом альбоме; видео SHALL заменяться
постером-кадром. При включении режима C приложение SHALL запрашивать широкий доступ
к файлам, чтобы замена выполнялась без системного диалога согласия.
#### Scenario: Разгрузка фото
- **WHEN** оригинал фото успешно загружен
- **THEN** файл в галерее автоматически заменяется превью с сохранением даты съёмки и места в альбоме
#### Scenario: Разгрузка видео
- **WHEN** оригинал видео успешно загружен
- **THEN** видео в галерее автоматически заменяется статичным постером-кадром, а элемент в приложении остаётся видео с оригиналом на сервере
#### Scenario: Доступ к файлам для разгрузки
- **WHEN** пользователь включает режим переноса
- **THEN** приложение запрашивает широкий доступ к файлам, и разгрузка выполняется без системного диалога согласия
#### Scenario: Копия не подтверждена
- **WHEN** серверная копия ассета отсутствует в текущем целевом альбоме
- **THEN** оригинал на устройстве не заменяется превью
## ADDED Requirements
### Requirement: Доверие только к живой серверной копии
Приложение SHALL считать записанный идентификатор серверного фото действительным только
если это фото присутствует в целевом альбоме. Если копии в целевом альбоме нет, приложение
SHALL при локальном оригинале загрузить ассет заново, а при одном локальном превью SHALL
пометить элемент как потерявший серверную копию.
#### Scenario: Копия пропала, оригинал локально
- **WHEN** записанное серверное фото ассета отсутствует в целевом альбоме, а оригинал ещё на устройстве
- **THEN** приложение загружает ассет в целевой альбом заново
#### Scenario: Копия пропала, локально только превью
- **WHEN** записанное серверное фото ассета отсутствует в целевом альбоме, а на устройстве только превью
- **THEN** ассет не загружается повторно и помечается как потерявший серверную копию
#### Scenario: Копия есть
- **WHEN** записанное серверное фото присутствует в целевом альбоме
- **THEN** ассет считается синхронизированным, и повторной загрузки не происходит
#### Scenario: Чужое фото не удаляется
- **WHEN** ассет исчез с устройства, а его записанное серверное фото не принадлежит целевому альбому
- **THEN** приложение не удаляет это фото на сервере
### Requirement: Перенос копий при смене целевого альбома
При смене целевого серверного альбома приложение SHALL перенести в новый альбом уже
загруженные этим альбомом устройства фото и SHALL переключить связь только если все
копии, доступные в прежнем альбоме, оказались в новом. Если перенести нельзя, связь
SHALL остаться прежней, а пользователь SHALL получить уведомление об отказе.
#### Scenario: Перенос удался
- **WHEN** пользователь выбирает новый целевой альбом, а прежние загрузки доступны
- **THEN** приложение переносит их в новый альбом и переключает связь
#### Scenario: Прежний альбом недоступен
- **WHEN** прежний целевой альбом удалён или недоступен
- **THEN** связь не меняется, и пользователь уведомляется, что фото перенести не удалось
#### Scenario: Часть копий перенести нельзя
- **WHEN** часть прежних загрузок недоступна для переноса
- **THEN** связь не переключается, и пользователь уведомляется, сколько фото не удалось перенести
@@ -0,0 +1,31 @@
# Tasks
## 1. Доверие к живой серверной копии в планировщике
- [x] 1.1 В `SyncPlanner.plan` учитывать только идентификаторы фото, присутствующих в целевом альбоме (значения `serverChecksums`): устаревшая запись ведёт к дедупу или повторной загрузке, а удаление серверных фото ограничивается живым набором; покрыть `SyncPlannerTest` случаями «копия пропала, оригинал локально», «копия пропала, локально превью» и «чужое фото не удаляется» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncPlannerTest"`).
- [x] 1.2 Проверить сценарии координатора: устаревший `serverPhotoId` при локальном оригинале ставит загрузку в очередь, а не разгружает, и не порождает удаление чужого фото (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncCoordinatorTest"`).
## 2. Защищённая разгрузка
- [x] 2.1 Изменить `AssetOffloader.offload`, принимая живой набор серверных фото, и добавить исход «копии нет»; `OffloadManager` SHALL отказываться от разгрузки без живой копии — проверить `OffloadManagerTest` (`cd android && ./gradlew :app:testDebugUnitTest --tests "*OffloadManagerTest"`).
- [x] 2.2 Передать живой набор в разгрузку из `SyncCoordinator.offloadUploaded`, из `UploadWorker.recordProvenance` (только что созданное фото) и из `DeviceAlbumViewModel.freeSpace` (набор из текущего целевого альбома, при ошибке — без разгрузки); покрыть `SyncCoordinatorTest` случаем «копия не подтверждена — разгрузки нет» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncCoordinatorTest"`).
## 3. Перенос копий при смене целевого альбома
- [x] 3.1 Добавить в `SyncCoordinator` операцию смены цели: перенести фото провенанса в новый альбом, перечитать новый альбом и переключить связь только при подтверждённом переносе; при отказе связь не менять и вернуть результат с числом непереносимых фото; покрыть `SyncCoordinatorTest` тремя сценариями (перенос удался, прежний альбом недоступен, часть копий не переносится) (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncCoordinatorTest"`).
- [x] 3.2 Перевести `DeviceAlbumViewModel.setTarget` и `createAlbum` на новую операцию и показать отказ пользователю; покрыть `DeviceAlbumViewModel` тестом либо тестом экрана, что при отказе связь сохраняется (`cd android && ./gradlew :app:testDebugUnitTest --tests "*DeviceAlbum*"`).
## 4. Видимость потерянной серверной копии
- [x] 4.1 Добавить в `SyncDao`/`SyncRepository` счётчик ассетов в состоянии «потеряна копия» по альбомам и юнит-тест на него (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncRepositoryTest"`).
- [x] 4.2 Расширить `AlbumSyncProgress` числом потерянных копий так, чтобы это состояние перекрывало прогресс и выделялось цветом; покрыть `AlbumSyncProgressTest` и связать со счётчиком в `AlbumsViewModel` (`cd android && ./gradlew :app:testDebugUnitTest --tests "*AlbumSyncProgressTest" --tests "*AlbumsViewModelTest"`).
- [x] 4.3 Показать предупреждение с числом потерянных копий на экране альбома устройства и на карточке в списке; покрыть `AlbumsContentTest` и `DeviceAlbumContentTest`, убедившись, что автоматического удаления превью нет (`cd android && ./gradlew :app:testDebugUnitTest --tests "*AlbumsContentTest" --tests "*DeviceAlbumContentTest"`).
## 5. Интеграционная проверка
- [x] 5.1 Прогнать весь Android-набор и собрать debug-APK (`cd android && ./gradlew test :app:assembleDebug`).
## Workflow follow-up
- Заархивировать изменение после прохождения ревью проекта.
- Проверить результат архивации.