phone: AdbController — единственная точка входа для adb-команд; screen on/off через Канал 1

- AdbController: shell/push/ensureConnected/invalidate; перед каждой командой
  проверяет соединение (echo ok) и сам поднимает wireless-adb (Mercury -> mDNS ->
  connect). Мьютекс только на подъём, длинный бутстрап не блокирует команды.
- ShizukuProvisioner переведён на AdbController (adb-код вынесен).
- Убрана неработающая UI-консоль adb (нужен только программный API).
- ScreenControl(sleep) в shared: гашение/пробуждение экрана очков их же
  Shizuku-UserService (инжект 223/224 как shell), не зависит от Mercury/adb.
- VmPhoneController.setScreen + кнопки «Экран очков» на Канале 1.
- DebugReceiver: DEBUG_ADB_SHELL (+ SET_DEEP_SUSPEND) как отладочный инструмент.
- MercuryController/AdbClient: сопутствующие правки.
This commit is contained in:
2026-09-27 04:55:47 +03:00
parent 027e2efb9c
commit 9914488258
13 changed files with 493 additions and 169 deletions
+4
View File
@@ -0,0 +1,4 @@
1. [ ] Стартовый экран (тот когда не запущино ничего на вирутальных мониторах) отображается криво, не бинокулдярно. Он тоже должен быть бинокулярным.
2. [ ] Надо добавить возможность проброса звука с виртуального дисплея на телефон. Должен быть переключатель через что выводить звук: через телефон или с очков. то есть надо сделать саму механику проброса, а потом кнопку переключатиль в мобильном приложении
3. [ ] Когда приложение на виртуальном дисплее запущено отображается положение мышки (жолтая точка). Когда она долго не двигается надо её скрывать на очках
4. [ ] Доработать функционал клавиатуры. 1. когда на самих очках поле ввода в фокусе НЕ нужно отображать клавиатуру на очках. 2. когда на телефоне тыкаю на кнопку клавиатуры нужно отобразить клавиатуру, и чтобы все что там вводится вводилось на очках
@@ -36,6 +36,7 @@ import pw.binom.rayneovm.shared.CursorScroll
import pw.binom.rayneovm.shared.CursorClick import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.RequestShizukuStatus import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.ShizukuStatus import pw.binom.rayneovm.shared.ShizukuStatus
/** /**
@@ -167,6 +168,10 @@ class VmService : LifecycleService() {
controller.sendKey(msg.keyCode, msg.action) controller.sendKey(msg.keyCode, msg.action)
} }
is SetAudioMode -> Log.d(TAG, "SetAudioMode: ${msg.mode}") is SetAudioMode -> Log.d(TAG, "SetAudioMode: ${msg.mode}")
is ScreenControl -> {
Log.i(TAG, "ScreenControl: sleep=${msg.sleep}")
controller.setScreen(msg.sleep)
}
is Shutdown -> { is Shutdown -> {
Log.i(TAG, "Shutdown") Log.i(TAG, "Shutdown")
controller.stopGuest() controller.stopGuest()
@@ -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, ...). */ /** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */
fun sendKey(keyCode: Int, action: Int) { fun sendKey(keyCode: Int, action: Int) {
val src = activeSource ?: return val src = activeSource ?: return
@@ -60,6 +60,10 @@
android:permission="android.permission.DUMP"> android:permission="android.permission.DUMP">
<intent-filter> <intent-filter>
<action android:name="pw.binom.rayneovm.phone.DEBUG_PROVISION" /> <action android:name="pw.binom.rayneovm.phone.DEBUG_PROVISION" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_SET_BRIGHTNESS" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_SET_DEEP_SUSPEND" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_ADB_SHELL" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_REFRESH_STATUS" />
</intent-filter> </intent-filter>
</receiver> </receiver>
</application> </application>
@@ -8,6 +8,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.adb.AdbController
import pw.binom.rayneovm.phone.bootstrap.ShizukuProvisioner import pw.binom.rayneovm.phone.bootstrap.ShizukuProvisioner
import pw.binom.rayneovm.phone.mercury.MercuryController import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider
@@ -22,14 +23,16 @@ import pw.binom.rayneovm.phone.protocol.VmPhoneController
* При старте подготавливает контроллер [VmPhoneController] (auto-reconnect к очкам через mDNS) * При старте подготавливает контроллер [VmPhoneController] (auto-reconnect к очкам через mDNS)
* и [MercuryController] (второй канал — RayNeo Mercury BLE). * и [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 appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val controller: VmPhoneController by lazy { VmPhoneController(this) } private val controller: VmPhoneController by lazy { VmPhoneController(this) }
private val mercuryController: MercuryController by lazy { MercuryController(this) } private val mercuryController: MercuryController by lazy { MercuryController(this) }
private val adbController: AdbController by lazy { AdbController(this, mercuryController) }
private val provisioner: ShizukuProvisioner by lazy { private val provisioner: ShizukuProvisioner by lazy {
ShizukuProvisioner(this, mercuryController, controller) ShizukuProvisioner(this, mercuryController, controller, adbController)
} }
override fun onCreate() { override fun onCreate() {
@@ -90,6 +93,7 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
override fun provideController(): VmPhoneController = controller override fun provideController(): VmPhoneController = controller
override fun provideMercuryController(): MercuryController = mercuryController override fun provideMercuryController(): MercuryController = mercuryController
override fun provideShizukuProvisioner(): ShizukuProvisioner = provisioner override fun provideShizukuProvisioner(): ShizukuProvisioner = provisioner
override fun provideAdbController(): AdbController = adbController
companion object { companion object {
private const val TAG = "PhoneVmApp" private const val TAG = "PhoneVmApp"
@@ -101,3 +105,8 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider,
interface ShizukuProvisionerProvider { interface ShizukuProvisionerProvider {
fun provideShizukuProvisioner(): ShizukuProvisioner fun provideShizukuProvisioner(): ShizukuProvisioner
} }
/** Провайдер adb-контроллера (единая точка входа для adb-команд) для UI. */
interface AdbControllerProvider {
fun provideAdbController(): AdbController
}
@@ -86,8 +86,8 @@ class AdbClient(private val context: Context) {
exec(listOf("-s", serial, "push", local.absolutePath, remote), timeoutMs) exec(listOf("-s", serial, "push", local.absolutePath, remote), timeoutMs)
/** Устройство реально отвечает по serial. */ /** Устройство реально отвечает по serial. */
fun isConnected(serial: String): Boolean = fun isConnected(serial: String, timeoutMs: Long = 10_000): Boolean =
shell(serial, "echo ok", timeoutMs = 10_000).output.contains("ok") shell(serial, "echo ok", timeoutMs = timeoutMs).output.contains("ok")
private fun exec(args: List<String>, timeoutMs: Long): Result { private fun exec(args: List<String>, timeoutMs: Long): Result {
val cmd = listOf(adbPath) + args val cmd = listOf(adbPath) + args
@@ -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>(AdbConnectionState.Disconnected)
val state: StateFlow<AdbConnectionState> = _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 <serial> shell ...`). Соединение проверяется
* и при необходимости поднимается автоматически.
*/
suspend fun shell(command: String, timeoutMs: Long = DEFAULT_SHELL_TIMEOUT_MS): Result<String> =
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<String> = 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<String?> { 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"
}
}
@@ -1,8 +1,6 @@
package pw.binom.rayneovm.phone.bootstrap package pw.binom.rayneovm.phone.bootstrap
import android.content.Context import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -11,19 +9,15 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull import pw.binom.rayneovm.phone.adb.AdbConnectionState
import pw.binom.rayneovm.phone.adb.AdbClient import pw.binom.rayneovm.phone.adb.AdbController
import pw.binom.rayneovm.phone.mercury.MercuryController import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.protocol.ConnectionState import pw.binom.rayneovm.phone.protocol.ConnectionState
import pw.binom.rayneovm.phone.protocol.VmPhoneController import pw.binom.rayneovm.phone.protocol.VmPhoneController
import java.io.File import java.io.File
import kotlin.coroutines.resume
/** /**
* Автономный подъём Shizuku на очках с телефона. * Автономный подъём Shizuku на очках с телефона.
@@ -33,15 +27,17 @@ import kotlin.coroutines.resume
* adb по wireless-debugging (TLS, ключ уже авторизован) → заливаем starter → * adb по wireless-debugging (TLS, ключ уже авторизован) → заливаем starter →
* запускаем `shizuku_server` → (опционально) просим Mercury запустить наше * запускаем `shizuku_server` → (опционально) просим Mercury запустить наше
* приложение. Всё без ПК и без ручных кнопок. * приложение. Всё без ПК и без ручных кнопок.
*
* Сам adb (подключение и команды) живёт в [AdbController] — здесь только домен Shizuku.
*/ */
class ShizukuProvisioner( class ShizukuProvisioner(
context: Context, context: Context,
private val mercury: MercuryController, private val mercury: MercuryController,
private val controller: VmPhoneController, private val controller: VmPhoneController,
private val adb: AdbController,
) { ) {
private val appContext = context.applicationContext private val appContext = context.applicationContext
private val adb = AdbClient(appContext)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutex = Mutex() private val mutex = Mutex()
@@ -59,10 +55,6 @@ class ShizukuProvisioner(
@Volatile @Volatile
private var provisionedInSession = false private var provisionedInSession = false
/** Последний живой adb-адрес очков (`ip:port`) — переиспользуем, не поднимая всё заново. */
@Volatile
private var lastAddress: String? = null
/** /**
* Проверить Shizuku и, если не запущен, поднять его. Безопасно вызывать часто: * Проверить Shizuku и, если не запущен, поднять его. Безопасно вызывать часто:
* дешёвая проверка через WiFi-статус, полный adb-цикл — максимум один раз за BLE-сессию. * дешёвая проверка через WiFi-статус, полный adb-цикл — максимум один раз за BLE-сессию.
@@ -103,6 +95,8 @@ class ShizukuProvisioner(
/** BLE-сессия разорвана: при следующем подключении проверяем Shizuku заново. */ /** BLE-сессия разорвана: при следующем подключении проверяем Shizuku заново. */
fun onBleDisconnected() { fun onBleDisconnected() {
provisionedInSession = false provisionedInSession = false
// wireless-adb очки гасят сами при потере сети — соединение считать мёртвым.
adb.invalidate()
} }
/** Принудительно прогнать весь сценарий (диагностика/кнопка). */ /** Принудительно прогнать весь сценарий (диагностика/кнопка). */
@@ -152,39 +146,17 @@ class ShizukuProvisioner(
return try { return try {
_state.value = ProvisionState.Checking _state.value = ProvisionState.Checking
var address = addressOverride
if (address == null) {
_state.value = ProvisionState.EnablingAdb _state.value = ProvisionState.EnablingAdb
// Mercury почти не отдаёт адрес (null), а mDNS находит за ~20мс — не ждём val serial = if (addressOverride != null) {
// полный таймаут, иначе «включаем ADB over WiFi…» висит полминуты. adb.ensureConnectedTo(addressOverride)
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")
}
} else { } else {
Log.i(TAG, "адрес задан вручную: $address") adb.ensureConnected()
} } ?: return (adb.state.value as? AdbConnectionState.Failed)?.message
if (address == null) return "не удалось получить адрес ADB over WiFi на очках" ?: "нет adb-соединения с очками"
_state.value = ProvisionState.Connecting(address) _state.value = ProvisionState.Connecting(serial)
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
if (isShizukuRunning(address)) { if (isShizukuRunning()) {
Log.i(TAG, "Shizuku уже запущен на очках") Log.i(TAG, "Shizuku уже запущен на очках")
_state.value = ProvisionState.Running _state.value = ProvisionState.Running
launchGlassesAppIfNeeded() launchGlassesAppIfNeeded()
@@ -194,29 +166,29 @@ class ShizukuProvisioner(
_state.value = ProvisionState.StartingShizuku _state.value = ProvisionState.StartingShizuku
val starter = copyStarterToCache() val starter = copyStarterToCache()
Log.i(TAG, "push starter ${starter.length()}b -> $REMOTE_STARTER") 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 может «провалиться» из-за уже существующего файла — проверяем наличие. // push может «провалиться» из-за уже существующего файла — проверяем наличие.
if (!push.ok && adb.shell(address, "test -f $REMOTE_STARTER").exitCode != 0) { if (push.isFailure && adb.shell("test -f $REMOTE_STARTER").isFailure) {
return "push starter не удался: ${push.output}" return "push starter не удался: ${push.exceptionOrNull()?.message}"
} }
adb.shell(address, "chmod 755 $REMOTE_STARTER") adb.shell("chmod 755 $REMOTE_STARTER")
val apkPath = shizukuApkPath(address) val apkPath = shizukuApkPath()
Log.i(TAG, "shizuku apk: $apkPath") Log.i(TAG, "shizuku apk: $apkPath")
val args = buildString { val args = buildString {
append(REMOTE_STARTER) append(REMOTE_STARTER)
if (apkPath != null) append(" --apk=").append(apkPath) if (apkPath != null) append(" --apk=").append(apkPath)
} }
val run = adb.shell(address, args, timeoutMs = 90_000) val run = adb.shell(args, timeoutMs = 90_000)
Log.i(TAG, "starter exit=${run.exitCode}: ${run.output}") Log.i(TAG, "starter -> ${run.getOrNull() ?: "ошибка: ${run.exceptionOrNull()?.message}"}")
delay(START_VERIFY_DELAY_MS) delay(START_VERIFY_DELAY_MS)
if (isShizukuRunning(address)) { if (isShizukuRunning()) {
Log.i(TAG, "Shizuku поднят") Log.i(TAG, "Shizuku поднят")
_state.value = ProvisionState.Running _state.value = ProvisionState.Running
launchGlassesAppIfNeeded() launchGlassesAppIfNeeded()
null null
} else { } else {
"стартер не поднял shizuku_server: ${run.output.take(300)}" "стартер не поднял shizuku_server: ${run.exceptionOrNull()?.message?.take(300).orEmpty()}"
} }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.w(TAG, "tryProvision(): исключение", t) Log.w(TAG, "tryProvision(): исключение", t)
@@ -224,55 +196,18 @@ class ShizukuProvisioner(
} }
} }
/** private suspend fun isShizukuRunning(): Boolean {
* Выполнить shell-команду на очках по wireless-adb (TLS). При необходимости сначала val pidof = adb.shell("pidof shizuku_server", timeoutMs = 15_000).getOrDefault("")
* поднимает «ADB over WiFi» через Mercury и подключается. Используется кнопками UI
* (гашение/пробуждение экрана и т.п.).
*/
suspend fun runShell(command: String): Result<String> = 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
Log.i(TAG, "pidof shizuku_server -> '$pidof'") Log.i(TAG, "pidof shizuku_server -> '$pidof'")
return pidof.isNotBlank() return pidof.isNotBlank()
} }
private fun shizukuApkPath(serial: String): String? = private suspend fun shizukuApkPath(): String? =
adb.shell(serial, "pm path $SHIZUKU_PACKAGE", timeoutMs = 15_000) adb.shell("pm path $SHIZUKU_PACKAGE", timeoutMs = 15_000)
.output .getOrNull()
.lineSequence() ?.lineSequence()
.map { it.removePrefix("package:").trim() } ?.map { it.removePrefix("package:").trim() }
.firstOrNull { it.endsWith(".apk") } ?.firstOrNull { it.endsWith(".apk") }
private fun copyStarterToCache(): File { private fun copyStarterToCache(): File {
val out = File(appContext.cacheDir, "shizuku_starter") val out = File(appContext.cacheDir, "shizuku_starter")
@@ -307,44 +242,6 @@ class ShizukuProvisioner(
launchGlassesApp() 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<String?> { 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) { private fun fail(message: String) {
Log.w(TAG, "Shizuku bootstrap: $message") Log.w(TAG, "Shizuku bootstrap: $message")
_state.value = ProvisionState.Failed(message) _state.value = ProvisionState.Failed(message)
@@ -4,27 +4,83 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.util.Log 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.ShizukuProvisionerProvider
import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider
/** /**
* Диагностический приёмник: `adb shell am broadcast -a pw.binom.rayneovm.phone.DEBUG_PROVISION` * Диагностический приёмник (shell/system, `android.permission.DUMP`):
* принудительно запускает автономный подъём Shizuku (кнопка без UI).
* *
* Защищён `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() { class DebugReceiver : BroadcastReceiver() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
val app = context.applicationContext as? ShizukuProvisionerProvider ?: return 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") Log.i(TAG, "получен ${intent.action} — запускаю бутстрап Shizuku")
val address = intent.getStringExtra("address") val address = intent.getStringExtra("address")
if (address.isNullOrBlank()) { if (address.isNullOrBlank()) {
app.provideShizukuProvisioner().force() provider.provideShizukuProvisioner().force()
} else { } else {
app.provideShizukuProvisioner().forceWithAddress(address) provider.provideShizukuProvisioner().forceWithAddress(address)
}
}
} }
} }
private companion object { private companion object {
const val TAG = "DebugReceiver" 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"
} }
} }
@@ -336,6 +336,26 @@ class MercuryController(private val context: Context) {
mercury.setAutoBrightness(enable) 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-радио очков. */ /** Включить/выключить WiFi-радио очков. */
fun setWifiEnabled(enable: Boolean) = launchMercury("setWifiEnabled($enable)") { fun setWifiEnabled(enable: Boolean) = launchMercury("setWifiEnabled($enable)") {
mercury.wifiToggle(enable) mercury.wifiToggle(enable)
@@ -415,18 +435,23 @@ class MercuryController(private val context: Context) {
mercury.wifiRemove(ssid) mercury.wifiRemove(ssid)
} }
private fun launchMercury(action: String, block: suspend () -> Unit) { private fun launchMercury(action: String, block: suspend () -> Any?) {
scope.launch { scope.launch {
try { val result = try {
block() block()
} catch (t: Throwable) { } catch (t: Throwable) {
android.util.Log.w(TAG, "$action failed: ${t.message}") android.util.Log.w(TAG, "$action failed: ${t.message}")
"исключение"
} }
// Логируем результат: библиотека молча возвращает false, если статус ещё не
// получен (тогда команда никуда не уходит — «слайдер не работает»).
android.util.Log.i(TAG, "$action -> $result")
} }
} }
/** Сбрасывает данные, которые живут только внутри BLE-сессии. */ /** Сбрасывает данные, которые живут только внутри BLE-сессии. */
private fun clearGlassesData() { private fun clearGlassesData() {
android.util.Log.i(TAG, "clearGlassesData(): данные BLE-сессии сброшены (статус=null)")
statusRefreshJob?.cancel() statusRefreshJob?.cancel()
statusRefreshJob = null statusRefreshJob = null
_generalStatus.value = null _generalStatus.value = null
@@ -477,6 +502,12 @@ class MercuryController(private val context: Context) {
} }
} }
is RayNeoMercury.Event.GeneralStatusUpdate -> { 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 _generalStatus.value = event.status
val cur = _state.value val cur = _state.value
if (cur is MercuryState.Connected) { if (cur is MercuryState.Connected) {
@@ -26,6 +26,7 @@ import pw.binom.rayneovm.shared.GlassesStatus
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
import pw.binom.rayneovm.shared.Pong import pw.binom.rayneovm.shared.Pong
import pw.binom.rayneovm.shared.RequestShizukuStatus import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.SetAudioMode import pw.binom.rayneovm.shared.SetAudioMode
import pw.binom.rayneovm.shared.ShizukuStatus import pw.binom.rayneovm.shared.ShizukuStatus
import pw.binom.rayneovm.shared.VmMessage import pw.binom.rayneovm.shared.VmMessage
@@ -195,6 +196,17 @@ class VmPhoneController(
/** Скролл в текущей позиции курсора: dx/dy — смещение пальцев в пикселях гостя. */ /** Скролл в текущей позиции курсора: dx/dy — смещение пальцев в пикселях гостя. */
suspend fun scrollCursor(dx: Float, dy: Float) = send(CursorScroll(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. */ /** Клавиша/медиа-команда гостю (Back, Play, Pause, ...): DOWN + UP. */
suspend fun sendKey(keyCode: Int) { suspend fun sendKey(keyCode: Int) {
send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_DOWN)) send(VmKeyEvent(keyCode, VmKeyEvent.ACTION_DOWN))
@@ -140,7 +140,8 @@ fun GlassesStatusScreen(
) )
ScreenPowerSection( ScreenPowerSection(
onShell = { command -> provisioner.runShell(command) }, enabled = connection is ConnectionState.Connected,
onSetScreen = { sleep -> controller.setScreen(sleep) },
) )
GlassesWifiSection( GlassesWifiSection(
@@ -331,42 +332,44 @@ private fun ScreenAndBrightnessSection(
} }
} }
/**
* Гашение/пробуждение экрана очков через adb (`input keyevent 223/224`).
* Работает по wireless-debugging: если очки ушли в глубокий сон и WiFi/adb отвалились —
* команда не дойдёт (Mercury setDeepSuspend мы выпилили, т.к. он не работал).
*/
@Composable @Composable
private fun ScreenPowerSection( private fun ScreenPowerSection(
onShell: suspend (String) -> Result<String>, enabled: Boolean,
onSetScreen: suspend (Boolean) -> Boolean,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) } var message by remember { mutableStateOf<String?>(null) }
fun send(command: String, okText: String) { fun send(sleep: Boolean, okText: String) {
if (busy) return if (busy || !enabled) return
busy = true busy = true
message = null message = null
scope.launch { scope.launch {
val r = onShell(command) try {
message = if (r.isSuccess) okText else "Ошибка: ${r.exceptionOrNull()?.message}" val ok = onSetScreen(sleep)
message = if (ok) okText else "Нет Канала 1 — команда не ушла"
} catch (t: Throwable) {
message = "Ошибка: ${t.message}"
} finally {
busy = false busy = false
} }
} }
}
SectionCard(title = "Экран очков") { SectionCard(title = "Экран очков") {
Hint("Через adb (wireless-debugging). Если очки ушли в глубокий сон и WiFi отвалился — команда не дойдёт.") Hint("Очки гасят/будят себя сами своим Shizuku-сервисом (Канал 1). Нужен Shizuku на очках.")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button( Button(
enabled = !busy, enabled = !busy && enabled,
onClick = { send("input keyevent 223", "Экран погашен") }, onClick = { send(true, "Экран погашен") },
) { Text("Погасить") } ) { Text("Погасить") }
OutlinedButton( OutlinedButton(
enabled = !busy, enabled = !busy && enabled,
onClick = { send("input keyevent 224", "Экран разбужен") }, onClick = { send(false, "Экран разбужен") },
) { Text("Разбудить") } ) { Text("Разбудить") }
} }
if (!enabled) Hint("Нужен Канал 1 (WebSocket) — приложение на очках не подключено.")
message?.let { Hint(it) } message?.let { Hint(it) }
} }
} }
@@ -10,7 +10,7 @@ import kotlinx.serialization.Serializable
* (настраивается в [VmProtocol]). * (настраивается в [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] * - Glasses → Phone: [GlassesStatus], [AppCrashedEvent], [AppLaunchedAck]
* - Двунаправленные: [Ping], [Pong] * - Двунаправленные: [Ping], [Pong]
*/ */
@@ -112,6 +112,18 @@ enum class AudioMode { ON_GLASSES, ON_PHONE }
@SerialName("shutdown") @SerialName("shutdown")
data object Shutdown : VmMessage 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 @Serializable
@SerialName("get_app_list") @SerialName("get_app_list")
data object GetAppList : VmMessage data object GetAppList : VmMessage