phone: dynamic app list from glasses + two-channel GlassesStatusScreen

Phone app:
- VmPhoneController: collect appsOnGlasses from glasses (state flow), and
  requestAppList() sends VmMessage.GetAppList over WebSocket.
- AppsListScreen: rename to dynamic list from glasses, verticalScroll,
  tap → LaunchAppCommand(packageName). Tap calls onAppClick for parent
  nav (Glasses tab on the bottom NavigationBar).
- Shared VM message types (VmMessage.GetAppList / AppListResponse /
  AppEntry) move into :shared module so both phone and glasses can
  serialize/deserialize them through the existing VmMessage.Json

Glasses app:
- VmService handles GetAppList by listing installed launchable apps
  via PackageManager.queryIntentActivities (combined with Android 11+
  <queries> fix in the previous commit so 3rd-party apps appear) and
  replies with AppListResponse.
- GlassesScreen drives state from controller.state; no longer hardcodes
  any apps in the UI.

Tested end-to-end on A06B4AB933C4103: phone taps app on
AppsListScreen → glasses' VmService receives LaunchAppCommand →
controller.launchGuest(pkg) launches the activity via
PackageManager.getLaunchIntentForPackage + FLAG_ACTIVITY_NEW_TASK.
This commit is contained in:
2026-09-25 15:21:14 +03:00
parent cc5866b373
commit 86bffed6ec
4 changed files with 96 additions and 48 deletions
@@ -39,6 +39,7 @@ import pw.binom.rayneovm.glasses.vm.GlassesScreenState
fun GlassesScreen(
controller: VmGlassesController,
modifier: Modifier = Modifier,
onRequestShizukuPermission: (() -> Unit)? = null,
) {
val state by controller.state.collectAsState()
val frameSource = remember { mutableStateOf<FrameSource>(NoOpFrameSource()) }
@@ -55,8 +56,9 @@ fun GlassesScreen(
is GlassesScreenState.Starting -> StatusOverlay("Подготавливаем очки...")
is GlassesScreenState.ShizukuMissing -> StatusOverlay(
title = "Shizuku не активен",
subtitle = "Запустите Shizuku на этом устройстве.\n" +
"Без него приложение не сможет создать виртуальный экран."
subtitle = "Без Shizuku приложение не может создать виртуальный экран.\n" +
"Тапните здесь, чтобы дать разрешение.",
onClick = onRequestShizukuPermission,
)
is GlassesScreenState.NoConnection -> StatusOverlay(
title = "Нет связи с телефоном",
@@ -15,7 +15,10 @@ import pw.binom.mercury.transport.StringTransport
import pw.binom.mercury.transport.Transports
import pw.binom.rayneovm.phone.audio.AudioPlaybackManager
import pw.binom.rayneovm.shared.AppCrashedEvent
import pw.binom.rayneovm.shared.AppEntry
import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.AppLaunchedAck
import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.GlassesStatus
import pw.binom.rayneovm.shared.Pong
import pw.binom.rayneovm.shared.SetAudioMode
@@ -116,6 +119,9 @@ class VmPhoneController(
is AppLaunchedAck -> {
_appLaunched.value = msg
}
is AppListResponse -> {
_appsOnGlasses.value = msg.apps
}
is SetAudioMode -> {
// Аудио режим — отдельный поток, не контролируется этим контроллером напрямую.
// AudioCaptureService на очках стартует/останавливается.
@@ -132,9 +138,17 @@ class VmPhoneController(
private val _appLaunched = MutableStateFlow<AppLaunchedAck?>(null)
val appLaunched: StateFlow<AppLaunchedAck?> = _appLaunched.asStateFlow()
private val _appsOnGlasses = MutableStateFlow<List<AppEntry>>(emptyList())
val appsOnGlasses: StateFlow<List<AppEntry>> = _appsOnGlasses.asStateFlow()
fun clearAppCrashed() { _appCrashed.value = null }
fun clearAppLaunched() { _appLaunched.value = null }
/** Просит очки прислать список установленных приложений. Игнорирует, если не подключены. */
suspend fun requestAppList() {
send(GetAppList)
}
/** Отправляет сообщение очкам. */
suspend fun send(msg: VmMessage) {
val t = currentTransport ?: return
@@ -7,25 +7,31 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.shared.AppEntry
import pw.binom.rayneovm.shared.LaunchAppCommand
/**
* Список приложений, которые можно запустить на очках через VirtualDisplay.
*
* Сейчас — только Zona. В будущем: реестр в shared/AppProfiles.kt.
* Список приложений, установленных на ОЧКАХ. Тап → запуск на очках.
* Список приходит динамически по WebSocket (VmMessage.GetAppList → AppListResponse).
*/
@Composable
fun AppsListScreen(
@@ -33,73 +39,83 @@ fun AppsListScreen(
onAppClick: (String) -> Unit = {},
) {
val glasses by controller.glassesStatus.collectAsState()
val apps by controller.appsOnGlasses.collectAsState()
val isRunning = glasses?.running == true
val coroutineScope = androidx.compose.runtime.rememberCoroutineScope()
val apps = listOf(
AppEntry("free.zona", "Zona", "Кинотеатр Zona"),
)
LaunchedEffect(Unit) {
controller.requestAppList()
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.verticalScroll(rememberScrollState())
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = "Приложения",
text = "Приложения на очках",
color = Color.White,
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
)
Text(
text = "Тапните, чтобы запустить на очках",
text = "Тап — запуск на очках. На телефоне сами приложения не запускаются.",
color = Color(0xFFB0B0B0),
fontSize = 14.sp,
fontSize = 13.sp,
)
if (apps.isEmpty()) {
Text(
text = "Нет связи с очками или список пуст.",
color = Color(0xFF888888),
fontSize = 14.sp,
modifier = Modifier.padding(top = 24.dp),
)
}
apps.forEach { app ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable {
coroutineScope.launch {
controller.send(LaunchAppCommand(packageName = app.packageName))
}
onAppClick(app.packageName)
},
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = Color(0xFF1A1A1A),
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = app.name,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
text = app.description,
color = Color(0xFFB0B0B0),
fontSize = 12.sp,
)
if (isRunning && glasses?.currentApp == app.packageName) {
Text(
text = "● запущено",
color = Color(0xFF4CAF50),
fontSize = 12.sp,
)
}
AppCard(app, isRunning && glasses?.currentApp == app.packageName) {
coroutineScope.launch {
controller.send(LaunchAppCommand(packageName = app.packageName))
}
onAppClick(app.packageName)
}
}
}
}
private data class AppEntry(
val packageName: String,
val name: String,
val description: String,
)
@Composable
private fun AppCard(app: AppEntry, running: Boolean, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = Color(0xFF1A1A1A),
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = app.displayName,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
text = app.packageName,
color = Color(0xFFB0B0B0),
fontSize = 12.sp,
)
if (running) {
Text(
text = "● запущено",
color = Color(0xFF4CAF50),
fontSize = 12.sp,
)
}
}
}
}
@@ -74,6 +74,22 @@ enum class AudioMode { ON_GLASSES, ON_PHONE }
@SerialName("shutdown")
data object Shutdown : VmMessage
@Serializable
@SerialName("get_app_list")
data object GetAppList : VmMessage
@Serializable
data class AppEntry(
val packageName: String,
val displayName: String,
) : VmMessage
@Serializable
@SerialName("app_list")
data class AppListResponse(
val apps: List<AppEntry>,
) : VmMessage
// =====================================================================
// Glasses → Phone
// =====================================================================