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..b101a61e
--- /dev/null
+++ b/app/src/main/java/com/termux/api/apis/AudioScoAPI.java
@@ -0,0 +1,304 @@
+package com.termux.api.apis;
+
+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;
+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.
+ *
+ * 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";
+ private static final int SCO_TIMEOUT_SECONDS = 5;
+
+ 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(apiReceiver, intent, "AudioManager unavailable");
+ return;
+ }
+
+ switch (command == null ? "status" : command) {
+ case "enable":
+ handleEnable(apiReceiver, context, intent, am);
+ break;
+ case "disable":
+ handleDisable(apiReceiver, intent, am);
+ break;
+ default:
+ handleStatus(apiReceiver, intent, am);
+ break;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // enable
+ // -------------------------------------------------------------------------
+
+ 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;
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ // 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;
+ }
+
+ // 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");
+
+ 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) {
+ 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) {
+ 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);
+ }
+ });
+ }
+
+ // -------------------------------------------------------------------------
+ // disable
+ // -------------------------------------------------------------------------
+
+ 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 {
+ am.stopBluetoothSco();
+ am.setMode(AudioManager.MODE_NORMAL);
+ }
+ returnJson(apiReceiver, intent, false, "SCO disabled");
+ }
+
+ // -------------------------------------------------------------------------
+ // status
+ // -------------------------------------------------------------------------
+
+ private static void handleStatus(final TermuxApiReceiver apiReceiver, final Intent intent,
+ final AudioManager am) {
+ boolean scoOn = isScoActive(am);
+ String deviceName = null;
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ AudioDeviceInfo dev = am.getCommunicationDevice();
+ if (dev != null) deviceName = dev.getProductName() != null
+ ? dev.getProductName().toString() : null;
+ }
+
+ final boolean finalScoOn = scoOn;
+ final String finalDeviceName = deviceName;
+
+ ResultReturner.returnData(apiReceiver, 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
+ // -------------------------------------------------------------------------
+
+ /**
+ * 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 void returnJson(final TermuxApiReceiver apiReceiver, final Intent intent,
+ final boolean scoActive, final String message) {
+ ResultReturner.returnData(apiReceiver, 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 TermuxApiReceiver apiReceiver, final Intent intent,
+ final String error) {
+ ResultReturner.returnData(apiReceiver, 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..d2634796 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()) {
+ Logger.logError(LOG_TAG, "SCO setup: no Bluetooth headset connected");
+ scoRequested = false;
+ 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) {
+ Logger.logError(LOG_TAG, "SCO setup: no Bluetooth SCO audio device available");
+ scoRequested = false;
+ adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy);
+ return;
+ }
+ boolean set = audioManager.setCommunicationDevice(targetDevice);
+ adapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy);
+ if (!set) {
+ Logger.logError(LOG_TAG, "SCO setup: setCommunicationDevice failed");
+ scoRequested = false;
+ return;
+ }
+ // Result socket is already closed by the time this async callback fires.
+ // Start recording silently — errors go to logcat only.
+ startRecording(context, intent, result);
+ Logger.logInfo(LOG_TAG, "SCO recording started: " + result.message
+ + (result.error != null ? " error=" + result.error : ""));
+ }
+
+ @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;
+ // Result socket is already closed — log only.
+ startRecording(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();
+ Logger.logError(LOG_TAG, "Bluetooth SCO connection error");
+ }
+ }
+ };
+ 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;
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