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:
2026-09-26 13:59:54 +03:00
parent 94f0ed28fc
commit 59069a741e
13 changed files with 436 additions and 12 deletions
+91
View File
@@ -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-хендшейк не проходит.
+32
View File
@@ -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`.
+12 -1
View File
@@ -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
@@ -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")
} }
@@ -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 "Найти сети")