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

10 KiB
Raw Permalink Blame History

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

  • Нужна ли поддержка «уменьшения движения» (отключение пульсации по системной настройке).