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 }