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
@@ -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
@@ -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")
}
@@ -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")