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
@@ -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 "Найти сети")