2222import android .content .Context ;
2323import android .content .Intent ;
2424import android .content .IntentFilter ;
25+ import android .media .AudioAttributes ;
2526import android .media .AudioDeviceInfo ;
2627import android .media .AudioManager ;
28+ import android .media .AudioFocusRequest ;
2729import android .os .Build ;
2830import android .os .Handler ;
2931import android .os .Looper ;
3335import androidx .annotation .Nullable ;
3436import androidx .core .content .ContextCompat ;
3537
38+ import java .util .List ;
39+
3640import static android .content .pm .PackageManager .PERMISSION_GRANTED ;
3741
3842public final class BluetoothMicRouter {
3943 private static final String TAG = "BluetoothMicRouter" ;
40- private static final long LEGACY_SCO_TIMEOUT_MS = 4000 ; // fail-safe
44+ private static final long LEGACY_SCO_TIMEOUT_MS = 4000 ;
4145
4246 private final Context appContext ;
4347 private final AudioManager audioManager ;
4448 private final Handler mainHandler = new Handler (Looper .getMainLooper ());
4549
4650 @ Nullable private BroadcastReceiver scoReceiver ;
47- @ Nullable private Runnable pendingCallback ;
4851 @ Nullable private Runnable scoTimeout ;
52+ @ Nullable private Runnable pendingCallback ;
53+
54+ // Audio focus (26+) or legacy focus
55+ @ Nullable private AudioFocusRequest focusRequest ;
56+ @ Nullable private AudioManager .OnAudioFocusChangeListener legacyFocusCb ;
4957
5058 private boolean usingBtMic = false ;
5159 private boolean waitingForSco = false ;
5260
5361 public BluetoothMicRouter (@ NonNull Context ctx ) {
54- this . appContext = ctx .getApplicationContext ();
55- this . audioManager = (AudioManager ) appContext .getSystemService (Context .AUDIO_SERVICE );
62+ appContext = ctx .getApplicationContext ();
63+ audioManager = (AudioManager ) appContext .getSystemService (Context .AUDIO_SERVICE );
5664 }
5765
5866 /**
59- * Attempts to route input to a Bluetooth microphone if one is present.
60- * Calls {@code onInputReady.run()}:
61- * - immediately if routing succeeds (API 31+) or no BT mic is present (fallback to built-in),
62- * - after SCO connects on legacy devices (<=30), or after a timeout (fallback).
67+ * Route to a Bluetooth mic if available, then run onInputReady.
68+ * Always calls the callback (falls back to built-in mic if routing fails).
6369 */
6470 public void routeToBluetoothIfPresentThen (@ NonNull Runnable onInputReady ) {
65- if (audioManager == null ) {
66- Log . w ( TAG , "AudioManager null; continuing without BT mic" );
67- runOnMain ( onInputReady );
68- return ;
69- }
71+ if (audioManager == null ) { runOnMain ( onInputReady ); return ; }
72+
73+ // 1) Ask for voice-comm focus and set COMM mode (helps a lot of stacks)
74+ requestVoiceCommFocus () ;
75+ try { audioManager . setMode ( AudioManager . MODE_IN_COMMUNICATION ); } catch ( Throwable ignored ) { }
7076
71- // API 31+ modern routing
7277 if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .S ) {
73- if (ContextCompat .checkSelfPermission (appContext , Manifest .permission .BLUETOOTH_CONNECT ) != PERMISSION_GRANTED ) {
74- Log .w (TAG , "BLUETOOTH_CONNECT not granted; continuing without BT routing" );
75- runOnMain (onInputReady );
76- return ;
78+ // 2) API 31+: pick a communication device (BLE preferred, then SCO)
79+ AudioDeviceInfo selected = pickBtCommDevice31Plus ();
80+ boolean ok = false ;
81+ if (selected != null ) {
82+ ok = audioManager .setCommunicationDevice (selected );
83+ usingBtMic = ok ;
84+ Log .d (TAG , "setCommunicationDevice -> " + ok + " (" + deviceLabel (selected ) + ")" );
85+ } else {
86+ Log .d (TAG , "No BT communication devices currently listed" );
7787 }
7888
79- try {
80- AudioDeviceInfo bt = findBluetoothInputApi31Plus ();
81- if (bt != null ) {
82- audioManager .setMode (AudioManager .MODE_IN_COMMUNICATION );
83- boolean ok = audioManager .setCommunicationDevice (bt );
84- usingBtMic = ok ;
85- Log .d (TAG , "setCommunicationDevice -> " + ok + " (" + deviceLabel (bt ) + ")" );
86- // Either way, start now (if ok=true it's BT, else built-in)
87- runOnMain (onInputReady );
88- return ;
89- }
90- } catch (SecurityException se ) {
91- Log .w (TAG , "setCommunicationDevice denied" , se );
92- } catch (Throwable t ) {
93- Log .w (TAG , "API31+ routing error" , t );
89+ // 3) If that didn’t stick, try SCO as a fallback (some stacks need SCO handshake)
90+ if (!ok && audioManager .isBluetoothScoAvailableOffCall ()) {
91+ startScoThen (onInputReady , /*retrySetCommDevice=*/ true );
92+ return ;
9493 }
9594
96- // No BT mic found; continue with built-in
95+ // 4) Log current comm device and continue
96+ AudioDeviceInfo cur = audioManager .getCommunicationDevice ();
97+ Log .d (TAG , "Current COMM device: " + (cur == null ? "null" : deviceLabel (cur )));
9798 runOnMain (onInputReady );
9899 return ;
99100 }
100101
101- // Legacy (<=30): SCO
102+ // 5) Legacy (<=30): use SCO if available, else continue
102103 if (audioManager .isBluetoothScoAvailableOffCall ()) {
103- try {
104- audioManager .setMode (AudioManager .MODE_IN_COMMUNICATION );
105- // Some OEMs require this deprecated toggle for SCO mic path pre-31:
106- try {
107- audioManager .setBluetoothScoOn (true );
108- } catch (Throwable ignored ) { }
109-
110- waitingForSco = true ;
111- registerScoReceiver (onInputReady );
112-
113- audioManager .startBluetoothSco ();
114- Log .d (TAG , "startBluetoothSco() called; waiting for SCO connect" );
115-
116- // Fail-safe timeout: start anyway after X ms if SCO never connects
117- scoTimeout = () -> {
118- Log .w (TAG , "SCO connect timeout; continuing without BT mic" );
119- waitingForSco = false ;
120- runAndClearPending (onInputReady );
121- };
122- mainHandler .postDelayed (scoTimeout , LEGACY_SCO_TIMEOUT_MS );
123- return ;
124- } catch (Throwable t ) {
125- Log .w (TAG , "Legacy SCO routing error; continuing without BT mic" , t );
126- }
104+ startScoThen (onInputReady , /*retrySetCommDevice=*/ false );
105+ } else {
106+ runOnMain (onInputReady );
127107 }
128-
129- // No SCO available; continue with built-in
130- runOnMain (onInputReady );
131108 }
132109
133- /** Undo routing and restore audio mode . Safe to call multiple times. */
110+ /** Undo routing and restore defaults . Safe to call multiple times. */
134111 public void clearRouting () {
135- // Cancel timeout/pending
136112 if (scoTimeout != null ) {
137113 mainHandler .removeCallbacks (scoTimeout );
138114 scoTimeout = null ;
139115 }
140116 pendingCallback = null ;
141117
142- // Unregister receiver
143118 if (scoReceiver != null ) {
144119 try { appContext .unregisterReceiver (scoReceiver ); } catch (Throwable ignored ) {}
145120 scoReceiver = null ;
146121 }
147122
148- // Clear routing
149123 if (audioManager != null ) {
150124 try {
151125 if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .S ) {
152126 audioManager .clearCommunicationDevice ();
153127 } else {
154- if (waitingForSco || usingBtMic ) {
155- try { audioManager .stopBluetoothSco (); } catch (Throwable ignored ) {}
156- try {
157- audioManager .setBluetoothScoOn (false );
158- } catch (Throwable ignored ) {}
159- }
128+ try { audioManager .stopBluetoothSco (); } catch (Throwable ignored ) {}
129+ try { /*noinspection deprecation*/ audioManager .setBluetoothScoOn (false ); } catch (Throwable ignored ) {}
160130 }
161131 } catch (Throwable t ) {
162132 Log .w (TAG , "clearRouting error" , t );
163133 }
164-
165- try {
166- audioManager .setMode (AudioManager .MODE_NORMAL );
167- } catch (Throwable ignored ) {}
134+ try { audioManager .setMode (AudioManager .MODE_NORMAL ); } catch (Throwable ignored ) {}
168135 }
169136
137+ abandonVoiceCommFocus ();
138+
170139 usingBtMic = false ;
171140 waitingForSco = false ;
172141 }
173142
174- /** True if we successfully routed to a BT mic. */
175143 public boolean isUsingBtMic () { return usingBtMic ; }
176-
177- /** True while <=30 is waiting for SCO connection. */
178144 public boolean isWaitingForSco () { return waitingForSco ; }
179145
180146 // ---------- Internals ----------
181147
182- @ Nullable
183- private AudioDeviceInfo findBluetoothInputApi31Plus () {
184- // Look through input devices and find first BT-capable mic
185- for (AudioDeviceInfo dev : audioManager .getDevices (AudioManager .GET_DEVICES_INPUTS )) {
186- int type = dev .getType ();
187- if (type == AudioDeviceInfo .TYPE_BLUETOOTH_SCO
188- || (Build .VERSION .SDK_INT >= Build .VERSION_CODES .S
189- && type == AudioDeviceInfo .TYPE_BLE_HEADSET )) {
190- return dev ;
191- }
148+ private void startScoThen (@ NonNull Runnable onInputReady , boolean retrySetCommDeviceAfterConnected ) {
149+ try { /*noinspection deprecation*/ audioManager .setBluetoothScoOn (true ); } catch (Throwable ignored ) {}
150+ waitingForSco = true ;
151+ registerScoReceiver (onInputReady , retrySetCommDeviceAfterConnected );
152+ try {
153+ audioManager .startBluetoothSco ();
154+ Log .d (TAG , "startBluetoothSco… waiting for SCO_AUDIO_STATE_CONNECTED" );
155+ } catch (Throwable t ) {
156+ Log .w (TAG , "startBluetoothSco failed; continuing without BT mic" , t );
157+ waitingForSco = false ;
158+ runOnMain (onInputReady );
159+ return ;
192160 }
193- return null ;
161+
162+ // Fail-safe timeout
163+ scoTimeout = () -> {
164+ Log .w (TAG , "SCO timeout; continuing" );
165+ waitingForSco = false ;
166+ runOnMain (onInputReady );
167+ };
168+ mainHandler .postDelayed (scoTimeout , LEGACY_SCO_TIMEOUT_MS );
194169 }
195170
196- private void registerScoReceiver (@ NonNull Runnable onInputReady ) {
171+ private void registerScoReceiver (@ NonNull Runnable onInputReady , boolean retrySetCommDeviceAfterConnected ) {
197172 if (scoReceiver != null ) return ;
198173
199174 pendingCallback = onInputReady ;
@@ -208,13 +183,24 @@ private void registerScoReceiver(@NonNull Runnable onInputReady) {
208183 usingBtMic = true ;
209184 waitingForSco = false ;
210185
211- // Cancel timeout, run callback
212186 if (scoTimeout != null ) {
213187 mainHandler .removeCallbacks (scoTimeout );
214188 scoTimeout = null ;
215189 }
216- runAndClearPending (null );
217190
191+ // On API 31+, retry setting COMM device now that SCO is active
192+ if (retrySetCommDeviceAfterConnected && Build .VERSION .SDK_INT >= Build .VERSION_CODES .S ) {
193+ AudioDeviceInfo pick = pickBtCommDevice31Plus (/*preferSco=*/ true );
194+ if (pick != null ) {
195+ boolean ok2 = audioManager .setCommunicationDevice (pick );
196+ Log .d (TAG , "setCommunicationDevice(after SCO) -> " + ok2 + " (" + deviceLabel (pick ) + ")" );
197+ usingBtMic = usingBtMic || ok2 ;
198+ }
199+ AudioDeviceInfo cur = audioManager .getCommunicationDevice ();
200+ Log .d (TAG , "Current COMM device: " + (cur == null ? "null" : deviceLabel (cur )));
201+ }
202+
203+ runAndClearPending ();
218204 } else if (state == AudioManager .SCO_AUDIO_STATE_DISCONNECTED ) {
219205 Log .d (TAG , "SCO disconnected" );
220206 }
@@ -223,8 +209,84 @@ private void registerScoReceiver(@NonNull Runnable onInputReady) {
223209 appContext .registerReceiver (scoReceiver , f );
224210 }
225211
226- private void runAndClearPending (@ Nullable Runnable fallback ) {
227- Runnable cb = pendingCallback != null ? pendingCallback : fallback ;
212+ @ Nullable
213+ private AudioDeviceInfo pickBtCommDevice31Plus () {
214+ return pickBtCommDevice31Plus (false );
215+ }
216+
217+ @ Nullable
218+ private AudioDeviceInfo pickBtCommDevice31Plus (boolean preferSco ) {
219+ if (Build .VERSION .SDK_INT < Build .VERSION_CODES .S ) return null ;
220+ if (ContextCompat .checkSelfPermission (appContext , Manifest .permission .BLUETOOTH_CONNECT ) != PERMISSION_GRANTED ) {
221+ Log .w (TAG , "BLUETOOTH_CONNECT not granted; cannot list comm devices" );
222+ return null ;
223+ }
224+ try {
225+ List <AudioDeviceInfo > comm = audioManager .getAvailableCommunicationDevices ();
226+ if (comm == null || comm .isEmpty ()) return null ;
227+
228+ AudioDeviceInfo ble = null , sco = null ;
229+ for (AudioDeviceInfo d : comm ) {
230+ if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .S && d .getType () == AudioDeviceInfo .TYPE_BLE_HEADSET ) ble = d ;
231+ if (d .getType () == AudioDeviceInfo .TYPE_BLUETOOTH_SCO ) sco = d ;
232+ }
233+ if (preferSco ) return (sco != null ) ? sco : ble ;
234+ return (ble != null ) ? ble : sco ;
235+ } catch (Throwable t ) {
236+ Log .w (TAG , "pickBtCommDevice31Plus error" , t );
237+ return null ;
238+ }
239+ }
240+
241+ private void requestVoiceCommFocus () {
242+ if (audioManager == null ) return ;
243+ try {
244+ AudioAttributes attrs = new AudioAttributes .Builder ()
245+ .setUsage (AudioAttributes .USAGE_VOICE_COMMUNICATION )
246+ .setContentType (AudioAttributes .CONTENT_TYPE_SPEECH )
247+ .build ();
248+
249+ if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .O ) {
250+ focusRequest = new AudioFocusRequest .Builder (AudioManager .AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE )
251+ .setAudioAttributes (attrs )
252+ .setOnAudioFocusChangeListener (fc -> {})
253+ .build ();
254+ int res = audioManager .requestAudioFocus (focusRequest );
255+ Log .d (TAG , "requestAudioFocus(26+) -> " + res );
256+ } else {
257+ legacyFocusCb = fc -> {};
258+ @ SuppressWarnings ("deprecation" )
259+ int res = audioManager .requestAudioFocus (
260+ legacyFocusCb ,
261+ AudioManager .STREAM_VOICE_CALL ,
262+ AudioManager .AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE
263+ );
264+ Log .d (TAG , "requestAudioFocus(legacy) -> " + res );
265+ }
266+ } catch (Throwable t ) {
267+ Log .w (TAG , "requestVoiceCommFocus failed" , t );
268+ }
269+ }
270+
271+ private void abandonVoiceCommFocus () {
272+ if (audioManager == null ) return ;
273+ try {
274+ if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .O ) {
275+ if (focusRequest != null ) {
276+ audioManager .abandonAudioFocusRequest (focusRequest );
277+ focusRequest = null ;
278+ }
279+ } else if (legacyFocusCb != null ) {
280+ @ SuppressWarnings ("deprecation" )
281+ int res = audioManager .abandonAudioFocus (legacyFocusCb );
282+ legacyFocusCb = null ;
283+ Log .d (TAG , "abandonAudioFocus(legacy) -> " + res );
284+ }
285+ } catch (Throwable ignored ) {}
286+ }
287+
288+ private void runAndClearPending () {
289+ Runnable cb = pendingCallback ;
228290 pendingCallback = null ;
229291 if (cb != null ) runOnMain (cb );
230292 }
@@ -234,15 +296,13 @@ private void runOnMain(@NonNull Runnable r) {
234296 else mainHandler .post (r );
235297 }
236298
237- private static String deviceLabel (AudioDeviceInfo dev ) {
299+ private static String deviceLabel (@ NonNull AudioDeviceInfo dev ) {
238300 String addr = null ;
239- if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .P ) { // API 28+
301+ if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .P ) {
240302 try { addr = dev .getAddress (); } catch (Throwable ignored ) {}
241303 }
242- CharSequence pn = dev .getProductName (); // avail since API 23
304+ CharSequence pn = dev .getProductName ();
243305 String name = (pn != null ) ? pn .toString () : "unknown" ;
244-
245- // On API < 28 we won't have an address; include id/name instead.
246306 return "type=" + dev .getType ()
247307 + " id=" + dev .getId ()
248308 + " name=" + name
0 commit comments