diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..809ccd4 --- /dev/null +++ b/TASKS.md @@ -0,0 +1,4 @@ +1. [ ] Стартовый экран (тот когда не запущино ничего на вирутальных мониторах) отображается криво, не бинокулдярно. Он тоже должен быть бинокулярным. +2. [ ] Надо добавить возможность проброса звука с виртуального дисплея на телефон. Должен быть переключатель через что выводить звук: через телефон или с очков. то есть надо сделать саму механику проброса, а потом кнопку переключатиль в мобильном приложении +3. [ ] Когда приложение на виртуальном дисплее запущено отображается положение мышки (жолтая точка). Когда она долго не двигается надо её скрывать на очках +4. [ ] Доработать функционал клавиатуры. 1. когда на самих очках поле ввода в фокусе НЕ нужно отображать клавиатуру на очках. 2. когда на телефоне тыкаю на кнопку клавиатуры нужно отобразить клавиатуру, и чтобы все что там вводится вводилось на очках \ No newline at end of file diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt index 13a2a47..2eeac68 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt @@ -36,6 +36,7 @@ import pw.binom.rayneovm.shared.CursorScroll import pw.binom.rayneovm.shared.CursorClick import pw.binom.rayneovm.shared.CursorMove import pw.binom.rayneovm.shared.RequestShizukuStatus +import pw.binom.rayneovm.shared.ScreenControl import pw.binom.rayneovm.shared.ShizukuStatus /** @@ -167,6 +168,10 @@ class VmService : LifecycleService() { controller.sendKey(msg.keyCode, msg.action) } is SetAudioMode -> Log.d(TAG, "SetAudioMode: ${msg.mode}") + is ScreenControl -> { + Log.i(TAG, "ScreenControl: sleep=${msg.sleep}") + controller.setScreen(msg.sleep) + } is Shutdown -> { Log.i(TAG, "Shutdown") controller.stopGuest() diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt index 14346e1..ca55992 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt @@ -245,6 +245,27 @@ class VmGlassesController { } } + /** + * Гасит/будит экран самих очков (дисплей 0) через Shizuku-UserService. + * + * Идёт через инжект `KEYCODE_SLEEP`/`KEYCODE_WAKEUP` от shell UID, поэтому не зависит + * ни от Mercury BLE, ни от wireless-adb. Биндер берём у «стража» (он всегда поднят, пока + * живёт приложение), иначе — у активного гостевого дисплея. + */ + fun setScreen(sleep: Boolean) { + val binder = guardBinder ?: activeSource?.shizukuBinder + if (binder == null) { + Log.w(TAG, "setScreen(sleep=$sleep): нет Shizuku-биндера (Shizuku не поднят?)") + return + } + testScope.launch { + val ok = runCatching { ShizukuVdBinder.setScreen(binder, !sleep) } + .onFailure { Log.w(TAG, "setScreen: binder мёртв", it) } + .getOrDefault(false) + Log.i(TAG, "setScreen(sleep=$sleep) -> $ok") + } + } + /** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */ fun sendKey(keyCode: Int, action: Int) { val src = activeSource ?: return diff --git a/app-phone-vm/src/main/AndroidManifest.xml b/app-phone-vm/src/main/AndroidManifest.xml index 8b76dd8..6c72903 100644 --- a/app-phone-vm/src/main/AndroidManifest.xml +++ b/app-phone-vm/src/main/AndroidManifest.xml @@ -60,6 +60,10 @@ android:permission="android.permission.DUMP"> + + + + diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/PhoneVmApp.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/PhoneVmApp.kt index f6e5d5b..46a94fd 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/PhoneVmApp.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/PhoneVmApp.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch +import pw.binom.rayneovm.phone.adb.AdbController import pw.binom.rayneovm.phone.bootstrap.ShizukuProvisioner import pw.binom.rayneovm.phone.mercury.MercuryController import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider @@ -22,14 +23,16 @@ import pw.binom.rayneovm.phone.protocol.VmPhoneController * При старте подготавливает контроллер [VmPhoneController] (auto-reconnect к очкам через mDNS) * и [MercuryController] (второй канал — RayNeo Mercury BLE). */ -class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider, ShizukuProvisionerProvider { +class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider, + ShizukuProvisionerProvider, AdbControllerProvider { private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val controller: VmPhoneController by lazy { VmPhoneController(this) } private val mercuryController: MercuryController by lazy { MercuryController(this) } + private val adbController: AdbController by lazy { AdbController(this, mercuryController) } private val provisioner: ShizukuProvisioner by lazy { - ShizukuProvisioner(this, mercuryController, controller) + ShizukuProvisioner(this, mercuryController, controller, adbController) } override fun onCreate() { @@ -90,6 +93,7 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider, override fun provideController(): VmPhoneController = controller override fun provideMercuryController(): MercuryController = mercuryController override fun provideShizukuProvisioner(): ShizukuProvisioner = provisioner + override fun provideAdbController(): AdbController = adbController companion object { private const val TAG = "PhoneVmApp" @@ -101,3 +105,8 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider, interface ShizukuProvisionerProvider { fun provideShizukuProvisioner(): ShizukuProvisioner } + +/** Провайдер adb-контроллера (единая точка входа для adb-команд) для UI. */ +interface AdbControllerProvider { + fun provideAdbController(): AdbController +} diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/adb/AdbClient.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/adb/AdbClient.kt index 80ecdb0..504edd5 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/adb/AdbClient.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/adb/AdbClient.kt @@ -86,8 +86,8 @@ class AdbClient(private val context: Context) { exec(listOf("-s", serial, "push", local.absolutePath, remote), timeoutMs) /** Устройство реально отвечает по serial. */ - fun isConnected(serial: String): Boolean = - shell(serial, "echo ok", timeoutMs = 10_000).output.contains("ok") + fun isConnected(serial: String, timeoutMs: Long = 10_000): Boolean = + shell(serial, "echo ok", timeoutMs = timeoutMs).output.contains("ok") private fun exec(args: List, timeoutMs: Long): Result { val cmd = listOf(adbPath) + args diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/adb/AdbController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/adb/AdbController.kt new file mode 100644 index 0000000..a83f40a --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/adb/AdbController.kt @@ -0,0 +1,270 @@ +package pw.binom.rayneovm.phone.adb + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import pw.binom.rayneovm.phone.mercury.MercuryController +import java.io.File +import kotlin.coroutines.resume + +/** + * Единая точка входа для всех adb-вызовов к очкам. + * + * Зачем: wireless-adb на очках **сам гаснет**, как только они теряют WiFi-сеть, к которой + * он был привязан (`adb_wifi_enabled` → 0, порт → -1), и не переживает ребут. Поэтому + * хранить «готовое» соединение бессмысленно — его надо каждый раз проверять и при + * необходимости поднимать заново. + * + * Контракт: **все** команды идут через [shell] / [push]; каждый вызов сначала + * [ensureConnected] — если соединение живо, оно переиспользуется (кэш `ip:port` + дешёвый + * `echo ok`), если мертво — wireless-adb включается через Mercury, адрес при + * необходимости ищется по mDNS `_adb-tls-connect._tcp`, и adb подключается заново. + * + * Мьютекс держится **только на время подъёма** соединения, поэтому длинный бутстрап + * Shizuku не блокирует пользовательские команды (кнопки в UI). + */ +class AdbController( + context: Context, + private val mercury: MercuryController, +) { + + private val appContext = context.applicationContext + private val adb = AdbClient(appContext) + + /** Только на подъём соединения: сами команды после него идут без блокировки. */ + private val connectMutex = Mutex() + + @Volatile + private var address: String? = null + + private val _state = MutableStateFlow(AdbConnectionState.Disconnected) + val state: StateFlow = _state.asStateFlow() + + /** Бинарник adb и ключ встроены в сборку. */ + val available: Boolean get() = adb.available + + /** + * Живой serial очков (`ip:port`), поднимая соединение при необходимости. + * + * Быстрый путь: сохранённый адрес + `echo ok` (≤ [PROBE_TIMEOUT_MS]). Если не отвечает — + * полный подъём: Mercury включает wireless-adb → адрес от Mercury или mDNS → `adb connect`. + * + * @return serial для [AdbClient] или null, если поднять не удалось (см. [state]). + */ + suspend fun ensureConnected(): String? = withContext(Dispatchers.IO) { + if (!adb.available) { + fail("adb-бинарник или ключ не встроены в сборку") + return@withContext null + } + address?.let { cached -> + if (adb.isConnected(cached, timeoutMs = PROBE_TIMEOUT_MS)) { + _state.value = AdbConnectionState.Connected(cached) + return@withContext cached + } + Log.i(TAG, "соединение с $cached протухло — поднимаем заново") + address = null + } + connectMutex.withLock { + // Пока ждали мьютекс, подъём мог уже случиться в другом вызове. + address?.let { cached -> + if (adb.isConnected(cached, timeoutMs = PROBE_TIMEOUT_MS)) { + _state.value = AdbConnectionState.Connected(cached) + return@withLock cached + } + address = null + } + bringUp()?.also { address = it } + } + } + + /** + * Выполнить shell-команду на очках (`adb -s shell ...`). Соединение проверяется + * и при необходимости поднимается автоматически. + */ + suspend fun shell(command: String, timeoutMs: Long = DEFAULT_SHELL_TIMEOUT_MS): Result = + withContext(Dispatchers.IO) { + val serial = ensureConnected() + ?: return@withContext Result.failure( + IllegalStateException("нет adb-соединения с очками (wireless-adb не поднялся?)"), + ) + val result = adb.shell(serial, command, timeoutMs = timeoutMs) + Log.i(TAG, "shell($serial): $command -> exit=${result.exitCode} ${result.output.take(200)}") + if (result.ok) { + Result.success(result.output) + } else { + Result.failure(IllegalStateException(result.output.ifBlank { "exit=${result.exitCode}" })) + } + } + + /** Залить файл на очки (`adb push`). Соединение поднимается автоматически. */ + suspend fun push( + local: File, + remote: String, + timeoutMs: Long = DEFAULT_PUSH_TIMEOUT_MS, + ): Result = withContext(Dispatchers.IO) { + val serial = ensureConnected() + ?: return@withContext Result.failure( + IllegalStateException("нет adb-соединения с очками (wireless-adb не поднялся?)"), + ) + val result = adb.push(serial, local, remote, timeoutMs = timeoutMs) + Log.i(TAG, "push($serial): ${local.name} -> $remote exit=${result.exitCode} ${result.output.take(200)}") + if (result.ok) { + Result.success(result.output) + } else { + Result.failure(IllegalStateException(result.output.ifBlank { "exit=${result.exitCode}" })) + } + } + + /** + * Диагностика: подключиться к заданному `ip:port` в обход Mercury/mDNS. + * Нужно, чтобы проверять adb-часть, когда телефон и очки в разных сетях (через релей). + */ + suspend fun ensureConnectedTo(hostPort: String): String? = withContext(Dispatchers.IO) { + if (!adb.available) { + fail("adb-бинарник или ключ не встроены в сборку") + return@withContext null + } + connectMutex.withLock { + _state.value = AdbConnectionState.BringingUp + adb.installKey() + adb.startServer(timeoutMs = 15_000) + if (!adb.isConnected(hostPort, timeoutMs = PROBE_TIMEOUT_MS)) { + adb.disconnect(hostPort, timeoutMs = 5_000) + adb.connect(hostPort, timeoutMs = 12_000) + } + if (!adb.isConnected(hostPort, timeoutMs = PROBE_TIMEOUT_MS)) { + fail("adb connect $hostPort не удался") + return@withLock null + } + address = hostPort + _state.value = AdbConnectionState.Connected(hostPort) + hostPort + } + } + + /** + * Сбросить кэш соединения — вызывать при разрыве BLE/WiFi: адрес почти наверняка мёртв, + * а wireless-adb очки уже погасили сами. + */ + fun invalidate() { + Log.i(TAG, "invalidate(): забываем adb-соединение") + address = null + _state.value = AdbConnectionState.Disconnected + } + + /** Полный подъём: wireless-adb через Mercury → адрес (Mercury/mDNS) → `adb connect`. */ + private suspend fun bringUp(): String? { + _state.value = AdbConnectionState.BringingUp + adb.installKey() + adb.startServer(timeoutMs = 15_000) + + // Mercury почти никогда не отдаёт адрес (flow не срабатывает), а mDNS находит за ~20мс — + // поэтому ждём Mercury недолго, чтобы UI не висел «включаем ADB over WiFi». + val found = mercury.enableWirelessAdbAndAwaitAddress(timeoutMs = MERCURY_ADDRESS_WAIT_MS) + ?: discoverWirelessAdb(timeoutMs = MDNS_TIMEOUT_MS) + if (found == null) { + fail("не удалось получить адрес ADB over WiFi на очках") + return null + } + Log.i(TAG, "адрес wireless-adb: $found") + + if (!adb.isConnected(found, timeoutMs = PROBE_TIMEOUT_MS)) { + // Возможен «already connected» на протухшую сессию (adb-сервер переживает + // перезапуск процесса) — рвём соединение и подключаемся заново. + adb.disconnect(found, timeoutMs = 5_000) + val connect = adb.connect(found, timeoutMs = 12_000) + Log.i(TAG, "adb connect $found -> ${connect.output}") + } + if (!adb.isConnected(found, timeoutMs = PROBE_TIMEOUT_MS)) { + fail("adb connect $found не удался") + return null + } + _state.value = AdbConnectionState.Connected(found) + Log.i(TAG, "adb подключён: $found") + return found + } + + /** + * Fallback: если Mercury не отдал `ip:port`, ищем сервис `_adb-tls-connect._tcp`, + * который adbd очков публикует в mDNS при включённом wireless debugging. + */ + private suspend fun discoverWirelessAdb(timeoutMs: Long): String? = + withTimeoutOrNull(timeoutMs) { + suspendCancellableCoroutine { cont -> + val nsd = appContext.getSystemService(Context.NSD_SERVICE) as? NsdManager ?: run { + cont.resume(null); return@suspendCancellableCoroutine + } + val discovery = object : NsdManager.DiscoveryListener { + override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) { + cont.resume(null) + } + + override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) = Unit + + override fun onDiscoveryStarted(serviceType: String?) = Unit + override fun onDiscoveryStopped(serviceType: String?) = Unit + override fun onServiceLost(serviceInfo: NsdServiceInfo?) = Unit + + override fun onServiceFound(serviceInfo: NsdServiceInfo) { + nsd.resolveService(serviceInfo, object : NsdManager.ResolveListener { + override fun onResolveFailed(info: NsdServiceInfo?, errorCode: Int) = Unit + override fun onServiceResolved(info: NsdServiceInfo) { + val host = info.host?.hostAddress ?: return + if (cont.isActive) cont.resume("$host:${info.port}") + } + }) + } + } + nsd.discoverServices("_adb-tls-connect._tcp", NsdManager.PROTOCOL_DNS_SD, discovery) + cont.invokeOnCancellation { + runCatching { nsd.stopServiceDiscovery(discovery) } + } + } + } + + private fun fail(message: String) { + Log.w(TAG, "adb: $message") + _state.value = AdbConnectionState.Failed(message) + } + + private companion object { + const val TAG = "AdbController" + + /** Быстрая проверка «жив ли serial» — короткий таймаут, чтобы не ждать мёртвое соединение. */ + const val PROBE_TIMEOUT_MS = 4_000L + + /** Сколько ждём адрес от Mercury, прежде чем искать по mDNS. */ + const val MERCURY_ADDRESS_WAIT_MS = 5_000L + + const val MDNS_TIMEOUT_MS = 8_000L + + const val DEFAULT_SHELL_TIMEOUT_MS = 15_000L + const val DEFAULT_PUSH_TIMEOUT_MS = 60_000L + } +} + +/** Состояние adb-соединения с очками (для UI). */ +sealed interface AdbConnectionState { + data object Disconnected : AdbConnectionState + data object BringingUp : AdbConnectionState + data class Connected(val address: String) : AdbConnectionState + data class Failed(val message: String) : AdbConnectionState + + val label: String + get() = when (this) { + Disconnected -> "нет соединения" + BringingUp -> "подключаемся…" + is Connected -> address + is Failed -> "ошибка: $message" + } +} diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt index 6084a6d..c8b5f9b 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt @@ -1,8 +1,6 @@ package pw.binom.rayneovm.phone.bootstrap import android.content.Context -import android.net.nsd.NsdManager -import android.net.nsd.NsdServiceInfo import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -11,19 +9,15 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withTimeoutOrNull -import pw.binom.rayneovm.phone.adb.AdbClient +import pw.binom.rayneovm.phone.adb.AdbConnectionState +import pw.binom.rayneovm.phone.adb.AdbController import pw.binom.rayneovm.phone.mercury.MercuryController import pw.binom.rayneovm.phone.protocol.ConnectionState import pw.binom.rayneovm.phone.protocol.VmPhoneController import java.io.File -import kotlin.coroutines.resume /** * Автономный подъём Shizuku на очках с телефона. @@ -33,15 +27,17 @@ import kotlin.coroutines.resume * adb по wireless-debugging (TLS, ключ уже авторизован) → заливаем starter → * запускаем `shizuku_server` → (опционально) просим Mercury запустить наше * приложение. Всё без ПК и без ручных кнопок. + * + * Сам adb (подключение и команды) живёт в [AdbController] — здесь только домен Shizuku. */ class ShizukuProvisioner( context: Context, private val mercury: MercuryController, private val controller: VmPhoneController, + private val adb: AdbController, ) { private val appContext = context.applicationContext - private val adb = AdbClient(appContext) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val mutex = Mutex() @@ -59,10 +55,6 @@ class ShizukuProvisioner( @Volatile private var provisionedInSession = false - /** Последний живой adb-адрес очков (`ip:port`) — переиспользуем, не поднимая всё заново. */ - @Volatile - private var lastAddress: String? = null - /** * Проверить Shizuku и, если не запущен, поднять его. Безопасно вызывать часто: * дешёвая проверка через WiFi-статус, полный adb-цикл — максимум один раз за BLE-сессию. @@ -103,6 +95,8 @@ class ShizukuProvisioner( /** BLE-сессия разорвана: при следующем подключении проверяем Shizuku заново. */ fun onBleDisconnected() { provisionedInSession = false + // wireless-adb очки гасят сами при потере сети — соединение считать мёртвым. + adb.invalidate() } /** Принудительно прогнать весь сценарий (диагностика/кнопка). */ @@ -152,39 +146,17 @@ class ShizukuProvisioner( return try { _state.value = ProvisionState.Checking - var address = addressOverride - if (address == null) { - _state.value = ProvisionState.EnablingAdb - // Mercury почти не отдаёт адрес (null), а mDNS находит за ~20мс — не ждём - // полный таймаут, иначе «включаем ADB over WiFi…» висит полминуты. - address = mercury.enableWirelessAdbAndAwaitAddress(timeoutMs = 5_000) - Log.i(TAG, "адрес от Mercury: $address") - if (address == null) { - Log.i(TAG, "Mercury не прислал адрес — ищу wireless-adb по mDNS") - address = discoverWirelessAdb() - Log.i(TAG, "адрес от mDNS: $address") - } + _state.value = ProvisionState.EnablingAdb + val serial = if (addressOverride != null) { + adb.ensureConnectedTo(addressOverride) } else { - Log.i(TAG, "адрес задан вручную: $address") - } - if (address == null) return "не удалось получить адрес ADB over WiFi на очках" + adb.ensureConnected() + } ?: return (adb.state.value as? AdbConnectionState.Failed)?.message + ?: "нет adb-соединения с очками" - _state.value = ProvisionState.Connecting(address) - adb.installKey() - adb.startServer() - if (!adb.isConnected(address)) { - // Возможен «already connected» на протухшую сессию (adb-сервер переживает - // перезапуск процесса) — рвём соединение и подключаемся заново. - adb.disconnect(address) - val connectResult = adb.connect(address) - if (!adb.isConnected(address)) { - return "adb connect $address не удался: ${connectResult.output}" - } - } - Log.i(TAG, "adb подключён к $address") - lastAddress = address + _state.value = ProvisionState.Connecting(serial) - if (isShizukuRunning(address)) { + if (isShizukuRunning()) { Log.i(TAG, "Shizuku уже запущен на очках") _state.value = ProvisionState.Running launchGlassesAppIfNeeded() @@ -194,29 +166,29 @@ class ShizukuProvisioner( _state.value = ProvisionState.StartingShizuku val starter = copyStarterToCache() Log.i(TAG, "push starter ${starter.length()}b -> $REMOTE_STARTER") - val push = adb.push(address, starter, REMOTE_STARTER) + val push = adb.push(starter, REMOTE_STARTER) // push может «провалиться» из-за уже существующего файла — проверяем наличие. - if (!push.ok && adb.shell(address, "test -f $REMOTE_STARTER").exitCode != 0) { - return "push starter не удался: ${push.output}" + if (push.isFailure && adb.shell("test -f $REMOTE_STARTER").isFailure) { + return "push starter не удался: ${push.exceptionOrNull()?.message}" } - adb.shell(address, "chmod 755 $REMOTE_STARTER") - val apkPath = shizukuApkPath(address) + adb.shell("chmod 755 $REMOTE_STARTER") + val apkPath = shizukuApkPath() Log.i(TAG, "shizuku apk: $apkPath") val args = buildString { append(REMOTE_STARTER) if (apkPath != null) append(" --apk=").append(apkPath) } - val run = adb.shell(address, args, timeoutMs = 90_000) - Log.i(TAG, "starter exit=${run.exitCode}: ${run.output}") + val run = adb.shell(args, timeoutMs = 90_000) + Log.i(TAG, "starter -> ${run.getOrNull() ?: "ошибка: ${run.exceptionOrNull()?.message}"}") delay(START_VERIFY_DELAY_MS) - if (isShizukuRunning(address)) { + if (isShizukuRunning()) { Log.i(TAG, "Shizuku поднят") _state.value = ProvisionState.Running launchGlassesAppIfNeeded() null } else { - "стартер не поднял shizuku_server: ${run.output.take(300)}" + "стартер не поднял shizuku_server: ${run.exceptionOrNull()?.message?.take(300).orEmpty()}" } } catch (t: Throwable) { Log.w(TAG, "tryProvision(): исключение", t) @@ -224,55 +196,18 @@ class ShizukuProvisioner( } } - /** - * Выполнить shell-команду на очках по wireless-adb (TLS). При необходимости сначала - * поднимает «ADB over WiFi» через Mercury и подключается. Используется кнопками UI - * (гашение/пробуждение экрана и т.п.). - */ - suspend fun runShell(command: String): Result = mutex.withLock { - if (!adb.available) { - return@withLock Result.failure(IllegalStateException("adb-бинарник или ключ не встроены")) - } - val address = ensureAdbConnection() - ?: return@withLock Result.failure(IllegalStateException("не удалось подключиться к очкам по adb")) - val result = adb.shell(address, command, timeoutMs = 20_000) - Log.i(TAG, "runShell($address): $command -> exit=${result.exitCode} ${result.output.take(200)}") - if (result.ok) { - Result.success(result.output) - } else { - Result.failure(IllegalStateException(result.output.ifBlank { "exit=${result.exitCode}" })) - } - } - - /** Живое adb-соединение, поднимая wireless-adb при необходимости. null — не вышло. */ - private suspend fun ensureAdbConnection(): String? { - lastAddress?.let { if (adb.isConnected(it)) return it } - adb.installKey() - adb.startServer() - val address = mercury.enableWirelessAdbAndAwaitAddress(timeoutMs = 5_000) - ?: discoverWirelessAdb() - ?: return null - if (!adb.isConnected(address)) { - adb.disconnect(address) - adb.connect(address) - } - if (!adb.isConnected(address)) return null - lastAddress = address - return address - } - - private fun isShizukuRunning(serial: String): Boolean { - val pidof = adb.shell(serial, "pidof shizuku_server", timeoutMs = 15_000).output + private suspend fun isShizukuRunning(): Boolean { + val pidof = adb.shell("pidof shizuku_server", timeoutMs = 15_000).getOrDefault("") Log.i(TAG, "pidof shizuku_server -> '$pidof'") return pidof.isNotBlank() } - private fun shizukuApkPath(serial: String): String? = - adb.shell(serial, "pm path $SHIZUKU_PACKAGE", timeoutMs = 15_000) - .output - .lineSequence() - .map { it.removePrefix("package:").trim() } - .firstOrNull { it.endsWith(".apk") } + private suspend fun shizukuApkPath(): String? = + adb.shell("pm path $SHIZUKU_PACKAGE", timeoutMs = 15_000) + .getOrNull() + ?.lineSequence() + ?.map { it.removePrefix("package:").trim() } + ?.firstOrNull { it.endsWith(".apk") } private fun copyStarterToCache(): File { val out = File(appContext.cacheDir, "shizuku_starter") @@ -307,44 +242,6 @@ class ShizukuProvisioner( launchGlassesApp() } - /** - * Fallback: если Mercury не отдал `ip:port`, ищем сервис `_adb-tls-connect._tcp`, - * который adbd очков публикует в mDNS при включённом wireless debugging. - */ - private suspend fun discoverWirelessAdb(timeoutMs: Long = 15_000): String? = - withTimeoutOrNull(timeoutMs) { - suspendCancellableCoroutine { cont -> - val nsd = appContext.getSystemService(Context.NSD_SERVICE) as? NsdManager ?: run { - cont.resume(null); return@suspendCancellableCoroutine - } - val discovery = object : NsdManager.DiscoveryListener { - override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) { - cont.resume(null) - } - - override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) = Unit - - override fun onDiscoveryStarted(serviceType: String?) = Unit - override fun onDiscoveryStopped(serviceType: String?) = Unit - override fun onServiceLost(serviceInfo: NsdServiceInfo?) = Unit - - override fun onServiceFound(serviceInfo: NsdServiceInfo) { - nsd.resolveService(serviceInfo, object : NsdManager.ResolveListener { - override fun onResolveFailed(info: NsdServiceInfo?, errorCode: Int) = Unit - override fun onServiceResolved(info: NsdServiceInfo) { - val host = info.host?.hostAddress ?: return - if (cont.isActive) cont.resume("$host:${info.port}") - } - }) - } - } - nsd.discoverServices("_adb-tls-connect._tcp", NsdManager.PROTOCOL_DNS_SD, discovery) - cont.invokeOnCancellation { - runCatching { nsd.stopServiceDiscovery(discovery) } - } - } - } - private fun fail(message: String) { Log.w(TAG, "Shizuku bootstrap: $message") _state.value = ProvisionState.Failed(message) diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/debug/DebugReceiver.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/debug/DebugReceiver.kt index 0bb8f4a..c52ba14 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/debug/DebugReceiver.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/debug/DebugReceiver.kt @@ -4,27 +4,83 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import pw.binom.rayneovm.phone.AdbControllerProvider import pw.binom.rayneovm.phone.ShizukuProvisionerProvider +import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider /** - * Диагностический приёмник: `adb shell am broadcast -a pw.binom.rayneovm.phone.DEBUG_PROVISION` - * принудительно запускает автономный подъём Shizuku (кнопка без UI). + * Диагностический приёмник (shell/system, `android.permission.DUMP`): * - * Защищён `android.permission.DUMP` — им обладает shell/system, обычные приложения нет. + * - `-a pw.binom.rayneovm.phone.DEBUG_PROVISION` — принудительный подъём Shizuku + * (`--es address ip:port` — с заданным адресом, в обход Mercury/mDNS); + * - `-a pw.binom.rayneovm.phone.DEBUG_SET_BRIGHTNESS --ei value 42` — выставить яркость + * очков через Mercury BLE (проверка слайдера без тыканья в UI); + * - `-a pw.binom.rayneovm.phone.DEBUG_REFRESH_STATUS` — перезапросить статус очков; + * - `-a pw.binom.rayneovm.phone.DEBUG_ADB_SHELL --es cmd '...'` — adb-shell команда на очки + * через [pw.binom.rayneovm.phone.adb.AdbController] (сам поднимает wireless-adb). */ class DebugReceiver : BroadcastReceiver() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + override fun onReceive(context: Context, intent: Intent) { - val app = context.applicationContext as? ShizukuProvisionerProvider ?: return - Log.i(TAG, "получен ${intent.action} — запускаю бутстрап Shizuku") - val address = intent.getStringExtra("address") - if (address.isNullOrBlank()) { - app.provideShizukuProvisioner().force() - } else { - app.provideShizukuProvisioner().forceWithAddress(address) + val app = context.applicationContext + when (intent.action) { + ACTION_SET_BRIGHTNESS -> { + val mc = (app as? MercuryControllerProvider)?.provideMercuryController() ?: return + val value = intent.getIntExtra("value", -1) + Log.i(TAG, "DEBUG_SET_BRIGHTNESS value=$value") + mc.setBrightness(value) + } + + ACTION_SET_DEEP_SUSPEND -> { + val mc = (app as? MercuryControllerProvider)?.provideMercuryController() ?: return + val sleep = intent.getIntExtra("on", 0) != 0 + Log.i(TAG, "DEBUG_SET_DEEP_SUSPEND on=$sleep") + mc.setDeepSuspendAsync(sleep) + } + + ACTION_REFRESH_STATUS -> { + val mc = (app as? MercuryControllerProvider)?.provideMercuryController() ?: return + Log.i(TAG, "DEBUG_REFRESH_STATUS") + mc.refreshStatus() + } + + ACTION_ADB_SHELL -> { + val adb = (app as? AdbControllerProvider)?.provideAdbController() ?: return + val cmd = intent.getStringExtra("cmd") + if (cmd.isNullOrBlank()) { + Log.w(TAG, "DEBUG_ADB_SHELL без --es cmd") + return + } + Log.i(TAG, "DEBUG_ADB_SHELL: $cmd") + scope.launch { + val result = adb.shell(cmd) + Log.i(TAG, "DEBUG_ADB_SHELL результат: ${result.getOrNull() ?: result.exceptionOrNull()}") + } + } + + else -> { + val provider = app as? ShizukuProvisionerProvider ?: return + Log.i(TAG, "получен ${intent.action} — запускаю бутстрап Shizuku") + val address = intent.getStringExtra("address") + if (address.isNullOrBlank()) { + provider.provideShizukuProvisioner().force() + } else { + provider.provideShizukuProvisioner().forceWithAddress(address) + } + } } } private companion object { const val TAG = "DebugReceiver" + const val ACTION_SET_BRIGHTNESS = "pw.binom.rayneovm.phone.DEBUG_SET_BRIGHTNESS" + const val ACTION_SET_DEEP_SUSPEND = "pw.binom.rayneovm.phone.DEBUG_SET_DEEP_SUSPEND" + const val ACTION_REFRESH_STATUS = "pw.binom.rayneovm.phone.DEBUG_REFRESH_STATUS" + const val ACTION_ADB_SHELL = "pw.binom.rayneovm.phone.DEBUG_ADB_SHELL" } } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt index 385fb8c..0e72613 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt @@ -336,6 +336,26 @@ class MercuryController(private val context: Context) { mercury.setAutoBrightness(enable) } + /** + * Погасить ([enable] = true) / разбудить экран очков по BLE (deepSuspend). + * + * Suspend-вариант с реальным результатом — кнопка в UI показывает ошибку, если + * команда не ушла (раньше «не работало» из-за баги hasStatus, см. library). + */ + suspend fun setDeepSuspend(enable: Boolean): Boolean = try { + mercury.setDeepSuspend(enable).also { + android.util.Log.i(TAG, "setDeepSuspend($enable) -> $it") + } + } catch (t: Throwable) { + android.util.Log.w(TAG, "setDeepSuspend($enable) failed: ${t.message}") + false + } + + /** Fire-and-forget вариант [setDeepSuspend] (диагностика/DebugReceiver). */ + fun setDeepSuspendAsync(enable: Boolean) = launchMercury("setDeepSuspend($enable)") { + mercury.setDeepSuspend(enable) + } + /** Включить/выключить WiFi-радио очков. */ fun setWifiEnabled(enable: Boolean) = launchMercury("setWifiEnabled($enable)") { mercury.wifiToggle(enable) @@ -415,18 +435,23 @@ class MercuryController(private val context: Context) { mercury.wifiRemove(ssid) } - private fun launchMercury(action: String, block: suspend () -> Unit) { + private fun launchMercury(action: String, block: suspend () -> Any?) { scope.launch { - try { + val result = try { block() } catch (t: Throwable) { android.util.Log.w(TAG, "$action failed: ${t.message}") + "исключение" } + // Логируем результат: библиотека молча возвращает false, если статус ещё не + // получен (тогда команда никуда не уходит — «слайдер не работает»). + android.util.Log.i(TAG, "$action -> $result") } } /** Сбрасывает данные, которые живут только внутри BLE-сессии. */ private fun clearGlassesData() { + android.util.Log.i(TAG, "clearGlassesData(): данные BLE-сессии сброшены (статус=null)") statusRefreshJob?.cancel() statusRefreshJob = null _generalStatus.value = null @@ -477,6 +502,12 @@ class MercuryController(private val context: Context) { } } is RayNeoMercury.Event.GeneralStatusUpdate -> { + android.util.Log.i( + TAG, + "статус очков: brightness=${event.status.brightness} " + + "autoBright=${event.status.automaticBrightness} " + + "wifi=${event.status.wifiEnabled} battery=${event.status.battery}", + ) _generalStatus.value = event.status val cur = _state.value if (cur is MercuryState.Connected) { diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt index cf3dc79..3deae74 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt @@ -26,6 +26,7 @@ import pw.binom.rayneovm.shared.GlassesStatus import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent import pw.binom.rayneovm.shared.Pong import pw.binom.rayneovm.shared.RequestShizukuStatus +import pw.binom.rayneovm.shared.ScreenControl import pw.binom.rayneovm.shared.SetAudioMode import pw.binom.rayneovm.shared.ShizukuStatus import pw.binom.rayneovm.shared.VmMessage @@ -195,6 +196,17 @@ class VmPhoneController( /** Скролл в текущей позиции курсора: dx/dy — смещение пальцев в пикселях гостя. */ suspend fun scrollCursor(dx: Float, dy: Float) = send(CursorScroll(dx, dy)) + /** + * Погасить ([sleep] = true) / разбудить экран очков их же Shizuku-сервисом (Канал 1). + * + * @return false, если канал не подключён — вызывающий покажет ошибку, а не «0 реакции» + */ + suspend fun setScreen(sleep: Boolean): Boolean { + if (_state.value !is ConnectionState.Connected) return false + send(ScreenControl(sleep)) + return true + } + /** Клавиша/медиа-команда гостю (Back, Play, Pause, ...): DOWN + UP. */ suspend fun sendKey(keyCode: Int) { send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_DOWN)) diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt index 18b72be..22af3f9 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt @@ -140,7 +140,8 @@ fun GlassesStatusScreen( ) ScreenPowerSection( - onShell = { command -> provisioner.runShell(command) }, + enabled = connection is ConnectionState.Connected, + onSetScreen = { sleep -> controller.setScreen(sleep) }, ) GlassesWifiSection( @@ -331,42 +332,44 @@ private fun ScreenAndBrightnessSection( } } -/** - * Гашение/пробуждение экрана очков через adb (`input keyevent 223/224`). - * Работает по wireless-debugging: если очки ушли в глубокий сон и WiFi/adb отвалились — - * команда не дойдёт (Mercury setDeepSuspend мы выпилили, т.к. он не работал). - */ @Composable private fun ScreenPowerSection( - onShell: suspend (String) -> Result, + enabled: Boolean, + onSetScreen: suspend (Boolean) -> Boolean, ) { val scope = rememberCoroutineScope() var busy by remember { mutableStateOf(false) } var message by remember { mutableStateOf(null) } - fun send(command: String, okText: String) { - if (busy) return + fun send(sleep: Boolean, okText: String) { + if (busy || !enabled) return busy = true message = null scope.launch { - val r = onShell(command) - message = if (r.isSuccess) okText else "Ошибка: ${r.exceptionOrNull()?.message}" - busy = false + try { + val ok = onSetScreen(sleep) + message = if (ok) okText else "Нет Канала 1 — команда не ушла" + } catch (t: Throwable) { + message = "Ошибка: ${t.message}" + } finally { + busy = false + } } } SectionCard(title = "Экран очков") { - Hint("Через adb (wireless-debugging). Если очки ушли в глубокий сон и WiFi отвалился — команда не дойдёт.") + Hint("Очки гасят/будят себя сами своим Shizuku-сервисом (Канал 1). Нужен Shizuku на очках.") Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button( - enabled = !busy, - onClick = { send("input keyevent 223", "Экран погашен") }, + enabled = !busy && enabled, + onClick = { send(true, "Экран погашен") }, ) { Text("Погасить") } OutlinedButton( - enabled = !busy, - onClick = { send("input keyevent 224", "Экран разбужен") }, + enabled = !busy && enabled, + onClick = { send(false, "Экран разбужен") }, ) { Text("Разбудить") } } + if (!enabled) Hint("Нужен Канал 1 (WebSocket) — приложение на очках не подключено.") message?.let { Hint(it) } } } diff --git a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt index 20bb869..0505a2b 100644 --- a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt +++ b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable * (настраивается в [VmProtocol]). * * Потоки: - * - Phone → Glasses: [TouchEvent], [KeyEvent], [CursorMove], [CursorClick], [LaunchAppCommand], [SetAudioMode], [Shutdown] + * - Phone → Glasses: [TouchEvent], [KeyEvent], [CursorMove], [CursorClick], [LaunchAppCommand], [SetAudioMode], [ScreenControl], [Shutdown] * - Glasses → Phone: [GlassesStatus], [AppCrashedEvent], [AppLaunchedAck] * - Двунаправленные: [Ping], [Pong] */ @@ -112,6 +112,18 @@ enum class AudioMode { ON_GLASSES, ON_PHONE } @SerialName("shutdown") data object Shutdown : VmMessage +/** + * Погасить/разбудить экран очков ([sleep] = true — погасить). + * + * Выполняется на очках их же Shizuku-UserService (инжект `KEYCODE_SLEEP`/`KEYCODE_WAKEUP` + * от shell UID), поэтому не зависит ни от Mercury BLE, ни от wireless-adb. + */ +@Serializable +@SerialName("screen_control") +data class ScreenControl( + val sleep: Boolean, +) : VmMessage + @Serializable @SerialName("get_app_list") data object GetAppList : VmMessage