14 KiB
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
- VM переведены на
viewModel(), но фабрика создаёт новыйAppContainerвместо использования существующего — появились вторые экземпляры БД и менеджера соединений. Проверить, что контейнер один. ChatViewModelпривязан к Activity, а не к маршруту: уход из чата не освобождает VM, утечка сохранилась в другой форме.unbindAll()добавлен, ноscanner.close()забыт — ML Kit продолжает держать ресурсы.- Snackbar показывается, но
errorне сбрасывается — сообщение всплывает при каждой рекомпозиции. - Cancel закрывает диалог локально, но хосту ничего не отправляется — хост висит до таймаута. Это половина фикса, а не фикс.
- Подтверждения отфильтрованы во ViewModel, но репозиторий по-прежнему хранит один слот clarify — второй запрос всё так же затирает первый. Проверить именно хранилище, а не отображение.
- Тесты написаны только на VM, без проверки репозитория; дефект
DATA-05живёт в репозитории. - Тест на
UI-01не падает на base SHA, потому что проверяет фабрику, а не сохранение состояния при пересоздании. Требование: тест воспроизводит пересоздание. - В отчёте
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
./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.