glasses: launch guest on Shizuku VirtualDisplay + binocular render

- VmShizukuService is a Shizuku UserService only; remove its <service> manifest entry (ServiceStarter instantiates it reflectively, an Android service entry causes ClassCastException)
- VirtualDisplayFrameSource: await binder via ShizukuVdClient.bindForResult(5s), expose virtualDisplayId + shizukuBinder, stop the UserService on release
- VmGlassesController: serialize acquire via Mutex, use double-buffered SurfaceTexture (DisplayManagerService rejects single-buffer), launch the guest on the VD with ShizukuVdBinder.launchActivity
- GlassesScreen: render controller.frameSource (Running) directly; drop the LaunchedEffect acquire
- DebugTestReceiver: register at runtime from MainActivity (background manifest receivers are blocked on Android 12+)
This commit is contained in:
2026-09-25 17:19:49 +03:00
parent 5afdde70e1
commit 03f1d7dcc7
6 changed files with 112 additions and 89 deletions
@@ -1,5 +1,7 @@
package pw.binom.rayneovm.glasses
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -26,10 +28,6 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// По доке Shizuku (https://github.com/RikkaApps/Shizuku-API): подписываемся на
// listener-ы здесь, после super.onCreate, но ДО setContent. ShizukuProvider
// (rikka.shizuku.ShizukuProvider в манифесте) уже инициализировал binder через
// ContentProvider.onCreate — pingBinder() сразу вернёт true.
ShizukuBootstrap.attach(this)
val controller = VmGlassesController.fromContext(this)
@@ -42,18 +40,11 @@ class MainActivity : ComponentActivity() {
.background(Color.Black)
) {
val shizukuState by ShizukuBootstrap.state.collectAsState()
val vmState by controller.state.collectAsState()
DriveShizukuState(controller, shizukuState)
GlassesScreen(
controller = controller,
onRequestShizukuPermission = { ShizukuBootstrap.refresh() },
)
// Как только VM ушёл в Running (запущен гость) — сворачиваем свой Activity,
// чтобы SurfaceView гостя оказался поверх и был виден на очках.
// Service продолжает жить в фоне (WebSocket/mDNS не рвутся).
if (vmState is GlassesScreenState.Running) {
(this as? ComponentActivity)?.finish()
}
}
}
}
@@ -65,10 +56,6 @@ class MainActivity : ComponentActivity() {
ShizukuBootstrap.refresh()
}
/**
* По доке Shizuku, мы должны вручную передать результат диалога в Shizuku -
* иначе он не узнает, что пользователь нажал Allow.
*/
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
@@ -3,12 +3,16 @@ package pw.binom.rayneovm.glasses
import pw.binom.mercury.shizuku.ShizukuVdService
/**
* Shizuku UserService для системных вызовов (VirtualDisplay, launchActivity, injectInputEvent).
* Shizuku UserService для системных вызовов на очках (VirtualDisplay, launchActivity,
* injectInputEvent и т.д.).
*
* Запускается в отдельном процессе :shizuku_vd через [pw.binom.mercury.shizuku.ShizukuVdClient].
* Запускается в отдельном isolated-процессе от имени shell UID 2000 через
* [pw.binom.mercury.shizuku.ShizukuVdClient].
*
* Методы Binder-API ([createDisplay], [launchActivity], и т.д.) сейчас — заглушки (STUB):
* реальная реализация через [rikka.shizuku.SystemServiceHelper] + reflection добавляется
* в следующей итерации.
* **Не объявляется в AndroidManifest.xml** как `<service>` — Shizuku создаёт экземпляр
* через `Class.forName().newInstance()`, никакого Android Service-lifecycle не происходит.
*
* Вся логика транзакций — в базовом классе [ShizukuVdService]. Наследник нужен только
* как конкретный класс для `ComponentName` + публичный no-arg конструктор.
*/
class VmShizukuService : ShizukuVdService()
@@ -14,7 +14,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.mercury.binocular.FrameSource
import pw.binom.mercury.shizuku.ShizukuVdBinder
import pw.binom.rayneovm.glasses.VmShizukuService
import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
@@ -35,19 +38,24 @@ class VmGlassesController {
private var activeSource: VirtualDisplayFrameSource? = null
@Volatile private var appContext: Context? = null
suspend fun acquireFrameSource(packageName: String): FrameSource? {
/** Сериализует создание/освобождение VirtualDisplay — параллельный acquire оставил бы висеть второй UserService. */
private val acquireMutex = Mutex()
suspend fun acquireFrameSource(packageName: String): FrameSource? = acquireMutex.withLock {
val ctx = appContext ?: run {
Log.w(TAG, "acquireFrameSource: appContext not set")
return null
return@withLock null
}
releaseFrameSource()
val surfaceTexture = try {
SurfaceTexture(true).also {
// ВАЖНО: single-buffer нельзя — DisplayManagerService reject'ит
// "Surface can't be single-buffered" при создании VirtualDisplay.
SurfaceTexture(false).also {
it.setDefaultBufferSize(VIRTUAL_WIDTH, VIRTUAL_HEIGHT)
}
} catch (t: Throwable) {
Log.e(TAG, "SurfaceTexture creation failed", t)
return null
return@withLock null
}
val source = try {
VirtualDisplayFrameSource.acquire(
@@ -60,11 +68,11 @@ class VmGlassesController {
)
} catch (t: Throwable) {
Log.e(TAG, "VirtualDisplayFrameSource.acquire failed for $packageName", t)
return null
return@withLock null
}
activeSource = source
_frameSource.value = source
return source
source
}
/** Освобождает активный VirtualDisplay и очищает FrameSource. */
@@ -88,21 +96,12 @@ class VmGlassesController {
}
/**
* DEBUG: запустить гостя напрямую (без Compose-UI).
* Используется только для тестирования cross-process Surface на устройстве.
* DEBUG: запустить гостя напрямую (без Compose-UI) — то же, что и [launchGuest].
* Используется для тестирования через adb broadcast.
*/
fun startTestLaunch(packageName: String) {
Log.i(TAG, "startTestLaunch: $packageName")
setState(GlassesScreenState.Running(packageName))
// Прямо сейчас (без ожидания Compose) запускаем создание FrameSource и activity.
testScope.launch {
try {
val src = acquireFrameSource(packageName)
Log.i(TAG, "acquireFrameSource result: $src for $packageName")
} catch (t: Throwable) {
Log.e(TAG, "startTestLaunch: acquire failed", t)
}
}
launchGuest(packageName)
}
/** Запустить гостя через VirtualDisplay (вызывается из VmService по LaunchAppCommand). */
@@ -110,37 +109,37 @@ class VmGlassesController {
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-слой покажет чёрный экран. Но приложение
// уже запущено на основном дисплее очков, и пользователь его увидит.
// Шаг 1: создать VirtualDisplay (нужны Shizuku права) и дождаться готовности displayId.
// Без Shizuku acquireFrameSource вернёт null → гость не запустится.
testScope.launch {
val src = acquireFrameSource(packageName)
val src = acquireFrameSource(packageName) as? VirtualDisplayFrameSource
if (src == null) {
Log.w(TAG, "launchGuest: FrameSource не получен (Shizuku?), гость работает на основном дисплее")
} else {
Log.i(TAG, "launchGuest: FrameSource активен для $packageName")
Log.w(TAG, "launchGuest: FrameSource не получен (Shizuku не активен?)")
setState(GlassesScreenState.Error("Не удалось создать виртуальный экран (Shizuku?)"))
return@launch
}
// Шаг 2: запустить гостя на нашем виртуальном дисплее через Shizuku-UserService.
// Запуск идёт от лица shell UID — ActivityOptions.setLaunchDisplayId проходит
// проверку AMS и Activity стартует на нашем virtual display, не на основном.
// Разрешаем MainActivity на клиенте через наш PackageManager — UserService
// работает в isolated-процессе без своего PackageManager.
val ctx2 = appContext
if (ctx2 == null) {
Log.w(TAG, "launchGuest: appContext not set")
return@launch
}
val pm = ctx2.packageManager
val launchIntent = pm.getLaunchIntentForPackage(packageName)
val className = launchIntent?.component?.className ?: ""
val ok = ShizukuVdBinder.launchActivity(
binder = src.shizukuBinder,
packageName = packageName,
className = className,
displayId = src.virtualDisplayId,
)
Log.i(TAG, "launchGuest: $packageName/$className запущен на virtualDisplayId=${src.virtualDisplayId}, ok=$ok")
if (!ok) {
setState(GlassesScreenState.Error("Не удалось запустить $packageName"))
}
}
}
@@ -177,15 +176,44 @@ interface ControllerProvider {
}
/**
* DEBUG broadcast receiver для тестирования через adb:
* DEBUG broadcast receiver для тестирования через adb.
*
* Android 12+ блокирует доставку broadcast-ов в manifest-объявленные receiver-ы если
* процесс в фоне ("Background execution not allowed"). Поэтому регистрируем программно
* из MainActivity.onCreate() — это runtime-register receiver, exempt от background-check.
*
* Использование:
* adb shell am broadcast -a pw.binom.rayneovm.glasses.TEST -e package free.zona
*/
class DebugTestReceiver : BroadcastReceiver() {
private var controller: VmGlassesController? = null
private var registeredContext: Context? = null
/** Вызывается из MainActivity.onCreate (когда приложение в foreground). */
fun attach(context: Context, controller: VmGlassesController) {
this.controller = controller
val filter = IntentFilter(ACTION)
// RECEIVER_EXPORTED — можно принимать от adb shell; pre-Android 13 можно было
// без флага, с 13+ обязательно указывать.
context.registerReceiver(this, filter, Context.RECEIVER_EXPORTED)
registeredContext = context
Log.i(TAG, "DebugTestReceiver registered")
}
fun detach(context: Context) {
try {
context.unregisterReceiver(this)
} catch (_: Throwable) {
// not registered, ignore
}
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION) return
val pkg = intent.getStringExtra("package") ?: return
Log.i(TAG, "DebugTestReceiver: launching $pkg")
val controller = (context.applicationContext as? ControllerProvider)?.provideController()
val controller = controller
?: (context.applicationContext as? ControllerProvider)?.provideController()
?: return
controller.startTestLaunch(pkg)
}
@@ -25,14 +25,23 @@ class VirtualDisplayFrameSource private constructor(
private val surface: Surface,
private val binder: IBinder,
private val displayId: Int,
private val client: ShizukuVdClient,
width: Int,
height: Int,
) : SurfaceTextureFrameSource(source, width, height) {
/** ID виртуального дисплея (нужен для запуска Activity через ActivityOptions.setLaunchDisplayId). */
val virtualDisplayId: Int get() = displayId
/** Binder к ShizukuVdService (для последующих вызовов launchActivity / injectInputEvent). */
val shizukuBinder: IBinder get() = binder
override fun release() {
runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) }
runCatching { surface.release() }
super.release()
// Отвязываем UserService — держим его живым ровно на время жизни дисплея.
runCatching { client.stop() }
Log.i(TAG, "VirtualDisplayFrameSource released: displayId=$displayId")
}
@@ -60,10 +69,10 @@ class VirtualDisplayFrameSource private constructor(
val surface = Surface(surfaceTexture)
val client = ShizukuVdClient(context, serviceClass)
return try {
var binder: IBinder? = null
client.start { b -> binder = b }
val b = binder ?: run {
Log.w(TAG, "Shizuku binder not ready, returning null")
// bindUserService асинхронен — ждём Binder через suspend-обёртку (5с таймаут).
val b = client.bindForResult(timeoutMs = 5000)
if (b == null) {
Log.w(TAG, "Shizuku binder not ready (timeout или pingBinder=false), returning null")
surface.release()
client.stop()
return null
@@ -78,7 +87,7 @@ class VirtualDisplayFrameSource private constructor(
return null
}
Log.i(TAG, "Acquired VirtualDisplay id=$displayId ${width}x${height}")
VirtualDisplayFrameSource(surfaceTexture, surface, b, displayId, width, height)
VirtualDisplayFrameSource(surfaceTexture, surface, b, displayId, client, width, height)
} catch (t: Throwable) {
Log.e(TAG, "acquire failed", t)
runCatching { surface.release() }
@@ -12,7 +12,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -21,7 +20,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.mercury.binocular.BinocularRendererView
import pw.binom.mercury.binocular.FrameSource
import pw.binom.mercury.binocular.NoOpFrameSource
import pw.binom.rayneovm.glasses.protocol.VmGlassesController
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
@@ -42,12 +40,14 @@ fun GlassesScreen(
onRequestShizukuPermission: (() -> Unit)? = null,
) {
val state by controller.state.collectAsState()
val frameSource = remember { mutableStateOf<FrameSource>(NoOpFrameSource()) }
val controllerFrameSource by controller.frameSource.collectAsState()
val noOp = remember { NoOpFrameSource() }
val frameSource = controllerFrameSource ?: noOp
Box(modifier = modifier.fillMaxSize().background(Color.Black)) {
// GL-слой: всегда рисуем, но при NoOpFrameSource — просто чёрный экран
BinocularRendererView(
frameSource = frameSource.value,
frameSource = frameSource,
modifier = Modifier.fillMaxSize(),
)
@@ -72,12 +72,8 @@ fun GlassesScreen(
}
)
is GlassesScreenState.Running -> {
// Ничего поверх GL — пусть пользователь видит гостевой контент.
// FrameSource обновляется в LaunchedEffect ниже.
androidx.compose.runtime.LaunchedEffect(s.packageName) {
val src = controller.acquireFrameSource(s.packageName)
if (src != null) frameSource.value = src
}
// Ничего поверх GL — пользователь видит гостевой контент.
// FrameSource уже установлен контроллером (launchGuest → controller.frameSource).
}
is GlassesScreenState.Error -> StatusOverlay(
title = "Ошибка",