diff --git a/BOOTSTRAP_TEST.md b/BOOTSTRAP_TEST.md index cb7313a..700ef09 100644 --- a/BOOTSTRAP_TEST.md +++ b/BOOTSTRAP_TEST.md @@ -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 ` и публикует 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 сам. diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ShizukuBootstrap.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ShizukuBootstrap.kt index 8dc948c..27903ce 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ShizukuBootstrap.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/ShizukuBootstrap.kt @@ -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. * diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt index 01b1e20..13a2a47 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt @@ -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) { diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt index ea13eeb..14346e1 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt @@ -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) { diff --git a/app-phone-vm/build.gradle.kts b/app-phone-vm/build.gradle.kts index 55f93f4..64e0749 100644 --- a/app-phone-vm/build.gradle.kts +++ b/app-phone-vm/build.gradle.kts @@ -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 + } } } diff --git a/app-phone-vm/src/main/AndroidManifest.xml b/app-phone-vm/src/main/AndroidManifest.xml index 72ed485..8b76dd8 100644 --- a/app-phone-vm/src/main/AndroidManifest.xml +++ b/app-phone-vm/src/main/AndroidManifest.xml @@ -51,5 +51,16 @@ android:name=".audio.AudioPlaybackService" android:exported="false" android:foregroundServiceType="mediaPlayback" /> + + + + + + + diff --git a/app-phone-vm/src/main/assets/shizuku/starter b/app-phone-vm/src/main/assets/shizuku/starter new file mode 100644 index 0000000..5d10bff Binary files /dev/null and b/app-phone-vm/src/main/assets/shizuku/starter differ diff --git a/app-phone-vm/src/main/jniLibs/arm64-v8a/libadb.so b/app-phone-vm/src/main/jniLibs/arm64-v8a/libadb.so new file mode 100644 index 0000000..572b479 Binary files /dev/null and b/app-phone-vm/src/main/jniLibs/arm64-v8a/libadb.so differ diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/MainActivity.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/MainActivity.kt index aaf87a3..9ccbbfd 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/MainActivity.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/MainActivity.kt @@ -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) }, diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/PhoneVmApp.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/PhoneVmApp.kt index 9dfb042..f6e5d5b 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/PhoneVmApp.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/PhoneVmApp.kt @@ -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 +} diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/adb/AdbClient.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/adb/AdbClient.kt new file mode 100644 index 0000000..80ecdb0 --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/adb/AdbClient.kt @@ -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 shell `. */ + fun shell(serial: String, command: String, timeoutMs: Long = 60_000): Result = + exec(listOf("-s", serial, "shell", command), timeoutMs) + + /** `adb -s push `. */ + 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, 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" + } +} diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt new file mode 100644 index 0000000..6084a6d --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt @@ -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.Idle) + val state: StateFlow = _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 = 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 { 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" + } +} diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/debug/DebugReceiver.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/debug/DebugReceiver.kt new file mode 100644 index 0000000..0bb8f4a --- /dev/null +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/debug/DebugReceiver.kt @@ -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" + } +} diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt index 6afd425..385fb8c 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/mercury/MercuryController.kt @@ -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.PermissionsMissing) val state: StateFlow = _state.asStateFlow() @@ -62,6 +66,14 @@ class MercuryController(private val context: Context) { /** Идёт ли сейчас скан WiFi-сетей очков. */ val wifiScanning: StateFlow = _wifiScanning.asStateFlow() + /** Адрес «ADB over WiFi» на очках (`ip:port`), полученный после [enableWirelessAdb]. */ + private val _wirelessAdbAddress = MutableStateFlow(null) + val wirelessAdbAddress: StateFlow = _wirelessAdbAddress.asStateFlow() + + /** Идёт ли ожидание адреса ADB over WiFi. */ + private val _wirelessAdbEnabling = MutableStateFlow(false) + val wirelessAdbEnabling: StateFlow = _wirelessAdbEnabling.asStateFlow() + /** Разрешения, которые нужно запросить для работы с BLE на этом API. */ val requiredPermissions: Array = 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 { error("MercuryController not provided") } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt index 174eecb..cf3dc79 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt @@ -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(null) val glassesStatus: StateFlow = _glassesStatus.asStateFlow() + /** Состояние Shizuku на очках (приходит с очков после подключения/по запросу). */ + private val _glassesShizuku = MutableStateFlow(null) + val glassesShizuku: StateFlow = _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 diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt index 80afbcc..18b72be 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/GlassesStatusScreen.kt @@ -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, +) { + val scope = rememberCoroutineScope() + var busy by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(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?, diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/HomeScreen.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/HomeScreen.kt index 93ec546..adaae2f 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/HomeScreen.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/ui/HomeScreen.kt @@ -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) diff --git a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt index 5c801bd..20bb869 100644 --- a/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt +++ b/shared/src/main/kotlin/pw/binom/rayneovm/shared/VmMessage.kt @@ -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 // =====================================================================