# TASK-cancel-listen: отмена слушания двойным тапом ## Симптом (с железа, 24.08) Пользователь в чате: сказал фразу → ассистент ответил. Далее **один тап** (чтобы снова начать слушать) → очки начали слушать (микрофон открыт), пользователь молчит. Теперь хочет **отменить слушание** — по замыслу это **двойной тап** — но отмена НЕ происходит: либо ничего не меняется, либо микрофон снова открывается, кружок записи гаснет/возвращается непонятно. Состояние «слушаю» залипает. ## Причина (расследование — opencode) Ключевой дефект: на стороне очков ветка `SttCancel` реагирует на `reason` некорректно. - «Пустой клик» (тап без голоса) и «двойной клик-отмена» на телефоне приходят на очки ОДИНАКОВО (`SttCancel` с `reason="user"`), и очки на любой делают `resumeSttListening()` → микрофон снова открыт, из режима не выходим. - После закрытия микрофона (`sendPhrase()`/`cancelDictation()`, `MainActivity.kt` ~279-321) флаг `listening` **остаётся `true`**, и в `AssistantGestures.kt` (~34-39) `listening` побеждает все остальные ветки → никакого двойного тапа в ANSWER/THINKING не видно, «выход из режима» недоступен. ## Целевое поведение (договорно) 1. **Один тап** (в режиме общения, не слушаю) → начать слушать (микрофон открыт, `listening=true`, красный кружок). Без изменений. 2. **Пустой клик без голоса** (тап, но человек ничего не сказал) → телефон шлёт `StopStt(cancel=false)` с пустым текстом → очки **возвращаются в «слушаю»** (как сейчас, `resumeSttListening`) — это желаемое поведение, НЕ трогать. 3. **Двойной тап в состоянии «слушаю»** → **отмена диктовки**: микрофон закрыт, `listening=false`, `thinking=false`, остаёмся в режиме общения (показываем последний ответ), НЕ выходим из режима целиком. И НЕ открываем микрофон заново. Это новое поведение — сейчас оно не работает. 4. **Таймаут тишины 30с** (`reason="timeout"`) → выход ИЗ режима общения (`exitAssistantMode`), как сейчас. НЕ трогать. 5. После любой посылки/отмены микрофон должен быть закрыт, `listening` сбрасывать на `false`. ## Механика правки (семантика протокола) `SttCancel.reason` — свободная строка, значения: - `"user"` — пустой клик без голоса → очки возвращаются в «слушаю» (resume). (без изменений) - `"timeout"` — тишина 30с → очки ВЫХОДЯТ из режима (exitAssistantMode). (без изменений) - **`"cancel"`** — НОВОЕ: явная отмена диктовки двойным тапом → очки закрывают микрофон, `listening=false`, `thinking=false`, остаются в режиме (показывают последний ответ), НЕ открывают микрофон заново. ### Точки правки (подтверди точные строки/логику при чтении) 1. **lib-core** `.../core/protocol/Messages.kt` — `SttCancel` уже несёт `reason: String = "user"`. Ввести константу-компаньон `SttCancel.REASON_CANCEL = "cancel"` (и REASON_USER/REASON_TIMEOUT для читабельности). Протокол меняется на ОБЕИХ сторонах — после правки переустанавливать И app-glasses, И app-phone (рассинхрон версий вернёт старый баг). 2. **app-phone** `.../phone/GlassesServer.kt` (~206) — обработчик `StopStt`: - `StopStt(cancel=true)` → бродкастить `SttCancel(reason=REASON_CANCEL)` (ранее шло "user"). - `StopStt(cancel=false)` с пустым `full` → `SttCancel(reason=REASON_USER)` (возврат в «слушаю»). - `StopStt(cancel=false)` с непустым текстом → `SttDone(...)` (без изменений). 3. **app-glasses** `.../glasses/.../HostConnection.kt` (~362-377) — ветка `SttCancel`: - `reason == REASON_CANCEL` → закрыть микрофон + `listening=false` + `thinking=false`, НЕ `resumeSttListening()`, НЕ выход из режима — остаться в режиме со следующим ответом. - `reason == REASON_USER` → как сейчас (`thinking=false` + `resumeSttListening()`). - `reason == REASON_TIMEOUT` → `exitAssistantMode()` (как сейчас). 4. **app-glasses** `.../glasses/MainActivity.kt` (~279-321) — `sendPhrase()` и `cancelDictation()`: после закрытия микрофона обязательно `listening=false`. Ввести/использовать `cancelDictation()` как точку «отмена слушания» (закрывает микрофон, сбрасывает `listening`). 5. **app-glasses** `.../glasses/gestures/AssistantGestures.kt` (~34-39, ~103-107) — проверить, что **двойной тап в ветке LISTENING** маршрутизируется в `cancelDictation()` (которое шлёт `StopStt(cancel=true)` → на телефоне `StopStt(cancel=true)` → `SttCancel(REASON_CANCEL)`). Убедиться, что приоритет `listening` не перебивает двойной тап и не «залипает». ## Подтвердить при чтении (не догадываться) - Реально ли `GestureDetectorWithSound` при двойном тапе НЕ дёргает промежуточный single-Click (гонка таймингов). Если дёргает — двойной тап превращается в single+single и отмена не срабатывает. При необходимости поправить дискриминацию single/double. - Точные имена полей/функций в `Messages.kt` и номера строк в `HostConnection.kt`/`GlassesServer.kt` — сверить с реальным кодом, не брать на веру из описания выше. ## Ограничения - НЕ менять поведение `reason="user"` (пустой клик → возврат в «слушаю») и `reason="timeout"` (выход из режима) — это зафиксированные решения от 23.08. - Рабочая копия: `/root/WORK/view-mate`. Не трогать абсолютные пути вне workspace. - Картинки/red-индикатор (504e66f) и историю диалога (706cc10) не ломать. - Язык кода — только Kotlin. ## Тесты - **JVM-юнит** (lib-core + app-glasses gestures): новый `SttCancel(reason="cancel")`: - очки НЕ вызывают `resumeSttListening()`, НЕ делают `exitAssistantMode()`, `listening=false`. - `reason="user"` → `resumeSttListening()` (регресс). - `reason="timeout"` → `exitAssistantMode()` (регресс). - **app-phone test** (`GlassesServerTest`): `StopStt(cancel=true)` → бродкастится `SttCancel("cancel")`; `StopStt(cancel=false)` пустой → `SttCancel("user")`; непустой → `SttDone`. - Гейт: `./gradlew :lib-core:jvmTest :app-glasses:testDebugUnitTest :app-phone:testDebugUnitTest`. - Полная сборка: `./gradlew :app-phone:assembleDebug :app-glasses:assembleDebug :lib-core:jvmTest`. ## Примечание по приёмке на железе DEBUG-крюк: `adb -s 7687f645 shell 'am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type double_click'` проверяет, как машина жествов реагирует на двойной тап в состоянии «слушаю». Проверка на очках после установки — за Hermes-ом (не агента).