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:
@@ -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