fix(audio,#2): переиспользование одного MediaProjection — устранён слом повторного ON_PHONE

- MainActivity: requestTick собирается в repeatOnLifecycle(RESUMED) + флаг AudioBridge.activityResumed
- requestProjection(): не дёргает startActivity(MainActivity), пока Activity RESUMED (гонка с grant)
- ensureAudioCaptureLoop(): запрос проекции один раз, повтор не чаще 20 c (было 2.5 c)
- AudioBridge/GlassesAudioCapture: проекция одна на процесс и не гасится, переиспользуется
- ShizukuProvisioner: PROJECT_MEDIA per-UID (на Android 12 package-level = no-op)
- отладочные логи phone-side audio; BLOCKERS.md B-3 закрыт (E2E проверен)
This commit is contained in:
2026-09-27 12:49:05 +03:00
parent c485306bc4
commit 3808281d42
8 changed files with 161 additions and 82 deletions
@@ -34,7 +34,11 @@ class AudioPlaybackManager(private val context: Context) {
private var wakeLock: PowerManager.WakeLock? = null
fun start(glassesHost: String) {
if (track != null) return // уже запущено
if (track != null) {
Log.w(TAG, "start: уже running — пропускаю (host=$glassesHost)")
return
}
Log.i(TAG, "start: host=$glassesHost")
val sampleRate = SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_OUT_STEREO
@@ -73,6 +77,7 @@ class AudioPlaybackManager(private val context: Context) {
// Подключаемся к очкам по тому же ws://host:port/vm-audio
val wsUrl = "ws://$glassesHost:${GLASSES_PORT}/vm-audio"
Log.i(TAG, "start: подключаюсь к $wsUrl")
transport = Transports.wifiBytes(wsUrl, autoReconnect = true)
streamJob = scope.launch {
@@ -82,25 +87,31 @@ class AudioPlaybackManager(private val context: Context) {
transport!!.connect(
onMessage = { pcm ->
val t = track ?: return@connect
t.write(pcm, 0, pcm.size)
if (frames == 0 || frames % 40 == 0) {
Log.i(TAG, "playing: frame#=$frames size=${pcm.size} bytes=$totalBytes rms=${rms16(pcm).toInt()}")
}
val written = t.write(pcm, 0, pcm.size)
if (written < 0) {
Log.w(TAG, "playing: AudioTrack.write вернул $written (size=${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") },
onDisconnected = { Log.w(TAG, "audio disconnected from $wsUrl") },
)
} catch (t: kotlinx.coroutines.CancellationException) {
Log.i(TAG, "start: stream job cancelled (stop)")
// Штатная остановка (stop()/смена режима) — не ошибка.
} catch (t: Throwable) {
Log.e(TAG, "audio transport failed", t)
}
Log.i(TAG, "start: stream job завершился frames=$frames bytes=$totalBytes")
}
}
fun stop() {
Log.i(TAG, "stop: track=${track != null} transport=${transport != null}")
streamJob?.cancel(); streamJob = null
transport?.close(); transport = null
track?.stop(); track?.release(); track = null
@@ -103,6 +103,11 @@ class ShizukuProvisioner(
/**
* Разовые разрешения на очках. Идемпотентно, один раз за сессию. Не блокирует ensure():
* `adb.ensureConnected()` переиспользует соединение, поднятое бутстрапом Shizuku.
*
* `PROJECT_MEDIA` ставится и для пакета, и для UID — иначе на свежих прошивках
* `MediaProjectionManager.createScreenCaptureIntent()` возвращает `RESULT_CANCELED`,
* даже если `cmd appops get <pkg> PROJECT_MEDIA` показывает `allow`. Проверено на
* RayNeo X3 Pro / Android 12 (см. BLOCKERS.md, B-3 follow-up).
*/
private fun maybeApplyGrants() {
if (grantsAppliedInSession) return
@@ -110,7 +115,19 @@ class ShizukuProvisioner(
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)
val appopPkg = adb.shell("cmd appops set $GLASSES_PACKAGE PROJECT_MEDIA allow", timeoutMs = 15_000)
// PROJECT_MEDIA на Android 12+ привязан к UID, не к пакету: даже если per-package
// стоит allow, диалог проекции сразу RESULT_CANCELED пока per-UID = ignore.
val uidOut = adb.shell("cmd package list packages -U $GLASSES_PACKAGE", timeoutMs = 15_000).getOrNull()
val uid = uidOut?.lineSequence()
?.mapNotNull { Regex("""uid:(\d+)""").find(it)?.groupValues?.get(1)?.toIntOrNull() }
?.firstOrNull()
val appopUid = if (uid != null) {
adb.shell("cmd appops set --uid $uid PROJECT_MEDIA allow", timeoutMs = 15_000)
} else {
Log.w(TAG, "не удалось узнать UID $GLASSES_PACKAGE — PROJECT_MEDIA per-UID не выставлен")
Result.failure(IllegalStateException("uid not found"))
}
// #4.1: назначаем наш VmNoopImeService дефолтным IME навсегда (см. [VmNoopImeService]).
// Подавляет системный IME, который гость (Zona) поднимает при фокусе в поле ввода.
val ime = adb.shell(
@@ -120,9 +137,12 @@ class ShizukuProvisioner(
)
Log.i(
TAG,
"гранты: RECORD_AUDIO=${grant.isSuccess}, PROJECT_MEDIA=${appop.isSuccess}, IME=${ime.isSuccess}",
"гранты: RECORD_AUDIO=${grant.isSuccess}, PROJECT_MEDIA[pkg]=${appopPkg.isSuccess}" +
"[uid=$uid]=${appopUid.isSuccess}, IME=${ime.isSuccess}",
)
if (grant.isSuccess && appop.isSuccess && ime.isSuccess) grantsAppliedInSession = true
if (grant.isSuccess && appopPkg.isSuccess && appopUid.isSuccess && ime.isSuccess) {
grantsAppliedInSession = true
}
}
}
@@ -242,6 +242,7 @@ class VmPhoneController(
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
*/
suspend fun setAudioMode(mode: pw.binom.rayneovm.shared.AudioMode) {
android.util.Log.i("VmPhoneController", "setAudioMode: prev=${_audioMode.value} → $mode state=${_state.value}")
_audioMode.value = mode
send(SetAudioMode(mode))
when (mode) {
@@ -249,6 +250,7 @@ class VmPhoneController(
pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> {
val host = (_state.value as? ConnectionState.Connected)?.host
if (host != null) {
android.util.Log.i("VmPhoneController", "setAudioMode: ON_PHONE → audioManager.start($host)")
audioManager.start(host)
} else {
// Не подключены — команда уйдёт при следующем подключении не сразу,
@@ -257,6 +259,7 @@ class VmPhoneController(
}
}
}
android.util.Log.i("VmPhoneController", "setAudioMode done: mode=$mode audioManager=${audioManager}")
}
companion object {