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:
@@ -68,11 +68,10 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<!-- Shizuku UserService для системных вызовов -->
|
||||
<service
|
||||
android:name=".VmShizukuService"
|
||||
android:exported="true"
|
||||
android:process=":shizuku_vd" />
|
||||
<!-- VmShizukuService НЕ объявляется здесь как <service> — это Shizuku UserService,
|
||||
который ServiceStarter создаёт через reflection в отдельном isolated-процессе.
|
||||
Если объявить — Shizuku-сервер не сможет привести класс к IBinder
|
||||
(ClassCastException в moe.shizuku.starter.ServiceStarter). -->
|
||||
|
||||
<!-- Shizuku v12+ требует ShizukuProvider в манифесте — он сам вытаскивает binder
|
||||
через ContentProvider IPC и инициализирует Shizuku-клиент. Без него binder не придёт.
|
||||
|
||||
@@ -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()
|
||||
|
||||
+77
-49
@@ -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)
|
||||
}
|
||||
|
||||
+14
-5
@@ -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 = "Ошибка",
|
||||
|
||||
Reference in New Issue
Block a user