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
+4 -5
View File
@@ -68,11 +68,10 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaPlayback" /> android:foregroundServiceType="mediaPlayback" />
<!-- Shizuku UserService для системных вызовов --> <!-- VmShizukuService НЕ объявляется здесь как <service> — это Shizuku UserService,
<service который ServiceStarter создаёт через reflection в отдельном isolated-процессе.
android:name=".VmShizukuService" Если объявить — Shizuku-сервер не сможет привести класс к IBinder
android:exported="true" (ClassCastException в moe.shizuku.starter.ServiceStarter). -->
android:process=":shizuku_vd" />
<!-- Shizuku v12+ требует ShizukuProvider в манифесте — он сам вытаскивает binder <!-- Shizuku v12+ требует ShizukuProvider в манифесте — он сам вытаскивает binder
через ContentProvider IPC и инициализирует Shizuku-клиент. Без него binder не придёт. через ContentProvider IPC и инициализирует Shizuku-клиент. Без него binder не придёт.
@@ -1,5 +1,7 @@
package pw.binom.rayneovm.glasses package pw.binom.rayneovm.glasses
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -26,10 +28,6 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
// По доке Shizuku (https://github.com/RikkaApps/Shizuku-API): подписываемся на
// listener-ы здесь, после super.onCreate, но ДО setContent. ShizukuProvider
// (rikka.shizuku.ShizukuProvider в манифесте) уже инициализировал binder через
// ContentProvider.onCreate — pingBinder() сразу вернёт true.
ShizukuBootstrap.attach(this) ShizukuBootstrap.attach(this)
val controller = VmGlassesController.fromContext(this) val controller = VmGlassesController.fromContext(this)
@@ -42,18 +40,11 @@ class MainActivity : ComponentActivity() {
.background(Color.Black) .background(Color.Black)
) { ) {
val shizukuState by ShizukuBootstrap.state.collectAsState() val shizukuState by ShizukuBootstrap.state.collectAsState()
val vmState by controller.state.collectAsState()
DriveShizukuState(controller, shizukuState) DriveShizukuState(controller, shizukuState)
GlassesScreen( GlassesScreen(
controller = controller, controller = controller,
onRequestShizukuPermission = { ShizukuBootstrap.refresh() }, 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() ShizukuBootstrap.refresh()
} }
/**
* По доке Shizuku, мы должны вручную передать результат диалога в Shizuku -
* иначе он не узнает, что пользователь нажал Allow.
*/
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,
permissions: Array<String>, permissions: Array<String>,
@@ -3,12 +3,16 @@ package pw.binom.rayneovm.glasses
import pw.binom.mercury.shizuku.ShizukuVdService 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): * **Не объявляется в AndroidManifest.xml** как `<service>` — Shizuku создаёт экземпляр
* реальная реализация через [rikka.shizuku.SystemServiceHelper] + reflection добавляется * через `Class.forName().newInstance()`, никакого Android Service-lifecycle не происходит.
* в следующей итерации. *
* Вся логика транзакций — в базовом классе [ShizukuVdService]. Наследник нужен только
* как конкретный класс для `ComponentName` + публичный no-arg конструктор.
*/ */
class VmShizukuService : ShizukuVdService() class VmShizukuService : ShizukuVdService()
@@ -14,7 +14,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import pw.binom.mercury.binocular.FrameSource import pw.binom.mercury.binocular.FrameSource
import pw.binom.mercury.shizuku.ShizukuVdBinder
import pw.binom.rayneovm.glasses.VmShizukuService import pw.binom.rayneovm.glasses.VmShizukuService
import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource
import pw.binom.rayneovm.glasses.vm.GlassesScreenState import pw.binom.rayneovm.glasses.vm.GlassesScreenState
@@ -35,19 +38,24 @@ class VmGlassesController {
private var activeSource: VirtualDisplayFrameSource? = null private var activeSource: VirtualDisplayFrameSource? = null
@Volatile private var appContext: Context? = 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 { val ctx = appContext ?: run {
Log.w(TAG, "acquireFrameSource: appContext not set") Log.w(TAG, "acquireFrameSource: appContext not set")
return null return@withLock null
} }
releaseFrameSource() releaseFrameSource()
val surfaceTexture = try { 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) it.setDefaultBufferSize(VIRTUAL_WIDTH, VIRTUAL_HEIGHT)
} }
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(TAG, "SurfaceTexture creation failed", t) Log.e(TAG, "SurfaceTexture creation failed", t)
return null return@withLock null
} }
val source = try { val source = try {
VirtualDisplayFrameSource.acquire( VirtualDisplayFrameSource.acquire(
@@ -60,11 +68,11 @@ class VmGlassesController {
) )
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(TAG, "VirtualDisplayFrameSource.acquire failed for $packageName", t) Log.e(TAG, "VirtualDisplayFrameSource.acquire failed for $packageName", t)
return null return@withLock null
} }
activeSource = source activeSource = source
_frameSource.value = source _frameSource.value = source
return source source
} }
/** Освобождает активный VirtualDisplay и очищает FrameSource. */ /** Освобождает активный VirtualDisplay и очищает FrameSource. */
@@ -88,21 +96,12 @@ class VmGlassesController {
} }
/** /**
* DEBUG: запустить гостя напрямую (без Compose-UI). * DEBUG: запустить гостя напрямую (без Compose-UI) — то же, что и [launchGuest].
* Используется только для тестирования cross-process Surface на устройстве. * Используется для тестирования через adb broadcast.
*/ */
fun startTestLaunch(packageName: String) { fun startTestLaunch(packageName: String) {
Log.i(TAG, "startTestLaunch: $packageName") Log.i(TAG, "startTestLaunch: $packageName")
setState(GlassesScreenState.Running(packageName)) launchGuest(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)
}
}
} }
/** Запустить гостя через VirtualDisplay (вызывается из VmService по LaunchAppCommand). */ /** Запустить гостя через VirtualDisplay (вызывается из VmService по LaunchAppCommand). */
@@ -110,37 +109,37 @@ class VmGlassesController {
Log.i(TAG, "launchGuest: $packageName") Log.i(TAG, "launchGuest: $packageName")
setState(GlassesScreenState.Running(packageName)) setState(GlassesScreenState.Running(packageName))
// Шаг 1: запустить гостя на основном дисплее через обычный startActivity. // Шаг 1: создать VirtualDisplay (нужны Shizuku права) и дождаться готовности displayId.
// Это работает без системных прав — обычный Android API. // Без Shizuku acquireFrameSource вернёт null → гость не запустится.
// Пока без 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 { testScope.launch {
val src = acquireFrameSource(packageName) val src = acquireFrameSource(packageName) as? VirtualDisplayFrameSource
if (src == null) { if (src == null) {
Log.w(TAG, "launchGuest: FrameSource не получен (Shizuku?), гость работает на основном дисплее") Log.w(TAG, "launchGuest: FrameSource не получен (Shizuku не активен?)")
} else { setState(GlassesScreenState.Error("Не удалось создать виртуальный экран (Shizuku?)"))
Log.i(TAG, "launchGuest: FrameSource активен для $packageName") 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 * adb shell am broadcast -a pw.binom.rayneovm.glasses.TEST -e package free.zona
*/ */
class DebugTestReceiver : BroadcastReceiver() { 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) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION) return if (intent.action != ACTION) return
val pkg = intent.getStringExtra("package") ?: return val pkg = intent.getStringExtra("package") ?: return
Log.i(TAG, "DebugTestReceiver: launching $pkg") Log.i(TAG, "DebugTestReceiver: launching $pkg")
val controller = (context.applicationContext as? ControllerProvider)?.provideController() val controller = controller
?: (context.applicationContext as? ControllerProvider)?.provideController()
?: return ?: return
controller.startTestLaunch(pkg) controller.startTestLaunch(pkg)
} }
@@ -25,14 +25,23 @@ class VirtualDisplayFrameSource private constructor(
private val surface: Surface, private val surface: Surface,
private val binder: IBinder, private val binder: IBinder,
private val displayId: Int, private val displayId: Int,
private val client: ShizukuVdClient,
width: Int, width: Int,
height: Int, height: Int,
) : SurfaceTextureFrameSource(source, width, height) { ) : 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() { override fun release() {
runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) } runCatching { ShizukuVdBinder.releaseDisplay(binder, displayId) }
runCatching { surface.release() } runCatching { surface.release() }
super.release() super.release()
// Отвязываем UserService — держим его живым ровно на время жизни дисплея.
runCatching { client.stop() }
Log.i(TAG, "VirtualDisplayFrameSource released: displayId=$displayId") Log.i(TAG, "VirtualDisplayFrameSource released: displayId=$displayId")
} }
@@ -60,10 +69,10 @@ class VirtualDisplayFrameSource private constructor(
val surface = Surface(surfaceTexture) val surface = Surface(surfaceTexture)
val client = ShizukuVdClient(context, serviceClass) val client = ShizukuVdClient(context, serviceClass)
return try { return try {
var binder: IBinder? = null // bindUserService асинхронен — ждём Binder через suspend-обёртку (5с таймаут).
client.start { b -> binder = b } val b = client.bindForResult(timeoutMs = 5000)
val b = binder ?: run { if (b == null) {
Log.w(TAG, "Shizuku binder not ready, returning null") Log.w(TAG, "Shizuku binder not ready (timeout или pingBinder=false), returning null")
surface.release() surface.release()
client.stop() client.stop()
return null return null
@@ -78,7 +87,7 @@ class VirtualDisplayFrameSource private constructor(
return null return null
} }
Log.i(TAG, "Acquired VirtualDisplay id=$displayId ${width}x${height}") 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) { } catch (t: Throwable) {
Log.e(TAG, "acquire failed", t) Log.e(TAG, "acquire failed", t)
runCatching { surface.release() } runCatching { surface.release() }
@@ -12,7 +12,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import pw.binom.mercury.binocular.BinocularRendererView import pw.binom.mercury.binocular.BinocularRendererView
import pw.binom.mercury.binocular.FrameSource
import pw.binom.mercury.binocular.NoOpFrameSource import pw.binom.mercury.binocular.NoOpFrameSource
import pw.binom.rayneovm.glasses.protocol.VmGlassesController import pw.binom.rayneovm.glasses.protocol.VmGlassesController
import pw.binom.rayneovm.glasses.vm.GlassesScreenState import pw.binom.rayneovm.glasses.vm.GlassesScreenState
@@ -42,12 +40,14 @@ fun GlassesScreen(
onRequestShizukuPermission: (() -> Unit)? = null, onRequestShizukuPermission: (() -> Unit)? = null,
) { ) {
val state by controller.state.collectAsState() 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)) { Box(modifier = modifier.fillMaxSize().background(Color.Black)) {
// GL-слой: всегда рисуем, но при NoOpFrameSource — просто чёрный экран // GL-слой: всегда рисуем, но при NoOpFrameSource — просто чёрный экран
BinocularRendererView( BinocularRendererView(
frameSource = frameSource.value, frameSource = frameSource,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
@@ -72,12 +72,8 @@ fun GlassesScreen(
} }
) )
is GlassesScreenState.Running -> { is GlassesScreenState.Running -> {
// Ничего поверх GL — пусть пользователь видит гостевой контент. // Ничего поверх GL — пользователь видит гостевой контент.
// FrameSource обновляется в LaunchedEffect ниже. // FrameSource уже установлен контроллером (launchGuest → controller.frameSource).
androidx.compose.runtime.LaunchedEffect(s.packageName) {
val src = controller.acquireFrameSource(s.packageName)
if (src != null) frameSource.value = src
}
} }
is GlassesScreenState.Error -> StatusOverlay( is GlassesScreenState.Error -> StatusOverlay(
title = "Ошибка", title = "Ошибка",