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