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