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`. **Когда замечено:** 2026-09-27, во время тестов запуска Zona через `DEBUG_LAUNCH`.
@@ -16,7 +16,12 @@ cls=com.ffalconxr.mercury.launcher.wizard.netconfig.NetConfigActivity displayId=
**Что это на самом деле:** Непонятно. Это мог быть мой собственный `GlassesDebugReceiver`, **Что это на самом деле:** Непонятно. Это мог быть мой собственный `GlassesDebugReceiver`,
который я перепосылал несколько раз и он где-то зациклился; мог быть артефакт system-job’а; который я перепосылал несколько раз и он где-то зациклился; мог быть артефакт 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». **Когда замечено:** 2026-09-27, E2E-проверка после восстановления общей сети «O2 5».
**Что доказано работающим на очках:** **Симптом:** звук на телефон работал только в первый раз; после `ON_GLASSES` → `ON_PHONE` тишина
- `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`) отвечает с телефона.
**Что не проверено вживую:** воспроизведение на телефоне. AudioPlaybackManager код опубликован, **Корень (найден по логам 2026-09-27):** не сеть и не телефон, а **grant MediaProjection на очках**.
но не вызван (`controller.setAudioMode(ON_PHONE)` никто не дёрнул). UI-переключатель на `ensureAudioCaptureLoop` ре-запрашивал проекцию каждые 2.5 c, а `requestProjection()` каждый раз
телефоне готов, но нажать его можно только с руки (в этой сессии PhoneDebugReceiver я не дёргал `startActivity(MainActivity, NEW_TASK|SINGLE_TOP)`. Система:
реализовал, broadcast дропается HONOR). ```
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` **Фикс (вариант A):**
с коротким интервалом пересоздаёт projection → `AudioRecord.read -2`. Не блокирует нормальную 1. `MainActivity` собирает `requestTick` в `repeatOnLifecycle(**RESUMED**)` (был `STARTED`) и флагом
работу (UI нажимается один раз → один tick), но при отладке DEBUG_AUDIO_PIPE после уже `AudioBridge.activityResumed` сообщает сервису, что она наверху.
активного захвата ломает capture. Чиню при случае. 2. `requestProjection()` больше **не** вызывает `startActivity(MainActivity)`, пока Activity уже
`RESUMED` (убрана гонка с запуском consent-активити).
3. `ensureAudioCaptureLoop` запрашивает проекцию **один раз**; повтор — не чаще `AUDIO_RETRY_MS=20 c`.
4. Проекция **одна на процесс и не гасится**: `GlassesAudioCapture.stop()` останавливает только
`AudioRecord`, а `AudioBridge.onProjectionGranted` не трогает уже имеющуюся. Следующий `ON_PHONE`
переиспользует ту же проекцию — новый grant не нужен вовсе.
**Следующий шаг:** нажать в UI телефона переключатель «Звук на телефоне» — должно **Проверено вживую 2026-09-27 (очки A06B4AB933C4103 → телефон AXGL024B05001337):**
зазвучать. Если нет — проверить `adb -s AXGL024B05001337 logcat -s AudioPlaybackManager:V`. - `[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() enableEdgeToEdge()
ShizukuBootstrap.attach(this) ShizukuBootstrap.attach(this)
// Запросы MediaProjection приходят из VmService через AudioBridge (задача #2). // Запросы MediaProjection приходят из VmService через AudioBridge (задача #2).
// Запускаем интент только когда Activity хотя бы STARTED, иначе лончер кинет. // Запускаем интент только когда Activity в RESUMED (реально видима): если её запускать
// из не-foreground состояния, системная grant-активити отменяется и возвращает CANCELED.
// //
// handledTick обязателен: repeatOnLifecycle перезапускает сбор при каждом выходе // handledTick обязателен: repeatOnLifecycle перезапускает сбор при каждом выходе
// Activity в STARTED (а после диалога проекции мы как раз возвращаемся в STARTED). // Activity в RESUMED (а после диалога проекции мы как раз возвращаемся в RESUMED).
// Без него один и тот же tick запускал бы запрос повторно, новая проекция гасила // Без него один и тот же tick запускал бы запрос повторно.
// предыдущую, и AudioRecord умирал с read -2.
lifecycleScope.launch { lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) { repeatOnLifecycle(Lifecycle.State.RESUMED) {
AudioBridge.requestTick.collect { tick -> AudioBridge.requestTick.collect { tick ->
if (tick > handledTick) { if (tick > handledTick) {
handledTick = tick handledTick = tick
@@ -112,9 +112,15 @@ class MainActivity : ComponentActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
AudioBridge.activityResumed = true
ShizukuBootstrap.refresh() ShizukuBootstrap.refresh()
} }
override fun onPause() {
AudioBridge.activityResumed = false
super.onPause()
}
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(
requestCode: Int, requestCode: Int,
permissions: Array<String>, permissions: Array<String>,
@@ -307,36 +307,35 @@ class VmService : LifecycleService() {
} }
/** /**
* Пока режим ON_PHONE и захват не поднят — периодически пробуем снова. * Пока режим ON_PHONE и захват не поднят — доводим его до старта.
* *
* Зачем цикл, а не один вызов: projection выдаёт MainActivity асинхронно, а `RECORD_AUDIO` * Проекцию запрашиваем [requestProjection] **один раз** (если её ещё нет). Повторный запрос —
* может быть ещё не выдан (provisioning телефона выдаёт его через adb). Разовая попытка * только если проекция так и не появилась за [AUDIO_RETRY_MS]. Каждый запрос поднимает
* «выстреливает» только когда всё готово; цикл с интервалом [AUDIO_ENSURE_MS] дожимает. * системную grant-активити, а её нельзя запускать, пока наша Activity только-только
* Частый ре-запрос projection вреден: новая проекция гасит предыдущую — отсюда read -2. * перезапускается — система такие запуски отменяет (`resultCode=0`). Поэтому прежний цикл с
* ре-запросом каждые 2.5 c сам себя и ломал.
*
* Полученную проекцию НЕ гасим ([GlassesAudioCapture.stop] не трогает проекцию): она живёт
* до конца процесса и переиспользуется на все последующие ON_PHONE.
*/ */
private fun ensureAudioCaptureLoop() { private fun ensureAudioCaptureLoop() {
audioEnsureJob?.cancel() audioEnsureJob?.cancel()
audioEnsureJob = lifecycleScope.launch { audioEnsureJob = lifecycleScope.launch {
var fails = 0 if (AudioBridge.projection.value == null) requestProjection()
var waited = 0L
while (isActive && audioMode == AudioMode.ON_PHONE && audioCapture == null) { while (isActive && audioMode == AudioMode.ON_PHONE && audioCapture == null) {
val projection = AudioBridge.projection.value val projection = AudioBridge.projection.value
if (projection == null) { if (projection != null && startAudioCapture(projection)) {
requestProjection()
} else if (startAudioCapture(projection)) {
Log.i(TAG, "аудио-захват поднят") Log.i(TAG, "аудио-захват поднят")
break break
} else {
// Захват не поднялся (нет RECORD_AUDIO / проекция мертва). Не дёргаем
// новую проекцию сразу — даём провижинингу/системе время; после
// нескольких неудач берём свежую проекцию.
fails++
if (fails >= 3) {
Log.w(TAG, "захват не поднимается ($fails) — берём новую проекцию")
AudioBridge.clearProjection()
fails = 0
}
} }
kotlinx.coroutines.delay(AUDIO_ENSURE_MS) kotlinx.coroutines.delay(AUDIO_ENSURE_MS)
waited += AUDIO_ENSURE_MS
if (projection == null && waited >= AUDIO_RETRY_MS) {
Log.w(TAG, "проекции всё ещё нет — повторный запрос")
requestProjection()
waited = 0L
}
} }
} }
} }
@@ -373,8 +372,10 @@ class VmService : LifecycleService() {
private fun requestProjection() { private fun requestProjection() {
AudioBridge.requestProjection() AudioBridge.requestProjection()
// MainActivity обычно уже жив (HOME), но если нет — поднимаем: запустить // MainActivity обычно уже жива и в RESUMED — тогда она сама обработает tick, и трогать
// ActivityResult-лончер иначе некому. // её нельзя: `startActivity` в этот момент гоняется с запуском consent-активити и
// приводит к отмене grant (resultCode=0). Поднимаем Activity только если она не наверху.
if (!AudioBridge.activityResumed) {
runCatching { runCatching {
startActivity( startActivity(
Intent(this, MainActivity::class.java).apply { Intent(this, MainActivity::class.java).apply {
@@ -383,6 +384,7 @@ class VmService : LifecycleService() {
) )
}.onFailure { Log.w(TAG, "Не удалось поднять MainActivity: ${it.message}") } }.onFailure { Log.w(TAG, "Не удалось поднять MainActivity: ${it.message}") }
} }
}
private fun setOutputMuted(muted: Boolean) { private fun setOutputMuted(muted: Boolean) {
runCatching { runCatching {
@@ -540,9 +542,12 @@ class VmService : LifecycleService() {
const val AUDIO_PORT = 8081 const val AUDIO_PORT = 8081
const val AUDIO_PATH = "/vm-audio" const val AUDIO_PATH = "/vm-audio"
/** Интервал ретраев подъёма аудио-захвата (пока режим ON_PHONE). */ /** Интервал опроса готовности аудио-захвата (пока режим ON_PHONE). */
private const val AUDIO_ENSURE_MS = 2_500L 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 NOTIFICATION_ID = 100
private const val TAG = "VmService" private const val TAG = "VmService"
@@ -582,7 +587,7 @@ class VmService : LifecycleService() {
val cap = GlassesAudioCapture( val cap = GlassesAudioCapture(
projection = p, projection = p,
sink = { /* no-op: RMS пишется внутри capture-цикла */ }, sink = { /* no-op: RMS пишется внутри capture-цикла */ },
).apply { keepProjectionOnStop = true } )
if (cap.start(svc.lifecycleScope)) { if (cap.start(svc.lifecycleScope)) {
Log.i(TAG, "DEBUG preview capture started") Log.i(TAG, "DEBUG preview capture started")
kotlinx.coroutines.delay(seconds * 1000L) kotlinx.coroutines.delay(seconds * 1000L)
@@ -1,6 +1,7 @@
package pw.binom.rayneovm.glasses.audio package pw.binom.rayneovm.glasses.audio
import android.media.projection.MediaProjection import android.media.projection.MediaProjection
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -14,11 +15,18 @@ import kotlinx.coroutines.flow.asStateFlow
* и по запросу поднимает системный интент; полученный projection кладётся сюда, а * и по запросу поднимает системный интент; полученный projection кладётся сюда, а
* [VmService] его забирает. * [VmService] его забирает.
* *
* Важно (задача #2): проекция **одна на процесс и не гасится**. Повторный grant на этом ROM
* ненадёжен — системная grant-активити отменяется, если её запускает только что перезапущенная
* Activity. Поэтому полученную проекцию мы держим живой и переиспользуем для всех последующих
* `ON_PHONE` (останавливаем только [android.media.AudioRecord], не проекцию).
*
* Диалог разрешения при выставленном appop `PROJECT_MEDIA = allow` не показывается — * Диалог разрешения при выставленном appop `PROJECT_MEDIA = allow` не показывается —
* интент сразу возвращает RESULT_OK. * интент сразу возвращает RESULT_OK.
*/ */
object AudioBridge { object AudioBridge {
private const val TAG = "AudioBridge"
private val _requestTick = MutableStateFlow(0L) private val _requestTick = MutableStateFlow(0L)
/** Счётчик запросов на получение projection: MainActivity реагирует на изменение. */ /** Счётчик запросов на получение projection: MainActivity реагирует на изменение. */
@@ -26,22 +34,32 @@ object AudioBridge {
private val _projection = MutableStateFlow<MediaProjection?>(null) private val _projection = MutableStateFlow<MediaProjection?>(null)
/** Текущий выданный projection (null — ещё не получен/сброшен). */ /** Текущий выданный projection (null — ещё не получен). Живёт до конца процесса. */
val projection: StateFlow<MediaProjection?> = _projection.asStateFlow() val projection: StateFlow<MediaProjection?> = _projection.asStateFlow()
/**
* `true`, пока MainActivity в RESUMED. Нужен [VmService], чтобы не дёргать
* `startActivity(MainActivity)` (это ломает grant — гонка с запуском consent-активити),
* когда Activity уже поднята и сама обработает [requestTick].
*/
@Volatile
var activityResumed: Boolean = false
/** Запросить у пользователя/системы новый projection (интент поднимет MainActivity). */ /** Запросить у пользователя/системы новый projection (интент поднимет MainActivity). */
fun requestProjection() { fun requestProjection() {
_requestTick.value = _requestTick.value + 1 _requestTick.value = _requestTick.value + 1
} }
/**
* Принять выданную проекцию. Если проекция уже есть — новую игнорируем и НЕ гасим старую:
* на приложение допускается одна проекция, а её переиспользование — основа стратегии #2.
*/
fun onProjectionGranted(projection: MediaProjection) { fun onProjectionGranted(projection: MediaProjection) {
// Медиапроекция на приложение ровно одна: получив новую, гасим старую. if (_projection.value != null) {
_projection.value?.let { runCatching { it.stop() } } Log.w(TAG, "проекция уже есть — новую игнорирую (старую не трогаю)")
_projection.value = projection return
} }
_projection.value = projection
fun clearProjection() { Log.i(TAG, "проекция сохранена (живёт до конца процесса)")
_projection.value?.let { runCatching { it.stop() } }
_projection.value = null
} }
} }
@@ -33,12 +33,6 @@ class GlassesAudioCapture(
private var job: Job? = null private var job: Job? = null
@Volatile private var running = false @Volatile private var running = false
/**
* `true` — при [stop] НЕ вызывать [MediaProjection.stop].
* Используется в debug-preview (`DEBUG_TEST_AUDIO_PIPE`), когда проекцию выдавать отдельно.
*/
var keepProjectionOnStop: Boolean = false
fun start(scope: CoroutineScope): Boolean { fun start(scope: CoroutineScope): Boolean {
if (running) return true if (running) return true
val sampleRate = SAMPLE_RATE val sampleRate = SAMPLE_RATE
@@ -115,6 +109,11 @@ class GlassesAudioCapture(
return true return true
} }
/**
* Останавливает только [AudioRecord]. Проекцию НЕ гасит: она одна на процесс и
* переиспользуется (см. [AudioBridge]) — иначе следующий `ON_PHONE` требовал бы нового
* grant, который на этом ROM ненадёжен.
*/
fun stop() { fun stop() {
running = false running = false
job?.cancel() job?.cancel()
@@ -124,10 +123,7 @@ class GlassesAudioCapture(
runCatching { it.release() } runCatching { it.release() }
} }
record = null record = null
Log.i(TAG, "AudioPlaybackCapture остановлен") Log.i(TAG, "AudioPlaybackCapture остановлен (проекцию сохраняю)")
if (!keepProjectionOnStop) {
runCatching { projection.stop() }
}
} }
private fun rms16(buf: ByteArray, len: Int): Double { private fun rms16(buf: ByteArray, len: Int): Double {
@@ -34,7 +34,11 @@ class AudioPlaybackManager(private val context: Context) {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
fun start(glassesHost: String) { 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 sampleRate = SAMPLE_RATE
val channelMask = AudioFormat.CHANNEL_OUT_STEREO val channelMask = AudioFormat.CHANNEL_OUT_STEREO
@@ -73,6 +77,7 @@ class AudioPlaybackManager(private val context: Context) {
// Подключаемся к очкам по тому же ws://host:port/vm-audio // Подключаемся к очкам по тому же ws://host:port/vm-audio
val wsUrl = "ws://$glassesHost:${GLASSES_PORT}/vm-audio" val wsUrl = "ws://$glassesHost:${GLASSES_PORT}/vm-audio"
Log.i(TAG, "start: подключаюсь к $wsUrl")
transport = Transports.wifiBytes(wsUrl, autoReconnect = true) transport = Transports.wifiBytes(wsUrl, autoReconnect = true)
streamJob = scope.launch { streamJob = scope.launch {
@@ -82,25 +87,31 @@ class AudioPlaybackManager(private val context: Context) {
transport!!.connect( transport!!.connect(
onMessage = { pcm -> onMessage = { pcm ->
val t = track ?: return@connect 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 totalBytes += pcm.size
frames++ frames++
if (frames % 40 == 0) {
Log.i(TAG, "playing: bytes=$totalBytes rms=${rms16(pcm).toInt()}")
}
}, },
onConnected = { Log.i(TAG, "audio connected to $wsUrl") }, 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) { } catch (t: kotlinx.coroutines.CancellationException) {
Log.i(TAG, "start: stream job cancelled (stop)")
// Штатная остановка (stop()/смена режима) — не ошибка. // Штатная остановка (stop()/смена режима) — не ошибка.
} catch (t: Throwable) { } catch (t: Throwable) {
Log.e(TAG, "audio transport failed", t) Log.e(TAG, "audio transport failed", t)
} }
Log.i(TAG, "start: stream job завершился frames=$frames bytes=$totalBytes")
} }
} }
fun stop() { fun stop() {
Log.i(TAG, "stop: track=${track != null} transport=${transport != null}")
streamJob?.cancel(); streamJob = null streamJob?.cancel(); streamJob = null
transport?.close(); transport = null transport?.close(); transport = null
track?.stop(); track?.release(); track = null track?.stop(); track?.release(); track = null
@@ -103,6 +103,11 @@ class ShizukuProvisioner(
/** /**
* Разовые разрешения на очках. Идемпотентно, один раз за сессию. Не блокирует ensure(): * Разовые разрешения на очках. Идемпотентно, один раз за сессию. Не блокирует ensure():
* `adb.ensureConnected()` переиспользует соединение, поднятое бутстрапом Shizuku. * `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() { private fun maybeApplyGrants() {
if (grantsAppliedInSession) return if (grantsAppliedInSession) return
@@ -110,7 +115,19 @@ class ShizukuProvisioner(
val serial = adb.ensureConnected() ?: return@launch val serial = adb.ensureConnected() ?: return@launch
Log.i(TAG, "применяю системные гранты на очках ($serial)") Log.i(TAG, "применяю системные гранты на очках ($serial)")
val grant = adb.shell("pm grant $GLASSES_PACKAGE android.permission.RECORD_AUDIO", timeoutMs = 15_000) 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]). // #4.1: назначаем наш VmNoopImeService дефолтным IME навсегда (см. [VmNoopImeService]).
// Подавляет системный IME, который гость (Zona) поднимает при фокусе в поле ввода. // Подавляет системный IME, который гость (Zona) поднимает при фокусе в поле ввода.
val ime = adb.shell( val ime = adb.shell(
@@ -120,9 +137,12 @@ class ShizukuProvisioner(
) )
Log.i( Log.i(
TAG, 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. * Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
*/ */
suspend fun setAudioMode(mode: pw.binom.rayneovm.shared.AudioMode) { 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 _audioMode.value = mode
send(SetAudioMode(mode)) send(SetAudioMode(mode))
when (mode) { when (mode) {
@@ -249,6 +250,7 @@ class VmPhoneController(
pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> { pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> {
val host = (_state.value as? ConnectionState.Connected)?.host val host = (_state.value as? ConnectionState.Connected)?.host
if (host != null) { if (host != null) {
android.util.Log.i("VmPhoneController", "setAudioMode: ON_PHONE → audioManager.start($host)")
audioManager.start(host) audioManager.start(host)
} else { } else {
// Не подключены — команда уйдёт при следующем подключении не сразу, // Не подключены — команда уйдёт при следующем подключении не сразу,
@@ -257,6 +259,7 @@ class VmPhoneController(
} }
} }
} }
android.util.Log.i("VmPhoneController", "setAudioMode done: mode=$mode audioManager=${audioManager}")
} }
companion object { companion object {