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
@@ -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 {