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