Files
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

11 KiB
Raw Permalink Blame History

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-перезагрузки, чтобы убрать дублирующий запрос? На поведение и разбивку задач не влияет.