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:
@@ -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
|
||||
// =====================================================================
|
||||
|
||||
Reference in New Issue
Block a user