Skip to content

Commit f7a46e2

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

3 files changed

Lines changed: 267 additions & 12 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: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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.AudioDeviceInfo;
26+
import android.media.AudioManager;
27+
import android.os.Build;
28+
import android.os.Handler;
29+
import android.os.Looper;
30+
import android.util.Log;
31+
32+
import androidx.annotation.NonNull;
33+
import androidx.annotation.Nullable;
34+
import androidx.core.content.ContextCompat;
35+
36+
import static android.content.pm.PackageManager.PERMISSION_GRANTED;
37+
38+
public final class BluetoothMicRouter {
39+
private static final String TAG = "BluetoothMicRouter";
40+
private static final long LEGACY_SCO_TIMEOUT_MS = 4000; // fail-safe
41+
42+
private final Context appContext;
43+
private final AudioManager audioManager;
44+
private final Handler mainHandler = new Handler(Looper.getMainLooper());
45+
46+
@Nullable private BroadcastReceiver scoReceiver;
47+
@Nullable private Runnable pendingCallback;
48+
@Nullable private Runnable scoTimeout;
49+
50+
private boolean usingBtMic = false;
51+
private boolean waitingForSco = false;
52+
53+
public BluetoothMicRouter(@NonNull Context ctx) {
54+
this.appContext = ctx.getApplicationContext();
55+
this.audioManager = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE);
56+
}
57+
58+
/**
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).
63+
*/
64+
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+
}
70+
71+
// API 31+ modern routing
72+
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;
77+
}
78+
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);
94+
}
95+
96+
// No BT mic found; continue with built-in
97+
runOnMain(onInputReady);
98+
return;
99+
}
100+
101+
// Legacy (<=30): SCO
102+
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+
}
127+
}
128+
129+
// No SCO available; continue with built-in
130+
runOnMain(onInputReady);
131+
}
132+
133+
/** Undo routing and restore audio mode. Safe to call multiple times. */
134+
public void clearRouting() {
135+
// Cancel timeout/pending
136+
if (scoTimeout != null) {
137+
mainHandler.removeCallbacks(scoTimeout);
138+
scoTimeout = null;
139+
}
140+
pendingCallback = null;
141+
142+
// Unregister receiver
143+
if (scoReceiver != null) {
144+
try { appContext.unregisterReceiver(scoReceiver); } catch (Throwable ignored) {}
145+
scoReceiver = null;
146+
}
147+
148+
// Clear routing
149+
if (audioManager != null) {
150+
try {
151+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
152+
audioManager.clearCommunicationDevice();
153+
} else {
154+
if (waitingForSco || usingBtMic) {
155+
try { audioManager.stopBluetoothSco(); } catch (Throwable ignored) {}
156+
try {
157+
audioManager.setBluetoothScoOn(false);
158+
} catch (Throwable ignored) {}
159+
}
160+
}
161+
} catch (Throwable t) {
162+
Log.w(TAG, "clearRouting error", t);
163+
}
164+
165+
try {
166+
audioManager.setMode(AudioManager.MODE_NORMAL);
167+
} catch (Throwable ignored) {}
168+
}
169+
170+
usingBtMic = false;
171+
waitingForSco = false;
172+
}
173+
174+
/** True if we successfully routed to a BT mic. */
175+
public boolean isUsingBtMic() { return usingBtMic; }
176+
177+
/** True while <=30 is waiting for SCO connection. */
178+
public boolean isWaitingForSco() { return waitingForSco; }
179+
180+
// ---------- Internals ----------
181+
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+
}
192+
}
193+
return null;
194+
}
195+
196+
private void registerScoReceiver(@NonNull Runnable onInputReady) {
197+
if (scoReceiver != null) return;
198+
199+
pendingCallback = onInputReady;
200+
201+
IntentFilter f = new IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED);
202+
scoReceiver = new BroadcastReceiver() {
203+
@Override public void onReceive(Context context, Intent intent) {
204+
if (!AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED.equals(intent.getAction())) return;
205+
int state = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1);
206+
if (state == AudioManager.SCO_AUDIO_STATE_CONNECTED) {
207+
Log.d(TAG, "SCO connected");
208+
usingBtMic = true;
209+
waitingForSco = false;
210+
211+
// Cancel timeout, run callback
212+
if (scoTimeout != null) {
213+
mainHandler.removeCallbacks(scoTimeout);
214+
scoTimeout = null;
215+
}
216+
runAndClearPending(null);
217+
218+
} else if (state == AudioManager.SCO_AUDIO_STATE_DISCONNECTED) {
219+
Log.d(TAG, "SCO disconnected");
220+
}
221+
}
222+
};
223+
appContext.registerReceiver(scoReceiver, f);
224+
}
225+
226+
private void runAndClearPending(@Nullable Runnable fallback) {
227+
Runnable cb = pendingCallback != null ? pendingCallback : fallback;
228+
pendingCallback = null;
229+
if (cb != null) runOnMain(cb);
230+
}
231+
232+
private void runOnMain(@NonNull Runnable r) {
233+
if (Looper.myLooper() == Looper.getMainLooper()) r.run();
234+
else mainHandler.post(r);
235+
}
236+
237+
private static String deviceLabel(AudioDeviceInfo dev) {
238+
String addr = null;
239+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { // API 28+
240+
try { addr = dev.getAddress(); } catch (Throwable ignored) {}
241+
}
242+
CharSequence pn = dev.getProductName(); // avail since API 23
243+
String name = (pn != null) ? pn.toString() : "unknown";
244+
245+
// On API < 28 we won't have an address; include id/name instead.
246+
return "type=" + dev.getType()
247+
+ " id=" + dev.getId()
248+
+ " name=" + name
249+
+ ((addr != null && !addr.isEmpty()) ? " addr=" + addr : "");
250+
}
251+
}

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

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ public class VideoRecService extends Service implements LifecycleOwner {
8686
private VideoCapture<Recorder> videoCapture;
8787
private Recording activeRecording;
8888

89+
private BluetoothMicRouter btRouter;
90+
8991
// Optional: last known location (for MediaStore LAT/LON columns)
9092
@Nullable private Location location;
9193

@@ -95,6 +97,8 @@ public void onCreate() {
9597
lifecycleRegistry = new LifecycleRegistry(this);
9698
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE);
9799
createNotification();
100+
101+
btRouter = new BluetoothMicRouter(this);
98102
}
99103

