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