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
|
||||
```
|
||||
|
||||
## 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 (проверено)
|
||||
|
||||
1. Запустить `GlassesVmApp` на очках → VmService стартует, `netstat -tlnp` показывает `*:8080`.
|
||||
|
||||
@@ -50,16 +50,27 @@
|
||||
android:supportsRtl="true"
|
||||
tools:targetApi="36">
|
||||
|
||||
<!-- MainActivity — заодно HOME-кандидат: на этих очках BOOT_COMPLETED
|
||||
ненадёжен, а домашний экран система поднимает всегда.
|
||||
Дефолтным его не назначаем — это делается отдельно
|
||||
(`cmd package set-home-activity`), по явной команде. -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|keyboardHidden|navigation|mcc|mnc"
|
||||
android:launchMode="singleTask"
|
||||
android:stateNotNeeded="true"
|
||||
android:clearTaskOnLaunch="true"
|
||||
android:theme="@style/Theme.RayNeoVm">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</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>
|
||||
|
||||
<!-- Foreground service: WebSocket server + mDNS -->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+92
-4
@@ -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<VmGlassesController> {
|
||||
error("VmGlassesController not provided")
|
||||
}
|
||||
|
||||
+4
@@ -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")
|
||||
|
||||
@@ -39,6 +39,13 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Хранитель связи: foreground service, не даёт системе усыпить процесс при
|
||||
выключенном экране (иначе очки теряют WebSocket-связь). -->
|
||||
<service
|
||||
android:name=".ConnectionKeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<!-- Foreground service для фонового воспроизведения аудио -->
|
||||
<service
|
||||
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
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -11,8 +14,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.core.content.ContextCompat
|
||||
import pw.binom.rayneovm.phone.mercury.MercuryController
|
||||
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
||||
import pw.binom.rayneovm.phone.ui.HomeScreen
|
||||
@@ -34,6 +39,20 @@ class MainActivity : ComponentActivity() {
|
||||
val allGranted = result.values.all { it }
|
||||
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(
|
||||
VmPhoneController.Local provides controller,
|
||||
MercuryController.Local provides mercuryController,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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?> = _generalStatus.asStateFlow()
|
||||
|
||||
/** Цикл опроса статуса (живёт, пока держится BLE-сессия). */
|
||||
private var statusRefreshJob: Job? = null
|
||||
|
||||
private val _wifiNetworks = MutableStateFlow<List<MyWifiInfo>>(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<MercuryController> {
|
||||
error("MercuryController not provided")
|
||||
}
|
||||
|
||||
@@ -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<Float?>(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 "Найти сети")
|
||||
|
||||
Reference in New Issue
Block a user