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 (корень, поверх любого экрана). } } }