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`.
|
**Когда замечено:** 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-10
@@ -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
|
||||||
|
|||||||
+23
-3
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user