phone: autonomous Shizuku revive over wireless-adb; glasses screen on/off buttons

AdbClient wraps the bundled libadb.so (useLegacyPackaging -> exec from nativeLibraryDir, HOME/TMPDIR overridden, adb key injected into BuildConfig from ~/.android at build time and never committed). ShizukuProvisioner: on each BLE+WiFi connect it enables ADB-over-WiFi via Mercury, connects with mDNS fallback, pushes the Shizuku starter and runs it; runs once per BLE session, does not relaunch GlassesApp when the WS channel is already up, and exposes runShell() for arbitrary commands. DebugReceiver (DUMP) + am start --ez provision for diagnostics.

Protocol: ShizukuStatus(running/granted) + RequestShizukuStatus; glasses push it on connect and on Shizuku state change, using a passive probe() that never shows a permission dialog. Glasses kill-guard now recreates its UserService when the Shizuku binder dies instead of spamming DeadObjectException. Mercury: brightness disables auto-brightness first (was silently ignored), BLE auto-connect loop retries every 6s (fixes 'searches then stops'). New phone section 'Экран очков' with wake/sleep via adb (input keyevent 224/223).
This commit is contained in:
2026-09-27 03:52:05 +03:00
parent 59069a741e
commit 8a0b7832c0
18 changed files with 1112 additions and 44 deletions
+49
View File
@@ -89,3 +89,52 @@ RayDesk — Moonlight-клиент: стримит рабочий стол ПК
### Ложная зацепка
- `127.0.0.1:8872` (uid 1002) — при коннекте отдаёт поток с магией `btsnoop` → это
Bluetooth HCI-логгер, не adbd. ADB-хендшейк не проходит.
---
## Этап F — автономный подъём Shizuku С ТЕЛЕФОНА (успех)
### Механизм включения wireless-adb на очках
- Нужно **два** действия (иначе TLS-сервер не поднимается):
1. `IAdbManager.allowWirelessDebugging(true, ssid)` — транзакция binder `adb` №4,
добавляет доверенную сеть (`adb_temp_keys.xml` + запись wifiAP/bssid).
2. `settings put global adb_wifi_enabled 1` — adbd стартует
`TlsServer running on port <random>` и публикует mDNS `_adb-tls-connect._tcp`.
- Порт — **случайный**; узнать: binder-транзакция `adb` №10 (`getAdbWirelessPort`).
- Shell (uid 2000) вправе делать оба вызова — root не нужен.
### Критично: wireless-adb сам гаснет
- Как только очки теряют ту WiFi-сеть, к которой были привязаны, `adb_wifi_enabled`
сбрасывается в 0, порт становится `-1`. Поэтому при каждом бутстрапе его надо
перевключать (через Mercury `enableWirelessAdb`, который и вызывает оба шага).
### Авторизация без сопряжения
- Встроенный adb-ключ рабочей машины уже лежит в `/data/misc/adb/adb_keys` очков.
`AdbDebuggingManager.addUserKeysToKeyStore()` подмешивает user-ключи в wireless-keystore,
поэтому при connect по TLS ключ принимается без pairing-кода. Проверено.
### Реализация в PhoneApp
- `jniLibs/arm64-v8a/libadb.so` — AOSP adb (префилд LADB, Apache-2.0);
`useLegacyPackaging = true` → распаковывается в `nativeLibraryDir`, откуда
разрешён `exec` (W^X запрещает exec из filesDir/cacheDir — первая ошибка была `EACCES`).
- Ключ `~/.android/adbkey` зашивается в `BuildConfig` на сборке (в репо не коммитится).
- `AdbClient`: HOME=filesDir/.android (ключ кладём и в `.android/.android`),
TMPDIR=cacheDir, `ANDROID_USER_CONFIG`=HOME.
- `ShizukuProvisioner`: Mercury адрес → mDNS fallback → `start-server` → `connect` →
`pidof shizuku_server` → push `assets/shizuku/starter` → `chmod` →
`starter --apk=$(pm path moe.shizuku.privileged.api)` → проверка `pidof` → `appMarketLaunch`.
### Проверено на устройстве (через TCP-релей ПК, т.к. телефон и очки были в разных сетях)
```
forceWithAddress(192.168.76.142:39998)
exec: -s ... shell /data/local/tmp/shizuku_starter --apk=.../base.apk
info: shizuku_server pid is 4342
pidof shizuku_server -> '4342'
Shizuku поднят
```
Диагностика: `am broadcast -a pw.binom.rayneovm.phone.DEBUG_PROVISION [--es address ip:port]`
или `am start -n pw.binom.rayneovm.phone/.MainActivity --ez provision true [--es address ip:port]`.
### Остаётся проверить «в бою»
- Реальный сценарий: телефон держит хотспот, очки к нему подключены, BLE соединён →
`enableWirelessAdb` отдаёт адрес (или mDNS), телефон поднимает Shizuku сам.
@@ -112,6 +112,23 @@ object ShizukuBootstrap {
fun isGranted(): Boolean = _state.value == AllowState.Granted
/** Снимок состояния без побочных эффектов (не показывает диалог разрешения). */
data class Snapshot(val running: Boolean, val granted: Boolean)
/**
* Пассивная проверка Shizuku: жив ли сервер и выдан ли нам permission.
*
* Зовётся из сервиса (не из Activity), поэтому не имеет права запрашивать разрешение —
* только читает. Нужна, чтобы телефон узнал, поднимать ли Shizuku через ADB.
*/
fun probe(): Snapshot {
val running = try { Shizuku.pingBinder() } catch (_: Throwable) { false }
val granted = if (!running) false else try {
Shizuku.checkSelfPermission() == 0 /* PERMISSION_GRANTED */
} catch (_: Throwable) { false }
return Snapshot(running = running, granted = granted)
}
/**
* Хук для Activity.onRequestPermissionResult: перенаправить в Shizuku.
*
@@ -35,6 +35,8 @@ import pw.binom.rayneovm.shared.AppListResponse
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.ShizukuStatus
/**
* Foreground service на стороне очков.
@@ -67,6 +69,11 @@ class VmService : LifecycleService() {
startForeground()
startServer()
registerMdns()
// Сообщаем телефону о Shizuku, как только меняется его состояние (сервер поднялся
// или нам выдали permission) — чтобы телефон не опрашивал вручную.
lifecycleScope.launch {
ShizukuBootstrap.state.collect { pushShizukuStatus() }
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -110,6 +117,8 @@ class VmService : LifecycleService() {
currentConn.set(connId)
// Переподключение: снять залипшее «Нет связи с телефоном».
controller.onPhoneConnected()
// И сразу отдать телефoну состояние Shizuku (для ADB-бутстрапа).
lifecycleScope.launch { sendShizukuStatus(connId) }
},
onDisconnected = { connId ->
Log.i(TAG, "Phone disconnected: $connId")
@@ -174,11 +183,32 @@ class VmService : LifecycleService() {
Log.e(TAG, "Failed to send app list", t)
}
}
is RequestShizukuStatus -> sendShizukuStatus(connId)
is Ping -> server?.send(connId, VmProtocol.encode(Pong))
else -> Log.d(TAG, "Unhandled: $msg")
}
}
/** Отправляет текущее состояние Shizuku конкретной сессии (ответ на запрос). */
private suspend fun sendShizukuStatus(connId: String) {
val snapshot = ShizukuBootstrap.probe()
val json = VmProtocol.encode(
ShizukuStatus(running = snapshot.running, granted = snapshot.granted),
)
try {
server?.send(connId, json)
} catch (t: Throwable) {
Log.w(TAG, "Не удалось отправить ShizukuStatus: ${t.message}")
}
Log.i(TAG, "ShizukuStatus: running=${snapshot.running} granted=${snapshot.granted}")
}
/** Отправляет состояние Shizuku текущей активной сессии (если есть). */
private suspend fun pushShizukuStatus() {
val connId = currentConn.get() ?: return
sendShizukuStatus(connId)
}
private fun createNotificationChannel(): String {
val channelId = "vm_service_channel"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -477,14 +477,44 @@ class VmGlassesController {
val binder = guardBinder ?: return
val displayId = guardDisplayId
if (displayId <= 0) return
runCatching {
try {
ShizukuVdBinder.launchActivity(
binder = binder,
packageName = GUARD_PACKAGE,
className = GUARD_ACTIVITY,
displayId = displayId,
)
}.onFailure { Log.w(TAG, "launchGuardActivity failed (displayId=$displayId)", it) }
} catch (t: Throwable) {
// Binder умер (Shizuku-сервер перезапускался/упал): старый UserService бесполезен,
// его надо выбросить и поднять новый — иначе watchdog вечно долбит мёртвый binder
// (DeadObjectException каждые 5с), а стража на экране нет.
if (t is android.os.RemoteException || t is IllegalStateException) {
Log.w(TAG, "launchGuardActivity: binder мёртв (displayId=$displayId) — пересоздаю стража", t)
resetGuardState()
} else {
Log.w(TAG, "launchGuardActivity failed (displayId=$displayId)", t)
}
}
}
/** Сбрасывает состояние стража (без остановки watchdog), чтобы тот поднял его заново. */
private fun resetGuardState() {
val binder = guardBinder
val displayId = guardDisplayId
val client = guardClient
val texture = guardSurfaceTexture
guardBinder = null
guardDisplayId = 0
guardClient = null
guardSurfaceTexture = null
testScope.launch {
runCatching { texture?.release() }
if (binder != null) {
if (displayId > 0) runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) }
runCatching { ShizukuVdBinder.destroy(binder) }
}
runCatching { client?.stop() }
}
}
fun setAppContext(context: Context) {
+21
View File
@@ -4,6 +4,17 @@ plugins {
alias(libs.plugins.kotlin.serialization)
}
// adb-ключ рабочей машины (уже авторизован на очках) зашивается в APK, чтобы
// телефон мог подключиться к очкам по wireless-debugging без сопряжения.
// Ключ НЕ коммитится — берётся из ~/.android при сборке.
fun String.toBuildConfigString(): String =
"\"" + replace("\\", "\\\\").replace("\"", "\\\"").replace("\r", "").replace("\n", "\\n") + "\""
val adbKeyFile = File(System.getProperty("user.home"), ".android/adbkey")
val adbPubKeyFile = File(System.getProperty("user.home"), ".android/adbkey.pub")
val adbPrivateKeyConst = if (adbKeyFile.exists()) adbKeyFile.readText().trim().toBuildConfigString() else "\"\""
val adbPublicKeyConst = if (adbPubKeyFile.exists()) adbPubKeyFile.readText().trim().toBuildConfigString() else "\"\""
android {
namespace = "pw.binom.rayneovm.phone"
compileSdk = 37
@@ -14,6 +25,9 @@ android {
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
buildConfigField("String", "ADB_PRIVATE_KEY", adbPrivateKeyConst)
buildConfigField("String", "ADB_PUBLIC_KEY", adbPublicKeyConst)
}
buildTypes {
@@ -29,12 +43,19 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
// libadb.so — исполняемый бинарник adb, а не shared-библиотека. Чтобы его можно
// было запускать из nativeLibraryDir (единственное разрешённое место на W^X),
// включаем legacy-packaging: jniLibs распаковываются на диск при установке.
jniLibs {
useLegacyPackaging = true
}
}
}
+11
View File
@@ -51,5 +51,16 @@
android:name=".audio.AudioPlaybackService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<!-- Диагностика: `adb shell am broadcast -a pw.binom.rayneovm.phone.DEBUG_PROVISION`
принудительно запускает автономный подъём Shizuku. Защищено DUMP (есть у shell). -->
<receiver
android:name=".debug.DebugReceiver"
android:exported="true"
android:permission="android.permission.DUMP">
<intent-filter>
<action android:name="pw.binom.rayneovm.phone.DEBUG_PROVISION" />
</intent-filter>
</receiver>
</application>
</manifest>
Binary file not shown.
Binary file not shown.
@@ -32,6 +32,13 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
val controller = VmPhoneController.fromContext(this)
val mercuryController = MercuryController.fromContext(this)
val provisioner = (application as ShizukuProvisionerProvider).provideShizukuProvisioner()
// Диагностика: `am start ... --es address ip:port --ez provision true` прогоняет
// бутстрап сразу при запуске (когда broadcast доставить не удаётся).
if (intent?.getBooleanExtra("provision", false) == true) {
val address = intent.getStringExtra("address")
if (address.isNullOrBlank()) provisioner.force() else provisioner.forceWithAddress(address)
}
setContent {
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions(),
@@ -66,6 +73,7 @@ class MainActivity : ComponentActivity() {
HomeScreen(
controller = controller,
mercuryController = mercuryController,
provisioner = provisioner,
onRequestMercuryPermissions = {
permissionLauncher.launch(mercuryController.requiredPermissions)
},
@@ -2,8 +2,17 @@ package pw.binom.rayneovm.phone
import android.app.Application
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.bootstrap.ShizukuProvisioner
import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider
import pw.binom.rayneovm.phone.mercury.MercuryState
import pw.binom.rayneovm.phone.protocol.ConnectionState
import pw.binom.rayneovm.phone.protocol.ControllerProvider
import pw.binom.rayneovm.phone.protocol.VmPhoneController
@@ -13,10 +22,15 @@ import pw.binom.rayneovm.phone.protocol.VmPhoneController
* При старте подготавливает контроллер [VmPhoneController] (auto-reconnect к очкам через mDNS)
* и [MercuryController] (второй канал — RayNeo Mercury BLE).
*/
class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider {
class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider, ShizukuProvisionerProvider {
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 provisioner: ShizukuProvisioner by lazy {
ShizukuProvisioner(this, mercuryController, controller)
}
override fun onCreate() {
super.onCreate()
@@ -32,19 +46,58 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider
// Запуск отложен до первого обращения — в lazy.
controller.start()
mercuryController.refresh()
// Автономный подъём Shizuku. Условие: есть и WiFi-канал (наша WebSocket-связь
// с очками), и BLE (Канал 2, через который очки включают ADB over WiFi).
// Проверяем при каждом появлении связи и периодически, пока BLE жив, —
// если Shizuku не запущен, поднимаем его.
appScope.launch {
controller.state.collect { st ->
if (st is ConnectionState.Connected && mercuryController.state.value is MercuryState.Connected) {
Log.i(TAG, "WiFi подключение к очкам — проверяю Shizuku")
provisioner.ensure()
}
}
}
// Периодический дешёвый дозвон, пока BLE жив. Полный adb-цикл гоняется не чаще
// одного раза за BLE-сессию (см. ShizukuProvisioner.provisionedInSession), поэтому
// это не дёргает очки; зато так мы подхватываем случай, когда Shizuku упал и очки
// прислали явный статус running=false.
appScope.launch {
while (true) {
delay(PROVISION_RETRY_MS)
if (mercuryController.state.value is MercuryState.Connected) {
provisioner.ensure()
}
}
}
// Разрыв BLE — новая сессия: при следующем подключении проверяем Shizuku заново.
appScope.launch {
mercuryController.state.collect { st ->
if (st !is MercuryState.Connected) provisioner.onBleDisconnected()
}
}
}
override fun onTerminate() {
super.onTerminate()
ConnectionKeepAliveService.stop(this)
provisioner.close()
controller.stop()
mercuryController.close()
}
override fun provideController(): VmPhoneController = controller
override fun provideMercuryController(): MercuryController = mercuryController
override fun provideShizukuProvisioner(): ShizukuProvisioner = provisioner
companion object {
private const val TAG = "PhoneVmApp"
private const val PROVISION_RETRY_MS = 30_000L
}
}
/** Провайдер автономного бутстрапа Shizuku для UI. */
interface ShizukuProvisionerProvider {
fun provideShizukuProvisioner(): ShizukuProvisioner
}
@@ -0,0 +1,131 @@
package pw.binom.rayneovm.phone.adb
import android.content.Context
import android.util.Log
import pw.binom.rayneovm.phone.BuildConfig
import java.io.File
import java.util.concurrent.TimeUnit
import java.util.zip.ZipFile
/**
* Тонкая обёртка над встроенным adb-бинарником (`jniLibs/arm64-v8a/libadb.so`).
*
* Это настоящий AOSP `adb`, запущенный как отдельный процесс из nativeLibraryDir
* (тот же приём, что в LADB). Нужен, чтобы с телефона по wireless-debugging
* (TLS) подключиться к очкам и поднять там Shizuku — без ПК и без ручных кнопок.
*
* adb ищет клиентский ключ в `$HOME/.android/adbkey`, поэтому подменяем HOME на
* filesDir и кладём туда ключ рабочей машины (он уже авторизован в adb_keys очков).
*/
class AdbClient(private val context: Context) {
/**
* `libadb.so` — исполняемый файл, а не shared-библиотека. Система всё равно
* распаковывает jniLibs в nativeLibraryDir (см. `useLegacyPackaging = true`),
* а это единственное место, откуда приложению разрешено `exec` на Android 10+
* (W^X запрещает запуск из filesDir/cacheDir).
*/
private val adbFile: File
get() = File(context.applicationInfo.nativeLibraryDir, "libadb.so")
private val adbPath: String get() = adbFile.absolutePath
/** adb ищет клиентский ключ в `$HOME/.android/adbkey` (в сборке LADB — в `$HOME/adbkey`).
* Совмещаем: HOME = filesDir/.android, ключ кладём и туда, и в `.android/.android`. */
private val home: File get() = File(context.filesDir, ".android").apply { mkdirs() }
/** Бинарник на месте и ключ зашит в APK. */
val available: Boolean
get() = adbFile.canExecute() &&
BuildConfig.ADB_PRIVATE_KEY.isNotBlank() &&
BuildConfig.ADB_PUBLIC_KEY.isNotBlank()
/**
* Раскладывает adb-ключ в `$HOME/.android/`, если он ещё не там / изменился.
* Возвращает false, если ключ не зашит в сборку.
*/
fun installKey(): Boolean {
if (BuildConfig.ADB_PRIVATE_KEY.isBlank() || BuildConfig.ADB_PUBLIC_KEY.isBlank()) return false
val privText = BuildConfig.ADB_PRIVATE_KEY.replace("\\n", "\n")
val pubText = BuildConfig.ADB_PUBLIC_KEY.replace("\\n", "\n")
// Кладём ключ в обе возможные точки поиска — какая сборка adb как ищет, неизвестно.
val dirs = listOf(home, File(home, ".android"))
dirs.forEach { dir ->
dir.mkdirs()
val priv = File(dir, "adbkey")
val pub = File(dir, "adbkey.pub")
if (!priv.exists() || priv.readText() != privText) priv.writeText(privText)
if (!pub.exists() || pub.readText() != pubText) pub.writeText(pubText)
priv.setReadable(true, true)
priv.setWritable(true, true)
}
return true
}
data class Result(val exitCode: Int, val output: String) {
val ok: Boolean get() = exitCode == 0
}
/** `adb start-server` (поднимает локальный adb-сервер приложения). */
fun startServer(timeoutMs: Long = 60_000): Result = exec(listOf("start-server"), timeoutMs)
/** `adb connect host:port`. */
fun connect(hostPort: String, timeoutMs: Long = 30_000): Result =
exec(listOf("connect", hostPort), timeoutMs)
/** `adb disconnect host:port`. */
fun disconnect(hostPort: String, timeoutMs: Long = 15_000): Result =
exec(listOf("disconnect", hostPort), timeoutMs)
/** `adb -s <serial> shell <command>`. */
fun shell(serial: String, command: String, timeoutMs: Long = 60_000): Result =
exec(listOf("-s", serial, "shell", command), timeoutMs)
/** `adb -s <serial> push <local> <remote>`. */
fun push(serial: String, local: File, remote: String, timeoutMs: Long = 60_000): Result =
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")
private fun exec(args: List<String>, timeoutMs: Long): Result {
val cmd = listOf(adbPath) + args
Log.d(TAG, "exec: ${args.joinToString(" ")}")
val process = try {
ProcessBuilder(cmd).directory(home).redirectErrorStream(true).apply {
environment()["HOME"] = home.absolutePath
// adb пишет свой лог/сокеты в TMPDIR; по умолчанию это /tmp недоступно приложению.
environment()["TMPDIR"] = context.cacheDir.absolutePath
environment()["ANDROID_USER_CONFIG"] = home.absolutePath
}.start()
} catch (t: Throwable) {
Log.w(TAG, "не удалось запустить adb", t)
return Result(-1, "adb spawn failed: ${t.message}")
}
val output = StringBuilder()
val reader = Thread {
try {
process.inputStream.bufferedReader().forEachLine { output.appendLine(it) }
} catch (_: Throwable) {
}
}.apply { isDaemon = true; start() }
val finished = try {
process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
} catch (_: InterruptedException) {
false
}
if (!finished) process.destroyForcibly()
reader.join(500)
val code = if (finished) runCatching { process.exitValue() }.getOrDefault(-1) else -1
val text = output.toString().trim()
Log.d(TAG, "exit=$code out=${text.take(400)}")
return Result(code, text)
}
private companion object {
const val TAG = "AdbClient"
}
}
@@ -0,0 +1,388 @@
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
import kotlinx.coroutines.SupervisorJob
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.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 на очках с телефона.
*
* Сценарий: телефон подключился к очкам по Mercury BLE → если Shizuku на очках
* не запущен, просим очки включить «ADB over WiFi» → подключаемся к ним встроенным
* adb по wireless-debugging (TLS, ключ уже авторизован) → заливаем starter →
* запускаем `shizuku_server` → (опционально) просим Mercury запустить наше
* приложение. Всё без ПК и без ручных кнопок.
*/
class ShizukuProvisioner(
context: Context,
private val mercury: MercuryController,
private val controller: VmPhoneController,
) {
private val appContext = context.applicationContext
private val adb = AdbClient(appContext)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mutex = Mutex()
private val _state = MutableStateFlow<ProvisionState>(ProvisionState.Idle)
val state: StateFlow<ProvisionState> = _state.asStateFlow()
/**
* true, если Shizuku уже поднимали в текущей BLE-сессии. Сбрасывается при разрыве BLE
* (см. [onBleDisconnected]) и при явном статусе «Shizuku не работает» с очков.
*
* Без этого флага полный цикл (wireless-adb + adb connect + appMarketLaunch) гонялся
* каждые 30с: очки не отдают ShizukuStatus, пока WiFi-канал не устоялся, и телефон
* бесконечно перезапускал GlassesApp — экран очков «мигал».
*/
@Volatile
private var provisionedInSession = false
/** Последний живой adb-адрес очков (`ip:port`) — переиспользуем, не поднимая всё заново. */
@Volatile
private var lastAddress: String? = null
/**
* Проверить Shizuku и, если не запущен, поднять его. Безопасно вызывать часто:
* дешёвая проверка через WiFi-статус, полный adb-цикл — максимум один раз за BLE-сессию.
*/
fun ensure() {
Log.i(
TAG,
"ensure(): вызов, состояние=${_state.value}, shizuku=${controller.glassesShizuku.value}, " +
"provisioned=$provisionedInSession",
)
scope.launch {
val shizuku = controller.glassesShizuku.value
// Shizuku точно работает — ничего не делаем.
if (shizuku?.running == true) {
_state.value = ProvisionState.Running
provisionedInSession = true
return@launch
}
// Shizuku точно НЕ работает (очки прислали явный статус) — поднимаем заново,
// даже если уже поднимали в этой сессии.
if (shizuku?.running == false) {
provisionedInSession = false
}
// Статус неизвестен (WiFi-канал ещё не устоялся / GlassesApp не запущен), но
// Shizuku мы уже поднимали в этой BLE-сессии — не гоняем цикл повторно.
if (provisionedInSession && shizuku == null) {
Log.i(TAG, "ensure(): уже поднимали в этой сессии, статус неизвестен — пропуск")
return@launch
}
if (mutex.isLocked) {
Log.i(TAG, "ensure(): уже идёт бутстрап")
return@launch
}
mutex.withLock { provisionLocked() }
}
}
/** BLE-сессия разорвана: при следующем подключении проверяем Shizuku заново. */
fun onBleDisconnected() {
provisionedInSession = false
}
/** Принудительно прогнать весь сценарий (диагностика/кнопка). */
fun force() {
Log.i(TAG, "force(): ручной запуск бутстрапа")
scope.launch {
if (mutex.isLocked) return@launch
mutex.withLock { provisionLocked() }
}
}
/**
* Диагностика: прогнать сценарий с заданным адресом `ip:port` (в обход Mercury/mDNS).
* Нужно, чтобы проверить adb-часть, когда телефон и очки в разных сетях (через релей).
*/
fun forceWithAddress(address: String) {
Log.i(TAG, "forceWithAddress($address)")
scope.launch {
if (mutex.isLocked) return@launch
mutex.withLock { provisionLocked(addressOverride = address) }
}
}
private suspend fun provisionLocked(addressOverride: String? = null) {
Log.i(TAG, "provisionLocked(): старт, adb.available=${adb.available}, override=$addressOverride")
if (!adb.available) {
fail("adb-бинарник или ключ не встроены в сборку")
return
}
var lastError: String? = null
for (attempt in 1..MAX_ATTEMPTS) {
Log.i(TAG, "provisionLocked(): попытка $attempt/$MAX_ATTEMPTS")
val error = tryProvision(addressOverride)
if (error == null) {
provisionedInSession = true
return
}
lastError = error
_state.value = ProvisionState.Failed(error)
if (attempt < MAX_ATTEMPTS) delay(RETRY_DELAY_MS)
}
fail(lastError ?: "неизвестная ошибка бутстрапа")
}
/** Одна попытка. `null` — успех (Shizuku поднят), иначе текст ошибки. */
private suspend fun tryProvision(addressOverride: String? = null): String? {
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")
}
} else {
Log.i(TAG, "адрес задан вручную: $address")
}
if (address == null) return "не удалось получить адрес ADB over WiFi на очках"
_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
if (isShizukuRunning(address)) {
Log.i(TAG, "Shizuku уже запущен на очках")
_state.value = ProvisionState.Running
launchGlassesAppIfNeeded()
return null
}
_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)
// push может «провалиться» из-за уже существующего файла — проверяем наличие.
if (!push.ok && adb.shell(address, "test -f $REMOTE_STARTER").exitCode != 0) {
return "push starter не удался: ${push.output}"
}
adb.shell(address, "chmod 755 $REMOTE_STARTER")
val apkPath = shizukuApkPath(address)
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}")
delay(START_VERIFY_DELAY_MS)
if (isShizukuRunning(address)) {
Log.i(TAG, "Shizuku поднят")
_state.value = ProvisionState.Running
launchGlassesAppIfNeeded()
null
} else {
"стартер не поднял shizuku_server: ${run.output.take(300)}"
}
} catch (t: Throwable) {
Log.w(TAG, "tryProvision(): исключение", t)
"ошибка бутстрапа: ${t.message}"
}
}
/**
* Выполнить shell-команду на очках по wireless-adb (TLS). При необходимости сначала
* поднимает «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'")
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 fun copyStarterToCache(): File {
val out = File(appContext.cacheDir, "shizuku_starter")
appContext.assets.open("shizuku/starter").use { input ->
out.outputStream().use { input.copyTo(it) }
}
out.setExecutable(true, true)
return out
}
private fun launchGlassesApp() {
scope.launch {
try {
val ok = mercury.launchGlassesApp(GLASSES_PACKAGE)
Log.i(TAG, "appMarketLaunch($GLASSES_PACKAGE) -> $ok")
} catch (t: Throwable) {
Log.w(TAG, "appMarketLaunch failed: ${t.message}")
}
}
}
/**
* Запускаем GlassesApp через Mercury только если WiFi-канал ещё НЕ подключён.
* Если канал жив — приложение на очках уже работает, а повторный appMarketLaunch
* лишь перезапускает Activity (экран мигает).
*/
private fun launchGlassesAppIfNeeded() {
if (controller.state.value is ConnectionState.Connected) {
Log.i(TAG, "WiFi-канал уже подключён — GlassesApp не перезапускаем")
return
}
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) {
Log.w(TAG, "Shizuku bootstrap: $message")
_state.value = ProvisionState.Failed(message)
}
fun close() {
scope.coroutineContext[kotlinx.coroutines.Job]?.cancel()
}
private companion object {
const val TAG = "ShizukuProvisioner"
const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
const val GLASSES_PACKAGE = "pw.binom.rayneovm.glasses"
const val REMOTE_STARTER = "/data/local/tmp/shizuku_starter"
const val START_VERIFY_DELAY_MS = 4_000L
const val MAX_ATTEMPTS = 3
const val RETRY_DELAY_MS = 5_000L
}
}
/** Состояние автономного бутстрапа Shizuku (для UI). */
sealed interface ProvisionState {
data object Idle : ProvisionState
data object Checking : ProvisionState
data object EnablingAdb : ProvisionState
data class Connecting(val address: String) : ProvisionState
data object StartingShizuku : ProvisionState
data object Running : ProvisionState
data class Failed(val message: String) : ProvisionState
val label: String
get() = when (this) {
Idle -> "не запускался"
Checking -> "проверяем Shizuku…"
EnablingAdb -> "включаем ADB over WiFi…"
is Connecting -> "подключаемся к $address…"
StartingShizuku -> "запускаем Shizuku…"
Running -> "Shizuku поднят"
is Failed -> "ошибка: $message"
}
}
@@ -0,0 +1,30 @@
package pw.binom.rayneovm.phone.debug
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import pw.binom.rayneovm.phone.ShizukuProvisionerProvider
/**
* Диагностический приёмник: `adb shell am broadcast -a pw.binom.rayneovm.phone.DEBUG_PROVISION`
* принудительно запускает автономный подъём Shizuku (кнопка без UI).
*
* Защищён `android.permission.DUMP` — им обладает shell/system, обычные приложения нет.
*/
class DebugReceiver : BroadcastReceiver() {
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)
}
}
private companion object {
const val TAG = "DebugReceiver"
}
}
@@ -16,8 +16,11 @@ 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.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import pw.binom.mercury.DiscoveredGlasses
import pw.binom.mercury.RayNeoMercury
import pw.binom.mercury.model.GeneralStatus
@@ -38,6 +41,7 @@ class MercuryController(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val mercury = RayNeoMercury(context, externalScope = scope)
private val prefs = context.applicationContext.getSharedPreferences("mercury", Context.MODE_PRIVATE)
private val _state = MutableStateFlow<MercuryState>(MercuryState.PermissionsMissing)
val state: StateFlow<MercuryState> = _state.asStateFlow()
@@ -62,6 +66,14 @@ class MercuryController(private val context: Context) {
/** Идёт ли сейчас скан WiFi-сетей очков. */
val wifiScanning: StateFlow<Boolean> = _wifiScanning.asStateFlow()
/** Адрес «ADB over WiFi» на очках (`ip:port`), полученный после [enableWirelessAdb]. */
private val _wirelessAdbAddress = MutableStateFlow<String?>(null)
val wirelessAdbAddress: StateFlow<String?> = _wirelessAdbAddress.asStateFlow()
/** Идёт ли ожидание адреса ADB over WiFi. */
private val _wirelessAdbEnabling = MutableStateFlow(false)
val wirelessAdbEnabling: StateFlow<Boolean> = _wirelessAdbEnabling.asStateFlow()
/** Разрешения, которые нужно запросить для работы с BLE на этом API. */
val requiredPermissions: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
@@ -75,6 +87,8 @@ class MercuryController(private val context: Context) {
init {
observeEvents()
refreshPermissionState()
// Автопоиск/автоподключение BLE без кнопок: как только очки доступны — соединяемся.
scope.launch { autoConnectLoop() }
}
/** Перепроверить состояние Bluetooth-адаптера и разрешений. */
@@ -151,28 +165,102 @@ class MercuryController(private val context: Context) {
return
}
_state.value = MercuryState.Connecting(glasses)
scope.launch {
try {
val paired = mercury.pair(glasses)
if (paired is pw.binom.mercury.pair.PairManager.PairingResult.Failed) {
_state.value = MercuryState.Disconnected(
message = "Сопряжение не удалось: ${paired.reason}",
)
return@launch
}
val ok = mercury.connect(glasses)
if (!ok) {
_state.value = MercuryState.Disconnected(
message = "Не удалось открыть GATT-сессию",
)
return@launch
}
_state.value = MercuryState.Connected(glasses, batteryPercent = null)
startStatusRefresh()
} catch (t: Throwable) {
_state.value = MercuryState.Disconnected(message = "Ошибка: ${t.message}")
scope.launch { connectInternal(glasses) }
}
/** Общая логика подключения: пара (если надо) + GATT. Сохраняет адрес для автоподключения. */
private suspend fun connectInternal(glasses: DiscoveredGlasses) {
try {
val paired = mercury.pair(glasses)
if (paired is pw.binom.mercury.pair.PairManager.PairingResult.Failed) {
_state.value = MercuryState.Disconnected(
message = "Сопряжение не удалось: ${paired.reason}",
)
return
}
val ok = mercury.connect(glasses)
if (!ok) {
_state.value = MercuryState.Disconnected(
message = "Не удалось открыть GATT-сессию",
)
return
}
saveAddress(glasses)
_state.value = MercuryState.Connected(glasses, batteryPercent = null)
startStatusRefresh()
} catch (t: Throwable) {
_state.value = MercuryState.Disconnected(message = "Ошибка: ${t.message}")
}
}
/**
* Автопоиск и автоподключение BLE — без кнопок. Сначала пробуем известный адрес
* (мгновенно, без скана), иначе сканируем. Повторяем, пока не соединимся.
*/
private suspend fun autoConnectLoop() {
while (scope.isActive) {
val st = _state.value
if (st is MercuryState.Connected || st is MercuryState.Connecting) {
delay(AUTO_IDLE_STEP_MS)
continue
}
if (!hasPermissions()) {
_state.value = MercuryState.PermissionsMissing
delay(AUTO_IDLE_STEP_MS)
continue
}
if (!isBluetoothEnabled()) {
_state.value = MercuryState.BluetoothOff
delay(AUTO_IDLE_STEP_MS)
continue
}
tryAutoConnect()
delay(AUTO_RETRY_MS)
}
}
private suspend fun tryAutoConnect() {
// 1) Известный адрес — подключаемся напрямую, без скана.
savedAddress()?.let { address ->
val device = remoteDevice(address)
if (device != null) {
android.util.Log.i(TAG, "автоподключение по известному адресу $address")
val glasses = DiscoveredGlasses(device, name = savedName())
_state.value = MercuryState.Connecting(glasses)
connectInternal(glasses)
if (_state.value is MercuryState.Connected) return
}
}
// 2) Скан — на случай смены адреса или первого подключения.
android.util.Log.i(TAG, "автопоиск: скан очков…")
_state.value = MercuryState.Scanning(emptyList())
val found = mercury.scanOnce(SCAN_TIMEOUT_MS)
if (found != null) {
_state.value = MercuryState.Connecting(found)
connectInternal(found)
if (_state.value is MercuryState.Connected) return
}
_state.value = MercuryState.Disconnected(message = "Очки не найдены — продолжаю поиск…")
}
private fun savedAddress(): String? = prefs.getString(KEY_ADDRESS, null)
private fun savedName(): String? = prefs.getString(KEY_NAME, null)
private fun saveAddress(glasses: DiscoveredGlasses) {
prefs.edit()
.putString(KEY_ADDRESS, glasses.address)
.putString(KEY_NAME, glasses.name ?: glasses.device.name)
.apply()
}
@Suppress("DEPRECATION")
private fun remoteDevice(address: String): android.bluetooth.BluetoothDevice? = try {
(context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
?.adapter
?.getRemoteDevice(address)
} catch (t: Throwable) {
null
}
fun disconnect() {
@@ -232,8 +320,14 @@ class MercuryController(private val context: Context) {
// ------------------------------------------------------------------ экран / яркость / wifi
/** Яркость экрана очков, 0..100. */
/**
* Яркость экрана очков, 0..100.
*
* При включённой авто-яркости очки отбрасывают явное значение, поэтому сначала
* выключаем авто (как в view-mate — иначе слайдер «не работает»).
*/
fun setBrightness(value: Int) = launchMercury("setBrightness") {
mercury.setAutoBrightness(false)
mercury.setBrightness(value)
}
@@ -242,17 +336,6 @@ class MercuryController(private val context: Context) {
mercury.setAutoBrightness(enable)
}
/**
* Погасить (on=false, deepSuspend) или разбудить (on=true) экран очков.
*
* Перед засыпанием включаем heartbeat: без него очки роняют BLE-связь
* примерно через 3с после выключения экрана (см. RayNeoMercury.enableHeartbeat).
*/
fun setScreenOn(on: Boolean) = launchMercury("setScreenOn($on)") {
if (!on) mercury.enableHeartbeat()
mercury.setDeepSuspend(!on)
}
/** Включить/выключить WiFi-радио очков. */
fun setWifiEnabled(enable: Boolean) = launchMercury("setWifiEnabled($enable)") {
mercury.wifiToggle(enable)
@@ -283,6 +366,50 @@ class MercuryController(private val context: Context) {
mercury.wifiConnect(ssid, password)
}
/**
* Просит очки включить «ADB over WiFi». Адрес вернётся событием и попадёт
* в [wirelessAdbAddress]. Нужен для ADB-бутстрапа Shizuku с телефона.
*/
fun enableWirelessAdb() {
_wirelessAdbAddress.value = null
_wirelessAdbEnabling.value = true
launchMercury("enableWirelessAdb") { mercury.enableWirelessAdb() }
// Очки не всегда отвечают адресом — снимаем «включаем…» по таймауту.
scope.launch {
delay(WIRELESS_ADB_TIMEOUT_MS)
_wirelessAdbEnabling.value = false
}
}
/** Просит очки выключить «ADB over WiFi». */
fun disableWirelessAdb() = launchMercury("disableWirelessAdb") {
mercury.disableWirelessAdb()
}
/**
* Включает «ADB over WiFi» на очках и дожидается адреса `ip:port`
* (или возвращает null, если очки не прислали его за [timeoutMs]).
*/
suspend fun enableWirelessAdbAndAwaitAddress(timeoutMs: Long = WIRELESS_ADB_WAIT_MS): String? {
_wirelessAdbAddress.value = null
_wirelessAdbEnabling.value = true
val sent = try {
mercury.enableWirelessAdb()
} catch (t: Throwable) {
android.util.Log.w(TAG, "enableWirelessAdb failed: ${t.message}")
false
}
if (!sent) {
_wirelessAdbEnabling.value = false
return null
}
return try {
withTimeoutOrNull(timeoutMs) { _wirelessAdbAddress.filterNotNull().first() }
} finally {
_wirelessAdbEnabling.value = false
}
}
/** Забыть сохранённую сеть на очках. */
fun forgetWifi(ssid: String) = launchMercury("forgetWifi($ssid)") {
mercury.wifiRemove(ssid)
@@ -305,6 +432,8 @@ class MercuryController(private val context: Context) {
_generalStatus.value = null
_wifiNetworks.value = emptyList()
_wifiScanning.value = false
_wirelessAdbAddress.value = null
_wirelessAdbEnabling.value = false
}
private fun refreshPermissionState() {
@@ -360,6 +489,11 @@ class MercuryController(private val context: Context) {
_wifiNetworks.value = event.networks
_wifiScanning.value = false
}
is RayNeoMercury.Event.WirelessAdbAddress -> {
android.util.Log.i(TAG, "WirelessAdbAddress: ${event.address}")
_wirelessAdbAddress.value = event.address
_wirelessAdbEnabling.value = false
}
else -> Unit
}
}
@@ -377,12 +511,30 @@ class MercuryController(private val context: Context) {
/** Сколько ждать ответ скана WiFi, прежде чем снять статус «сканируем…». */
private const val WIFI_SCAN_TIMEOUT_MS = 15_000L
/** Сколько ждать адрес ADB-over-WiFi, прежде чем снять статус «включаем…». */
private const val WIRELESS_ADB_TIMEOUT_MS = 15_000L
/** Сколько ждать адрес ADB-over-WiFi при автоматическом бутстрапе. */
private const val WIRELESS_ADB_WAIT_MS = 25_000L
/** Пауза между повторами первичного запроса статуса (растёт линейно). */
private const val STATUS_RETRY_STEP_MS = 1_000L
/** Период фонового обновления статуса, когда он уже получен. */
private const val STATUS_REFRESH_MS = 15_000L
/** Пауза автопоиска BLE после неудачной попытки. */
private const val AUTO_RETRY_MS = 6_000L
/** Пауза цикла, когда уже подключены или ждём разрешений/Bluetooth. */
private const val AUTO_IDLE_STEP_MS = 3_000L
/** Таймаут однократного BLE-скана в автопоиске. */
private const val SCAN_TIMEOUT_MS = 8_000L
private const val KEY_ADDRESS = "glasses_address"
private const val KEY_NAME = "glasses_name"
val Local = staticCompositionLocalOf<MercuryController> {
error("MercuryController not provided")
}
@@ -25,7 +25,9 @@ import pw.binom.rayneovm.shared.GetAppList
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.SetAudioMode
import pw.binom.rayneovm.shared.ShizukuStatus
import pw.binom.rayneovm.shared.VmMessage
import pw.binom.rayneovm.shared.VmProtocol
@@ -47,6 +49,10 @@ class VmPhoneController(
private val _glassesStatus = MutableStateFlow<GlassesStatusInfo?>(null)
val glassesStatus: StateFlow<GlassesStatusInfo?> = _glassesStatus.asStateFlow()
/** Состояние Shizuku на очках (приходит с очков после подключения/по запросу). */
private val _glassesShizuku = MutableStateFlow<ShizukuStatus?>(null)
val glassesShizuku: StateFlow<ShizukuStatus?> = _glassesShizuku.asStateFlow()
private var currentTransport: StringTransport? = null
private val audioManager by lazy { AudioPlaybackManager(context) }
@@ -83,7 +89,6 @@ class VmPhoneController(
val url = "ws://${service.host}:${service.port}/vm"
val transport = Transports.wifi(url, autoReconnect = false)
currentTransport = transport
_state.value = ConnectionState.Connected(service.host)
attempt = 0
transport.connect(
onMessage = { json ->
@@ -92,9 +97,18 @@ class VmPhoneController(
} catch (_: Throwable) { return@connect }
handleIncoming(msg)
},
onConnected = { /* уже выставлено в Connecting->Connected */ },
onConnected = {
// Только здесь канал реально поднят (рукопожатие прошло). Раньше
// Connected выставлялся сразу после mDNS — из-за этого триггер
// бутстрапа Shizuku срабатывал на «мёртвом» канале.
_state.value = ConnectionState.Connected(service.host)
// Очки сами присылают ShizukuStatus при подключении; на всякий
// случай дублируем запросом — первый ответ легко теряется.
scope.launch { send(RequestShizukuStatus) }
},
onDisconnected = {
currentTransport = null
_glassesShizuku.value = null
_state.value = ConnectionState.Disconnected
},
)
@@ -127,6 +141,9 @@ class VmPhoneController(
is AppListResponse -> {
_appsOnGlasses.value = msg.apps
}
is ShizukuStatus -> {
_glassesShizuku.value = msg
}
is SetAudioMode -> {
// Аудио режим — отдельный поток, не контролируется этим контроллером напрямую.
// AudioCaptureService на очках стартует/останавливается.
@@ -154,6 +171,11 @@ class VmPhoneController(
send(GetAppList)
}
/** Перезапрашивает состояние Shizuku на очках (см. [glassesShizuku]). */
suspend fun requestShizukuStatus() {
send(RequestShizukuStatus)
}
/** Отправляет сообщение очкам. */
suspend fun send(msg: VmMessage) {
val t = currentTransport ?: return
@@ -48,6 +48,9 @@ import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.mercury.MercuryState
import pw.binom.rayneovm.phone.protocol.ConnectionState
import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.phone.bootstrap.ProvisionState
import pw.binom.rayneovm.phone.bootstrap.ShizukuProvisioner
import pw.binom.rayneovm.shared.ShizukuStatus
/**
* Экран «Очки»: два независимых канала связи + запуск нашего приложения.
@@ -65,14 +68,19 @@ import pw.binom.rayneovm.phone.protocol.VmPhoneController
fun GlassesStatusScreen(
controller: VmPhoneController,
mercuryController: MercuryController,
provisioner: ShizukuProvisioner,
onRequestMercuryPermissions: () -> Unit = {},
) {
val connection by controller.state.collectAsState()
val glasses by controller.glassesStatus.collectAsState()
val shizuku by controller.glassesShizuku.collectAsState()
val provisionState by provisioner.state.collectAsState()
val mercury by mercuryController.state.collectAsState()
val generalStatus by mercuryController.generalStatus.collectAsState()
val wifiNetworks by mercuryController.wifiNetworks.collectAsState()
val wifiScanning by mercuryController.wifiScanning.collectAsState()
val adbAddress by mercuryController.wirelessAdbAddress.collectAsState()
val adbEnabling by mercuryController.wirelessAdbEnabling.collectAsState()
val scope = rememberCoroutineScope()
val mercuryConnected = mercury is MercuryState.Connected
@@ -83,6 +91,11 @@ fun GlassesStatusScreen(
if (mercuryConnected) mercuryController.refreshStatus()
}
// При живом WebSocket-канале перезапрашиваем Shizuku-статус очков.
LaunchedEffect(connection) {
if (connection is ConnectionState.Connected) controller.requestShizukuStatus()
}
Column(
modifier = Modifier
.fillMaxSize()
@@ -100,6 +113,15 @@ fun GlassesStatusScreen(
Channel1WebSocketSection(connection = connection, glasses = glasses)
ShizukuSection(
shizuku = shizuku,
provision = provisionState,
adbAddress = adbAddress,
adbEnabling = adbEnabling,
canControl = mercuryConnected,
onProvision = { provisioner.force() },
)
HorizontalDivider(color = Color(0xFF2A2A2A))
Channel2MercurySection(
@@ -115,7 +137,10 @@ fun GlassesStatusScreen(
enabled = mercuryConnected,
onBrightness = { mercuryController.setBrightness(it) },
onAutoBrightness = { mercuryController.setAutoBrightness(it) },
onScreenOn = { mercuryController.setScreenOn(it) },
)
ScreenPowerSection(
onShell = { command -> provisioner.runShell(command) },
)
GlassesWifiSection(
@@ -171,6 +196,50 @@ private fun Channel1WebSocketSection(
}
}
@Composable
private fun ShizukuSection(
shizuku: ShizukuStatus?,
provision: ProvisionState,
adbAddress: String?,
adbEnabling: Boolean,
canControl: Boolean,
onProvision: () -> Unit,
) {
SectionCard(title = "Shizuku на очках") {
val state = when {
shizuku == null -> "ждём статус…"
shizuku.running && shizuku.granted -> "работает"
shizuku.running -> "работает, нет разрешения"
else -> "не запущен"
}
StatusRow("Состояние", state)
StatusRow("Бутстрап", provision.label)
when {
shizuku?.running == true -> {
// Сервер жив: если прав нет — их выдаёт диалог на очках, ADB не нужен.
if (!shizuku.granted) {
Hint("Разрешение выдаётся диалогом на очках при запуске GlassesApp.")
}
}
else -> {
Hint(
"Если Shizuku не запущен, телефон поднимет его сам: включит ADB over WiFi " +
"на очках, подключится по беспроводному adb и запустит shizuku_server.",
)
if (!canControl) {
Spacer(modifier = Modifier.height(8.dp))
Hint("Для автоподъёма нужен Канал 2 (Mercury BLE).")
}
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = onProvision, enabled = canControl && provision !is ProvisionState.EnablingAdb) {
Text("Поднять Shizuku на очках")
}
adbAddress?.let { StatusRow("ADB-адрес", it) }
}
}
}
}
@Composable
private fun Channel2MercurySection(
mercury: MercuryState,
@@ -230,9 +299,8 @@ private fun ScreenAndBrightnessSection(
enabled: Boolean,
onBrightness: (Int) -> Unit,
onAutoBrightness: (Boolean) -> Unit,
onScreenOn: (Boolean) -> Unit,
) {
SectionCard(title = "Экран и яркость") {
SectionCard(title = "Яркость") {
if (!enabled) {
Hint("Нужен Канал 2 (Mercury BLE).")
} else if (status == null) {
@@ -259,14 +327,50 @@ private fun ScreenAndBrightnessSection(
checked = status.automaticBrightness,
onCheckedChange = onAutoBrightness,
)
val sleeping = status.deepSuspend == 1
OutlinedButton(onClick = { onScreenOn(sleeping) }) {
Text(if (sleeping) "Разбудить экран" else "Погасить экран")
}
}
}
}
/**
* Гашение/пробуждение экрана очков через adb (`input keyevent 223/224`).
* Работает по wireless-debugging: если очки ушли в глубокий сон и WiFi/adb отвалились —
* команда не дойдёт (Mercury setDeepSuspend мы выпилили, т.к. он не работал).
*/
@Composable
private fun ScreenPowerSection(
onShell: suspend (String) -> Result<String>,
) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
fun send(command: String, okText: String) {
if (busy) return
busy = true
message = null
scope.launch {
val r = onShell(command)
message = if (r.isSuccess) okText else "Ошибка: ${r.exceptionOrNull()?.message}"
busy = false
}
}
SectionCard(title = "Экран очков") {
Hint("Через adb (wireless-debugging). Если очки ушли в глубокий сон и WiFi отвалился — команда не дойдёт.")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
enabled = !busy,
onClick = { send("input keyevent 223", "Экран погашен") },
) { Text("Погасить") }
OutlinedButton(
enabled = !busy,
onClick = { send("input keyevent 224", "Экран разбужен") },
) { Text("Разбудить") }
}
message?.let { Hint(it) }
}
}
@Composable
private fun GlassesWifiSection(
status: GeneralStatus?,
@@ -22,6 +22,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import pw.binom.rayneovm.phone.bootstrap.ShizukuProvisioner
import pw.binom.rayneovm.phone.mercury.MercuryController
import pw.binom.rayneovm.phone.protocol.VmPhoneController
@@ -36,6 +37,7 @@ import pw.binom.rayneovm.phone.protocol.VmPhoneController
fun HomeScreen(
controller: VmPhoneController,
mercuryController: MercuryController,
provisioner: ShizukuProvisioner,
onRequestMercuryPermissions: () -> Unit = {},
) {
var selectedTab by remember { mutableStateOf(Tab.GLASSES) }
@@ -62,6 +64,7 @@ fun HomeScreen(
Tab.GLASSES -> GlassesStatusScreen(
controller = controller,
mercuryController = mercuryController,
provisioner = provisioner,
onRequestMercuryPermissions = onRequestMercuryPermissions,
)
Tab.APPS -> AppsListScreen(controller = controller)
@@ -116,6 +116,11 @@ data object Shutdown : VmMessage
@SerialName("get_app_list")
data object GetAppList : VmMessage
/** Просьба к очкам прислать текущее состояние Shizuku (см. [ShizukuStatus]). */
@Serializable
@SerialName("request_shizuku_status")
data object RequestShizukuStatus : VmMessage
@Serializable
data class AppEntry(
val packageName: String,
@@ -154,6 +159,20 @@ data class AppLaunchedAck(
val success: Boolean,
) : VmMessage
/**
* Состояние Shizuku на очках.
*
* [running] — жив ли сервер Shizuku (`Shizuku.pingBinder()`): без него GlassesApp не может
* создать VirtualDisplay, и его надо поднимать через ADB. [granted] — выдано ли нашему
* приложению разрешение (если сервер жив, но прав нет, лечится диалогом на очках, не ADB).
*/
@Serializable
@SerialName("shizuku_status")
data class ShizukuStatus(
val running: Boolean,
val granted: Boolean,
) : VmMessage
// =====================================================================
// Bidirectional
// =====================================================================