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:
+73
@@ -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`.
|
||||||
@@ -169,3 +169,15 @@ shell `settings put` работает.
|
|||||||
- Порядок работы ночью: **#2 → #1 → #3 → #4 → #5** (по убыванию риска/размера).
|
- Порядок работы ночью: **#2 → #1 → #3 → #4 → #5** (по убыванию риска/размера).
|
||||||
- Коммиты/пуш — по завершении блоков; отдельно rayneo-vm и rayneo-mercury.
|
- Коммиты/пуш — по завершении блоков; отдельно 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) на постоянку — можно; при равнозначных развилках
|
||||||
|
выбирать самому и документировать.
|
||||||
|
|||||||
@@ -73,11 +73,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Foreground service: WebSocket server + mDNS -->
|
<!-- Foreground service: WebSocket server + mDNS + захват аудио (задача #2) -->
|
||||||
<service
|
<service
|
||||||
android:name=".VmService"
|
android:name=".VmService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="mediaPlayback" />
|
android:foregroundServiceType="mediaPlayback|mediaProjection" />
|
||||||
|
|
||||||
<!-- Пассивный Accessibility-сервис (задача #5): живучесть процесса +
|
<!-- Пассивный Accessibility-сервис (задача #5): живучесть процесса +
|
||||||
легальный запуск Activity из фона. Ничего не читает и не инжектит.
|
легальный запуск Activity из фона. Ничего не читает и не инжектит.
|
||||||
@@ -111,6 +111,20 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
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" />
|
<meta-data android:name="com.rayneo.mercury.app" android:value="true" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package pw.binom.rayneovm.glasses
|
package pw.binom.rayneovm.glasses
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.media.projection.MediaProjectionManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
import android.view.Display
|
import android.view.Display
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -20,12 +24,41 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.protocol.VmGlassesController
|
||||||
import pw.binom.rayneovm.glasses.ui.GlassesScreen
|
import pw.binom.rayneovm.glasses.ui.GlassesScreen
|
||||||
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
|
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
// HOME живёт на display 0. Если систему (или гостя на VirtualDisplay)
|
// HOME живёт на display 0. Если систему (или гостя на VirtualDisplay)
|
||||||
@@ -38,6 +71,23 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
ShizukuBootstrap.attach(this)
|
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)
|
val controller = VmGlassesController.fromContext(this)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
@@ -76,6 +126,17 @@ class MainActivity : ComponentActivity() {
|
|||||||
ShizukuBootstrap.onRequestPermissionResult(requestCode, grant)
|
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
|
@Composable
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.app.PendingIntent
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.media.AudioManager
|
||||||
import android.net.nsd.NsdManager
|
import android.net.nsd.NsdManager
|
||||||
import android.net.nsd.NsdServiceInfo
|
import android.net.nsd.NsdServiceInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -15,11 +16,17 @@ import android.util.Log
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.lifecycle.LifecycleService
|
import androidx.lifecycle.LifecycleService
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import pw.binom.mercury.transport.ServerTransport
|
import pw.binom.mercury.transport.ServerTransport
|
||||||
import pw.binom.mercury.transport.Transports
|
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.protocol.VmGlassesController
|
||||||
import pw.binom.rayneovm.glasses.vm.GlassesScreenState
|
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.KeyEvent as VmKeyEvent
|
||||||
import pw.binom.rayneovm.shared.LaunchAppCommand
|
import pw.binom.rayneovm.shared.LaunchAppCommand
|
||||||
import pw.binom.rayneovm.shared.Ping
|
import pw.binom.rayneovm.shared.Ping
|
||||||
@@ -58,6 +65,26 @@ class VmService : LifecycleService() {
|
|||||||
private var nsdRegistered: Boolean = false
|
private var nsdRegistered: Boolean = false
|
||||||
private lateinit var controller: VmGlassesController
|
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() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
instance = this
|
instance = this
|
||||||
@@ -69,12 +96,23 @@ class VmService : LifecycleService() {
|
|||||||
controller.ensureMercuryKillGuard()
|
controller.ensureMercuryKillGuard()
|
||||||
startForeground()
|
startForeground()
|
||||||
startServer()
|
startServer()
|
||||||
|
startAudioServer()
|
||||||
|
startAudioPump()
|
||||||
registerMdns()
|
registerMdns()
|
||||||
// Сообщаем телефону о Shizuku, как только меняется его состояние (сервер поднялся
|
// Сообщаем телефону о Shizuku, как только меняется его состояние (сервер поднялся
|
||||||
// или нам выдали permission) — чтобы телефон не опрашивал вручную.
|
// или нам выдали permission) — чтобы телефон не опрашивал вручную.
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
ShizukuBootstrap.state.collect { pushShizukuStatus() }
|
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 {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
@@ -91,7 +129,14 @@ class VmService : LifecycleService() {
|
|||||||
val channelId = createNotificationChannel()
|
val channelId = createNotificationChannel()
|
||||||
val notification = buildNotification(channelId)
|
val notification = buildNotification(channelId)
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
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 {
|
} else {
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
}
|
}
|
||||||
@@ -167,7 +212,10 @@ class VmService : LifecycleService() {
|
|||||||
Log.d(TAG, "Key: ${msg.keyCode} action=${msg.action}")
|
Log.d(TAG, "Key: ${msg.keyCode} action=${msg.action}")
|
||||||
controller.sendKey(msg.keyCode, 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 -> {
|
is ScreenControl -> {
|
||||||
Log.i(TAG, "ScreenControl: sleep=${msg.sleep}")
|
Log.i(TAG, "ScreenControl: sleep=${msg.sleep}")
|
||||||
controller.setScreen(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 конкретной сессии (ответ на запрос). */
|
/** Отправляет текущее состояние Shizuku конкретной сессии (ответ на запрос). */
|
||||||
private suspend fun sendShizukuStatus(connId: String) {
|
private suspend fun sendShizukuStatus(connId: String) {
|
||||||
val snapshot = ShizukuBootstrap.probe()
|
val snapshot = ShizukuBootstrap.probe()
|
||||||
@@ -244,6 +437,13 @@ class VmService : LifecycleService() {
|
|||||||
// Не покрывает SIGKILL/reinstall — там наш код не выполняется вообще.
|
// Не покрывает SIGKILL/reinstall — там наш код не выполняется вообще.
|
||||||
runCatching { controller.stopGuest() }
|
runCatching { controller.stopGuest() }
|
||||||
runCatching { controller.stopMercuryKillGuard() }
|
runCatching { controller.stopMercuryKillGuard() }
|
||||||
|
runCatching { audioEnsureJob?.cancel() }
|
||||||
|
runCatching { stopAudioCapture() }
|
||||||
|
runCatching { setOutputMuted(false) }
|
||||||
|
runCatching { audioFrames.close() }
|
||||||
|
runCatching { audioServer?.close() }
|
||||||
|
audioServer = null
|
||||||
|
audioSessions.clear()
|
||||||
unregisterMdns()
|
unregisterMdns()
|
||||||
server?.close()
|
server?.close()
|
||||||
server = null
|
server = null
|
||||||
@@ -329,6 +529,14 @@ class VmService : LifecycleService() {
|
|||||||
companion object {
|
companion object {
|
||||||
const val WS_PORT = 8080
|
const val WS_PORT = 8080
|
||||||
const val WS_PATH = "/vm"
|
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 NOTIFICATION_ID = 100
|
||||||
private const val TAG = "VmService"
|
private const val TAG = "VmService"
|
||||||
|
|
||||||
@@ -349,5 +557,49 @@ class VmService : LifecycleService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun get(): VmService? = instance
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
-159
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+152
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+130
@@ -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_SET_DEEP_SUSPEND" />
|
||||||
<action android:name="pw.binom.rayneovm.phone.DEBUG_ADB_SHELL" />
|
<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_REFRESH_STATUS" />
|
||||||
|
<action android:name="pw.binom.rayneovm.phone.DEBUG_SET_AUDIO" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -76,15 +76,24 @@ class AudioPlaybackManager(private val context: Context) {
|
|||||||
transport = Transports.wifiBytes(wsUrl, autoReconnect = true)
|
transport = Transports.wifiBytes(wsUrl, autoReconnect = true)
|
||||||
|
|
||||||
streamJob = scope.launch {
|
streamJob = scope.launch {
|
||||||
|
var totalBytes = 0L
|
||||||
|
var frames = 0
|
||||||
try {
|
try {
|
||||||
transport!!.connect(
|
transport!!.connect(
|
||||||
onMessage = { pcm ->
|
onMessage = { pcm ->
|
||||||
val t = track ?: return@connect
|
val t = track ?: return@connect
|
||||||
t.write(pcm, 0, pcm.size)
|
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") },
|
onConnected = { Log.i(TAG, "audio connected to $wsUrl") },
|
||||||
onDisconnected = { Log.w(TAG, "audio disconnected") },
|
onDisconnected = { Log.w(TAG, "audio disconnected") },
|
||||||
)
|
)
|
||||||
|
} catch (t: kotlinx.coroutines.CancellationException) {
|
||||||
|
// Штатная остановка (stop()/смена режима) — не ошибка.
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Log.e(TAG, "audio transport failed", t)
|
Log.e(TAG, "audio transport failed", t)
|
||||||
}
|
}
|
||||||
@@ -100,9 +109,23 @@ class AudioPlaybackManager(private val context: Context) {
|
|||||||
Log.i(TAG, "AudioPlaybackManager stopped")
|
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 {
|
companion object {
|
||||||
private const val TAG = "AudioPlaybackMgr"
|
private const val TAG = "AudioPlaybackMgr"
|
||||||
const val SAMPLE_RATE = 44100
|
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
|
@Volatile
|
||||||
private var provisionedInSession = false
|
private var provisionedInSession = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Разовые системные разрешения на очках (RECORD_AUDIO, appop PROJECT_MEDIA) —
|
||||||
|
* выдаём один раз за сессию. Без них AudioPlaybackCapture (задача #2) не поднимется.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var grantsAppliedInSession = false
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Проверить Shizuku и, если не запущен, поднять его. Безопасно вызывать часто:
|
* Проверить Shizuku и, если не запущен, поднять его. Безопасно вызывать часто:
|
||||||
* дешёвая проверка через WiFi-статус, полный adb-цикл — максимум один раз за BLE-сессию.
|
* дешёвая проверка через WiFi-статус, полный adb-цикл — максимум один раз за BLE-сессию.
|
||||||
@@ -65,6 +72,7 @@ class ShizukuProvisioner(
|
|||||||
"ensure(): вызов, состояние=${_state.value}, shizuku=${controller.glassesShizuku.value}, " +
|
"ensure(): вызов, состояние=${_state.value}, shizuku=${controller.glassesShizuku.value}, " +
|
||||||
"provisioned=$provisionedInSession",
|
"provisioned=$provisionedInSession",
|
||||||
)
|
)
|
||||||
|
maybeApplyGrants()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val shizuku = controller.glassesShizuku.value
|
val shizuku = controller.glassesShizuku.value
|
||||||
// Shizuku точно работает — ничего не делаем.
|
// 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 заново. */
|
/** BLE-сессия разорвана: при следующем подключении проверяем Shizuku заново. */
|
||||||
fun onBleDisconnected() {
|
fun onBleDisconnected() {
|
||||||
provisionedInSession = false
|
provisionedInSession = false
|
||||||
|
grantsAppliedInSession = false
|
||||||
// wireless-adb очки гасят сами при потере сети — соединение считать мёртвым.
|
// wireless-adb очки гасят сами при потере сети — соединение считать мёртвым.
|
||||||
adb.invalidate()
|
adb.invalidate()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import kotlinx.coroutines.launch
|
|||||||
import pw.binom.rayneovm.phone.AdbControllerProvider
|
import pw.binom.rayneovm.phone.AdbControllerProvider
|
||||||
import pw.binom.rayneovm.phone.ShizukuProvisionerProvider
|
import pw.binom.rayneovm.phone.ShizukuProvisionerProvider
|
||||||
import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider
|
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`):
|
* Диагностический приёмник (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 -> {
|
else -> {
|
||||||
val provider = app as? ShizukuProvisionerProvider ?: return
|
val provider = app as? ShizukuProvisionerProvider ?: return
|
||||||
Log.i(TAG, "получен ${intent.action} — запускаю бутстрап Shizuku")
|
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_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_REFRESH_STATUS = "pw.binom.rayneovm.phone.DEBUG_REFRESH_STATUS"
|
||||||
const val ACTION_ADB_SHELL = "pw.binom.rayneovm.phone.DEBUG_ADB_SHELL"
|
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 var currentTransport: StringTransport? = null
|
||||||
private val audioManager by lazy { AudioPlaybackManager(context) }
|
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() {
|
fun start() {
|
||||||
if (scope.coroutineContext[Job]?.children?.any { it.isActive } == true) return
|
if (scope.coroutineContext[Job]?.children?.any { it.isActive } == true) return
|
||||||
scope.launch { reconnectLoop() }
|
scope.launch { reconnectLoop() }
|
||||||
@@ -106,10 +110,18 @@ class VmPhoneController(
|
|||||||
// Очки сами присылают ShizukuStatus при подключении; на всякий
|
// Очки сами присылают ShizukuStatus при подключении; на всякий
|
||||||
// случай дублируем запросом — первый ответ легко теряется.
|
// случай дублируем запросом — первый ответ легко теряется.
|
||||||
scope.launch { send(RequestShizukuStatus) }
|
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 = {
|
onDisconnected = {
|
||||||
currentTransport = null
|
currentTransport = null
|
||||||
_glassesShizuku.value = null
|
_glassesShizuku.value = null
|
||||||
|
// Очки отвалились — аудио-поток всё равно мёртв, глушим трек.
|
||||||
|
audioManager.stop()
|
||||||
_state.value = ConnectionState.Disconnected
|
_state.value = ConnectionState.Disconnected
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -218,13 +230,19 @@ class VmPhoneController(
|
|||||||
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
|
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
|
||||||
*/
|
*/
|
||||||
suspend fun setAudioMode(mode: pw.binom.rayneovm.shared.AudioMode) {
|
suspend fun setAudioMode(mode: pw.binom.rayneovm.shared.AudioMode) {
|
||||||
|
_audioMode.value = mode
|
||||||
send(SetAudioMode(mode))
|
send(SetAudioMode(mode))
|
||||||
when (mode) {
|
when (mode) {
|
||||||
pw.binom.rayneovm.shared.AudioMode.ON_GLASSES -> audioManager.stop()
|
pw.binom.rayneovm.shared.AudioMode.ON_GLASSES -> audioManager.stop()
|
||||||
pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> {
|
pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> {
|
||||||
val host = (currentTransport as? pw.binom.mercury.transport.WifiTransport)?.let { null }
|
val host = (_state.value as? ConnectionState.Connected)?.host
|
||||||
?: (_state.value as? ConnectionState.Connected)?.host
|
if (host != null) {
|
||||||
if (host != null) audioManager.start(host)
|
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.protocol.VmPhoneController
|
||||||
import pw.binom.rayneovm.phone.bootstrap.ProvisionState
|
import pw.binom.rayneovm.phone.bootstrap.ProvisionState
|
||||||
import pw.binom.rayneovm.phone.bootstrap.ShizukuProvisioner
|
import pw.binom.rayneovm.phone.bootstrap.ShizukuProvisioner
|
||||||
|
import pw.binom.rayneovm.shared.AudioMode
|
||||||
import pw.binom.rayneovm.shared.ShizukuStatus
|
import pw.binom.rayneovm.shared.ShizukuStatus
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +82,7 @@ fun GlassesStatusScreen(
|
|||||||
val wifiScanning by mercuryController.wifiScanning.collectAsState()
|
val wifiScanning by mercuryController.wifiScanning.collectAsState()
|
||||||
val adbAddress by mercuryController.wirelessAdbAddress.collectAsState()
|
val adbAddress by mercuryController.wirelessAdbAddress.collectAsState()
|
||||||
val adbEnabling by mercuryController.wirelessAdbEnabling.collectAsState()
|
val adbEnabling by mercuryController.wirelessAdbEnabling.collectAsState()
|
||||||
|
val audioMode by controller.audioMode.collectAsState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val mercuryConnected = mercury is MercuryState.Connected
|
val mercuryConnected = mercury is MercuryState.Connected
|
||||||
@@ -144,6 +146,12 @@ fun GlassesStatusScreen(
|
|||||||
onSetScreen = { sleep -> controller.setScreen(sleep) },
|
onSetScreen = { sleep -> controller.setScreen(sleep) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
AudioOutputSection(
|
||||||
|
mode = audioMode,
|
||||||
|
enabled = connection is ConnectionState.Connected,
|
||||||
|
onChange = { mode -> controller.setAudioMode(mode) },
|
||||||
|
)
|
||||||
|
|
||||||
GlassesWifiSection(
|
GlassesWifiSection(
|
||||||
status = generalStatus,
|
status = generalStatus,
|
||||||
networks = wifiNetworks,
|
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
|
@Composable
|
||||||
private fun GlassesWifiSection(
|
private fun GlassesWifiSection(
|
||||||
status: GeneralStatus?,
|
status: GeneralStatus?,
|
||||||
|
|||||||
Reference in New Issue
Block a user