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:
@@ -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?,
|
||||
|
||||
Reference in New Issue
Block a user