phone: keep-alive foreground service + notification perm; glasses: clear NoConnection on reconnect
- ConnectionKeepAliveService (foreground, PARTIAL_WAKE_LOCK + WIFI_MODE_FULL_HIGH_PERF, started in PhoneVmApp.onCreate): without it HONOR deep-sleeps the phone process on screen-off and the glasses lose the WebSocket link. Same mechanic as view-mate. - MainActivity: request POST_NOTIFICATIONS so the FGS notification is visible. - VmGlassesController.onPhoneConnected(): drop a stuck NoConnection state when the phone (re)connects; returns to Running if a guest is active, else NoAppSelected. - VmService: call it from onConnected. - stopGuest/switch: force-stop the previous guest before releasing its display (releasing first migrates the guest task to display 0 full-screen); VirtualDisplay frame source now destroys its UserService on release. - Mercury status: retry the first pullGeneralStatus until it lands, refresh every 15s and on tab open; GlassesStatusScreen shows 'Ждём статус от очков…' instead of fake zeros. - MainActivity(glasses): HOME candidate flag + finish() on a non-default display (recursion guard). - README: Home-launcher setup/rollback section. - BOOTSTRAP_TEST.md: post-reboot Shizuku bootstrap investigation log.
This commit is contained in:
@@ -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-хендшейк не проходит.
|
||||||
@@ -114,6 +114,38 @@ adb -s <glasses-serial> install -r app-glasses-vm/build/outputs/apk/debug/app-gl
|
|||||||
adb -s <phone-serial> install -r app-phone-vm/build/outputs/apk/debug/app-phone-vm-debug.apk
|
adb -s <phone-serial> 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 <glasses-serial> shell cmd package set-home-activity pw.binom.rayneovm.glasses/.MainActivity
|
||||||
|
```
|
||||||
|
|
||||||
|
Вернуть штатный лаунчер RayNeo обратно:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb -s <glasses-serial> shell cmd package set-home-activity com.ffalconxr.mercury.launcher/home.MainActivity
|
||||||
|
```
|
||||||
|
|
||||||
|
> Компонент записывается через `/` (package/class), а не через `/.` — именно так его принимает `set-home-activity` на этой прошивке.
|
||||||
|
|
||||||
|
Проверить, кто сейчас дефолтный HOME:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb -s <glasses-serial> shell cmd package resolve-activity -a android.intent.action.MAIN -c android.intent.category.HOME
|
||||||
|
```
|
||||||
|
|
||||||
|
Показать всех кандидатов в HOME:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb -s <glasses-serial> 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 (проверено)
|
## Smoke-test (проверено)
|
||||||
|
|
||||||
1. Запустить `GlassesVmApp` на очках → VmService стартует, `netstat -tlnp` показывает `*:8080`.
|
1. Запустить `GlassesVmApp` на очках → VmService стартует, `netstat -tlnp` показывает `*:8080`.
|
||||||
|
|||||||
@@ -50,16 +50,27 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
tools:targetApi="36">
|
tools:targetApi="36">
|
||||||
|
|
||||||
|
<!-- MainActivity — заодно HOME-кандидат: на этих очках BOOT_COMPLETED
|
||||||
|
ненадёжен, а домашний экран система поднимает всегда.
|
||||||
|
Дефолтным его не назначаем — это делается отдельно
|
||||||
|
(`cmd package set-home-activity`), по явной команде. -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|keyboardHidden|navigation|mcc|mnc"
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
|
android:stateNotNeeded="true"
|
||||||
|
android:clearTaskOnLaunch="true"
|
||||||
android:theme="@style/Theme.RayNeoVm">
|
android:theme="@style/Theme.RayNeoVm">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.HOME" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Foreground service: WebSocket server + mDNS -->
|
<!-- Foreground service: WebSocket server + mDNS -->
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package pw.binom.rayneovm.glasses
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.Display
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
@@ -27,6 +28,14 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
// HOME живёт на display 0. Если систему (или гостя на VirtualDisplay)
|
||||||
|
// угораздит запросить домашний экран внутри чужого дисплея — мы бы
|
||||||
|
// открылись сами внутри себя (зеркальная рекурсия), поэтому выходим.
|
||||||
|
val displayId = display?.displayId ?: Display.DEFAULT_DISPLAY
|
||||||
|
if (displayId != Display.DEFAULT_DISPLAY) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
ShizukuBootstrap.attach(this)
|
ShizukuBootstrap.attach(this)
|
||||||
val controller = VmGlassesController.fromContext(this)
|
val controller = VmGlassesController.fromContext(this)
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ class VmService : LifecycleService() {
|
|||||||
onConnected = { connId ->
|
onConnected = { connId ->
|
||||||
Log.i(TAG, "Phone connected: $connId")
|
Log.i(TAG, "Phone connected: $connId")
|
||||||
currentConn.set(connId)
|
currentConn.set(connId)
|
||||||
|
// Переподключение: снять залипшее «Нет связи с телефоном».
|
||||||
|
controller.onPhoneConnected()
|
||||||
},
|
},
|
||||||
onDisconnected = { connId ->
|
onDisconnected = { connId ->
|
||||||
Log.i(TAG, "Phone disconnected: $connId")
|
Log.i(TAG, "Phone disconnected: $connId")
|
||||||
@@ -203,6 +205,10 @@ class VmService : LifecycleService() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
// Прибираем за собой: гость (force-stop + дисплей), страж (флаг + дисплей + UserService).
|
||||||
|
// Не покрывает SIGKILL/reinstall — там наш код не выполняется вообще.
|
||||||
|
runCatching { controller.stopGuest() }
|
||||||
|
runCatching { controller.stopMercuryKillGuard() }
|
||||||
unregisterMdns()
|
unregisterMdns()
|
||||||
server?.close()
|
server?.close()
|
||||||
server = null
|
server = null
|
||||||
|
|||||||
+92
-4
@@ -51,6 +51,9 @@ class VmGlassesController {
|
|||||||
|
|
||||||
private var activeSource: VirtualDisplayFrameSource? = null
|
private var activeSource: VirtualDisplayFrameSource? = null
|
||||||
|
|
||||||
|
/** Пакет гостя, запущенного на [activeSource] — его и убиваем в [stopGuest]. */
|
||||||
|
@Volatile private var activeGuestPackage: String? = null
|
||||||
|
|
||||||
/** Служебный дисплей Mercury-«стража» (см. [ensureMercuryKillGuard]); 0 — не создан. */
|
/** Служебный дисплей Mercury-«стража» (см. [ensureMercuryKillGuard]); 0 — не создан. */
|
||||||
private var guardDisplayId = 0
|
private var guardDisplayId = 0
|
||||||
|
|
||||||
@@ -81,6 +84,9 @@ class VmGlassesController {
|
|||||||
Log.w(TAG, "acquireFrameSource: appContext not set")
|
Log.w(TAG, "acquireFrameSource: appContext not set")
|
||||||
return@withLock null
|
return@withLock null
|
||||||
}
|
}
|
||||||
|
// Смена приложения: прошлого гостя сначала УБИВАЕМ, и только потом рвём его дисплей —
|
||||||
|
// иначе задача переезжает на display 0 и вылезает во весь экран (см. [stopGuest]).
|
||||||
|
killActiveGuest()
|
||||||
releaseFrameSource()
|
releaseFrameSource()
|
||||||
val surfaceTexture = try {
|
val surfaceTexture = try {
|
||||||
// ВАЖНО: single-buffer нельзя — DisplayManagerService reject'ит
|
// ВАЖНО: single-buffer нельзя — DisplayManagerService reject'ит
|
||||||
@@ -106,6 +112,7 @@ class VmGlassesController {
|
|||||||
return@withLock null
|
return@withLock null
|
||||||
}
|
}
|
||||||
activeSource = source
|
activeSource = source
|
||||||
|
activeGuestPackage = packageName
|
||||||
_frameSource.value = source
|
_frameSource.value = source
|
||||||
source
|
source
|
||||||
}
|
}
|
||||||
@@ -131,6 +138,18 @@ class VmGlassesController {
|
|||||||
_state.value = newState
|
_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]
|
* Перемещает виртуальный курсор мыши. Очки рисуют его в обоих глазах, а [clickCursor]
|
||||||
* использует последнюю позицию как точку клика.
|
* использует последнюю позицию как точку клика.
|
||||||
@@ -293,12 +312,48 @@ class VmGlassesController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Остановить гостя и освободить VirtualDisplay (по Shutdown или смене приложения). */
|
/**
|
||||||
|
* Остановить гостя и освободить VirtualDisplay (по Shutdown или смене приложения).
|
||||||
|
*
|
||||||
|
* Порядок критичен: СНАЧАЛА force-stop гостя, потом release дисплея. Если освободить
|
||||||
|
* дисплей первым, система не убивает процесс, а переносит задачу на основной дисплей —
|
||||||
|
* приложение вылезает на очках во весь экран (так было с `free.zona`).
|
||||||
|
*
|
||||||
|
* Всё под [acquireMutex]: параллельный запуск нового гостя не должен поднять дисплей,
|
||||||
|
* пока мы добиваем старого.
|
||||||
|
*/
|
||||||
fun stopGuest() {
|
fun stopGuest() {
|
||||||
Log.i(TAG, "stopGuest")
|
Log.i(TAG, "stopGuest")
|
||||||
releaseFrameSource()
|
if (activeSource == null && activeGuestPackage == null) {
|
||||||
_cursor.value = null
|
setState(GlassesScreenState.NoAppSelected)
|
||||||
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")
|
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-перезапуск активности стража: если система её прибила, флаг надо вернуть. */
|
/** Watchdog-перезапуск активности стража: если система её прибила, флаг надо вернуть. */
|
||||||
private fun launchGuardActivity() {
|
private fun launchGuardActivity() {
|
||||||
val binder = guardBinder ?: return
|
val binder = guardBinder ?: return
|
||||||
@@ -436,6 +521,9 @@ class VmGlassesController {
|
|||||||
/** Пауза перед повторной попыткой поднять стража (например, Shizuku ещё не дал права). */
|
/** Пауза перед повторной попыткой поднять стража (например, Shizuku ещё не дал права). */
|
||||||
private const val GUARD_RETRY_MS = 3_000L
|
private const val GUARD_RETRY_MS = 3_000L
|
||||||
|
|
||||||
|
/** Пауза после force-stop гостя: AMS должен успеть снять задачу до release дисплея. */
|
||||||
|
private const val GUEST_KILL_SETTLE_MS = 300L
|
||||||
|
|
||||||
val Local = staticCompositionLocalOf<VmGlassesController> {
|
val Local = staticCompositionLocalOf<VmGlassesController> {
|
||||||
error("VmGlassesController not provided")
|
error("VmGlassesController not provided")
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -40,6 +40,10 @@ class VirtualDisplayFrameSource private constructor(
|
|||||||
runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) }
|
runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) }
|
||||||
runCatching { surface.release() }
|
runCatching { surface.release() }
|
||||||
super.release()
|
super.release()
|
||||||
|
// Убиваем процесс UserService: `unbindUserService(remove = true)` сам по себе его не
|
||||||
|
// завершает (Shizuku держит UserService живым для переиспользования), и после каждого
|
||||||
|
// запуска гостя оставался висеть процесс-сирота (~13 МБ). `destroy` шлёт System.exit.
|
||||||
|
runCatching { ShizukuVdBinder.destroy(binder) }
|
||||||
// Отвязываем UserService — держим его живым ровно на время жизни дисплея.
|
// Отвязываем UserService — держим его живым ровно на время жизни дисплея.
|
||||||
runCatching { client.stop() }
|
runCatching { client.stop() }
|
||||||
Log.i(TAG, "VirtualDisplayFrameSource released: displayId=$displayId")
|
Log.i(TAG, "VirtualDisplayFrameSource released: displayId=$displayId")
|
||||||
|
|||||||
@@ -39,6 +39,13 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Хранитель связи: foreground service, не даёт системе усыпить процесс при
|
||||||
|
выключенном экране (иначе очки теряют WebSocket-связь). -->
|
||||||
|
<service
|
||||||
|
android:name=".ConnectionKeepAliveService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaPlayback" />
|
||||||
|
|
||||||
<!-- Foreground service для фонового воспроизведения аудио -->
|
<!-- Foreground service для фонового воспроизведения аудио -->
|
||||||
<service
|
<service
|
||||||
android:name=".audio.AudioPlaybackService"
|
android:name=".audio.AudioPlaybackService"
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package pw.binom.rayneovm.phone
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.PowerManager
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service-«хранитель связи»: не даёт системе усыпить процесс телефона при
|
||||||
|
* выключенном экране.
|
||||||
|
*
|
||||||
|
* Без него HONOR замораживает/убивает фоновый процесс, и очки теряют WebSocket-связь
|
||||||
|
* (механика подсмотрена в view-mate/app-phone PlaybackService). PARTIAL_WAKE_LOCK держит CPU,
|
||||||
|
* WIFI_MODE_FULL_HIGH_PERF — радио; сам факт foreground-сервиса поднимает приоритет процесса,
|
||||||
|
* поэтому он не уходит в глубокий сон вместе с экраном.
|
||||||
|
*
|
||||||
|
* Стартует в [PhoneVmApp.onCreate], останавливается в [PhoneVmApp.onTerminate].
|
||||||
|
*/
|
||||||
|
class ConnectionKeepAliveService : Service() {
|
||||||
|
|
||||||
|
private var wakeLock: PowerManager.WakeLock? = null
|
||||||
|
private var wifiLock: WifiManager.WifiLock? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID, "Связь с очками",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply { setShowBadge(false) }
|
||||||
|
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||||
|
startForeground(NOTIFICATION_ID, buildNotification())
|
||||||
|
|
||||||
|
wakeLock = (getSystemService(PowerManager::class.java) as PowerManager)
|
||||||
|
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "rayneovm:connection")
|
||||||
|
?.apply { acquire() }
|
||||||
|
wifiLock = (applicationContext.getSystemService(WIFI_SERVICE) as WifiManager)
|
||||||
|
.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "rayneovm:wifi")
|
||||||
|
?.apply { acquire() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
wakeLock?.takeIf { it.isHeld }?.release()
|
||||||
|
wifiLock?.takeIf { it.isHeld }?.release()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
private fun buildNotification(): Notification {
|
||||||
|
val openApp = PendingIntent.getActivity(
|
||||||
|
this, 0, Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
|
||||||
|
.setContentTitle("RayNeo VM")
|
||||||
|
.setContentText("Связь с очками активна")
|
||||||
|
.setContentIntent(openApp)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL_ID = "vm_connection"
|
||||||
|
private const val NOTIFICATION_ID = 301
|
||||||
|
|
||||||
|
fun start(context: Context) {
|
||||||
|
ContextCompat.startForegroundService(
|
||||||
|
context, Intent(context, ConnectionKeepAliveService::class.java),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.stopService(Intent(context, ConnectionKeepAliveService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package pw.binom.rayneovm.phone
|
package pw.binom.rayneovm.phone
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
@@ -11,8 +14,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import pw.binom.rayneovm.phone.mercury.MercuryController
|
import pw.binom.rayneovm.phone.mercury.MercuryController
|
||||||
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
||||||
import pw.binom.rayneovm.phone.ui.HomeScreen
|
import pw.binom.rayneovm.phone.ui.HomeScreen
|
||||||
@@ -34,6 +39,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
val allGranted = result.values.all { it }
|
val allGranted = result.values.all { it }
|
||||||
mercuryController.onPermissionsResult(allGranted)
|
mercuryController.onPermissionsResult(allGranted)
|
||||||
}
|
}
|
||||||
|
// POST_NOTIFICATIONS нужен, чтобы foreground-«хранитель связи» показал уведомление
|
||||||
|
// и не был придушен системой (Android 13+).
|
||||||
|
val notificationLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { /* результат не критичен: сервис работает и без видимого уведомления */ }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
this@MainActivity, Manifest.permission.POST_NOTIFICATIONS,
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
notificationLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
VmPhoneController.Local provides controller,
|
VmPhoneController.Local provides controller,
|
||||||
MercuryController.Local provides mercuryController,
|
MercuryController.Local provides mercuryController,
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Log.i(TAG, "PhoneVmApp onCreate, uid=${android.os.Process.myUid()}")
|
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.
|
// Запуск отложен до первого обращения — в lazy.
|
||||||
controller.start()
|
controller.start()
|
||||||
mercuryController.refresh()
|
mercuryController.refresh()
|
||||||
@@ -28,6 +36,7 @@ class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider
|
|||||||
|
|
||||||
override fun onTerminate() {
|
override fun onTerminate() {
|
||||||
super.onTerminate()
|
super.onTerminate()
|
||||||
|
ConnectionKeepAliveService.stop(this)
|
||||||
controller.stop()
|
controller.stop()
|
||||||
mercuryController.close()
|
mercuryController.close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.mercury.DiscoveredGlasses
|
import pw.binom.mercury.DiscoveredGlasses
|
||||||
import pw.binom.mercury.RayNeoMercury
|
import pw.binom.mercury.RayNeoMercury
|
||||||
@@ -48,6 +49,9 @@ class MercuryController(private val context: Context) {
|
|||||||
/** Последний известный статус очков: яркость, WiFi, экран, батарея. */
|
/** Последний известный статус очков: яркость, WiFi, экран, батарея. */
|
||||||
val generalStatus: StateFlow<GeneralStatus?> = _generalStatus.asStateFlow()
|
val generalStatus: StateFlow<GeneralStatus?> = _generalStatus.asStateFlow()
|
||||||
|
|
||||||
|
/** Цикл опроса статуса (живёт, пока держится BLE-сессия). */
|
||||||
|
private var statusRefreshJob: Job? = null
|
||||||
|
|
||||||
private val _wifiNetworks = MutableStateFlow<List<MyWifiInfo>>(emptyList())
|
private val _wifiNetworks = MutableStateFlow<List<MyWifiInfo>>(emptyList())
|
||||||
|
|
||||||
/** Сети, найденные последним сканом WiFi на очках. */
|
/** Сети, найденные последним сканом WiFi на очках. */
|
||||||
@@ -164,8 +168,7 @@ class MercuryController(private val context: Context) {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
_state.value = MercuryState.Connected(glasses, batteryPercent = null)
|
_state.value = MercuryState.Connected(glasses, batteryPercent = null)
|
||||||
// Запрашиваем актуальный статус, чтобы получить battery/wifi
|
startStatusRefresh()
|
||||||
mercury.pullGeneralStatus()
|
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
_state.value = MercuryState.Disconnected(message = "Ошибка: ${t.message}")
|
_state.value = MercuryState.Disconnected(message = "Ошибка: ${t.message}")
|
||||||
}
|
}
|
||||||
@@ -178,6 +181,43 @@ class MercuryController(private val context: Context) {
|
|||||||
_state.value = MercuryState.Disconnected()
|
_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
|
* Запустить приложение на очках через штатный механизм Mercury
|
||||||
* (appMarketLaunch). Требует активной BLE-сессии — иначе вернёт false.
|
* (appMarketLaunch). Требует активной BLE-сессии — иначе вернёт false.
|
||||||
@@ -260,6 +300,8 @@ class MercuryController(private val context: Context) {
|
|||||||
|
|
||||||
/** Сбрасывает данные, которые живут только внутри BLE-сессии. */
|
/** Сбрасывает данные, которые живут только внутри BLE-сессии. */
|
||||||
private fun clearGlassesData() {
|
private fun clearGlassesData() {
|
||||||
|
statusRefreshJob?.cancel()
|
||||||
|
statusRefreshJob = null
|
||||||
_generalStatus.value = null
|
_generalStatus.value = null
|
||||||
_wifiNetworks.value = emptyList()
|
_wifiNetworks.value = emptyList()
|
||||||
_wifiScanning.value = false
|
_wifiScanning.value = false
|
||||||
@@ -335,6 +377,12 @@ class MercuryController(private val context: Context) {
|
|||||||
/** Сколько ждать ответ скана WiFi, прежде чем снять статус «сканируем…». */
|
/** Сколько ждать ответ скана WiFi, прежде чем снять статус «сканируем…». */
|
||||||
private const val WIFI_SCAN_TIMEOUT_MS = 15_000L
|
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<MercuryController> {
|
val Local = staticCompositionLocalOf<MercuryController> {
|
||||||
error("MercuryController not provided")
|
error("MercuryController not provided")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.compose.material3.Switch
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -76,6 +77,12 @@ fun GlassesStatusScreen(
|
|||||||
|
|
||||||
val mercuryConnected = mercury is MercuryState.Connected
|
val mercuryConnected = mercury is MercuryState.Connected
|
||||||
|
|
||||||
|
// Открыли вкладку «Очки» при живом BLE — ещё раз спросим статус: нотификации
|
||||||
|
// от очков приходят только при изменениях, и первый ответ легко теряется.
|
||||||
|
LaunchedEffect(mercuryConnected) {
|
||||||
|
if (mercuryConnected) mercuryController.refreshStatus()
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -228,11 +235,15 @@ private fun ScreenAndBrightnessSection(
|
|||||||
SectionCard(title = "Экран и яркость") {
|
SectionCard(title = "Экран и яркость") {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
Hint("Нужен Канал 2 (Mercury BLE).")
|
Hint("Нужен Канал 2 (Mercury BLE).")
|
||||||
|
} else if (status == null) {
|
||||||
|
// Пока статус не пришёл, не показываем нули: очки применяют статус целиком,
|
||||||
|
// так что «на глазок» выставленная яркость может заодно выключить им WiFi.
|
||||||
|
Hint("Ждём статус от очков…")
|
||||||
} else {
|
} else {
|
||||||
// Пока палец на слайдере — показываем локальное значение,
|
// Пока палец на слайдере — показываем локальное значение,
|
||||||
// а в очки отправляем один раз, на отпускании (BLE не любит спам).
|
// а в очки отправляем один раз, на отпускании (BLE не любит спам).
|
||||||
var drag by remember { mutableStateOf<Float?>(null) }
|
var drag by remember { mutableStateOf<Float?>(null) }
|
||||||
val brightness = drag ?: (status?.brightness ?: 0).toFloat()
|
val brightness = drag ?: status.brightness.toFloat()
|
||||||
StatusRow("Яркость", "${brightness.toInt()}%")
|
StatusRow("Яркость", "${brightness.toInt()}%")
|
||||||
Slider(
|
Slider(
|
||||||
value = brightness,
|
value = brightness,
|
||||||
@@ -245,10 +256,10 @@ private fun ScreenAndBrightnessSection(
|
|||||||
)
|
)
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
label = "Авто-яркость",
|
label = "Авто-яркость",
|
||||||
checked = status?.automaticBrightness == true,
|
checked = status.automaticBrightness,
|
||||||
onCheckedChange = onAutoBrightness,
|
onCheckedChange = onAutoBrightness,
|
||||||
)
|
)
|
||||||
val sleeping = (status?.deepSuspend ?: 0) == 1
|
val sleeping = status.deepSuspend == 1
|
||||||
OutlinedButton(onClick = { onScreenOn(sleeping) }) {
|
OutlinedButton(onClick = { onScreenOn(sleeping) }) {
|
||||||
Text(if (sleeping) "Разбудить экран" else "Погасить экран")
|
Text(if (sleeping) "Разбудить экран" else "Погасить экран")
|
||||||
}
|
}
|
||||||
@@ -271,13 +282,15 @@ private fun GlassesWifiSection(
|
|||||||
SectionCard(title = "WiFi очков") {
|
SectionCard(title = "WiFi очков") {
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
Hint("Нужен Канал 2 (Mercury BLE).")
|
Hint("Нужен Канал 2 (Mercury BLE).")
|
||||||
|
} else if (status == null) {
|
||||||
|
Hint("Ждём статус от очков…")
|
||||||
} else {
|
} else {
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
label = "WiFi включён",
|
label = "WiFi включён",
|
||||||
checked = status?.wifiEnabled == true,
|
checked = status.wifiEnabled,
|
||||||
onCheckedChange = onToggle,
|
onCheckedChange = onToggle,
|
||||||
)
|
)
|
||||||
StatusRow("Сеть", status?.wifiInfo?.ssid ?: "не подключено")
|
StatusRow("Сеть", status.wifiInfo?.ssid ?: "не подключено")
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
OutlinedButton(onClick = onScan, enabled = !scanning) {
|
OutlinedButton(onClick = onScan, enabled = !scanning) {
|
||||||
Text(if (scanning) "Сканируем…" else "Найти сети")
|
Text(if (scanning) "Сканируем…" else "Найти сети")
|
||||||
|
|||||||
Reference in New Issue
Block a user