116 lines
14 KiB
Markdown
116 lines
14 KiB
Markdown
# Task 03: Критичный UX — жизненный цикл, камера, ошибки, модальные запросы (hermes-android)
|
||
|
||
**Repo:** `ochenstarik-ui/hermes-android`
|
||
**Assigned to:** Antigravity (режим оркестратора, два кодера)
|
||
**Priority:** CRITICAL (потеря состояния, утечки, тупиковый диалог)
|
||
**Date:** 2026-08-24
|
||
**Base SHA:** результат задания 02 — указать фактический SHA при выдаче
|
||
**Зависимость:** задания 01 и 02 приняты.
|
||
|
||
## Роли и протокол
|
||
|
||
| Роль | Модель | Что делает |
|
||
|---|---|---|
|
||
| Оркестратор | Antigravity | Разбивает работу, маршрутизирует, принимает результат |
|
||
| Кодер 1 | Gemini Flash 3.7 high | Реализация §Scope целиком |
|
||
| Кодер 2 | Gemini Pro high | Независимая проверка работы кодера 1 и доработка |
|
||
|
||
**Раунд 1 (кодер 1).** Сначала тесты из §Required tests с фиксацией падения на base SHA дословным выводом, затем код по пунктам §Scope. Нерешённое — в `OPEN QUESTIONS`.
|
||
**Раунд 2 (кодер 2).** Независимое воспроизведение падения своим запуском, проход §Anti-checklist с явной отметкой каждого пункта, доводка минимальным дифом, findings по шкале. Тесты кодера 1 не удалять и не ослаблять.
|
||
**Раунд 3 (оркестратор).** Приёмка только при фактическом выводе команд и подтверждении обоими кодерами.
|
||
|
||
## Проблема
|
||
|
||
**1. ViewModel не переживают смену конфигурации (`UI-01`).** `MainActivity.kt:87-92,116-118,145-147` создают `UnifiedSessionsViewModel`, `HostsViewModel`, `ChatViewModel`, `NativeSessionsViewModel` через `remember`. Поворот экрана пересоздаёт Activity и вместе с ней все VM: состояние чата, введённый текст и подписки теряются. `onCleared()` не вызывается никогда, `viewModelScope` не отменяется — корутины утекают при каждом пересоздании.
|
||
Смежно: `HostsViewModel.kt:101` — `startSignIn(context, …)` удерживает `Context` Activity на протяжении всего входа (до трёх минут).
|
||
|
||
**2. Камера работает после закрытия сканера (`UI-02`).** `QrScannerSheet.kt:148-166` — `bindToLifecycle` привязан к жизненному циклу Activity, а `onDispose` закрывает только executor. Ни `unbindAll()`, ни `scanner.close()` не вызываются: камера и ML Kit остаются активными до ухода приложения в фон, индикатор использования камеры горит. Плюс `cameraProviderFuture.addListener` навешивается заново при каждом перезапуске эффекта и никогда не снимается, а `unbindAll()` отвязывает все use-case-ы процесса.
|
||
|
||
**3. Ошибки чата вычисляются и не показываются (`UI-03`).** `ChatViewModel.kt:82-85` кладёт текст ошибки в `uiState.error`, `ChatScreen` не обращается к этому полю ни разу — 0 вхождений по файлу. Неудачная отправка промпта выглядит как «ничего не произошло». То же с `UnifiedSessionsViewModel.uiState.error`.
|
||
|
||
**4. Диалог sudo невозможно закрыть (`UI-04`).** `ChatScreen.kt:280-288` передаёт `onDismiss = { /* dismiss */ }` — пустая лямбда. Кнопка Cancel (`ClarifyDialog.kt:130-134`) и тап вне диалога не делают ничего. Пользователь заперт, пока не введёт что-нибудь и не отправит — для запроса пароля это ещё и подталкивает ввести пароль ради выхода.
|
||
|
||
**5. Запросы подтверждений глобальны (`DATA-05`).** `UnifiedSessionRepository.kt:41-44` — `activeApprovals` один общий список, `activeClarify` одно поле. `ChatViewModel.kt:28-29` отдаёт их без фильтрации: подтверждение из сессии A всплывает в чате сессии B, а второй sudo-запрос затирает первый, на который уже никто не ответит.
|
||
|
||
**6. Незащищённый `valueOf` роняет процесс (`UI-06`).** `HostsViewModel.kt:166` — `HostStatus.valueOf(existingHost.lastKnownStatus)` без `try/catch`, хотя во всех остальных местах проекта конвертация обёрнута. Неизвестное значение в БД даёт `IllegalArgumentException` в `viewModelScope` и падение.
|
||
|
||
## Scope
|
||
|
||
**1. Настоящие ViewModel (`UI-01`).**
|
||
Перевести все четыре VM на `viewModel(factory = …)` с фабрикой, получающей зависимости из `AppContainer`. Проверить, что `onCleared` вызывается и `viewModelScope` отменяется.
|
||
`ChatViewModel` привязать к маршруту чата, а не к Activity, чтобы уход с экрана освобождал VM.
|
||
`Context` для Custom Tabs передавать в момент вызова, не хранить в поле VM и не захватывать в suspend-функции.
|
||
|
||
**2. Освобождение камеры (`UI-02`).**
|
||
В `DisposableEffect` вызывать `cameraProvider.unbindAll()` и `scanner.close()`. Перейти с `addListener` на `await()`/`suspendCancellableCoroutine`, чтобы слушатели не накапливались. Колбэк `onQrScanned` обернуть в `rememberUpdatedState`. Чтение `isScanning` из фонового анализатора убрать — Compose-состояние не читается вне главного потока.
|
||
|
||
**3. Показ ошибок (`UI-03`).**
|
||
`SnackbarHost` в `Scaffold` чата и списка сессий; ошибка показывается с действием «Повторить» и сбрасывается после показа, чтобы не всплывать повторно при рекомпозиции. Текст ошибки не должен содержать секретов и сырых стектрейсов.
|
||
|
||
**4. Отменяемый модальный запрос (`UI-04`).**
|
||
Cancel и тап вне диалога закрывают его и отправляют хосту отрицательный ответ через соответствующий метод (`clarify.respond` / `sudo.respond` / `secret.respond` — уточнить у контракта, какой канал принимает отказ; если контракт этого не поддерживает, зафиксировать в `OPEN QUESTIONS` и не выдумывать метод).
|
||
Введённое значение очищать при закрытии, не оставлять в Compose-состоянии.
|
||
|
||
**5. Скоупинг подтверждений (`DATA-05`).**
|
||
`activeApprovals` и `activeClarify` ключевать по `(sessionId, hostId)`; вместо одного слота clarify — очередь, чтобы второй запрос не затирал первый. `ChatViewModel` отдаёт только записи своей сессии.
|
||
Ответ по-прежнему маршрутизируется на тот хост и рантайм-сессию, которые запрос породили — это в коде уже верно, не сломать.
|
||
|
||
**6. Безопасная конвертация статуса (`UI-06`).**
|
||
Один общий маппер `HostStatus.fromStringOrOffline()`, применённый во всех местах конвертации, включая `HostsViewModel.kt:166` и `HermesConnectionManager.toDomain`.
|
||
|
||
## Do not change
|
||
|
||
- Транспорт, парсер событий — задание 01.
|
||
- Схему БД, батчинг записи — задание 02.
|
||
- Кэши `sessionMessagesState` и автопрокрутку (`DATA-09`, `UI-07`) — задание 09.
|
||
- Иконку, строки, тему (`UI-05`, `UI-08`, `UI-09`) — задание 10.
|
||
- PKCE-флоу и `FLAG_SECURE` — задание 06.
|
||
- Дизайн экранов и композицию UI без необходимости: правки минимальные, по существу пунктов.
|
||
|
||
## Anti-checklist
|
||
|
||
1. VM переведены на `viewModel()`, но фабрика создаёт новый `AppContainer` вместо использования существующего — появились вторые экземпляры БД и менеджера соединений. Проверить, что контейнер один.
|
||
2. `ChatViewModel` привязан к Activity, а не к маршруту: уход из чата не освобождает VM, утечка сохранилась в другой форме.
|
||
3. `unbindAll()` добавлен, но `scanner.close()` забыт — ML Kit продолжает держать ресурсы.
|
||
4. Snackbar показывается, но `error` не сбрасывается — сообщение всплывает при каждой рекомпозиции.
|
||
5. Cancel закрывает диалог локально, но хосту ничего не отправляется — хост висит до таймаута. Это половина фикса, а не фикс.
|
||
6. Подтверждения отфильтрованы во ViewModel, но репозиторий по-прежнему хранит один слот clarify — второй запрос всё так же затирает первый. Проверить именно хранилище, а не отображение.
|
||
7. Тесты написаны только на VM, без проверки репозитория; дефект `DATA-05` живёт в репозитории.
|
||
8. Тест на `UI-01` не падает на base SHA, потому что проверяет фабрику, а не сохранение состояния при пересоздании. Требование: тест воспроизводит пересоздание.
|
||
9. В отчёте `green` для незапущенной команды (`AGENTS.md §3`).
|
||
|
||
## Definition of Done
|
||
|
||
- Поворот экрана в чате сохраняет введённый текст, историю и состояние выполнения.
|
||
- `onCleared` вызывается при уходе с экрана чата; `viewModelScope` отменён.
|
||
- После закрытия шторки сканера камера освобождена — подтверждено на устройстве.
|
||
- Ошибка отправки промпта видна пользователю и не повторяется после показа.
|
||
- Cancel в диалоге sudo закрывает его и хост получает отказ.
|
||
- Подтверждение, поднятое в сессии A, не отображается в сессии B; два подряд clarify от разных хостов оба доступны для ответа.
|
||
- Неизвестное значение статуса в БД не роняет приложение.
|
||
- Все существующие тесты зелёные, ни один не удалён и не ослаблен.
|
||
|
||
## Required tests
|
||
|
||
`feature/chat/ChatViewModelScopeTest.kt` — состояние переживает пересоздание; `onCleared` отменяет scope.
|
||
`core/repository/ApprovalScopingTest.kt` — подтверждение сессии A не попадает в выборку сессии B; два clarify от разных хостов сосуществуют в очереди. Обязан падать на base SHA.
|
||
`feature/chat/ClarifyCancelTest.kt` — отмена отправляет хосту отрицательный ответ.
|
||
`feature/hosts/HostStatusMappingTest.kt` — неизвестная строка статуса даёт `OFFLINE`, а не исключение. Обязан падать на base SHA.
|
||
Compose-тест (androidTest) `ChatErrorSnackbarTest` — ошибка отображается и не дублируется.
|
||
|
||
Освобождение камеры юнит-тестом не проверяется: подтверждение — на устройстве, по индикатору камеры и `adb shell dumpsys media.camera`. Без устройства пункт `UNVERIFIED`.
|
||
|
||
## Required verification
|
||
|
||
```text
|
||
./gradlew --no-daemon testDebugUnitTest
|
||
./gradlew --no-daemon connectedDebugAndroidTest
|
||
./gradlew --no-daemon lint
|
||
./gradlew --no-daemon assembleDebug
|
||
```
|
||
|
||
Приложить `git diff --stat`. Незапущенные команды указывать явно с текстом ошибки.
|
||
|
||
## Result
|
||
|
||
`agents/antigravity/done/TASK-2026-08-24-03-critical-ux.md` — разделы `## Кодер 1`, `## Кодер 2 (review + доработка)`, `## Вердикт оркестратора`. Содержимое по `AGENTS.md §4`.
|