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,114 @@
# Design
## Context
См. `proposal.md` — Why. Ниже только то, что определяет подход.
- Карточка собирается из данных без представления о синхронизации: `AlbumListBuilder.build`
получает `List<Album>` (серверные), `List<AlbumLink>` и `List<DeviceAlbum>`, а
`AlbumsScreen.AlbumCardBody` рисует вторую строку по `StoragePlacement`, выведенному из
`SyncMode`.
- Очередь загрузки уже привязана к альбому устройства: `UploadJobEntity.deviceAlbumId` и
`assetKey` заполняются `enqueueSync`, но доменная модель `UploadJob` эти поля отбрасывает,
а `UploadDao` умеет только `observeAll()`.
- Индекс провенанса (`device_assets`) хранит строку на каждый ассет, у которого есть серверное
фото (`serverPhotoId`), и состояние из `LocalAssetState`; `SyncDao` читает его по одному
альбому (`observeAssets`), агрегата «сколько загружено по всем альбомам» нет.
- Прогресс пишется пофайлово: `StreamingRequestBody` считает `sent/total`, `UploadWorker`
сохраняет `progress` 0..100 на RUNNING-джобе (throttled по изменению процента).
- `job.size` для sync-джобов равен `0L` (реальная длина берётся из file descriptor в момент
отправки), поэтому байтовое взвешивание по очереди недоступно без изменения схемы.
- В приложении нет ни одной бесконечной анимации: `rememberInfiniteTransition` не
используется, «уменьшение движения» нигде не обрабатывается.
## Goals / Non-Goals
**Goals:**
- Различить режимы B и C на карточке, не теряя информацию о локальной копии в C.
- Показать ход операции копирования прямо на карточке, включая ожидание и ошибку.
- Держать прогресс монотонным и вычислять его из уже существующих данных (без изменения схемы
Room).
- Сохранить чистую, тестируемую сборку списка.
**Non-Goals:**
- Прогресс показывается для обоих синхронизируемых режимов (B и C): и копии, и перенос
загружают оригиналы, поэтому процент операции не должен зависеть от режима.
- Ручные загрузки в серверный альбом (у них `deviceAlbumId = null`, отдельный экран).
- Изменение самой синхронизации, её политик или серверной части.
- Веб-клиент.
## Decisions
**Метка режима вместо вариантов облака.** Третья строка называет режим («Копии»/«Перенос»),
а значок карты памяти показывается и в C. Альтернатива — варианты глифа облака
(«облако↑»/«облако✓») — отклонена: в сетке медиа те же облако/перечёркнутое облако уже
означают состояние **элемента**, и третья трактовка глифа запутала бы. Альтернатива
«переопределить SD как «есть локальная копия», режимы не различать» отклонена: B и C
сливаются, а режим — главное, что различает карточки.
**SD в режиме переноса означает «есть локальная копия» (превью), а не «оригинал здесь».**
Трактовка расширяется относительно текущей спеки; описана в delta-спеке явно.
**Формула процента — гибрид, а не просто счётчик.** `percent = clamp((uploaded + running) /
total * 100)`, где `uploaded` — число строк индекса с `serverPhotoId`, `running` — сумма
`progress/100` по RUNNING-джобам альбома, `total` — `itemCount` альбома устройства. Формула
общая для копий и переноса. Альтернативы: `uploaded/total` замирает на большом видео;
среднее по `job.progress` «едет назад», потому что `clearCompleted` удаляет SUCCESS-джобы и
знаменатель тает. Гибрид монотонен: при завершении файла его дробь `1.0` уходит из `running`,
а `uploaded` увеличивается на единицу.
**Процент считается по альбому, а не по текущей очереди.** Знаменатель — полное число
элементов альбома, поэтому добавление новых фото корректно понижает процент, а не
перезапускает его. Дедуп (`SyncAction.Link`, без загрузки) тоже засчитывается как «дошло» и
может дать скачок вверх — это осознанно.
**Пульсация включается только при наличии RUNNING-загрузок.** Иначе альбом, ждущий Wi‑Fi
(`wifiOnlyUploads` + отсутствие `UNMETERED`), пульсировал бы вечно. Состояния разводятся по
статусу: `RUNNING` → процент с пульсацией, `QUEUED` → «в очереди», `QUEUED` при Wi‑Fi-only →
«ждёт Wi‑Fi», `ERROR` → акцент ошибки, ничего нет → «Копии».
**Данные для списка — агрегатные запросы, а не N подписок.** В `UploadDao` добавляется поток
sync-джобов (`deviceAlbumId IS NOT NULL`), в `SyncDao` — `GROUP BY deviceAlbumId` по числу
строк с `serverPhotoId`. Оба потока сводятся в `AlbumsViewModel` вместе с существующей
`combine`, а в `AlbumListBuilder` попадает готовая карта «deviceAlbumId → статус», поэтому
сама сборка остаётся чистой функцией и тестируется без Room.
**Новый доменный тип статуса.** `AlbumSyncProgress` (режим + процент/счётчик очереди/ошибка/
ожидание Wi‑Fi) с чистой функцией сборки из (режим, агрегат очереди, число загруженных,
`itemCount`). Альтернатива — тащить поля в `AlbumListItem` по отдельности — отклонена:
размазывает правило и усложняет тесты.
**Пульсация — альфа-анимация текста.** `rememberInfiniteTransition` поверх прозрачности
процента; полосы нет. Дешёвая анимация в одной строке, не меняет высоту плитки.
**Граничные случаи.** `total == 0` → процент не показывается, состояние падает в очередь/
простой. Неизвестный режим в БД по-прежнему трактуется как `DISABLED` (существующее
поведение `SyncMode.from`).
## Risks / Trade-offs
- [Процент скачет из-за дедупа] → принято: скачок вверх честно отражает, что файл уже был на
сервере; монотонность важнее равномерности.
- [`total` из MediaStore и индекс расходятся (reconcile ещё не проходил)] → процент стартует
не с нуля и может превысить 100; клампим и опираемся на статус очереди, а не только на число.
- [Несколько загрузок параллельно делают `running` многократным] → сумма дробей корректна, но
процент может «забегать» вперёд при параллелизме; это допустимо для индикатора хода.
- [Первая в приложении бесконечная анимация] → нет обработки «уменьшения движения»; риск для
пользователей с motion sensitivity. Митигация: пульсирует только текст и только при активной
загрузке; вопрос reduce-motion вынесен в открытые.
- [Производительность: агрегаты на каждую эмиссию списка] → запросы покрыты индексами
(`upload_jobs.status`, `upload_jobs.albumId`) и `GROUP BY` по `device_assets`; объёмы на
устройстве невелики.
- [Провал, попавший в `ERROR` после `MAX_ATTEMPTS`, оставляет процент ниже 100] → карточка
показывает состояние ошибки вместо тихого плато.
## Migration Plan
Схема Room не меняется, миграций нет. Раскатка — обычная сборка APK; откат — релиз без
изменения (старые данные совместимы, поле `deviceAlbumId` уже присутствует).
## Open Questions
- Нужна ли поддержка «уменьшения движения» (отключение пульсации по системной настройке).
@@ -0,0 +1,62 @@
# Proposal
## Why
Карточка альбома в Android-клиенте кодирует режим синхронизации одними значками, и это
перестало быть информативным. В режиме переноса (C) на телефоне остаются превью, но
карточка показывает только облако — «локальной копии» не видно. Добавить карте памяти в C
нельзя без последствий: тогда карты памяти и облако будут у копий (B) и переноса (C)
одинаковы, и режимы станут неразличимы.
Отдельно невидим прогресс копирования (B). Единственный след работы — счётчик в рейле и
экран «Загрузки»; на самой карточке не понять, сколько уже на сервере, идёт ли заливка,
ждёт ли она Wi‑Fi или упала. Провал заливки сейчас вообще не проявляется в списке альбомов.
## What Changes
- Карточка альбома устройства получает **метку режима** третьей строкой: «Копии» для B,
«Перенос» для C, ничего для выключенной синхронизации (A) и серверных альбомов.
- Карта памяти (SD) теперь показывается и для режима переноса: `[SD][Cloud]`. Режимы
различаются не значками, а меткой режима, поэтому SD читается как «есть локальная копия»
(оригинал в B, превью в C).
- Для режима копий третья строка показывает **процент операции**, пока заливка активна:
`«Копии 62%»`. Текст процента пульсирует, пока есть работающие загрузки.
- Состояния вместо/рядом с процентом: `«Копии 8 в очереди»` для ожидающих, `«Копии — ждёт
Wi‑Fi»` для ожидающих по ограничению сети, `«Копии — ошибка»` при провале, просто
`«Копии»` в простое.
- Процент считается по альбому: `(uploaded + running) / total`, где `uploaded` — записи
индекса провенанса с серверным фото, `running` — дроби `progress/100` работающих загрузок,
`total` — число элементов альбома устройства. Формула держит монотонность и не «едет
назад» при завершении файлов.
- Пульсация включается только при наличии работающей загрузки, поэтому альбом, ждущий
Wi‑Fi, не выглядит как активный.
- **BREAKING**: нет. Для серверных альбомов и выключенной синхронизации карточка не меняется;
меняется только вид карточек альбомов устройства с включённой синхронизацией.
## Capabilities
### New Capabilities
_Нет — поведение расширяет существующую способность `android-client`._
### Modified Capabilities
- `android-client`: требование «Подпись карточки альбома» переформулируется (метка режима
третьей строкой; карта памяти показывается и для переноса) и дополняется новым
требованием о прогрессе операции копирования с его состояниями.
## Impact
- **Код (Android):** `ui/albums/AlbumListItem` (модель строки: метка режима и прогресс),
`ui/albums/AlbumListBuilder` (сборка прогресса в элемент списка), `ui/albums/AlbumsViewModel`
(агрегат очереди загрузок и индекса), `ui/albums/AlbumsScreen` (`AlbumCardBody`: третья
строка, пульсация), доменная модель `domain/model/Upload` (поле привязки к альбому
устройства), `data/upload/UploadRepository`, `data/db/dao/UploadDao` и `SyncDao`
(счётные запросы по альбому).
- **Схема Room:** новых таблиц/колонок не требуется — `upload_jobs.deviceAlbumId` и
`device_assets` уже хранят всё нужное; добавляются только запросы.
- **API и бэкенд:** не затрагиваются.
- **Веб-клиент:** не затрагивается.
- **Не затрагивается / отдельно:** прогресс в фазе загрузки режима переноса (C) — тот же
механизм применим, но в объём этого изменения не входит; ручные загрузки в серверный
альбом на карточке не отражаются (у них нет привязки к альбому устройства).
@@ -0,0 +1,73 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Подпись карточки альбома
Карточка альбома SHALL показывать в первой строке имя альбома, а для связанной пары — со
стрелкой имя связанного серверного альбома, и только когда имена различаются. Вторая строка
SHALL показывать значками расположение содержимого и число элементов: значок карты памяти для
локального без синхронизации, значки карты памяти и облака для режимов копий и переноса; для
серверного альбома — значок облака. Третья строка SHALL называть режим синхронизации
(«Копии» или «Перенос») и SHALL отсутствовать у выключенной синхронизации и у серверного
альбома.
#### Scenario: Локальный без синхронизации
- **WHEN** показан локальный альбом без синхронизации
- **THEN** вторая строка показывает значок карты памяти и число элементов, а третьей строки нет
#### Scenario: Режим копий
- **WHEN** локальный альбом синхронизируется в режиме копий
- **THEN** вторая строка показывает значки карты памяти и облака и число элементов, а третья называет режим «Копии»
#### Scenario: Режим переноса
- **WHEN** локальный альбом синхронизируется в режиме переноса
- **THEN** вторая строка показывает значки карты памяти и облака и число элементов, а третья называет режим «Перенос»
#### Scenario: Серверный альбом
- **WHEN** показан серверный альбом
- **THEN** вторая строка показывает значок облака и число фото, а третьей строки нет
#### Scenario: Разные имена связанной пары
- **WHEN** имя локального альбома не совпадает с именем связанного серверного
- **THEN** первая строка показывает «имя → имя серверного»
#### Scenario: Одинаковые имена
- **WHEN** имена совпадают
- **THEN** стрелка и имя серверного не показываются
## ADDED Requirements
### Requirement: Прогресс операции загрузки
Для альбома устройства с включённой синхронизацией (копии или перенос) карточка SHALL
показывать ход загрузки в третьей строке: процент, пока есть работающие загрузки (текст
пульсирует); число ожидающих без активной работы; отдельные состояния ожидания Wi-Fi и
ошибки; в простое — только метку режима. Процент SHALL считаться по альбому и SHALL NOT
убывать при завершении отдельных файлов.
#### Scenario: Идёт копирование
- **WHEN** у альбома в режиме копий есть хотя бы одна работающая загрузка
- **THEN** третья строка показывает «Копии <процент>%», и текст пульсирует
#### Scenario: Идёт перенос
- **WHEN** у альбома в режиме переноса есть хотя бы одна работающая загрузка
- **THEN** третья строка показывает «Перенос <процент>%», и текст пульсирует
#### Scenario: Очередь без активной работы
- **WHEN** загрузки альбома ожидают, но ни одна не работает
- **THEN** третья строка показывает число ожидающих, и пульсации нет
#### Scenario: Ожидание Wi-Fi
- **WHEN** загрузки ждут только из-за ограничения «только Wi-Fi»
- **THEN** третья строка сообщает ожидание Wi-Fi, и пульсации нет
#### Scenario: Ошибка
- **WHEN** последняя попытка загрузки файла провалилась
- **THEN** третья строка показывает ошибку выделенным цветом, и пульсации нет
#### Scenario: Простой
- **WHEN** незавершённых загрузок нет
- **THEN** третья строка показывает только метку режима
#### Scenario: Монотонность
- **WHEN** один файл завершается, пока остальные ещё загружаются
- **THEN** показанный процент не уменьшается
@@ -0,0 +1,32 @@
# Tasks
## 1. Агрегаты данных для карточки
- [x] 1.1 Добавить в `UploadDao`/`UploadRepository` поток sync-загрузок с полями `deviceAlbumId`, `status`, `progress`, `wifiOnly` (джобы ручной загрузки с `deviceAlbumId = null` исключены) и доменную модель для него; проверить тестом в `data/repo`, что выборка не смешивает альбомы и отбрасывает ручные загрузки (`cd android && ./gradlew test --tests "*SyncUpload*"`).
- [x] 1.2 Добавить в `SyncDao` групповой запрос числа ассетов с непустым `serverPhotoId` по `deviceAlbumId` и метод в `SyncRepository`; проверить тестом, что альбом без серверных фото даёт 0, а альбом с провенансом — фактическое число (`cd android && ./gradlew test --tests "*SyncRepository*"`).
## 2. Модель статуса синхронизации карточки
- [x] 2.1 Добавить доменный тип `AlbumSyncProgress` и чистую функцию сборки из (режим, список sync-загрузок альбома, число загруженных, `itemCount`); покрыть юнит-тестами пульсацию при `RUNNING`, «в очереди» без активных, «ждёт Wi‑Fi» для `QUEUED` + `wifiOnly`, ошибку при `ERROR`, простой без загрузок, кламп процента и случай `total == 0` (`cd android && ./gradlew test --tests "*AlbumSyncProgress*"`).
- [x] 2.2 Покрыть тестами монотонность формулы: при завершении одного файла из нескольких показанный процент не уменьшается (`cd android && ./gradlew test --tests "*AlbumSyncProgress*"`).
## 3. Сборка списка
- [x] 3.1 Расширить `AlbumListItem.DeviceAlbumItem` статусом и принимать карту «deviceAlbumId → AlbumSyncProgress» в `AlbumListBuilder.build`; проверить в `AlbumListBuilderTest`, что статус попадает в нужную карточку, а серверные карточки остаются без него (`cd android && ./gradlew test --tests "*AlbumListBuilderTest"`).
- [x] 3.2 Связать агрегаты (задачи 1.1–1.2) с существующей `combine` в `AlbumsViewModel` и покрыть `AlbumsViewModelTest` случаем, когда очередь и индекс дают ожидаемый статус для альбома устройства (`cd android && ./gradlew test --tests "*AlbumsViewModelTest"`).
## 4. Карточка альбома
- [x] 4.1 Изменить `placementOf` так, чтобы режим переноса давал `DEVICE_AND_CLOUD` (карта памяти + облако); обновить/добавить проверку в `AlbumsContentTest` для трёх режимов (`cd android && ./gradlew test --tests "*AlbumsContentTest"`).
- [x] 4.2 Добавить третью строку карточки: метка режима («Копии»/«Перенос») и текст статуса копий (процент, «в очереди», «ждёт Wi‑Fi», «ошибка»); процент пульсирует через `rememberInfiniteTransition` только при `RUNNING`; покрыть `AlbumsContentTest` и завести тест-теги для строки статуса (`cd android && ./gradlew test --tests "*AlbumsContentTest"`).
- [x] 4.3 Показывать процент операции и в режиме переноса: загрузка оригиналов идёт и в C, поэтому процент считается по общей формуле для B и C, а метка даёт «Перенос <процент>%»; покрыть `AlbumSyncProgressTest` и `AlbumsContentTest` (`cd android && ./gradlew :app:testDebugUnitTest --tests "*AlbumSyncProgressTest" --tests "*AlbumsContentTest"`).
## 5. Интеграционная проверка
- [x] 5.1 Прогнать весь Android-набор и убедиться, что он зелёный, затем собрать debug-APK (`cd android && ./gradlew test && ./gradlew :app:assembleDebug`).
## Workflow follow-up
- Заархивировать изменение после прохождения ревью проекта.
- Проверить результат архивации.