Files
iu 1eb1177273 feat(android): статус синхронизации на карточке и безопасная разгрузка
Карточка альбома устройства получает третью строку: метку режима («Копии»/
«Перенос») и ход загрузки. Процент считается как (uploaded + running) / total
для обоих синхронизируемых режимов, пульсирует только при активной загрузке,
показывает очередь, ожидание Wi-Fi и ошибку. В режиме переноса карточка
показывает и карту памяти, и облако — режимы различает метка, а не значки.

Разгрузка оригиналов теперь требует подтверждённой серверной копии в текущем
целевом альбоме: устаревший serverPhotoId перепроверяется по альбому, смена
цели переносит уже загруженные фото и отклоняется, если перенести нельзя.
Потеря серверной копии видна на карточке и на экране альбома, автозатирания
превью нет.

Изменения OpenSpec: android-album-card-sync-status, safe-album-offload.
2026-10-16 18:36:51 +03:00

115 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
- Нужна ли поддержка «уменьшения движения» (отключение пульсации по системной настройке).