diff --git a/BLOCKERS.md b/BLOCKERS.md index 06fed0d..4fb3f65 100644 --- a/BLOCKERS.md +++ b/BLOCKERS.md @@ -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(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 закрыт полностью.** --- diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt index 0dc6aa7..3767ca1 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/MainActivity.kt @@ -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, diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt index 11e2882..78a5e70 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/VmService.kt @@ -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) diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioBridge.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioBridge.kt index eaa0d31..290915a 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioBridge.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/AudioBridge.kt @@ -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(null) - /** Текущий выданный projection (null — ещё не получен/сброшен). */ + /** Текущий выданный projection (null — ещё не получен). Живёт до конца процесса. */ val projection: StateFlow = _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, "проекция сохранена (живёт до конца процесса)") } } diff --git a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt index 36b4508..7fd08e5 100644 --- a/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt +++ b/app-glasses-vm/src/main/kotlin/pw/binom/rayneovm/glasses/audio/GlassesAudioCapture.kt @@ -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 { diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt index 776d226..042252d 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/audio/AudioPlaybackManager.kt @@ -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 diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt index 91b1ad9..374d4df 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/bootstrap/ShizukuProvisioner.kt @@ -103,6 +103,11 @@ class ShizukuProvisioner( /** * Разовые разрешения на очках. Идемпотентно, один раз за сессию. Не блокирует ensure(): * `adb.ensureConnected()` переиспользует соединение, поднятое бутстрапом Shizuku. + * + * `PROJECT_MEDIA` ставится и для пакета, и для UID — иначе на свежих прошивках + * `MediaProjectionManager.createScreenCaptureIntent()` возвращает `RESULT_CANCELED`, + * даже если `cmd appops get 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 + } } } diff --git a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt index 01ade41..8860d9b 100644 --- a/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt +++ b/app-phone-vm/src/main/kotlin/pw/binom/rayneovm/phone/protocol/VmPhoneController.kt @@ -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 {