Compare commits

...

8 Commits

Author SHA1 Message Date
subochev 28d1c02062 очки: HUD (время+заряд) сверху по центру обоих глаз, всегда поверх
Панель перенесена из view-mate (StatusPanel) как часть активити, без виджета:
StatusHud + rememberStatusHudData в :app-glasses-vm/ui. Слой добавляется в
GlassesScreen поверх when(state) внутри Binocular, поэтому виден в любом
состоянии — ожидание/нет связи/запущенный гость — и дублируется в обе
половины (левый и правый глаз).
2026-10-01 17:50:22 +03:00
subochev 65782abfdb клавиатура: подавить системный IME на очках + ввод текста через Accessibility 2026-10-01 17:40:28 +03:00
subochev c02e126bc6 очки: не гасить экран при закрытии посторонней WS-сессии
Экран залипал в «Нет связи», хотя телефон был подключён и команды
доходили. Причина: currentConn — одна ссылка на все сессии, и ЛЮБОЙ
onDisconnected переводил экран в NoConnection. Лишние сессии создаёт
в т.ч. mDNS-фоллбек телефона (probeWebSocket делает полный WS-апгрейд
для проверки кандидата и сразу рвёт соединение).

Теперь очки ведут набор живых сессий и гасят экран только когда
закрылась последняя.
2026-10-01 14:10:34 +03:00
subochev 8be9afb592 аудио: убрать прогрев кодера — Concentus деградировал в ~30x
Прогрев 30 кадров тишины на старте переводил Concentus в патологическое
состояние: enc_us вырастал с ~2.6мс до ~88мс на кадр (113% CPU), кодер не
успевал, терялось ~85% кадров -> «полная жопа» в звуке. Подтверждено A/B:
без прогрева enc_us~2.6мс, dropped единицы, пробник принимает ровно 100/с.
2026-10-01 12:18:40 +03:00
subochev 20d0d05fb4 аудио: убрать потери кадров (Concentus не успевал); экран — POWER
- Opus complexity 8->3, отключён inband FEC: кодер выдавал ~60 кадров/с при 100
  захваченных -> 40% кадров терялось в очереди (DROP_OLDEST) = «хрип».
  Стало enc_us~3мс на 10-мс кадр, dropped перестал расти.
- Прогрев кодера тишиной на старте (JIT Concentus) — убирает стартовый всплеск потерь.
- Диагностика в логе: enc_us / snd_us.
- ScreenControl идемпотентен (POWER — переключатель): сверяемся с реальным isInteractive.
- Зависимость shizuku-vd 0.1.1 -> 0.1.2 (POWER-инъекция).
2026-10-01 12:10:30 +03:00
subochev 4743427756 экран очков: единый тумблер на «Пульте» + очки шлют реальное состояние экрана
- shared: новое сообщение ScreenState(on) от очков телефону
- glasses: слушаем ACTION_SCREEN_ON/OFF (PowerManager.isInteractive), шлём состояние
  при подключении телефона и на каждое изменение; после ScreenControl — актуализация
- phone: VmPhoneController.screenOn (StateFlow<Boolean?>), оптимистичное обновление в setScreen
- phone: на экране «Пульт» одна кнопка-тумблер «Погасить/Разбудить экран очков»,
  подпись отражает реальное состояние, disabled без Канала 1
2026-10-01 11:26:37 +03:00
subochev de6c648c35 аудио: убрать «просадки» — запас буферов + PLC на пропусках
- glasses: очередь capture→Opus 4→12 кадров (джиттер планировщика больше не роняет кадры
  до присвоения seq, отчего телефон не видел потерь); буфер AudioRecord 2→4 кадра
