From f7a46e2fa0d46692bbe7c566d15b6f46505d2807 Mon Sep 17 00:00:00 2001 From: Keith Conger Date: Tue, 9 Sep 2025 17:03:17 -0600 Subject: [PATCH 1/3] Add Bluetooth mic support for video recording, untested. Signed-off-by: Keith Conger --- app/src/main/AndroidManifest.xml | 1 + .../WunderLINQ/BluetoothMicRouter.java | 251 ++++++++++++++++++ .../WunderLINQ/VideoRecService.java | 27 +- 3 files changed, 267 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/blackboxembedded/WunderLINQ/BluetoothMicRouter.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1fbd60e..873008b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,6 +28,7 @@ + diff --git a/app/src/main/java/com/blackboxembedded/WunderLINQ/BluetoothMicRouter.java b/app/src/main/java/com/blackboxembedded/WunderLINQ/BluetoothMicRouter.java new file mode 100644 index 0000000..91a9d91 --- /dev/null +++ b/app/src/main/java/com/blackboxembedded/WunderLINQ/BluetoothMicRouter.java @@ -0,0 +1,251 @@ +/* +WunderLINQ Client Application +Copyright (C) 2020 Keith Conger, Black Box Embedded, LLC + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ +package com.blackboxembedded.WunderLINQ; + +import android.Manifest; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import static android.content.pm.PackageManager.PERMISSION_GRANTED; + +public final class BluetoothMicRouter { + private static final String TAG = "BluetoothMicRouter"; + private static final long LEGACY_SCO_TIMEOUT_MS = 4000; // fail-safe + + private final Context appContext; + private final AudioManager audioManager; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + @Nullable private BroadcastReceiver scoReceiver; + @Nullable private Runnable pendingCallback; + @Nullable private Runnable scoTimeout; + + private boolean usingBtMic = false; + private boolean waitingForSco = false; + + public BluetoothMicRouter(@NonNull Context ctx) { + this.appContext = ctx.getApplicationContext(); + this.audioManager = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE); + } + + /** + * Attempts to route input to a Bluetooth microphone if one is present. + * Calls {@code onInputReady.run()}: + * - immediately if routing succeeds (API 31+) or no BT mic is present (fallback to built-in), + * - after SCO connects on legacy devices (<=30), or after a timeout (fallback). + */ + public void routeToBluetoothIfPresentThen(@NonNull Runnable onInputReady) { + if (audioManager == null) { + Log.w(TAG, "AudioManager null; continuing without BT mic"); + runOnMain(onInputReady); + return; + } + + // API 31+ modern routing + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (ContextCompat.checkSelfPermission(appContext, Manifest.permission.BLUETOOTH_CONNECT) != PERMISSION_GRANTED) { + Log.w(TAG, "BLUETOOTH_CONNECT not granted; continuing without BT routing"); + runOnMain(onInputReady); + return; + } + + try { + AudioDeviceInfo bt = findBluetoothInputApi31Plus(); + if (bt != null) { + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + boolean ok = audioManager.setCommunicationDevice(bt); + usingBtMic = ok; + Log.d(TAG, "setCommunicationDevice -> " + ok + " (" + deviceLabel(bt) + ")"); + // Either way, start now (if ok=true it's BT, else built-in) + runOnMain(onInputReady); + return; + } + } catch (SecurityException se) { + Log.w(TAG, "setCommunicationDevice denied", se); + } catch (Throwable t) { + Log.w(TAG, "API31+ routing error", t); + } + + // No BT mic found; continue with built-in + runOnMain(onInputReady); + return; + } + + // Legacy (<=30): SCO + if (audioManager.isBluetoothScoAvailableOffCall()) { + try { + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + // Some OEMs require this deprecated toggle for SCO mic path pre-31: + try { + audioManager.setBluetoothScoOn(true); + } catch (Throwable ignored) { } + + waitingForSco = true; + registerScoReceiver(onInputReady); + + audioManager.startBluetoothSco(); + Log.d(TAG, "startBluetoothSco() called; waiting for SCO connect"); + + // Fail-safe timeout: start anyway after X ms if SCO never connects + scoTimeout = () -> { + Log.w(TAG, "SCO connect timeout; continuing without BT mic"); + waitingForSco = false; + runAndClearPending(onInputReady); + }; + mainHandler.postDelayed(scoTimeout, LEGACY_SCO_TIMEOUT_MS); + return; + } catch (Throwable t) { + Log.w(TAG, "Legacy SCO routing error; continuing without BT mic", t); + } + } + + // No SCO available; continue with built-in + runOnMain(onInputReady); + } + + /** Undo routing and restore audio mode. Safe to call multiple times. */ + public void clearRouting() { + // Cancel timeout/pending + if (scoTimeout != null) { + mainHandler.removeCallbacks(scoTimeout); + scoTimeout = null; + } + pendingCallback = null; + + // Unregister receiver + if (scoReceiver != null) { + try { appContext.unregisterReceiver(scoReceiver); } catch (Throwable ignored) {} + scoReceiver = null; + } + + // Clear routing + if (audioManager != null) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.clearCommunicationDevice(); + } else { + if (waitingForSco || usingBtMic) { + try { audioManager.stopBluetoothSco(); } catch (Throwable ignored) {} + try { + audioManager.setBluetoothScoOn(false); + } catch (Throwable ignored) {} + } + } + } catch (Throwable t) { + Log.w(TAG, "clearRouting error", t); + } + + try { + audioManager.setMode(AudioManager.MODE_NORMAL); + } catch (Throwable ignored) {} + } + + usingBtMic = false; + waitingForSco = false; + } + + /** True if we successfully routed to a BT mic. */ + public boolean isUsingBtMic() { return usingBtMic; } + + /** True while <=30 is waiting for SCO connection. */ + public boolean isWaitingForSco() { return waitingForSco; } + + // ---------- Internals ---------- + + @Nullable + private AudioDeviceInfo findBluetoothInputApi31Plus() { + // Look through input devices and find first BT-capable mic + for (AudioDeviceInfo dev : audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)) { + int type = dev.getType(); + if (type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO + || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + && type == AudioDeviceInfo.TYPE_BLE_HEADSET)) { + return dev; + } + } + return null; + } + + private void registerScoReceiver(@NonNull Runnable onInputReady) { + if (scoReceiver != null) return; + + pendingCallback = onInputReady; + + IntentFilter f = new IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED); + scoReceiver = new BroadcastReceiver() { + @Override public void onReceive(Context context, Intent intent) { + if (!AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED.equals(intent.getAction())) return; + int state = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1); + if (state == AudioManager.SCO_AUDIO_STATE_CONNECTED) { + Log.d(TAG, "SCO connected"); + usingBtMic = true; + waitingForSco = false; + + // Cancel timeout, run callback + if (scoTimeout != null) { + mainHandler.removeCallbacks(scoTimeout); + scoTimeout = null; + } + runAndClearPending(null); + + } else if (state == AudioManager.SCO_AUDIO_STATE_DISCONNECTED) { + Log.d(TAG, "SCO disconnected"); + } + } + }; + appContext.registerReceiver(scoReceiver, f); + } + + private void runAndClearPending(@Nullable Runnable fallback) { + Runnable cb = pendingCallback != null ? pendingCallback : fallback; + pendingCallback = null; + if (cb != null) runOnMain(cb); + } + + private void runOnMain(@NonNull Runnable r) { + if (Looper.myLooper() == Looper.getMainLooper()) r.run(); + else mainHandler.post(r); + } + + private static String deviceLabel(AudioDeviceInfo dev) { + String addr = null; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { // API 28+ + try { addr = dev.getAddress(); } catch (Throwable ignored) {} + } + CharSequence pn = dev.getProductName(); // avail since API 23 + String name = (pn != null) ? pn.toString() : "unknown"; + + // On API < 28 we won't have an address; include id/name instead. + return "type=" + dev.getType() + + " id=" + dev.getId() + + " name=" + name + + ((addr != null && !addr.isEmpty()) ? " addr=" + addr : ""); + } +} diff --git a/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java b/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java index 7a6ab4b..388189c 100644 --- a/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java +++ b/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java @@ -86,6 +86,8 @@ public class VideoRecService extends Service implements LifecycleOwner { private VideoCapture videoCapture; private Recording activeRecording; + private BluetoothMicRouter btRouter; + // Optional: last known location (for MediaStore LAT/LON columns) @Nullable private Location location; @@ -95,6 +97,8 @@ public void onCreate() { lifecycleRegistry = new LifecycleRegistry(this); lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE); createNotification(); + + btRouter = new BluetoothMicRouter(this); } @Override @@ -178,7 +182,7 @@ private void startRecordingToMediaStore() { .setContentValues(values) .build(); - beginRecording(outputOptions); + btRouter.routeToBluetoothIfPresentThen(() -> beginRecording(outputOptions)); } private void startRecordingToFile() { @@ -195,7 +199,7 @@ private void startRecordingToFile() { FileOutputOptions outputOptions = new FileOutputOptions.Builder(out).build(); - beginRecording(outputOptions); + btRouter.routeToBluetoothIfPresentThen(() -> beginRecording(outputOptions)); } @SuppressLint("MissingPermission") @@ -288,21 +292,20 @@ public void onDestroy() { ((MyApplication) getApplication()).setVideoRecording(false); lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY); + if (btRouter != null) btRouter.clearRouting(); super.onDestroy(); } // --- Foreground notification (unchanged style) --- private void createNotification() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel ch = new NotificationChannel( - CHANNEL_ID, - getString(R.string.title_video_notification), - NotificationManager.IMPORTANCE_DEFAULT - ); - ch.setShowBadge(false); - ch.setSound(null, null); - ((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).createNotificationChannel(ch); - } + NotificationChannel ch = new NotificationChannel( + CHANNEL_ID, + getString(R.string.title_video_notification), + NotificationManager.IMPORTANCE_DEFAULT + ); + ch.setShowBadge(false); + ch.setSound(null, null); + ((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).createNotificationChannel(ch); Notification notif = new NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.title_video_notification)) .setContentText("") From d53c55fc4cb04f3c10d33623fed66c74969f4fc5 Mon Sep 17 00:00:00 2001 From: Keith Conger Date: Wed, 10 Sep 2025 09:03:46 -0600 Subject: [PATCH 2/3] Add Bluetooth mic support for video recording, tested. Signed-off-by: Keith Conger --- .../WunderLINQ/BluetoothMicRouter.java | 260 +++++--- .../WunderLINQ/VideoRecService.java | 586 ++++++++++++------ 2 files changed, 556 insertions(+), 290 deletions(-) diff --git a/app/src/main/java/com/blackboxembedded/WunderLINQ/BluetoothMicRouter.java b/app/src/main/java/com/blackboxembedded/WunderLINQ/BluetoothMicRouter.java index 91a9d91..03b2445 100644 --- a/app/src/main/java/com/blackboxembedded/WunderLINQ/BluetoothMicRouter.java +++ b/app/src/main/java/com/blackboxembedded/WunderLINQ/BluetoothMicRouter.java @@ -22,8 +22,10 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.media.AudioAttributes; import android.media.AudioDeviceInfo; import android.media.AudioManager; +import android.media.AudioFocusRequest; import android.os.Build; import android.os.Handler; import android.os.Looper; @@ -33,167 +35,140 @@ import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; +import java.util.List; + import static android.content.pm.PackageManager.PERMISSION_GRANTED; public final class BluetoothMicRouter { private static final String TAG = "BluetoothMicRouter"; - private static final long LEGACY_SCO_TIMEOUT_MS = 4000; // fail-safe + private static final long LEGACY_SCO_TIMEOUT_MS = 4000; private final Context appContext; private final AudioManager audioManager; private final Handler mainHandler = new Handler(Looper.getMainLooper()); @Nullable private BroadcastReceiver scoReceiver; - @Nullable private Runnable pendingCallback; @Nullable private Runnable scoTimeout; + @Nullable private Runnable pendingCallback; + + // Audio focus (26+) or legacy focus + @Nullable private AudioFocusRequest focusRequest; + @Nullable private AudioManager.OnAudioFocusChangeListener legacyFocusCb; private boolean usingBtMic = false; private boolean waitingForSco = false; public BluetoothMicRouter(@NonNull Context ctx) { - this.appContext = ctx.getApplicationContext(); - this.audioManager = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE); + appContext = ctx.getApplicationContext(); + audioManager = (AudioManager) appContext.getSystemService(Context.AUDIO_SERVICE); } /** - * Attempts to route input to a Bluetooth microphone if one is present. - * Calls {@code onInputReady.run()}: - * - immediately if routing succeeds (API 31+) or no BT mic is present (fallback to built-in), - * - after SCO connects on legacy devices (<=30), or after a timeout (fallback). + * Route to a Bluetooth mic if available, then run onInputReady. + * Always calls the callback (falls back to built-in mic if routing fails). */ public void routeToBluetoothIfPresentThen(@NonNull Runnable onInputReady) { - if (audioManager == null) { - Log.w(TAG, "AudioManager null; continuing without BT mic"); - runOnMain(onInputReady); - return; - } + if (audioManager == null) { runOnMain(onInputReady); return; } + + // 1) Ask for voice-comm focus and set COMM mode (helps a lot of stacks) + requestVoiceCommFocus(); + try { audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); } catch (Throwable ignored) {} - // API 31+ modern routing if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (ContextCompat.checkSelfPermission(appContext, Manifest.permission.BLUETOOTH_CONNECT) != PERMISSION_GRANTED) { - Log.w(TAG, "BLUETOOTH_CONNECT not granted; continuing without BT routing"); - runOnMain(onInputReady); - return; + // 2) API 31+: pick a communication device (BLE preferred, then SCO) + AudioDeviceInfo selected = pickBtCommDevice31Plus(); + boolean ok = false; + if (selected != null) { + ok = audioManager.setCommunicationDevice(selected); + usingBtMic = ok; + Log.d(TAG, "setCommunicationDevice -> " + ok + " (" + deviceLabel(selected) + ")"); + } else { + Log.d(TAG, "No BT communication devices currently listed"); } - try { - AudioDeviceInfo bt = findBluetoothInputApi31Plus(); - if (bt != null) { - audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); - boolean ok = audioManager.setCommunicationDevice(bt); - usingBtMic = ok; - Log.d(TAG, "setCommunicationDevice -> " + ok + " (" + deviceLabel(bt) + ")"); - // Either way, start now (if ok=true it's BT, else built-in) - runOnMain(onInputReady); - return; - } - } catch (SecurityException se) { - Log.w(TAG, "setCommunicationDevice denied", se); - } catch (Throwable t) { - Log.w(TAG, "API31+ routing error", t); + // 3) If that didn’t stick, try SCO as a fallback (some stacks need SCO handshake) + if (!ok && audioManager.isBluetoothScoAvailableOffCall()) { + startScoThen(onInputReady, /*retrySetCommDevice=*/true); + return; } - // No BT mic found; continue with built-in + // 4) Log current comm device and continue + AudioDeviceInfo cur = audioManager.getCommunicationDevice(); + Log.d(TAG, "Current COMM device: " + (cur == null ? "null" : deviceLabel(cur))); runOnMain(onInputReady); return; } - // Legacy (<=30): SCO + // 5) Legacy (<=30): use SCO if available, else continue if (audioManager.isBluetoothScoAvailableOffCall()) { - try { - audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); - // Some OEMs require this deprecated toggle for SCO mic path pre-31: - try { - audioManager.setBluetoothScoOn(true); - } catch (Throwable ignored) { } - - waitingForSco = true; - registerScoReceiver(onInputReady); - - audioManager.startBluetoothSco(); - Log.d(TAG, "startBluetoothSco() called; waiting for SCO connect"); - - // Fail-safe timeout: start anyway after X ms if SCO never connects - scoTimeout = () -> { - Log.w(TAG, "SCO connect timeout; continuing without BT mic"); - waitingForSco = false; - runAndClearPending(onInputReady); - }; - mainHandler.postDelayed(scoTimeout, LEGACY_SCO_TIMEOUT_MS); - return; - } catch (Throwable t) { - Log.w(TAG, "Legacy SCO routing error; continuing without BT mic", t); - } + startScoThen(onInputReady, /*retrySetCommDevice=*/false); + } else { + runOnMain(onInputReady); } - - // No SCO available; continue with built-in - runOnMain(onInputReady); } - /** Undo routing and restore audio mode. Safe to call multiple times. */ + /** Undo routing and restore defaults. Safe to call multiple times. */ public void clearRouting() { - // Cancel timeout/pending if (scoTimeout != null) { mainHandler.removeCallbacks(scoTimeout); scoTimeout = null; } pendingCallback = null; - // Unregister receiver if (scoReceiver != null) { try { appContext.unregisterReceiver(scoReceiver); } catch (Throwable ignored) {} scoReceiver = null; } - // Clear routing if (audioManager != null) { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { audioManager.clearCommunicationDevice(); } else { - if (waitingForSco || usingBtMic) { - try { audioManager.stopBluetoothSco(); } catch (Throwable ignored) {} - try { - audioManager.setBluetoothScoOn(false); - } catch (Throwable ignored) {} - } + try { audioManager.stopBluetoothSco(); } catch (Throwable ignored) {} + try { /*noinspection deprecation*/ audioManager.setBluetoothScoOn(false); } catch (Throwable ignored) {} } } catch (Throwable t) { Log.w(TAG, "clearRouting error", t); } - - try { - audioManager.setMode(AudioManager.MODE_NORMAL); - } catch (Throwable ignored) {} + try { audioManager.setMode(AudioManager.MODE_NORMAL); } catch (Throwable ignored) {} } + abandonVoiceCommFocus(); + usingBtMic = false; waitingForSco = false; } - /** True if we successfully routed to a BT mic. */ public boolean isUsingBtMic() { return usingBtMic; } - - /** True while <=30 is waiting for SCO connection. */ public boolean isWaitingForSco() { return waitingForSco; } // ---------- Internals ---------- - @Nullable - private AudioDeviceInfo findBluetoothInputApi31Plus() { - // Look through input devices and find first BT-capable mic - for (AudioDeviceInfo dev : audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)) { - int type = dev.getType(); - if (type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO - || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - && type == AudioDeviceInfo.TYPE_BLE_HEADSET)) { - return dev; - } + private void startScoThen(@NonNull Runnable onInputReady, boolean retrySetCommDeviceAfterConnected) { + try { /*noinspection deprecation*/ audioManager.setBluetoothScoOn(true); } catch (Throwable ignored) {} + waitingForSco = true; + registerScoReceiver(onInputReady, retrySetCommDeviceAfterConnected); + try { + audioManager.startBluetoothSco(); + Log.d(TAG, "startBluetoothSco… waiting for SCO_AUDIO_STATE_CONNECTED"); + } catch (Throwable t) { + Log.w(TAG, "startBluetoothSco failed; continuing without BT mic", t); + waitingForSco = false; + runOnMain(onInputReady); + return; } - return null; + + // Fail-safe timeout + scoTimeout = () -> { + Log.w(TAG, "SCO timeout; continuing"); + waitingForSco = false; + runOnMain(onInputReady); + }; + mainHandler.postDelayed(scoTimeout, LEGACY_SCO_TIMEOUT_MS); } - private void registerScoReceiver(@NonNull Runnable onInputReady) { + private void registerScoReceiver(@NonNull Runnable onInputReady, boolean retrySetCommDeviceAfterConnected) { if (scoReceiver != null) return; pendingCallback = onInputReady; @@ -208,13 +183,24 @@ private void registerScoReceiver(@NonNull Runnable onInputReady) { usingBtMic = true; waitingForSco = false; - // Cancel timeout, run callback if (scoTimeout != null) { mainHandler.removeCallbacks(scoTimeout); scoTimeout = null; } - runAndClearPending(null); + // On API 31+, retry setting COMM device now that SCO is active + if (retrySetCommDeviceAfterConnected && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + AudioDeviceInfo pick = pickBtCommDevice31Plus(/*preferSco=*/true); + if (pick != null) { + boolean ok2 = audioManager.setCommunicationDevice(pick); + Log.d(TAG, "setCommunicationDevice(after SCO) -> " + ok2 + " (" + deviceLabel(pick) + ")"); + usingBtMic = usingBtMic || ok2; + } + AudioDeviceInfo cur = audioManager.getCommunicationDevice(); + Log.d(TAG, "Current COMM device: " + (cur == null ? "null" : deviceLabel(cur))); + } + + runAndClearPending(); } else if (state == AudioManager.SCO_AUDIO_STATE_DISCONNECTED) { Log.d(TAG, "SCO disconnected"); } @@ -223,8 +209,84 @@ private void registerScoReceiver(@NonNull Runnable onInputReady) { appContext.registerReceiver(scoReceiver, f); } - private void runAndClearPending(@Nullable Runnable fallback) { - Runnable cb = pendingCallback != null ? pendingCallback : fallback; + @Nullable + private AudioDeviceInfo pickBtCommDevice31Plus() { + return pickBtCommDevice31Plus(false); + } + + @Nullable + private AudioDeviceInfo pickBtCommDevice31Plus(boolean preferSco) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null; + if (ContextCompat.checkSelfPermission(appContext, Manifest.permission.BLUETOOTH_CONNECT) != PERMISSION_GRANTED) { + Log.w(TAG, "BLUETOOTH_CONNECT not granted; cannot list comm devices"); + return null; + } + try { + List comm = audioManager.getAvailableCommunicationDevices(); + if (comm == null || comm.isEmpty()) return null; + + AudioDeviceInfo ble = null, sco = null; + for (AudioDeviceInfo d : comm) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && d.getType() == AudioDeviceInfo.TYPE_BLE_HEADSET) ble = d; + if (d.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) sco = d; + } + if (preferSco) return (sco != null) ? sco : ble; + return (ble != null) ? ble : sco; + } catch (Throwable t) { + Log.w(TAG, "pickBtCommDevice31Plus error", t); + return null; + } + } + + private void requestVoiceCommFocus() { + if (audioManager == null) return; + try { + AudioAttributes attrs = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) + .setAudioAttributes(attrs) + .setOnAudioFocusChangeListener(fc -> {}) + .build(); + int res = audioManager.requestAudioFocus(focusRequest); + Log.d(TAG, "requestAudioFocus(26+) -> " + res); + } else { + legacyFocusCb = fc -> {}; + @SuppressWarnings("deprecation") + int res = audioManager.requestAudioFocus( + legacyFocusCb, + AudioManager.STREAM_VOICE_CALL, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE + ); + Log.d(TAG, "requestAudioFocus(legacy) -> " + res); + } + } catch (Throwable t) { + Log.w(TAG, "requestVoiceCommFocus failed", t); + } + } + + private void abandonVoiceCommFocus() { + if (audioManager == null) return; + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (focusRequest != null) { + audioManager.abandonAudioFocusRequest(focusRequest); + focusRequest = null; + } + } else if (legacyFocusCb != null) { + @SuppressWarnings("deprecation") + int res = audioManager.abandonAudioFocus(legacyFocusCb); + legacyFocusCb = null; + Log.d(TAG, "abandonAudioFocus(legacy) -> " + res); + } + } catch (Throwable ignored) {} + } + + private void runAndClearPending() { + Runnable cb = pendingCallback; pendingCallback = null; if (cb != null) runOnMain(cb); } @@ -234,15 +296,13 @@ private void runOnMain(@NonNull Runnable r) { else mainHandler.post(r); } - private static String deviceLabel(AudioDeviceInfo dev) { + private static String deviceLabel(@NonNull AudioDeviceInfo dev) { String addr = null; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { // API 28+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { try { addr = dev.getAddress(); } catch (Throwable ignored) {} } - CharSequence pn = dev.getProductName(); // avail since API 23 + CharSequence pn = dev.getProductName(); String name = (pn != null) ? pn.toString() : "unknown"; - - // On API < 28 we won't have an address; include id/name instead. return "type=" + dev.getType() + " id=" + dev.getId() + " name=" + name diff --git a/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java b/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java index 388189c..425d1c6 100644 --- a/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java +++ b/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java @@ -23,230 +23,416 @@ import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.Service; +import android.content.ContentResolver; import android.content.ContentValues; +import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCaptureSession; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraDevice; +import android.hardware.camera2.CameraManager; +import android.hardware.camera2.CaptureRequest; +import android.hardware.camera2.params.StreamConfigurationMap; import android.location.Criteria; import android.location.Location; import android.location.LocationManager; +import android.media.CamcorderProfile; +import android.media.MediaRecorder; import android.net.Uri; import android.os.Build; import android.os.Environment; +import android.os.Handler; +import android.os.HandlerThread; import android.os.IBinder; import android.provider.MediaStore; -import android.text.format.DateFormat; import android.util.Log; +import android.util.Size; +import android.util.SparseIntArray; +import android.view.Surface; +import android.view.WindowManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.camera.core.CameraSelector; -import androidx.camera.lifecycle.ProcessCameraProvider; -import androidx.camera.video.FallbackStrategy; -import androidx.camera.video.FileOutputOptions; -import androidx.camera.video.MediaStoreOutputOptions; -import androidx.camera.video.PendingRecording; -import androidx.camera.video.Quality; -import androidx.camera.video.QualitySelector; -import androidx.camera.video.Recorder; -import androidx.camera.video.Recording; -import androidx.camera.video.VideoCapture; -import androidx.camera.video.VideoRecordEvent; -import androidx.camera.video.QualitySelector; -import androidx.camera.video.Quality; import androidx.core.app.NotificationCompat; import androidx.core.content.ContextCompat; -import androidx.lifecycle.Lifecycle; -import androidx.lifecycle.LifecycleOwner; -import androidx.lifecycle.LifecycleRegistry; - -import com.google.common.util.concurrent.ListenableFuture; import java.io.File; +import java.io.IOException; import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; import java.util.Date; import java.util.Locale; -import java.util.concurrent.ExecutionException; -public class VideoRecService extends Service implements LifecycleOwner { +public class VideoRecService extends Service { private static final String TAG = "VideoRecService"; // Foreground notification private static final String CHANNEL_ID = "wlq-video"; private static final int NOTIF_ID = 1234; - // Intent extras (match your existing usage) - // CAMERA: 0=front, 1=back (default back) - private int cameraArg = CameraSelector.LENS_FACING_BACK; - - private LifecycleRegistry lifecycleRegistry; - private ProcessCameraProvider cameraProvider; - - // CameraX 1.4.0 video API - private Recorder recorder; - private VideoCapture videoCapture; - private Recording activeRecording; + // Intent extra: CAMERA 0=front, 1=back (default back) + private int cameraArg = CameraCharacteristics.LENS_FACING_BACK; + + // Camera2 plumbing + private HandlerThread cameraThread; + private Handler cameraHandler; + private CameraDevice cameraDevice; + private CameraCaptureSession captureSession; + private CaptureRequest.Builder recordRequestBuilder; + + // MediaRecorder + outputs + private MediaRecorder mediaRecorder; + private Uri outputUri; // API 29+ + private android.os.ParcelFileDescriptor pfd;// API 29+ + private File outputFile; // <29 + + // Chosen camera info + private String cameraId; + private int lensFacing = CameraCharacteristics.LENS_FACING_BACK; + private int sensorOrientation = 0; + private Size videoSize; + + // Optional: last known location (for MediaStore LAT/LON) + @Nullable private Location location; + // Bluetooth mic router private BluetoothMicRouter btRouter; - // Optional: last known location (for MediaStore LAT/LON columns) - @Nullable private Location location; + // Display rotation → degrees for MediaRecorder orientation hint + private static final SparseIntArray ORIENTATIONS = new SparseIntArray(); + static { + ORIENTATIONS.append(Surface.ROTATION_0, 90); + ORIENTATIONS.append(Surface.ROTATION_90, 0); + ORIENTATIONS.append(Surface.ROTATION_180, 270); + ORIENTATIONS.append(Surface.ROTATION_270, 180); + } - @Override - public void onCreate() { + @Override public void onCreate() { super.onCreate(); - lifecycleRegistry = new LifecycleRegistry(this); - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_CREATE); createNotification(); - btRouter = new BluetoothMicRouter(this); + fetchLastKnownLocation(); + startCameraThread(); } @Override public int onStartCommand(@Nullable Intent intent, int flags, int startId) { - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START); - if (intent != null) { - cameraArg = intent.getIntExtra("CAMERA", CameraSelector.LENS_FACING_BACK); + cameraArg = intent.getIntExtra("CAMERA", CameraCharacteristics.LENS_FACING_BACK); } + // Route BT mic (if present) first, then start camera+recording + btRouter.routeToBluetoothIfPresentThen(this::startFlow); + return START_STICKY; + } - // Try to fetch a last-known location if we have permission. - fetchLastKnownLocation(); - - // Spin up CameraX and start recording - ListenableFuture providerFuture = ProcessCameraProvider.getInstance(this); - providerFuture.addListener(() -> { - try { - cameraProvider = providerFuture.get(); - startCameraAndRecord(); - } catch (ExecutionException | InterruptedException e) { - Log.e(TAG, "CameraProvider error", e); + private void startFlow() { + try { + if (!pickCamera()) { + Log.e(TAG, "No matching camera found"); + stopSelf(); + return; + } + if (!prepareMediaRecorder()) { + Log.e(TAG, "MediaRecorder prepare failed"); stopSelf(); + return; } - }, ContextCompat.getMainExecutor(this)); + openCameraThenRecord(); + } catch (Throwable t) { + Log.e(TAG, "startFlow error", t); + stopSelf(); + } + } - return START_STICKY; + // ---------------- Camera thread ---------------- + + private void startCameraThread() { + cameraThread = new HandlerThread("WLQ-Cam2"); + cameraThread.start(); + cameraHandler = new Handler(cameraThread.getLooper()); } - private void startCameraAndRecord() { - if (cameraProvider == null) { - Log.e(TAG, "cameraProvider null"); - stopSelf(); - return; + private void stopCameraThread() { + if (cameraThread != null) { + cameraThread.quitSafely(); + try { cameraThread.join(); } catch (InterruptedException ignored) {} + cameraThread = null; + cameraHandler = null; } + } - cameraProvider.unbindAll(); + // ---------------- Camera selection ---------------- - CameraSelector selector = new CameraSelector.Builder() - .requireLensFacing(cameraArg == 0 - ? CameraSelector.LENS_FACING_FRONT - : CameraSelector.LENS_FACING_BACK) - .build(); + private boolean pickCamera() throws CameraAccessException { + CameraManager cm = (CameraManager) getSystemService(Context.CAMERA_SERVICE); + if (cm == null) return false; - // Prefer FHD, then HD, then SD (fallbacks are important across devices) - QualitySelector qualitySelector = QualitySelector.fromOrderedList( - java.util.Arrays.asList(Quality.FHD, Quality.HD, Quality.SD), - FallbackStrategy.lowerQualityOrHigherThan(Quality.FHD)); + int desired = (cameraArg == 0) + ? CameraCharacteristics.LENS_FACING_FRONT + : CameraCharacteristics.LENS_FACING_BACK; - recorder = new Recorder.Builder() - .setQualitySelector(qualitySelector) - .build(); + // First pass: try desired lens + for (String id : cm.getCameraIdList()) { + CameraCharacteristics cc = cm.getCameraCharacteristics(id); + Integer facing = cc.get(CameraCharacteristics.LENS_FACING); + if (facing == null || facing != desired) continue; - videoCapture = VideoCapture.withOutput(recorder); + StreamConfigurationMap map = cc.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + if (map == null) continue; - // Bind to this Service's lifecycle - cameraProvider.bindToLifecycle(this, selector, videoCapture); + Size[] recorderSizes = map.getOutputSizes(MediaRecorder.class); + if (recorderSizes == null || recorderSizes.length == 0) continue; - // Choose output: MediaStore (scoped storage) for API 29+; else a file in Movies/WunderLINQ - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startRecordingToMediaStore(); - } else { - startRecordingToFile(); + Size best = chooseVideoSize(recorderSizes); + if (best == null) continue; + + cameraId = id; + lensFacing = facing; + Integer so = cc.get(CameraCharacteristics.SENSOR_ORIENTATION); + sensorOrientation = (so != null) ? so : 0; + videoSize = best; + return true; } + + // Fallback: pick any with MediaRecorder output + for (String id : cm.getCameraIdList()) { + CameraCharacteristics cc = cm.getCameraCharacteristics(id); + StreamConfigurationMap map = cc.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); + if (map == null) continue; + Size[] recorderSizes = map.getOutputSizes(MediaRecorder.class); + if (recorderSizes == null || recorderSizes.length == 0) continue; + + cameraId = id; + Integer facing = cc.get(CameraCharacteristics.LENS_FACING); + lensFacing = (facing != null) ? facing : CameraCharacteristics.LENS_FACING_BACK; + Integer so = cc.get(CameraCharacteristics.SENSOR_ORIENTATION); + sensorOrientation = (so != null) ? so : 0; + videoSize = chooseVideoSize(recorderSizes); + return (videoSize != null); + } + + return false; } - private void startRecordingToMediaStore() { - String displayName = "WLQ_" + new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); - ContentValues values = new ContentValues(); - values.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName); - values.put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4"); - values.put(MediaStore.Video.Media.TITLE, "WunderLINQ Video"); - if (location != null) { - // These columns are respected by some OEM galleries and Google Photos for videos. - values.put(MediaStore.Video.Media.LATITUDE, location.getLatitude()); - values.put(MediaStore.Video.Media.LONGITUDE, location.getLongitude()); + private static Size chooseVideoSize(Size[] choices) { + // Prefer 1080p, else 720p, else largest ~16:9, else largest overall + Size pick1080 = null, pick720 = null, best169 = null; + for (Size s : choices) { + if (s.getWidth() == 1920 && s.getHeight() == 1080) pick1080 = s; + if (s.getWidth() == 1280 && s.getHeight() == 720) pick720 = s; + float r = (float) s.getWidth() / s.getHeight(); + if (Math.abs(r - 16f/9f) < 0.05f) { + if (best169 == null || + (s.getWidth()*s.getHeight() > best169.getWidth()*best169.getHeight())) { + best169 = s; + } + } } + if (pick1080 != null) return pick1080; + if (pick720 != null) return pick720; + if (best169 != null) return best169; + return Collections.max(Arrays.asList(choices), + Comparator.comparingInt(a -> a.getWidth() * a.getHeight())); + } + + // ---------------- MediaRecorder ---------------- - MediaStoreOutputOptions outputOptions = - new MediaStoreOutputOptions.Builder(getContentResolver(), - MediaStore.Video.Media.EXTERNAL_CONTENT_URI) - .setContentValues(values) - .build(); + private int computeOrientationHint() { + int rotation = Surface.ROTATION_0; + try { + WindowManager wm = (WindowManager) getSystemService(WINDOW_SERVICE); + if (wm != null && wm.getDefaultDisplay() != null) { + rotation = wm.getDefaultDisplay().getRotation(); + } + } catch (Throwable ignored) {} - btRouter.routeToBluetoothIfPresentThen(() -> beginRecording(outputOptions)); + int deviceDeg = ORIENTATIONS.get(rotation, 0); + // Typical recorder mapping (front gets the +180 mirror compensation) + if (lensFacing == CameraCharacteristics.LENS_FACING_FRONT) { + return (sensorOrientation + deviceDeg + 180) % 360; + } else { + return (sensorOrientation + deviceDeg) % 360; + } } - private void startRecordingToFile() { - File movies = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); - File appDir = new File(movies, "WunderLINQ"); - if (!appDir.exists() && !appDir.mkdirs()) { - Log.e(TAG, "Failed to create dir: " + appDir); - stopSelf(); - return; + @SuppressLint("MissingPermission") + private boolean prepareMediaRecorder() { + releaseMediaRecorder(); + + mediaRecorder = new MediaRecorder(); + + // Audio source that respects BT SCO routing; change to MIC if you prefer less processing. + mediaRecorder.setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION); + mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE); + + CamcorderProfile prof = bestProfileForCamera(cameraId); + if (prof != null) { + mediaRecorder.setOutputFormat(prof.fileFormat); + mediaRecorder.setVideoEncoder(prof.videoCodec); + mediaRecorder.setAudioEncoder(prof.audioCodec); + mediaRecorder.setVideoEncodingBitRate(prof.videoBitRate); + mediaRecorder.setVideoFrameRate(prof.videoFrameRate); + mediaRecorder.setVideoSize(videoSize.getWidth(), videoSize.getHeight()); + mediaRecorder.setAudioEncodingBitRate(prof.audioBitRate); + mediaRecorder.setAudioSamplingRate(prof.audioSampleRate); + // Channels from profile are fine; SCO is typically mono and will be upmixed as needed. + } else { + mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); + mediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264); + mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); + mediaRecorder.setVideoEncodingBitRate(10_000_000); + mediaRecorder.setVideoFrameRate(30); + mediaRecorder.setVideoSize(videoSize.getWidth(), videoSize.getHeight()); + mediaRecorder.setAudioEncodingBitRate(128_000); + mediaRecorder.setAudioSamplingRate(48_000); + mediaRecorder.setAudioChannels(1); + } + + // Output target + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + String name = "WLQ_" + new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, name); + values.put(MediaStore.MediaColumns.MIME_TYPE, "video/mp4"); + values.put(MediaStore.Video.Media.TITLE, "WunderLINQ Video"); + if (location != null) { + values.put(MediaStore.Video.Media.LATITUDE, location.getLatitude()); + values.put(MediaStore.Video.Media.LONGITUDE, location.getLongitude()); + } + + ContentResolver cr = getContentResolver(); + outputUri = cr.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values); + if (outputUri == null) throw new IOException("MediaStore insert failed"); + pfd = cr.openFileDescriptor(outputUri, "rw"); + if (pfd == null) throw new IOException("openFileDescriptor null"); + mediaRecorder.setOutputFile(pfd.getFileDescriptor()); + } else { + File movies = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES); + File appDir = new File(movies, "WunderLINQ"); + if (!appDir.exists() && !appDir.mkdirs()) throw new IOException("mkdirs failed: " + appDir); + String ts = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); + outputFile = new File(appDir, "WLQ_" + ts + ".mp4"); + mediaRecorder.setOutputFile(outputFile.getAbsolutePath()); + } + } catch (IOException ioe) { + Log.e(TAG, "Output setup failed", ioe); + return false; } - String ts = DateFormat.format("yyyyMMdd_HHmmss", System.currentTimeMillis()).toString(); - File out = new File(appDir, "WLQ_" + ts + ".mp4"); - FileOutputOptions outputOptions = - new FileOutputOptions.Builder(out).build(); + mediaRecorder.setOrientationHint(computeOrientationHint()); - btRouter.routeToBluetoothIfPresentThen(() -> beginRecording(outputOptions)); + try { + mediaRecorder.prepare(); + return true; + } catch (Exception e) { + Log.e(TAG, "MediaRecorder.prepare failed", e); + return false; + } } - @SuppressLint("MissingPermission") - private void beginRecording(@NonNull Object outputOptions) { - if (videoCapture == null || recorder == null) { - Log.e(TAG, "Video components not ready"); - stopSelf(); - return; + private @Nullable CamcorderProfile bestProfileForCamera(@Nullable String id) { + int cid = -1; + if (id != null) { + try { cid = Integer.parseInt(id); } catch (Exception ignored) {} } + int[] quals = new int[] { + CamcorderProfile.QUALITY_1080P, + CamcorderProfile.QUALITY_720P, + CamcorderProfile.QUALITY_480P, + CamcorderProfile.QUALITY_HIGH + }; + for (int q : quals) { + try { + if (cid >= 0 && CamcorderProfile.hasProfile(cid, q)) return CamcorderProfile.get(cid, q); + if (cid < 0 && CamcorderProfile.hasProfile(q)) return CamcorderProfile.get(q); + } catch (Throwable ignored) {} + } + return null; + } - PendingRecording pending; - if (outputOptions instanceof MediaStoreOutputOptions) { - pending = recorder.prepareRecording(this, (MediaStoreOutputOptions) outputOptions); - } else if (outputOptions instanceof FileOutputOptions) { - pending = recorder.prepareRecording(this, (FileOutputOptions) outputOptions); - } else { - Log.e(TAG, "Unsupported output options"); + // ---------------- Open camera & start recording ---------------- + + @SuppressLint("MissingPermission") + private void openCameraThenRecord() throws CameraAccessException { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + != PackageManager.PERMISSION_GRANTED) { + Log.e(TAG, "CAMERA permission not granted"); stopSelf(); return; } - - // Enable audio if we have permission - boolean hasAudio = ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) - == PackageManager.PERMISSION_GRANTED; - if (hasAudio) pending = pending.withAudioEnabled(); - - activeRecording = pending.start(ContextCompat.getMainExecutor(this), event -> { - if (event instanceof VideoRecordEvent.Start) { - Log.d(TAG, "Recording started"); - ((MyApplication) getApplication()).setVideoRecording(true); - } else if (event instanceof VideoRecordEvent.Finalize finalizeEvent) { - Uri uri = finalizeEvent.getOutputResults().getOutputUri(); - Log.d(TAG, "Recording finalized: " + uri + " error=" + finalizeEvent.getError()); - ((MyApplication) getApplication()).setVideoRecording(false); - // Stop the service once finalized (adjust if you want continuous) + CameraManager cm = (CameraManager) getSystemService(Context.CAMERA_SERVICE); + if (cm == null) { stopSelf(); return; } + + cm.openCamera(cameraId, new CameraDevice.StateCallback() { + @Override public void onOpened(@NonNull CameraDevice camera) { + cameraDevice = camera; + try { + createRecordSessionAndStart(); + } catch (Exception e) { + Log.e(TAG, "createRecordSession failed", e); + stopSelf(); + } + } + @Override public void onDisconnected(@NonNull CameraDevice camera) { + Log.w(TAG, "Camera disconnected"); + camera.close(); + cameraDevice = null; + stopSelf(); + } + @Override public void onError(@NonNull CameraDevice camera, int error) { + Log.e(TAG, "Camera error: " + error); + camera.close(); + cameraDevice = null; stopSelf(); - } else if (event instanceof VideoRecordEvent.Status status) { - // Optional: bitrate, duration, etc. - // Log.v(TAG, "Status: " + status.getRecordedDurationNanos()); - } else if (event instanceof VideoRecordEvent.Pause) { - Log.d(TAG, "Recording paused"); - } else if (event instanceof VideoRecordEvent.Resume) { - Log.d(TAG, "Recording resumed"); } - }); + }, cameraHandler); + } + + private void createRecordSessionAndStart() throws CameraAccessException { + if (cameraDevice == null || mediaRecorder == null) throw new IllegalStateException("Not ready"); + + final Surface recorderSurface = mediaRecorder.getSurface(); + + recordRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD); + recordRequestBuilder.addTarget(recorderSurface); + recordRequestBuilder.set(CaptureRequest.CONTROL_MODE, CaptureRequest.CONTROL_MODE_AUTO); + recordRequestBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_VIDEO); + recordRequestBuilder.set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON); + + cameraDevice.createCaptureSession( + Collections.singletonList(recorderSurface), + new CameraCaptureSession.StateCallback() { + @Override public void onConfigured(@NonNull CameraCaptureSession session) { + captureSession = session; + try { + session.setRepeatingRequest(recordRequestBuilder.build(), null, cameraHandler); + mediaRecorder.start(); + Log.d(TAG, "Recording started: " + (outputUri != null ? outputUri : + (outputFile != null ? outputFile.getAbsolutePath() : "unknown"))); + if (getApplication() instanceof MyApplication) { + ((MyApplication) getApplication()).setVideoRecording(true); + } + } catch (Exception e) { + Log.e(TAG, "Recorder start failed", e); + stopSelf(); + } + } + @Override public void onConfigureFailed(@NonNull CameraCaptureSession session) { + Log.e(TAG, "CaptureSession configure failed"); + stopSelf(); + } + }, + cameraHandler + ); } + // ---------------- Location helper ---------------- + private void fetchLastKnownLocation() { boolean fine = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED; @@ -266,46 +452,19 @@ private void fetchLastKnownLocation() { } } - @Override - public void onDestroy() { - Log.d(TAG, "onDestroy"); - - // Stop recording if active - if (activeRecording != null) { - try { - activeRecording.stop(); - } catch (Throwable t) { - Log.w(TAG, "Error stopping recording", t); - } - try { - activeRecording.close(); - } catch (Throwable ignored) {} - activeRecording = null; - } - - // Unbind and release the camera - if (cameraProvider != null) { - cameraProvider.unbindAll(); - cameraProvider = null; - } - - ((MyApplication) getApplication()).setVideoRecording(false); - - lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY); - if (btRouter != null) btRouter.clearRouting(); - super.onDestroy(); - } + // ---------------- Foreground notification ---------------- - // --- Foreground notification (unchanged style) --- private void createNotification() { - NotificationChannel ch = new NotificationChannel( - CHANNEL_ID, - getString(R.string.title_video_notification), - NotificationManager.IMPORTANCE_DEFAULT - ); - ch.setShowBadge(false); - ch.setSound(null, null); - ((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).createNotificationChannel(ch); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel ch = new NotificationChannel( + CHANNEL_ID, + getString(R.string.title_video_notification), + NotificationManager.IMPORTANCE_DEFAULT + ); + ch.setShowBadge(false); + ch.setSound(null, null); + ((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).createNotificationChannel(ch); + } Notification notif = new NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.title_video_notification)) .setContentText("") @@ -314,12 +473,59 @@ private void createNotification() { startForeground(NOTIF_ID, notif); } - // --- LifecycleOwner for binding --- - @NonNull @Override - public Lifecycle getLifecycle() { - return lifecycleRegistry; + // ---------------- Teardown ---------------- + + @Override public void onDestroy() { + Log.d(TAG, "onDestroy"); + + // Stop camera repeating and recording first (order matters on some OEMs) + try { + if (captureSession != null) { + try { captureSession.stopRepeating(); } catch (Throwable ignored) {} + try { captureSession.abortCaptures(); } catch (Throwable ignored) {} + } + } catch (Throwable ignored) {} + + try { + if (mediaRecorder != null) { + try { mediaRecorder.stop(); } catch (Throwable t) { Log.w(TAG, "Recorder stop", t); } + } + } catch (Throwable ignored) {} + + releaseMediaRecorder(); + + if (captureSession != null) { + try { captureSession.close(); } catch (Throwable ignored) {} + captureSession = null; + } + if (cameraDevice != null) { + try { cameraDevice.close(); } catch (Throwable ignored) {} + cameraDevice = null; + } + if (pfd != null) { + try { pfd.close(); } catch (Throwable ignored) {} + pfd = null; + } + + // Clear BT routing + if (btRouter != null) btRouter.clearRouting(); + + stopCameraThread(); + + if (getApplication() instanceof MyApplication) { + ((MyApplication) getApplication()).setVideoRecording(false); + } + super.onDestroy(); + } + + private void releaseMediaRecorder() { + if (mediaRecorder != null) { + try { mediaRecorder.reset(); } catch (Throwable ignored) {} + try { mediaRecorder.release(); } catch (Throwable ignored) {} + mediaRecorder = null; + } } - @Nullable @Override - public IBinder onBind(Intent intent) { return null; } + // --- Service binding --- + @Nullable @Override public IBinder onBind(Intent intent) { return null; } } From 84bc3a793ca208519f264b4dd78c4547c338334f Mon Sep 17 00:00:00 2001 From: Keith Conger Date: Wed, 10 Sep 2025 09:16:06 -0600 Subject: [PATCH 3/3] Fix video orientation Signed-off-by: Keith Conger --- .../WunderLINQ/VideoRecService.java | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java b/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java index 425d1c6..271e55e 100644 --- a/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java +++ b/app/src/main/java/com/blackboxembedded/WunderLINQ/VideoRecService.java @@ -103,14 +103,21 @@ public class VideoRecService extends Service { private BluetoothMicRouter btRouter; // Display rotation → degrees for MediaRecorder orientation hint - private static final SparseIntArray ORIENTATIONS = new SparseIntArray(); + private static final SparseIntArray DEFAULT_ORIENTATIONS = new SparseIntArray(); + private static final SparseIntArray INVERSE_ORIENTATIONS = new SparseIntArray(); static { - ORIENTATIONS.append(Surface.ROTATION_0, 90); - ORIENTATIONS.append(Surface.ROTATION_90, 0); - ORIENTATIONS.append(Surface.ROTATION_180, 270); - ORIENTATIONS.append(Surface.ROTATION_270, 180); + // For sensors with orientation = 90 + DEFAULT_ORIENTATIONS.append(Surface.ROTATION_0, 90); + DEFAULT_ORIENTATIONS.append(Surface.ROTATION_90, 0); + DEFAULT_ORIENTATIONS.append(Surface.ROTATION_180, 270); + DEFAULT_ORIENTATIONS.append(Surface.ROTATION_270, 180); + + // For sensors with orientation = 270 + INVERSE_ORIENTATIONS.append(Surface.ROTATION_0, 270); + INVERSE_ORIENTATIONS.append(Surface.ROTATION_90, 180); + INVERSE_ORIENTATIONS.append(Surface.ROTATION_180, 90); + INVERSE_ORIENTATIONS.append(Surface.ROTATION_270, 0); } - @Override public void onCreate() { super.onCreate(); createNotification(); @@ -250,12 +257,16 @@ private int computeOrientationHint() { } } catch (Throwable ignored) {} - int deviceDeg = ORIENTATIONS.get(rotation, 0); - // Typical recorder mapping (front gets the +180 mirror compensation) - if (lensFacing == CameraCharacteristics.LENS_FACING_FRONT) { - return (sensorOrientation + deviceDeg + 180) % 360; + // Use the table based on the camera sensor's mounting + if (sensorOrientation == 90) { + return DEFAULT_ORIENTATIONS.get(rotation); + } else if (sensorOrientation == 270) { + return INVERSE_ORIENTATIONS.get(rotation); } else { - return (sensorOrientation + deviceDeg) % 360; + // Rare sensors; fall back to a simple sum + // (kept for completeness; most phones are 90 or 270) + int base = DEFAULT_ORIENTATIONS.get(rotation, 0); + return (base + sensorOrientation - 90 + 360) % 360; } }