package pw.binom.rayneovm.glasses import android.app.Activity import android.content.Intent import android.content.IntentFilter import android.media.projection.MediaProjectionManager import android.os.Bundle import android.util.Log import android.view.Display import android.view.InputDevice import android.view.MotionEvent import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import kotlinx.coroutines.launch import pw.binom.rayneovm.glasses.audio.AudioBridge import pw.binom.rayneovm.glasses.input.TempleGestureDetector import pw.binom.rayneovm.glasses.protocol.VmGlassesController import pw.binom.rayneovm.glasses.ui.GlassesScreen import pw.binom.rayneovm.glasses.vm.GlassesScreenState class MainActivity : ComponentActivity() { /** Последний обработанный tick запроса проекции (см. onCreate). */ private var handledTick = 0L /** Контроллер очков — нужен и Compose, и распознавателю жестов дужки. */ private lateinit var controller: VmGlassesController /** * Распознаватель жестов тач-сенсора дужки. Дужка на X3 Pro — отдельное input-устройство * `cyttsp5_mt` без ассоциированного дисплея, поэтому события не доходят до Compose и * перехватываются в [dispatchTouchEvent]. Жесты уходят гостю через * [VmGlassesController.sendTempleGesture]. */ private val templeGestureDetector by lazy { TempleGestureDetector { gesture -> controller.sendTempleGesture(gesture) } } /** id input-устройства дужки (`cyttsp5_mt`); -1 — не найдено. */ private val templeDeviceId: Int by lazy { InputDevice.getDeviceIds().firstOrNull { id -> InputDevice.getDevice(id)?.name?.contains(TEMPLE_DEVICE_HINT) == true } ?: -1 } /** * Пользовательский диалог MediaProjection. При выставленном appop `PROJECT_MEDIA = allow` * он не показывается — интент сразу возвращает RESULT_OK (нужно для задачи #2). */ private val projectionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val data = result.data if (result.resultCode == Activity.RESULT_OK && data != null) { val mp = getSystemService(MediaProjectionManager::class.java) .getMediaProjection(result.resultCode, data) if (mp != null) { Log.i(TAG, "MediaProjection получен") AudioBridge.onProjectionGranted(mp) } else { Log.w(TAG, "getMediaProjection вернул null") } } else { Log.w(TAG, "MediaProjection не выдан: resultCode=${result.resultCode}") } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // HOME живёт на display 0. Если систему (или гостя на VirtualDisplay) // угораздит запросить домашний экран внутри чужого дисплея — мы бы // открылись сами внутри себя (зеркальная рекурсия), поэтому выходим. val displayId = display?.displayId ?: Display.DEFAULT_DISPLAY if (displayId != Display.DEFAULT_DISPLAY) { finish() return } enableEdgeToEdge() ShizukuBootstrap.attach(this) // Диагностика: печатаем input-устройства и id дужки, чтобы в логе было видно, // куда приходят жесты (см. dispatchTouchEvent). InputDevice.getDeviceIds().forEach { id -> val d = InputDevice.getDevice(id) Log.i(TAG, "input device id=$id name=${d?.name} sources=0x${d?.sources?.toString(16)}") } Log.i(TAG, "temple device id=$templeDeviceId (hint='$TEMPLE_DEVICE_HINT')") // Запросы MediaProjection приходят из VmService через AudioBridge (задача #2). // Запускаем интент только когда Activity в RESUMED (реально видима): если её запускать // из не-foreground состояния, системная grant-активити отменяется и возвращает CANCELED. // // handledTick обязателен: repeatOnLifecycle перезапускает сбор при каждом выходе // Activity в RESUMED (а после диалога проекции мы как раз возвращаемся в RESUMED). // Без него один и тот же tick запускал бы запрос повторно. lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { AudioBridge.requestTick.collect { tick -> if (tick > handledTick) { handledTick = tick launchProjectionRequest() } } } } val controller = VmGlassesController.fromContext(this) this.controller = controller setContent { CompositionLocalProvider(VmGlassesController.Local provides controller) { MaterialTheme(colorScheme = darkColorScheme()) { Box( modifier = Modifier .fillMaxSize() .background(Color.Black) ) { val shizukuState by ShizukuBootstrap.state.collectAsState() DriveShizukuState(controller, shizukuState) GlassesScreen( controller = controller, onRequestShizukuPermission = { ShizukuBootstrap.refresh() }, ) } } } } } override fun onResume() { super.onResume() AudioBridge.activityResumed = true ShizukuBootstrap.refresh() } /** * Перехватываем тач-события окна ДО Compose/View-иерархии. События с устройства дужки * (`cyttsp6_mt`) не ассоциированы с дисплеем и иначе теряются: `onTouchEvent` Activity * при этом может не вызываться вовсе. Всё, что пришло с дужки, отдаём распознавателю и * потребляем, чтобы не утекло в UI. */ override fun dispatchTouchEvent(event: MotionEvent): Boolean { // Диагностика: на ACTION_DOWN печатаем устройство — видно, доходят ли касания дужки // (deviceId тача дужки = 4, `cyttsp5_mt`) до окна Activity вообще. if (event.actionMasked == MotionEvent.ACTION_DOWN) { Log.d( TAG, "touch down: deviceId=${event.deviceId} source=0x${event.source.toString(16)} " + "x=${event.x} y=${event.y}", ) } val isTemple = event.deviceId == templeDeviceId && templeDeviceId != -1 if (!isTemple) return super.dispatchTouchEvent(event) Log.d(TAG, "temple: action=${event.actionMasked} x=${event.x} y=${event.y}") templeGestureDetector.onTouchEvent(event) return true } override fun onPause() { AudioBridge.activityResumed = false super.onPause() } override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray, ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == ShizukuBootstrap.REQUEST_CODE_PERMISSION) { val grant = grantResults.firstOrNull() ?: -1 ShizukuBootstrap.onRequestPermissionResult(requestCode, grant) } } private fun launchProjectionRequest() { runCatching { val mpm = getSystemService(MediaProjectionManager::class.java) projectionLauncher.launch(mpm.createScreenCaptureIntent()) }.onFailure { Log.w(TAG, "Не удалось запустить запрос MediaProjection: ${it.message}") } } companion object { private const val TAG = "MainActivity" /** * Имя input-устройства тач-сенсора дужки RayNeo X3 Pro (проверено через `getevent`: * все касания дужки приходят на `/dev/input/event2`, устройство `cyttsp5_mt`). * Второй контроллер `cyttsp6_mt` не генерирует событий. */ private const val TEMPLE_DEVICE_HINT = "cyttsp5_mt" } } @Composable private fun DriveShizukuState(controller: VmGlassesController, shizuku: ShizukuBootstrap.AllowState) { LaunchedEffect(shizuku) { when (shizuku) { ShizukuBootstrap.AllowState.NotRunning, ShizukuBootstrap.AllowState.Waiting, ShizukuBootstrap.AllowState.NeedsPermission -> { if (controller.state.value !is GlassesScreenState.Running) { controller.setState(GlassesScreenState.ShizukuMissing) } } ShizukuBootstrap.AllowState.Granted -> { // Если мы сейчас в ShizukuMissing — переходим в NoAppSelected. // Running не трогаем: приложение уже запущено, Shizuku-статус не должен его ронять. if (controller.state.value is GlassesScreenState.ShizukuMissing) { controller.setState(GlassesScreenState.NoAppSelected) } } } } }