diff --git a/app-glasses-vm/src/main/AndroidManifest.xml b/app-glasses-vm/src/main/AndroidManifest.xml
index 9448b57..e316209 100644
--- a/app-glasses-vm/src/main/AndroidManifest.xml
+++ b/app-glasses-vm/src/main/AndroidManifest.xml
@@ -24,14 +24,22 @@
android:name="android.hardware.type.embedded"
android:required="true" />
-
+
+
+
+
+
{
- controller.setState(GlassesScreenState.ShizukuMissing)
+ if (controller.state.value !is GlassesScreenState.Running) {
+ controller.setState(GlassesScreenState.ShizukuMissing)
+ }
}
ShizukuBootstrap.AllowState.Granted -> {
+ // Если мы сейчас в ShizukuMissing — переходим в NoAppSelected.
+ // Running не трогаем: приложение уже запущено, Shizuku-статус не должен его ронять.
if (controller.state.value is GlassesScreenState.ShizukuMissing) {
controller.setState(GlassesScreenState.NoAppSelected)
}
diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt
index d4aba44..20bf8df 100644
--- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt
+++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt
@@ -29,6 +29,9 @@ import pw.binom.rayneovm.shared.Shutdown
import pw.binom.rayneovm.shared.TouchEvent
import pw.binom.rayneovm.shared.VmMessage
import pw.binom.rayneovm.shared.VmProtocol
+import pw.binom.rayneovm.shared.GetAppList
+import pw.binom.rayneovm.shared.AppEntry
+import pw.binom.rayneovm.shared.AppListResponse
/**
* Foreground service на стороне очков.
@@ -111,16 +114,28 @@ class VmService : LifecycleService() {
when (msg) {
is LaunchAppCommand -> {
Log.i(TAG, "LaunchAppCommand: ${msg.packageName}")
- // Реальный запуск — через Shizuku service (см. VmShizukuService).
- // Пока — переключаем состояние для UI.
- controller.setState(GlassesScreenState.Running(msg.packageName))
+ // Реальный запуск: VirtualDisplay + launchActivity через Shizuku.
+ // acquireFrameSource сам освобождает предыдущий VirtualDisplay, если был.
+ controller.launchGuest(msg.packageName)
}
is TouchEvent -> Log.d(TAG, "Touch: ${msg.actions.size}")
is VmKeyEvent -> Log.d(TAG, "Key: ${msg.keyCode}")
is SetAudioMode -> Log.d(TAG, "SetAudioMode: ${msg.mode}")
is Shutdown -> {
Log.i(TAG, "Shutdown")
- controller.setState(GlassesScreenState.NoAppSelected)
+ controller.stopGuest()
+ }
+ is GetAppList -> {
+ Log.i(TAG, "GetAppList")
+ val apps = listInstalledLaunchableApps()
+ val response = AppListResponse(apps = apps)
+ val json = VmProtocol.encode(response)
+ try {
+ server!!.send(connId, json)
+ Log.i(TAG, "Sent app list: ${apps.size} apps")
+ } catch (t: Throwable) {
+ Log.e(TAG, "Failed to send app list", t)
+ }
}
is Ping -> server?.send(connId, VmProtocol.encode(Pong))
else -> Log.d(TAG, "Unhandled: $msg")
@@ -218,6 +233,23 @@ class VmService : LifecycleService() {
}
}
+ private fun listInstalledLaunchableApps(): List {
+ val pm = packageManager
+ val mainIntent = android.content.Intent(android.content.Intent.ACTION_MAIN)
+ .addCategory(android.content.Intent.CATEGORY_LAUNCHER)
+ // MATCH_ALL: на Android 11+ без `` для конкретного пакета обычные флаги
+ // могут не возвращать часть приложений. free.zona — пример.
+ val flags = android.content.pm.PackageManager.MATCH_ALL or
+ android.content.pm.PackageManager.GET_RESOLVED_FILTER
+ val resolved = pm.queryIntentActivities(mainIntent, flags)
+ return resolved.map { ri ->
+ AppEntry(
+ packageName = ri.activityInfo.packageName,
+ displayName = ri.loadLabel(pm).toString(),
+ )
+ }.sortedBy { it.displayName.lowercase() }
+ }
+
companion object {
const val WS_PORT = 8080
const val WS_PATH = "/vm"
diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt
index 3f7fead..08358a4 100644
--- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt
+++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/protocol/VmGlassesController.kt
@@ -26,7 +26,7 @@ class VmGlassesController {
private val testScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
- private val _state = MutableStateFlow(GlassesScreenState.Starting)
+ private val _state = MutableStateFlow(GlassesScreenState.NoAppSelected)
val state: StateFlow = _state.asStateFlow()
private val _frameSource = MutableStateFlow(null)
@@ -40,8 +40,7 @@ class VmGlassesController {
Log.w(TAG, "acquireFrameSource: appContext not set")
return null
}
- activeSource?.release()
- activeSource = null
+ releaseFrameSource()
val surfaceTexture = try {
SurfaceTexture(true).also {
it.setDefaultBufferSize(VIRTUAL_WIDTH, VIRTUAL_HEIGHT)
@@ -50,18 +49,39 @@ class VmGlassesController {
Log.e(TAG, "SurfaceTexture creation failed", t)
return null
}
- val source = VirtualDisplayFrameSource.acquire(
- context = ctx,
- surfaceTexture = surfaceTexture,
- serviceClass = VmShizukuService::class.java,
- width = VIRTUAL_WIDTH,
- height = VIRTUAL_HEIGHT,
- density = VIRTUAL_DENSITY,
- )
+ val source = try {
+ VirtualDisplayFrameSource.acquire(
+ context = ctx,
+ surfaceTexture = surfaceTexture,
+ serviceClass = VmShizukuService::class.java,
+ width = VIRTUAL_WIDTH,
+ height = VIRTUAL_HEIGHT,
+ density = VIRTUAL_DENSITY,
+ )
+ } catch (t: Throwable) {
+ Log.e(TAG, "VirtualDisplayFrameSource.acquire failed for $packageName", t)
+ return null
+ }
activeSource = source
+ _frameSource.value = source
return source
}
+ /** Освобождает активный VirtualDisplay и очищает FrameSource. */
+ fun releaseFrameSource() {
+ val src = activeSource ?: run {
+ _frameSource.value = null
+ return
+ }
+ try {
+ src.release()
+ } catch (t: Throwable) {
+ Log.w(TAG, "releaseFrameSource failed", t)
+ }
+ activeSource = null
+ _frameSource.value = null
+ }
+
fun setState(newState: GlassesScreenState) {
Log.i(TAG, "setState: $newState")
_state.value = newState
@@ -85,6 +105,53 @@ class VmGlassesController {
}
}
+ /** Запустить гостя через VirtualDisplay (вызывается из VmService по LaunchAppCommand). */
+ fun launchGuest(packageName: String) {
+ Log.i(TAG, "launchGuest: $packageName")
+ setState(GlassesScreenState.Running(packageName))
+
+ // Шаг 1: запустить гостя на основном дисплее через обычный startActivity.
+ // Это работает без системных прав — обычный Android API.
+ // Пока без VirtualDisplay, потом перебросим на него (нужна Shizuku).
+ val ctx = appContext
+ if (ctx == null) {
+ Log.w(TAG, "launchGuest: appContext не установлен")
+ return
+ }
+ try {
+ val intent = ctx.packageManager.getLaunchIntentForPackage(packageName)
+ if (intent == null) {
+ Log.w(TAG, "launchGuest: $packageName не установлен")
+ return
+ }
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
+ ctx.startActivity(intent)
+ Log.i(TAG, "launchGuest: $packageName запущен на основном дисплее")
+ } catch (t: Throwable) {
+ Log.e(TAG, "launchGuest: не удалось запустить $packageName", t)
+ return
+ }
+
+ // Шаг 2: создать VirtualDisplay (нужны Shizuku права). Если Shizuku не активен —
+ // FrameSource останется null и GL-слой покажет чёрный экран. Но приложение
+ // уже запущено на основном дисплее очков, и пользователь его увидит.
+ testScope.launch {
+ val src = acquireFrameSource(packageName)
+ if (src == null) {
+ Log.w(TAG, "launchGuest: FrameSource не получен (Shizuku?), гость работает на основном дисплее")
+ } else {
+ Log.i(TAG, "launchGuest: FrameSource активен для $packageName")
+ }
+ }
+ }
+
+ /** Остановить гостя и освободить VirtualDisplay (по Shutdown или смене приложения). */
+ fun stopGuest() {
+ Log.i(TAG, "stopGuest")
+ releaseFrameSource()
+ setState(GlassesScreenState.NoAppSelected)
+ }
+
fun setAppContext(context: Context) {
this.appContext = context.applicationContext
}