# 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`.
