Skip to content

Commit 6e6e9a5

Browse files
Merge pull request #231 from blackboxembedded/bt_mic
Use Bluetooth microphone if available for Video Recordings.
2 parents c101c85 + 84bc3a7 commit 6e6e9a5

3 files changed

Lines changed: 712 additions & 180 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
2929
<uses-permission android:name="android.permission.CAMERA" />
3030
<uses-permission android:name="android.permission.RECORD_AUDIO" />
31+
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
3132
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
3233
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
3334
<uses-permission android:name="android.permission.CALL_PHONE" />
Lines changed: 311 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,311 @@
1+
/*
2+
WunderLINQ Client Application
3+
Copyright (C) 2020 Keith Conger, Black Box Embedded, LLC
4+
5+
This program is free software: you can redistribute it and/or modify
6+
it under the terms of the GNU General Public License as published by
7+
the Free Software Foundation, either version 3 of the License, or
8+
(at your option) any later version.
9+
10+
This program is distributed in the hope that it will be useful,
11+
but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
GNU General Public License for more details.
14+
15+
You should have received a copy of the GNU General Public License
16+
along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
package com.blackboxembedded.WunderLINQ;
19+
20+
import android.Manifest;
21+
import android.content.BroadcastReceiver;
22+
import android.content.Context;
23+
import android.content.Intent;
24+
import android.content.IntentFilter;
25+
import android.media.AudioAttributes;
26+
import android.media.AudioDeviceInfo;
27+
import android.media.AudioManager;
28+
import android.media.AudioFocusRequest;
29+
import android.os.Build;
30+
import android.os.Handler;
31+
import android.os.Looper;
32+
import android.util.Log;
33+
34+
import androidx.annotation.NonNull;
35+
import androidx.annotation.Nullable;
36+
import androidx.core.content.ContextCompat;
37+
38+
import java.util.List;
39+
40+
import static android.content.pm.PackageManager.PERMISSION_GRANTED;
41+
42+
public final class BluetoothMicRouter {
43+
private static final String TAG = "BluetoothMicRouter";
44+
private static final long LEGACY_SCO_TIMEOUT_MS = 4000;
45+
46+
private final Context appContext;
47+
private final AudioManager audioManager;
48+
private final Handler mainHandler = new Handler(Looper.getMainLooper());
49+
50+
@Nullable private BroadcastReceiver scoReceiver;
51+
@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;
57+
58+
private boolean usingBtMic = false;
59+
private boolean waitingForSco = false;
60+
61+
public BluetoothMicRouter(@NonNull Context ctx) {
62+
appContext = ctx.getApplicationContext();
63+
audioManager = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE);
64+
}
65+
66+
/**
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).
69+
*/
70+
public void routeToBluetoothIfPresentThen(@NonNull Runnable onInputReady) {
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) {}
76+
77+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
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");
87+
}
88+
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;
93+
}
94+
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)));
98+
runOnMain(onInputReady);
99+
return;
100+
}
101+
102+
// 5) Legacy (<=30): use SCO if available, else continue
103+
if (audioManager.isBluetoothScoAvailableOffCall()) {
104+
startScoThen(onInputReady, /*retrySetCommDevice=*/false);
105+
} else {
106+
runOnMain(onInputReady);
107+
}
108+
}
109+
110+
/** Undo routing and restore defaults. Safe to call multiple times. */
111+
public void clearRouting() {
112+
if (scoTimeout != null) {
113+
mainHandler.removeCallbacks(scoTimeout);
114+
scoTimeout = null;
115+
}
116+
pendingCallback = null;
117+
118+
if (scoReceiver != null) {
119+
try { appContext.unregisterReceiver(scoReceiver); } catch (Throwable ignored) {}
120+
scoReceiver = null;
121+
}
122+
123+
if (audioManager != null) {
124+
try {
125+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
126+
audioManager.clearCommunicationDevice();
127+
} else {
128+
try { audioManager.stopBluetoothSco(); } catch (Throwable ignored) {}
129+
try { /*noinspection deprecation*/ audioManager.setBluetoothScoOn(false); } catch (Throwable ignored) {}
130+
}
131+
} catch (Throwable t) {
132+
Log.w(TAG, "clearRouting error", t);
133+
}
134+
try { audioManager.setMode(AudioManager.MODE_NORMAL); } catch (Throwable ignored) {}
135+
}
136+
137+
abandonVoiceCommFocus();
138+
139+
usingBtMic = false;
140+
waitingForSco = false;
141+
}
142+
143+
public boolean isUsingBtMic() { return usingBtMic; }
144+
public boolean isWaitingForSco() { return waitingForSco; }
145+
146+
// ---------- Internals ----------
147+
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;
160+
}
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);
169+
}
170+
171+
private void registerScoReceiver(@NonNull Runnable onInputReady, boolean retrySetCommDeviceAfterConnected) {
172+
if (scoReceiver != null) return;
173+
174+
pendingCallback = onInputReady;
175+
176+
IntentFilter f = new IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED);
177+
scoReceiver = new BroadcastReceiver() {
178+
@Override public void onReceive(Context context, Intent intent) {
179+
if (!AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED.equals(intent.getAction())) return;
180+
int state = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1);
181+
if (state == AudioManager.SCO_AUDIO_STATE_CONNECTED) {
182+
Log.d(TAG, "SCO connected");
183+
usingBtMic = true;
184+
waitingForSco = false;
185+
186+
if (scoTimeout != null) {
187+
mainHandler.removeCallbacks(scoTimeout);
188+
scoTimeout = null;
189+
}
190+
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();
204+
} else if (state == AudioManager.SCO_AUDIO_STATE_DISCONNECTED) {
205+
Log.d(TAG, "SCO disconnected");
206+
}
207+
}
208+
};
209+
appContext.registerReceiver(scoReceiver, f);
210+
}
211+
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;
290+
pendingCallback = null;
291+
if (cb != null) runOnMain(cb);
292+
}
293+
294+
private void runOnMain(@NonNull Runnable r) {
295+
if (Looper.myLooper() == Looper.getMainLooper()) r.run();
296+
else mainHandler.post(r);
297+
}
298+
299+
private static String deviceLabel(@NonNull AudioDeviceInfo dev) {
300+
String addr = null;
301+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
302+
try { addr = dev.getAddress(); } catch (Throwable ignored) {}
303+
}
304+
CharSequence pn = dev.getProductName();
305+
String name = (pn != null) ? pn.toString() : "unknown";
306+
return "type=" + dev.getType()
307+
+ " id=" + dev.getId()
308+
+ " name=" + name
309+
+ ((addr != null && !addr.isEmpty()) ? " addr=" + addr : "");
310+
}
311+
}

0 commit comments

Comments
 (0)