Compare commits

...

6 Commits

Author SHA1 Message Date
subochev 5afdde70e1 build: add .gitignore (build, .gradle, .idea, .cortexkit) 2026-09-25 15:22:43 +03:00
subochev 42f117796a glasses: ShizukuBootstrap rewrite per docs (sticky listener + activity context)
Rewrite the Shizuku integration to follow https://github.com/RikkaApps/Shizuku-API:

- attach(activity) instead of attach(): requestPermission() must run from
  an Activity context (Manager's RequestPermissionActivity is a normal
  Activity started from ours).
- addBinderReceivedListenerSticky: ensures our listener fires even if the
  binder was already received before we attached (race against the
  ShizukuProvider init).
- addRequestPermissionResultListener: routes the dialog's allow/deny
  back into the StateFlow.
- onRequestPermissionResult(requestCode, grant): helper for the Activity
  to forward the system callback.
- pingBinder / checkSelfPermission / requestPermission gates follow the
  state machine from the docs (Waiting → NeedsPermission → Granted).

Move attach() out of GlassesVmApp.onCreate (no Activity context there)
into MainActivity.onCreate after super.onCreate.
2026-09-25 15:21:34 +03:00
subochev 86bffed6ec phone: dynamic app list from glasses + two-channel GlassesStatusScreen
Phone app:
- VmPhoneController: collect appsOnGlasses from glasses (state flow), and
  requestAppList() sends VmMessage.GetAppList over WebSocket.
- AppsListScreen: rename to dynamic list from glasses, verticalScroll,
  tap → LaunchAppCommand(packageName). Tap calls onAppClick for parent
  nav (Glasses tab on the bottom NavigationBar).
- Shared VM message types (VmMessage.GetAppList / AppListResponse /
  AppEntry) move into :shared module so both phone and glasses can
  serialize/deserialize them through the existing VmMessage.Json

Glasses app:
- VmService handles GetAppList by listing installed launchable apps
  via PackageManager.queryIntentActivities (combined with Android 11+
  <queries> fix in the previous commit so 3rd-party apps appear) and
  replies with AppListResponse.
- GlassesScreen drives state from controller.state; no longer hardcodes
  any apps in the UI.

Tested end-to-end on A06B4AB933C4103: phone taps app on
AppsListScreen → glasses' VmService receives LaunchAppCommand →
controller.launchGuest(pkg) launches the activity via
PackageManager.getLaunchIntentForPackage + FLAG_ACTIVITY_NEW_TASK.
2026-09-25 15:21:14 +03:00
subochev cc5866b373 glasses: list all installed launchable apps on phone (Android 11+ queries)
- Add <intent MAIN+LAUNCHER> to <queries> so queryIntentActivities on the
  glasses side returns third-party apps (free.zona etc), not only system /
  RayNeo system apps. Without this entry Android 11+ package visibility
  filter strips apps whose package is not explicitly named.
- Pass MATCH_ALL | GET_RESOLVED_FILTER to queryIntentActivities as a
  belt-and-suspenders measure (no visible effect with queries above, but
  safe on Android 12+ where the platform may still filter).
- Default VmGlassesController state is NoAppSelected instead of Starting,
  so we don't show 'Подготавливаем очки...' on first launch when no
  LaunchAppCommand has been received yet.
- DriveShizukuState leaves Running state alone on re-check (don't drop
  a running guest back to ShizukuMissing just because Shizuku was
  re-polled).
2026-09-25 15:19:57 +03:00
subochev ca01a7b918 docs: SPEC §5.3.1 — document Shizuku v12+ integration on RayNeo X3 Pro
- Replace obsolete moe.shizuku.permission.SHIZUKU (v11) with the v12+
  moe.shizuku.manager.permission.API_V23 in the GlassesApp permission list.
- Document the rikka.shizuku.ShizukuProvider provider block required by
  the Shizuku-API guide; without it the binder never arrives.
- Add §5.3.1 'Интеграция с Shizuku' explaining:
  * why dev.rikka.shizuku:provider is required (ContentProvider.onCreate
    pulls the binder via IPC);
  * the activity-side listener pattern (addBinderReceivedListenerSticky +
    addRequestPermissionResultListener);
  * the RayNeo system launcher race condition (PID 24482
    com.ffalconxr.mercury.launcher, UID system, forceStopPackage on
    RequestPermissionActivity within ~14ms).
2026-09-25 14:47:12 +03:00
subochev e01fafd5a9 glasses: switch to rikka.shizuku.ShizukuProvider, fix binder acquisition
- Add dev.rikka.shizuku:provider:13.1.5 dependency.
- Replace custom ShizukuInitProvider with rikka.shizuku.ShizukuProvider
  from the upstream library; configured per Shizuku v12+ docs with
  android:permission='android.permission.INTERACT_ACROSS_USERS_FULL'
  to protect the provider from ordinary apps.
- Move ShizukuBootstrap.attach() from GlassesVmApp.onCreate to
  MainActivity.onCreate (requestPermission needs an Activity context).
- MainActivity now overrides onRequestPermissionsResult and forwards
  the grant to ShizukuBootstrap.
- Rewrite ShizukuBootstrap strictly per the Shizuku-API guide:
  addBinderReceivedListenerSticky + addRequestPermissionResultListener
  in attach(); pingBinder / checkSelfPermission / requestPermission
  in refresh().
- Drop ShizukuInitProvider.kt — its job is now done by
  rikka.shizuku.ShizukuProvider.onCreate (verified in logcat:
  'ShizukuProvider: binder received').

Verified on device ARGF20 (A06B4AB933C4103, API 32): Shizuku dialog
'Allow rayneo-vm to use Shizuku?' opens correctly; binder received
via ShizukuProvider; launcher icon now visible (meta-data fix).
2026-09-25 14:44:52 +03:00
14 changed files with 526 additions and 77 deletions
+4
View File
@@ -0,0 +1,4 @@
build
.cortexkit
.idea
.gradle
+1
View File
@@ -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)
+34 -1
View File
@@ -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"
@@ -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,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
}
@@ -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,73 +39,83 @@ 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 {
coroutineScope.launch {
controller.send(LaunchAppCommand(packageName = app.packageName))
}
onAppClick(app.packageName)
},
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = Color(0xFF1A1A1A),
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = app.name,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
text = app.description,
color = Color(0xFFB0B0B0),
fontSize = 12.sp,
)
if (isRunning && glasses?.currentApp == app.packageName) {
Text(
text = "● запущено",
color = Color(0xFF4CAF50),
fontSize = 12.sp,
)
}
AppCard(app, isRunning && glasses?.currentApp == app.packageName) {
coroutineScope.launch {
controller.send(LaunchAppCommand(packageName = app.packageName))
}
onAppClick(app.packageName)
}
}
}
}
private data class AppEntry(
val packageName: String,
val name: String,
val description: String,
)
@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.displayName,
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
)
Text(
text = app.packageName,
color = Color(0xFFB0B0B0),
fontSize = 12.sp,
)
if (running) {
Text(
text = "● запущено",
color = Color(0xFF4CAF50),
fontSize = 12.sp,
)
}
}
}
}
+64 -3
View File
@@ -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 (по правилу «брать последнее по возможности»):**
+2
View File
@@ -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
// =====================================================================