Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.REQUEST_DELETE_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/termux/api/TermuxApiReceiver.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
304 changes: 304 additions & 0 deletions app/src/main/java/com/termux/api/apis/AudioScoAPI.java
Original file line number Diff line number Diff line change
@@ -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<AudioDeviceInfo> 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<String> 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 + ")";
}
}
}
Loading