Verify shared Android audio ownership on a physical device

This commit is contained in:
evgeny committed 2026-10-22 15:07:26 +02:00
1 parent 44a3b33c3a
commit 2b63c680b2
3 files changed
+139 -4

No files matched your search

+10 -4
View File
@@ -225,10 +225,16 @@ Microphone lease публикации исключает call/recorder; раци
Если первой работала рация, публикация заменяет профиль захвата своим; после остановки публикации
оставшаяся рация продолжает тот же захват. Recovery останавливает callbacks, workers TX и один раз
открывает общий вход; RX рации сохраняется. Каждый потребитель обрабатывает собственную копию PCM.
На Android `MicrophoneCapture` владеет AudioRecord и reader-потоком, подписчики — ограниченными очередями.
Stop публикации удаляет её подписку до join Opus worker и FIN; AudioRecord остаётся живым для рации.
PTT и PCM рации проходят через одну очередь. Последний подписчик делает stop/read join/release;
приоритет глобального AudioManager сохраняется до освобождения трансляционного захвата.
На Android `MediaAudioController` владеет общим выбором устройства, AudioManager и одним аппаратным reader.
Маршрутизация использует `CallAudioRouter`; AudioTrack рации и микшера просмотра регистрируются у одного владельца.
`MicrophoneCapture` раздаёт независимые ограниченные очереди постоянного PCM 48k stereo; моно апмиксуется.
После подключения публикации встроенный микрофон использует CAMCORDER, сохраняемый до последней подписки.
Смена профиля заменяет только аппаратный AudioRecord: камера, видеокодер и source_epoch продолжают работать.
Общая сетка по capture timestamps имеет запас 200ms; пропуски заполняются тишиной, старый PCM не переносится вперёд.
Stop публикации удаляет её подписку до join Opus worker и FIN, сохраняя захват для оставшейся рации.
PTT и PCM рации проходят через одну очередь. Последняя подписка завершает общий reader;
контроллер закрывает аппаратный вход, сохраняя выходы. Перед Telecom все владельцы снимаются,
а `MediaAudioController.awaitIdle()` дожидается освобождения входа и глобального маршрута.
Ограничения фонового захвата рации, её разрешения и foreground-сервис продолжают действовать.
Fatal ошибка audio worker передаётся в GUI с session ID и завершает всю публикацию;
откат видеоконфигурации применяется только к video/capture. Поздний отказ старого publisher
@@ -1,4 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<instrumentation android:name="com.utun.chat.data.CallVideoProbe"
android:targetPackage="com.utun.chat" android:label="Call video codec probe" />
<instrumentation android:name="com.utun.chat.data.MediaAudioProbe"
android:targetPackage="com.utun.chat" android:label="Shared media audio probe" />
<application>
<activity android:name="com.utun.chat.data.MediaAudioProbeActivity" android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,124 @@
package com.utun.chat.data
import android.app.Activity
import android.app.Instrumentation
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import android.os.Bundle
import android.widget.TextView
/** Явная аппаратная проверка через adb am instrument. PCM остаётся в памяти; сеть и камера не используются. */
class MediaAudioProbeActivity : Activity() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
if (android.os.Build.VERSION.SDK_INT >= 27) { setShowWhenLocked(true); setTurnScreenOn(true) }
else window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
setContentView(TextView(this).apply { text = "Shared media audio test" })
}
}
class MediaAudioProbe : Instrumentation() {
override fun onCreate(arguments: Bundle?) { super.onCreate(arguments); start() }
override fun onStart() {
var activity: Activity? = null
var radio: MicrophoneCapture.Subscription? = null
var stream: MicrophoneCapture.Subscription? = null
var track: AudioTrack? = null
var output: AutoCloseable? = null
var writer: Thread? = null
val writing = java.util.concurrent.atomic.AtomicBoolean(true)
var saved: MediaAudioProfile? = null
var problem: Exception? = null
try {
activity = startActivitySync(Intent(targetContext, MediaAudioProbeActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
check(MicrophonePermission.granted()) { "RECORD_AUDIO is required for the explicit microphone probe" }
MediaAudioController.awaitIdle()
saved = MediaAudioController.state.value.preferred
MediaAudioController.select(MediaAudioProfile.PHONE)
MediaAudioController.awaitIdle()
val sink = AudioTrack.Builder().setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA).build())
.setAudioFormat(AudioFormat.Builder().setSampleRate(48000).setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT).build())
.setBufferSizeInBytes(15360).setTransferMode(AudioTrack.MODE_STREAM).build()
track = sink
output = MediaAudioController.attachOutput(sink)
sink.play()
writer = Thread({
val silence = ShortArray(960)
while (writing.get()) {
if (sink.write(silence, 0, silence.size) < 0) break
Thread.sleep(10)
}
}, "media-probe-output").apply { start() }
val first = MediaAudioController.subscribe("radio"); radio = first
repeat(15) { frame(first) }
val second = MediaAudioController.subscribe("stream"); stream = second
var previous = -1L
for (profile in listOf(MediaAudioProfile.PHONE, MediaAudioProfile.AUTO, MediaAudioProfile.PHONE)) {
MediaAudioController.select(profile)
val until = System.nanoTime() + 10_000_000_000L
var count = 0
do {
val a = frame(second)
if (previous >= 0) check(a.timeNs == previous + 20_000_000L) { "Non-contiguous stream PTS $previous -> ${a.timeNs}" }
previous = a.timeNs
var b = frame(first)
while (b.sequence < a.sequence) b = frame(first)
check(a.sequence == b.sequence && a.timeNs == b.timeNs && a.pcm.contentEquals(b.pcm)) { "Consumers received different capture" }
val unchanged = b.pcm.copyOf()
a.pcm.fill(123)
check(b.pcm.contentEquals(unchanged)) { "PCM copies are shared" }
check(System.nanoTime() < until) { "Profile did not settle: ${MediaAudioController.state.value}" }
count++
} while (count < 30 || MediaAudioController.state.value.switching)
check(MediaAudioController.state.value.notice == null && MediaAudioController.state.value.inputName != null) {
"Microphone was not confirmed: ${MediaAudioController.state.value}"
}
android.util.Log.i("MediaAudioProbe", "profile verified=$profile frames=$count state=${MediaAudioController.state.value}")
}
first.close(); radio = null
val next = frame(second)
check(next.timeNs == previous + 20_000_000L) { "Closing radio interrupted stream capture" }
val remaining = MediaAudioController.subscribe("radio"); radio = remaining
val beforeDetach = frame(remaining)
second.close(); stream = null
val afterDetach = frame(remaining)
check(afterDetach.timeNs == beforeDetach.timeNs + 20_000_000L && afterDetach.generation == beforeDetach.generation) {
"Closing stream restarted the remaining radio microphone"
}
remaining.close(); radio = null
// Оставшийся output продолжает принадлежать общей подсистеме после закрытия последнего input.
Thread.sleep(250)
check(sink.playState == AudioTrack.PLAYSTATE_PLAYING) { "Closing input stopped shared output" }
} catch (e: Exception) {
problem = e
android.util.Log.e("MediaAudioProbe", "probe failed", e)
} finally {
try {
radio?.close(); stream?.close()
writing.set(false)
track?.stop(); writer?.join()
output?.close(); track?.release()
MediaAudioController.awaitIdle()
saved?.let { MediaAudioController.select(it); MediaAudioController.awaitIdle() }
runOnMainSync { activity?.finish() }
} catch (e: Exception) {
android.util.Log.e("MediaAudioProbe", "cleanup failed", e)
if (problem == null) problem = e
}
}
finish(if (problem == null) 0 else 1, Bundle().apply {
putString("result", problem?.toString() ?: "Shared PCM, CAMCORDER, output route and continuous PTS verified")
})
}
private fun frame(subscription: MicrophoneCapture.Subscription): MicrophoneCapture.Frame {
val deadline = System.nanoTime() + 3_000_000_000L
while (System.nanoTime() < deadline) (subscription.poll() as? MicrophoneCapture.Frame)?.let { return it }
error("Shared microphone frame timeout")
}
}