feat(android): album/photo attribute-actions and device gallery sync fixes

Album panel and photo sheet with attributes/actions for every album (server and
device), local album rename/cover/delete, EXIF attribute settings, and a unified
device-gallery sync check:

- add repository "Атрибуты и действия" panel (swipe left) on server and device
  albums: cover, info, navigation, rename/delete; album settings screen; photo
  sheet (swipe up) with info, tags and actions; EXIF attribute settings backed by
  profile preferences
- local albums use local metadata and actions: app-only display name and cover
  (new device_album_meta table), delete removes photos from the phone via the
  system delete consent
- unified mode x state policy (GallerySyncPolicy/SyncCheck) runs on start and on
  album open: upload/offload in move, restore originals in copy/off
- fix move->copy restore, offload of any linked-but-local original, provenance
  recovery that no longer overwrites the album mode, serialized reconcile and
  duplicate-upload suppression; prompt for "all files" access on start
- album card caption: name (with linked server name when different) and storage
  icons (memory card / cloud) with item count

Sync delta specs into android-client and archive the change
add-mobile-album-photo-actions.

Verified: unit/Robolectric suite green, :app:assembleDebug, :app:lint, and the
DeviceSyncE2ETest instrumented suite on the emulator (7/7).
This commit is contained in:
iu
2026-10-13 21:49:12 +03:00
parent d1bb964685
commit fa210e6c7f
75 changed files with 6094 additions and 167 deletions
@@ -0,0 +1,129 @@
# Design
## Context
См. `proposal.md` — зачем. Текущее состояние, влияющее на решение:
- `GaloApi` умеет только `albums list/get/create`, `photos list/get/upload`, `renamePhoto`,
`deletePhoto`, `movePhotos`. Доступны, но не задействованы: `PATCH/DELETE /albums/:id`,
`/albums/:id/members`, `/albums/:id/share`, `POST /photo-batch/copy`,
`/photos/:id/tags`, `/tags`, `PATCH /auth/me/preferences`.
- Сервер уже отдаёт в JSON фото `exif`, `tags`, `uploader`, `download_url`, а `/auth/me` —
`preferences`; `PhotoDto`/`UserDto` в Android этих полей не читают.
- `DeviceAlbumViewModel` держит `serverPhotoByMediaId` (сопоставление локального медиа
серверному фото), но `DeviceViewerScreen` его не получает.
- `DeviceAlbumScreen` уже использует `ModalNavigationDrawer` (стартовый/левый drawer) под
настройки синхронизации.
- Просмотр (`ViewerScreen`/`DeviceViewerScreen`) занят жестами: горизонтальный пейджер,
pinch-zoom, double-tap, панорама (drag по двум осям), плеер видео.
## Goals / Non-Goals
**Goals:**
- Единая панель «Атрибуты и действия» на всех экранах альбомов (свайп справа налево).
- Единый лист «Атрибуты и действия» в обоих просмотрах (свайп вверх).
- Резолвинг источника атрибутов: сервер / связанный сервер / локальные значения.
- Расширение клиентского API/DTO до паритета с web без изменений бэкенда.
**Non-Goals:**
- Изменения бэкенда, web и схемы spec-ов (кроме этой delta).
- Новые серверные способы работы с медиа (всё уже есть в API).
- Пересмотр существующих жестов просмотра (пейджер, зум, панорама) и drawer'а синхронизации.
## Decisions
### 1. Панель альбома = `ModalNavigationDrawer` с end-drawer
Свайп справа налево открывает правый (end) `ModalNavigationDrawer` на `AlbumScreen` и на
`DeviceAlbumScreen`. Так сохраняется симметрия: на device-экране левый drawer — синхронизация,
правый — атрибуты/действия; на серверном — только правый.
_Альтернатива_: bottom sheet на альбоме — не повторяет web-раскладку «правая панель» и хуже
для длинного списка атрибутов.
### 2. Один компонент панели; атрибуты локального альбома — локальные
Общий composable-контент панели принимает «модель атрибутов альбома», которую строит экран:
- серверный альбом → из `AlbumDto` (включая `role` из envelope);
- локальный альбом → локальные метаданные (`DeviceAlbum` + app-only метаданные), связанный
серверный альбом используется только как источник для навигации «Участники»/«Ссылки».
Секции панели показываются всегда; строки навигации, требующие серверной стороны, отображаются
неактивными, когда связи нет.
_Альтернатива_: три отдельные панели — дублирование и расхождение поведения.
### 2a. App-only метаданные локального альбома
Имя (отображаемое) и выбранная обложка локального альбома хранятся в новой Room-таблице
`device_album_meta` (ключ — id альбома-устройства). «Переименовать» пишет `displayName`,
«Выбрать обложку»/«Сбросить» — `coverUri`. Список альбомов и панель применяют их поверх
данных MediaStore.
_Альтернатива_: менять bucket в MediaStore — невозможно без прав и необратимо.
### 2b. Удаление локального альбома удаляет медиа телефона
«Удалить» собирает все URI медиа альбома и запрашивает системное согласие
(`MediaStore.createDeleteRequest`) через тот же механизм, что и разгрузка в режиме C. После
подтверждения система удаляет файлы; приложение забывает локальный индекс и метаданные
(`forgetAlbum`).
### 3. Лист фото = `ModalBottomSheet`, открытие по порогу свайпа вверх
`ViewerScreen` и `DeviceViewerScreen` добавляют `detectVerticalDragGestures` под условием:
не открывать при активном зуме, панораме и поверх контролов видео. В счёт: открывать при
`!zoomed && !isPanorama && kind != VIDEO` (для видео — через видимый drag-handle/кнопку).
_Альтернатива_: кнопка вместо жеста — расходится с исходным требованием (свайп вверх).
### 4. Резолвинг атрибутов/действий device-медиа
Если `serverPhotoByMediaId[mediaId]` есть — лист грузит серверное фото (`GET /photos/:id`) и
показывает серверные атрибуты/действия; иначе — локальную модель из `DeviceMedia`.
Сопоставление передаётся в `DeviceViewerScreen` из `DeviceAlbumViewModel`.
### 5. Расширение клиентского API/DTO (без бэкенда)
- `PhotoDto` + `Photo`: `exif`, `tags`, `uploader`, `download_url`, `likes_count`,
`comments_count` (используется то, что нужно листу).
- `UserDto`/`me()`: `preferences`.
- `GaloApi`: `updateAlbum` (title/description/cover/clear), `deleteAlbum`, `members`,
share-links, `copyPhotos`, `addTag`/`removeTag`/`tags`, `download` (через существующий
`download_url`), `savePreferences`.
- `ProfileApi` не трогаем; `preferences` добавляется в ответ `me()`.
### 6. Настройки атрибутов — новый экран, состояние в профиле
Переносим каталог `EXIF_LABELS`/`EXIF_BASE` (web `exif.ts`) в Android-ресурс/объект и храним
`photo_info.visible` + `photo_info.labels` в `preferences` через `PATCH /auth/me/preferences`
(тот же формат, что у web). _Альтернатива_: собственный формат — рассинхрон настроек между
web и мобильным для одного пользователя.
## Risks / Trade-offs
- [Конфликт жеста «вверх» с зумом/панорамой/плеером] → открывать лист только вне этих
состояний; для видео — не перехватывать жест, доступ через handle/кнопку.
- [Удаление/переименование локального альбома не имеет смысла и деструктивно] → действия
серверного уровня показываются только при связи и применяются к серверному альбому; без
связи раздел действий пуст.
- [Офлайн] → операции серверного уровня недоступны офлайн; используем уже существующее
офлайн-состояние экрана и показываем ошибку без падения.
- [Дублирование EXIF-каталога между web и Android] → переносим один в один и фиксируем
базовый набор; расхождение контролируется тестом.
- [Права доступа] → гейтим секции по `role` (владелец/админ/автор), как web.
### 7. Полная проверка синхронизации при запуске
`SyncWorker` реконсилирует все известные галереи (связанные плюс имеющие локальный индекс),
восстанавливает утраченные связи по меткам происхождения (случай частичной синхронизации) и
перевыставляет незавершённые загрузки (`requeuePending`). Запуск воркера происходит после
загрузки настроек (когда известен base URL), а смена режима на серверный сразу вызывает
`reconcile`. Та же проверка выполняется при открытии галереи. Разгрузка в режиме переноса
автоматическая: любой ассет, уже имеющий серверное фото и всё ещё хранящий оригинал на
устройстве, разгружается (включая повторные попытки и ассеты, оставшиеся «ожидающими
освобождения»). Обратный случай (переход из переноса в копии/выключено) возвращает оригиналы с
server и довосстанавливает незавершённый возврат при следующей проверке; восстановление не
обрывается на единичных ошибках. Решение по каждому ассету вынесено в политику
`GallerySyncPolicy` («режим × состояние × связанность»), которую применяет общая проверка
`SyncCheck` — так охвачены все сочетания, включая «копии с разгруженными файлами».
## Migration Plan
Изменение аддитивное, бэкенд и данные не мигрируются. Выкатывается в составе обычного релиза
Android-клиента; откат — откат версии приложения. Пользовательские `preferences` не меняют
формат, поэтому web и мобильный остаются совместимы.
## Open Questions
Нет — все решения, влияющие на спек и декомпозицию, приняты выше.