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,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
|
||||
|
||||
- Заархивировать изменение после прохождения ревью проекта.
|
||||
- Проверить результат архивации.
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-10-16
|
||||
@@ -0,0 +1,92 @@
|
||||
# Design
|
||||
|
||||
## Context
|
||||
|
||||
См. `proposal.md` — Why. Ниже — что в коде определяет подход.
|
||||
|
||||
- `SyncCoordinator.reconcile` получает `serverChecksums(link.serverAlbumId)` и вычисляет
|
||||
`livePhotoIds = serverChecksums.values.toSet()`, но `SyncPlanner.plan` и `offloadUploaded`
|
||||
этот набор не используют: первый смотрит только на `indexed.serverPhotoId != null`, второй —
|
||||
на `entity.serverPhotoId != null`. `reflectLostCopies` умеет лишь перекрасить
|
||||
`PREVIEW ↔ ORPHANED` и тоже по `livePhotoIds`.
|
||||
- Разгрузка вызывается из трёх мест: `SyncCoordinator.offloadUploaded` (режим C),
|
||||
`UploadWorker.recordProvenance` (сразу после успешной загрузки) и
|
||||
`DeviceAlbumViewModel.freeSpace` (ручное освобождение по согласию системы).
|
||||
- `AssetOffloader.offload(asset)` не требует доказательств, что копия жива.
|
||||
- `SyncRepository.link` перезаписывает `serverAlbumId`; смену цели обслуживают
|
||||
`DeviceAlbumViewModel.setTarget` и `createAlbum` — оба просто линкуют и запускают reconcile.
|
||||
- Серверный перенос фото есть: `SyncRemote.movePhotos` → `POST /api/photo-batch/move`,
|
||||
который пропускает удалённые фото и возвращает число перенесённых, не сообщая об ошибке за
|
||||
пропуск.
|
||||
- `LocalAssetState.ORPHANED` уже вычисляется и показывается перечёркнутым облаком на элементе;
|
||||
счётчика и заметного предупреждения нет.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Сделать невозможной разгрузку без подтверждённой копии в текущем целевом альбоме.
|
||||
- Не терять связь с уже загруженными фото при смене целевого альбома.
|
||||
- Сделать потерю серверной копии видимой и оставить её устранение явным действием.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Восстановление уже потерянных копий из корзины сервера.
|
||||
- Предупреждение об удалении серверного альбома из веб-клиента: сервер и веб не знают о
|
||||
зависимости альбома устройства.
|
||||
- Ручные загрузки в серверный альбом и их режимы.
|
||||
|
||||
## Decisions
|
||||
|
||||
**Единственный источник доверия — живой список фото целевого альбома.** И `plan`, и разгрузка
|
||||
опираются на `livePhotoIds`, уже полученный в `reconcile`. Альтернатива — отдельный запрос
|
||||
«фото существует» — отклонена: лишние round-trip и рассинхрон.
|
||||
|
||||
**Планировщик лечит устаревшую запись, а не игнорирует её.** Ветка «записанный `photoId`»
|
||||
срабатывает только если `photoId ∈ livePhotoIds`; иначе ассет проходит обычный путь: дедуп по
|
||||
контрольной сумме, а при отсутствии — загрузка. Удаление серверных фото в конце плана тоже
|
||||
ограничено `livePhotoIds`, чтобы не тронуть чужую копию.
|
||||
|
||||
**Разгрузка требует доказательства в подписи.** `AssetOffloader.offload(asset,
|
||||
liveServerPhotoIds)` и новый исход `OffloadResult.NotOnServer`, если копии нет. Так инвариант
|
||||
живёт в одном месте, а вызывающий обязан доказать, что копия жива:
|
||||
- `offloadUploaded` передаёт `livePhotoIds` из `reconcile`;
|
||||
- `recordProvenance` передаёт `setOf(photo.id)` — только что созданное фото;
|
||||
- `freeSpace` сначала читает `serverChecksums` текущего линка и передаёт его значения; при
|
||||
ошибке не разгружает ничего.
|
||||
|
||||
**Смена цели — транзакция «перенести, проверить, переключить».** `SyncCoordinator.retarget`
|
||||
переносит фото провенанса этого альбома в новый альбом, затем перечитывает новый альбом и
|
||||
убеждается, что все переносимые копии на месте; только тогда переключает связь. Если прежний
|
||||
альбом недоступен или копии не доехали — связь остаётся прежней, результат возвращается как
|
||||
отказ. Альтернатива «переключить и попробовать потом» отклонена: именно она дала инцидент.
|
||||
|
||||
**Уже потерянные копии не блокируют смену цели, но сообщаются.** Фото, которых нет ни в
|
||||
прежнем альбоме, ни в новом (например, давно осиротевшие), перенести нельзя; они считаются и
|
||||
возвращаются в результате, чтобы пользователь знал, но не мешают переключить альбом.
|
||||
|
||||
**Потеря копии показывается из `ORPHANED`, а не из проверки существования альбома.**
|
||||
Существование целевого альбома при офлайне недоказуемо, а `ORPHANED` выставляется только после
|
||||
успешного чтения альбома. Один новый запрос `GROUP BY deviceAlbumId` по `state = 'ORPHANED'`
|
||||
даёт счётчик для карточки и баннера; на карточке потеря копии перекрывает прогресс операции.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Перенос пропускает удалённые фото без ошибки] → проверяем результат чтением нового
|
||||
альбома, а не ответом `move`; ненулевое расхождение — отказ.
|
||||
- [Лишние `serverChecksums` в `freeSpace` и `retarget`] → это редкие явные действия, не цикл;
|
||||
при ошибке сети ничего не разгружаем и не переключаем.
|
||||
- [Планировщик начинает перезаливать содержимое, если сервер не отдал контрольную сумму] →
|
||||
`serverChecksums` падает вместе с `reconcile` (исключение до применения плана), поэтому
|
||||
«пустой живой список из-за ошибки» не доходит до загрузок.
|
||||
- [Счётчик `ORPHANED` в карточке устаревает между проходами] → приемлемо: он обновляется тем
|
||||
же потоком Room, что и всё представление, и означает «на последнем успешном проходе».
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Схема Room не меняется; миграций нет. Откат — обычная сборка без изменения.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Нужен ли отдельный сценарий «перенести потерянные копии из прежнего альбома» как ручное
|
||||
действие, когда включённый режим уже сменился — отдельным изменением.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Proposal
|
||||
|
||||
## Why
|
||||
|
||||
Реальный инцидент: альбом устройства U2 синхронизировался в режиме переноса в серверный
|
||||
альбом A (фото загружены, локальные оригиналы заменены превью), затем альбом A удалили на
|
||||
сервере, а альбом устройства перепривязали к новому серверному альбому U2. В U2 уехало всего
|
||||
1 фото: остальные на телефоне уже были превью, и загрузить их заново было нечем. Оригиналы
|
||||
остались только в корзине сервера, а приложение молча оставило телефон с превью.
|
||||
|
||||
Три причины:
|
||||
- решение о разгрузке принимается по `serverPhotoId` в локальном индексе, без проверки, что
|
||||
это фото вообще есть в **текущем** целевом альбоме;
|
||||
- смена целевого альбома только перезаписывает связь: уже загруженные фото никто не переносит
|
||||
на сервере, а из превью повторно не загрузить;
|
||||
- потеря серверной копии — тихий тупик: `ORPHANED` виден, но нигде не сказано, что оригинал
|
||||
утрачен, и нет явного предупреждения.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Доверие только к живой копии.** Записанный `serverPhotoId` считается действительным,
|
||||
только если это фото присутствует в целевом альбоме. Если копии нет: при локальном
|
||||
оригинале ассет загружается заново, при одном превью элемент помечается потерявшим копию.
|
||||
- **Разгрузка только под защитой.** Замена оригинала превью выполняется, только если копия
|
||||
подтверждена в целевом альбоме; разгрузка «по памяти индекса» запрещена. Автоматическая
|
||||
разгрузка в режиме переноса сохраняется.
|
||||
- **Смена цели переносит копии.** При смене целевого альбома приложение сначала переносит на
|
||||
сервере уже загруженные этим альбомом устройства фото в новый альбом и переключает связь
|
||||
только если перенос удался; иначе связь не меняется и пользователь получает отказ.
|
||||
- **Потеря копии видна.** Экран альбома устройства и карточка альбома в списке показывают,
|
||||
сколько фото осталось без серверной копии; никакого автоматического удаления превью.
|
||||
- **BREAKING**: нет. Меняются только защитные условия; успешные сценарии (загрузка,
|
||||
разгрузка, повторная синхронизация) сохраняют поведение.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
_Нет — поведение относится к существующим способностям._
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `phone-album-sync`: разгрузка требует живой копии в текущем целевом альбоме; записанный
|
||||
`serverPhotoId` перепроверяется по целевому альбому; смена целевого альбома переносит уже
|
||||
загруженные фото и отклоняется, если перенести нельзя.
|
||||
- `android-client`: потеря серверной копии показывается на экране альбома устройства и на
|
||||
карточке в списке; автоматического удаления превью нет.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Код (Android):** `data/sync/SyncPlan` (доверие к живой копии), `data/sync/SyncCoordinator`
|
||||
(защищённая разгрузка, перенос при смене цели), `data/sync/OffloadManager` (условие разгрузки),
|
||||
`data/upload/UploadWorker` (передача живой копии после загрузки), `data/db/dao/SyncDao` и
|
||||
`data/repo/SyncRepository` (счёт потерянных копий), `ui/devicealbum/DeviceAlbumViewModel`
|
||||
(смена цели, разгрузка, предупреждение), `ui/devicealbum/DeviceAlbumScreen` (баннер),
|
||||
`domain/sync/AlbumSyncProgress` и `ui/albums/*` (сигнал на карточке).
|
||||
- **Схема Room:** без изменений; добавляются только запросы.
|
||||
- **API и бэкенд:** не затрагиваются (используется существующий `/api/photo-batch/move`).
|
||||
- **Веб-клиент:** не затрагивается.
|
||||
- **Не затрагивается:** ручные загрузки в серверный альбом; восстановление уже потерянных
|
||||
копий из корзины сервера (по решению пользователя это отдельная задача).
|
||||
@@ -0,0 +1,24 @@
|
||||
# Spec Delta
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Видимость потерянной серверной копии
|
||||
|
||||
Приложение SHALL явно показывать, сколько фото альбома устройства остались без серверной
|
||||
копии: на экране альбома устройства и на его карточке в списке. Приложение SHALL NOT
|
||||
удалять такие превью автоматически.
|
||||
|
||||
#### Scenario: Экран альбома устройства
|
||||
|
||||
- **WHEN** хотя бы одно фото альбома устройства потеряло серверную копию
|
||||
- **THEN** экран альбома показывает предупреждение с числом таких фото
|
||||
|
||||
#### Scenario: Карточка в списке
|
||||
|
||||
- **WHEN** у альбома устройства есть фото без серверной копии
|
||||
- **THEN** карточка показывает это в третьей строке выделенным цветом
|
||||
|
||||
#### Scenario: Автоматического удаления нет
|
||||
|
||||
- **WHEN** элемент помечен как потерявший серверную копию
|
||||
- **THEN** превью остаётся на устройстве, пока пользователь сам его не удалит
|
||||
@@ -0,0 +1,82 @@
|
||||
# Spec Delta
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Разгрузка оригиналов в режиме переноса
|
||||
|
||||
В режиме C после успешной загрузки приложение SHALL автоматически заменять оригинал
|
||||
на устройстве превью, без отдельного действия на экране, но SHALL делать это только если
|
||||
серверная копия подтверждена в текущем целевом альбоме; видео SHALL заменяться
|
||||
постером-кадром. При включении режима C приложение SHALL запрашивать широкий доступ
|
||||
к файлам, чтобы замена выполнялась без системного диалога согласия.
|
||||
|
||||
#### Scenario: Разгрузка фото
|
||||
|
||||
- **WHEN** оригинал фото успешно загружен
|
||||
- **THEN** файл в галерее автоматически заменяется превью с сохранением даты съёмки и места в альбоме
|
||||
|
||||
#### Scenario: Разгрузка видео
|
||||
|
||||
- **WHEN** оригинал видео успешно загружен
|
||||
- **THEN** видео в галерее автоматически заменяется статичным постером-кадром, а элемент в приложении остаётся видео с оригиналом на сервере
|
||||
|
||||
#### Scenario: Доступ к файлам для разгрузки
|
||||
|
||||
- **WHEN** пользователь включает режим переноса
|
||||
- **THEN** приложение запрашивает широкий доступ к файлам, и разгрузка выполняется без системного диалога согласия
|
||||
|
||||
#### Scenario: Копия не подтверждена
|
||||
|
||||
- **WHEN** серверная копия ассета отсутствует в текущем целевом альбоме
|
||||
- **THEN** оригинал на устройстве не заменяется превью
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Доверие только к живой серверной копии
|
||||
|
||||
Приложение SHALL считать записанный идентификатор серверного фото действительным только
|
||||
если это фото присутствует в целевом альбоме. Если копии в целевом альбоме нет, приложение
|
||||
SHALL при локальном оригинале загрузить ассет заново, а при одном локальном превью SHALL
|
||||
пометить элемент как потерявший серверную копию.
|
||||
|
||||
#### Scenario: Копия пропала, оригинал локально
|
||||
|
||||
- **WHEN** записанное серверное фото ассета отсутствует в целевом альбоме, а оригинал ещё на устройстве
|
||||
- **THEN** приложение загружает ассет в целевой альбом заново
|
||||
|
||||
#### Scenario: Копия пропала, локально только превью
|
||||
|
||||
- **WHEN** записанное серверное фото ассета отсутствует в целевом альбоме, а на устройстве только превью
|
||||
- **THEN** ассет не загружается повторно и помечается как потерявший серверную копию
|
||||
|
||||
#### Scenario: Копия есть
|
||||
|
||||
- **WHEN** записанное серверное фото присутствует в целевом альбоме
|
||||
- **THEN** ассет считается синхронизированным, и повторной загрузки не происходит
|
||||
|
||||
#### Scenario: Чужое фото не удаляется
|
||||
|
||||
- **WHEN** ассет исчез с устройства, а его записанное серверное фото не принадлежит целевому альбому
|
||||
- **THEN** приложение не удаляет это фото на сервере
|
||||
|
||||
### Requirement: Перенос копий при смене целевого альбома
|
||||
|
||||
При смене целевого серверного альбома приложение SHALL перенести в новый альбом уже
|
||||
загруженные этим альбомом устройства фото и SHALL переключить связь только если все
|
||||
копии, доступные в прежнем альбоме, оказались в новом. Если перенести нельзя, связь
|
||||
SHALL остаться прежней, а пользователь SHALL получить уведомление об отказе.
|
||||
|
||||
#### Scenario: Перенос удался
|
||||
|
||||
- **WHEN** пользователь выбирает новый целевой альбом, а прежние загрузки доступны
|
||||
- **THEN** приложение переносит их в новый альбом и переключает связь
|
||||
|
||||
#### Scenario: Прежний альбом недоступен
|
||||
|
||||
- **WHEN** прежний целевой альбом удалён или недоступен
|
||||
- **THEN** связь не меняется, и пользователь уведомляется, что фото перенести не удалось
|
||||
|
||||
#### Scenario: Часть копий перенести нельзя
|
||||
|
||||
- **WHEN** часть прежних загрузок недоступна для переноса
|
||||
- **THEN** связь не переключается, и пользователь уведомляется, сколько фото не удалось перенести
|
||||
@@ -0,0 +1,31 @@
|
||||
# Tasks
|
||||
|
||||
## 1. Доверие к живой серверной копии в планировщике
|
||||
|
||||
- [x] 1.1 В `SyncPlanner.plan` учитывать только идентификаторы фото, присутствующих в целевом альбоме (значения `serverChecksums`): устаревшая запись ведёт к дедупу или повторной загрузке, а удаление серверных фото ограничивается живым набором; покрыть `SyncPlannerTest` случаями «копия пропала, оригинал локально», «копия пропала, локально превью» и «чужое фото не удаляется» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncPlannerTest"`).
|
||||
- [x] 1.2 Проверить сценарии координатора: устаревший `serverPhotoId` при локальном оригинале ставит загрузку в очередь, а не разгружает, и не порождает удаление чужого фото (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncCoordinatorTest"`).
|
||||
|
||||
## 2. Защищённая разгрузка
|
||||
|
||||
- [x] 2.1 Изменить `AssetOffloader.offload`, принимая живой набор серверных фото, и добавить исход «копии нет»; `OffloadManager` SHALL отказываться от разгрузки без живой копии — проверить `OffloadManagerTest` (`cd android && ./gradlew :app:testDebugUnitTest --tests "*OffloadManagerTest"`).
|
||||
- [x] 2.2 Передать живой набор в разгрузку из `SyncCoordinator.offloadUploaded`, из `UploadWorker.recordProvenance` (только что созданное фото) и из `DeviceAlbumViewModel.freeSpace` (набор из текущего целевого альбома, при ошибке — без разгрузки); покрыть `SyncCoordinatorTest` случаем «копия не подтверждена — разгрузки нет» (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncCoordinatorTest"`).
|
||||
|
||||
## 3. Перенос копий при смене целевого альбома
|
||||
|
||||
- [x] 3.1 Добавить в `SyncCoordinator` операцию смены цели: перенести фото провенанса в новый альбом, перечитать новый альбом и переключить связь только при подтверждённом переносе; при отказе связь не менять и вернуть результат с числом непереносимых фото; покрыть `SyncCoordinatorTest` тремя сценариями (перенос удался, прежний альбом недоступен, часть копий не переносится) (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncCoordinatorTest"`).
|
||||
- [x] 3.2 Перевести `DeviceAlbumViewModel.setTarget` и `createAlbum` на новую операцию и показать отказ пользователю; покрыть `DeviceAlbumViewModel` тестом либо тестом экрана, что при отказе связь сохраняется (`cd android && ./gradlew :app:testDebugUnitTest --tests "*DeviceAlbum*"`).
|
||||
|
||||
## 4. Видимость потерянной серверной копии
|
||||
|
||||
- [x] 4.1 Добавить в `SyncDao`/`SyncRepository` счётчик ассетов в состоянии «потеряна копия» по альбомам и юнит-тест на него (`cd android && ./gradlew :app:testDebugUnitTest --tests "*SyncRepositoryTest"`).
|
||||
- [x] 4.2 Расширить `AlbumSyncProgress` числом потерянных копий так, чтобы это состояние перекрывало прогресс и выделялось цветом; покрыть `AlbumSyncProgressTest` и связать со счётчиком в `AlbumsViewModel` (`cd android && ./gradlew :app:testDebugUnitTest --tests "*AlbumSyncProgressTest" --tests "*AlbumsViewModelTest"`).
|
||||
- [x] 4.3 Показать предупреждение с числом потерянных копий на экране альбома устройства и на карточке в списке; покрыть `AlbumsContentTest` и `DeviceAlbumContentTest`, убедившись, что автоматического удаления превью нет (`cd android && ./gradlew :app:testDebugUnitTest --tests "*AlbumsContentTest" --tests "*DeviceAlbumContentTest"`).
|
||||
|
||||
## 5. Интеграционная проверка
|
||||
|
||||
- [x] 5.1 Прогнать весь Android-набор и собрать debug-APK (`cd android && ./gradlew test :app:assembleDebug`).
|
||||
|
||||
## Workflow follow-up
|
||||
|
||||
- Заархивировать изменение после прохождения ревью проекта.
|
||||
- Проверить результат архивации.
|
||||
Reference in New Issue
Block a user