diff --git a/docs/mobile-testing.md b/docs/mobile-testing.md
index 93fa35aedd..375da30708 100644
--- a/docs/mobile-testing.md
+++ b/docs/mobile-testing.md
@@ -300,16 +300,33 @@ done
### Android audio focus interruptions
-Voice mode uses the custom `expo-two-way-audio` Android module, so incoming calls and other system audio owners must be tested with emulator/system commands, not a JS-only test. To verify that voice resume handles denied audio focus without crashing:
+Use native instrumentation for audio interruptions. JS-only tests cannot prove Android focus,
+input silencing, or route recovery. The module's `android/src/androidTest` suite runs through
+Gradle's `connectedDebugAndroidTest` task and uses real `AudioManager` and `AudioRecord` instances.
+Run the microphone-privacy cases on Android 12 or newer.
+
+Verify these distinct outcomes:
+
+- Dictation continues when another media owner takes permanent, temporary, or ducking focus.
+- Disabling microphone access pauses audio delivery without discarding the dictation. Enabling
+ access resumes it. Starting dictation while access is disabled waits for the same recovery.
+- Returning from music controls resumes dictation without pausing music again. Cancelling a
+ backgrounded recording must prevent the lifecycle callback from restarting it.
+- Two-way voice still stops coherently on focus loss. Keep the incoming-call check below: it
+ exercises denied focus during lifecycle resume, beyond an ordinary media takeover.
```bash
-adb shell am start -n sh.paseo/.MainActivity
-# Start voice mode in an existing composer, then background Paseo with Home.
+# Start voice mode in an existing composer, then background Paseo.
adb emu gsm call 5551234
-# Foreground Paseo while the call is still ringing.
+# Foreground Paseo while the call is still ringing, then end the simulated call.
+adb emu gsm cancel 5551234
```
-Expected result: Paseo does not throw `RuntimeException: Audio focus request failed`; native audio reports an interruption and voice mode stops or pauses coherently.
+Expected result: no `RuntimeException: Audio focus request failed`, and voice mode stops or pauses
+coherently. Repeat with dictation: it must retain captured audio and recover microphone access.
+Use browser/device automation to operate the app; the commands above only control emulator telephony.
+
+This fix requires a rebuilt native app. An OTA update on an older binary retains legacy capture.
### Releasing the audio session when idle
diff --git a/packages/app/src/audio/audio-engine-types.ts b/packages/app/src/audio/audio-engine-types.ts
index 90bec732fb..d96f9c5ef9 100644
--- a/packages/app/src/audio/audio-engine-types.ts
+++ b/packages/app/src/audio/audio-engine-types.ts
@@ -2,6 +2,7 @@ export interface AudioEngineCallbacks {
onCaptureData(pcm: Uint8Array): void;
onVolumeLevel(level: number): void;
onInterruption?(): void;
+ onCaptureAvailabilityChange?(available: boolean): void;
onError?(error: Error): void;
}
@@ -25,3 +26,8 @@ export interface AudioEngine {
clearQueue(): void;
isPlaying(): boolean;
}
+
+export interface AudioEngineOptions {
+ traceLabel?: string;
+ captureMode?: "conversation" | "dictation";
+}
diff --git a/packages/app/src/audio/audio-engine.native.ts b/packages/app/src/audio/audio-engine.native.ts
index 86a6fc3d26..ed9f15a765 100644
--- a/packages/app/src/audio/audio-engine.native.ts
+++ b/packages/app/src/audio/audio-engine.native.ts
@@ -1,4 +1,9 @@
-import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
+import type {
+ AudioEngine,
+ AudioEngineCallbacks,
+ AudioEngineOptions,
+ AudioPlaybackSource,
+} from "./audio-engine-types";
import { createAudioPlayer, setAudioModeAsync } from "expo-audio";
import { File, Paths } from "expo-file-system";
@@ -8,7 +13,7 @@ import { playPcm16 } from "./pcm";
export function createAudioEngine(
callbacks: AudioEngineCallbacks,
- _options?: { traceLabel?: string },
+ options?: AudioEngineOptions,
): AudioEngine {
const native = require("@getpaseo/expo-two-way-audio");
@@ -47,6 +52,12 @@ export function createAudioEngine(
const interruptionSubscription = native.addExpoTwoWayAudioEventListener(
"onAudioInterruption",
(event: { data: string }) => {
+ if (event.data === "capture_paused" || event.data === "capture_resumed") {
+ if (refs.captureActive) {
+ callbacks.onCaptureAvailabilityChange?.(event.data === "capture_resumed");
+ }
+ return;
+ }
if (event.data !== "blocked") {
return;
}
@@ -180,7 +191,10 @@ export function createAudioEngine(
try {
await ensureMicrophonePermission();
await ensureInitialized();
- const isRecording = native.toggleRecording(true);
+ const isRecording =
+ options?.captureMode === "dictation"
+ ? native.startDictation()
+ : native.toggleRecording(true);
if (!isRecording) {
throw new Error(
"Microphone capture could not start because Android audio focus is unavailable.",
@@ -201,6 +215,7 @@ export function createAudioEngine(
refs.captureActive = false;
refs.muted = false;
callbacks.onVolumeLevel(0);
+ callbacks.onCaptureAvailabilityChange?.(true);
releaseSessionIfIdle();
},
diff --git a/packages/app/src/audio/audio-engine.web.ts b/packages/app/src/audio/audio-engine.web.ts
index dc0f049ca7..5516575e69 100644
--- a/packages/app/src/audio/audio-engine.web.ts
+++ b/packages/app/src/audio/audio-engine.web.ts
@@ -1,6 +1,11 @@
import { parsePcmSampleRate, resampleToPcm16 } from "./pcm";
import { isElectronRuntime } from "@/desktop/host";
-import type { AudioEngine, AudioEngineCallbacks, AudioPlaybackSource } from "./audio-engine-types";
+import type {
+ AudioEngine,
+ AudioEngineCallbacks,
+ AudioEngineOptions,
+ AudioPlaybackSource,
+} from "./audio-engine-types";
import { createPlaybackQueue } from "./playback";
@@ -46,7 +51,7 @@ async function decodeAudioData(context: AudioContext, buffer: ArrayBuffer): Prom
export function createAudioEngine(
callbacks: AudioEngineCallbacks,
- _options?: { traceLabel?: string },
+ _options?: AudioEngineOptions,
): AudioEngine {
const refs: {
playbackContext: AudioContext | null;
diff --git a/packages/app/src/components/dictation-controls.tsx b/packages/app/src/components/dictation-controls.tsx
index 5c8a990987..2191f0d480 100644
--- a/packages/app/src/components/dictation-controls.tsx
+++ b/packages/app/src/components/dictation-controls.tsx
@@ -87,7 +87,9 @@ export function DictationControls({
- {formatDuration(duration)}
+
+ {status === "paused" ? t("message.dictation.paused") : formatDuration(duration)}
+
{formatDuration(duration)}
+ {status === "paused" ? (
+
+ {t("message.dictation.paused")}
+
+ ) : null}
{isFailed ? (
{errorText
diff --git a/packages/app/src/hooks/use-dictation-audio-source.native.ts b/packages/app/src/hooks/use-dictation-audio-source.native.ts
index 293412fe1a..c34f9bbd02 100644
--- a/packages/app/src/hooks/use-dictation-audio-source.native.ts
+++ b/packages/app/src/hooks/use-dictation-audio-source.native.ts
@@ -14,6 +14,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
const onErrorRef = useRef(config.onError);
const onInterruptionRef = useRef(config.onInterruption);
const [volume, setVolume] = useState(0);
+ const [isPaused, setIsPaused] = useState(false);
const engineRef = useRef | null>(null);
const getOrCreateEngine = useCallback(() => {
@@ -21,20 +22,24 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
return engineRef.current;
}
- engineRef.current = createAudioEngine({
- onCaptureData: (pcm) => {
- onPcmSegmentRef.current(Buffer.from(pcm).toString("base64"));
+ engineRef.current = createAudioEngine(
+ {
+ onCaptureData: (pcm) => {
+ onPcmSegmentRef.current(Buffer.from(pcm).toString("base64"));
+ },
+ onVolumeLevel: (level) => {
+ setVolume(level);
+ },
+ onError: (error) => {
+ onErrorRef.current?.(error);
+ },
+ onInterruption: () => {
+ onInterruptionRef.current?.();
+ },
+ onCaptureAvailabilityChange: (available) => setIsPaused(!available),
},
- onVolumeLevel: (level) => {
- setVolume(level);
- },
- onError: (error) => {
- onErrorRef.current?.(error);
- },
- onInterruption: () => {
- onInterruptionRef.current?.();
- },
- });
+ { captureMode: "dictation" },
+ );
return engineRef.current;
}, []);
@@ -67,5 +72,6 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
start,
stop,
volume,
+ isPaused,
};
}
diff --git a/packages/app/src/hooks/use-dictation-audio-source.types.ts b/packages/app/src/hooks/use-dictation-audio-source.types.ts
index 711cf690c8..8fe82ef63e 100644
--- a/packages/app/src/hooks/use-dictation-audio-source.types.ts
+++ b/packages/app/src/hooks/use-dictation-audio-source.types.ts
@@ -8,4 +8,5 @@ export interface DictationAudioSource {
start: () => Promise;
stop: () => Promise;
volume: number;
+ isPaused: boolean;
}
diff --git a/packages/app/src/hooks/use-dictation-audio-source.web.ts b/packages/app/src/hooks/use-dictation-audio-source.web.ts
index c482bece18..6b71ca7181 100644
--- a/packages/app/src/hooks/use-dictation-audio-source.web.ts
+++ b/packages/app/src/hooks/use-dictation-audio-source.web.ts
@@ -442,6 +442,7 @@ export function useDictationAudioSource(config: DictationAudioSourceConfig): Dic
},
stop,
volume,
+ isPaused: false,
}),
[start, stop, volume],
);
diff --git a/packages/app/src/hooks/use-dictation.shared.ts b/packages/app/src/hooks/use-dictation.shared.ts
index 275c9d93d7..fee3da46c1 100644
--- a/packages/app/src/hooks/use-dictation.shared.ts
+++ b/packages/app/src/hooks/use-dictation.shared.ts
@@ -1,6 +1,6 @@
import { i18n } from "@/i18n/i18next";
-export type DictationStatus = "idle" | "recording" | "uploading" | "failed";
+export type DictationStatus = "idle" | "recording" | "paused" | "uploading" | "failed";
export interface UseDictationOptions {
client: import("@getpaseo/client/internal/daemon-client").DaemonClient | null;
diff --git a/packages/app/src/hooks/use-dictation.ts b/packages/app/src/hooks/use-dictation.ts
index dde72964bc..a2a3f46bcc 100644
--- a/packages/app/src/hooks/use-dictation.ts
+++ b/packages/app/src/hooks/use-dictation.ts
@@ -461,7 +461,7 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
volume: audio.volume,
duration,
error,
- status,
+ status: status === "recording" && audio.isPaused ? "paused" : status,
startDictation,
cancelDictation,
confirmDictation,
diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index c5b9cf42d6..2f1fd7397f 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -360,6 +360,7 @@ export const ar: TranslationResources = {
details: "تفاصيل",
},
dictation: {
+ paused: "تمت مقاطعة الميكروفون. في انتظار الاستئناف…",
start: "بدء الإملاء الصوتي",
cancel: "إلغاء الإملاء",
retry: "أعد محاولة الإملاء",
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index 76135fb6c9..14cde732e7 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -357,6 +357,7 @@ export const en = {
details: "Details",
},
dictation: {
+ paused: "Microphone interrupted. Waiting to resume…",
start: "Start voice dictation",
cancel: "Cancel dictation",
retry: "Retry dictation",
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index d2572a4bab..18eb17f291 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -365,6 +365,7 @@ export const es: TranslationResources = {
details: "Detalles",
},
dictation: {
+ paused: "Micrófono interrumpido. Esperando para reanudar…",
start: "Iniciar dictado de voz",
cancel: "Cancelar dictado",
retry: "Reintentar el dictado",
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index 48fd08c9ed..2bae7b8647 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -365,6 +365,7 @@ export const fr: TranslationResources = {
details: "Détails",
},
dictation: {
+ paused: "Microphone interrompu. En attente de reprise…",
start: "Démarrer la dictée vocale",
cancel: "Annuler la dictée",
retry: "Relancer la dictée",
diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts
index b3482f5c15..58e2706b95 100644
--- a/packages/app/src/i18n/resources/ja.ts
+++ b/packages/app/src/i18n/resources/ja.ts
@@ -365,6 +365,7 @@ export const ja: TranslationResources = {
details: "詳細",
},
dictation: {
+ paused: "マイクが中断されました。再開を待っています…",
start: "音声入力を開始",
cancel: "音声入力をキャンセル",
retry: "音声入力を再試行",
diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts
index e39d625253..fa962a5562 100644
--- a/packages/app/src/i18n/resources/ko.ts
+++ b/packages/app/src/i18n/resources/ko.ts
@@ -361,6 +361,7 @@ export const ko: TranslationResources = {
details: "세부 정보",
},
dictation: {
+ paused: "마이크가 중단되었습니다. 재개를 기다리는 중…",
start: "음성 받아쓰기 시작",
cancel: "받아쓰기 취소",
retry: "받아쓰기 다시 시도",
diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts
index 8c3d93499a..c5c6607ca6 100644
--- a/packages/app/src/i18n/resources/pt-BR.ts
+++ b/packages/app/src/i18n/resources/pt-BR.ts
@@ -364,6 +364,7 @@ export const ptBR: TranslationResources = {
details: "Detalhes",
},
dictation: {
+ paused: "Microfone interrompido. Aguardando para retomar…",
start: "Iniciar ditado por voz",
cancel: "Cancelar ditado",
retry: "Tentar ditado novamente",
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index df57855cb6..4df3f721bb 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -364,6 +364,7 @@ export const ru: TranslationResources = {
details: "Подробности",
},
dictation: {
+ paused: "Микрофон прерван. Ожидание возобновления…",
start: "Начать диктовку голосом",
cancel: "Отменить диктовку",
retry: "Повторить диктовку",
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index 984cc0d71a..bad9cc4690 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -360,6 +360,7 @@ export const zhCN: TranslationResources = {
details: "详情",
},
dictation: {
+ paused: "麦克风已中断,正在等待恢复…",
start: "开始语音听写",
cancel: "取消听写",
retry: "重试听写",
diff --git a/packages/expo-two-way-audio/android/build.gradle b/packages/expo-two-way-audio/android/build.gradle
index 84a7837cf9..5bb7508992 100644
--- a/packages/expo-two-way-audio/android/build.gradle
+++ b/packages/expo-two-way-audio/android/build.gradle
@@ -36,8 +36,14 @@ android {
defaultConfig {
versionCode 1
versionName "0.1.0"
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
lintOptions {
abortOnError false
}
}
+
+dependencies {
+ androidTestImplementation 'androidx.test:runner:1.6.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.2.1'
+}
diff --git a/packages/expo-two-way-audio/android/src/androidTest/AndroidManifest.xml b/packages/expo-two-way-audio/android/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000000..6501d613de
--- /dev/null
+++ b/packages/expo-two-way-audio/android/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
diff --git a/packages/expo-two-way-audio/android/src/androidTest/java/expo/modules/twowayaudio/AudioEngineTest.kt b/packages/expo-two-way-audio/android/src/androidTest/java/expo/modules/twowayaudio/AudioEngineTest.kt
new file mode 100644
index 0000000000..ad840deb20
--- /dev/null
+++ b/packages/expo-two-way-audio/android/src/androidTest/java/expo/modules/twowayaudio/AudioEngineTest.kt
@@ -0,0 +1,223 @@
+package expo.modules.twowayaudio
+
+import AudioEngine
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.media.AudioAttributes
+import android.media.AudioFocusRequest
+import android.media.AudioManager
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.filters.SdkSuppress
+import org.junit.After
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.CopyOnWriteArrayList
+import java.util.concurrent.atomic.AtomicInteger
+
+class AudioEngineTestActivity : Activity()
+
+/** Real AudioManager and AudioRecord: focus loss is delivered by Android, never injected. */
+@RunWith(AndroidJUnit4::class)
+class AudioEngineTest {
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private lateinit var activity: Activity
+ private lateinit var engine: AudioEngine
+ private lateinit var audio: AudioManager
+ private val packets = AtomicInteger()
+ private val events = CopyOnWriteArrayList()
+ private val requests = mutableListOf()
+
+ @Before fun setUp() {
+ val context = instrumentation.context
+ shell("pm grant ${context.packageName} android.permission.RECORD_AUDIO")
+ activity = instrumentation.startActivitySync(
+ Intent(context, AudioEngineTestActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ )
+ instrumentation.waitForIdleSync()
+ audio = activity.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+ main {
+ engine = AudioEngine(activity)
+ engine.onMicDataCallback = { packets.incrementAndGet() }
+ engine.onAudioInterruptionCallback = { events.add(it) }
+ }
+ }
+
+ @After fun tearDown() {
+ shell("cmd sensor_privacy disable 0 microphone")
+ main {
+ requests.forEach { audio.abandonAudioFocusRequest(it) }
+ engine.tearDown()
+ activity.finish()
+ }
+ }
+
+ @Test fun dictationContinuesWhenMusicTakesFocus() {
+ assertDictationSurvives(AudioManager.AUDIOFOCUS_GAIN)
+ }
+
+ @Test fun dictationContinuesWhenFocusIsTemporarilyLost() {
+ assertDictationSurvives(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
+ }
+
+ @Test fun dictationContinuesWhenAnotherAppRequestsDucking() {
+ assertDictationSurvives(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK)
+ }
+
+ @Test fun conversationStillStopsOnFocusLoss() {
+ main { assertTrue(engine.toggleRecording(true)) }
+ await("conversation microphone data") { packets.get() > 2 }
+ takeFocus(AudioManager.AUDIOFOCUS_GAIN)
+ await("terminal conversation interruption") { events.contains("blocked") }
+ assertFalse(engine.isRecording)
+ }
+
+ @Test fun dictationStartsWhileMusicOwnsFocusAndCanBeStartedAgain() {
+ takeFocus(AudioManager.AUDIOFOCUS_GAIN)
+ startDictation()
+ main { engine.toggleRecording(false); engine.releaseAudioSession() }
+ val stoppedAt = packets.get()
+ startDictation()
+ await("new recording data") { packets.get() > stoppedAt + 2 }
+ assertFalse(events.contains("blocked"))
+ }
+
+ @Test fun cancellingWhileBackgroundedDoesNotRestartOnResume() {
+ startDictation()
+ main {
+ engine.pauseRecordingAndPlayer()
+ engine.toggleRecording(false)
+ engine.releaseAudioSession()
+ engine.resumeRecordingAndPlayer()
+ }
+ assertFalse(engine.isRecording)
+ }
+
+ @Test fun dictationKeepsItsCaptureModeAfterBackgroundResume() {
+ startDictation()
+ main { engine.pauseRecordingAndPlayer(); engine.resumeRecordingAndPlayer() }
+ val resumedAt = packets.get()
+ takeFocus(AudioManager.AUDIOFOCUS_GAIN)
+ await("resumed dictation data after focus loss") { packets.get() > resumedAt + 3 }
+ assertTrue(engine.isRecording)
+ assertFalse(events.contains("blocked"))
+ }
+
+ @Test @SdkSuppress(minSdkVersion = 31)
+ fun microphonePrivacyInterruptionPreservesCaptureAndRecovers() {
+ startDictation()
+ shell("cmd sensor_privacy enable 0 microphone")
+ await("input paused") { events.contains("capture_paused") }
+ assertTrue(engine.isRecording)
+ assertFalse(events.contains("blocked"))
+ // Once Android reports silencing, do not enqueue artificial silence for transcription.
+ SystemClock.sleep(100)
+ val pausedAt = packets.get()
+ SystemClock.sleep(150)
+ assertEquals(pausedAt, packets.get())
+ shell("cmd sensor_privacy disable 0 microphone")
+ await("input resumed") { events.contains("capture_resumed") }
+ await("microphone data after recovery") { packets.get() > pausedAt + 3 }
+ assertTrue(engine.isRecording)
+ assertEquals(listOf("capture_paused", "capture_resumed"), events.toList())
+ }
+
+ @Test @SdkSuppress(minSdkVersion = 31)
+ fun startingWithMicrophonePrivacyEnabledWaitsAndRecovers() {
+ shell("cmd sensor_privacy enable 0 microphone")
+ main { assertTrue(engine.startDictation()) }
+ await("initial input paused") { events.contains("capture_paused") }
+ assertTrue(engine.isRecording)
+ assertFalse(events.contains("blocked"))
+ shell("cmd sensor_privacy disable 0 microphone")
+ await("initial input resumed") { events.contains("capture_resumed") }
+ await("microphone data") { packets.get() > 3 }
+ }
+
+ @Test fun resumingDictationDoesNotTakeFocusBackFromMusic() {
+ startDictation()
+ main { engine.pauseRecordingAndPlayer() }
+ val changes = CopyOnWriteArrayList()
+ val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
+ .setOnAudioFocusChangeListener({ changes.add(it) }, Handler(Looper.getMainLooper()))
+ .build()
+ requests.add(request)
+ main { assertEquals(AudioManager.AUDIOFOCUS_REQUEST_GRANTED, audio.requestAudioFocus(request)) }
+ val before = packets.get()
+ main { engine.resumeRecordingAndPlayer() }
+ await("resumed microphone data") { packets.get() > before + 3 }
+ assertTrue(engine.isRecording)
+ assertTrue("Music lost focus on dictation resume: $changes", changes.isEmpty())
+ }
+
+ @Test @SdkSuppress(minSdkVersion = 31)
+ fun stoppingDictationReleasesTheCommunicationRoute() {
+ startDictation()
+ await("selected communication route") { hasCommunicationRoute() }
+ main { engine.toggleRecording(false); engine.releaseAudioSession() }
+ await("normal audio mode") { audio.mode == AudioManager.MODE_NORMAL }
+ await("cleared communication route") { !hasCommunicationRoute() }
+ assertFalse(engine.isRecording)
+ }
+
+ private fun assertDictationSurvives(gain: Int) {
+ startDictation()
+ val before = packets.get()
+ val request = takeFocus(gain)
+ await("microphone data while focus is lost") { packets.get() > before + 3 }
+ assertTrue(engine.isRecording)
+ assertFalse(events.contains("blocked"))
+ val during = packets.get()
+ main { audio.abandonAudioFocusRequest(request) }
+ await("microphone data after focus release") { packets.get() > during + 3 }
+ assertTrue(engine.isRecording)
+ assertFalse(events.contains("blocked"))
+ }
+
+ private fun startDictation() {
+ val before = packets.get()
+ main { assertTrue(engine.startDictation()) }
+ await("initial microphone data") { packets.get() > before + 3 }
+ }
+
+ private fun takeFocus(gain: Int): AudioFocusRequest {
+ val request = AudioFocusRequest.Builder(gain)
+ .setAudioAttributes(AudioAttributes.Builder()
+ .setUsage(AudioAttributes.USAGE_MEDIA)
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build())
+ .setOnAudioFocusChangeListener({}, Handler(Looper.getMainLooper()))
+ .build()
+ requests.add(request)
+ main { assertEquals(AudioManager.AUDIOFOCUS_REQUEST_GRANTED, audio.requestAudioFocus(request)) }
+ return request
+ }
+
+ private fun main(action: () -> Unit) = instrumentation.runOnMainSync(action)
+
+ private fun await(description: String, condition: () -> Boolean) {
+ val deadline = SystemClock.elapsedRealtime() + 5000
+ while (!condition() && SystemClock.elapsedRealtime() < deadline) SystemClock.sleep(20)
+ assertTrue("Timed out waiting for $description; events=$events packets=${packets.get()}", condition())
+ }
+
+ // communicationDevice reports the effective system device even after our selection is cleared.
+ // Inspect only current route clients, never the historical routing event log.
+ private fun hasCommunicationRoute(): Boolean {
+ val dump = shell("dumpsys audio")
+ assertTrue("Missing Android communication route diagnostics", dump.contains("Communication route clients:"))
+ return dump.lineSequence().any {
+ it.trimStart().startsWith("[CommunicationRouteClient: mUid: ${android.os.Process.myUid()} ")
+ }
+ }
+
+ private fun shell(command: String): String =
+ instrumentation.uiAutomation.executeShellCommand(command).use {
+ android.os.ParcelFileDescriptor.AutoCloseInputStream(it).readBytes().toString(Charsets.UTF_8)
+ }
+}
diff --git a/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt b/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt
index a6fe25303e..48aff1f1df 100644
--- a/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt
+++ b/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/AudioEngine.kt
@@ -6,12 +6,14 @@ import android.media.AudioFocusRequest
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioRecord
+import android.media.AudioRecordingConfiguration
import android.media.AudioTrack
import android.media.MediaRecorder
import android.media.audiofx.AcousticEchoCanceler
import android.media.audiofx.NoiseSuppressor
import android.os.Build
-import android.os.PowerManager
+import android.os.Handler
+import android.os.Looper
import android.util.Log
import androidx.annotation.RequiresApi
import java.util.LinkedList
@@ -34,7 +36,26 @@ class AudioEngine (context: Context) {
private var noiseSuppressor: NoiseSuppressor? = null
private val executorServiceMicrophone = Executors.newFixedThreadPool(1)
private val executorServicePlayback = Executors.newFixedThreadPool(1)
- private var speakerDevice: AudioDeviceInfo? = null
+ private enum class CaptureMode { CONVERSATION, DICTATION }
+ private var captureMode = CaptureMode.CONVERSATION
+ private val mainHandler = Handler(Looper.getMainLooper())
+ @Volatile private var captureSilenced = false
+ private val recordingCallback = object : AudioManager.AudioRecordingCallback() {
+ override fun onRecordingConfigChanged(configs: MutableList) {
+ if (!isRecording || !::audioRecord.isInitialized) return
+ val config = configs.firstOrNull { it.clientAudioSessionId == audioRecord.audioSessionId }
+ ?: return
+ setCaptureSilenced(config.isClientSilenced)
+ }
+ }
+ private val deviceCallback = object : android.media.AudioDeviceCallback() {
+ override fun onAudioDevicesAdded(devices: Array) {
+ if (communicationRouteActive) updateAudioRouting()
+ }
+ override fun onAudioDevicesRemoved(devices: Array) {
+ if (communicationRouteActive) updateAudioRouting()
+ }
+ }
private var communicationRouteActive = false
private var bridgeWindowStartedAtMs = System.currentTimeMillis()
private var micEvents = 0
@@ -44,7 +65,7 @@ class AudioEngine (context: Context) {
private var playbackWrites = 0
private var playbackWriteBytes = 0L
- var isRecording = false
+ @Volatile var isRecording = false
private var isRecordingBeforePause = false
var isPlaying = false
@@ -83,28 +104,9 @@ class AudioEngine (context: Context) {
@SuppressLint("NewApi")
private fun initializeAudio(context:Context) {
audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
- activateCommunicationRoute()
- if (!requestAudioFocus()) {
- handleAudioFocusBlocked()
- }
-
- // Listen for changes in audio routing
- audioManager.registerAudioDeviceCallback(object:android.media.AudioDeviceCallback(){
- override fun onAudioDevicesAdded(addedDevices: Array?) {
- Log.d("AudioEngine", "onAudioDevicesAdded")
- super.onAudioDevicesAdded(addedDevices)
- if (communicationRouteActive) {
- updateAudioRouting()
- }
- }
- override fun onAudioDevicesRemoved(removedDevices: Array?) {
- Log.d("AudioEngine", "onAudioDevicesRemoved")
- super.onAudioDevicesRemoved(removedDevices)
- if (communicationRouteActive) {
- updateAudioRouting()
- }
- }
- }, null)
+ // Initialization must not interrupt music before a capture/playback operation owns a session.
+ audioManager.registerAudioDeviceCallback(deviceCallback, mainHandler)
+ audioManager.registerAudioRecordingCallback(recordingCallback, mainHandler)
val bufferSize = AudioTrack.getMinBufferSize(
SAMPLE_RATE,
@@ -125,49 +127,42 @@ class AudioEngine (context: Context) {
bufferSize,
AudioTrack.MODE_STREAM,
audioManager.generateAudioSessionId()
- ).apply {
- play()
- }
+ )
}
+ @SuppressLint("NewApi")
private fun updateAudioRouting() {
- val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
- var isExternalDeviceConnected = false
- var selectedDevice: AudioDeviceInfo? = null
-
- for (device in devices) {
- if (device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) {
- speakerDevice = device
- }
- if (device.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
- device.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
- device.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
- device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) {
- isExternalDeviceConnected = true
- selectedDevice = device
- break
- } else if (device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER) {
- selectedDevice = device
- }
- }
-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- // Use the modern API for Android S and above
- try {
- selectedDevice?.let {
- audioManager.setCommunicationDevice(it)
- }
- }catch (e:Exception){
- Log.e("AudioEngine", "Error setting communication device. Using speaker")
- speakerDevice?.let {
- audioManager.setCommunicationDevice(it)
- }
+ val devices = audioManager.availableCommunicationDevices
+ // A2DP is a music output, not a microphone route. Android chooses the matching
+ // input for a valid communication sink (including SCO and BLE headsets).
+ val selected = devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
+ it.type == AudioDeviceInfo.TYPE_BLE_HEADSET }
+ ?: devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
+ it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
+ it.type == AudioDeviceInfo.TYPE_USB_HEADSET }
+ ?: devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER }
+ val routed = try {
+ selected != null && audioManager.setCommunicationDevice(selected)
+ } catch (_: IllegalArgumentException) {
+ // The device disconnected between enumeration and selection.
+ false
}
-
+ if (!routed) audioManager.clearCommunicationDevice()
} else {
- // Fall back to deprecated method for older Android versions
@Suppress("DEPRECATION")
- audioManager.isSpeakerphoneOn = !isExternalDeviceConnected
+ val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
+ // Before Android 12, A2DP signals a connected headset; start SCO to open its mic.
+ val bluetooth = devices.any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO ||
+ it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP }
+ @Suppress("DEPRECATION")
+ if (bluetooth) audioManager.startBluetoothSco() else audioManager.stopBluetoothSco()
+ @Suppress("DEPRECATION")
+ audioManager.isSpeakerphoneOn = !bluetooth && !devices.any {
+ it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
+ it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
+ it.type == AudioDeviceInfo.TYPE_USB_HEADSET
+ }
}
}
@@ -186,6 +181,8 @@ class AudioEngine (context: Context) {
} else {
@Suppress("DEPRECATION")
audioManager.isSpeakerphoneOn = false
+ @Suppress("DEPRECATION")
+ audioManager.stopBluetoothSco()
}
audioManager.mode = AudioManager.MODE_NORMAL
}
@@ -245,8 +242,8 @@ class AudioEngine (context: Context) {
audioFocusRequest = null
}
- val focusRequest =
- AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
+ lateinit var focusRequest: AudioFocusRequest
+ focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
@@ -255,6 +252,7 @@ class AudioEngine (context: Context) {
)
.setAcceptsDelayedFocusGain(true)
.setOnAudioFocusChangeListener { focusChange ->
+ if (audioFocusRequest !== focusRequest) return@setOnAudioFocusChangeListener
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
Log.d("AudioEngine", "Audio focus gained")
@@ -286,6 +284,14 @@ class AudioEngine (context: Context) {
}
private fun handleAudioFocusBlocked() {
+ // Focus governs playback. Dictation keeps its AudioRecord alive; Android's
+ // recording configuration reports real input silencing and restores it after calls.
+ if (captureMode == CaptureMode.DICTATION) {
+ stopPlayback()
+ audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
+ audioFocusRequest = null
+ return
+ }
if (isRecording) {
stopRecording()
}
@@ -306,11 +312,11 @@ class AudioEngine (context: Context) {
@RequiresApi(Build.VERSION_CODES.Q)
@SuppressLint("MissingPermission")
- private fun startRecording(): Boolean {
+ private fun startRecording(requestFocus: Boolean = true): Boolean {
activateCommunicationRoute()
- if (!requestAudioFocus()) {
+ if (requestFocus && !requestAudioFocus()) {
handleAudioFocusBlocked()
- return false
+ if (captureMode == CaptureMode.CONVERSATION) return false
}
val bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT)
@@ -344,17 +350,27 @@ class AudioEngine (context: Context) {
audioRecord.startRecording()
isRecording = true
- startMicSampleTap()
+ setCaptureSilenced(audioRecord.activeRecordingConfiguration?.isClientSilenced ?: false)
+ startMicSampleTap(audioRecord)
return true
}
- private fun startMicSampleTap(){
+ private fun setCaptureSilenced(silenced: Boolean) {
+ if (captureSilenced == silenced) return
+ captureSilenced = silenced
+ if (silenced) onInputVolumeCallback?.invoke(0.0F)
+ onAudioInterruptionCallback?.invoke(if (silenced) "capture_paused" else "capture_resumed")
+ }
+
+ private fun startMicSampleTap(recorder: AudioRecord){
executorServiceMicrophone.execute {
val buffer = ByteArray(1024)
try {
- while (isRecording) {
- val read = audioRecord.read(buffer, 0, buffer.size)
- if (read > 0) {
+ while (isRecording && audioRecord === recorder) {
+ val read = recorder.read(buffer, 0, buffer.size)
+ if (!isRecording || audioRecord !== recorder) break
+ if (read < 0) throw IllegalStateException("AudioRecord read failed: $read")
+ if (read > 0 && !captureSilenced) {
val data = buffer.copyOf(read)
micEvents += 1
micBytes += data.size.toLong()
@@ -367,9 +383,13 @@ class AudioEngine (context: Context) {
Log.d("AudioEngine", "Mic sample tap stopped.")
}catch (e: Exception){
Log.e("AudioEngine", "Error reading mic sample data", e)
- isRecording = false
- tearDown()
- throw e
+ mainHandler.post {
+ if (isRecording && audioRecord === recorder) {
+ stopRecording()
+ releaseAudioSession()
+ onAudioInterruptionCallback?.invoke("blocked")
+ }
+ }
}
}
}
@@ -379,16 +399,31 @@ class AudioEngine (context: Context) {
isRecording = false
if (audioRecord.recordingState == AudioRecord.RECORDSTATE_RECORDING) {
audioRecord.stop()
- audioRecord.release()
}
+ audioRecord.release()
+ echoCanceler?.release()
+ echoCanceler = null
+ noiseSuppressor?.release()
+ noiseSuppressor = null
+ setCaptureSilenced(false)
onInputVolumeCallback?.invoke(0.0F)
}
+ @RequiresApi(Build.VERSION_CODES.Q)
+ fun startDictation(): Boolean {
+ isRecordingBeforePause = false
+ if (isRecording) return true
+ captureMode = CaptureMode.DICTATION
+ return startRecording()
+ }
+
@RequiresApi(Build.VERSION_CODES.Q)
fun toggleRecording(value: Boolean): Boolean {
+ isRecordingBeforePause = false
if (value == isRecording) return isRecording
if (value) {
+ captureMode = CaptureMode.CONVERSATION
return startRecording()
} else {
stopRecording()
@@ -401,6 +436,7 @@ class AudioEngine (context: Context) {
@SuppressLint("NewApi")
fun playPCMData(data: ByteArray) {
acquireAudioSessionIfNeeded()
+ audioTrack.play()
audioSampleQueue.add(data)
playbackEvents += 1
playbackQueuedBytes += data.size.toLong()
@@ -464,7 +500,7 @@ class AudioEngine (context: Context) {
@RequiresApi(Build.VERSION_CODES.Q)
fun pauseRecordingAndPlayer() {
isRecordingBeforePause = isRecording
- isRecording = toggleRecording(false)
+ stopRecording()
audioTrack.pause()
}
@@ -478,16 +514,18 @@ class AudioEngine (context: Context) {
if (!wasRecordingBeforePause && !isPlaying) {
return
}
- activateCommunicationRoute()
- if (!requestAudioFocus()) {
- handleAudioFocusBlocked()
- return
+ isRecordingBeforePause = false
+ if (wasRecordingBeforePause) {
+ // Returning from music controls must not pause music the user explicitly resumed.
+ if (!startRecording(requestFocus = captureMode == CaptureMode.CONVERSATION)) return
+ } else {
+ activateCommunicationRoute()
+ if (!requestAudioFocus()) {
+ handleAudioFocusBlocked()
+ return
+ }
}
- isRecording = toggleRecording(wasRecordingBeforePause)
- if (wasRecordingBeforePause && !isRecording) {
- return
- }
- audioTrack.play()
+ if (captureMode == CaptureMode.CONVERSATION) audioTrack.play()
}
fun stopPlayback() {
@@ -520,7 +558,11 @@ class AudioEngine (context: Context) {
audioManager.abandonAudioFocusRequest(request)
}
audioFocusRequest = null
+ audioManager.unregisterAudioDeviceCallback(deviceCallback)
+ audioManager.unregisterAudioRecordingCallback(recordingCallback)
+ audioTrack.release()
executorServiceMicrophone.shutdownNow()
+ executorServicePlayback.shutdownNow()
}
diff --git a/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/ExpoTwoWayAudioModule.kt b/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/ExpoTwoWayAudioModule.kt
index f9b107c99f..68f1489de7 100644
--- a/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/ExpoTwoWayAudioModule.kt
+++ b/packages/expo-two-way-audio/android/src/main/java/expo/modules/twowayaudio/ExpoTwoWayAudioModule.kt
@@ -42,6 +42,12 @@ class ExpoTwoWayAudioModule : Module() {
audioEngine?.isRecording ?: false
}
+ Function("startDictation") {
+ val isRecording = audioEngine?.startDictation() ?: false
+ sendEvent(ON_RECORDING_CHANGE_EVENT, mapOf("data" to isRecording))
+ isRecording
+ }
+
Function("toggleRecording") { value: Boolean ->
audioEngine?.let { engine ->
val isRecording = engine.toggleRecording(value)
diff --git a/packages/expo-two-way-audio/ios/ExpoTwoWayAudioModule.swift b/packages/expo-two-way-audio/ios/ExpoTwoWayAudioModule.swift
index baec747995..046f856747 100644
--- a/packages/expo-two-way-audio/ios/ExpoTwoWayAudioModule.swift
+++ b/packages/expo-two-way-audio/ios/ExpoTwoWayAudioModule.swift
@@ -59,6 +59,12 @@ public class ExpoTwoWayAudioModule: Module {
return audioEngine.isRecording
}
+ Function("startDictation") { () -> Bool in
+ let isRecording = self.audioEngine?.toggleRecording(true) ?? false
+ self.sendEvent(ON_RECORDING_CHANGE_EVENT_NAME, ["data": isRecording])
+ return isRecording
+ }
+
Function("toggleRecording") { (val: Bool) -> Bool in
guard let audioEngine = self.audioEngine else {
print("AudioEngine not initialized")
diff --git a/packages/expo-two-way-audio/src/core.ts b/packages/expo-two-way-audio/src/core.ts
index 1a54e0e9ce..48215f2bae 100644
--- a/packages/expo-two-way-audio/src/core.ts
+++ b/packages/expo-two-way-audio/src/core.ts
@@ -17,6 +17,16 @@ export function toggleRecording(val: boolean): boolean {
return ExpoTwoWayAudioModule.toggleRecording(val);
}
+/** Capture speech without treating another app's playback focus as a microphone failure. */
+export function startDictation(): boolean {
+ // COMPAT(dictationCapture): added after v0.11.2, remove after 2027-01-16 once
+ // the native binary floor includes startDictation. Older OTA clients retain legacy capture.
+ if (typeof ExpoTwoWayAudioModule.startDictation !== "function") {
+ return ExpoTwoWayAudioModule.toggleRecording(true);
+ }
+ return ExpoTwoWayAudioModule.startDictation();
+}
+
export function isRecording(): boolean {
return ExpoTwoWayAudioModule.isRecording();
}