502 lines
24 KiB
Kotlin
502 lines
24 KiB
Kotlin
package pw.binom.viewmate.glasses
|
|
|
|
import android.content.BroadcastReceiver
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.IntentFilter
|
|
import android.content.pm.PackageManager
|
|
import android.hardware.display.DisplayManager
|
|
import android.os.Build
|
|
import android.os.Bundle
|
|
import android.os.Handler
|
|
import android.os.Looper
|
|
import android.view.Display
|
|
import android.view.KeyEvent
|
|
import android.view.MotionEvent
|
|
import java.io.File
|
|
import androidx.activity.compose.setContent
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.offset
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.items
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.darkColorScheme
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.collectAsState
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.core.view.WindowCompat
|
|
import androidx.core.view.WindowInsetsCompat
|
|
import androidx.core.view.WindowInsetsControllerCompat
|
|
import androidx.lifecycle.Lifecycle
|
|
import androidx.lifecycle.lifecycleScope
|
|
import androidx.lifecycle.repeatOnLifecycle
|
|
import com.rayneo.arsdk.android.touch.TempleAction
|
|
import com.rayneo.arsdk.android.ui.activity.BaseEventActivity
|
|
import kotlinx.coroutines.launch
|
|
import pw.binom.viewmate.core.MediaCommand
|
|
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
|
import pw.binom.viewmate.core.protocol.SttAudio
|
|
import pw.binom.viewmate.core.protocol.StopStt
|
|
import pw.binom.viewmate.glasses.gestures.AssistantState
|
|
import pw.binom.viewmate.glasses.gestures.GestureDecision
|
|
import pw.binom.viewmate.glasses.gestures.actionName
|
|
import pw.binom.viewmate.glasses.gestures.assistantMode
|
|
import pw.binom.viewmate.glasses.gestures.gestureDecision
|
|
import pw.binom.viewmate.glasses.status.StatusPanelCompose
|
|
import pw.binom.viewmate.glasses.stt.SttMicStream
|
|
import pw.binom.viewmate.glasses.ui.AssistantOverlay
|
|
import pw.binom.viewmate.glasses.ui.Binocular
|
|
import pw.binom.viewmate.glasses.ui.BinocularScreenSide
|
|
import pw.binom.viewmate.glasses.ui.BinocularScreenSize
|
|
import pw.binom.viewmate.glasses.ui.GlassesMovieScreen
|
|
import pw.binom.viewmate.glasses.ui.Side
|
|
import pw.binom.viewmate.glasses.ui.px
|
|
import pw.binom.viewmate.glasses.ui.scaleOffsetX
|
|
|
|
class MainActivity : BaseEventActivity() {
|
|
private var resumeAfterScreenOn = false
|
|
|
|
/** Поток аудио распознавания: микрофон, на эмуляторе — wav-файл из samples. */
|
|
private val sttMic = SttMicStream(lifecycleScope) { chunk ->
|
|
GlassesApp.instance.hostConnection.sendToHost(SttAudio(chunk))
|
|
}
|
|
|
|
/**
|
|
* DEBUG-имитация жестов (эмулятор без трекпада):
|
|
* `adb shell am broadcast -a pw.binom.viewmate.glasses.DEBUG_GESTURE --es type triple_click`
|
|
* Типы: triple_click / click / double_click. Оставлен для отладки.
|
|
*/
|
|
private val debugReceiver = object : BroadcastReceiver() {
|
|
override fun onReceive(context: Context?, intent: Intent?) {
|
|
when (intent?.getStringExtra("type")) {
|
|
"triple_click" -> onTempleAction(TempleAction.TripleClick())
|
|
"click" -> onTempleAction(TempleAction.Click())
|
|
"double_click" -> onTempleAction(TempleAction.DoubleClick())
|
|
else -> log("gesture", "DEBUG_GESTURE: неизвестный тип ${intent?.getStringExtra("type")}")
|
|
}
|
|
}
|
|
}
|
|
|
|
private val screenReceiver = object : BroadcastReceiver() {
|
|
override fun onReceive(context: Context?, intent: Intent?) {
|
|
when (intent?.action) {
|
|
Intent.ACTION_SCREEN_OFF -> {
|
|
log("ui", "SCREEN_OFF receiver")
|
|
val was = GlassesApp.instance.movieController.pauseIfPlaying()
|
|
resumeAfterScreenOn = resumeAfterScreenOn || was
|
|
logEvent("SCREEN_OFF: pause wasPlaying=$was")
|
|
GlassesApp.instance.movieController.ensureWifiLock()
|
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
|
if (was) log("ui", "экран погас (receiver) — пауза")
|
|
}
|
|
Intent.ACTION_SCREEN_ON -> {
|
|
log("ui", "SCREEN_ON receiver")
|
|
logEvent("SCREEN_ON: resume flag=$resumeAfterScreenOn")
|
|
if (resumeAfterScreenOn) {
|
|
resumeAfterScreenOn = false
|
|
GlassesApp.instance.movieController.resume()
|
|
sendPositionWithRetries()
|
|
log("ui", "экран включён (receiver) — продолжаю")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
GlassesApp.instance.mainActivity = this
|
|
requestBtPermissions()
|
|
collectTempleActions()
|
|
disableTrackpadSounds()
|
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
|
setContent {
|
|
MaterialTheme(colorScheme = darkColorScheme()) {
|
|
Box(
|
|
modifier = Modifier
|
|
.fillMaxSize()
|
|
.background(Color.Black)
|
|
) {
|
|
val controller = GlassesApp.instance.movieController
|
|
val movieActive by controller.movieActive.collectAsState()
|
|
val title by controller.title.collectAsState()
|
|
val player = controller.player
|
|
if (movieActive && player != null) {
|
|
GlassesMovieScreen(player = player, title = title)
|
|
} else {
|
|
ConnectionScreen()
|
|
}
|
|
// Оверлей режима общения: поверх любого экрана (затемняет кино),
|
|
// виден в «слушаю»/«думаю»/«ответ виден».
|
|
AssistantOverlay()
|
|
}
|
|
}
|
|
}
|
|
hideSystemBars()
|
|
log("ui", "иммерсивный режим включён")
|
|
val filter = IntentFilter().apply {
|
|
addAction(Intent.ACTION_SCREEN_OFF)
|
|
addAction(Intent.ACTION_SCREEN_ON)
|
|
}
|
|
registerReceiver(screenReceiver, filter)
|
|
|
|
// DEBUG-имитация жестов на эмуляторе (см. debugReceiver). RECEIVER_EXPORTED
|
|
// на 33+ — shell `am broadcast` ходит под uid shell и не достучится до
|
|
// NOT_EXPORTED-ресивера. Ресивер ДЕБАГОВЫЙ (симулирует жесты) — см. TASK.md.
|
|
if (Build.VERSION.SDK_INT >= 33) {
|
|
registerReceiver(
|
|
debugReceiver,
|
|
IntentFilter("pw.binom.viewmate.glasses.DEBUG_GESTURE"),
|
|
Context.RECEIVER_EXPORTED,
|
|
)
|
|
} else {
|
|
registerReceiver(
|
|
debugReceiver,
|
|
IntentFilter("pw.binom.viewmate.glasses.DEBUG_GESTURE"),
|
|
)
|
|
}
|
|
|
|
val dm = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
|
dm.registerDisplayListener(object : DisplayManager.DisplayListener {
|
|
override fun onDisplayAdded(displayId: Int) {}
|
|
override fun onDisplayRemoved(displayId: Int) {}
|
|
override fun onDisplayChanged(displayId: Int) {
|
|
if (displayId != Display.DEFAULT_DISPLAY) return
|
|
val state = dm.getDisplay(displayId).state
|
|
logEvent("display state=$state")
|
|
if (state == Display.STATE_OFF) {
|
|
val was = GlassesApp.instance.movieController.pauseIfPlaying()
|
|
resumeAfterScreenOn = resumeAfterScreenOn || was
|
|
logEvent("display OFF: pause wasPlaying=$was")
|
|
GlassesApp.instance.movieController.ensureWifiLock()
|
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
|
} else if (state == Display.STATE_ON) {
|
|
logEvent("display ON: resume flag=$resumeAfterScreenOn")
|
|
if (resumeAfterScreenOn) {
|
|
resumeAfterScreenOn = false
|
|
GlassesApp.instance.movieController.resume()
|
|
sendPositionWithRetries()
|
|
}
|
|
}
|
|
}
|
|
}, null)
|
|
}
|
|
|
|
private fun requestBtPermissions() {
|
|
if (!GlassesConfig.USE_BLUETOOTH) return
|
|
val granted = BtGlassesTransport.runtimePermissions().all {
|
|
checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
|
}
|
|
if (granted) return
|
|
requestPermissions(BtGlassesTransport.runtimePermissions(), 1008)
|
|
log("app", "запрошены Bluetooth-пермишены (SPP): ${BtGlassesTransport.runtimePermissions().joinToString()}")
|
|
}
|
|
|
|
/** События трекпада (SDK) → [onTempleAction] — единственная точка входа жестов. */
|
|
private fun collectTempleActions() {
|
|
lifecycleScope.launch {
|
|
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
|
templeActionViewModel.state.collect { action ->
|
|
onTempleAction(action)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Единая точка входа жестов: SDK-событие трекпада или DEBUG-broadcast
|
|
* (debugReceiver, эмулятор). Решение — чистая [gestureDecision] по снимку
|
|
* состояния (юнит-тесты в AssistantGesturesTest), эффекты — ниже.
|
|
*/
|
|
private fun onTempleAction(action: TempleAction) {
|
|
val app = GlassesApp.instance
|
|
val state = AssistantState(
|
|
listening = app.listening.value,
|
|
thinking = app.thinking.value,
|
|
sttText = app.sttText.value,
|
|
assistantText = app.assistantText.value,
|
|
)
|
|
when (val decision = gestureDecision(action, state)) {
|
|
GestureDecision.Ignore ->
|
|
log("gesture", "жест ${actionName(action)} проигнорирован (${assistantMode(state)})")
|
|
GestureDecision.CallAssistant -> startAssistantMode()
|
|
GestureDecision.TogglePlayback -> togglePlayback()
|
|
GestureDecision.SendPhrase -> sendPhrase()
|
|
GestureDecision.NewPhrase -> startNewPhrase()
|
|
GestureDecision.CancelDictation -> cancelDictation()
|
|
GestureDecision.ExitMode -> exitAssistantMode()
|
|
is GestureDecision.Seek -> seekBy(decision)
|
|
is GestureDecision.ScrollAnswer -> {
|
|
val acc = app.answerScrollDelta.value + decision.delta
|
|
log("gesture", "свайп → прокрутка ответа: дельта ${decision.delta}px → накоплено ${acc}px")
|
|
app.answerScrollDelta.value = acc
|
|
}
|
|
}
|
|
}
|
|
|
|
/** TripleClick (кино): пауза фильма + открыть микрофон — вход в режим общения. */
|
|
private fun startAssistantMode() {
|
|
val app = GlassesApp.instance
|
|
if (app.listening.value) {
|
|
log("gesture", "TripleClick проигнорирован: распознавание уже идёт")
|
|
return
|
|
}
|
|
log("stt", "ассистент: слушаю")
|
|
if (app.movieController.pauseIfPlaying()) {
|
|
log("stt", "кино на паузе (TripleClick) — остаётся на паузе до выхода")
|
|
}
|
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
|
app.listening.value = true
|
|
app.thinking.value = false
|
|
app.sttText.value = ""
|
|
app.assistantText.value = ""
|
|
app.answerScrollDelta.value = 0f
|
|
sttMic.start(filesDir) { app.listening.value }
|
|
}
|
|
|
|
/** Click (кино): плей/пауза — как сегодня. */
|
|
private fun togglePlayback() {
|
|
log("gesture", "жест: Click → ${MediaCommand.TOGGLE}")
|
|
GlassesApp.instance.movieController.applyMediaCommand(MediaCommandMsg(MediaCommand.TOGGLE)) {
|
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
|
}
|
|
}
|
|
|
|
/** Click («слушаю»): закрыть микрофон, отправить фразу на телефон (STT → LLM). */
|
|
private fun sendPhrase() {
|
|
log("gesture", "Click → отправить фразу")
|
|
sttMic.stop()
|
|
GlassesApp.instance.thinking.value = true
|
|
lifecycleScope.launch {
|
|
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = false))
|
|
}
|
|
}
|
|
|
|
/** Click («думаю»/«ответ виден»): новая фраза — стереть ответ, открыть микрофон. */
|
|
private fun startNewPhrase() {
|
|
log("gesture", "Click → новая фраза (ответ сотёрт, микрофон открыт)")
|
|
val app = GlassesApp.instance
|
|
app.assistantText.value = ""
|
|
app.sttText.value = ""
|
|
app.thinking.value = false
|
|
app.answerScrollDelta.value = 0f
|
|
app.listening.value = true
|
|
sttMic.start(filesDir) { app.listening.value }
|
|
}
|
|
|
|
/**
|
|
* Возврат в «слушаю» после отмены распознавания (SttCancel с телефона — клик без
|
|
* голоса или двойной клик): сброс «думаю», открытие микрофона. Разговор не прерывается,
|
|
* накопленный sttText/история сохраняются (TASK-glasses-chat-fix.md касяк №2).
|
|
*/
|
|
fun resumeSttListening() {
|
|
val app = GlassesApp.instance
|
|
app.thinking.value = false
|
|
app.listening.value = true
|
|
sttMic.start(filesDir) { app.listening.value }
|
|
log("stt", "возврат в «слушаю» (SttCancel)")
|
|
}
|
|
|
|
/** DoubleClick («слушаю»): отмена распознавания — остаёмся в разговоре (без выхода). */
|
|
private fun cancelDictation() {
|
|
log("gesture", "DoubleClick → отмена распознавания")
|
|
sttMic.stop()
|
|
GlassesApp.instance.thinking.value = false
|
|
lifecycleScope.launch {
|
|
GlassesApp.instance.hostConnection.sendToHost(StopStt(cancel = true))
|
|
}
|
|
}
|
|
|
|
/** DoubleClick («думаю»/«ответ виден»): выход — закрыть оверлей; плей/пауза кино не трогаем. */
|
|
private fun exitAssistantMode() = finishAssistantMode("DoubleClick → выход из режима (кино остаётся на паузе)")
|
|
|
|
/**
|
|
* Таймаут тишины 30с с телефона (SttCancel reason="timeout"): пользователь молчит/ушёл —
|
|
* выход из режима общения (микрофон закрыть, оверлей скрыть). Вызывается из HostConnection.
|
|
*/
|
|
fun onSttTimeout() = finishAssistantMode("СтсCancel(timeout) — тишина 30с, выход из режима")
|
|
|
|
/** Общая логика выхода из режима общения: стоп микрофона + сброс состояний. */
|
|
private fun finishAssistantMode(reason: String) {
|
|
log("gesture", reason)
|
|
sttMic.stop()
|
|
val app = GlassesApp.instance
|
|
app.listening.value = false
|
|
app.thinking.value = false
|
|
app.sttText.value = ""
|
|
app.assistantText.value = ""
|
|
app.answerScrollDelta.value = 0f
|
|
}
|
|
|
|
/** Свайпы (кино): seek на ±шаг (маппинг — templeActionToCommand через [GestureDecision.Seek]). */
|
|
private fun seekBy(seek: GestureDecision.Seek) {
|
|
log("gesture", "жест: свайп → ${seek.command}")
|
|
GlassesApp.instance.movieController.applyMediaCommand(
|
|
MediaCommandMsg(seek.command, seek.seekMs)
|
|
) {
|
|
GlassesApp.instance.hostConnection.sendPlaybackPositionNow()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Звуки касания трекпада играет сам SDK (GestureDetectorWithSound, SoundPool:
|
|
* mercury_system_single_tap/fling/double_click/long_press) — это и есть
|
|
* системные щелчки очков. Отключаем все четыре типа (индексы 0..3),
|
|
* чтобы клики/свайпы не щёлкали. Распознавание жестов не затрагивается.
|
|
*/
|
|
private fun disableTrackpadSounds() {
|
|
for (soundType in 0..3) toggleSoundEffect(soundType, false)
|
|
log("ui", "звуки трекпада SDK отключены (types 0..3)")
|
|
}
|
|
|
|
/**
|
|
* Жесты SDK детектятся ТОЛЬКО в onTouchEvent (единственный канал в aar:
|
|
* BaseTouchActivity.onTouchEvent → TouchDispatcher.onMotionEvent). Поэтому
|
|
* событие НЕ съедаем — прогоняем через onTouchEvent: жест распознаётся,
|
|
* а в систему/UI событие не уходит (на очках нет таповых элементов).
|
|
* dispatchKeyEvent глушит клавиши трекпада целиком.
|
|
*/
|
|
override fun dispatchTouchEvent(event: MotionEvent): Boolean = onTouchEvent(event)
|
|
|
|
override fun dispatchKeyEvent(event: KeyEvent): Boolean = true
|
|
|
|
override fun onPause() {
|
|
super.onPause()
|
|
log("ui", "onPause")
|
|
val app = GlassesApp.instance
|
|
// Защита «очки лежат и слушают»: при потере фокуса Activity (пользователь ушёл
|
|
// в лаунчер) режим общения гарантированно закрывается — микрофон не останется открытым.
|
|
if (app.listening.value || app.thinking.value || app.assistantText.value.isNotBlank()) {
|
|
log("ui", "onPause: режим общения активен — выход (микрофон закрыт)")
|
|
finishAssistantMode("onPause — потеря фокуса, выход из режима общения")
|
|
}
|
|
val was = app.movieController.pauseIfPlaying()
|
|
resumeAfterScreenOn = resumeAfterScreenOn || was
|
|
logEvent("onPause: pause wasPlaying=$was")
|
|
app.movieController.ensureWifiLock()
|
|
app.hostConnection.sendPlaybackPositionNow()
|
|
if (was) log("ui", "экран погас — пауза")
|
|
}
|
|
|
|
override fun onResume() {
|
|
super.onResume()
|
|
log("ui", "onResume")
|
|
hideSystemBars()
|
|
logEvent("onResume: resume flag=$resumeAfterScreenOn")
|
|
if (resumeAfterScreenOn) {
|
|
resumeAfterScreenOn = false
|
|
GlassesApp.instance.movieController.resume()
|
|
sendPositionWithRetries()
|
|
log("ui", "экран включён — продолжаю")
|
|
}
|
|
}
|
|
|
|
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
|
super.onWindowFocusChanged(hasFocus)
|
|
logEvent("focus=$hasFocus")
|
|
log("ui", "focus=$hasFocus")
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
GlassesApp.instance.mainActivity = null
|
|
super.onDestroy()
|
|
unregisterReceiver(screenReceiver)
|
|
unregisterReceiver(debugReceiver)
|
|
}
|
|
|
|
/**
|
|
* Немедленная отправка позиции + повторы (0с/2с/5с): радио RayNeo просыпается
|
|
* ~5-7с после включения экрана, первый PlaybackPosition уходит только когда радио
|
|
* ожило — свежая позиция (playing=true) доедет сразу после пробуждения.
|
|
* Повторы безвредны: телефон идемпотентен.
|
|
*/
|
|
private fun sendPositionWithRetries() {
|
|
val conn = GlassesApp.instance.hostConnection
|
|
val handler = Handler(Looper.getMainLooper())
|
|
conn.sendPlaybackPositionNow()
|
|
handler.postDelayed({ conn.sendPlaybackPositionNow() }, 2_000)
|
|
handler.postDelayed({ conn.sendPlaybackPositionNow() }, 5_000)
|
|
}
|
|
|
|
private fun logEvent(msg: String) {
|
|
runCatching {
|
|
val f = File(filesDir, "events.log")
|
|
f.appendText("${System.currentTimeMillis()} $msg\n")
|
|
}
|
|
}
|
|
|
|
private fun hideSystemBars() {
|
|
window.decorView.post {
|
|
WindowInsetsControllerCompat(window, window.decorView).apply {
|
|
hide(WindowInsetsCompat.Type.systemBars())
|
|
systemBarsBehavior =
|
|
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Composable
|
|
fun ConnectionScreen() {
|
|
val connection = GlassesApp.instance.hostConnection
|
|
val connected by connection.connected.collectAsState()
|
|
val status by connection.status.collectAsState()
|
|
val messages by connection.messages.collectAsState()
|
|
val scale by GlassesApp.instance.scale.collectAsState()
|
|
|
|
// Бинокулярно: контент дублируется в оба глаза, панель — по центру каждой
|
|
// половины (явная ширина половины, иначе fillMaxSize растянет на 1280).
|
|
Binocular(scale = false) {
|
|
val screenWidthPx = BinocularScreenSize.current.width
|
|
val screenHeightPx = BinocularScreenSize.current.height
|
|
val side = BinocularScreenSide.current
|
|
|
|
// Масштаб через layout: контейнер каждого глаза — scale от половины экрана,
|
|
// сдвинут к центру (к носу) на (1 - scale) своей ширины.
|
|
val containerW = (screenWidthPx * scale).toInt()
|
|
val containerH = (screenHeightPx * scale).toInt()
|
|
|
|
Box(
|
|
modifier = Modifier
|
|
.width(containerW.px)
|
|
.height(containerH.px)
|
|
.offset(x = scaleOffsetX(side, screenWidthPx, scale).px)
|
|
.background(Color.Black),
|
|
) {
|
|
Column(
|
|
modifier = Modifier.fillMaxSize().padding(start = 24.dp, end = 24.dp, top = 56.dp),
|
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
) {
|
|
Text("ViewMate Glasses", style = MaterialTheme.typography.headlineMedium)
|
|
Text(if (connected) "подключено к телефону" else "нет соединения")
|
|
Text(status, style = MaterialTheme.typography.bodyMedium)
|
|
Text("Последние сообщения:", style = MaterialTheme.typography.titleMedium)
|
|
LazyColumn(modifier = Modifier.fillMaxWidth()) {
|
|
items(messages) { msg ->
|
|
Text(msg, style = MaterialTheme.typography.bodySmall)
|
|
}
|
|
}
|
|
}
|
|
|
|
StatusPanelCompose(
|
|
modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
|
|
scale = scale,
|
|
)
|
|
// Оверлей распознавания/ответа — в AssistantOverlay (корень, поверх любого экрана).
|
|
}
|
|
}
|
|
}
|