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:
@@ -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, "проекция сохранена (живёт до конца процесса)")
|
||||
}
|
||||
}
|
||||
|
||||
+6
-10
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user