Files
ds-galo/openspec/changes/archive/2026-10-16-android-live-albums/design.md
T
iu 14db79632a chore(openspec): архивировать android-live-albums и fix-album-cover-readiness
Дельта android-live-albums: android-client (+3 требования: живое обновление
списка, догон при переподключении, потеря доступа) и realtime (порядок списка
теперь правило отображения клиента).

Дельта fix-album-cover-readiness: realtime (+1 требование: album.updated при
готовности вариантов фото-обложки).
2026-10-16 12:48:32 +03:00

124 lines
11 KiB
Markdown
Raw 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` — мотивация. Состояние и ограничения, определяющие подход:
- Android подписан на SSE **только** пер-альбомно: `AlbumSseClient` вызывается из
`AlbumViewModel` и живёт ровно столько, сколько живёт экран альбома. Парсер
`AlbumEventParser` сворачивает `album.updated` и `album.deleted` в один `AlbumChanged`,
а неизвестные типы возвращает как `null` (тихо игнорирует).
- Список альбомов — offline-first: UI наблюдает Room, а `AlbumRepository.refresh()`
заменяет кэш серверным снимком через `replaceAll`.
- `AlbumDto` уже несёт форму элемента `GET /api/albums` (`cover_thumb_url`,
`cover_medium_url`, `photos_count`, `members_count`), и `AlbumEntity` хранит те же поля,
поэтому payload события ложится в кэш без сборки объекта вручную.
- В Room между `photos` и `albums` **нет** `ForeignKey`/`CASCADE`: удаление строки альбома
не уносит его фото.
- `TokenStore` не наблюдаем: «залогинен» проверяется императивно, `logout()` чистит токен и
запускает навигацию. Наблюдаемого сигнала сессии, на который можно подписать стрим, нет.
- `AppContainer` живёт с процессом и запускает фоновые вещи в `start()` (прецедент —
`DeviceMediaObserver`), но у него нет крючка «сессия началась/закончилась».
- SSE-клиент Android авторизуется заголовком `Authorization` через интерцептор, поэтому
query-параметр `token`, нужный вебу, здесь не требуется.
- Бэкенд уже всё публикует: удаление альбома владельцем уходит и в канал альбома, и в
личные потоки; исключение участника доходит до исключённого **только** личным потоком
(в канал альбома уходит `member.left`, который клиент не использует).
## Goals / Non-Goals
**Goals:**
- Обновлять список альбомов из персонального потока без запроса списка на каждое событие.
- Закрыть потерю доступа одним путём, покрывающим и удаление владельцем, и исключение.
- Не терять изменения, пропущенные за время разрыва соединения.
**Non-Goals:**
- Центр уведомлений и приглашения (в Android их нет; `notification` — игнорируем).
- Серверный обрыв пер-альбомного SSE при исключении участника — существующее ограничение.
- Изменение пер-альбомного контракта событий и REST.
- Работа подписки в фоне и обход Doze.
- Сортировка списка — отдельный change (`android-album-sort`).
## Decisions
**1. Дом подписки — ViewModel главного экрана, клиент — в `AppContainer`.**
`AlbumsViewModel` создаётся на стартовой записи навигации после входа, переживает уход на
экран альбома/профиля (запись остаётся в back stack) и уничтожается на выходе из аккаунта
(`popUpTo ... inclusive`). Это функциональный аналог вебовой подписки на уровне layout'а:
жизненный цикл сессии даёт навигация, а не процессный scope.
Альтернативы: (а) `AppContainer.scope` — как веб, но нет наблюдаемой сессии и подписка
живёт в фоне; (б) подписка на каждом экране — соединения дублируются, список всё равно
остаётся мёртвым. Сам клиент кладём в `AppContainer` рядом с `albumSseClient` — как
зависимость, чтобы ViewModel оставался тонким.
**2. События применяются в Room, а не в UI-состояние.**
Обработчик вызывает `AlbumRepository` (upsert/удаление), поэтому и список, и открытый
экран альбома получают изменение сами, без дополнительных запросов.
Альтернатива — править `UiState` списка напрямую: тогда открытый экран альбома не узнаёт
ничего, и логика расходится с offline-first моделью.
**3. Потеря доступа — через переход состояния кэша, один путь на два триггера.**
`album.deleted` (удаление владельцем или исключение) удаляет альбом из Room; экран альбома
реагирует на переход «альбом был ненулевым и стал `null`». Флаг «уже видел» отличает это
от честного холодного старта без кэша и без сети.
Альтернатива — явные события (`AlbumDeleted` из пер-альбомного парсера и `album.deleted`
из личного) с прямым `onBack()`: два пути, дублирование и потребность знать собственный
`user_id` для разбора `member.left` (сейчас экран знает только роль).
**4. Удаление альбома уносит и его фото.**
Так как FK с каскадом нет, удаление в репозитории должно чистить и `photos` этого альбома.
Иначе сетка удалённого альбома остаётся живой из кэша.
**5. Отдельный клиент и парсер для личного потока.**
Личный поток несёт `album.created`, `album.updated`, `album.deleted` и `notification`.
`notification` игнорируется явно: у него собственный `type` внутри payload'а
(`member.joined`, `like.added`), который нельзя путать с типом SSE-события.
Альтернатива — обобщить `AlbumSseClient` параметром запроса и парсера: пока переиспользования
мало, обобщение усложняет существующий рабочий путь.
**6. Догон при реконнекте — существующий `AlbumRepository.refresh()`.**
`refresh()` делает `replaceAll` серверным снимком, поэтому заодно уносит альбомы,
удалённые за время разрыва. Отдельный механизм догона не нужен.
**7. Пер-альбомную обработку не трогаем.**
Открытый экран получает `album.updated`/`album.deleted` и из своего канала, и косвенно из
личного. Обработка идемпотентна (`upsert`/удаление), дублирующая работа невелика, а
трогать работающий путь ради экономии одного запроса не стоит.
**8. Unit-тесты гоняются только на debug-варианте.**
Compose-тестовая активность (`androidx.activity.ComponentActivity`) объявляется
`ui-test-manifest`, который подключён как debug-зависимость, поэтому release-манифест её
не содержит и Robolectric не может запустить `createComposeRule`. Источники тестов
вариант-агностичны, поэтому прогон один раз на debug покрывает то же самое, а тестовая
активность не попадает в production-манифест. Альтернатива — подключить
`ui-test-manifest` как `implementation` — отвергнута: она добавляет тестовую активность
в release-APK.
## Risks / Trade-offs
- **Отмена коллектора может не отменить `EventSource`** (`closed.await()` бросает до
`source.cancel()`) → утечка соединения и цикл реконнектов на удалённом альбоме → покрыть
тестом и, если подтвердится, завершать соединение в `finally`/`invokeOnClose`.
- **`sseClient` собран без `TokenAuthenticator`** → на 401 посреди длинной сессии клиент не
обновит токен, а переоткроется с прежним → покрыть сценарием истечения токена.
- **Порядок списка меняется от живых событий** (альбом упорядочены по времени изменения)
→ принято осознанно; средство управления порядком приходит в `android-album-sort`.
- **Исключённый участник продолжает получать пер-альбомный SSE** до разрыва соединения →
существующее ограничение, не закрывается здесь.
- **Фон и Doze** → подписка не переживает вытеснение процесса; реконнект и догон
восстанавливают состояние при возвращении.
## Migration Plan
Изменение аддитивное: схемы Room, эндпоинтов и контрактов не меняем. Выкатывается обычным
обновлением приложения; откат — предыдущая версия APK, при которой список снова обновляется
только по REST-снимку. Данные на сервере и в кэше не мигрируются.
## Open Questions
- Стоит ли позже перевести пер-альбомный `album.updated` на применение payload'а в Room
вместо REST-перезагрузки, чтобы убрать дублирующий запрос? На поведение и разбивку
задач не влияет.