Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5afdde70e1 | |||
| 42f117796a | |||
| 86bffed6ec | |||
| cc5866b373 | |||
| ca01a7b918 | |||
| e01fafd5a9 |
@@ -0,0 +1,4 @@
|
||||
build
|
||||
.cortexkit
|
||||
.idea
|
||||
.gradle
|
||||
@@ -64,6 +64,7 @@ dependencies {
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
implementation(libs.shizuku.api)
|
||||
implementation(libs.shizuku.provider)
|
||||
implementation(libs.mercury.library)
|
||||
implementation(libs.mercury.transport)
|
||||
implementation(libs.mercury.binocular)
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="moe.shizuku.permission.SHIZUKU" />
|
||||
<!-- Shizuku v12+: BinderSender проверяет requestedPermissions на наличие
|
||||
этого permission (см. moe.shizuku.server.BinderSender.sendBinder).
|
||||
Старое moe.shizuku.permission.SHIZUKU (Shizuku v11) больше не работает. -->
|
||||
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
|
||||
|
||||
<uses-feature
|
||||
android:glEsVersion="0x00020000"
|
||||
@@ -21,6 +24,24 @@
|
||||
android:name="android.hardware.type.embedded"
|
||||
android:required="true" />
|
||||
|
||||
<!-- Android 11+ package visibility:
|
||||
- Shizuku API выполняет context.bindService на сервис внутри пакета
|
||||
moe.shizuku.privileged.api; без явного queries bindService возвращает false
|
||||
и binder к нам никогда не приедет.
|
||||
- queryIntentActivities(MAIN+LAUNCHER) без intent-queries возвращает только
|
||||
системные/RayNeo приложения; для всех сторонних (free.zona и т.п.)
|
||||
нужен явный <intent> с ACTION_MAIN + CATEGORY_LAUNCHER. -->
|
||||
<queries>
|
||||
<package android:name="moe.shizuku.privileged.api" />
|
||||
<intent>
|
||||
<action android:name="rikka.shizuku.intent.action.REQUEST_BINDER" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".GlassesVmApp"
|
||||
android:label="@string/app_name"
|
||||
@@ -52,6 +73,18 @@
|
||||
android:name=".VmShizukuService"
|
||||
android:exported="true"
|
||||
android:process=":shizuku_vd" />
|
||||
|
||||
<!-- Shizuku v12+ требует ShizukuProvider в манифесте — он сам вытаскивает binder
|
||||
через ContentProvider IPC и инициализирует Shizuku-клиент. Без него binder не придёт.
|
||||
permission=INTERACT_ACROSS_USERS_FULL защищает провайдер от обычных приложений. -->
|
||||
<provider
|
||||
android:name="rikka.shizuku.ShizukuProvider"
|
||||
android:authorities="${applicationId}.shizuku"
|
||||
android:multiprocess="false"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||
|
||||
<meta-data android:name="com.rayneo.mercury.app" android:value="true" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -10,6 +10,9 @@ import pw.binom.rayneovm.glasses.protocol.VmGlassesController
|
||||
*
|
||||
* Запускает [VmService] при создании (foreground-сервис держит WebSocket сервер + mDNS,
|
||||
* и продолжает работать после сворачивания Activity).
|
||||
*
|
||||
* Также стартует [ShizukuBootstrap] — он регистрирует binder listener, ждёт/запрашивает
|
||||
* permission и обновляет state. MainActivity потом подпишется на этот state.
|
||||
*/
|
||||
class GlassesVmApp : Application(), ControllerProvider {
|
||||
|
||||
@@ -18,11 +21,15 @@ class GlassesVmApp : Application(), ControllerProvider {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(TAG, "GlassesVmApp onCreate, uid=${android.os.Process.myUid()}")
|
||||
// Инициализация RayNeo Mercury SDK.
|
||||
com.ffalcon.mercury.android.sdk.MercurySDK.init(this)
|
||||
Log.i(TAG, "MercurySDK initialized")
|
||||
|
||||
// 1. RayNeo Mercury SDK init (BLE-канал, если нужно)
|
||||
runCatching { com.ffalcon.mercury.android.sdk.MercurySDK.init(this) }
|
||||
.onSuccess { Log.i(TAG, "MercurySDK initialized") }
|
||||
.onFailure { Log.w(TAG, "MercurySDK init failed: ${it.message}") }
|
||||
|
||||
controller.setAppContext(this)
|
||||
// Регистрируем debug-receiver для тестирования через adb.
|
||||
|
||||
// 2. Регистрируем debug-receiver для тестирования через adb
|
||||
val filter = android.content.IntentFilter(pw.binom.rayneovm.glasses.protocol.DebugTestReceiver.ACTION)
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(pw.binom.rayneovm.glasses.protocol.DebugTestReceiver(), filter, android.content.Context.RECEIVER_EXPORTED)
|
||||
@@ -30,6 +37,11 @@ class GlassesVmApp : Application(), ControllerProvider {
|
||||
@Suppress("UnspecifiedRegisterReceiverFlag")
|
||||
registerReceiver(pw.binom.rayneovm.glasses.protocol.DebugTestReceiver(), filter)
|
||||
}
|
||||
|
||||
// 3. Shizuku bootstrap здесь не зовём — он вызывается из MainActivity.onCreate,
|
||||
// где есть Activity-контекст (для requestPermission flow).
|
||||
|
||||
// 4. Запуск foreground-сервиса (WebSocket + mDNS publisher)
|
||||
VmService.start(this)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,23 +9,30 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import pw.binom.rayneovm.glasses.protocol.VmGlassesController
|
||||
import pw.binom.rayneovm.glasses.ui.GlassesScreen
|
||||
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
|
||||
|
||||
/**
|
||||
* Single-Activity приложения: Compose host для GlassesScreen.
|
||||
*
|
||||
* Никакой навигации — здесь один экран (зеркало гостевого приложения + state overlay).
|
||||
*/
|
||||
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)
|
||||
|
||||
setContent {
|
||||
CompositionLocalProvider(VmGlassesController.Local provides controller) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
@@ -34,10 +41,65 @@ class MainActivity : ComponentActivity() {
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
GlassesScreen(controller = controller)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
ShizukuBootstrap.refresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* По доке Shizuku, мы должны вручную передать результат диалога в Shizuku -
|
||||
* иначе он не узнает, что пользователь нажал Allow.
|
||||
*/
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray,
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == ShizukuBootstrap.REQUEST_CODE_PERMISSION) {
|
||||
val grant = grantResults.firstOrNull() ?: -1
|
||||
ShizukuBootstrap.onRequestPermissionResult(requestCode, grant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DriveShizukuState(controller: VmGlassesController, shizuku: ShizukuBootstrap.AllowState) {
|
||||
LaunchedEffect(shizuku) {
|
||||
when (shizuku) {
|
||||
ShizukuBootstrap.AllowState.NotRunning,
|
||||
ShizukuBootstrap.AllowState.Waiting,
|
||||
ShizukuBootstrap.AllowState.NeedsPermission -> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package pw.binom.rayneovm.glasses
|
||||
|
||||
import android.app.Activity
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import rikka.shizuku.Shizuku
|
||||
|
||||
/**
|
||||
* Управляет жизненным циклом Shizuku для GlassesApp.
|
||||
*
|
||||
* Состояния:
|
||||
* - Waiting — Shizuku ещё не ответил (binder listener ждёт)
|
||||
* - NotRunning — Shizuku не запущен (нет сервера)
|
||||
* - NeedsPermission — Shizuku запущен, но прав ещё нет
|
||||
* - Granted — права есть, можно работать
|
||||
*
|
||||
* Использование (см. https://github.com/RikkaApps/Shizuku-API):
|
||||
* 1. В AndroidManifest.xml добавить `<provider rikka.shizuku.ShizukuProvider .../>` (уже есть).
|
||||
* 2. В Application.onCreate / Activity.onCreate один раз:
|
||||
* Shizuku.addBinderReceivedListenerSticky(...)
|
||||
* Shizuku.addRequestPermissionResultListener(...)
|
||||
* 3. Проверять ShizukuBootstrap.state.
|
||||
*
|
||||
* Зависимость `dev.rikka.shizuku:provider` нужна (в ней ShizukuProvider).
|
||||
*/
|
||||
object ShizukuBootstrap {
|
||||
|
||||
enum class AllowState {
|
||||
Waiting,
|
||||
NotRunning,
|
||||
NeedsPermission,
|
||||
Granted,
|
||||
}
|
||||
|
||||
private const val TAG = "ShizukuBootstrap"
|
||||
const val REQUEST_CODE_PERMISSION = 7801
|
||||
|
||||
private val _state = MutableStateFlow(AllowState.Waiting)
|
||||
val state: StateFlow<AllowState> = _state.asStateFlow()
|
||||
|
||||
@Volatile private var binderReceivedCalled = false
|
||||
|
||||
/**
|
||||
* Привязывает listeners к Shizuku (по доке). Вызывать из Activity.onCreate().
|
||||
*
|
||||
* - addBinderReceivedListenerSticky — сразу же выстреливает, если binder уже получен
|
||||
* (без "sticky" мы бы пропустили).
|
||||
* - addRequestPermissionResultListener — вызовется после диалога.
|
||||
*
|
||||
* ShizukuProvider из `dev.rikka.shizuku:provider` инициализирует binder до того,
|
||||
* как наш процесс получит управление в onCreate (через ContentProvider.onCreate),
|
||||
* поэтому pingBinder() сразу вернёт true — если Shizuku-сервер живой и v12+.
|
||||
*/
|
||||
fun attach(activity: Activity) {
|
||||
// 1. Подписка на приход binder-а (sticky ⇒ сразу же вызовется, если уже есть).
|
||||
Shizuku.addBinderReceivedListenerSticky(object : Shizuku.OnBinderReceivedListener {
|
||||
override fun onBinderReceived() {
|
||||
if (binderReceivedCalled) return
|
||||
binderReceivedCalled = true
|
||||
Log.i(TAG, "onBinderReceived")
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Подписка на результат диалога разрешения.
|
||||
Shizuku.addRequestPermissionResultListener { _, grantResult ->
|
||||
Log.i(TAG, "onRequestPermissionResult: grant=$grantResult")
|
||||
refresh()
|
||||
}
|
||||
|
||||
// 3. Сразу пробуем — может, binder уже есть.
|
||||
if (Shizuku.pingBinder()) {
|
||||
Log.i(TAG, "attach: pingBinder сразу ok")
|
||||
refresh()
|
||||
} else {
|
||||
Log.w(TAG, "attach: pingBinder=false, ждём binder от ShizukuProvider")
|
||||
_state.value = AllowState.Waiting
|
||||
}
|
||||
}
|
||||
|
||||
/** Перечитывает состояние и (если нужно) запрашивает permission. */
|
||||
fun refresh() {
|
||||
val running = try { Shizuku.pingBinder() } catch (t: Throwable) { false }
|
||||
if (!running) {
|
||||
Log.w(TAG, "refresh: Shizuku не запущен")
|
||||
_state.value = AllowState.NotRunning
|
||||
return
|
||||
}
|
||||
val perm = try {
|
||||
Shizuku.checkSelfPermission()
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "checkSelfPermission failed", t)
|
||||
-1
|
||||
}
|
||||
if (perm == 0 /* PERMISSION_GRANTED */) {
|
||||
Log.i(TAG, "refresh: granted")
|
||||
_state.value = AllowState.Granted
|
||||
} else {
|
||||
Log.i(TAG, "refresh: needs permission (perm=$perm)")
|
||||
if (_state.value != AllowState.NeedsPermission) {
|
||||
_state.value = AllowState.NeedsPermission
|
||||
try {
|
||||
Shizuku.requestPermission(REQUEST_CODE_PERMISSION)
|
||||
} catch (t: Throwable) {
|
||||
Log.w(TAG, "requestPermission failed", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isGranted(): Boolean = _state.value == AllowState.Granted
|
||||
|
||||
/**
|
||||
* Хук для Activity.onRequestPermissionResult: перенаправить в Shizuku.
|
||||
*
|
||||
* override fun onRequestPermissionsResult(rc: Int, p: Array<String>, g: IntArray) {
|
||||
* ...
|
||||
* ShizukuBootstrap.onRequestPermissionResult(rc, g[0])
|
||||
* }
|
||||
*/
|
||||
fun onRequestPermissionResult(requestCode: Int, grantResult: Int) {
|
||||
// Shizuku.addRequestPermissionResultListener вызывается автоматически внутри Shizuku,
|
||||
// здесь делать ничего не надо. Метод оставлен как no-op для удобства и обратной совместимости.
|
||||
}
|
||||
}
|
||||
@@ -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<AppEntry> {
|
||||
val pm = packageManager
|
||||
val mainIntent = android.content.Intent(android.content.Intent.ACTION_MAIN)
|
||||
.addCategory(android.content.Intent.CATEGORY_LAUNCHER)
|
||||
// MATCH_ALL: на Android 11+ без `<queries>` для конкретного пакета обычные флаги
|
||||
// могут не возвращать часть приложений. 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"
|
||||
|
||||
+71
-4
@@ -26,7 +26,7 @@ class VmGlassesController {
|
||||
|
||||
private val testScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val _state = MutableStateFlow<GlassesScreenState>(GlassesScreenState.Starting)
|
||||
private val _state = MutableStateFlow<GlassesScreenState>(GlassesScreenState.NoAppSelected)
|
||||
val state: StateFlow<GlassesScreenState> = _state.asStateFlow()
|
||||
|
||||
private val _frameSource = MutableStateFlow<FrameSource?>(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,7 +49,8 @@ class VmGlassesController {
|
||||
Log.e(TAG, "SurfaceTexture creation failed", t)
|
||||
return null
|
||||
}
|
||||
val source = VirtualDisplayFrameSource.acquire(
|
||||
val source = try {
|
||||
VirtualDisplayFrameSource.acquire(
|
||||
context = ctx,
|
||||
surfaceTexture = surfaceTexture,
|
||||
serviceClass = VmShizukuService::class.java,
|
||||
@@ -58,10 +58,30 @@ class VmGlassesController {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import pw.binom.rayneovm.glasses.vm.GlassesScreenState
|
||||
fun GlassesScreen(
|
||||
controller: VmGlassesController,
|
||||
modifier: Modifier = Modifier,
|
||||
onRequestShizukuPermission: (() -> Unit)? = null,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val frameSource = remember { mutableStateOf<FrameSource>(NoOpFrameSource()) }
|
||||
@@ -55,8 +56,9 @@ fun GlassesScreen(
|
||||
is GlassesScreenState.Starting -> StatusOverlay("Подготавливаем очки...")
|
||||
is GlassesScreenState.ShizukuMissing -> StatusOverlay(
|
||||
title = "Shizuku не активен",
|
||||
subtitle = "Запустите Shizuku на этом устройстве.\n" +
|
||||
"Без него приложение не сможет создать виртуальный экран."
|
||||
subtitle = "Без Shizuku приложение не может создать виртуальный экран.\n" +
|
||||
"Тапните здесь, чтобы дать разрешение.",
|
||||
onClick = onRequestShizukuPermission,
|
||||
)
|
||||
is GlassesScreenState.NoConnection -> StatusOverlay(
|
||||
title = "Нет связи с телефоном",
|
||||
|
||||
@@ -15,7 +15,10 @@ import pw.binom.mercury.transport.StringTransport
|
||||
import pw.binom.mercury.transport.Transports
|
||||
import pw.binom.rayneovm.phone.audio.AudioPlaybackManager
|
||||
import pw.binom.rayneovm.shared.AppCrashedEvent
|
||||
import pw.binom.rayneovm.shared.AppEntry
|
||||
import pw.binom.rayneovm.shared.AppListResponse
|
||||
import pw.binom.rayneovm.shared.AppLaunchedAck
|
||||
import pw.binom.rayneovm.shared.GetAppList
|
||||
import pw.binom.rayneovm.shared.GlassesStatus
|
||||
import pw.binom.rayneovm.shared.Pong
|
||||
import pw.binom.rayneovm.shared.SetAudioMode
|
||||
@@ -116,6 +119,9 @@ class VmPhoneController(
|
||||
is AppLaunchedAck -> {
|
||||
_appLaunched.value = msg
|
||||
}
|
||||
is AppListResponse -> {
|
||||
_appsOnGlasses.value = msg.apps
|
||||
}
|
||||
is SetAudioMode -> {
|
||||
// Аудио режим — отдельный поток, не контролируется этим контроллером напрямую.
|
||||
// AudioCaptureService на очках стартует/останавливается.
|
||||
@@ -132,9 +138,17 @@ class VmPhoneController(
|
||||
private val _appLaunched = MutableStateFlow<AppLaunchedAck?>(null)
|
||||
val appLaunched: StateFlow<AppLaunchedAck?> = _appLaunched.asStateFlow()
|
||||
|
||||
private val _appsOnGlasses = MutableStateFlow<List<AppEntry>>(emptyList())
|
||||
val appsOnGlasses: StateFlow<List<AppEntry>> = _appsOnGlasses.asStateFlow()
|
||||
|
||||
fun clearAppCrashed() { _appCrashed.value = null }
|
||||
fun clearAppLaunched() { _appLaunched.value = null }
|
||||
|
||||
/** Просит очки прислать список установленных приложений. Игнорирует, если не подключены. */
|
||||
suspend fun requestAppList() {
|
||||
send(GetAppList)
|
||||
}
|
||||
|
||||
/** Отправляет сообщение очкам. */
|
||||
suspend fun send(msg: VmMessage) {
|
||||
val t = currentTransport ?: return
|
||||
|
||||
@@ -7,25 +7,31 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
||||
import pw.binom.rayneovm.shared.AppEntry
|
||||
import pw.binom.rayneovm.shared.LaunchAppCommand
|
||||
|
||||
/**
|
||||
* Список приложений, которые можно запустить на очках через VirtualDisplay.
|
||||
*
|
||||
* Сейчас — только Zona. В будущем: реестр в shared/AppProfiles.kt.
|
||||
* Список приложений, установленных на ОЧКАХ. Тап → запуск на очках.
|
||||
* Список приходит динамически по WebSocket (VmMessage.GetAppList → AppListResponse).
|
||||
*/
|
||||
@Composable
|
||||
fun AppsListScreen(
|
||||
@@ -33,59 +39,77 @@ fun AppsListScreen(
|
||||
onAppClick: (String) -> Unit = {},
|
||||
) {
|
||||
val glasses by controller.glassesStatus.collectAsState()
|
||||
val apps by controller.appsOnGlasses.collectAsState()
|
||||
val isRunning = glasses?.running == true
|
||||
val coroutineScope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
|
||||
val apps = listOf(
|
||||
AppEntry("free.zona", "Zona", "Кинотеатр Zona"),
|
||||
)
|
||||
LaunchedEffect(Unit) {
|
||||
controller.requestAppList()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Приложения",
|
||||
text = "Приложения на очках",
|
||||
color = Color.White,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "Тапните, чтобы запустить на очках",
|
||||
text = "Тап — запуск на очках. На телефоне сами приложения не запускаются.",
|
||||
color = Color(0xFFB0B0B0),
|
||||
fontSize = 14.sp,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
|
||||
if (apps.isEmpty()) {
|
||||
Text(
|
||||
text = "Нет связи с очками или список пуст.",
|
||||
color = Color(0xFF888888),
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
}
|
||||
|
||||
apps.forEach { app ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
AppCard(app, isRunning && glasses?.currentApp == app.packageName) {
|
||||
coroutineScope.launch {
|
||||
controller.send(LaunchAppCommand(packageName = app.packageName))
|
||||
}
|
||||
onAppClick(app.packageName)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppCard(app: AppEntry, running: Boolean, onClick: () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick),
|
||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||
containerColor = Color(0xFF1A1A1A),
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = app.name,
|
||||
text = app.displayName,
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = app.description,
|
||||
text = app.packageName,
|
||||
color = Color(0xFFB0B0B0),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
if (isRunning && glasses?.currentApp == app.packageName) {
|
||||
if (running) {
|
||||
Text(
|
||||
text = "● запущено",
|
||||
color = Color(0xFF4CAF50),
|
||||
@@ -95,11 +119,3 @@ fun AppsListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class AppEntry(
|
||||
val packageName: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
)
|
||||
|
||||
+64
-3
@@ -915,8 +915,21 @@ class AudioPlaybackService : Service() {
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <!-- AudioPlaybackService -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- Android 13+ для foreground notification -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" /> <!-- PARTIAL_WAKE_LOCK для AudioPlaybackService -->
|
||||
<!-- Shizuku permission для VM-сервиса -->
|
||||
<uses-permission android:name="moe.shizuku.permission.SHIZUKU" />
|
||||
<!-- Shizuku permission v12+ (BinderSender проверяет requestedPermissions на наличие
|
||||
этого permission; старое moe.shizuku.permission.SHIZUKU v11 не работает). -->
|
||||
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
|
||||
```
|
||||
|
||||
**Shizuku Provider (v12+ обязательно, см. ниже §5.3.1):**
|
||||
|
||||
```xml
|
||||
<provider
|
||||
android:name="rikka.shizuku.ShizukuProvider"
|
||||
android:authorities="${applicationId}.shizuku"
|
||||
android:multiprocess="false"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||
```
|
||||
|
||||
**PhoneApp (`app-phone-vm/AndroidManifest.xml`):**
|
||||
@@ -934,9 +947,57 @@ class AudioPlaybackService : Service() {
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <!-- для BLE scan на Android 6-11 -->
|
||||
<!-- Shizuku permission (необязательно, у нас только Mercury-канал) -->
|
||||
<uses-permission android:name="moe.shizuku.permission.SHIZUKU" />
|
||||
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
|
||||
```
|
||||
|
||||
### 5.3.1. Интеграция с Shizuku
|
||||
|
||||
**Цель.** GlassesApp использует Shizuku (см. память #4779) как обёртку над `IDisplayManager.createVirtualDisplay` без флага `OWN_CONTENT_ONLY` — единственный способ создать VirtualDisplay, на который можно стартовать чужие Activity (`ActivityOptions.setLaunchDisplayId`).
|
||||
|
||||
**Зависимости (`gradle/libs.versions.toml`):**
|
||||
|
||||
```toml
|
||||
shizukuApi = "13.1.5"
|
||||
shizukuProvider = "13.1.5"
|
||||
shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizukuApi" }
|
||||
shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizukuProvider" }
|
||||
```
|
||||
|
||||
В `app-glasses-vm/build.gradle.kts` — `implementation(libs.shizuku.api)` + `implementation(libs.shizuku.provider)`.
|
||||
|
||||
**Манифест — обязательная структура (см. https://github.com/RikkaApps/Shizuku-API):**
|
||||
|
||||
1. `<uses-permission android:name="moe.shizuku.manager.permission.API_V23"/>` (v12+). Старое `moe.shizuku.permission.SHIZUKU` (v11) НЕ работает — BinderSender на стороне сервера проверяет, что в `requestedPermissions` есть именно `API_V23`.
|
||||
2. `<provider android:name="rikka.shizuku.ShizukuProvider" android:authorities="${applicationId}.shizuku" android:exported="true" android:permission="android.permission.INTERACT_ACROSS_USERS_FULL"/>` — этот ContentProvider из либы `rikka.shizuku:provider` через свой `onCreate` сам вытаскивает binder у Shizuku-сервера через ContentProvider IPC. **Без него binder к нам никогда не придёт.** Кастомные ContentProvider'ы (например, наш `ShizukuInitProvider`) этого **не заменяют** — у `rikka.shizuku.ShizukuProvider` специальные системные AIDL-хуки.
|
||||
3. `<queries><package android:name="moe.shizuku.privileged.api"/></queries>` — для Android 11+ package visibility (`bindService` к сервису внутри пакета Shizuku-менеджера иначе возвращает false).
|
||||
|
||||
**Activity-side (MainActivity.onCreate, строго после `super.onCreate`):**
|
||||
|
||||
```kotlin
|
||||
Shizuku.addBinderReceivedListenerSticky(object : Shizuku.OnBinderReceivedListener {
|
||||
override fun onBinderReceived() { refresh() }
|
||||
})
|
||||
Shizuku.addRequestPermissionResultListener { _, grant -> refresh() }
|
||||
if (Shizuku.pingBinder()) refresh() else state.value = AllowState.Waiting
|
||||
// где refresh():
|
||||
// running = Shizuku.pingBinder(); perm = Shizuku.checkSelfPermission()
|
||||
// если perm == GRANTED -> Granted, иначе NeedsPermission + requestPermission(code)
|
||||
```
|
||||
|
||||
И override `onRequestPermissionsResult(...)` в Activity (нужен для прокидывания результата диалога).
|
||||
|
||||
**Запрос разрешения и диалог.** Когда пользователь первый раз запускает GlassesApp:
|
||||
1. `Shizuku.checkSelfPermission()` возвращает `PERMISSION_DENIED`.
|
||||
2. `Shizuku.requestPermission(code)` → Shizuku-сервер стартует `RequestPermissionActivity` от `moe.shizuku.privileged.api`.
|
||||
3. Activity показывает диалог «Allow rayneo-vm to use Shizuku?». После Allow — `onRequestPermissionResult` выстреливает, `refresh()` ставит state=Granted.
|
||||
|
||||
**Race condition на RayNeo X3 Pro (см. память #4778).** Системный лаунчер `com.ffalconxr.mercury.launcher` (PID 24482, UID 1000) **иногда** принудительно убивает (`forceStopPackage`) только что запущенную `RequestPermissionActivity` в течение ~14мс. То есть диалог либо показывается, либо нет — гонка. Workaround: при `state==NeedsPermission` после рестарта активности пользователь тапнет ещё раз. В коде: в `refresh()` если `requestPermission()` бросил — повторить на следующем `onResume()`.
|
||||
|
||||
**Что НЕ работает:**
|
||||
- Кастомный ContentProvider вместо `rikka.shizuku.ShizukuProvider` — binder не придёт.
|
||||
- `addBinderReceivedListener` без `Sticky` — пропустим событие, если binder пришёл до того, как мы зарегистрировались.
|
||||
- `requestPermission()` без Activity-контекста — сервер стартует Activity, но если она умерла до того, как пользователь нажал Allow, результат не вернётся.
|
||||
|
||||
### 5.4. Дефолты (фиксировано для ночной сессии)
|
||||
|
||||
**Версии — latest stable на сентябрь 2026 (по правилу «брать последнее по возможности»):**
|
||||
|
||||
@@ -8,6 +8,7 @@ lifecycle = "2.11.0"
|
||||
activityCompose = "1.11.0"
|
||||
composeBom = "2026.09.00"
|
||||
shizukuApi = "13.1.5"
|
||||
shizukuProvider = "13.1.5"
|
||||
navCompose = "2.10.2"
|
||||
mercury = "0.1.0-SNAPSHOT"
|
||||
mercuryTransport = "0.1.0-SNAPSHOT"
|
||||
@@ -45,6 +46,7 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version = "1.15.0" }
|
||||
|
||||
# Shizuku
|
||||
shizuku-api = { module = "dev.rikka.shizuku:api", version.ref = "shizukuApi" }
|
||||
shizuku-provider = { module = "dev.rikka.shizuku:provider", version.ref = "shizukuProvider" }
|
||||
|
||||
# rayneo-mercury (библиотеки)
|
||||
mercury-library = { module = "pw.binom.mercury:rayneo-mercury", version.ref = "mercury" }
|
||||
|
||||
@@ -74,6 +74,22 @@ enum class AudioMode { ON_GLASSES, ON_PHONE }
|
||||
@SerialName("shutdown")
|
||||
data object Shutdown : VmMessage
|
||||
|
||||
@Serializable
|
||||
@SerialName("get_app_list")
|
||||
data object GetAppList : VmMessage
|
||||
|
||||
@Serializable
|
||||
data class AppEntry(
|
||||
val packageName: String,
|
||||
val displayName: String,
|
||||
) : VmMessage
|
||||
|
||||
@Serializable
|
||||
@SerialName("app_list")
|
||||
data class AppListResponse(
|
||||
val apps: List<AppEntry>,
|
||||
) : VmMessage
|
||||
|
||||
// =====================================================================
|
||||
// Glasses → Phone
|
||||
// =====================================================================
|
||||
|
||||
Reference in New Issue
Block a user