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:
+40
-20
@@ -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,8 +372,10 @@ class VmService : LifecycleService() {
|
||||
|
||||
private fun requestProjection() {
|
||||
AudioBridge.requestProjection()
|
||||
// MainActivity обычно уже жив (HOME), но если нет — поднимаем: запустить
|
||||
// ActivityResult-лончер иначе некому.
|
||||
// MainActivity обычно уже жива и в RESUMED — тогда она сама обработает tick, и трогать
|
||||
// её нельзя: `startActivity` в этот момент гоняется с запуском consent-активити и
|
||||
// приводит к отмене grant (resultCode=0). Поднимаем Activity только если она не наверху.
|
||||
if (!AudioBridge.activityResumed) {
|
||||
runCatching {
|
||||
startActivity(
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
@@ -383,6 +384,7 @@ class VmService : LifecycleService() {
|
||||
)
|
||||
}.onFailure { Log.w(TAG, "Не удалось поднять MainActivity: ${it.message}") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun setOutputMuted(muted: Boolean) {
|
||||
runCatching {
|
||||
@@ -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() } }
|
||||
_projection.value = projection
|
||||
if (_projection.value != null) {
|
||||
Log.w(TAG, "проекция уже есть — новую игнорирую (старую не трогаю)")
|
||||
return
|
||||
}
|
||||
|
||||
fun clearProjection() {
|
||||
_projection.value?.let { runCatching { it.stop() } }
|
||||
_projection.value = null
|
||||
_projection.value = projection
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
-3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user