- glasses: диагностика captured/encoded/dropped в логе (видно потери на источнике)
- phone: AudioTrack-буфер 1→4 кадра (редкие underrun'ы давали щелчки); на пропуске seq
  вставляем PLC-кадры (было — сразу склейка); в лог добавлен underruns
2026-10-01 05:58:50 +03:00
subochev d4e1b66924 аудио UDP+Opus (очки→телефон), жесты дужки → гость, «убить всё лишнее» на очках
- shared/audio: вендорнут Concentus (libs/concentus.jar), OpusCodec/AudioDatagram/AudioConfig;
  захват на очках 48кГц стерео → Opus → UDP-датаграммы вместо сырого PCM по WebSocket
- glass.audio: AudioRecord 48кГц с минимальным буфером; UDP-сервер запоминает адрес пира из датаграммы
- glasses: жесты тач-сенсора дужки (cyttsp5_mt) через dispatchTouchEvent → DPAD/BACK гостю (TempleGesture*)
- glasses: forceStopAll — force-stop сторонних + xr.runtime, kill scrcpy-сирот через Shizuku UserService
- phone: AudioPlaybackManager на UDP+Opus+low-latency AudioTrack; кнопка «Убить всё лишнее» на вкладке очков
- deps: mercury transport 0.1.2-SNAPSHOT, shizuku-vd 0.1.1-SNAPSHOT
2026-10-01 05:54:54 +03:00
25 changed files with 1249 additions and 147 deletions
+2 -1
View File
@@ -100,7 +100,8 @@
<service
android:name=".VmNoopImeService"
android:exported="true"
android:label="@string/app_name">
android:label="@string/app_name"
android:permission="android.permission.BIND_INPUT_METHOD">
<intent-filter>
<action android:name="android.view.InputMethod" />
</intent-filter>
@@ -7,6 +7,8 @@ import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.util.Log
import android.view.Display
import android.view.InputDevice
import android.view.MotionEvent
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -29,6 +31,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import pw.binom.rayneovm.glasses.audio.AudioBridge
import pw.binom.rayneovm.glasses.input.TempleGestureDetector
import pw.binom.rayneovm.glasses.protocol.VmGlassesController
import pw.binom.rayneovm.glasses.ui.GlassesScreen
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
@@ -38,6 +41,26 @@ class MainActivity : ComponentActivity() {
/** Последний обработанный tick запроса проекции (см. onCreate). */
private var handledTick = 0L
/** Контроллер очков — нужен и Compose, и распознавателю жестов дужки. */
private lateinit var controller: VmGlassesController
/**
* Распознаватель жестов тач-сенсора дужки. Дужка на X3 Pro — отдельное input-устройство
* `cyttsp5_mt` без ассоциированного дисплея, поэтому события не доходят до Compose и
* перехватываются в [dispatchTouchEvent]. Жесты уходят гостю через
* [VmGlassesController.sendTempleGesture].
*/
private val templeGestureDetector by lazy {
TempleGestureDetector { gesture -> controller.sendTempleGesture(gesture) }
}
/** id input-устройства дужки (`cyttsp5_mt`); -1 — не найдено. */
private val templeDeviceId: Int by lazy {
InputDevice.getDeviceIds().firstOrNull { id ->
InputDevice.getDevice(id)?.name?.contains(TEMPLE_DEVICE_HINT) == true
} ?: -1
}
/**
* Пользовательский диалог MediaProjection. При выставленном appop `PROJECT_MEDIA = allow`
* он не показывается — интент сразу возвращает RESULT_OK (нужно для задачи #2).
@@ -71,6 +94,13 @@ class MainActivity : ComponentActivity() {
}
enableEdgeToEdge()
ShizukuBootstrap.attach(this)
// Диагностика: печатаем input-устройства и id дужки, чтобы в логе было видно,
// куда приходят жесты (см. dispatchTouchEvent).
InputDevice.getDeviceIds().forEach { id ->
val d = InputDevice.getDevice(id)
Log.i(TAG, "input device id=$id name=${d?.name} sources=0x${d?.sources?.toString(16)}")
}
Log.i(TAG, "temple device id=$templeDeviceId (hint='$TEMPLE_DEVICE_HINT')")
// Запросы MediaProjection приходят из VmService через AudioBridge (задача #2).
// Запускаем интент только когда Activity в RESUMED (реально видима): если её запускать
// из не-foreground состояния, системная grant-активити отменяется и возвращает CANCELED.
@@ -89,6 +119,7 @@ class MainActivity : ComponentActivity() {
}
}
val controller = VmGlassesController.fromContext(this)
this.controller = controller
setContent {
CompositionLocalProvider(VmGlassesController.Local provides controller) {
@@ -116,6 +147,29 @@ class MainActivity : ComponentActivity() {
ShizukuBootstrap.refresh()
}
/**
* Перехватываем тач-события окна ДО Compose/View-иерархии. События с устройства дужки
* (`cyttsp6_mt`) не ассоциированы с дисплеем и иначе теряются: `onTouchEvent` Activity
* при этом может не вызываться вовсе. Всё, что пришло с дужки, отдаём распознавателю и
* потребляем, чтобы не утекло в UI.
*/
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
// Диагностика: на ACTION_DOWN печатаем устройство — видно, доходят ли касания дужки
// (deviceId тача дужки = 4, `cyttsp5_mt`) до окна Activity вообще.
if (event.actionMasked == MotionEvent.ACTION_DOWN) {
Log.d(
TAG,
"touch down: deviceId=${event.deviceId} source=0x${event.source.toString(16)} " +
"x=${event.x} y=${event.y}",
)
}
val isTemple = event.deviceId == templeDeviceId && templeDeviceId != -1
if (!isTemple) return super.dispatchTouchEvent(event)
Log.d(TAG, "temple: action=${event.actionMasked} x=${event.x} y=${event.y}")
templeGestureDetector.onTouchEvent(event)
return true
}
override fun onPause() {
AudioBridge.activityResumed = false
super.onPause()
@@ -142,6 +196,13 @@ class MainActivity : ComponentActivity() {
companion object {
private const val TAG = "MainActivity"
/**
* Имя input-устройства тач-сенсора дужки RayNeo X3 Pro (проверено через `getevent`:
* все касания дужки приходят на `/dev/input/event2`, устройство `cyttsp5_mt`).
* Второй контроллер `cyttsp6_mt` не генерирует событий.
*/
private const val TEMPLE_DEVICE_HINT = "cyttsp5_mt"
}
}
@@ -2,30 +2,30 @@ package pw.binom.rayneovm.glasses
import android.accessibilityservice.AccessibilityService
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.util.Log
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
/**
* Пассивный Accessibility-сервис GlassesApp.
* Accessibility-сервис GlassesApp.
*
* Зачем: Accessibility-сервис — единственный «легальный» способ для обычного
* приложения (без root/Shizuku/system-app) получить:
* - живучесть процесса: систему сама поднимает и перезапускает сервис,
* - возможность открывать Activity из фона,
* - [onAccessibilityEvent] (в будущем — для отслеживания фокуса ввода и клавиатуры, задача #4).
* Зачем:
* - живучесть процесса: система сама поднимает и перезапускает сервис;
* - «легальный» запуск Activity из фона;
* - ввод текста в поле гостя на VirtualDisplay ([setText], ACTION_SET_TEXT) — IME на
* этих очках сменить нельзя (ROM жёстко держит LatinIME), поэтому системная
* клавиатура гостя отключается, а текст приходит из телефона сюда.
*
* ВАЖНО: сервис НИЧЕГО не делает с чужими окнами — не читает содержимое
* ([android:canRetrieveWindowContent] = false) и не инжектит события. Это
* сознательно: минимум привилегий, минимум риска сломать ввод в гостевых приложениях.
*
* Регистрируется в манифесте через [VM_ACCESSIBILITY_SERVICE] с конфигом
* `res/xml/vm_accessibility_service.xml`. Активируется пользователем вручную:
* Настройки → Специальные возможности → RayNeo VM.
* Регистрируется в манифесте с конфигом `res/xml/vm_accessibility_service.xml`.
* Активируется пользователем вручную: Настройки → Специальные возможности → RayNeo VM.
*/
class VmAccessibilityService : AccessibilityService() {
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
Log.i(TAG, "Accessibility-сервис подключён")
// Авто-старт: если основное приложение ещё не поднято — поднимаем его.
// Это и есть «легальный авто-запуск» из задачи #5.
@@ -37,17 +37,97 @@ class VmAccessibilityService : AccessibilityService() {
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// Намеренно пусто. Слушаем только для того, чтобы сервис считался живым.
// Тут позже появится логика задачи #4 (фокус поля ввода → скрыть IME гостя).
// Намеренно пусто: слушаем только чтобы сервис считался живым.
}
override fun onInterrupt() {
// Ничего: сервис не выполняет длительных операций.
}
override fun onUnbind(intent: Intent?): Boolean {
if (instance === this) instance = null
return super.onUnbind(intent)
}
override fun onDestroy() {
if (instance === this) instance = null
super.onDestroy()
}
/** Заменяет текст в сфокусированном поле ввода гостя. false — если поля в фокусе нет. */
private fun applyText(text: String): Boolean {
val node = findFocusedInputNode() ?: return false
return try {
val args = Bundle().apply {
putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, text)
}
val ok = node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
if (!ok) Log.w(TAG, "ACTION_SET_TEXT вернул false")
ok
} catch (t: Throwable) {
Log.w(TAG, "ACTION_SET_TEXT упал", t)
false
}
}
/**
* Ищет сфокусированный узел ввода: сначала в активном окне, затем во всех
* интерактивных окнах (в т.ч. окно гостя на VirtualDisplay).
*/
private fun findFocusedInputNode(): AccessibilityNodeInfo? {
val active = rootInActiveWindow
val activeFocus = active?.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
Log.i(
TAG,
"findFocused: activeRoot=${active?.packageName} focus=${activeFocus?.className} " +
"editable=${activeFocus?.isEditable}",
)
if (activeFocus?.isEditable == true) return activeFocus
findEditable(active)?.let {
Log.i(TAG, "findFocused: редактируемый узел в активном окне: ${it.className}")
return it
}
val ws = windows
Log.i(TAG, "findFocused: окон=${ws?.size ?: 0}")
ws?.forEach { w ->
val root = w.root ?: return@forEach
val focus = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT)
Log.i(
TAG,
" окно type=${w.type} pkg=${root.packageName} focus=${focus?.className} editable=${focus?.isEditable}",
)
if (focus?.isEditable == true) return focus
findEditable(root)?.let {
Log.i(TAG, " редактируемый узел: ${it.className} pkg=${root.packageName}")
return it
}
}
return null
}
/** Первый редактируемый (EditText-подобный) потомок в дереве. */
private fun findEditable(root: AccessibilityNodeInfo?): AccessibilityNodeInfo? {
root ?: return null
if (root.isEditable) return root
for (i in 0 until root.childCount) {
findEditable(root.getChild(i))?.let { return it }
}
return null
}
companion object {
private const val TAG = "VmAccessibilitySvc"
/** Активный экземпляр сервиса (в том же процессе, что и [VmService]). */
@Volatile
private var instance: VmAccessibilityService? = null
/** Ввести [text] в текущее поле ввода гостя. false — если поля в фокусе нет. */
fun setText(text: String): Boolean {
if (text.isEmpty()) return true
return instance?.applyText(text) ?: false
}
/** Действие для запроса состояния сервиса/проверки активности (диагностика). */
const val ACTION_PING = "pw.binom.rayneovm.glasses.ACCESSIBILITY_PING"
}
@@ -4,14 +4,20 @@ import android.inputmethodservice.InputMethodService
import android.util.Log
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
/**
* Пустой IME — глушилка системного IME гостя на нашем VirtualDisplay.
*
* Когда это дефолтный IME очков — гость (Zona, ...) не показывает свою клавиатуру поверх
* нашего приложения: InputMethodService.showInputRequestedAndUpdated → false (нет UI).
* нашего приложения: [onCreateInputView] возвращает null (нет UI).
*
* Активируется через Shizuku/binder: `settings put secure default_input_method
* Заодно сервис служит каналом ввода текста: только IME держит актуальный [InputConnection]
* сфокусированного поля гостя. Сервис живёт в том же процессе, что и [VmService], поэтому
* текст с телефона ([SetText]) доставляется напрямую через [commit] — без раскладки
* и без кросс-процессного доступа к чужому InputConnection.
*
* Активируется через Shizuku: `settings put secure default_input_method
* pw.binom.rayneovm.glasses/.VmNoopImeService`. Возврат к штатному IME не предусмотрен.
*/
class VmNoopImeService : InputMethodService() {
@@ -21,15 +27,44 @@ class VmNoopImeService : InputMethodService() {
return null
}
override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
Log.i(TAG, "onStartInputView: info=$info restarting=$restarting")
// UI не показываем (onCreateInputView == null). Фокус отдаём гостю, как обычно —
// его кастомные EditText-ы получают inputType как обычно.
override fun onStartInput(attribute: EditorInfo?, restarting: Boolean) {
super.onStartInput(attribute, restarting)
connection = currentInputConnection
Log.i(TAG, "onStartInput: conn=${connection != null} restarting=$restarting")
}
override fun onFinishInputView(finishedInput: Boolean) {}
override fun onStartInputView(info: EditorInfo?, restarting: Boolean) {
super.onStartInputView(info, restarting)
connection = currentInputConnection
Log.i(TAG, "onStartInputView: conn=${connection != null}")
}
override fun onFinishInput() {
connection = null
super.onFinishInput()
}
companion object {
private const val TAG = "VmNoopIme"
/**
* Активный [InputConnection] гостя. Наш IME — дефолтный на очках и живёт в том же
* процессе, что и контроллер, поэтому текст инжектится напрямую через этот объект.
*/
@Volatile
private var connection: InputConnection? = null
/** Доставляет [text] в текущее поле ввода гостя. false — если поля в фокусе нет. */
fun commit(text: String): Boolean {
if (text.isEmpty()) return true
val c = connection ?: return false
return try {
c.commitText(text, 1)
true
} catch (t: Throwable) {
Log.w(TAG, "commitText failed", t)
false
}
}
}
}
@@ -4,14 +4,17 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ServiceInfo
import android.media.AudioManager
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
@@ -38,6 +41,7 @@ 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.ForceStopAll
import pw.binom.rayneovm.shared.AppEntry
import pw.binom.rayneovm.shared.AppListResponse
import pw.binom.rayneovm.shared.CursorScroll
@@ -45,7 +49,11 @@ import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.ScreenState
import pw.binom.rayneovm.shared.ShizukuStatus
import pw.binom.rayneovm.shared.audio.AudioConfig
import pw.binom.rayneovm.shared.audio.AudioDatagram
import pw.binom.rayneovm.shared.audio.OpusAudioEncoder
/**
* Foreground service на стороне очков.
@@ -73,19 +81,33 @@ class VmService : LifecycleService() {
private var audioCapture: GlassesAudioCapture? = null
private var audioEnsureJob: kotlinx.coroutines.Job? = null
/** Opus-кодер: используется только из аудио-пампа, поэтому не нужна синхронизация. */
private val audioEncoder = OpusAudioEncoder()
/** seq аудио-датаграмм; оборачивается через переполнение Int — это нормально. */
private var audioSeq = 0
@Volatile
private var audioMode: AudioMode = AudioMode.ON_GLASSES
/**
* Очередь кадров PCM между потоком чтения [AudioRecord] и отправкой в сокет.
* При переполнении выкидываем самый старый кадр — для live-аудио важнее свежесть,
* чем полнота (иначе задержка растёт безгранично).
* Очередь кадров PCM (interleaved shorts 48 кГц stereo) между потоком чтения
* [AudioRecord] и Opus-кодером.
*
* Ёмкость — запас на джиттер планировщика/GC/кодера; в норме очередь почти пуста, так что
* на задержку это не влияет. DROP_OLDEST остаётся аварийным клапаном, но при ёмкости 12
* (120 мс) срабатывает куда реже, чем при 4 (40 мс), где любой чих кодировщика рождал
* слышимый «провал» (кадр выкидывался до присвоения seq — телефон этого даже не замечал).
*/
private val audioFrames = Channel<ByteArray>(
capacity = 16,
private val audioFrames = Channel<ShortArray>(
capacity = 12,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Диагностика потерь на очках: сколько кадров сняли с AudioRecord и сколько закодировали. */
private val audioCaptured = java.util.concurrent.atomic.AtomicInteger()
private val audioEncoded = java.util.concurrent.atomic.AtomicInteger()
override fun onCreate() {
super.onCreate()
instance = this
@@ -100,6 +122,7 @@ class VmService : LifecycleService() {
startAudioServer()
startAudioPump()
registerMdns()
registerScreenReceiver()
// Сообщаем телефону о Shizuku, как только меняется его состояние (сервер поднялся
// или нам выдали permission) — чтобы телефон не опрашивал вручную.
lifecycleScope.launch {
@@ -151,6 +174,19 @@ class VmService : LifecycleService() {
*/
private val currentConn = java.util.concurrent.atomic.AtomicReference<String?>(null)
/**
* Все живые WS-сессии. Связь с телефоном считаем живой, пока жива ХОТЬ ОДНА.
*
* Нельзя вешать `NoConnection` на закрытие конкретной сессии: помимо телефона сюда
* прилетают короткоживущие сессии (mDNS-фоллбек телефона делает полный WS-апгрейд
* для проверки кандидата и сразу рвёт соединение). Из-за этого экран очков залипал
* в «Нет связи», хотя телефон был подключён и команды доходили.
*/
private val liveConns = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
/** Ресивер `ACTION_SCREEN_ON/OFF`: сообщаем телефону реальное состояние экрана очков. */
private var screenReceiver: BroadcastReceiver? = null
private fun startServer() {
server = Transports.wifiServer(port = WS_PORT, path = WS_PATH)
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
@@ -160,19 +196,26 @@ class VmService : LifecycleService() {
handleIncoming(connId, json)
},
onConnected = { connId ->
Log.i(TAG, "Phone connected: $connId")
liveConns.add(connId)
Log.i(TAG, "Phone connected: $connId (всего сессий: ${liveConns.size})")
currentConn.set(connId)
// Переподключение: снять залипшее «Нет связи с телефоном».
controller.onPhoneConnected()
// И сразу отдать телефoну состояние Shizuku (для ADB-бутстрапа).
lifecycleScope.launch { sendShizukuStatus(connId) }
// ...и реальное состояние экрана (для кнопки-тумблера на телефоне).
lifecycleScope.launch { sendScreenState(connId) }
},
onDisconnected = { connId ->
Log.i(TAG, "Phone disconnected: $connId")
if (currentConn.compareAndSet(connId, null)) {
liveConns.remove(connId)
Log.i(TAG, "Session closed: $connId (осталось: ${liveConns.size})")
// «Push»-сессию переводим на любую живую, если ушла именно она.
if (currentConn.get() == connId) {
currentConn.set(liveConns.firstOrNull())
}
// Экран гасим ТОЛЬКО когда не осталось ни одной сессии.
if (liveConns.isEmpty()) {
controller.setState(GlassesScreenState.NoConnection)
} else {
Log.i(TAG, "Ignoring disconnect of stale session $connId")
}
},
)
@@ -218,8 +261,20 @@ class VmService : LifecycleService() {
applyAudioMode(msg.mode)
}
is ScreenControl -> {
Log.i(TAG, "ScreenControl: sleep=${msg.sleep}")
controller.setScreen(msg.sleep)
val wantOn = !msg.sleep
val isOn = currentScreenOn()
Log.i(TAG, "ScreenControl: sleep=${msg.sleep} (сейчас on=$isOn)")
if (isOn == wantOn) {
// POWER — переключатель; если цель уже достигнута, инжектить нельзя (моргнём).
lifecycleScope.launch { pushScreenState() }
} else {
controller.setScreen(msg.sleep)
// Экран переключается чуть позже инжекта — сообщаем телефону актуальное состояние.
lifecycleScope.launch {
kotlinx.coroutines.delay(600)
pushScreenState()
}
}
}
is SetText -> {
Log.i(TAG, "SetText: displayId=${msg.displayId} len=${msg.text.length}")
@@ -230,6 +285,10 @@ class VmService : LifecycleService() {
Log.i(TAG, "Shutdown")
controller.stopGuest()
}
is ForceStopAll -> {
Log.i(TAG, "ForceStopAll")
controller.forceStopAll()
}
is GetAppList -> {
Log.i(TAG, "GetAppList")
val apps = listInstalledLaunchableApps()
@@ -250,20 +309,26 @@ class VmService : LifecycleService() {
// ---------------- Аудио-мост (задача #2) ----------------
/** Сервер байтового канала `/vm-audio`: телефон подключается сюда за PCM-потоком. */
/**
* UDP-сервер аудио. Телефон шлёт сюда keepalive/подписку, мы запоминаем адрес
* источника (`ip:port`) как пира и отвечаем Opus-датаграммами.
*
* IP телефона НЕ константа — он берётся из адреса входящей датаграммы. В реальной жизни
* очки подключены к точке доступа самого телефона, адреса меняются от сессии к сессии.
*/
private fun startAudioServer() {
audioServer = Transports.wifiServerBytes(port = AUDIO_PORT, path = AUDIO_PATH)
audioServer = Transports.udpServer(port = AudioConfig.UDP_PORT)
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
audioServer!!.accept(
onMessage = { _, _ -> /* телефон в этот канал ничего не шлёт */ },
onMessage = { _, _ -> /* keepalive/подписка: содержимое не важно, важен источник */ },
onConnected = { connId ->
audioSessions.add(connId)
Log.i(TAG, "Audio client connected: $connId")
Log.i(TAG, "Audio peer connected: $connId")
},
onDisconnected = { connId ->
audioSessions.remove(connId)
Log.i(TAG, "Audio client disconnected: $connId")
Log.i(TAG, "Audio peer disconnected: $connId")
},
)
} catch (t: Throwable) {
@@ -272,18 +337,46 @@ class VmService : LifecycleService() {
}
}
/** Разбирает очередь кадров и рассылает их всем подключённым аудио-клиентам. */
/** Кодирует кадры PCM в Opus и рассылает датаграммы всем подключённым аудио-пирам. */
private fun startAudioPump() {
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
for (bytes in audioFrames) {
val packet = ByteArray(AudioConfig.MAX_DATAGRAM)
var encNanos = 0L
var sndNanos = 0L
for (pcm in audioFrames) {
val server = audioServer ?: continue
if (audioSessions.isEmpty()) continue
val t0 = System.nanoTime()
val payload = try {
audioEncoder.encode(pcm, 0, AudioConfig.FRAME_SAMPLES)
} catch (t: Throwable) {
Log.w(TAG, "opus encode failed: ${t.message}")
continue
}
val t1 = System.nanoTime()
val len = AudioDatagram.pack(audioSeq++, payload, payload.size, packet)
val datagram = packet.copyOf(len)
for (connId in audioSessions) {
try {
server.send(connId, bytes)
server.send(connId, datagram)
} catch (t: Throwable) {
Log.w(TAG, "audio send failed: ${t.message}")
}
}
val t2 = System.nanoTime()
encNanos += t1 - t0
sndNanos += t2 - t1
val encoded = audioEncoded.incrementAndGet()
if (encoded % 200 == 0) {
val captured = audioCaptured.get()
Log.i(
TAG,
"audio: captured=$captured encoded=$encoded dropped=${captured - encoded} " +
"sessions=${audioSessions.size} enc_us=${encNanos / 200 / 1000} snd_us=${sndNanos / 200 / 1000}",
)
encNanos = 0
sndNanos = 0
}
}
}
}
@@ -344,7 +437,10 @@ class VmService : LifecycleService() {
if (audioCapture != null) return true
val capture = GlassesAudioCapture(
projection = projection,
sink = { bytes -> audioFrames.trySend(bytes) },
sink = { bytes ->
audioCaptured.incrementAndGet()
audioFrames.trySend(bytes)
},
onStopped = { lifecycleScope.launch { onCaptureDied() } },
)
return if (capture.start(lifecycleScope)) {
@@ -415,6 +511,55 @@ class VmService : LifecycleService() {
sendShizukuStatus(connId)
}
/** Текущее состояние экрана очков (true — экран включён). */
private fun currentScreenOn(): Boolean =
(getSystemService(POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
/** Отправляет состояние экрана конкретной сессии (ответ на подключение / изменение). */
private suspend fun sendScreenState(connId: String) {
val on = currentScreenOn()
val json = VmProtocol.encode(ScreenState(on = on))
try {
server?.send(connId, json)
} catch (t: Throwable) {
Log.w(TAG, "Не удалось отправить ScreenState: ${t.message}")
}
Log.i(TAG, "ScreenState: on=$on")
}
/** Отправляет состояние экрана текущей активной сессии (если есть). */
private suspend fun pushScreenState() {
val connId = currentConn.get() ?: return
sendScreenState(connId)
}
/**
* Слушаем системные `ACTION_SCREEN_ON/OFF`, чтобы держать телефон в курсе реального
* состояния экрана — это питает кнопку-тумблер «экран вкл/выкл» на экране «Пульт».
*/
private fun registerScreenReceiver() {
if (screenReceiver != null) return
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val on = intent?.action == Intent.ACTION_SCREEN_ON
Log.i(TAG, "Экран ${if (on) "включён" else "погашен"}")
lifecycleScope.launch { pushScreenState() }
}
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
}
registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED)
screenReceiver = receiver
}
private fun unregisterScreenReceiver() {
val r = screenReceiver ?: return
screenReceiver = null
runCatching { unregisterReceiver(r) }
}
private fun createNotificationChannel(): String {
val channelId = "vm_service_channel"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -453,6 +598,7 @@ class VmService : LifecycleService() {
audioServer = null
audioSessions.clear()
unregisterMdns()
unregisterScreenReceiver()
server?.close()
server = null
instance = null
@@ -538,10 +684,6 @@ class VmService : LifecycleService() {
const val WS_PORT = 8080
const val WS_PATH = "/vm"
/** Отдельный порт/путь для PCM-аудио (задача #2). Телефон знает их по константе. */
const val AUDIO_PORT = 8081
const val AUDIO_PATH = "/vm-audio"
/** Интервал опроса готовности аудио-захвата (пока режим ON_PHONE). */
private const val AUDIO_ENSURE_MS = 2_500L
@@ -11,21 +11,25 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.rayneovm.shared.audio.AudioConfig
/**
* Захват системного аудио очков через [AudioPlaybackCapture] (задача #2).
*
* Требует [MediaProjection] (см. [AudioBridge]) и appop `PROJECT_MEDIA = allow`.
*
* Собранные PCM-байты отдаются в [sink] на `Dispatchers.IO`. Раз в ~1 c пишется RMS
* в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`.
* Пишет 48 кГц stereo 16-bit кадрами ровно по [AudioConfig.FRAME_MS] мс и отдаёт их
* в [sink] на `Dispatchers.IO` как `ShortArray` (interleaved). Кадр фиксированного
* размера нужен Opus-кодеру: он кодирует ровно [AudioConfig.FRAME_SAMPLES] сэмплов/канал.
*
* Раз в ~1 c пишется RMS в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`.
*
* Если [onStopped] не `null`, вызывается когда цикл захвата умер сам
* (например, [AudioRecord.read] вернул ошибку) и нужно пере-стартовать.
*/
class GlassesAudioCapture(
private val projection: MediaProjection,
private val sink: suspend (ByteArray) -> Unit,
private val sink: suspend (ShortArray) -> Unit,
/** Вызывается, если цикл чтения завершился сам (ошибка [AudioRecord]) — не при [stop]. */
private val onStopped: (() -> Unit)? = null,
) {
@@ -35,7 +39,7 @@ class GlassesAudioCapture(
fun start(scope: CoroutineScope): Boolean {
if (running) return true
val sampleRate = SAMPLE_RATE
val sampleRate = AudioConfig.SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_IN_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT
@@ -44,7 +48,9 @@ class GlassesAudioCapture(
Log.w(TAG, "getMinBufferSize=$minBuf")
return false
}
val bufferSize = minBuf * 4
// Небольшой буфер = меньшая задержка. Берём минимум HAL, но не меньше четырёх кадров
// (40 мс): при двух кадрах любой сбой планировщика/GC приводил к overrun и «просадке».
val bufferSize = maxOf(minBuf, AudioConfig.FRAME_BYTES * 4)
val rec = try {
val config = AudioPlaybackCaptureConfiguration.Builder(projection)
@@ -78,21 +84,26 @@ class GlassesAudioCapture(
running = true
rec.startRecording()
job = scope.launch(Dispatchers.IO) {
val buf = ByteArray(FRAME_BYTES)
val frame = ShortArray(AudioConfig.FRAME_SHORTS)
var filled = 0
var frameCount = 0
var totalBytes = 0L
var totalSamples = 0L
while (isActive && running) {
val read = rec.read(buf, 0, buf.size)
val read = rec.read(frame, filled, frame.size - filled)
if (read > 0) {
try {
sink(buf.copyOf(read))
} catch (t: Throwable) {
Log.w(TAG, "sink упал: ${t.message}")
}
totalBytes += read
frameCount++
if (frameCount % 40 == 0) {
Log.i(TAG, "capture: bytes=$totalBytes rms=${rms16(buf, read).toInt()}")
filled += read
if (filled == frame.size) {
try {
sink(frame.copyOf())
} catch (t: Throwable) {
Log.w(TAG, "sink упал: ${t.message}")
}
totalSamples += filled
frameCount++
if (frameCount % 100 == 0) {
Log.i(TAG, "capture: samples=$totalSamples rms=${rms16(frame).toInt()}")
}
filled = 0
}
} else if (read < 0) {
Log.w(TAG, "AudioRecord.read ошибка: $read")
@@ -105,7 +116,7 @@ class GlassesAudioCapture(
onStopped?.invoke()
}
}
Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, buffer=$bufferSize")
Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, кадр=${AudioConfig.FRAME_MS}мс, buffer=$bufferSize")
return true
}
@@ -126,23 +137,16 @@ class GlassesAudioCapture(
Log.i(TAG, "AudioPlaybackCapture остановлен (проекцию сохраняю)")
}
private fun rms16(buf: ByteArray, len: Int): Double {
if (len < 2) return 0.0
private fun rms16(buf: ShortArray): Double {
if (buf.isEmpty()) return 0.0
var sumSq = 0.0
var i = 0
while (i + 1 < len) {
val s = ((buf[i].toInt() and 0xFF) or (buf[i + 1].toInt() shl 8)).toShort().toInt()
for (s in buf) {
sumSq += s.toDouble() * s
i += 2
}
return Math.sqrt(sumSq / (len / 2))
return Math.sqrt(sumSq / buf.size)
}
companion object {
private const val TAG = "GlassesAudioCapture"
const val SAMPLE_RATE = 44100
/** ~23 мс @ 44.1 кГц 16-bit stereo — маленькая задержка без перегрузки кадрами. */
private const val FRAME_BYTES = 4096
}
}
@@ -0,0 +1,117 @@
package pw.binom.rayneovm.glasses.input
/**
* Жест, распознанный на тач-сенсоре дужки RayNeo X3 Pro.
*
* SDK RayNeo ([com.rayneo.arsdk.android.touch.TempleAction]) даёт похожий набор, но мы
* распознаём сами: логика тривиальна, а обфусцированный `TouchDispatcher` тянуть не хочется.
*
* Дужка на X3 Pro — отдельное input-устройство `cyttsp5_mt` (`/dev/input/event2`), у которого
* НЕТ ассоциированного дисплея (`AssociatedDisplay: displayId=''`), поэтому события приходят
* в окно Activity, но Compose/View-иерархия их не интерпретирует как касание. Перехватываем
* их в [android.app.Activity.dispatchTouchEvent] и превращаем в [TempleGesture].
*/
sealed interface TempleGesture {
/** Короткое одиночное касание. */
data object Click : TempleGesture
/** Два касания подряд — по умолчанию «назад» в госте. */
data object DoubleClick : TempleGesture
/**
* Завершённый свайп по дужке с итоговым смещением.
*
* @param dx смещение по X в пикселях сенсора (дужка — длина тач-зоны, ось «вдоль»).
* @param dy смещение по Y в пикселях сенсора (ось «поперёк»).
* @param velocity приблизительная скорость, px/с.
*/
data class Swipe(val dx: Float, val dy: Float, val velocity: Float) : TempleGesture
}
/**
* Распознаватель жестов тач-сенсора дужки.
*
* Питается обычными [android.view.MotionEvent], отфильтрованными по устройству дужки
* (см. `MainActivity.TEMPLE_DEVICE_HINT`). Классификация на ACTION_UP:
* - путь < [TAP_SLOP_PX] и время < [LONG_PRESS_MS] → [TempleGesture.Click];
* - путь ≥ [SLIDE_SLOP_PX] → [TempleGesture.Swipe].
*
* Двойной клик собирается по двум [TempleGesture.Click] подряд с интервалом ≤ [DOUBLE_TAP_MS]:
* второй превращается в [TempleGesture.DoubleClick], первый «съедается» ожиданием.
*
* Состояния между касаниями почти нет (только таймер двойного клика). Вызывать строго
* с UI-потока (`dispatchTouchEvent`).
*/
class TempleGestureDetector(
private val onGesture: (TempleGesture) -> Unit,
) {
private var downX = 0f
private var downY = 0f
private var downTime = 0L
/**
* Одиночный клик откладываем на [DOUBLE_TAP_MS]: если второй тап придёт в окно, первый
* превратится в [TempleGesture.DoubleClick], а не в отдельный [TempleGesture.Click].
* Без этой задержки двойной тап давал бы сначала лишний «выбор», и только потом «назад».
*/
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
private var clickPending = false
private val emitPendingClick = Runnable {
clickPending = false
onGesture(TempleGesture.Click)
}
fun onTouchEvent(event: android.view.MotionEvent) {
when (event.actionMasked) {
android.view.MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
downTime = event.eventTime
}
android.view.MotionEvent.ACTION_UP -> {
val dt = event.eventTime - downTime
val dx = event.x - downX
val dy = event.y - downY
val distance = kotlin.math.hypot(dx, dy)
if (distance < TAP_SLOP_PX && dt < LONG_PRESS_MS) {
if (clickPending) {
handler.removeCallbacks(emitPendingClick)
clickPending = false
onGesture(TempleGesture.DoubleClick)
} else {
clickPending = true
handler.postDelayed(emitPendingClick, DOUBLE_TAP_MS)
}
} else if (distance >= SLIDE_SLOP_PX) {
cancelPendingClick()
val velocity = distance / dt.coerceAtLeast(1L).toFloat() * 1000f
onGesture(TempleGesture.Swipe(dx = dx, dy = dy, velocity = velocity))
}
}
android.view.MotionEvent.ACTION_CANCEL -> {
downTime = 0L
}
}
}
private fun cancelPendingClick() {
handler.removeCallbacks(emitPendingClick)
}
companion object {
/** Движение короче этого считаем касанием, а не свайпом (в пикселях сенсора). */
const val TAP_SLOP_PX = 40f
/** Смещение, с которого касание становится свайпом. */
const val SLIDE_SLOP_PX = 80f
/** Нажатие дольше этого — не клик (пока не обрабатываем, резерв). */
const val LONG_PRESS_MS = 600L
/**
* Максимальный интервал между двумя кликами для склейки в двойной и одновременно
* задержка одиночного клика (см. [emitPendingClick]).
*/
const val DOUBLE_TAP_MS = 300L
}
}
@@ -26,7 +26,10 @@ import kotlinx.coroutines.sync.withLock
import pw.binom.mercury.binocular.FrameSource
import pw.binom.mercury.shizuku.ShizukuVdBinder
import pw.binom.mercury.shizuku.ShizukuVdClient
import pw.binom.rayneovm.glasses.VmAccessibilityService
import pw.binom.rayneovm.glasses.VmNoopImeService
import pw.binom.rayneovm.glasses.VmShizukuService
import pw.binom.rayneovm.glasses.input.TempleGesture
import pw.binom.rayneovm.glasses.shizuku.VirtualDisplayFrameSource
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
import pw.binom.rayneovm.shared.VmDisplay
@@ -303,20 +306,21 @@ class VmGlassesController {
* у активного гостевого дисплея.
*/
fun injectText(displayId: Int, text: String): Boolean {
val binder = guardBinder ?: activeSource?.shizukuBinder
if (binder == null) {
Log.w(TAG, "injectText: нет Shizuku-биндера")
return false
}
if (text.isEmpty()) return true
return try {
val ok = ShizukuVdBinder.injectText(binder, displayId, text)
if (!ok) Log.w(TAG, "injectText: binder вернул false")
ok
} catch (t: Throwable) {
Log.w(TAG, "injectText: binder transact упал", t)
false
// Основной путь: AccessibilityService + ACTION_SET_TEXT — единственный способ
// достучаться до поля гостя на VirtualDisplay без IME (сменить IME на этих очках
// нельзя, а системная клавиатура гостя отключена).
if (VmAccessibilityService.setText(text)) {
Log.i(TAG, "injectText: доставлено через Accessibility (len=${text.length})")
return true
}
// Резерв: если наш IME всё-таки является активным — шлём через его InputConnection.
if (VmNoopImeService.commit(text)) {
Log.i(TAG, "injectText: доставлено через IME (len=${text.length})")
return true
}
Log.w(TAG, "injectText: нет активного поля ввода (ни Accessibility, ни IME)")
return false
}
/** Клавиатурное/медиа-событие гостю на его VirtualDisplay (Back, Play, Pause, ...). */
@@ -330,6 +334,49 @@ class VmGlassesController {
}
}
/**
* Жест тач-сенсора дужки → событие гостю на VirtualDisplay.
*
* Маппинг — DPAD-клавиши (крестовина джойстика, которую понимает гость, например ZONA):
* - [TempleGesture.Swipe] с доминирующей осью X → LEFT/RIGHT, осью Y → UP/DOWN;
* - [TempleGesture.DoubleClick] → KEYCODE_BACK («назад»);
* - [TempleGesture.Click] → KEYCODE_DPAD_CENTER (подтверждение).
*
* Знаки осей (какой свайп = «вверх») подбираются эмпирически на устройстве.
* Каждому событию инжектим DOWN+UP парой, как одиночное нажатие клавиши.
*/
fun sendTempleGesture(gesture: TempleGesture) {
val src = activeSource ?: run {
Log.d(TAG, "sendTempleGesture($gesture): нет активного гостя")
return
}
val binder = src.shizukuBinder
val displayId = src.virtualDisplayId
val keyCode: Int = when (gesture) {
is TempleGesture.Swipe -> {
if (kotlin.math.abs(gesture.dx) >= kotlin.math.abs(gesture.dy)) {
if (gesture.dx >= 0f) android.view.KeyEvent.KEYCODE_DPAD_RIGHT
else android.view.KeyEvent.KEYCODE_DPAD_LEFT
} else {
if (gesture.dy >= 0f) android.view.KeyEvent.KEYCODE_DPAD_DOWN
else android.view.KeyEvent.KEYCODE_DPAD_UP
}
}
is TempleGesture.DoubleClick -> android.view.KeyEvent.KEYCODE_BACK
is TempleGesture.Click -> android.view.KeyEvent.KEYCODE_DPAD_CENTER
}
Log.i(TAG, "sendTempleGesture: $gesture -> keyCode=$keyCode")
testScope.launch {
val down = ShizukuVdBinder.injectKeyEvent(
binder, displayId, keyCode, android.view.KeyEvent.ACTION_DOWN,
)
val up = ShizukuVdBinder.injectKeyEvent(
binder, displayId, keyCode, android.view.KeyEvent.ACTION_UP,
)
if (!down || !up) Log.w(TAG, "sendTempleGesture: injectKeyEvent failed (keyCode=$keyCode)")
}
}
/**
* DEBUG: запустить гостя напрямую (без Compose-UI) — то же, что и [launchGuest].
* Используется для тестирования через adb broadcast.
@@ -413,6 +460,43 @@ class VmGlassesController {
}
}
/**
* «Убить всё лишнее» на очках: force-stop всех сторонних приложений + RayNeo XR-runtime
* (держит залипшую камеру и греет) + `kill -9` процессов-сирот вроде scrcpy.
*
* Выполняется нашим Shizuku UserService от shell UID. Защищены: сам GlassesApp, Shizuku
* (через него же и работаем) и активный гость на VirtualDisplay — по просьбе пользователя
* его не трогаем. Дисплей гостя при этом не освобождаем: убьём только процессы вокруг.
*/
fun forceStopAll() {
testScope.launch {
// Страж живёт постоянно (watchdog), его binder — надёжный долгоживущий вариант.
// Если стража нет, берём binder активного гостя.
val binder = guardBinder ?: activeSource?.shizukuBinder
if (binder == null) {
Log.w(TAG, "forceStopAll: нет Shizuku binder (стража нет, гостя нет)")
return@launch
}
val protected = buildList {
add(GLASSES_PACKAGE)
add(SHIZUKU_PACKAGE)
activeGuestPackage?.let { add(it) }
}
val killed = runCatching {
ShizukuVdBinder.forceStopAll(
binder = binder,
protectedPackages = protected,
extraPackages = listOf(XR_RUNTIME_PACKAGE),
processNameSubstrings = listOf(SCRCPY_PROCESS),
)
}.getOrElse {
Log.w(TAG, "forceStopAll failed", it)
-1
}
Log.i(TAG, "forceStopAll: killed=$killed (protected=$protected)")
}
}
/**
* Force-stop активного гостя, дисплей НЕ трогает. Вызывать под [acquireMutex].
*
@@ -619,6 +703,21 @@ class VmGlassesController {
private const val GUARD_ACTIVITY =
"com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity"
/** Наш пакет — не убиваем себя при «убить всё» (см. [forceStopAll]). */
private const val GLASSES_PACKAGE = "pw.binom.rayneovm.glasses"
/** Shizuku — не убиваем: через его UserService и выполняем force-stop. */
private const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
/**
* RayNeo XR-runtime. Формально системный (`system_ext/priv-app`), но именно он держит
* открытой аппаратную камеру и жрёт ~100% CPU — поэтому его принудительно снимаем.
*/
private const val XR_RUNTIME_PACKAGE = "com.rayneo.xr.runtime"
/** Подстрока командной строки процессов-зеркал (scrcpy), которые тоже добиваем. */
private const val SCRCPY_PROCESS = "scrcpy"
private const val GUARD_DISPLAY_NAME = "VmKillGuard"
private const val GUARD_WIDTH = 320
private const val GUARD_HEIGHT = 240
@@ -46,6 +46,7 @@ fun GlassesScreen(
val state by controller.state.collectAsState()
val controllerFrameSource by controller.frameSource.collectAsState()
val cursor by controller.cursor.collectAsState()
val hud = rememberStatusHudData()
val noOp = remember { NoOpFrameSource() }
val frameSource = controllerFrameSource ?: noOp
@@ -95,6 +96,17 @@ fun GlassesScreen(
)
}
}
// HUD (время + заряд) — всегда поверх, в обеих половинах (на обоих глазах),
// сверху по центру. Виден в любом состоянии: и в ожидании связи, и поверх гостя.
Binocular {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
StatusHud(hud, modifier = Modifier.padding(top = 6.dp))
}
}
}
}
@@ -0,0 +1,125 @@
package pw.binom.rayneovm.glasses.ui
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/** Данные HUD: время + заряд + цвет индикатора заряда. */
data class StatusHudData(
val time: String,
val percent: Int,
val chargeColorArgb: Int,
)
private const val CHARGE_COLOR_CHARGING = 0xFF4CAF50.toInt() // зелёный — идёт заряд
private const val CHARGE_COLOR_DISCHARGING = 0xFFFFFFFF.toInt() // белый — разряд
private const val CHARGE_COLOR_PLUGGED_NOT_CHARGING = 0xFFE53935.toInt() // красный — подключён, но не заряжается
/** Цвет индикатора заряда: не подключён=белый, CHARGING/FULL=зелёный, иначе красный. */
private fun chargeColorArgb(plugged: Boolean, status: Int): Int = when {
!plugged -> CHARGE_COLOR_DISCHARGING
status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL -> CHARGE_COLOR_CHARGING
else -> CHARGE_COLOR_PLUGGED_NOT_CHARGING
}
/**
* HUD очков: время + заряд, компактной «пилюлей» на полупрозрачном тёмном фоне.
*
* Панель вызывается внутри [pw.binom.mercury.binocular.Binocular] (см. `GlassesScreen`),
* поэтому автоматически дублируется в обеих половинах — левом и правом глазу.
*/
@Composable
fun StatusHud(data: StatusHudData, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.background(Color(0x99000000))
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(data.time, color = Color.White, fontSize = 14.sp)
Spacer(Modifier.width(6.dp))
// Процент — цветом состояния заряда, без отдельного квадратика.
Text("${data.percent}%", color = Color(data.chargeColorArgb), fontSize = 14.sp)
}
}
/**
* Провайдер данных HUD: время обновляется раз в секунду, заряд — из sticky
* `ACTION_BATTERY_CHANGED`. Держится один на весь экран; обе половины читают одни данные.
*/
@Composable
fun rememberStatusHudData(): StatusHudData {
val context = LocalContext.current
var data by remember {
mutableStateOf(
StatusHudData(
time = "--:--",
percent = 0,
chargeColorArgb = CHARGE_COLOR_DISCHARGING,
)
)
}
DisposableEffect(Unit) {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
val timeFormat = SimpleDateFormat("HH:mm", Locale.getDefault())
var percent = 0
var plugged = false
var status = BatteryManager.BATTERY_STATUS_UNKNOWN
fun readBattery() {
val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
?: return
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
status = intent.getIntExtra(
BatteryManager.EXTRA_STATUS, BatteryManager.BATTERY_STATUS_UNKNOWN
)
plugged = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0
percent = if (scale > 0 && level >= 0) {
(level * 100f / scale).roundToInt().coerceIn(0, 100)
} else {
level.coerceIn(0, 100)
}
}
scope.launch {
while (isActive) {
readBattery()
data = StatusHudData(timeFormat.format(Date()), percent, chargeColorArgb(plugged, status))
delay(1_000)
}
}
onDispose { scope.cancel() }
}
return data
}
@@ -2,20 +2,22 @@
<!--
Конфиг Accessibility-сервиса RayNeo VM.
Сервис — пассивный слушатель: он НЕ управляет чужими окнами и НЕ инжектит
события. Нужен только как «легальный» живучий процесс, который система
поднимает/перезапускает сама, и через который можно открывать Activity из фона.
Сервис — «легальный» живучий процесс, который система поднимает/перезапускает сама,
через который можно открывать Activity из фона, и — главное — единственный
корректный путь ввода текста в поле гостя на VirtualDisplay без IME
(ACTION_SET_TEXT). Чужой ввод при этом НЕ инжектится.
Никаких eventTypes/feedbackType не запрашиваем сверх необходимого:
eventTypes="typeWindowStateChanged" — минимально нужный, чтобы сервис считался
активным и `onAccessibilityEvent` вызывался. canRetrieveWindowContent=false —
содержимое окон нам не нужно, меньше привилегий.
eventTypes: typeWindowStateChanged|typeViewFocused — второе нужно, чтобы ловить
фокус поля ввода (в будущем).
canRetrieveWindowContent=true — иначе ACCESSIBILITY недоступен rootInActiveWindow и
ACTION_SET_TEXT не выполнить. flagRetrieveInteractiveWindows — чтобы видеть окна
гостя на VirtualDisplay, а не только окно по умолчанию.
-->
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged"
android:accessibilityEventTypes="typeWindowStateChanged|typeViewFocused"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault"
android:canRetrieveWindowContent="false"
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:description="@string/vm_accessibility_description"
android:notificationTimeout="100"
android:summary="@string/vm_accessibility_summary" />
@@ -9,17 +9,25 @@ import android.os.PowerManager
import android.util.Log
import pw.binom.mercury.transport.ByteTransport
import pw.binom.mercury.transport.Transports
import pw.binom.rayneovm.shared.audio.AudioConfig
import pw.binom.rayneovm.shared.audio.AudioDatagram
import pw.binom.rayneovm.shared.audio.OpusAudioDecoder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Менеджер воспроизведения аудио с очков на телефон (Audio mode 2).
* Воспроизведение аудио с очков на телефоне (Audio mode 2).
*
* Держит AudioTrack + WiFi/Wake lock + WebSocket-клиент, качающий PCM с очков.
* Канал — **UDP + Opus**: телефон шлёт очкам keepalive-датаграммы (так очки запоминают
* наш адрес как пира) и принимает Opus-пакеты, декодирует и пишет в low-latency [AudioTrack].
*
* UDP выбран вместо WebSocket: устаревшие пакеты дропаются, а не копятся (нет роста задержки).
*
* Вызывается из [pw.binom.rayneovm.phone.protocol.VmPhoneController] при получении
* [pw.binom.rayneovm.shared.SetAudioMode] с режимом ON_PHONE.
@@ -30,9 +38,16 @@ class AudioPlaybackManager(private val context: Context) {
private var track: AudioTrack? = null
private var transport: ByteTransport? = null
private var streamJob: Job? = null
private var keepAliveJob: Job? = null
private var wifiLock: WifiManager.WifiLock? = null
private var wakeLock: PowerManager.WakeLock? = null
@Volatile
private var running = false
private val decoder = OpusAudioDecoder()
private val pcmOut = ShortArray(AudioConfig.FRAME_SHORTS)
fun start(glassesHost: String) {
if (track != null) {
Log.w(TAG, "start: уже running — пропускаю (host=$glassesHost)")
@@ -40,12 +55,14 @@ class AudioPlaybackManager(private val context: Context) {
}
Log.i(TAG, "start: host=$glassesHost")
val sampleRate = SAMPLE_RATE
val sampleRate = AudioConfig.SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_OUT_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuffer = AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding)
val bufferSize = minBuffer * 4
// Четыре кадра (40 мс) запаса: при буфере меньше этого редкие подвисания потока
// приёма/GC опустошали очередь и давали слышимый «провал» (underrun).
val bufferSize = maxOf(minBuffer, AudioConfig.FRAME_BYTES * 4)
track = AudioTrack.Builder()
.setAudioAttributes(
@@ -63,9 +80,10 @@ class AudioPlaybackManager(private val context: Context) {
)
.setBufferSizeInBytes(bufferSize)
.setTransferMode(AudioTrack.MODE_STREAM)
.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY)
.build()
track!!.play()
Log.i(TAG, "AudioTrack started: $sampleRate Hz, buffer=$bufferSize")
Log.i(TAG, "AudioTrack started: $sampleRate Hz stereo, buffer=$bufferSize")
// WiFi + Wake lock
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
@@ -75,43 +93,85 @@ class AudioPlaybackManager(private val context: Context) {
acquire(60 * 60 * 1000L)
}
// Подключаемся к очкам по тому же ws://host:port/vm-audio
val wsUrl = "ws://$glassesHost:${GLASSES_PORT}/vm-audio"
Log.i(TAG, "start: подключаюсь к $wsUrl")
transport = Transports.wifiBytes(wsUrl, autoReconnect = true)
running = true
transport = Transports.udp(glassesHost, AudioConfig.UDP_PORT)
val subscribe = ByteArray(0)
streamJob = scope.launch {
var totalBytes = 0L
var frames = 0
var lastSeq = -1
var lost = 0
try {
transport!!.connect(
onMessage = { pcm ->
onMessage = { datagram ->
if (!AudioDatagram.hasPayload(datagram)) return@connect
val seq = AudioDatagram.seq(datagram)
val t = track ?: return@connect
if (frames == 0 || frames % 40 == 0) {
Log.i(TAG, "playing: frame#=$frames size=${pcm.size} bytes=$totalBytes rms=${rms16(pcm).toInt()}")
if (lastSeq >= 0) {
// Оборачивание Int через переполнение даёт «огромный» gap — отсекаем.
val gap = seq - lastSeq - 1
if (gap in 1 until MAX_GAP) {
lost += gap
// PLC: синтезируем заменители потерянных кадров, чтобы на месте
// пропуска не было резкого «щелчка» на стыке.
repeat(minOf(gap, MAX_CONCEAL)) {
val n = try {
decoder.decodeLost(pcmOut)
} catch (e: Throwable) {
-1
}
if (n > 0) t.write(pcmOut, 0, n * AudioConfig.CHANNELS)
}
}
}
val written = t.write(pcm, 0, pcm.size)
if (written < 0) {
Log.w(TAG, "playing: AudioTrack.write вернул $written (size=${pcm.size})")
lastSeq = seq
val decoded = try {
decoder.decode(
datagram,
AudioDatagram.HEADER_SIZE,
datagram.size - AudioDatagram.HEADER_SIZE,
pcmOut,
)
} catch (e: Throwable) {
Log.w(TAG, "opus decode failed: ${e.message}")
-1
}
if (decoded > 0) {
t.write(pcmOut, 0, decoded * AudioConfig.CHANNELS)
}
totalBytes += pcm.size
frames++
if (frames % 200 == 0) {
Log.i(TAG, "playing: frames=$frames lost=$lost underruns=${t.underrunCount} lastSeq=$seq")
}
},
onConnected = { Log.i(TAG, "audio connected to $wsUrl") },
onDisconnected = { Log.w(TAG, "audio disconnected from $wsUrl") },
onConnected = {
Log.i(TAG, "audio udp connected to $glassesHost:${AudioConfig.UDP_PORT}")
// Подписка: заставляем очки запомнить наш адрес как аудио-пира.
runCatching { transport!!.send(subscribe) }
},
onDisconnected = { Log.w(TAG, "audio udp disconnected from $glassesHost") },
)
} catch (t: kotlinx.coroutines.CancellationException) {
Log.i(TAG, "start: stream job cancelled (stop)")
// Штатная остановка (stop()/смена режима) — не ошибка.
} catch (t: CancellationException) {
Log.i(TAG, "stream job cancelled (stop)")
} catch (t: Throwable) {
Log.e(TAG, "audio transport failed", t)
}
Log.i(TAG, "start: stream job завершился frames=$frames bytes=$totalBytes")
Log.i(TAG, "stream job завершился frames=$frames lost=$lost")
}
// Очки считают пира живым, пока от него идут датаграммы (peerTimeoutMs). Держим связь.
keepAliveJob = scope.launch {
while (isActive && running) {
delay(AudioConfig.KEEPALIVE_MS)
runCatching { transport?.send(subscribe) }
}
}
}
fun stop() {
Log.i(TAG, "stop: track=${track != null} transport=${transport != null}")
running = false
keepAliveJob?.cancel(); keepAliveJob = null
streamJob?.cancel(); streamJob = null
transport?.close(); transport = null
track?.stop(); track?.release(); track = null
@@ -120,23 +180,13 @@ class AudioPlaybackManager(private val context: Context) {
Log.i(TAG, "AudioPlaybackManager stopped")
}
private fun rms16(buf: ByteArray): Double {
if (buf.size < 2) return 0.0
var sumSq = 0.0
var i = 0
while (i + 1 < buf.size) {
val s = ((buf[i].toInt() and 0xFF) or (buf[i + 1].toInt() shl 8)).toShort().toInt()
sumSq += s.toDouble() * s
i += 2
}
return Math.sqrt(sumSq / (buf.size / 2))
}
private companion object {
const val TAG = "AudioPlaybackMgr"
companion object {
private const val TAG = "AudioPlaybackMgr"
const val SAMPLE_RATE = 44100
/** Пропуск seq больше этого — оборачивание Int или сбой, а не реальные потери. */
const val MAX_GAP = 100
/** Совпадает с [pw.binom.rayneovm.glasses.VmService.AUDIO_PORT]. */
const val GLASSES_PORT = 8081
/** Максимум кадров подряд «дотягиваем» PLC, чтобы не залипнуть на длинной потере. */
const val MAX_CONCEAL = 5
}
}
@@ -128,11 +128,16 @@ class ShizukuProvisioner(
Log.w(TAG, "не удалось узнать UID $GLASSES_PACKAGE — PROJECT_MEDIA per-UID не выставлен")
Result.failure(IllegalStateException("uid not found"))
}
// #4.1: назначаем наш VmNoopImeService дефолтным IME навсегда (см. [VmNoopImeService]).
// Подавляет системный IME, который гость (Zona) поднимает при фокусе в поле ввода.
// #4.1: глушим системную клавиатуру гостя. Наш VmNoopImeService дефолтом стать
// не может — вендорский ROM жёстко держит LatinIME (allowlist; даже системный
// Sogou откатывается). Поэтому просто отключаем LatinIME: на очках не поднимается
// ни один IME, а текст с телефона доставляется через AccessibilityService
// (ACTION_SET_TEXT, см. VmAccessibilityService). На «обычных» ROM сработает и
// первый способ — назначение нашего IME.
val ime = adb.shell(
"settings put secure default_input_method " +
"$GLASSES_PACKAGE/.VmNoopImeService",
"ime enable $GLASSES_PACKAGE/.VmNoopImeService; " +
"ime set $GLASSES_PACKAGE/.VmNoopImeService; " +
"pm disable-user --user 0 com.android.inputmethod.latin",
timeoutMs = 15_000,
)
Log.i(
@@ -21,12 +21,14 @@ import pw.binom.rayneovm.shared.AppLaunchedAck
import pw.binom.rayneovm.shared.CursorClick
import pw.binom.rayneovm.shared.CursorMove
import pw.binom.rayneovm.shared.CursorScroll
import pw.binom.rayneovm.shared.ForceStopAll
import pw.binom.rayneovm.shared.GetAppList
import pw.binom.rayneovm.shared.GlassesStatus
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
import pw.binom.rayneovm.shared.Pong
import pw.binom.rayneovm.shared.RequestShizukuStatus
import pw.binom.rayneovm.shared.ScreenControl
import pw.binom.rayneovm.shared.ScreenState
import pw.binom.rayneovm.shared.SetAudioMode
import pw.binom.rayneovm.shared.SetText
import pw.binom.rayneovm.shared.ShizukuStatus
@@ -55,6 +57,10 @@ class VmPhoneController(
private val _glassesShizuku = MutableStateFlow<ShizukuStatus?>(null)
val glassesShizuku: StateFlow<ShizukuStatus?> = _glassesShizuku.asStateFlow()
/** Реальное состояние экрана очков (шлют очки); null — пока неизвестно. */
private val _screenOn = MutableStateFlow<Boolean?>(null)
val screenOn: StateFlow<Boolean?> = _screenOn.asStateFlow()
private var currentTransport: StringTransport? = null
private val audioManager by lazy { AudioPlaybackManager(context) }
@@ -121,6 +127,7 @@ class VmPhoneController(
onDisconnected = {
currentTransport = null
_glassesShizuku.value = null
_screenOn.value = null
// Очки отвалились — аудио-поток всё равно мёртв, глушим трек.
audioManager.stop()
_state.value = ConnectionState.Disconnected
@@ -158,6 +165,9 @@ class VmPhoneController(
is ShizukuStatus -> {
_glassesShizuku.value = msg
}
is ScreenState -> {
_screenOn.value = msg.on
}
is SetAudioMode -> {
// Аудио режим — отдельный поток, не контролируется этим контроллером напрямую.
// AudioCaptureService на очках стартует/останавливается.
@@ -190,6 +200,14 @@ class VmPhoneController(
send(RequestShizukuStatus)
}
/**
* «Убить всё лишнее» на очках: force-stop сторонних приложений + RayNeo XR-runtime
* (греющая камера) + процессы-сироты вроде scrcpy. Наши приложения и системное не трогает.
*/
suspend fun forceStopAll() {
send(ForceStopAll)
}
/** Отправляет сообщение очкам. */
suspend fun send(msg: VmMessage) {
val t = currentTransport ?: return
@@ -217,6 +235,8 @@ class VmPhoneController(
suspend fun setScreen(sleep: Boolean): Boolean {
if (_state.value !is ConnectionState.Connected) return false
send(ScreenControl(sleep))
// Оптимистично: точное состояние очки пришлют сами (ScreenState) — возможно с задержкой.
_screenOn.value = !sleep
return true
}
@@ -173,6 +173,11 @@ fun GlassesStatusScreen(
onConnect = { ssid, password -> mercuryController.connectWifi(ssid, password) },
)
ForceStopSection(
enabled = connection is ConnectionState.Connected,
onForceStop = { controller.forceStopAll() },
)
Spacer(modifier = Modifier.height(8.dp))
LaunchGlassesAppButton(
@@ -431,6 +436,54 @@ private fun AudioOutputSection(
}
}
/**
* «Убить всё лишнее» на очках: снимает сторонние приложения, RayNeo XR-runtime
* (он держит камеру и греет) и процессы-сироты вроде scrcpy. Наши приложения, Shizuku и
* активный гость на виртуальном дисплее остаются жить.
*/
@Composable
private fun ForceStopSection(
enabled: Boolean,
onForceStop: suspend () -> Unit,
) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
SectionCard(title = "Обслуживание") {
Hint(
"Остановит все лишние приложения на очках (включая RayNeo XR-runtime, который " +
"греет) и зеркала экрана. Наше приложение и текущий гость не тронутся.",
)
Button(
enabled = !busy && enabled,
onClick = {
busy = true
message = null
scope.launch {
try {
onForceStop()
message = "Команда отправлена на очки"
} catch (t: Throwable) {
message = "Ошибка: ${t.message}"
} finally {
busy = false
}
}
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF8E2A2A),
disabledContainerColor = Color(0xFF2A2A2A),
),
) {
Text(if (busy) "Отправляем…" else "Убить всё лишнее на очках")
}
if (!enabled) Hint("Нужен Канал 1 (WebSocket) — приложение на очках не подключено.")
message?.let { Hint(it) }
}
}
@Composable
private fun GlassesWifiSection(
status: GeneralStatus?,
@@ -20,10 +20,12 @@ import androidx.compose.material.icons.filled.FastRewind
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
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
@@ -42,6 +44,7 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.protocol.ConnectionState
import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.shared.VmDisplay
@@ -71,6 +74,9 @@ fun RemoteScreen(
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
// Реальное состояние экрана очков (шлют сами очки) — питает кнопку-тумблер ниже.
val screenOn by controller.screenOn.collectAsState()
val connected = controller.state.collectAsState().value is ConnectionState.Connected
// Позиция курсора в координатах VirtualDisplay гостя.
var cursor by remember { mutableStateOf(Offset(VmDisplay.WIDTH / 2f, VmDisplay.HEIGHT / 2f)) }
// Отложенный клик: два пальца кладут не одновременно, и первый палец успевает «щёлкнуть»
@@ -203,6 +209,23 @@ fun RemoteScreen(
}
}
// Одна кнопка-тумблер: гасит/будит экран очков. Символ отражает текущее состояние
// (его шлют очки), поэтому нажатие всегда чередует вкл/выкл корректно.
Row(modifier = Modifier.fillMaxWidth()) {
RemoteButton(
icon = Icons.Default.PowerSettingsNew,
label = when (screenOn) {
true -> "Погасить экран очков"
false -> "Разбудить экран очков"
null -> "Экран очков: вкл/выкл"
},
modifier = Modifier.weight(1f),
enabled = connected,
) {
scope.launch { controller.setScreen(sleep = screenOn != false) }
}
}
if (showKeyboard) {
KeyboardDialog(
onDismiss = { showKeyboard = false },
@@ -210,7 +233,6 @@ fun RemoteScreen(
scope.launch { controller.sendText(text) }
showKeyboard = false
},
onBackspace = { scope.launch { controller.sendKey(KEYCODE_DEL) } },
onEnter = { scope.launch { controller.sendKey(KEYCODE_ENTER) } },
)
}
@@ -131,7 +131,6 @@ fun ZonaProfile(
scope.launch { controller.sendText(text) }
showKeyboard = false
},
onBackspace = { scope.launch { controller.send(VmKeyEvent(keyCode = 67, action = 1)) } },
onEnter = { scope.launch { controller.send(VmKeyEvent(keyCode = 66, action = 1)) } },
)
}
+2 -2
View File
@@ -11,9 +11,9 @@ shizukuApi = "13.1.5"
shizukuProvider = "13.1.5"
navCompose = "2.10.2"
mercury = "0.1.1-SNAPSHOT"
mercuryTransport = "0.1.1-SNAPSHOT"
mercuryTransport = "0.1.2-SNAPSHOT"
mercuryBinocular = "0.1.0-SNAPSHOT"
mercuryShizuku = "0.1.0-SNAPSHOT"
mercuryShizuku = "0.1.2-SNAPSHOT"
[libraries]
# kotlinx
+4
View File
@@ -35,6 +35,10 @@ dependencies {
api(libs.mercury.transport)
// Concentus — чистый JVM-порт libopus (BSD-3), вендорнут в libs/ чтобы сборка не
// зависела от внешнего JitPack. Используется обёрткой pw.binom.rayneovm.shared.audio.OpusCodec.
implementation(files("libs/concentus.jar"))
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.junit.jupiter)
testImplementation(libs.kotlin.test) // need for kotlin.test.Test
Binary file not shown.
@@ -141,6 +141,18 @@ data class ScreenControl(
@SerialName("get_app_list")
data object GetAppList : VmMessage
/**
* «Убить всё лишнее» на очках: force-stop всех сторонних приложений + RayNeo XR-runtime
* (держит залипшую камеру и греет) + добить процессы-сироты вроде scrcpy.
*
* Выполняется на очках их Shizuku UserService (shell UID). Защищены от убийства: сам
* GlassesApp, Shizuku, все системные/привилегированные пакеты и текущий гость на
* VirtualDisplay.
*/
@Serializable
@SerialName("force_stop_all")
data object ForceStopAll : VmMessage
/** Просьба к очкам прислать текущее состояние Shizuku (см. [ShizukuStatus]). */
@Serializable
@SerialName("request_shizuku_status")
@@ -184,6 +196,18 @@ data class AppLaunchedAck(
val success: Boolean,
) : VmMessage
/**
* Состояние экрана очков: [on] = true — экран включён, false — погашен.
*
* Очки шлют его при подключении телефона и при каждом изменении (`ACTION_SCREEN_ON`/`OFF`),
* чтобы кнопка-тумблер на телефоне отражала реальное состояние, а не догадку.
*/
@Serializable
@SerialName("screen_state")
data class ScreenState(
val on: Boolean,
) : VmMessage
/**
* Состояние Shizuku на очках.
*
@@ -0,0 +1,47 @@
package pw.binom.rayneovm.shared.audio
/**
* Единые параметры аудио-тракта очки → телефон.
*
* Opus не поддерживает 44.1 кГц, поэтому весь тракт (захват, кодирование, вывод) работает
* на 48 кГц. Кадр 10 мс держит задержку маленькой, а сжатие Opus (~64 кбит/с против
* ~1.5 Мбит/с сырого PCM) уменьшает трафик примерно в 20 раз.
*/
object AudioConfig {
const val SAMPLE_RATE = 48_000
const val CHANNELS = 2
/** Длительность одного Opus-кадра. Компромисс: меньше — меньше задержка, больше накладные. */
const val FRAME_MS = 10
/** Сэмплов на канал в одном кадре (480 @ 10 мс / 48 кГц). */
const val FRAME_SAMPLES = SAMPLE_RATE * FRAME_MS / 1000
/** Всего сэмплов (interleaved) в кадре. */
const val FRAME_SHORTS = FRAME_SAMPLES * CHANNELS
/** Размер кадра PCM (interleaved 16-bit). */
const val FRAME_BYTES = FRAME_SHORTS * 2
const val BITRATE = 64_000
/**
* Сложность Opus-кодера. Concentus — JVM-порт, кодирует на очках в реальном времени;
* при complexity 8 один 10-мс stereo-кадр не укладывался в 10 мс, кодер не успевал
* (~60 кадров/с при 100 захваченных → 40% кадров терялось в очереди = «хрип»). На 3
* запас по CPU большой, а на 64 кбит/с разница в качестве незначительна.
*/
const val COMPLEXITY = 3
/** Ожидаемый процент потерь — для настройки inband FEC/PLC Opus. */
const val PACKET_LOSS_PERCENT = 10
/** UDP-порт аудио на очках (control-канал остаётся на TCP 8080). */
const val UDP_PORT = 8082
/** Верхняя граница размера датаграммы (Opus 10 мс stereo ≪ этого). */
const val MAX_DATAGRAM = 1500
/** Интервал keepalive-датаграмм от телефона: держит пира «живым» на UDP-сервере очков. */
const val KEEPALIVE_MS = 1_000L
}
@@ -0,0 +1,37 @@
package pw.binom.rayneovm.shared.audio
/**
* Формат аудио-датаграммы: `[4 байта seq big-endian][Opus payload]`.
*
* `seq` позволяет телефону детектить потери/дубли и понимать, что поток «жив», а не завис.
* Заголовок намеренно минимальный — каждые лишние байты это лишняя задержка.
*/
object AudioDatagram {
const val HEADER_SIZE = 4
const val MAX_PAYLOAD = AudioConfig.MAX_DATAGRAM - HEADER_SIZE
/**
* Кладёт [payload] в [out] с заголовком seq. Возвращает полную длину датаграммы.
* Буфер [out] должен быть не меньше `HEADER_SIZE + payloadLength`.
*/
fun pack(seq: Int, payload: ByteArray, payloadLength: Int, out: ByteArray): Int {
out[0] = (seq ushr 24).toByte()
out[1] = (seq ushr 16).toByte()
out[2] = (seq ushr 8).toByte()
out[3] = seq.toByte()
System.arraycopy(payload, 0, out, HEADER_SIZE, payloadLength)
return HEADER_SIZE + payloadLength
}
/** Читает seq из датаграммы; `-1`, если данных меньше заголовка (например keepalive). */
fun seq(data: ByteArray, length: Int = data.size): Int {
if (length < HEADER_SIZE) return -1
return ((data[0].toInt() and 0xFF) shl 24) or
((data[1].toInt() and 0xFF) shl 16) or
((data[2].toInt() and 0xFF) shl 8) or
(data[3].toInt() and 0xFF)
}
/** Есть ли в датаграмме вообще полезная нагрузка (не keepalive). */
fun hasPayload(data: ByteArray, length: Int = data.size): Boolean = length > HEADER_SIZE
}
@@ -0,0 +1,75 @@
package pw.binom.rayneovm.shared.audio
import org.concentus.OpusApplication
import org.concentus.OpusDecoder
import org.concentus.OpusEncoder
/**
* Обёртка над Concentus (чистый JVM-порт libopus, без NDK).
*
* Один экземпляр держит один Opus-поток, поэтому его нельзя использовать из нескольких
* потоков одновременно. Класс не потокобезопасен — вызывающий синхронизирует сам.
*/
class OpusAudioEncoder(
sampleRate: Int = AudioConfig.SAMPLE_RATE,
channels: Int = AudioConfig.CHANNELS,
bitrate: Int = AudioConfig.BITRATE,
complexity: Int = AudioConfig.COMPLEXITY,
) {
private val encoder = OpusEncoder(sampleRate, channels, OpusApplication.OPUS_APPLICATION_AUDIO).apply {
this.bitrate = bitrate
this.complexity = complexity
useVBR = true
// InbandFEC в Concentus удваивает стоимость кодирования (кодер считает ещё и
// резервную копию предыдущего кадра). Потери по UDP у нас ~0, а пропуски seq и так
// прячем на декодере (PLC, см. [OpusAudioDecoder.decodeLost]) — FEC только жжёт CPU.
useInbandFEC = false
packetLossPercent = AudioConfig.PACKET_LOSS_PERCENT
}
/** Переиспользуемый буфер вывода; копия отдаётся наружу только по факту кодирования. */
private val out = ByteArray(AudioConfig.MAX_DATAGRAM)
/**
* @param pcm interleaved 16-bit PCM (см. [AudioConfig.FRAME_SHORTS])
* @param offset смещение в [pcm]
* @param samplesPerChannel сэмплов на канал (см. [AudioConfig.FRAME_SAMPLES])
* @return закодированный Opus-пакет
*/
fun encode(pcm: ShortArray, offset: Int, samplesPerChannel: Int): ByteArray {
val n = encoder.encode(pcm, offset, samplesPerChannel, out, 0, out.size)
return out.copyOf(n)
}
}
/**
* Обёртка декодера Opus (Concentus). Не потокобезопасна.
*/
class OpusAudioDecoder(
sampleRate: Int = AudioConfig.SAMPLE_RATE,
channels: Int = AudioConfig.CHANNELS,
) {
private val decoder = OpusDecoder(sampleRate, channels)
/**
* @return число декодированных сэмплов на канал, либо отрицательное значение при ошибке.
*/
fun decode(
data: ByteArray,
offset: Int,
length: Int,
pcmOut: ShortArray,
frameSamplesPerChannel: Int = AudioConfig.FRAME_SAMPLES,
): Int = decoder.decode(data, offset, length, pcmOut, 0, frameSamplesPerChannel, false)
/**
* Packet Loss Concealment: синтезирует «заменитель» потерянного пакета, чтобы не было
* щелчка/тишины. Вызывать, когда обнаружен пропуск seq.
*
* @return число сэмплов на канал.
*/
fun decodeLost(
pcmOut: ShortArray,
frameSamplesPerChannel: Int = AudioConfig.FRAME_SAMPLES,
): Int = decoder.decode(null, 0, 0, pcmOut, 0, frameSamplesPerChannel, false)
}
@@ -0,0 +1,88 @@
package pw.binom.rayneovm.shared.audio
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class OpusCodecTest {
@Test
fun `opus roundtrip compresses and decodes the expected frame`() {
val encoder = OpusAudioEncoder()
val decoder = OpusAudioDecoder()
val pcm = ShortArray(AudioConfig.FRAME_SHORTS)
val out = ShortArray(AudioConfig.FRAME_SHORTS)
var totalEncoded = 0
val frames = 10
for (f in 0 until frames) {
// 1 кГц синус, одинаковый в обоих каналах
for (i in 0 until AudioConfig.FRAME_SAMPLES) {
val v = (Math.sin(2 * Math.PI * 1000 * i / AudioConfig.SAMPLE_RATE) * 12000).toInt().toShort()
pcm[i * 2] = v
pcm[i * 2 + 1] = v
}
val payload = encoder.encode(pcm, 0, AudioConfig.FRAME_SAMPLES)
assertTrue(payload.isNotEmpty(), "encoded payload пустой")
assertTrue(payload.size < AudioConfig.FRAME_BYTES, "Opus обязан сжимать PCM")
totalEncoded += payload.size
val decoded = decoder.decode(payload, 0, payload.size, out)
assertEquals(AudioConfig.FRAME_SAMPLES, decoded)
}
assertTrue(
totalEncoded < frames * AudioConfig.FRAME_BYTES,
"трафик Opus должен быть меньше сырого PCM",
)
}
@Test
fun `packet loss concealment produces a frame`() {
val encoder = OpusAudioEncoder()
val decoder = OpusAudioDecoder()
val pcm = ShortArray(AudioConfig.FRAME_SHORTS)
// «прогреваем» декодер одним реальным пакетом, затем прячем потерю
for (i in 0 until AudioConfig.FRAME_SAMPLES) {
val v = (Math.sin(2 * Math.PI * 440 * i / AudioConfig.SAMPLE_RATE) * 8000).toInt().toShort()
pcm[i * 2] = v
pcm[i * 2 + 1] = v
}
val payload = encoder.encode(pcm, 0, AudioConfig.FRAME_SAMPLES)
decoder.decode(payload, 0, payload.size, ShortArray(AudioConfig.FRAME_SHORTS))
val out = ShortArray(AudioConfig.FRAME_SHORTS)
val concealed = decoder.decodeLost(out)
assertEquals(AudioConfig.FRAME_SAMPLES, concealed)
}
@Test
fun `keepalive datagram has no payload and seq -1`() {
val keepAlive = ByteArray(0)
assertEquals(-1, AudioDatagram.seq(keepAlive))
assertTrue(!AudioDatagram.hasPayload(keepAlive))
}
@Test
fun `datagram pack and seq roundtrip`() {
val payload = byteArrayOf(1, 2, 3, 4, 5)
val out = ByteArray(AudioConfig.MAX_DATAGRAM)
val seq = 0x01020304
val len = AudioDatagram.pack(seq, payload, payload.size, out)
assertEquals(AudioDatagram.HEADER_SIZE + payload.size, len)
assertEquals(seq, AudioDatagram.seq(out, len))
assertTrue(AudioDatagram.hasPayload(out, len))
assertEquals(1, out[AudioDatagram.HEADER_SIZE].toInt())
assertEquals(5, out[AudioDatagram.HEADER_SIZE + 4].toInt())
}
@Test
fun `negative seq survives the roundtrip`() {
val out = ByteArray(AudioConfig.MAX_DATAGRAM)
val len = AudioDatagram.pack(-1, byteArrayOf(9), 1, out)
assertEquals(-1, AudioDatagram.seq(out, len))
}
}