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
+40 -20
View File
@@ -6,7 +6,7 @@
---
## B-1 · Периодический лаунч `Mercury/.NetConfigActivity` — НЕ ПОДТВЕРЖДЁН
## B-1 · Периодический лаунч `Mercury/.NetConfigActivity` — ПОДТВЕРЖДЁН, источник не найден
**Когда замечено:** 2026-09-27, во время тестов запуска Zona через `DEBUG_LAUNCH`.
@@ -16,7 +16,12 @@ cls=com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity displayId=
**Что это на самом деле:** Непонятно. Это мог быть мой собственный `GlassesDebugReceiver`,
который я перепосылал несколько раз и он где-то зациклился; мог быть артефакт system-job’а;
мог быть какой-то fallback в `VmGlassesController.ensureNetworking()`. **Не блокер.**
мог быть какой-то fallback в `VmGlassesController.ensureNetworking()`.
**Повторно замечено:** 2026-09-27 12:28:40, `START ... NetConfigActivity from uid 2000` (shell)
в логах очков во время отладки звука. `uid 2000` = shell ⇒ запускает **Shizuku UserService**
(наш код через Shizuku). Значит источник — где-то в нашем Shizuku-пути (не Mercury BLE).
На аудио-grant влияния не оказал (график grant сломался по другой причине — см. B-3).
**Что НЕ сделано:** не остановил очки в момент пика, не нашёл источник цикла. Пользователь
утром сказал «сейчас вижу наше приложение», значит цикл или закончился, или не повторяется.
@@ -63,30 +68,45 @@ settings put global adb_wifi_enabled 1 # не дать ему ав
---
## B-3 · Звук end-to-end — РЕШЕНО на стороне очков
## B-3 · Звук end-to-end — РЕШЕНО
**Когда замечено:** 2026-09-27, E2E-проверка после восстановления общей сети «O2 5».
**Что доказано работающим на очках:**
- `MediaProjection` получается без диалога (`PROJECT_MEDIA: allow` уже стоит с ночи).
- `AudioPlaybackCapture` стартует, читает PCM: `GlassesAudioCapture: capture: bytes=... rms=...`.
- 440 Гц TestTone → RMS=5674 (теория ~5657). **Реальный звук захвачен, не тишина**.
- Динамик очков мутится (`STREAM_MUSIC: mute`).
- Поток PCM по 4 КБ фреймам идёт в `Channel<ByteArray>(capacity=16)`, готов к WebSocket.
- TCP-порт `8081` на очках (`192.168.76.248:8081/vm-audio`) отвечает с телефона.
**Симптом:** звук на телефон работал только в первый раз; после `ON_GLASSES` → `ON_PHONE` тишина
и на очках, и на телефоне.
**Что не проверено вживую:** воспроизведение на телефоне. AudioPlaybackManager код опубликован,
но не вызван (`controller.setAudioMode(ON_PHONE)` никто не дёрнул). UI-переключатель на
телефоне готов, но нажать его можно только с руки (в этой сессии PhoneDebugReceiver я не
реализовал, broadcast дропается HONOR).
**Корень (найден по логам 2026-09-27):** не сеть и не телефон, а **grant MediaProjection на очках**.
`ensureAudioCaptureLoop` ре-запрашивал проекцию каждые 2.5 c, а `requestProjection()` каждый раз
дёргал `startActivity(MainActivity, NEW_TASK|SINGLE_TOP)`. Система:
```
START com.android.systemui/.media.MediaProjectionPermissionActivity from uid 10090
→ UsageStats event :23 (ACTIVITY_STOPPED) через ~30 мс
→ MainActivity: MediaProjection не выдан: resultCode=0
```
то есть grant-активити гасится, если её запускает только что перезапущенная Activity. Ручной
`KEYCODE_HOME` (уводит нашу Activity с переднего плана) «разблокировал» grant — подтверждало
гипотезу гонки.
**Известный race-condition:** `MainActivity.handledTick` при двойном `requestProjection`
с коротким интервалом пересоздаёт projection → `AudioRecord.read -2`. Не блокирует нормальную
работу (UI нажимается один раз → один tick), но при отладке DEBUG_AUDIO_PIPE после уже
активного захвата ломает capture. Чиню при случае.
**Фикс (вариант A):**
1. `MainActivity` собирает `requestTick` в `repeatOnLifecycle(**RESUMED**)` (был `STARTED`) и флагом
`AudioBridge.activityResumed` сообщает сервису, что она наверху.
2. `requestProjection()` больше **не** вызывает `startActivity(MainActivity)`, пока Activity уже
`RESUMED` (убрана гонка с запуском consent-активити).
3. `ensureAudioCaptureLoop` запрашивает проекцию **один раз**; повтор — не чаще `AUDIO_RETRY_MS=20 c`.
4. Проекция **одна на процесс и не гасится**: `GlassesAudioCapture.stop()` останавливает только
`AudioRecord`, а `AudioBridge.onProjectionGranted` не трогает уже имеющуюся. Следующий `ON_PHONE`
переиспользует ту же проекцию — новый grant не нужен вовсе.
**Следующий шаг:** нажать в UI телефона переключатель «Звук на телефоне» — должно
зазвучать. Если нет — проверить `adb -s AXGL024B05001337 logcat -s AudioPlaybackManager:V`.
**Проверено вживую 2026-09-27 (очки A06B4AB933C4103 → телефон AXGL024B05001337):**
- `[1] ON_PHONE` → `MediaProjection получен` **без HOME** → захват запущен.
- `[2] ON_GLASSES` → `остановлен (проекцию сохраняю)`.
- `[3] ON_PHONE` → `аудио-захват поднят`, **без нового grant** (переиспользование).
- Тестовый тон 440 Гц: очки `rms=5673`, телефон `playing ... rms=5620..5698`, байты совпали
(`bytes=4751360` с обеих сторон). **PCM реально дошёл по `/vm-audio`.**
**Проверено с руки пользователя 2026-09-27:** полный сценарий с Zona — `ON_PHONE` → `ON_GLASSES`
→ `ON_PHONE` — звук корректно переключается, повторный `ON_PHONE` больше не ломается.
**B-3 закрыт полностью.**
---
@@ -72,14 +72,14 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
ShizukuBootstrap.attach(this)
// Запросы MediaProjection приходят из VmService через AudioBridge (задача #2).
// Запускаем интент только когда Activity хотя бы STARTED, иначе лончер кинет.
// Запускаем интент только когда Activity в RESUMED (реально видима): если её запускать
// из не-foreground состояния, системная grant-активити отменяется и возвращает CANCELED.
//
// handledTick обязателен: repeatOnLifecycle перезапускает сбор при каждом выходе
// Activity в STARTED (а после диалога проекции мы как раз возвращаемся в STARTED).
// Без него один и тот же tick запускал бы запрос повторно, новая проекция гасила
// предыдущую, и AudioRecord умирал с read -2.
// Activity в RESUMED (а после диалога проекции мы как раз возвращаемся в RESUMED).
// Без него один и тот же tick запускал бы запрос повторно.
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
AudioBridge.requestTick.collect { tick ->
if (tick > handledTick) {
handledTick = tick
@@ -112,9 +112,15 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
AudioBridge.activityResumed = true
ShizukuBootstrap.refresh()
}
override fun onPause() {
AudioBridge.activityResumed = false
super.onPause()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
@@ -307,36 +307,35 @@ class VmService : LifecycleService() {
}
/**
* Пока режим ON_PHONE и захват не поднят — периодически пробуем снова.
* Пока режим ON_PHONE и захват не поднят — доводим его до старта.
*
* Зачем цикл, а не один вызов: projection выдаёт MainActivity асинхронно, а `RECORD_AUDIO`
* может быть ещё не выдан (provisioning телефона выдаёт его через adb). Разовая попытка
* «выстреливает» только когда всё готово; цикл с интервалом [AUDIO_ENSURE_MS] дожимает.
* Частый ре-запрос projection вреден: новая проекция гасит предыдущую — отсюда read -2.
* Проекцию запрашиваем [requestProjection] **один раз** (если её ещё нет). Повторный запрос —
* только если проекция так и не появилась за [AUDIO_RETRY_MS]. Каждый запрос поднимает
* системную grant-активити, а её нельзя запускать, пока наша Activity только-только
* перезапускается — система такие запуски отменяет (`resultCode=0`). Поэтому прежний цикл с
* ре-запросом каждые 2.5 c сам себя и ломал.
*
* Полученную проекцию НЕ гасим ([GlassesAudioCapture.stop] не трогает проекцию): она живёт
* до конца процесса и переиспользуется на все последующие ON_PHONE.
*/
private fun ensureAudioCaptureLoop() {
audioEnsureJob?.cancel()
audioEnsureJob = lifecycleScope.launch {
var fails = 0
if (AudioBridge.projection.value == null) requestProjection()
var waited = 0L
while (isActive && audioMode == AudioMode.ON_PHONE && audioCapture == null) {
val projection = AudioBridge.projection.value
if (projection == null) {
requestProjection()
} else if (startAudioCapture(projection)) {
if (projection != null && 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)
waited += AUDIO_ENSURE_MS
if (projection == null && waited >= AUDIO_RETRY_MS) {
Log.w(TAG, "проекции всё ещё нет — повторный запрос")
requestProjection()
waited = 0L
}
}
}
}
@@ -373,15 +372,18 @@ class VmService : LifecycleService() {
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}") }
// MainActivity обычно уже жива и в RESUMED — тогда она сама обработает tick, и трогать
// её нельзя: `startActivity` в этот момент гоняется с запуском consent-активити и
// приводит к отмене grant (resultCode=0). Поднимаем Activity только если она не наверху.
if (!AudioBridge.activityResumed) {
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) {
@@ -540,9 +542,12 @@ class VmService : LifecycleService() {
const val AUDIO_PORT = 8081
const val AUDIO_PATH = "/vm-audio"
/** Интервал ретраев подъёма аудио-захвата (пока режим ON_PHONE). */
/** Интервал опроса готовности аудио-захвата (пока режим ON_PHONE). */
private const val AUDIO_ENSURE_MS = 2_500L
/** Через сколько без проекции повторить её запрос (не чаще — иначе гонка grant). */
private const val AUDIO_RETRY_MS = 20_000L
private const val NOTIFICATION_ID = 100
private const val TAG = "VmService"
@@ -582,7 +587,7 @@ class VmService : LifecycleService() {
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)
@@ -1,6 +1,7 @@
package pw.binom.rayneovm.glasses.audio
import android.media.projection.MediaProjection
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -14,11 +15,18 @@ import kotlinx.coroutines.flow.asStateFlow
* и по запросу поднимает системный интент; полученный projection кладётся сюда, а
* [VmService] его забирает.
*
* Важно (задача #2): проекция **одна на процесс и не гасится**. Повторный grant на этом ROM
* ненадёжен — системная grant-активити отменяется, если её запускает только что перезапущенная
* Activity. Поэтому полученную проекцию мы держим живой и переиспользуем для всех последующих
* `ON_PHONE` (останавливаем только [android.media.AudioRecord], не проекцию).
*
* Диалог разрешения при выставленном appop `PROJECT_MEDIA = allow` не показывается —
* интент сразу возвращает RESULT_OK.
*/
object AudioBridge {
private const val TAG = "AudioBridge"
private val _requestTick = MutableStateFlow(0L)
/** Счётчик запросов на получение projection: MainActivity реагирует на изменение. */
@@ -26,22 +34,32 @@ object AudioBridge {
private val _projection = MutableStateFlow<MediaProjection?>(null)
/** Текущий выданный projection (null — ещё не получен/сброшен). */
/** Текущий выданный projection (null — ещё не получен). Живёт до конца процесса. */
val projection: StateFlow<MediaProjection?> = _projection.asStateFlow()
/**
* `true`, пока MainActivity в RESUMED. Нужен [VmService], чтобы не дёргать
* `startActivity(MainActivity)` (это ломает grant — гонка с запуском consent-активити),
* когда Activity уже поднята и сама обработает [requestTick].
*/
@Volatile
var activityResumed: Boolean = false
/** Запросить у пользователя/системы новый projection (интент поднимет MainActivity). */
fun requestProjection() {
_requestTick.value = _requestTick.value + 1
}
/**
* Принять выданную проекцию. Если проекция уже есть — новую игнорируем и НЕ гасим старую:
* на приложение допускается одна проекция, а её переиспользование — основа стратегии #2.
*/
fun onProjectionGranted(projection: MediaProjection) {
// Медиапроекция на приложение ровно одна: получив новую, гасим старую.
_projection.value?.let { runCatching { it.stop() } }
if (_projection.value != null) {
Log.w(TAG, "проекция уже есть — новую игнорирую (старую не трогаю)")
return
}
_projection.value = projection
}
fun clearProjection() {
_projection.value?.let { runCatching { it.stop() } }
_projection.value = null
Log.i(TAG, "проекция сохранена (живёт до конца процесса)")
}
}
@@ -33,12 +33,6 @@ class GlassesAudioCapture(
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
@@ -115,6 +109,11 @@ class GlassesAudioCapture(
return true
}
/**
* Останавливает только [AudioRecord]. Проекцию НЕ гасит: она одна на процесс и
* переиспользуется (см. [AudioBridge]) — иначе следующий `ON_PHONE` требовал бы нового
* grant, который на этом ROM ненадёжен.
*/
fun stop() {
running = false
job?.cancel()
@@ -124,10 +123,7 @@ class GlassesAudioCapture(
runCatching { it.release() }
}
record = null
Log.i(TAG, "AudioPlaybackCapture остановлен")
if (!keepProjectionOnStop) {
runCatching { projection.stop() }
}
Log.i(TAG, "AudioPlaybackCapture остановлен (проекцию сохраняю)")
}
private fun rms16(buf: ByteArray, len: Int): Double {
@@ -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 {