diff --git a/BOOTSTRAP_TEST.md b/BOOTSTRAP_TEST.md new file mode 100644 index 0000000..cb7313a --- /dev/null +++ b/BOOTSTRAP_TEST.md @@ -0,0 +1,91 @@ +# Тест автономного автозапуска GlassesApp (HOME + adb-бутстрап Shizuku) — ВЫВОДЫ + +Цель: доказать, что GlassesApp может подниматься и оживлять Shizuku после холодной +перезагрузки **без Mercury-лаунчера и без USB-компьютера**. + +Устройство: RayNeo X3 Pro, adb serial `A06B4AB933C4103`. +Дата экспериментов: 2026-09-25/26. + +--- + +## ИТОГОВЫЙ ВЫВОД (кратко) + +1. **Автозапуск через HOME не работает.** Наш пакет можно назначить дефолтным HOME + (`cmd package set-home-activity pw.binom.rayneovm.glasses/.MainActivity`, `isDefault=true`), + но при загрузке ROM-патч system_server всё равно поднимает **свою** + `com.ffalconxr.mercury.launcher/.home.LauncherHomeActivity`. Ручной HOME-intent + (`am start -a MAIN -c HOME`) открывает наш `MainActivity` правильно — то есть наш + домашний экран рабочий, но boot-резолв перехвачен Mercury. + +2. **Mercury-лаунчер убрать нельзя.** `pm disable-user com.ffalconxr.mercury.launcher` + применяется (`enabled=0`), но system_server всё равно стартует Mercury при буте + (PID uid system), и **его BLE продолжает работать** (телефон по Bluetooth соединяется). + Значит: убийца `BackgroundAppManager` присутствует всегда → стража + NetConfigActivity-на-скрытом-дисплее **обязательна**. Отключение лаунчера проблему не решает. + +3. **BLE жив всегда.** Раз Mercury стартует при буте в любом случае, Channel 2 (BLE: + запуск приложения, яркость, WiFi, статус) доступен всегда. Это надёжный канал. + +4. **`com.rayneo.mercury.app=true`** у нас уже стоит в манифесте, но **иммунитета от + убийцы не даёт** — нас всё равно убивало. + +## Про adb-бутстрап Shizuku после ребута + +| Способ | Результат | +|--------|-----------| +| legacy plain-TCP `persist.adb.tcp.port` / `service.adb.tcp.port` из shell (uid 2000) | ❌ SELinux запрещает (`Unable to set property ... adbd_config_prop, u:r:shell:s0`) | +| Wireless-ADB `settings put global adb_wifi_enabled 1` | ⚠️ включается, но сбрасывается ребутом и требует подключённого WiFi; при выключенном WiFi очки полностью пропадают из сети | +| «Второй adbd» на `127.0.0.1:8872` | ❌ это не adbd, а Bluetooth HCI-snoop (`btsnoop`), uid android.uid.bluetooth | +| Единственный настоящий adbd | штатный `adbd --root_seclabel=u:r:su:s0` (uid shell), слушает USB/WiFi | + +**Вывод:** автономного сетевого бутстрапа Shizuku без WiFi у нас нет. После ребута +очки оживают либо через USB, либо через WiFi+wireless-adb (если сеть есть). +Self-bootstrap (встроенный adb-клиент в приложении + авторизованный ключ) возможен, +но только когда сеть уже поднята, т.е. холодный старт им не закрыть. + +## RayDesk (github.com/Quad-Labs/RayDesk) — почему «без приседаний» + +RayDesk — Moonlight-клиент: стримит рабочий стол ПК в **свой** GL-рендер. Он: +- НЕ создаёт VirtualDisplay, НЕ использует Shizuku, НЕ запускает чужие Activity; +- «оба глаза» = свой `TextureView` (левый) + Mercury SDK `MirroringView` (правый); +- использует проприетарный Mercury SDK (`BaseMirrorActivity`, `TempleAction`), нужен + developer access от RayNeo. + +Их подход **неприменим** к нашей задаче: мы запускаем чужие приложения на VD — это +принципиально другой сценарий. Заимствовать нечего (наш meta-data уже стоит). + +## Остаётся рабочих варианта + +- **A (текущий):** Mercury живёт как сервис (BLE + убийца), стража NetConfigActivity + держит гостя, Shizuku держим (USB / wireless-adb при наличии WiFi). +- **B:** встроить в приложение adb-клиент (dadb) + один раз авторизовать наш ключ — + поднимает Shizuku по сети, когда WiFi есть (не закрывает холодный старт без сети). + +--- + +## Журнал (детали по этапам) + +### Этап A/B — HOME после ребута +- Назначили наш пакет дефолтным HOME. После ребута `topResumedActivity=` + `com.ffalconxr.mercury.launcher/.home.LauncherHomeActivity` — нас система не подняла. +- `resolve-activity` при этом указывает на нас (`isDefault=true`) — то есть boot идёт + мимо обычного HOME-резолва (ROM-патч). + +### Этап C — adb после ребута +- `settings put global adb_wifi_enabled 1` ✅ (запись из shell проходит). +- `setprop service.adb.tcp.port` / `persist.adb.tcp.port` ❌ SELinux. +- boot-лог: `AdbDebuggingManager: Not connected to any wireless network. Not enabling adbwifi.` +- Wireless-adb поднялся по WiFi: `adb connect 192.168.76.248:40481` → `device`. + +### Этап D — WiFi off (ложная «тыква») +- При `svc wifi disable` очки пропали из сети полностью (ping unreachable) — wireless-adb + живёт на WiFi-интерфейсе, loopback снаружи недоступен. Внутренний loopback не проверялся. + +### Этап E — отключение Mercury +- `pm disable-user` → `enabled=0`, но после ребута Mercury запущен и держит стол; + телефон по BLE соединился. Откат: `pm enable --user 0 com.ffalconxr.mercury.launcher` + выполнен (`enabled=1`). + +### Ложная зацепка +- `127.0.0.1:8872` (uid 1002) — при коннекте отдаёт поток с магией `btsnoop` → это + Bluetooth HCI-логгер, не adbd. ADB-хендшейк не проходит. diff --git a/README.md b/README.md index 86e2f75..1f93785 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,38 @@ adb -s install -r app-glasses-vm/build/outputs/apk/debug/app-gl adb -s install -r app-phone-vm/build/outputs/apk/debug/app-phone-vm-debug.apk ``` +## GlassesApp как Home (лаунчер) + +На RayNeo X3 Pro `BOOT_COMPLETED` ненадёжен, поэтому штатный автозапуск — сделать GlassesApp домашним экраном: система поднимает HOME при каждой загрузке. В манифесте у `MainActivity` уже есть intent-filter `MAIN` + `HOME` + `DEFAULT`, но **дефолтным он не назначается автоматически** — это отдельная команда, ставится вручную. + +Назначить нашим приложением (выполнять осознанно; пока не сделано): + +```bash +adb -s shell cmd package set-home-activity pw.binom.rayneovm.glasses/.MainActivity +``` + +Вернуть штатный лаунчер RayNeo обратно: + +```bash +adb -s shell cmd package set-home-activity com.ffalconxr.mercury.launcher/home.MainActivity +``` + +> Компонент записывается через `/` (package/class), а не через `/.` — именно так его принимает `set-home-activity` на этой прошивке. + +Проверить, кто сейчас дефолтный HOME: + +```bash +adb -s shell cmd package resolve-activity -a android.intent.action.MAIN -c android.intent.category.HOME +``` + +Показать всех кандидатов в HOME: + +```bash +adb -s shell cmd package query-activities -a android.intent.action.MAIN -c android.intent.category.HOME +``` + +Аварийный выход, если домашний экран вообще перестал открываться (например, после `pm disable-user` не того пакета): переключиться на другой зарегистрированный HOME временно тем же `set-home-activity` (у нас в системе есть `com.teslacoilsw.launcher`, Nova) либо снять отключение через `cmd package enable-user`. + ## Smoke-test (проверено) 1. Запустить `GlassesVmApp` на очках → VmService стартует, `netstat -tlnp` показывает `*:8080`. diff --git a/app-glasses-vm/src/main/AndroidManifest.xml b/app-glasses-vm/src/main/AndroidManifest.xml index 60a750e..7b284e6 100644 --- a/app-glasses-vm/src/main/AndroidManifest.xml +++ b/app-glasses-vm/src/main/AndroidManifest.xml @@ -50,16 +50,27 @@ android:supportsRtl="true" tools:targetApi="36"> + + + + + + diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt index 76dff42..1d601e9 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt @@ -3,6 +3,7 @@ package pw.binom.rayneovm.glasses import android.content.Intent import android.content.IntentFilter import android.os.Bundle +import android.view.Display import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -27,6 +28,14 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // HOME живёт на display 0. Если систему (или гостя на VirtualDisplay) + // угораздит запросить домашний экран внутри чужого дисплея — мы бы + // открылись сами внутри себя (зеркальная рекурсия), поэтому выходим. + val displayId = display?.displayId ?: Display.DEFAULT_DISPLAY + if (displayId != Display.DEFAULT_DISPLAY) { + finish() + return + } enableEdgeToEdge() ShizukuBootstrap.attach(this) val controller = VmGlassesController.fromContext(this) 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 89dd8b1..01b1e20 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 @@ -108,6 +108,8 @@ class VmService : LifecycleService() { onConnected = { connId -> Log.i(TAG, "Phone connected: $connId") currentConn.set(connId) + // Переподключение: снять залипшее «Нет связи с телефоном». + controller.onPhoneConnected() }, onDisconnected = { connId -> Log.i(TAG, "Phone disconnected: $connId") @@ -203,6 +205,10 @@ class VmService : LifecycleService() { override fun onDestroy() { super.onDestroy() + // Прибираем за собой: гость (force-stop + дисплей), страж (флаг + дисплей + UserService). + // Не покрывает SIGKILL/reinstall — там наш код не выполняется вообще. + runCatching { controller.stopGuest() } + runCatching { controller.stopMercuryKillGuard() } unregisterMdns() server?.close() server = null 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 c8051fe..ea13eeb 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 @@ -51,6 +51,9 @@ class VmGlassesController { private var activeSource: VirtualDisplayFrameSource? = null + /** Пакет гостя, запущенного на [activeSource] — его и убиваем в [stopGuest]. */ + @Volatile private var activeGuestPackage: String? = null + /** Служебный дисплей Mercury-«стража» (см. [ensureMercuryKillGuard]); 0 — не создан. */ private var guardDisplayId = 0 @@ -81,6 +84,9 @@ class VmGlassesController { Log.w(TAG, "acquireFrameSource: appContext not set") return@withLock null } + // Смена приложения: прошлого гостя сначала УБИВАЕМ, и только потом рвём его дисплей — + // иначе задача переезжает на display 0 и вылезает во весь экран (см. [stopGuest]). + killActiveGuest() releaseFrameSource() val surfaceTexture = try { // ВАЖНО: single-buffer нельзя — DisplayManagerService reject'ит @@ -106,6 +112,7 @@ class VmGlassesController { return@withLock null } activeSource = source + activeGuestPackage = packageName _frameSource.value = source source } @@ -131,6 +138,18 @@ class VmGlassesController { _state.value = newState } + /** + * Телефон подключился (в том числе переподключился после разрыва). Если на экране висел + * [GlassesScreenState.NoConnection], снимаем его — связь-то восстановлена. Гостя не трогаем: + * если он запущен, возвращаем [GlassesScreenState.Running], иначе [GlassesScreenState.NoAppSelected]. + */ + fun onPhoneConnected() { + if (_state.value !is GlassesScreenState.NoConnection) return + val pkg = activeGuestPackage + if (pkg != null) setState(GlassesScreenState.Running(pkg)) + else setState(GlassesScreenState.NoAppSelected) + } + /** * Перемещает виртуальный курсор мыши. Очки рисуют его в обоих глазах, а [clickCursor] * использует последнюю позицию как точку клика. @@ -293,12 +312,48 @@ class VmGlassesController { } } - /** Остановить гостя и освободить VirtualDisplay (по Shutdown или смене приложения). */ + /** + * Остановить гостя и освободить VirtualDisplay (по Shutdown или смене приложения). + * + * Порядок критичен: СНАЧАЛА force-stop гостя, потом release дисплея. Если освободить + * дисплей первым, система не убивает процесс, а переносит задачу на основной дисплей — + * приложение вылезает на очках во весь экран (так было с `free.zona`). + * + * Всё под [acquireMutex]: параллельный запуск нового гостя не должен поднять дисплей, + * пока мы добиваем старого. + */ fun stopGuest() { Log.i(TAG, "stopGuest") - releaseFrameSource() - _cursor.value = null - setState(GlassesScreenState.NoAppSelected) + if (activeSource == null && activeGuestPackage == null) { + setState(GlassesScreenState.NoAppSelected) + return + } + testScope.launch { + acquireMutex.withLock { + killActiveGuest() + releaseFrameSource() + _cursor.value = null + setState(GlassesScreenState.NoAppSelected) + } + } + } + + /** + * Force-stop активного гостя, дисплей НЕ трогает. Вызывать под [acquireMutex]. + * + * Без этого процесс гостя остаётся жить (со своим VirtualDisplay он уже не связан), а при + * простом release дисплея система переносит задачу на display 0 — гость вылезает во весь + * экран очков. + */ + private suspend fun killActiveGuest() { + val src = activeSource + val pkg = activeGuestPackage + if (src == null || pkg == null) return + val killed = ShizukuVdBinder.forceStopPackage(src.shizukuBinder, pkg) + Log.i(TAG, "killActiveGuest: forceStop($pkg) -> $killed") + activeGuestPackage = null + // Даём AMS снять задачу, прежде чем рвать дисплей. + if (killed) delay(GUEST_KILL_SETTLE_MS) } /** @@ -387,6 +442,36 @@ class VmGlassesController { Log.i(TAG, "startGuardOnce: страж поднят на displayId=$displayId") } + /** + * Гасит Mercury-«страж»: watchdog, выключатель, служебный дисплей и его UserService. + * Идемпотентно. Без этого после остановки приложения остаются висеть невидимый дисплей + * и процесс `:shizuku_vd` (см. правило #4847). + */ + fun stopMercuryKillGuard() { + guardWatchdog?.cancel() + guardWatchdog = null + val binder = guardBinder + val displayId = guardDisplayId + val client = guardClient + val texture = guardSurfaceTexture + guardBinder = null + guardDisplayId = 0 + guardClient = null + guardSurfaceTexture = null + if (binder == null && displayId <= 0 && client == null && texture == null) return + Log.i(TAG, "stopMercuryKillGuard: displayId=$displayId") + testScope.launch { + if (binder != null) { + runCatching { ShizukuVdBinder.stopKillGuard(binder) } + if (displayId > 0) runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) } + } + runCatching { texture?.release() } + // Процесс UserService надо именно убить: unbind(remove=true) его не завершает (#4847). + if (binder != null) runCatching { ShizukuVdBinder.destroy(binder) } + runCatching { client?.stop() } + } + } + /** Watchdog-перезапуск активности стража: если система её прибила, флаг надо вернуть. */ private fun launchGuardActivity() { val binder = guardBinder ?: return @@ -436,6 +521,9 @@ class VmGlassesController { /** Пауза перед повторной попыткой поднять стража (например, Shizuku ещё не дал права). */ private const val GUARD_RETRY_MS = 3_000L + /** Пауза после force-stop гостя: AMS должен успеть снять задачу до release дисплея. */ + private const val GUEST_KILL_SETTLE_MS = 300L + val Local = staticCompositionLocalOf { error("VmGlassesController not provided") } diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/shizuku/VirtualDisplayFrameSource.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/shizuku/VirtualDisplayFrameSource.kt index 8dacb32..7447ee7 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/shizuku/VirtualDisplayFrameSource.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/shizuku/VirtualDisplayFrameSource.kt @@ -40,6 +40,10 @@ class VirtualDisplayFrameSource private constructor( runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) } runCatching { surface.release() } super.release() + // Убиваем процесс UserService: `unbindUserService(remove = true)` сам по себе его не + // завершает (Shizuku держит UserService живым для переиспользования), и после каждого + // запуска гостя оставался висеть процесс-сирота (~13 МБ). `destroy` шлёт System.exit. + runCatching { ShizukuVdBinder.destroy(binder) } // Отвязываем UserService — держим его живым ровно на время жизни дисплея. runCatching { client.stop() } Log.i(TAG, "VirtualDisplayFrameSource released: displayId=$displayId") diff --git a/app-phone-vm/src/main/AndroidManifest.xml b/app-phone-vm/src/main/AndroidManifest.xml index 0578fa0..72ed485 100644 --- a/app-phone-vm/src/main/AndroidManifest.xml +++ b/app-phone-vm/src/main/AndroidManifest.xml @@ -39,6 +39,13 @@ + + + = Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + this@MainActivity, Manifest.permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + ) { + notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } CompositionLocalProvider( VmPhoneController.Local provides controller, MercuryController.Local provides mercuryController, 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 96df4c8..9dfb042 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 @@ -21,6 +21,14 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider override fun onCreate() { super.onCreate() Log.i(TAG, "PhoneVmApp onCreate, uid=${android.os.Process.myUid()}") + // Хранитель связи: держит процесс живым при выключенном экране, иначе HONOR усыпляет + // его и очки теряют WebSocket-связь. Может быть запрещён системой при старте из фона — + // тогда связь просто отвалится по экрану, как раньше; приложение не падает. + try { + ConnectionKeepAliveService.start(this) + } catch (t: Throwable) { + Log.w(TAG, "ConnectionKeepAliveService start failed", t) + } // Запуск отложен до первого обращения — в lazy. controller.start() mercuryController.refresh() @@ -28,6 +36,7 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider override fun onTerminate() { super.onTerminate() + ConnectionKeepAliveService.stop(this) controller.stop() mercuryController.close() } 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 81e1cc5..6afd425 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,6 +16,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import pw.binom.mercury.DiscoveredGlasses import pw.binom.mercury.RayNeoMercury @@ -48,6 +49,9 @@ class MercuryController(private val context: Context) { /** Последний известный статус очков: яркость, WiFi, экран, батарея. */ val generalStatus: StateFlow = _generalStatus.asStateFlow() + /** Цикл опроса статуса (живёт, пока держится BLE-сессия). */ + private var statusRefreshJob: Job? = null + private val _wifiNetworks = MutableStateFlow>(emptyList()) /** Сети, найденные последним сканом WiFi на очках. */ @@ -164,8 +168,7 @@ class MercuryController(private val context: Context) { return@launch } _state.value = MercuryState.Connected(glasses, batteryPercent = null) - // Запрашиваем актуальный статус, чтобы получить battery/wifi - mercury.pullGeneralStatus() + startStatusRefresh() } catch (t: Throwable) { _state.value = MercuryState.Disconnected(message = "Ошибка: ${t.message}") } @@ -178,6 +181,43 @@ class MercuryController(private val context: Context) { _state.value = MercuryState.Disconnected() } + /** + * Настойчиво тянем статус после подключения: очки присылают `general.status` + * при изменениях, а ответ на первый запрос легко теряется (BLE-нотификация + * приходит раньше, чем сессия полностью встала). Поэтому перезапрашиваем, + * пока не прилетит первый статус, дальше — редкий фоновый рефреш как страховка + * от потерянных нотификаций. + */ + private fun startStatusRefresh() { + statusRefreshJob?.cancel() + statusRefreshJob = scope.launch { + var attempt = 0 + while (isActive && _state.value is MercuryState.Connected && _generalStatus.value == null) { + if (attempt > 0) delay(STATUS_RETRY_STEP_MS * attempt) + attempt++ + pullStatus("initial#$attempt") + } + while (isActive && _state.value is MercuryState.Connected) { + delay(STATUS_REFRESH_MS) + pullStatus("refresh") + } + } + } + + /** Разовый запрос статуса (например, при открытии вкладки «Очки»). */ + fun refreshStatus() { + scope.launch { pullStatus("manual") } + } + + private suspend fun pullStatus(tag: String) { + try { + val ok = mercury.pullGeneralStatus() + android.util.Log.d(TAG, "pullGeneralStatus($tag) -> $ok") + } catch (t: Throwable) { + android.util.Log.w(TAG, "pullGeneralStatus($tag) failed: ${t.message}") + } + } + /** * Запустить приложение на очках через штатный механизм Mercury * (appMarketLaunch). Требует активной BLE-сессии — иначе вернёт false. @@ -260,6 +300,8 @@ class MercuryController(private val context: Context) { /** Сбрасывает данные, которые живут только внутри BLE-сессии. */ private fun clearGlassesData() { + statusRefreshJob?.cancel() + statusRefreshJob = null _generalStatus.value = null _wifiNetworks.value = emptyList() _wifiScanning.value = false @@ -335,6 +377,12 @@ class MercuryController(private val context: Context) { /** Сколько ждать ответ скана WiFi, прежде чем снять статус «сканируем…». */ private const val WIFI_SCAN_TIMEOUT_MS = 15_000L + /** Пауза между повторами первичного запроса статуса (растёт линейно). */ + private const val STATUS_RETRY_STEP_MS = 1_000L + + /** Период фонового обновления статуса, когда он уже получен. */ + private const val STATUS_REFRESH_MS = 15_000L + val Local = staticCompositionLocalOf { error("MercuryController not provided") } 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 4bd787e..80afbcc 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 @@ -26,6 +26,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -76,6 +77,12 @@ fun GlassesStatusScreen( val mercuryConnected = mercury is MercuryState.Connected + // Открыли вкладку «Очки» при живом BLE — ещё раз спросим статус: нотификации + // от очков приходят только при изменениях, и первый ответ легко теряется. + LaunchedEffect(mercuryConnected) { + if (mercuryConnected) mercuryController.refreshStatus() + } + Column( modifier = Modifier .fillMaxSize() @@ -228,11 +235,15 @@ private fun ScreenAndBrightnessSection( SectionCard(title = "Экран и яркость") { if (!enabled) { Hint("Нужен Канал 2 (Mercury BLE).") + } else if (status == null) { + // Пока статус не пришёл, не показываем нули: очки применяют статус целиком, + // так что «на глазок» выставленная яркость может заодно выключить им WiFi. + Hint("Ждём статус от очков…") } else { // Пока палец на слайдере — показываем локальное значение, // а в очки отправляем один раз, на отпускании (BLE не любит спам). var drag by remember { mutableStateOf(null) } - val brightness = drag ?: (status?.brightness ?: 0).toFloat() + val brightness = drag ?: status.brightness.toFloat() StatusRow("Яркость", "${brightness.toInt()}%") Slider( value = brightness, @@ -245,10 +256,10 @@ private fun ScreenAndBrightnessSection( ) ToggleRow( label = "Авто-яркость", - checked = status?.automaticBrightness == true, + checked = status.automaticBrightness, onCheckedChange = onAutoBrightness, ) - val sleeping = (status?.deepSuspend ?: 0) == 1 + val sleeping = status.deepSuspend == 1 OutlinedButton(onClick = { onScreenOn(sleeping) }) { Text(if (sleeping) "Разбудить экран" else "Погасить экран") } @@ -271,13 +282,15 @@ private fun GlassesWifiSection( SectionCard(title = "WiFi очков") { if (!enabled) { Hint("Нужен Канал 2 (Mercury BLE).") + } else if (status == null) { + Hint("Ждём статус от очков…") } else { ToggleRow( label = "WiFi включён", - checked = status?.wifiEnabled == true, + checked = status.wifiEnabled, onCheckedChange = onToggle, ) - StatusRow("Сеть", status?.wifiInfo?.ssid ?: "не подключено") + StatusRow("Сеть", status.wifiInfo?.ssid ?: "не подключено") Spacer(modifier = Modifier.height(4.dp)) OutlinedButton(onClick = onScan, enabled = !scanning) { Text(if (scanning) "Сканируем…" else "Найти сети")