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(
|
fun GlassesScreen(
|
||||||
controller: VmGlassesController,
|
controller: VmGlassesController,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
onRequestShizukuPermission: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val state by controller.state.collectAsState()
|
val state by controller.state.collectAsState()
|
||||||
val frameSource = remember { mutableStateOf<FrameSource>(NoOpFrameSource()) }
|
val frameSource = remember { mutableStateOf<FrameSource>(NoOpFrameSource()) }
|
||||||
@@ -55,8 +56,9 @@ fun GlassesScreen(
|
|||||||
is GlassesScreenState.Starting -> StatusOverlay("Подготавливаем очки...")
|
is GlassesScreenState.Starting -> StatusOverlay("Подготавливаем очки...")
|
||||||
is GlassesScreenState.ShizukuMissing -> StatusOverlay(
|
is GlassesScreenState.ShizukuMissing -> StatusOverlay(
|
||||||
title = "Shizuku не активен",
|
title = "Shizuku не активен",
|
||||||
subtitle = "Запустите Shizuku на этом устройстве.\n" +
|
subtitle = "Без Shizuku приложение не может создать виртуальный экран.\n" +
|
||||||
"Без него приложение не сможет создать виртуальный экран."
|
"Тапните здесь, чтобы дать разрешение.",
|
||||||
|
onClick = onRequestShizukuPermission,
|
||||||
)
|
)
|
||||||
is GlassesScreenState.NoConnection -> StatusOverlay(
|
is GlassesScreenState.NoConnection -> StatusOverlay(
|
||||||
title = "Нет связи с телефоном",
|
title = "Нет связи с телефоном",
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import pw.binom.mercury.transport.StringTransport
|
|||||||
import pw.binom.mercury.transport.Transports
|
import pw.binom.mercury.transport.Transports
|
||||||
import pw.binom.rayneovm.phone.audio.AudioPlaybackManager
|
import pw.binom.rayneovm.phone.audio.AudioPlaybackManager
|
||||||
import pw.binom.rayneovm.shared.AppCrashedEvent
|
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.AppLaunchedAck
|
||||||
|
import pw.binom.rayneovm.shared.GetAppList
|
||||||
import pw.binom.rayneovm.shared.GlassesStatus
|
import pw.binom.rayneovm.shared.GlassesStatus
|
||||||
import pw.binom.rayneovm.shared.Pong
|
import pw.binom.rayneovm.shared.Pong
|
||||||
import pw.binom.rayneovm.shared.SetAudioMode
|
import pw.binom.rayneovm.shared.SetAudioMode
|
||||||
@@ -116,6 +119,9 @@ class VmPhoneController(
|
|||||||
is AppLaunchedAck -> {
|
is AppLaunchedAck -> {
|
||||||
_appLaunched.value = msg
|
_appLaunched.value = msg
|
||||||
}
|
}
|
||||||
|
is AppListResponse -> {
|
||||||
|
_appsOnGlasses.value = msg.apps
|
||||||
|
}
|
||||||
is SetAudioMode -> {
|
is SetAudioMode -> {
|
||||||
// Аудио режим — отдельный поток, не контролируется этим контроллером напрямую.
|
// Аудио режим — отдельный поток, не контролируется этим контроллером напрямую.
|
||||||
// AudioCaptureService на очках стартует/останавливается.
|
// AudioCaptureService на очках стартует/останавливается.
|
||||||
@@ -132,9 +138,17 @@ class VmPhoneController(
|
|||||||
private val _appLaunched = MutableStateFlow<AppLaunchedAck?>(null)
|
private val _appLaunched = MutableStateFlow<AppLaunchedAck?>(null)
|
||||||
val appLaunched: StateFlow<AppLaunchedAck?> = _appLaunched.asStateFlow()
|
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 clearAppCrashed() { _appCrashed.value = null }
|
||||||
fun clearAppLaunched() { _appLaunched.value = null }
|
fun clearAppLaunched() { _appLaunched.value = null }
|
||||||
|
|
||||||
|
/** Просит очки прислать список установленных приложений. Игнорирует, если не подключены. */
|
||||||
|
suspend fun requestAppList() {
|
||||||
|
send(GetAppList)
|
||||||
|
}
|
||||||
|
|
||||||
/** Отправляет сообщение очкам. */
|
/** Отправляет сообщение очкам. */
|
||||||
suspend fun send(msg: VmMessage) {
|
suspend fun send(msg: VmMessage) {
|
||||||
val t = currentTransport ?: return
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Card
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
||||||
|
import pw.binom.rayneovm.shared.AppEntry
|
||||||
import pw.binom.rayneovm.shared.LaunchAppCommand
|
import pw.binom.rayneovm.shared.LaunchAppCommand
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Список приложений, которые можно запустить на очках через VirtualDisplay.
|
* Список приложений, установленных на ОЧКАХ. Тап → запуск на очках.
|
||||||
*
|
* Список приходит динамически по WebSocket (VmMessage.GetAppList → AppListResponse).
|
||||||
* Сейчас — только Zona. В будущем: реестр в shared/AppProfiles.kt.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AppsListScreen(
|
fun AppsListScreen(
|
||||||
@@ -33,59 +39,77 @@ fun AppsListScreen(
|
|||||||
onAppClick: (String) -> Unit = {},
|
onAppClick: (String) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val glasses by controller.glassesStatus.collectAsState()
|
val glasses by controller.glassesStatus.collectAsState()
|
||||||
|
val apps by controller.appsOnGlasses.collectAsState()
|
||||||
val isRunning = glasses?.running == true
|
val isRunning = glasses?.running == true
|
||||||
val coroutineScope = androidx.compose.runtime.rememberCoroutineScope()
|
val coroutineScope = androidx.compose.runtime.rememberCoroutineScope()
|
||||||
|
|
||||||
val apps = listOf(
|
LaunchedEffect(Unit) {
|
||||||
AppEntry("free.zona", "Zona", "Кинотеатр Zona"),
|
controller.requestAppList()
|
||||||
)
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Приложения",
|
text = "Приложения на очках",
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 24.sp,
|
fontSize = 24.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Тапните, чтобы запустить на очках",
|
text = "Тап — запуск на очках. На телефоне сами приложения не запускаются.",
|
||||||
color = Color(0xFFB0B0B0),
|
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 ->
|
apps.forEach { app ->
|
||||||
Card(
|
AppCard(app, isRunning && glasses?.currentApp == app.packageName) {
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable {
|
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
controller.send(LaunchAppCommand(packageName = app.packageName))
|
controller.send(LaunchAppCommand(packageName = app.packageName))
|
||||||
}
|
}
|
||||||
onAppClick(app.packageName)
|
onAppClick(app.packageName)
|
||||||
},
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppCard(app: AppEntry, running: Boolean, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick),
|
||||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||||
containerColor = Color(0xFF1A1A1A),
|
containerColor = Color(0xFF1A1A1A),
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = app.name,
|
text = app.displayName,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = 18.sp,
|
fontSize = 18.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = app.description,
|
text = app.packageName,
|
||||||
color = Color(0xFFB0B0B0),
|
color = Color(0xFFB0B0B0),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
if (isRunning && glasses?.currentApp == app.packageName) {
|
if (running) {
|
||||||
Text(
|
Text(
|
||||||
text = "● запущено",
|
text = "● запущено",
|
||||||
color = Color(0xFF4CAF50),
|
color = Color(0xFF4CAF50),
|
||||||
@@ -95,11 +119,3 @@ fun AppsListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class AppEntry(
|
|
||||||
val packageName: String,
|
|
||||||
val name: String,
|
|
||||||
val description: String,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -74,6 +74,22 @@ enum class AudioMode { ON_GLASSES, ON_PHONE }
|
|||||||
@SerialName("shutdown")
|
@SerialName("shutdown")
|
||||||
data object Shutdown : VmMessage
|
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
|
// Glasses → Phone
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user