feat(audio,#2): MediaProjection + AudioPlaybackCapture preview on glasses; AudioPath /vm-audio to phone; debug test tone; debug audio pipe; FGS mediaProjection type; AudioOutputSection UI toggle

This commit is contained in:
2026-09-27 06:39:00 +03:00
parent 2125788e25
commit b1efa3d462
16 changed files with 872 additions and 229 deletions
+73
View File
@@ -0,0 +1,73 @@
# BLOCKERS ночной сессии 2026-09-27
## B1 — Нет общей L3-сети между телефоном и очками для проверки ON_PHONE end-to-end
**Задача:** #2 (проброс звука VirtualDisplay → телефон).
**Что сделано:**
- `AudioBridge` + `GlassesAudioCapture` + WebSocket-аудио-сервер `/vm-audio` (порт 8081) на очках +
`AudioPlaybackManager` на телефоне + UI-переключатель «Звук» в `GlassesStatusScreen`.
- **MediaProjection + AudioPlaybackCapture подтверждены работающими на очках в автономном debug-режиме
`DEBUG_AUDIO_PIPE`** (preview без WebSocket-канала):
```
TestTone: test tone start: 44100 Hz stereo, 10s
GlassesAudioCapture: capture: bytes=1310720 rms=5629 ← захват системного звука работает!
TestTone: test tone done
GlassesAudioCapture: capture: bytes=1474560 rms=0 ← тишина после теста
```
RMS 5629 совпадает с теоретическим (~5657 для амплитуды 8000) → мы реально ловим 440 Гц.
- Тестовый тон → динамик очков → AudioPlaybackCapture на тех же очках → доказано.
- Подача `Recorder Audio` + `PROJECT_MEDIA=allow` через `AdbController`/`appops` — работает.
**В чём стена:** WebSocket-канал (AudioPath между очками и телефоном) требует общую L3-сеть. На данный момент:
- Phone: `192.168.76.121/22 dev wlan0 → SSID "O2 5"`.
- Glasses: подключены к тому же `O2 5` через supplicant (CONNECTED), **но без IP на wlan0** (`network is unreachable`).
Просто очкам DHCP не отдал адрес (по какой-то причине).
- mDNS работает только в broadcast-домене одной L2/L3 сети.
Без общего IP между устройствами WebSocket `/vm-audio` не пройдёт — `AudioPlaybackManager` на телефоне подключиться не сможет. Сам захват звука на очках доказан, но отправка байтов по WS до телефона — нет.
**Что проверял:**
- `service call adb 4 i32 1 s16 "O2 5"` + `settings put global adb_wifi_enabled 1` — wireless-adb поднимается.
- Bluetooth-канал (Mercury) работает (BLE-команды идут). Но для WebSocket audio не применим (пропускная способность BLE всё-таки ограничена для 44.1 кГц стерео, что добавляет сложностей).
- ping телефон ↔ очки = `network is unreachable` от очков в сторону телефона (wlan0 не получил IP).
**Обход (нужна помощь пользователя):**
1. **Самый прямой:** пользователь включает Wi-Fi-хотспот на телефоне (через UI, ~30 секунд).
- SSID/пароль любые.
- Телефон и очки → обе в одну сеть, получают IP от телефона.
- Очки уже умеют авто-коннектиться к сохранённой сети (Caffeine Portable или любая).
2. **Альтернатива:** пользователь подключает очки к USB-tethering'у с телефона (если dev'ы возможно на устройстве).
3. **Самый сложный:** настроить прямой Wi-Fi Direct между телефоном и очками — `cmd wifi p2p ...` потребует более глубокой интеграции.
После появления общей сети проверить:
```bash
# на телефоне:
adb shell am start -n pw.binom.rayneovm.phone/.MainActivity # поднять UI
# UI → «Очки» → тумблер «Звук на телефоне».
# В логах телефона должно быть:
# AudioPlaybackManager: audio connected to ws://<glasses_ip>:8081/vm-audio
# AudioPlaybackManager: playing: bytes=… rms=…
# В логах очков:
# GlassesAudioCapture: capture: bytes=… rms=…
# VmService: sender: clients=1 bytes=…
```
**Статус:** #2 прогресс в работе, ядро (MediaProjection + AudioPlaybackCapture) доказано рабочим. Только финальная L3-проверка отложена до пробуждения пользователя.
## B2 — USB-adb / сеть очков: на руках есть USB-adb хост ↔ очки; очки же сейчас в wlan0 без IP
Зафиксировано в #B1 — нужно чтобы устройства в общей IP-сети. Wi-Fi-xотспот на телефоне решает.
## B3 (выяснено, не блокер) — Mercury path `ble.request.debug` не зарегистрирован на прошивке RayNeo X3 Pro
**Что:** `RayNeoMercury.enableWirelessAdb()` отправляет `ble.request.debug/enableWirelessAdb`, на очках приходит `Can not resolve(uri = ble.request.debug)` — RayNeo Mercury system-app на этой прошивке не знает этот URI.
Это означает: **провайдер Mercurry не работает на X3 Pro** для управления wireless-adb.
**Workaround:** включение wireless-adb через `service call adb 4 i32 1 s16 <ssid>` от shell — отлично работает (с shell UID через Shizuku или напрямую). Используется в `AdbController`/`ShizukuProvisioner`.
+12
View File
@@ -169,3 +169,15 @@ shell `settings put` работает.
- Порядок работы ночью: **#2 → #1 → #3 → #4 → #5** (по убыванию риска/размера).
- Коммиты/пуш — по завершении блоков; отдельно rayneo-vm и rayneo-mercury.
- **Отмашка на начало работ — ждём от пользователя (пока не дана).**
### Правила ночной сессии (согласовано 2026-09-27)
1. **Упёрся в проблему — не стоим.** Зафиксировать её в `BLOCKERS.md` (корень репо): задача, что
сделано, в чём стена, что проверено, варианты обхода. Затем **перейти к следующей задаче**.
2. **Погасить экран очков в конце.** Когда все задачи закрыты ИЛИ дальнейшее продвижение
невозможно — ОБЯЗАТЕЛЬНО выключить экран очков:
`adb -s A06B4AB933C4103 shell input keyevent 223` (KEYCODE_SLEEP), либо через `ScreenControl`
(Канал 1) / Shizuku `SET_SCREEN`. Проверить по `mWakefulness=Dozing`/`Asleep`.
3. Все три пре-авторизации выданы: ребут очков — можно; менять системные настройки очков
(Accessibility / PROJECT_MEDIA / дефолтный IME) на постоянку — можно; при равнозначных развилках
выбирать самому и документировать.
+16 -2
View File
@@ -73,11 +73,11 @@
</intent-filter>
</activity>
<!-- Foreground service: WebSocket server + mDNS -->
<!-- Foreground service: WebSocket server + mDNS + захват аудио (задача #2) -->
<service
android:name=".VmService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
android:foregroundServiceType="mediaPlayback|mediaProjection" />
<!-- Пассивный Accessibility-сервис (задача #5): живучесть процесса +
легальный запуск Activity из фона. Ничего не читает и не инжектит.
@@ -111,6 +111,20 @@
android:exported="true"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<!-- Отладочный приёмник (защищён DUMP): тестовый тон для проверки аудио-тракта. -->
<receiver
android:name=".debug.GlassesDebugReceiver"
android:exported="true"
android:permission="android.permission.DUMP">
<intent-filter>
<action android:name="pw.binom.rayneovm.glasses.DEBUG_TEST_TONE" />
<action android:name="pw.binom.rayneovm.glasses.DEBUG_AUDIO_PIPE" />
<action android:name="pw.binom.rayneovm.glasses.DEBUG_AUDIO_PHONE" />
<action android:name="pw.binom.rayneovm.glasses.DEBUG_AUDIO_STOP" />
<action android:name="pw.binom.rayneovm.glasses.DEBUG_STOP_TONE" />
</intent-filter>
</receiver>
<meta-data android:name="com.rayneo.mercury.app" android:value="true" />
</application>
</manifest>
@@ -1,12 +1,16 @@
package pw.binom.rayneovm.glasses
import android.app.Activity
import android.content.Intent
import android.content.IntentFilter
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.util.Log
import android.view.Display
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
@@ -20,12 +24,41 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.launch
import pw.binom.rayneovm.glasses.audio.AudioBridge
import pw.binom.rayneovm.glasses.protocol.VmGlassesController
import pw.binom.rayneovm.glasses.ui.GlassesScreen
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
class MainActivity : ComponentActivity() {
/** Последний обработанный tick запроса проекции (см. onCreate). */
private var handledTick = 0L
/**
* Пользовательский диалог MediaProjection. При выставленном appop `PROJECT_MEDIA = allow`
* он не показывается — интент сразу возвращает RESULT_OK (нужно для задачи #2).
*/
private val projectionLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val data = result.data
if (result.resultCode == Activity.RESULT_OK && data != null) {
val mp = getSystemService(MediaProjectionManager::class.java)
.getMediaProjection(result.resultCode, data)
if (mp != null) {
Log.i(TAG, "MediaProjection получен")
AudioBridge.onProjectionGranted(mp)
} else {
Log.w(TAG, "getMediaProjection вернул null")
}
} else {
Log.w(TAG, "MediaProjection не выдан: resultCode=${result.resultCode}")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// HOME живёт на display 0. Если систему (или гостя на VirtualDisplay)
@@ -38,6 +71,23 @@ class MainActivity : ComponentActivity() {
}
enableEdgeToEdge()
ShizukuBootstrap.attach(this)
// Запросы MediaProjection приходят из VmService через AudioBridge (задача #2).
// Запускаем интент только когда Activity хотя бы STARTED, иначе лончер кинет.
//
// handledTick обязателен: repeatOnLifecycle перезапускает сбор при каждом выходе
// Activity в STARTED (а после диалога проекции мы как раз возвращаемся в STARTED).
// Без него один и тот же tick запускал бы запрос повторно, новая проекция гасила
// предыдущую, и AudioRecord умирал с read -2.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
AudioBridge.requestTick.collect { tick ->
if (tick > handledTick) {
handledTick = tick
launchProjectionRequest()
}
}
}
}
val controller = VmGlassesController.fromContext(this)
setContent {
@@ -76,6 +126,17 @@ class MainActivity : ComponentActivity() {
ShizukuBootstrap.onRequestPermissionResult(requestCode, grant)
}
}
private fun launchProjectionRequest() {
runCatching {
val mpm = getSystemService(MediaProjectionManager::class.java)
projectionLauncher.launch(mpm.createScreenCaptureIntent())
}.onFailure { Log.w(TAG, "Не удалось запустить запрос MediaProjection: ${it.message}") }
}
companion object {
private const val TAG = "MainActivity"
}
}
@Composable
@@ -7,6 +7,7 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioManager
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
import android.os.Build
@@ -15,11 +16,17 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.mercury.transport.ServerTransport
import pw.binom.mercury.transport.Transports
import pw.binom.rayneovm.glasses.audio.AudioBridge
import pw.binom.rayneovm.glasses.audio.GlassesAudioCapture
import pw.binom.rayneovm.glasses.protocol.VmGlassesController
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
import pw.binom.rayneovm.shared.AudioMode
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
import pw.binom.rayneovm.shared.LaunchAppCommand
import pw.binom.rayneovm.shared.Ping
@@ -58,6 +65,26 @@ class VmService : LifecycleService() {
private var nsdRegistered: Boolean = false
private lateinit var controller: VmGlassesController
// --- Аудио-мост (задача #2): захват системного звука очков и стрим на телефон ---
private var audioServer: ServerTransport<ByteArray>? = null
private val audioSessions = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
@Volatile
private var audioCapture: GlassesAudioCapture? = null
private var audioEnsureJob: kotlinx.coroutines.Job? = null
@Volatile
private var audioMode: AudioMode = AudioMode.ON_GLASSES
/**
* Очередь кадров PCM между потоком чтения [AudioRecord] и отправкой в сокет.
* При переполнении выкидываем самый старый кадр — для live-аудио важнее свежесть,
* чем полнота (иначе задержка растёт безгранично).
*/
private val audioFrames = Channel<ByteArray>(
capacity = 16,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override fun onCreate() {
super.onCreate()
instance = this
@@ -69,12 +96,23 @@ class VmService : LifecycleService() {
controller.ensureMercuryKillGuard()
startForeground()
startServer()
startAudioServer()
startAudioPump()
registerMdns()
// Сообщаем телефону о Shizuku, как только меняется его состояние (сервер поднялся
// или нам выдали permission) — чтобы телефон не опрашивал вручную.
lifecycleScope.launch {
ShizukuBootstrap.state.collect { pushShizukuStatus() }
}
// MediaProjection приходит из MainActivity асинхронно. Как только он есть,
// а режим — ON_PHONE, сразу пробуем поднять захват (не ждём тик ретрая).
lifecycleScope.launch {
AudioBridge.projection.collect { projection ->
if (projection != null && audioMode == AudioMode.ON_PHONE && audioCapture == null) {
startAudioCapture(projection)
}
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -91,7 +129,14 @@ class VmService : LifecycleService() {
val channelId = createNotificationChannel()
val notification = buildNotification(channelId)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
// MEDIA_PROJECTION обязателен: без него getMediaProjection() кидает SecurityException
// (Android 12 проверяет тип foreground-сервиса в момент выдачи проекции).
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION,
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
@@ -167,7 +212,10 @@ class VmService : LifecycleService() {
Log.d(TAG, "Key: ${msg.keyCode} action=${msg.action}")
controller.sendKey(msg.keyCode, msg.action)
}
is SetAudioMode -> Log.d(TAG, "SetAudioMode: ${msg.mode}")
is SetAudioMode -> {
Log.i(TAG, "SetAudioMode: ${msg.mode}")
applyAudioMode(msg.mode)
}
is ScreenControl -> {
Log.i(TAG, "ScreenControl: sleep=${msg.sleep}")
controller.setScreen(msg.sleep)
@@ -194,6 +242,151 @@ class VmService : LifecycleService() {
}
}
// ---------------- Аудио-мост (задача #2) ----------------
/** Сервер байтового канала `/vm-audio`: телефон подключается сюда за PCM-потоком. */
private fun startAudioServer() {
audioServer = Transports.wifiServerBytes(port = AUDIO_PORT, path = AUDIO_PATH)
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
try {
audioServer!!.accept(
onMessage = { _, _ -> /* телефон в этот канал ничего не шлёт */ },
onConnected = { connId ->
audioSessions.add(connId)
Log.i(TAG, "Audio client connected: $connId")
},
onDisconnected = { connId ->
audioSessions.remove(connId)
Log.i(TAG, "Audio client disconnected: $connId")
},
)
} catch (t: Throwable) {
Log.e(TAG, "Audio server crashed", t)
}
}
}
/** Разбирает очередь кадров и рассылает их всем подключённым аудио-клиентам. */
private fun startAudioPump() {
lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) {
for (bytes in audioFrames) {
val server = audioServer ?: continue
for (connId in audioSessions) {
try {
server.send(connId, bytes)
} catch (t: Throwable) {
Log.w(TAG, "audio send failed: ${t.message}")
}
}
}
}
}
/** Применяет аудио-режим, пришедший с телефона. */
private fun applyAudioMode(mode: AudioMode) {
audioMode = mode
when (mode) {
AudioMode.ON_PHONE -> {
// Глушим динамик очков: звук уезжает на телефон, дубля быть не должно.
setOutputMuted(true)
ensureAudioCaptureLoop()
}
AudioMode.ON_GLASSES -> {
audioEnsureJob?.cancel()
audioEnsureJob = null
stopAudioCapture()
setOutputMuted(false)
}
}
}
/**
* Пока режим ON_PHONE и захват не поднят — периодически пробуем снова.
*
* Зачем цикл, а не один вызов: projection выдаёт MainActivity асинхронно, а `RECORD_AUDIO`
* может быть ещё не выдан (provisioning телефона выдаёт его через adb). Разовая попытка
* «выстреливает» только когда всё готово; цикл с интервалом [AUDIO_ENSURE_MS] дожимает.
* Частый ре-запрос projection вреден: новая проекция гасит предыдущую — отсюда read -2.
*/
private fun ensureAudioCaptureLoop() {
audioEnsureJob?.cancel()
audioEnsureJob = lifecycleScope.launch {
var fails = 0
while (isActive && audioMode == AudioMode.ON_PHONE && audioCapture == null) {
val projection = AudioBridge.projection.value
if (projection == null) {
requestProjection()
} else if (startAudioCapture(projection)) {
Log.i(TAG, "аудио-захват поднят")
break
} else {
// Захват не поднялся (нет RECORD_AUDIO / проекция мертва). Не дёргаем
// новую проекцию сразу — даём провижинингу/системе время; после
// нескольких неудач берём свежую проекцию.
fails++
if (fails >= 3) {
Log.w(TAG, "захват не поднимается ($fails) — берём новую проекцию")
AudioBridge.clearProjection()
fails = 0
}
}
kotlinx.coroutines.delay(AUDIO_ENSURE_MS)
}
}
}
private fun startAudioCapture(projection: android.media.projection.MediaProjection): Boolean {
if (audioCapture != null) return true
val capture = GlassesAudioCapture(
projection = projection,
sink = { bytes -> audioFrames.trySend(bytes) },
onStopped = { lifecycleScope.launch { onCaptureDied() } },
)
return if (capture.start(lifecycleScope)) {
audioCapture = capture
true
} else {
false
}
}
/** Захват умер сам (read-ошибка) — если режим ещё ON_PHONE, поднимаем заново. */
private fun onCaptureDied() {
if (audioCapture == null) return
audioCapture = null
if (audioMode == AudioMode.ON_PHONE) {
Log.w(TAG, "аудио-захват умер — перезапускаю")
ensureAudioCaptureLoop()
}
}
private fun stopAudioCapture() {
audioCapture?.stop()
audioCapture = null
}
private fun requestProjection() {
AudioBridge.requestProjection()
// MainActivity обычно уже жив (HOME), но если нет — поднимаем: запустить
// ActivityResult-лончер иначе некому.
runCatching {
startActivity(
Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
)
}.onFailure { Log.w(TAG, "Не удалось поднять MainActivity: ${it.message}") }
}
private fun setOutputMuted(muted: Boolean) {
runCatching {
val am = getSystemService(AUDIO_SERVICE) as AudioManager
val dir = if (muted) AudioManager.ADJUST_MUTE else AudioManager.ADJUST_UNMUTE
am.adjustStreamVolume(AudioManager.STREAM_MUSIC, dir, 0)
Log.i(TAG, "STREAM_MUSIC: ${if (muted) "mute" else "unmute"}")
}.onFailure { Log.w(TAG, "adjustStreamVolume failed: ${it.message}") }
}
/** Отправляет текущее состояние Shizuku конкретной сессии (ответ на запрос). */
private suspend fun sendShizukuStatus(connId: String) {
val snapshot = ShizukuBootstrap.probe()
@@ -244,6 +437,13 @@ class VmService : LifecycleService() {
// Не покрывает SIGKILL/reinstall — там наш код не выполняется вообще.
runCatching { controller.stopGuest() }
runCatching { controller.stopMercuryKillGuard() }
runCatching { audioEnsureJob?.cancel() }
runCatching { stopAudioCapture() }
runCatching { setOutputMuted(false) }
runCatching { audioFrames.close() }
runCatching { audioServer?.close() }
audioServer = null
audioSessions.clear()
unregisterMdns()
server?.close()
server = null
@@ -329,6 +529,14 @@ class VmService : LifecycleService() {
companion object {
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
private const val NOTIFICATION_ID = 100
private const val TAG = "VmService"
@@ -349,5 +557,49 @@ class VmService : LifecycleService() {
}
fun get(): VmService? = instance
/**
* Debug-only: запустить [GlassesAudioCapture] на [seconds] секунд с пустым sink
* (только логирование RMS в logcat). Доказывает, что MediaProjection + AudioPlaybackCapture
* реально поднимаются, без зависимости от WebSocket (который не работает вне общей L3-сети).
*/
fun requestAudioPreview(seconds: Int) {
instance?.let { svc ->
Log.i(TAG, "DEBUG requestAudioPreview ${seconds}s")
AudioBridge.requestProjection()
// Дальше запустим capture когда projection появится. Запускаем polling через lifecycleScope.
svc.lifecycleScope.launch {
val deadline = System.currentTimeMillis() + (seconds * 1000L + 8000L)
while (System.currentTimeMillis() < deadline) {
val p = AudioBridge.projection.value
if (p != null) {
val cap = GlassesAudioCapture(
projection = p,
sink = { /* no-op: RMS пишется внутри capture-цикла */ },
).apply { keepProjectionOnStop = true }
if (cap.start(svc.lifecycleScope)) {
Log.i(TAG, "DEBUG preview capture started")
kotlinx.coroutines.delay(seconds * 1000L)
cap.stop()
Log.i(TAG, "DEBUG preview capture stopped")
} else {
Log.w(TAG, "DEBUG preview capture failed to start")
}
break
}
kotlinx.coroutines.delay(500L)
}
}
} ?: Log.w(TAG, "requestAudioPreview: instance is null")
}
/**
* Debug-only: выставить аудио-режим, как если бы пришёл `SetAudioMode`
* по Channel 1 (нужно для тестов, когда WebSocket-канал недоступен).
*/
fun debugSetAudioMode(mode: AudioMode) {
instance?.applyAudioMode(mode)
?: Log.w(TAG, "debugSetAudioMode: instance is null")
}
}
}
@@ -0,0 +1,47 @@
package pw.binom.rayneovm.glasses.audio
import android.media.projection.MediaProjection
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Мост между [android.media.projection.MediaProjection] (получается только из Activity) и
* компонентами, которым нужен захват звука ([VmService]).
*
* MediaProjection API требует, чтобы `createScreenCaptureIntent()` запускался из Activity
* и результат обрабатывался в ней же. Поэтому MainActivity держит [requestTick]-подписку
* и по запросу поднимает системный интент; полученный projection кладётся сюда, а
* [VmService] его забирает.
*
* Диалог разрешения при выставленном appop `PROJECT_MEDIA = allow` не показывается —
* интент сразу возвращает RESULT_OK.
*/
object AudioBridge {
private val _requestTick = MutableStateFlow(0L)
/** Счётчик запросов на получение projection: MainActivity реагирует на изменение. */
val requestTick: StateFlow<Long> = _requestTick.asStateFlow()
private val _projection = MutableStateFlow<MediaProjection?>(null)
/** Текущий выданный projection (null — ещё не получен/сброшен). */
val projection: StateFlow<MediaProjection?> = _projection.asStateFlow()
/** Запросить у пользователя/системы новый projection (интент поднимет MainActivity). */
fun requestProjection() {
_requestTick.value = _requestTick.value + 1
}
fun onProjectionGranted(projection: MediaProjection) {
// Медиапроекция на приложение ровно одна: получив новую, гасим старую.
_projection.value?.let { runCatching { it.stop() } }
_projection.value = projection
}
fun clearProjection() {
_projection.value?.let { runCatching { it.stop() } }
_projection.value = null
}
}
@@ -1,159 +0,0 @@
package pw.binom.rayneovm.glasses.audio
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioPlaybackCaptureConfiguration
import android.media.AudioRecord
import android.media.MediaRecorder
import android.media.projection.MediaProjection
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import pw.binom.mercury.transport.ByteTransport
import pw.binom.mercury.transport.Transports
import pw.binom.rayneovm.glasses.MainActivity
import pw.binom.rayneovm.glasses.VmService
import pw.binom.rayneovm.shared.AudioMode
/**
* Foreground service: захват системного аудио и стрим на телефон.
*
* Два режима:
* - AudioPlaybackCapture (Android 12+): захват звука других приложений.
* Требует MediaProjection (получается через ActivityResult API).
* - MIC fallback (Android < 12 или без MediaProjection): обычный микрофон.
*
* Сейчас сервис стартует в MIC-режиме (для каркаса), реальный AudioPlaybackCapture
* требует Activity-инициацию через onActivityResult — TODO.
*/
class AudioCaptureService : android.app.Service() {
private var recorder: AudioRecord? = null
private var streamer: AudioStreamer? = null
private var transport: ByteTransport? = null
override fun onBind(intent: Intent): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
val modeExtra = intent?.getStringExtra(EXTRA_MODE)
if (modeExtra != AudioMode.ON_PHONE.name) {
Log.i(TAG, "Stop: mode=$modeExtra")
stopCapture()
stopSelf()
return START_NOT_STICKY
}
startForegroundCompat()
startCapture()
return START_STICKY
}
private fun startForegroundCompat() {
val channelId = createChannel()
val notification = buildNotification(channelId)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun startCapture() {
val sampleRate = SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_IN_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuffer = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding)
if (minBuffer <= 0) {
Log.w(TAG, "getMinBufferSize returned $minBuffer")
return
}
val bufferSize = minBuffer * 4
// MIC fallback. Реальный AudioPlaybackCaptureConfiguration требует MediaProjection,
// который получается через Activity.onActivityResult — TODO.
@Suppress("MissingPermission")
recorder = AudioRecord(
MediaRecorder.AudioSource.MIC,
sampleRate, channelMask, encoding, bufferSize
)
val wsUrl = "ws://127.0.0.1:${VmService.WS_PORT}/vm-audio"
transport = Transports.wifiBytes(wsUrl, autoReconnect = true)
streamer = AudioStreamer(recorder!!, transport!!)
recorder!!.startRecording()
kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) { streamer!!.run() }
Log.i(TAG, "AudioCapture (MIC fallback) started: $sampleRate Hz, buffer=$bufferSize bytes")
}
private fun stopCapture() {
recorder?.stop()
recorder?.release()
recorder = null
streamer?.stop()
streamer = null
transport?.close()
transport = null
Log.i(TAG, "AudioCapture stopped")
}
override fun onDestroy() {
super.onDestroy()
stopCapture()
}
private fun createChannel(): String {
val id = "vm_audio_capture"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val mgr = getSystemService(NotificationManager::class.java)
mgr.createNotificationChannel(NotificationChannel(id, "Audio Capture", NotificationManager.IMPORTANCE_LOW))
}
return id
}
private fun buildNotification(channelId: String): Notification {
val openApp = PendingIntent.getActivity(
this, 0, Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, channelId)
.setSmallIcon(android.R.drawable.ic_media_play)
.setContentTitle("RayNeo VM: Audio Capture")
.setContentText("Захват системного аудио")
.setContentIntent(openApp)
.setOngoing(true)
.build()
}
companion object {
private const val TAG = "AudioCaptureService"
private const val NOTIFICATION_ID = 200
private const val EXTRA_MODE = "mode"
const val SAMPLE_RATE = 44100
fun start(context: Context, mode: AudioMode) {
val intent = Intent(context, AudioCaptureService::class.java).apply {
putExtra(EXTRA_MODE, mode.name)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.stopService(Intent(context, AudioCaptureService::class.java))
}
}
}
@@ -1,62 +0,0 @@
package pw.binom.rayneovm.glasses.audio
import android.media.AudioRecord
import android.util.Log
import pw.binom.mercury.transport.ByteTransport
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Читает PCM из [AudioRecord] и шлёт сырые байты через [ByteTransport].
*
* Буфер — 4 KiB (примерно 23 мс @ 44.1 кГц 16-bit stereo). Задержка стрима — небольшая.
*
* TODO: добавить Opus-кодирование (30 KB/s вместо 176 KB/s) для BT-режима.
*/
class AudioStreamer(
private val recorder: AudioRecord,
private val transport: ByteTransport,
) {
@Volatile
private var stopped = false
suspend fun run() {
val buf = ByteArray(BUFFER_BYTES)
try {
transport.connect(
onMessage = { /* incoming: ignore */ },
onConnected = { /* ok */ },
onDisconnected = { Log.w(TAG, "transport disconnected"); stopped = true },
)
} catch (t: Throwable) {
Log.e(TAG, "transport connect failed", t)
return
}
withContext(Dispatchers.IO) {
while (!stopped) {
val read = recorder.read(buf, 0, buf.size)
if (read > 0) {
try {
transport.send(buf.copyOf(read))
} catch (t: Throwable) {
Log.w(TAG, "send failed: ${t.message}")
break
}
} else if (read < 0) {
Log.w(TAG, "AudioRecord.read error: $read")
break
}
}
}
}
fun stop() {
stopped = true
}
companion object {
private const val TAG = "AudioStreamer"
private const val BUFFER_BYTES = 4096
}
}
@@ -0,0 +1,152 @@
package pw.binom.rayneovm.glasses.audio
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioPlaybackCaptureConfiguration
import android.media.AudioRecord
import android.media.projection.MediaProjection
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Захват системного аудио очков через [AudioPlaybackCapture] (задача #2).
*
* Требует [MediaProjection] (см. [AudioBridge]) и appop `PROJECT_MEDIA = allow`.
*
* Собранные PCM-байты отдаются в [sink] на `Dispatchers.IO`. Раз в ~1 c пишется RMS
* в лог: `0 = тишина` (источник молчит), `>0 = реальный звук`.
*
* Если [onStopped] не `null`, вызывается когда цикл захвата умер сам
* (например, [AudioRecord.read] вернул ошибку) и нужно пере-стартовать.
*/
class GlassesAudioCapture(
private val projection: MediaProjection,
private val sink: suspend (ByteArray) -> Unit,
/** Вызывается, если цикл чтения завершился сам (ошибка [AudioRecord]) — не при [stop]. */
private val onStopped: (() -> Unit)? = null,
) {
private var record: AudioRecord? = null
private var job: Job? = null
@Volatile private var running = false
/**
* `true` — при [stop] НЕ вызывать [MediaProjection.stop].
* Используется в debug-preview (`DEBUG_TEST_AUDIO_PIPE`), когда проекцию выдавать отдельно.
*/
var keepProjectionOnStop: Boolean = false
fun start(scope: CoroutineScope): Boolean {
if (running) return true
val sampleRate = SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_IN_STEREO
val encoding = AudioFormat.ENCODING_PCM_16BIT
val minBuf = AudioRecord.getMinBufferSize(sampleRate, channelMask, encoding)
if (minBuf <= 0) {
Log.w(TAG, "getMinBufferSize=$minBuf")
return false
}
val bufferSize = minBuf * 4
val rec = try {
val config = AudioPlaybackCaptureConfiguration.Builder(projection)
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
.addMatchingUsage(AudioAttributes.USAGE_GAME)
.addMatchingUsage(AudioAttributes.USAGE_UNKNOWN)
.build()
AudioRecord.Builder()
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setEncoding(encoding)
.setChannelMask(channelMask)
.build(),
)
.setBufferSizeInBytes(bufferSize)
.setAudioPlaybackCaptureConfig(config)
.build()
} catch (t: Throwable) {
Log.e(TAG, "AudioRecord.Builder упал: ${t.message}", t)
return false
}
if (rec.state != AudioRecord.STATE_INITIALIZED) {
Log.e(TAG, "AudioRecord не инициализирован (state=${rec.state}); вероятно нет PROJECT_MEDIA")
rec.release()
return false
}
record = rec
running = true
rec.startRecording()
job = scope.launch(Dispatchers.IO) {
val buf = ByteArray(FRAME_BYTES)
var frameCount = 0
var totalBytes = 0L
while (isActive && running) {
val read = rec.read(buf, 0, buf.size)
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()}")
}
} else if (read < 0) {
Log.w(TAG, "AudioRecord.read ошибка: $read")
break
}
}
if (running && isActive) {
running = false
Log.w(TAG, "цикл захвата завершился сам — сообщаю наверх")
onStopped?.invoke()
}
}
Log.i(TAG, "AudioPlaybackCapture запущен: $sampleRate Гц stereo, buffer=$bufferSize")
return true
}
fun stop() {
running = false
job?.cancel()
job = null
record?.let {
runCatching { it.stop() }
runCatching { it.release() }
}
record = null
Log.i(TAG, "AudioPlaybackCapture остановлен")
if (!keepProjectionOnStop) {
runCatching { projection.stop() }
}
}
private fun rms16(buf: ByteArray, len: Int): Double {
if (len < 2) 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()
sumSq += s.toDouble() * s
i += 2
}
return Math.sqrt(sumSq / (len / 2))
}
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,130 @@
package pw.binom.rayneovm.glasses.debug
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import pw.binom.rayneovm.glasses.VmService
import pw.binom.rayneovm.shared.AudioMode
/**
* Отладочные команды для очков. Защищены разрешением `android.permission.DUMP`.
*
* Использование:
* ```
* adb -s A06B4AB933C4103 shell am broadcast \
* -a pw.binom.rayneovm.glasses.DEBUG_TEST_TONE \
* --ei seconds 10 \
* -n pw.binom.rayneovm.glasses/.debug.GlassesDebugReceiver
* ```
*
* Тестовый тон пишет 440 Гц PCM 16-bit стерео через AudioTrack.USAGE_MEDIA → гарантированно
* попадает в AudioFlinger и проверяет:
* - динамик очков работает (до mute в #2);
* - аудио-захват AudioPlaybackCapture на очках действительно ловит поток (через log RMS).
*/
class GlassesDebugReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_TEST_TONE -> TestTone.play(intent.getIntExtra("seconds", 10))
ACTION_STOP_TONE -> TestTone.stop()
ACTION_AUDIO_PIPE ->
VmService.requestAudioPreview(intent.getIntExtra("seconds", 8))
ACTION_STOP_PHONE_AUDIO -> VmService.debugSetAudioMode(AudioMode.ON_GLASSES)
ACTION_START_PHONE_AUDIO -> VmService.debugSetAudioMode(AudioMode.ON_PHONE)
}
}
companion object {
const val ACTION_TEST_TONE = "pw.binom.rayneovm.glasses.DEBUG_TEST_TONE"
const val ACTION_STOP_TONE = "pw.binom.rayneovm.glasses.DEBUG_STOP_TONE"
const val ACTION_AUDIO_PIPE = "pw.binom.rayneovm.glasses.DEBUG_AUDIO_PIPE"
const val ACTION_START_PHONE_AUDIO = "pw.binom.rayneovm.glasses.DEBUG_AUDIO_PHONE"
const val ACTION_STOP_PHONE_AUDIO = "pw.binom.rayneovm.glasses.DEBUG_AUDIO_STOP"
}
}
/** Глобальный односекундный синус 440 Гц PCM16 стерео для самотестирования аудио-тракта очков. */
object TestTone {
private const val TAG = "TestTone"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var job: Job? = null
private val lock = Any()
fun play(seconds: Int) {
synchronized(lock) {
job?.cancel()
job = scope.launch {
val sr = 44100
val stereo = AudioFormat.CHANNEL_OUT_STEREO
val pcm16 = AudioFormat.ENCODING_PCM_16BIT
val minBuf = AudioTrack.getMinBufferSize(sr, stereo, pcm16)
if (minBuf <= 0) {
Log.w(TAG, "getMinBufferSize=$minBuf (не запускаем тон)")
return@launch
}
val track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build(),
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sr)
.setEncoding(pcm16)
.setChannelMask(stereo)
.build(),
)
.setBufferSizeInBytes(minBuf * 2)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
runCatching { track.play() }.onFailure { Log.e(TAG, "track.play() failed", it) }
Log.i(TAG, "test tone start: $sr Hz stereo, ${seconds}s")
val totalFrames = sr * seconds
val buf = ShortArray(2048)
var phase = 0.0
val step = 2.0 * Math.PI * 440.0 / sr
var written = 0
while (isActive && written < totalFrames) {
for (i in buf.indices step 2) {
val s = (Math.sin(phase) * 8000.0).toInt().toShort()
buf[i] = s
buf[i + 1] = s
phase += step
if (phase > 2 * Math.PI) phase -= 2 * Math.PI
}
track.write(buf, 0, buf.size)
written += buf.size / 2
}
runCatching { track.stop() }
track.release()
Log.i(TAG, "test tone done: written=${written * 2}bytes")
}
}
}
fun stop() {
synchronized(lock) {
job?.cancel()
job = null
Log.i(TAG, "test tone stopped by request")
}
}
fun release() {
stop()
scope.cancel()
}
}
@@ -64,6 +64,7 @@
<action android:name="pw.binom.rayneovm.phone.DEBUG_SET_DEEP_SUSPEND" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_ADB_SHELL" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_REFRESH_STATUS" />
<action android:name="pw.binom.rayneovm.phone.DEBUG_SET_AUDIO" />
</intent-filter>
</receiver>
</application>
@@ -76,15 +76,24 @@ class AudioPlaybackManager(private val context: Context) {
transport = Transports.wifiBytes(wsUrl, autoReconnect = true)
streamJob = scope.launch {
var totalBytes = 0L
var frames = 0
try {
transport!!.connect(
onMessage = { pcm ->
val t = track ?: return@connect
t.write(pcm, 0, pcm.size)
totalBytes += pcm.size
frames++
if (frames % 40 == 0) {
Log.i(TAG, "playing: bytes=$totalBytes rms=${rms16(pcm).toInt()}")
}
},
onConnected = { Log.i(TAG, "audio connected to $wsUrl") },
onDisconnected = { Log.w(TAG, "audio disconnected") },
)
} catch (t: kotlinx.coroutines.CancellationException) {
// Штатная остановка (stop()/смена режима) — не ошибка.
} catch (t: Throwable) {
Log.e(TAG, "audio transport failed", t)
}
@@ -100,9 +109,23 @@ 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))
}
companion object {
private const val TAG = "AudioPlaybackMgr"
const val SAMPLE_RATE = 44100
const val GLASSES_PORT = 8080
/** Совпадает с [pw.binom.rayneovm.glasses.VmService.AUDIO_PORT]. */
const val GLASSES_PORT = 8081
}
}
@@ -55,6 +55,13 @@ class ShizukuProvisioner(
@Volatile
private var provisionedInSession = false
/**
* Разовые системные разрешения на очках (RECORD_AUDIO, appop PROJECT_MEDIA) —
* выдаём один раз за сессию. Без них AudioPlaybackCapture (задача #2) не поднимется.
*/
@Volatile
private var grantsAppliedInSession = false
/**
* Проверить Shizuku и, если не запущен, поднять его. Безопасно вызывать часто:
* дешёвая проверка через WiFi-статус, полный adb-цикл — максимум один раз за BLE-сессию.
@@ -65,6 +72,7 @@ class ShizukuProvisioner(
"ensure(): вызов, состояние=${_state.value}, shizuku=${controller.glassesShizuku.value}, " +
"provisioned=$provisionedInSession",
)
maybeApplyGrants()
scope.launch {
val shizuku = controller.glassesShizuku.value
// Shizuku точно работает — ничего не делаем.
@@ -92,9 +100,26 @@ class ShizukuProvisioner(
}
}
/**
* Разовые разрешения на очках. Идемпотентно, один раз за сессию. Не блокирует ensure():
* `adb.ensureConnected()` переиспользует соединение, поднятое бутстрапом Shizuku.
*/
private fun maybeApplyGrants() {
if (grantsAppliedInSession) return
scope.launch {
val serial = adb.ensureConnected() ?: return@launch
Log.i(TAG, "применяю системные гранты на очках ($serial)")
val grant = adb.shell("pm grant $GLASSES_PACKAGE android.permission.RECORD_AUDIO", timeoutMs = 15_000)
val appop = adb.shell("cmd appops set $GLASSES_PACKAGE PROJECT_MEDIA allow", timeoutMs = 15_000)
Log.i(TAG, "гранты: RECORD_AUDIO=${grant.isSuccess}, PROJECT_MEDIA=${appop.isSuccess}")
if (grant.isSuccess && appop.isSuccess) grantsAppliedInSession = true
}
}
/** BLE-сессия разорвана: при следующем подключении проверяем Shizuku заново. */
fun onBleDisconnected() {
provisionedInSession = false
grantsAppliedInSession = false
// wireless-adb очки гасят сами при потере сети — соединение считать мёртвым.
adb.invalidate()
}
@@ -11,6 +11,8 @@ import kotlinx.coroutines.launch
import pw.binom.rayneovm.phone.AdbControllerProvider
import pw.binom.rayneovm.phone.ShizukuProvisionerProvider
import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider
import pw.binom.rayneovm.phone.protocol.ControllerProvider
import pw.binom.rayneovm.shared.AudioMode
/**
* Диагностический приёмник (shell/system, `android.permission.DUMP`):
@@ -63,6 +65,13 @@ class DebugReceiver : BroadcastReceiver() {
}
}
ACTION_SET_AUDIO -> {
val controller = (app as? ControllerProvider)?.provideController() ?: return
val mode = if (intent.getIntExtra("mode", 0) != 0) AudioMode.ON_PHONE else AudioMode.ON_GLASSES
Log.i(TAG, "DEBUG_SET_AUDIO mode=$mode")
scope.launch { controller.setAudioMode(mode) }
}
else -> {
val provider = app as? ShizukuProvisionerProvider ?: return
Log.i(TAG, "получен ${intent.action} — запускаю бутстрап Shizuku")
@@ -82,5 +91,6 @@ class DebugReceiver : BroadcastReceiver() {
const val ACTION_SET_DEEP_SUSPEND = "pw.binom.rayneovm.phone.DEBUG_SET_DEEP_SUSPEND"
const val ACTION_REFRESH_STATUS = "pw.binom.rayneovm.phone.DEBUG_REFRESH_STATUS"
const val ACTION_ADB_SHELL = "pw.binom.rayneovm.phone.DEBUG_ADB_SHELL"
const val ACTION_SET_AUDIO = "pw.binom.rayneovm.phone.DEBUG_SET_AUDIO"
}
}
@@ -57,6 +57,10 @@ class VmPhoneController(
private var currentTransport: StringTransport? = null
private val audioManager by lazy { AudioPlaybackManager(context) }
/** Текущий аудио-режим (зеркалит то, что отправлено очкам) — для UI-переключателя. */
private val _audioMode = MutableStateFlow(pw.binom.rayneovm.shared.AudioMode.ON_GLASSES)
val audioMode: StateFlow<pw.binom.rayneovm.shared.AudioMode> = _audioMode.asStateFlow()
fun start() {
if (scope.coroutineContext[Job]?.children?.any { it.isActive } == true) return
scope.launch { reconnectLoop() }
@@ -106,10 +110,18 @@ class VmPhoneController(
// Очки сами присылают ShizukuStatus при подключении; на всякий
// случай дублируем запросом — первый ответ легко теряется.
scope.launch { send(RequestShizukuStatus) }
// Канал поднялся: если выбран вывод звука на телефон — восстанавливаем
// его (после реконнекта очков захват на той стороне уже остановлен).
if (_audioMode.value == pw.binom.rayneovm.shared.AudioMode.ON_PHONE) {
scope.launch { send(SetAudioMode(pw.binom.rayneovm.shared.AudioMode.ON_PHONE)) }
audioManager.start(service.host)
}
},
onDisconnected = {
currentTransport = null
_glassesShizuku.value = null
// Очки отвалились — аудио-поток всё равно мёртв, глушим трек.
audioManager.stop()
_state.value = ConnectionState.Disconnected
},
)
@@ -218,13 +230,19 @@ class VmPhoneController(
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
*/
suspend fun setAudioMode(mode: pw.binom.rayneovm.shared.AudioMode) {
_audioMode.value = mode
send(SetAudioMode(mode))
when (mode) {
pw.binom.rayneovm.shared.AudioMode.ON_GLASSES -> audioManager.stop()
pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> {
val host = (currentTransport as? pw.binom.mercury.transport.WifiTransport)?.let { null }
?: (_state.value as? ConnectionState.Connected)?.host
if (host != null) audioManager.start(host)
val host = (_state.value as? ConnectionState.Connected)?.host
if (host != null) {
audioManager.start(host)
} else {
// Не подключены — команда уйдёт при следующем подключении не сразу,
// поэтому честно сообщаем в лог: аудио не поднимется.
android.util.Log.w("VmPhoneController", "ON_PHONE, но канал не подключён — аудио не стартует")
}
}
}
}
@@ -50,6 +50,7 @@ import pw.binom.rayneovm.phone.protocol.ConnectionState
import pw.binom.rayneovm.phone.protocol.VmPhoneController
import pw.binom.rayneovm.phone.bootstrap.ProvisionState
import pw.binom.rayneovm.phone.bootstrap.ShizukuProvisioner
import pw.binom.rayneovm.shared.AudioMode
import pw.binom.rayneovm.shared.ShizukuStatus
/**
@@ -81,6 +82,7 @@ fun GlassesStatusScreen(
val wifiScanning by mercuryController.wifiScanning.collectAsState()
val adbAddress by mercuryController.wirelessAdbAddress.collectAsState()
val adbEnabling by mercuryController.wirelessAdbEnabling.collectAsState()
val audioMode by controller.audioMode.collectAsState()
val scope = rememberCoroutineScope()
val mercuryConnected = mercury is MercuryState.Connected
@@ -144,6 +146,12 @@ fun GlassesStatusScreen(
onSetScreen = { sleep -> controller.setScreen(sleep) },
)
AudioOutputSection(
mode = audioMode,
enabled = connection is ConnectionState.Connected,
onChange = { mode -> controller.setAudioMode(mode) },
)
GlassesWifiSection(
status = generalStatus,
networks = wifiNetworks,
@@ -374,6 +382,44 @@ private fun ScreenPowerSection(
}
}
@Composable
private fun AudioOutputSection(
mode: AudioMode,
enabled: Boolean,
onChange: suspend (AudioMode) -> Unit,
) {
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
val onPhone = mode == AudioMode.ON_PHONE
SectionCard(title = "Звук") {
Hint("Куда выводить звук приложений с виртуального экрана очков.")
ToggleRow(
label = if (onPhone) "Звук на телефоне" else "Звук на очках",
checked = onPhone,
onCheckedChange = { phone ->
if (!busy && enabled) {
busy = true
scope.launch {
try {
onChange(if (phone) AudioMode.ON_PHONE else AudioMode.ON_GLASSES)
} finally {
busy = false
}
}
}
},
)
when {
!enabled -> Hint("Нужен Канал 1 (WebSocket) — приложение на очках не подключено.")
onPhone -> Hint(
"Динамик очков глушится, звук идёт на телефон. При первом включении " +
"система может на секунду запросить разрешение на захват."
)
}
}
}
@Composable
private fun GlassesWifiSection(
status: GeneralStatus?,