Files
view-mate/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/MainActivity.kt
T

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