Skip to content

Commit d53c55f

Browse files
Add Bluetooth mic support for video recording, tested.
Signed-off-by: Keith Conger <keith.conger@blackboxembedded.com>
1 parent f7a46e2 commit d53c55f

2 files changed

Lines changed: 556 additions & 290 deletions

File tree

app/src/main/java/com/blackboxembedded/WunderLINQ/BluetoothMicRouter.java

Lines changed: 160 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
import android.content.Context;
2323
import android.content.Intent;
2424
import android.content.IntentFilter;
25+
import android.media.AudioAttributes;
2526
import android.media.AudioDeviceInfo;
2627
import android.media.AudioManager;
28+
import android.media.AudioFocusRequest;
2729
import android.os.Build;
2830
import android.os.Handler;
2931
import android.os.Looper;
@@ -33,167 +35,140 @@
3335
import androidx.annotation.Nullable;
3436
import androidx.core.content.ContextCompat;
3537

38+
import java.util.List;
39+
3640
import static android.content.pm.PackageManager.PERMISSION_GRANTED;
3741

3842
public 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

Comments
 (0)