100104
@Override
@@ -178,7 +182,7 @@ private void startRecordingToMediaStore() {
178182
.setContentValues(values)
179183
.build();
180184

181-
beginRecording(outputOptions);
185+
btRouter.routeToBluetoothIfPresentThen(() -> beginRecording(outputOptions));
182186
}
183187

184188
private void startRecordingToFile() {
@@ -195,7 +199,7 @@ private void startRecordingToFile() {
195199
FileOutputOptions outputOptions =
196200
new FileOutputOptions.Builder(out).build();
197201

198-
beginRecording(outputOptions);
202+
btRouter.routeToBluetoothIfPresentThen(() -> beginRecording(outputOptions));
199203
}
200204

201205
@SuppressLint("MissingPermission")
@@ -288,21 +292,20 @@ public void onDestroy() {
288292
((MyApplication) getApplication()).setVideoRecording(false);
289293

290294
lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY);
295+
if (btRouter != null) btRouter.clearRouting();
291296
super.onDestroy();
292297
}
293298

294299
// --- Foreground notification (unchanged style) ---
295300
private void createNotification() {
296-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
297-
NotificationChannel ch = new NotificationChannel(
298-
CHANNEL_ID,
299-
getString(R.string.title_video_notification),
300-
NotificationManager.IMPORTANCE_DEFAULT
301-
);
302-
ch.setShowBadge(false);
303-
ch.setSound(null, null);
304-
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).createNotificationChannel(ch);
305-
}
301+
NotificationChannel ch = new NotificationChannel(
302+
CHANNEL_ID,
303+
getString(R.string.title_video_notification),
304+
NotificationManager.IMPORTANCE_DEFAULT
305+
);
306+
ch.setShowBadge(false);
307+
ch.setSound(null, null);
308+
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).createNotificationChannel(ch);
306309
Notification notif = new NotificationCompat.Builder(this, CHANNEL_ID)
307310
.setContentTitle(getString(R.string.title_video_notification))
308311
.setContentText("")

0 commit comments

Comments
 (0)