From c69ff2268430cce36bc784cb59a1f6d81cce06e0 Mon Sep 17 00:00:00 2001 From: devops Date: Sat, 11 Apr 2026 10:01:00 +0000 Subject: [PATCH 1/5] feat: add Bluetooth SCO support for microphone recording - MicRecorderAPI: extract startRecording() as standalone method, add setupScoAndRecord() with async SCO setup (Android 12+: setCommunicationDevice, older: startBluetoothSco + BroadcastReceiver), add teardownSco() called on stop/error - AudioScoAPI: new API for standalone SCO channel management (enable/disable/status), exposed as termux-audio-sco utility - TermuxApiReceiver: route "AudioSco" to AudioScoAPI - AndroidManifest: add BLUETOOTH, BLUETOOTH_CONNECT, MODIFY_AUDIO_SETTINGS When source=VOICE_COMMUNICATION (7) is passed to MicRecorder, SCO is activated automatically before recording starts and torn down after. Co-Authored-By: Claude Sonnet 4.6 --- app/src/main/AndroidManifest.xml | 3 + .../com/termux/api/TermuxApiReceiver.java | 4 + .../java/com/termux/api/apis/AudioScoAPI.java | 254 ++++++++++++++ .../com/termux/api/apis/MicRecorderAPI.java | 321 +++++++++++++----- 4 files changed, 501 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/com/termux/api/apis/AudioScoAPI.java diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f9d37121..c624f535 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -25,6 +25,9 @@ + + + diff --git a/app/src/main/java/com/termux/api/TermuxApiReceiver.java b/app/src/main/java/com/termux/api/TermuxApiReceiver.java index b752864b..db823783 100644 --- a/app/src/main/java/com/termux/api/TermuxApiReceiver.java +++ b/app/src/main/java/com/termux/api/TermuxApiReceiver.java @@ -9,6 +9,7 @@ import android.widget.Toast; import com.termux.api.apis.AudioAPI; +import com.termux.api.apis.AudioScoAPI; import com.termux.api.apis.BatteryStatusAPI; import com.termux.api.apis.BrightnessAPI; import com.termux.api.apis.CallLogAPI; @@ -87,6 +88,9 @@ private void doWork(Context context, Intent intent) { case "AudioInfo": AudioAPI.onReceive(this, context, intent); break; + case "AudioSco": + AudioScoAPI.onReceive(this, context, intent); + break; case "BatteryStatus": BatteryStatusAPI.onReceive(this, context, intent); break; diff --git a/app/src/main/java/com/termux/api/apis/AudioScoAPI.java b/app/src/main/java/com/termux/api/apis/AudioScoAPI.java new file mode 100644 index 00000000..eb5dfdd0 --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/AudioScoAPI.java @@ -0,0 +1,254 @@ +package com.termux.api.apis; + +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothHeadset; +import android.bluetooth.BluetoothProfile; +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.util.JsonWriter; + +import com.termux.api.TermuxApiReceiver; +import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; + +import java.io.IOException; +import java.util.List; + +/** + * API for managing Bluetooth SCO audio channel independently of recording. + * + * Provides two commands via the "command" string extra: + * "enable" — activate SCO and switch AudioManager to MODE_IN_COMMUNICATION + * "disable" — deactivate SCO and restore AudioManager mode + * (default) — return current SCO state as JSON + * + * Shell usage: + * termux-audio-sco # query state + * termux-audio-sco enable # start SCO + * termux-audio-sco disable # stop SCO + * + * After "enable" succeeds, any subsequent termux-microphone-record call with + * --ei source 7 (VOICE_COMMUNICATION) will capture from the Bluetooth microphone + * because the SCO channel is already established. + * + * Note: on Android 12+ "enable" uses setCommunicationDevice(); on older versions + * it uses the deprecated startBluetoothSco() with an async state broadcast. + */ +public class AudioScoAPI { + + private static final String LOG_TAG = "AudioScoAPI"; + + public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, + final Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + String command = intent.hasExtra("command") ? intent.getStringExtra("command") : "status"; + + AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + if (am == null) { + returnError(context, intent, "AudioManager unavailable"); + return; + } + + switch (command == null ? "status" : command) { + case "enable": + handleEnable(context, intent, am); + break; + case "disable": + handleDisable(context, intent, am); + break; + default: + handleStatus(context, intent, am); + break; + } + } + + // ------------------------------------------------------------------------- + // enable + // ------------------------------------------------------------------------- + + private static void handleEnable(final Context context, final Intent intent, + final AudioManager am) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Android 12+: use setCommunicationDevice + BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); + if (adapter == null || !adapter.isEnabled()) { + returnError(context, intent, "Bluetooth is not enabled"); + return; + } + + adapter.getProfileProxy(context, new BluetoothProfile.ServiceListener() { + @Override + public void onServiceConnected(int profile, BluetoothProfile proxy) { + BluetoothHeadset headset = (BluetoothHeadset) proxy; + List devices = headset.getConnectedDevices(); + adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy); + + if (devices.isEmpty()) { + returnError(context, intent, "No Bluetooth headset connected"); + return; + } + + AudioDeviceInfo scoDevice = findScoDevice(am); + if (scoDevice == null) { + returnError(context, intent, "No Bluetooth SCO audio device available"); + return; + } + + boolean ok = am.setCommunicationDevice(scoDevice); + if (!ok) { + returnError(context, intent, "setCommunicationDevice failed"); + return; + } + + returnJson(context, intent, true, "SCO enabled via setCommunicationDevice"); + } + + @Override + public void onServiceDisconnected(int profile) { } + }, BluetoothProfile.HEADSET); + + } else { + // Android < 12: legacy SCO with async state broadcast + am.setMode(AudioManager.MODE_IN_COMMUNICATION); + am.startBluetoothSco(); + + final BroadcastReceiver[] receiverHolder = new BroadcastReceiver[1]; + receiverHolder[0] = new BroadcastReceiver() { + @Override + public void onReceive(Context ctx, Intent scoIntent) { + int state = scoIntent.getIntExtra( + AudioManager.EXTRA_SCO_AUDIO_STATE, + AudioManager.SCO_AUDIO_STATE_ERROR); + + if (state == AudioManager.SCO_AUDIO_STATE_CONNECTED) { + context.unregisterReceiver(receiverHolder[0]); + returnJson(context, intent, true, "SCO enabled via startBluetoothSco"); + } else if (state == AudioManager.SCO_AUDIO_STATE_ERROR + || state == AudioManager.SCO_AUDIO_STATE_DISCONNECTED) { + context.unregisterReceiver(receiverHolder[0]); + am.stopBluetoothSco(); + am.setMode(AudioManager.MODE_NORMAL); + returnError(context, intent, "Bluetooth SCO connection failed (state=" + state + ")"); + } + // SCO_AUDIO_STATE_CONNECTING — keep waiting + } + }; + context.registerReceiver(receiverHolder[0], + new IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)); + } + } + + // ------------------------------------------------------------------------- + // disable + // ------------------------------------------------------------------------- + + private static void handleDisable(final Context context, final Intent intent, + final AudioManager am) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + am.clearCommunicationDevice(); + } else { + am.stopBluetoothSco(); + am.setMode(AudioManager.MODE_NORMAL); + } + returnJson(context, intent, false, "SCO disabled"); + } + + // ------------------------------------------------------------------------- + // status + // ------------------------------------------------------------------------- + + private static void handleStatus(final Context context, final Intent intent, + final AudioManager am) { + boolean scoOn; + String deviceName = null; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + AudioDeviceInfo dev = am.getCommunicationDevice(); + scoOn = (dev != null && dev.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_SCO); + if (dev != null) deviceName = dev.getProductName() != null + ? dev.getProductName().toString() : null; + } else { + scoOn = am.isBluetoothScoOn(); + } + + final boolean finalScoOn = scoOn; + final String finalDeviceName = deviceName; + + ResultReturner.returnData(context, intent, out -> { + JsonWriter writer = new JsonWriter(out); + try { + writer.beginObject(); + writer.name("sco_active").value(finalScoOn); + writer.name("audio_mode").value(modeToString(am.getMode())); + if (finalDeviceName != null) + writer.name("device").value(finalDeviceName); + writer.endObject(); + writer.flush(); + } catch (IOException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "JSON write error", e); + } + }); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static AudioDeviceInfo findScoDevice(AudioManager am) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + for (AudioDeviceInfo dev : (java.util.List) am.getAvailableCommunicationDevices()) { + if (dev.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) + return dev; + } + } + return null; + } + + private static void returnJson(final Context context, final Intent intent, + final boolean scoActive, final String message) { + ResultReturner.returnData(context, intent, out -> { + JsonWriter writer = new JsonWriter(out); + try { + writer.beginObject(); + writer.name("sco_active").value(scoActive); + writer.name("message").value(message); + writer.endObject(); + writer.flush(); + } catch (IOException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "JSON write error", e); + } + }); + } + + private static void returnError(final Context context, final Intent intent, + final String error) { + ResultReturner.returnData(context, intent, out -> { + JsonWriter writer = new JsonWriter(out); + try { + writer.beginObject(); + writer.name("error").value(error); + writer.endObject(); + writer.flush(); + } catch (IOException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "JSON write error", e); + } + }); + } + + private static String modeToString(int mode) { + switch (mode) { + case AudioManager.MODE_NORMAL: return "NORMAL"; + case AudioManager.MODE_RINGTONE: return "RINGTONE"; + case AudioManager.MODE_IN_CALL: return "IN_CALL"; + case AudioManager.MODE_IN_COMMUNICATION: return "IN_COMMUNICATION"; + default: return "UNKNOWN(" + mode + ")"; + } + } +} diff --git a/app/src/main/java/com/termux/api/apis/MicRecorderAPI.java b/app/src/main/java/com/termux/api/apis/MicRecorderAPI.java index 3b954ffd..6106f68e 100644 --- a/app/src/main/java/com/termux/api/apis/MicRecorderAPI.java +++ b/app/src/main/java/com/termux/api/apis/MicRecorderAPI.java @@ -1,8 +1,15 @@ package com.termux.api.apis; import android.app.Service; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothHeadset; +import android.bluetooth.BluetoothProfile; +import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; +import android.media.AudioManager; import android.media.MediaRecorder; import android.os.Build; import android.os.Environment; @@ -62,11 +69,16 @@ public static class MicRecorderService extends Service implements MediaRecorder. // file we're recording too protected static File file; + // SCO Bluetooth state + protected static boolean scoRequested; + protected static BroadcastReceiver scoReceiver; + protected static AudioManager audioManager; private static final String LOG_TAG = "MicRecorderService"; public void onCreate() { getMediaRecorder(this); + audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); } public int onStartCommand(Intent intent, int flags, int startId) { @@ -130,7 +142,7 @@ public void onDestroy() { } /** - * Releases MediaRecorder resources + * Releases MediaRecorder resources and tears down SCO if it was activated */ protected static void cleanupMediaRecorder() { if (isRecording) { @@ -139,6 +151,220 @@ protected static void cleanupMediaRecorder() { } mediaRecorder.reset(); mediaRecorder.release(); + teardownSco(); + } + + /** + * Activates Bluetooth SCO for microphone capture. + * Registers a BroadcastReceiver to wait for SCO_AUDIO_STATE_CONNECTED, + * then starts the actual recording once the channel is established. + * + * @param context application context + * @param intent original record intent (forwarded to startRecording on success) + * @param result RecorderCommandResult to populate on error + * @return true if SCO setup was initiated (async), false if SCO unavailable + */ + protected static boolean setupScoAndRecord(final Context context, final Intent intent, + final RecorderCommandResult result) { + if (audioManager == null) { + result.error = "AudioManager unavailable"; + return false; + } + + scoRequested = true; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Android 12+: use setCommunicationDevice + BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); + if (adapter == null || !adapter.isEnabled()) { + result.error = "Bluetooth is not enabled"; + scoRequested = false; + return false; + } + // Find a connected headset device + adapter.getProfileProxy(context, new BluetoothProfile.ServiceListener() { + @Override + public void onServiceConnected(int profile, BluetoothProfile proxy) { + BluetoothHeadset headset = (BluetoothHeadset) proxy; + java.util.List devices = headset.getConnectedDevices(); + if (devices.isEmpty()) { + result.error = "No Bluetooth headset connected"; + scoRequested = false; + postRecordCommandResult(context, intent, result); + adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy); + return; + } + android.media.AudioDeviceInfo targetDevice = null; + java.util.List allDevices = + audioManager.getAvailableCommunicationDevices(); + for (android.media.AudioDeviceInfo dev : allDevices) { + if (dev.getType() == android.media.AudioDeviceInfo.TYPE_BLUETOOTH_SCO) { + targetDevice = dev; + break; + } + } + if (targetDevice == null) { + result.error = "No Bluetooth SCO audio device available"; + scoRequested = false; + postRecordCommandResult(context, intent, result); + adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy); + return; + } + boolean set = audioManager.setCommunicationDevice(targetDevice); + adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy); + if (!set) { + result.error = "setCommunicationDevice failed"; + scoRequested = false; + postRecordCommandResult(context, intent, result); + return; + } + // SCO is synchronous on Android 12+ via setCommunicationDevice + startRecording(context, intent, result); + postRecordCommandResult(context, intent, result); + } + + @Override + public void onServiceDisconnected(int profile) { + // no-op + } + }, BluetoothProfile.HEADSET); + return true; // async path — result posted inside callback + } else { + // Android < 12: legacy startBluetoothSco + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + audioManager.startBluetoothSco(); + + IntentFilter filter = new IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED); + scoReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context ctx, Intent scoIntent) { + int state = scoIntent.getIntExtra( + AudioManager.EXTRA_SCO_AUDIO_STATE, + AudioManager.SCO_AUDIO_STATE_ERROR); + if (state == AudioManager.SCO_AUDIO_STATE_CONNECTED) { + context.unregisterReceiver(this); + scoReceiver = null; + startRecording(context, intent, result); + postRecordCommandResult(context, intent, result); + } else if (state == AudioManager.SCO_AUDIO_STATE_ERROR) { + context.unregisterReceiver(this); + scoReceiver = null; + teardownSco(); + result.error = "Bluetooth SCO connection error"; + postRecordCommandResult(context, intent, result); + } + } + }; + context.registerReceiver(scoReceiver, filter); + return true; // async path — result posted inside receiver + } + } + + /** + * Stops Bluetooth SCO and restores audio mode. + */ + protected static void teardownSco() { + if (!scoRequested) return; + scoRequested = false; + if (audioManager == null) return; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + audioManager.clearCommunicationDevice(); + } else { + audioManager.stopBluetoothSco(); + audioManager.setMode(AudioManager.MODE_NORMAL); + } + + if (scoReceiver != null) { + // Should not happen in normal flow, but guard just in case + scoReceiver = null; + } + } + + /** + * Core recording start logic (synchronous part, after SCO is ready). + * Populates result.message or result.error. + */ + protected static void startRecording(final Context context, final Intent intent, + final RecorderCommandResult result) { + int duration = intent.getIntExtra("limit", DEFAULT_RECORDING_LIMIT); + if (duration > 0 && duration < MIN_RECORDING_LIMIT) + duration = MIN_RECORDING_LIMIT; + + String sencoder = intent.hasExtra("encoder") ? intent.getStringExtra("encoder") : ""; + ArrayMap encoder_map = new ArrayMap<>(4); + encoder_map.put("aac", MediaRecorder.AudioEncoder.AAC); + encoder_map.put("amr_nb", MediaRecorder.AudioEncoder.AMR_NB); + encoder_map.put("amr_wb", MediaRecorder.AudioEncoder.AMR_WB); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + encoder_map.put("opus", MediaRecorder.AudioEncoder.OPUS); + + Integer encoder = encoder_map.get(sencoder.toLowerCase()); + if (encoder == null) + encoder = MediaRecorder.AudioEncoder.AAC; + + int format = intent.getIntExtra("format", MediaRecorder.OutputFormat.DEFAULT); + if (format == MediaRecorder.OutputFormat.DEFAULT) { + SparseIntArray format_map = new SparseIntArray(4); + format_map.put(MediaRecorder.AudioEncoder.AAC, MediaRecorder.OutputFormat.MPEG_4); + format_map.put(MediaRecorder.AudioEncoder.AMR_NB, MediaRecorder.OutputFormat.THREE_GPP); + format_map.put(MediaRecorder.AudioEncoder.AMR_WB, MediaRecorder.OutputFormat.THREE_GPP); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + format_map.put(MediaRecorder.AudioEncoder.OPUS, MediaRecorder.OutputFormat.OGG); + format = format_map.get(encoder, MediaRecorder.OutputFormat.DEFAULT); + } + + SparseArray extension_map = new SparseArray<>(3); + extension_map.put(MediaRecorder.OutputFormat.MPEG_4, ".m4a"); + extension_map.put(MediaRecorder.OutputFormat.THREE_GPP, ".3gp"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + extension_map.put(MediaRecorder.OutputFormat.OGG, ".ogg"); + String extension = extension_map.get(format); + + String filename = intent.hasExtra("file") + ? intent.getStringExtra("file") + : getDefaultRecordingFilename() + (extension != null ? extension : ""); + + int source = intent.getIntExtra("source", MediaRecorder.AudioSource.MIC); + int bitrate = intent.getIntExtra("bitrate", 0); + int srate = intent.getIntExtra("srate", 0); + int channels = intent.getIntExtra("channels", 0); + + file = new File(filename); + Logger.logInfo(LOG_TAG, "MediaRecording file is: " + file.getAbsolutePath()); + + if (file.exists()) { + result.error = String.format( + "File: %s already exists! Please specify a different filename", file.getName()); + teardownSco(); + } else if (isRecording) { + result.error = "Recording already in progress!"; + teardownSco(); + } else { + try { + mediaRecorder.setAudioSource(source); + mediaRecorder.setOutputFormat(format); + mediaRecorder.setAudioEncoder(encoder); + mediaRecorder.setOutputFile(filename); + mediaRecorder.setMaxDuration(duration); + if (bitrate > 0) + mediaRecorder.setAudioEncodingBitRate(bitrate); + if (srate > 0) + mediaRecorder.setAudioSamplingRate(srate); + if (channels > 0) + mediaRecorder.setAudioChannels(channels); + mediaRecorder.prepare(); + mediaRecorder.start(); + isRecording = true; + result.message = String.format("Recording started: %s \nMax Duration: %s", + file.getAbsolutePath(), + duration <= 0 ? "unlimited" : MediaPlayerAPI.getTimeString(duration / 1000)); + } catch (IllegalStateException | IOException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "MediaRecorder error", e); + result.error = "Recording error: " + e.getMessage(); + teardownSco(); + } + } } @Override @@ -208,90 +434,23 @@ public RecorderCommandResult handle(Context context, Intent intent) { public RecorderCommandResult handle(Context context, Intent intent) { RecorderCommandResult result = new RecorderCommandResult(); - int duration = intent.getIntExtra("limit", DEFAULT_RECORDING_LIMIT); - // allow the duration limit to be disabled with zero or negative - if (duration > 0 && duration < MIN_RECORDING_LIMIT) - duration = MIN_RECORDING_LIMIT; - - String sencoder = intent.hasExtra("encoder") ? intent.getStringExtra("encoder") : ""; - ArrayMap encoder_map = new ArrayMap<>(4); - encoder_map.put("aac", MediaRecorder.AudioEncoder.AAC); - encoder_map.put("amr_nb", MediaRecorder.AudioEncoder.AMR_NB); - encoder_map.put("amr_wb", MediaRecorder.AudioEncoder.AMR_WB); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) - encoder_map.put("opus", MediaRecorder.AudioEncoder.OPUS); - - Integer encoder = encoder_map.get(sencoder.toLowerCase()); - if (encoder == null) - encoder = MediaRecorder.AudioEncoder.AAC; - - int format = intent.getIntExtra("format", MediaRecorder.OutputFormat.DEFAULT); - if (format == MediaRecorder.OutputFormat.DEFAULT) { - SparseIntArray format_map = new SparseIntArray(4); - format_map.put(MediaRecorder.AudioEncoder.AAC, - MediaRecorder.OutputFormat.MPEG_4); - format_map.put(MediaRecorder.AudioEncoder.AMR_NB, - MediaRecorder.OutputFormat.THREE_GPP); - format_map.put(MediaRecorder.AudioEncoder.AMR_WB, - MediaRecorder.OutputFormat.THREE_GPP); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) - format_map.put(MediaRecorder.AudioEncoder.OPUS, MediaRecorder.OutputFormat.OGG); - format = format_map.get(encoder, MediaRecorder.OutputFormat.DEFAULT); - } - - SparseArray extension_map = new SparseArray<>(3); - extension_map.put(MediaRecorder.OutputFormat.MPEG_4, ".m4a"); - extension_map.put(MediaRecorder.OutputFormat.THREE_GPP, ".3gp"); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) - extension_map.put(MediaRecorder.OutputFormat.OGG, ".ogg"); - String extension = extension_map.get(format); - - String filename = intent.hasExtra("file") ? intent.getStringExtra("file") : getDefaultRecordingFilename() + (extension != null ? extension : ""); - int source = intent.getIntExtra("source", MediaRecorder.AudioSource.MIC); - int bitrate = intent.getIntExtra("bitrate", 0); - int srate = intent.getIntExtra("srate", 0); - int channels = intent.getIntExtra("channels", 0); - - file = new File(filename); - - Logger.logInfo(LOG_TAG, "MediaRecording file is: " + file.getAbsolutePath()); - - if (file.exists()) { - result.error = String.format("File: %s already exists! Please specify a different filename", file.getName()); - } else { - if (isRecording) { - result.error = "Recording already in progress!"; - } else { - try { - mediaRecorder.setAudioSource(source); - mediaRecorder.setOutputFormat(format); - mediaRecorder.setAudioEncoder(encoder); - mediaRecorder.setOutputFile(filename); - mediaRecorder.setMaxDuration(duration); - if (bitrate > 0) - mediaRecorder.setAudioEncodingBitRate(bitrate); - if (srate > 0) - mediaRecorder.setAudioSamplingRate(srate); - if (channels > 0) - mediaRecorder.setAudioChannels(channels); - mediaRecorder.prepare(); - mediaRecorder.start(); - isRecording = true; - result.message = String.format("Recording started: %s \nMax Duration: %s", - file.getAbsolutePath(), - duration <= 0 ? - "unlimited" : - MediaPlayerAPI.getTimeString(duration / - 1000)); - - } catch (IllegalStateException | IOException e) { - Logger.logStackTraceWithMessage(LOG_TAG, "MediaRecorder error", e); - result.error = "Recording error: " + e.getMessage(); - } + if (source == MediaRecorder.AudioSource.VOICE_COMMUNICATION) { + // Async path: set up Bluetooth SCO first, then start recording. + // Result is posted inside setupScoAndRecord callbacks. + boolean initiated = setupScoAndRecord(context, intent, result); + if (!initiated) { + // Synchronous failure — result.error already set + if (!isRecording) + context.stopService(intent); } + // Return empty result; the real result is posted asynchronously. + return new RecorderCommandResult(); } + + // Synchronous path for all other sources + startRecording(context, intent, result); if (!isRecording) context.stopService(intent); return result; From 8fbd71568359b511ef83e628eb62734e4fac4f36 Mon Sep 17 00:00:00 2001 From: devops Date: Sat, 11 Apr 2026 13:30:50 +0000 Subject: [PATCH 2/5] fix: resolve ResultReturner socket hang, fix List type, bump targetSdkVersion to 33 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AudioScoAPI: pass apiReceiver (not context) to ResultReturner.returnData() to prevent unix socket from hanging; reply immediately, setup SCO in background - MicRecorderAPI: fix List type (getAvailableCommunicationDevices returns List, not array); remove postRecordCommandResult from async SCO callbacks - gradle.properties: targetSdkVersion 28 → 33 (required for BLUETOOTH_CONNECT runtime permission grant via pm on Android 12+) Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/termux/api/apis/AudioScoAPI.java | 53 ++++++++++--------- .../com/termux/api/apis/MicRecorderAPI.java | 22 ++++---- gradle.properties | 2 +- 3 files changed, 41 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/termux/api/apis/AudioScoAPI.java b/app/src/main/java/com/termux/api/apis/AudioScoAPI.java index eb5dfdd0..303a468f 100644 --- a/app/src/main/java/com/termux/api/apis/AudioScoAPI.java +++ b/app/src/main/java/com/termux/api/apis/AudioScoAPI.java @@ -52,19 +52,19 @@ public static void onReceive(TermuxApiReceiver apiReceiver, final Context contex AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); if (am == null) { - returnError(context, intent, "AudioManager unavailable"); + returnError(apiReceiver, intent, "AudioManager unavailable"); return; } switch (command == null ? "status" : command) { case "enable": - handleEnable(context, intent, am); + handleEnable(apiReceiver, context, intent, am); break; case "disable": - handleDisable(context, intent, am); + handleDisable(apiReceiver, intent, am); break; default: - handleStatus(context, intent, am); + handleStatus(apiReceiver, intent, am); break; } } @@ -73,16 +73,19 @@ public static void onReceive(TermuxApiReceiver apiReceiver, final Context contex // enable // ------------------------------------------------------------------------- - private static void handleEnable(final Context context, final Intent intent, - final AudioManager am) { + private static void handleEnable(final TermuxApiReceiver apiReceiver, final Context context, + final Intent intent, final AudioManager am) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // Android 12+: use setCommunicationDevice + // Android 12+: getProfileProxy is async — return immediately, set up SCO in background. BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); if (adapter == null || !adapter.isEnabled()) { - returnError(context, intent, "Bluetooth is not enabled"); + returnError(apiReceiver, intent, "Bluetooth is not enabled"); return; } + // Reply to caller right away so the socket doesn't hang. + returnJson(apiReceiver, intent, false, "SCO enable initiated, check status with termux-audio-sco"); + adapter.getProfileProxy(context, new BluetoothProfile.ServiceListener() { @Override public void onServiceConnected(int profile, BluetoothProfile proxy) { @@ -91,23 +94,22 @@ public void onServiceConnected(int profile, BluetoothProfile proxy) { adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy); if (devices.isEmpty()) { - returnError(context, intent, "No Bluetooth headset connected"); + Logger.logError(LOG_TAG, "SCO enable: no Bluetooth headset connected"); return; } AudioDeviceInfo scoDevice = findScoDevice(am); if (scoDevice == null) { - returnError(context, intent, "No Bluetooth SCO audio device available"); + Logger.logError(LOG_TAG, "SCO enable: no Bluetooth SCO audio device available"); return; } boolean ok = am.setCommunicationDevice(scoDevice); if (!ok) { - returnError(context, intent, "setCommunicationDevice failed"); + Logger.logError(LOG_TAG, "SCO enable: setCommunicationDevice failed"); return; } - - returnJson(context, intent, true, "SCO enabled via setCommunicationDevice"); + Logger.logInfo(LOG_TAG, "SCO enabled via setCommunicationDevice"); } @Override @@ -115,10 +117,13 @@ public void onServiceDisconnected(int profile) { } }, BluetoothProfile.HEADSET); } else { - // Android < 12: legacy SCO with async state broadcast + // Android < 12: startBluetoothSco is async — return immediately. am.setMode(AudioManager.MODE_IN_COMMUNICATION); am.startBluetoothSco(); + // Reply to caller right away. + returnJson(apiReceiver, intent, false, "SCO enable initiated, check status with termux-audio-sco"); + final BroadcastReceiver[] receiverHolder = new BroadcastReceiver[1]; receiverHolder[0] = new BroadcastReceiver() { @Override @@ -129,13 +134,13 @@ public void onReceive(Context ctx, Intent scoIntent) { if (state == AudioManager.SCO_AUDIO_STATE_CONNECTED) { context.unregisterReceiver(receiverHolder[0]); - returnJson(context, intent, true, "SCO enabled via startBluetoothSco"); + Logger.logInfo(LOG_TAG, "SCO enabled via startBluetoothSco"); } else if (state == AudioManager.SCO_AUDIO_STATE_ERROR || state == AudioManager.SCO_AUDIO_STATE_DISCONNECTED) { context.unregisterReceiver(receiverHolder[0]); am.stopBluetoothSco(); am.setMode(AudioManager.MODE_NORMAL); - returnError(context, intent, "Bluetooth SCO connection failed (state=" + state + ")"); + Logger.logError(LOG_TAG, "SCO connection failed (state=" + state + ")"); } // SCO_AUDIO_STATE_CONNECTING — keep waiting } @@ -149,7 +154,7 @@ public void onReceive(Context ctx, Intent scoIntent) { // disable // ------------------------------------------------------------------------- - private static void handleDisable(final Context context, final Intent intent, + private static void handleDisable(final TermuxApiReceiver apiReceiver, final Intent intent, final AudioManager am) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { am.clearCommunicationDevice(); @@ -157,14 +162,14 @@ private static void handleDisable(final Context context, final Intent intent, am.stopBluetoothSco(); am.setMode(AudioManager.MODE_NORMAL); } - returnJson(context, intent, false, "SCO disabled"); + returnJson(apiReceiver, intent, false, "SCO disabled"); } // ------------------------------------------------------------------------- // status // ------------------------------------------------------------------------- - private static void handleStatus(final Context context, final Intent intent, + private static void handleStatus(final TermuxApiReceiver apiReceiver, final Intent intent, final AudioManager am) { boolean scoOn; String deviceName = null; @@ -181,7 +186,7 @@ private static void handleStatus(final Context context, final Intent intent, final boolean finalScoOn = scoOn; final String finalDeviceName = deviceName; - ResultReturner.returnData(context, intent, out -> { + ResultReturner.returnData(apiReceiver, intent, out -> { JsonWriter writer = new JsonWriter(out); try { writer.beginObject(); @@ -211,9 +216,9 @@ private static AudioDeviceInfo findScoDevice(AudioManager am) { return null; } - private static void returnJson(final Context context, final Intent intent, + private static void returnJson(final TermuxApiReceiver apiReceiver, final Intent intent, final boolean scoActive, final String message) { - ResultReturner.returnData(context, intent, out -> { + ResultReturner.returnData(apiReceiver, intent, out -> { JsonWriter writer = new JsonWriter(out); try { writer.beginObject(); @@ -227,9 +232,9 @@ private static void returnJson(final Context context, final Intent intent, }); } - private static void returnError(final Context context, final Intent intent, + private static void returnError(final TermuxApiReceiver apiReceiver, final Intent intent, final String error) { - ResultReturner.returnData(context, intent, out -> { + ResultReturner.returnData(apiReceiver, intent, out -> { JsonWriter writer = new JsonWriter(out); try { writer.beginObject(); diff --git a/app/src/main/java/com/termux/api/apis/MicRecorderAPI.java b/app/src/main/java/com/termux/api/apis/MicRecorderAPI.java index 6106f68e..d2634796 100644 --- a/app/src/main/java/com/termux/api/apis/MicRecorderAPI.java +++ b/app/src/main/java/com/termux/api/apis/MicRecorderAPI.java @@ -188,9 +188,8 @@ public void onServiceConnected(int profile, BluetoothProfile proxy) { BluetoothHeadset headset = (BluetoothHeadset) proxy; java.util.List devices = headset.getConnectedDevices(); if (devices.isEmpty()) { - result.error = "No Bluetooth headset connected"; + Logger.logError(LOG_TAG, "SCO setup: no Bluetooth headset connected"); scoRequested = false; - postRecordCommandResult(context, intent, result); adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy); return; } @@ -204,23 +203,23 @@ public void onServiceConnected(int profile, BluetoothProfile proxy) { } } if (targetDevice == null) { - result.error = "No Bluetooth SCO audio device available"; + Logger.logError(LOG_TAG, "SCO setup: no Bluetooth SCO audio device available"); scoRequested = false; - postRecordCommandResult(context, intent, result); adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy); return; } boolean set = audioManager.setCommunicationDevice(targetDevice); adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy); if (!set) { - result.error = "setCommunicationDevice failed"; + Logger.logError(LOG_TAG, "SCO setup: setCommunicationDevice failed"); scoRequested = false; - postRecordCommandResult(context, intent, result); return; } - // SCO is synchronous on Android 12+ via setCommunicationDevice + // Result socket is already closed by the time this async callback fires. + // Start recording silently — errors go to logcat only. startRecording(context, intent, result); - postRecordCommandResult(context, intent, result); + Logger.logInfo(LOG_TAG, "SCO recording started: " + result.message + + (result.error != null ? " error=" + result.error : "")); } @Override @@ -244,14 +243,15 @@ public void onReceive(Context ctx, Intent scoIntent) { if (state == AudioManager.SCO_AUDIO_STATE_CONNECTED) { context.unregisterReceiver(this); scoReceiver = null; + // Result socket is already closed — log only. startRecording(context, intent, result); - postRecordCommandResult(context, intent, result); + Logger.logInfo(LOG_TAG, "SCO recording started: " + result.message + + (result.error != null ? " error=" + result.error : "")); } else if (state == AudioManager.SCO_AUDIO_STATE_ERROR) { context.unregisterReceiver(this); scoReceiver = null; teardownSco(); - result.error = "Bluetooth SCO connection error"; - postRecordCommandResult(context, intent, result); + Logger.logError(LOG_TAG, "Bluetooth SCO connection error"); } } }; diff --git a/gradle.properties b/gradle.properties index 45aa582a..5b9e0241 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,5 +21,5 @@ android.enableJetifier=true android.useAndroidX=true minSdkVersion=24 -targetSdkVersion=28 +targetSdkVersion=33 compileSdkVersion=35 From d9210be3d5cd607bea1ecdeade6b29d48a6cb213 Mon Sep 17 00:00:00 2001 From: devops Date: Sun, 12 Apr 2026 09:15:12 +0000 Subject: [PATCH 3/5] feat(AudioScoAPI): block enable until SCO connected or timeout handleEnable now waits for the actual SCO connection result before responding, instead of returning "SCO enable initiated" immediately. Uses CountDownLatch to block the ResultReturner writer thread until the async callback (getProfileProxy on Android 12+, SCO state broadcast on older versions) signals completion. 5-second timeout for pre-12. Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/termux/api/apis/AudioScoAPI.java | 125 +++++++++++++----- 1 file changed, 94 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/termux/api/apis/AudioScoAPI.java b/app/src/main/java/com/termux/api/apis/AudioScoAPI.java index 303a468f..16d7705e 100644 --- a/app/src/main/java/com/termux/api/apis/AudioScoAPI.java +++ b/app/src/main/java/com/termux/api/apis/AudioScoAPI.java @@ -19,6 +19,10 @@ import java.io.IOException; import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; /** * API for managing Bluetooth SCO audio channel independently of recording. @@ -43,6 +47,7 @@ public class AudioScoAPI { private static final String LOG_TAG = "AudioScoAPI"; + private static final int SCO_TIMEOUT_SECONDS = 5; public static void onReceive(TermuxApiReceiver apiReceiver, final Context context, final Intent intent) { @@ -75,55 +80,69 @@ public static void onReceive(TermuxApiReceiver apiReceiver, final Context contex private static void handleEnable(final TermuxApiReceiver apiReceiver, final Context context, final Intent intent, final AudioManager am) { + // Use a latch + atomic results so the ResultWriter thread blocks until + // the async SCO operation completes (or times out). + final CountDownLatch latch = new CountDownLatch(1); + final AtomicBoolean resultError = new AtomicBoolean(false); + final AtomicReference resultMessage = new AtomicReference<>("SCO timeout"); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // Android 12+: getProfileProxy is async — return immediately, set up SCO in background. + // Android 12+: getProfileProxy is async, but setCommunicationDevice is synchronous. BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); if (adapter == null || !adapter.isEnabled()) { returnError(apiReceiver, intent, "Bluetooth is not enabled"); return; } - // Reply to caller right away so the socket doesn't hang. - returnJson(apiReceiver, intent, false, "SCO enable initiated, check status with termux-audio-sco"); - adapter.getProfileProxy(context, new BluetoothProfile.ServiceListener() { @Override public void onServiceConnected(int profile, BluetoothProfile proxy) { - BluetoothHeadset headset = (BluetoothHeadset) proxy; - List devices = headset.getConnectedDevices(); - adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy); - - if (devices.isEmpty()) { - Logger.logError(LOG_TAG, "SCO enable: no Bluetooth headset connected"); - return; - } - - AudioDeviceInfo scoDevice = findScoDevice(am); - if (scoDevice == null) { - Logger.logError(LOG_TAG, "SCO enable: no Bluetooth SCO audio device available"); - return; - } - - boolean ok = am.setCommunicationDevice(scoDevice); - if (!ok) { - Logger.logError(LOG_TAG, "SCO enable: setCommunicationDevice failed"); - return; + try { + BluetoothHeadset headset = (BluetoothHeadset) proxy; + List devices = headset.getConnectedDevices(); + adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy); + + if (devices.isEmpty()) { + resultError.set(true); + resultMessage.set("No Bluetooth headset connected"); + return; + } + + AudioDeviceInfo scoDevice = findScoDevice(am); + if (scoDevice == null) { + resultError.set(true); + resultMessage.set("No Bluetooth SCO audio device available"); + return; + } + + boolean ok = am.setCommunicationDevice(scoDevice); + if (ok) { + resultError.set(false); + resultMessage.set("SCO enabled via setCommunicationDevice"); + Logger.logInfo(LOG_TAG, "SCO enabled via setCommunicationDevice"); + } else { + resultError.set(true); + resultMessage.set("setCommunicationDevice failed"); + Logger.logError(LOG_TAG, "SCO enable: setCommunicationDevice failed"); + } + } finally { + latch.countDown(); } - Logger.logInfo(LOG_TAG, "SCO enabled via setCommunicationDevice"); } @Override - public void onServiceDisconnected(int profile) { } + public void onServiceDisconnected(int profile) { + resultError.set(true); + resultMessage.set("Bluetooth headset service disconnected"); + latch.countDown(); + } }, BluetoothProfile.HEADSET); } else { - // Android < 12: startBluetoothSco is async — return immediately. + // Android < 12: startBluetoothSco is async — listen for state broadcast. am.setMode(AudioManager.MODE_IN_COMMUNICATION); am.startBluetoothSco(); - // Reply to caller right away. - returnJson(apiReceiver, intent, false, "SCO enable initiated, check status with termux-audio-sco"); - final BroadcastReceiver[] receiverHolder = new BroadcastReceiver[1]; receiverHolder[0] = new BroadcastReceiver() { @Override @@ -133,21 +152,65 @@ public void onReceive(Context ctx, Intent scoIntent) { AudioManager.SCO_AUDIO_STATE_ERROR); if (state == AudioManager.SCO_AUDIO_STATE_CONNECTED) { - context.unregisterReceiver(receiverHolder[0]); + try { context.unregisterReceiver(receiverHolder[0]); } catch (Exception ignored) {} + resultError.set(false); + resultMessage.set("SCO enabled via startBluetoothSco"); Logger.logInfo(LOG_TAG, "SCO enabled via startBluetoothSco"); + latch.countDown(); } else if (state == AudioManager.SCO_AUDIO_STATE_ERROR || state == AudioManager.SCO_AUDIO_STATE_DISCONNECTED) { - context.unregisterReceiver(receiverHolder[0]); + try { context.unregisterReceiver(receiverHolder[0]); } catch (Exception ignored) {} am.stopBluetoothSco(); am.setMode(AudioManager.MODE_NORMAL); + resultError.set(true); + resultMessage.set("SCO connection failed (state=" + state + ")"); Logger.logError(LOG_TAG, "SCO connection failed (state=" + state + ")"); + latch.countDown(); } // SCO_AUDIO_STATE_CONNECTING — keep waiting } }; context.registerReceiver(receiverHolder[0], new IntentFilter(AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)); + + // Schedule timeout on main looper to clean up if no broadcast arrives. + new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> { + if (latch.getCount() > 0) { + try { context.unregisterReceiver(receiverHolder[0]); } catch (Exception ignored) {} + resultError.set(true); + resultMessage.set("SCO timeout after " + SCO_TIMEOUT_SECONDS + "s"); + Logger.logError(LOG_TAG, "SCO timeout after " + SCO_TIMEOUT_SECONDS + "s"); + latch.countDown(); + } + }, SCO_TIMEOUT_SECONDS * 1000L); } + + // Block the ResultReturner thread until the async operation signals completion. + // ResultReturner.returnData() calls goAsync() synchronously then runs the writer + // in a new thread, so this will not block the main thread. + ResultReturner.returnData(apiReceiver, intent, out -> { + JsonWriter writer = new JsonWriter(out); + try { + latch.await(SCO_TIMEOUT_SECONDS + 2, TimeUnit.SECONDS); + writer.beginObject(); + if (resultError.get()) { + writer.name("error").value(resultMessage.get()); + } else { + writer.name("sco_active").value(true); + writer.name("message").value(resultMessage.get()); + } + writer.endObject(); + writer.flush(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + writer.beginObject(); + writer.name("error").value("Interrupted while waiting for SCO"); + writer.endObject(); + writer.flush(); + } catch (IOException e) { + Logger.logStackTraceWithMessage(LOG_TAG, "JSON write error", e); + } + }); } // ------------------------------------------------------------------------- From f9652cc69d5e11905ef4f4907f10b56fbd68768a Mon Sep 17 00:00:00 2001 From: devops Date: Sun, 12 Apr 2026 09:18:37 +0000 Subject: [PATCH 4/5] fix(AudioScoAPI): handle already-active/inactive SCO state, code review fixes - Add isScoActive() helper that checks SCO state on both Android 12+ (getCommunicationDevice) and older (isBluetoothScoOn) - handleEnable: early return if SCO already active, skip getProfileProxy - handleDisable: early return if SCO already inactive, skip audio reset - handleStatus: refactored to use shared isScoActive() helper Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/termux/api/apis/AudioScoAPI.java | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/termux/api/apis/AudioScoAPI.java b/app/src/main/java/com/termux/api/apis/AudioScoAPI.java index 16d7705e..7df5d547 100644 --- a/app/src/main/java/com/termux/api/apis/AudioScoAPI.java +++ b/app/src/main/java/com/termux/api/apis/AudioScoAPI.java @@ -80,6 +80,13 @@ public static void onReceive(TermuxApiReceiver apiReceiver, final Context contex private static void handleEnable(final TermuxApiReceiver apiReceiver, final Context context, final Intent intent, final AudioManager am) { + // Fast path: if SCO is already active, return immediately. + if (isScoActive(am)) { + Logger.logInfo(LOG_TAG, "SCO already active, skipping enable"); + returnJson(apiReceiver, intent, true, "SCO already active"); + return; + } + // Use a latch + atomic results so the ResultWriter thread blocks until // the async SCO operation completes (or times out). final CountDownLatch latch = new CountDownLatch(1); @@ -219,6 +226,13 @@ public void onReceive(Context ctx, Intent scoIntent) { private static void handleDisable(final TermuxApiReceiver apiReceiver, final Intent intent, final AudioManager am) { + // Fast path: if SCO is already inactive, report it without touching audio state. + if (!isScoActive(am)) { + Logger.logInfo(LOG_TAG, "SCO already disabled, skipping disable"); + returnJson(apiReceiver, intent, false, "SCO already disabled"); + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { am.clearCommunicationDevice(); } else { @@ -234,16 +248,13 @@ private static void handleDisable(final TermuxApiReceiver apiReceiver, final Int private static void handleStatus(final TermuxApiReceiver apiReceiver, final Intent intent, final AudioManager am) { - boolean scoOn; + boolean scoOn = isScoActive(am); String deviceName = null; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { AudioDeviceInfo dev = am.getCommunicationDevice(); - scoOn = (dev != null && dev.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_SCO); if (dev != null) deviceName = dev.getProductName() != null ? dev.getProductName().toString() : null; - } else { - scoOn = am.isBluetoothScoOn(); } final boolean finalScoOn = scoOn; @@ -269,6 +280,20 @@ private static void handleStatus(final TermuxApiReceiver apiReceiver, final Inte // Helpers // ------------------------------------------------------------------------- + /** + * Check whether SCO audio is currently active. + * Android 12+: checks getCommunicationDevice() type. + * Older: checks isBluetoothScoOn(). + */ + private static boolean isScoActive(AudioManager am) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + AudioDeviceInfo dev = am.getCommunicationDevice(); + return dev != null && dev.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_SCO; + } else { + return am.isBluetoothScoOn(); + } + } + private static AudioDeviceInfo findScoDevice(AudioManager am) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { for (AudioDeviceInfo dev : (java.util.List) am.getAvailableCommunicationDevices()) { From 1ec0903aec7de7f0192bb833d7af7217f743e22c Mon Sep 17 00:00:00 2001 From: devops Date: Sun, 12 Apr 2026 09:29:38 +0000 Subject: [PATCH 5/5] fix(AudioScoAPI): remove getProfileProxy on Android 12+, use getAvailableCommunicationDevices directly Co-Authored-By: Claude Sonnet 4.6 --- .../java/com/termux/api/apis/AudioScoAPI.java | 99 ++++++------------- 1 file changed, 28 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/com/termux/api/apis/AudioScoAPI.java b/app/src/main/java/com/termux/api/apis/AudioScoAPI.java index 7df5d547..b101a61e 100644 --- a/app/src/main/java/com/termux/api/apis/AudioScoAPI.java +++ b/app/src/main/java/com/termux/api/apis/AudioScoAPI.java @@ -1,9 +1,5 @@ package com.termux.api.apis; -import android.bluetooth.BluetoothAdapter; -import android.bluetooth.BluetoothDevice; -import android.bluetooth.BluetoothHeadset; -import android.bluetooth.BluetoothProfile; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; @@ -87,69 +83,40 @@ private static void handleEnable(final TermuxApiReceiver apiReceiver, final Cont return; } - // Use a latch + atomic results so the ResultWriter thread blocks until - // the async SCO operation completes (or times out). - final CountDownLatch latch = new CountDownLatch(1); - final AtomicBoolean resultError = new AtomicBoolean(false); - final AtomicReference resultMessage = new AtomicReference<>("SCO timeout"); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - // Android 12+: getProfileProxy is async, but setCommunicationDevice is synchronous. - BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter(); - if (adapter == null || !adapter.isEnabled()) { - returnError(apiReceiver, intent, "Bluetooth is not enabled"); + // Android 12+: synchronous path using getAvailableCommunicationDevices. + // No getProfileProxy needed — avoids flaky onServiceDisconnected callbacks. + List devices = am.getAvailableCommunicationDevices(); + AudioDeviceInfo scoDevice = null; + for (AudioDeviceInfo dev : devices) { + if (dev.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) { + scoDevice = dev; + break; + } + } + if (scoDevice == null) { + returnError(apiReceiver, intent, "No Bluetooth SCO device available"); return; } + boolean ok = am.setCommunicationDevice(scoDevice); + returnJson(apiReceiver, intent, ok, ok ? "SCO enabled" : "setCommunicationDevice failed"); + if (ok) { + Logger.logInfo(LOG_TAG, "SCO enabled via setCommunicationDevice"); + } else { + Logger.logError(LOG_TAG, "SCO enable: setCommunicationDevice failed"); + } + return; + } - adapter.getProfileProxy(context, new BluetoothProfile.ServiceListener() { - @Override - public void onServiceConnected(int profile, BluetoothProfile proxy) { - try { - BluetoothHeadset headset = (BluetoothHeadset) proxy; - List devices = headset.getConnectedDevices(); - adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy); - - if (devices.isEmpty()) { - resultError.set(true); - resultMessage.set("No Bluetooth headset connected"); - return; - } - - AudioDeviceInfo scoDevice = findScoDevice(am); - if (scoDevice == null) { - resultError.set(true); - resultMessage.set("No Bluetooth SCO audio device available"); - return; - } - - boolean ok = am.setCommunicationDevice(scoDevice); - if (ok) { - resultError.set(false); - resultMessage.set("SCO enabled via setCommunicationDevice"); - Logger.logInfo(LOG_TAG, "SCO enabled via setCommunicationDevice"); - } else { - resultError.set(true); - resultMessage.set("setCommunicationDevice failed"); - Logger.logError(LOG_TAG, "SCO enable: setCommunicationDevice failed"); - } - } finally { - latch.countDown(); - } - } - - @Override - public void onServiceDisconnected(int profile) { - resultError.set(true); - resultMessage.set("Bluetooth headset service disconnected"); - latch.countDown(); - } - }, BluetoothProfile.HEADSET); + // Android < 12: startBluetoothSco is async — listen for state broadcast. + final CountDownLatch latch = new CountDownLatch(1); + final AtomicBoolean resultError = new AtomicBoolean(false); + final AtomicReference resultMessage = new AtomicReference<>("SCO timeout"); - } else { - // Android < 12: startBluetoothSco is async — listen for state broadcast. - am.setMode(AudioManager.MODE_IN_COMMUNICATION); - am.startBluetoothSco(); + am.setMode(AudioManager.MODE_IN_COMMUNICATION); + am.startBluetoothSco(); + { final BroadcastReceiver[] receiverHolder = new BroadcastReceiver[1]; receiverHolder[0] = new BroadcastReceiver() { @Override @@ -294,16 +261,6 @@ private static boolean isScoActive(AudioManager am) { } } - private static AudioDeviceInfo findScoDevice(AudioManager am) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - for (AudioDeviceInfo dev : (java.util.List) am.getAvailableCommunicationDevices()) { - if (dev.getType() == AudioDeviceInfo.TYPE_BLUETOOTH_SCO) - return dev; - } - } - return null; - } - private static void returnJson(final TermuxApiReceiver apiReceiver, final Intent intent, final boolean scoActive, final String message) { ResultReturner.returnData(apiReceiver, intent, out -> {