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:
@@ -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) {
|
||||
|
||||
+32
-2
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+184
-32
@@ -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
|
||||
// =====================================================================
|
||||
|
||||
Reference in New Issue
Block a user