diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index af4adc5..a6a5ae4 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -90,13 +90,13 @@
-
+
#include
#include "l2c_fcr_hook.h"
+#include
+#include
#define LOG_TAG "AirPodsHook"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
@@ -126,6 +128,9 @@ static void (*original_l2cu_process_our_cfg_req)(tL2C_CCB* p_ccb, tL2CAP_CFG_INF
static void (*original_l2c_csm_config)(tL2C_CCB* p_ccb, uint8_t event, void* p_data) = nullptr;
static void (*original_l2cu_send_peer_info_req)(tL2C_LCB* p_lcb, uint16_t info_type) = nullptr;
+// Add original pointer for BTA_DmSetLocalDiRecord
+static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) = nullptr;
+
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
LOGI("l2c_fcr_chk_chan_modes hooked, returning true.");
return 1;
@@ -156,6 +161,53 @@ void fake_l2cu_send_peer_info_req(tL2C_LCB* p_lcb, uint16_t info_type) {
return;
}
+// New loader for SDP hook offset (persist.librepods.sdp_offset)
+uintptr_t loadSdpOffset() {
+ const char* property_name = "persist.librepods.sdp_offset";
+ char value[PROP_VALUE_MAX] = {0};
+
+ int len = __system_property_get(property_name, value);
+ if (len > 0) {
+ LOGI("Read sdp offset from property: %s", value);
+ uintptr_t offset;
+ char* endptr = nullptr;
+
+ const char* parse_start = value;
+ if (value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) {
+ parse_start = value + 2;
+ }
+
+ errno = 0;
+ offset = strtoul(parse_start, &endptr, 16);
+
+ if (errno == 0 && endptr != parse_start && *endptr == '\0' && offset > 0) {
+ LOGI("Parsed sdp offset: 0x%x", offset);
+ return offset;
+ }
+
+ LOGE("Failed to parse sdp offset from property value: %s", value);
+ }
+
+ LOGI("No sdp offset property present - skipping SDP hook");
+ return 0;
+}
+
+// Fake BTA_DmSetLocalDiRecord: set vendor/vendor_id_source then call original
+tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) {
+ LOGI("BTA_DmSetLocalDiRecord hooked - forcing vendor fields");
+ if (p_device_info) {
+ p_device_info->vendor = 0x004C;
+ p_device_info->vendor_id_source = 0x0001;
+ }
+ LOGI("Set vendor=0x%04x, vendor_id_source=0x%04x", p_device_info->vendor, p_device_info->vendor_id_source);
+ if (original_BTA_DmSetLocalDiRecord) {
+ return original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
+ }
+
+ LOGE("Original BTA_DmSetLocalDiRecord not available");
+ return BTA_FAILURE;
+}
+
uintptr_t loadHookOffset([[maybe_unused]] const char* package_name) {
const char* property_name = "persist.librepods.hook_offset";
char value[PROP_VALUE_MAX] = {0};
@@ -320,6 +372,7 @@ bool findAndHookFunction(const char *library_name) {
uintptr_t l2cu_process_our_cfg_req_offset = loadL2cuProcessCfgReqOffset();
uintptr_t l2c_csm_config_offset = loadL2cCsmConfigOffset();
uintptr_t l2cu_send_peer_info_req_offset = loadL2cuSendPeerInfoReqOffset();
+ uintptr_t sdp_offset = loadSdpOffset();
bool success = false;
@@ -392,6 +445,21 @@ bool findAndHookFunction(const char *library_name) {
LOGI("Skipping l2cu_send_peer_info_req hook as offset is not available");
}
+ if (sdp_offset > 0) {
+ void* target = reinterpret_cast(base_addr + sdp_offset);
+ LOGI("Hooking BTA_DmSetLocalDiRecord at offset: 0x%x, base: %p, target: %p",
+ sdp_offset, (void*)base_addr, target);
+
+ int result = hook_func(target, (void*)fake_BTA_DmSetLocalDiRecord, (void**)&original_BTA_DmSetLocalDiRecord);
+ if (result != 0) {
+ LOGE("Failed to hook BTA_DmSetLocalDiRecord, error: %d", result);
+ } else {
+ LOGI("Successfully hooked BTA_DmSetLocalDiRecord (SDP)");
+ }
+ } else {
+ LOGI("Skipping BTA_DmSetLocalDiRecord hook as sdp offset is not available");
+ }
+
return success;
}
diff --git a/android/app/src/main/cpp/l2c_fcr_hook.h b/android/app/src/main/cpp/l2c_fcr_hook.h
index cff43d4..2ab3256 100644
--- a/android/app/src/main/cpp/l2c_fcr_hook.h
+++ b/android/app/src/main/cpp/l2c_fcr_hook.h
@@ -26,3 +26,25 @@ uintptr_t loadL2cuProcessCfgReqOffset();
uintptr_t loadL2cCsmConfigOffset();
uintptr_t loadL2cuSendPeerInfoReqOffset();
bool findAndHookFunction(const char *library_path);
+
+#define SDP_MAX_ATTR_LEN 400
+
+typedef struct t_sdp_di_record {
+ uint16_t vendor;
+ uint16_t vendor_id_source;
+ uint16_t product;
+ uint16_t version;
+ bool primary_record;
+ char client_executable_url[SDP_MAX_ATTR_LEN];
+ char service_description[SDP_MAX_ATTR_LEN];
+ char documentation_url[SDP_MAX_ATTR_LEN];
+} tSDP_DI_RECORD;
+
+typedef enum : uint8_t {
+ BTA_SUCCESS = 0, /* Successful operation. */
+ BTA_FAILURE = 1, /* Generic failure. */
+ BTA_PENDING = 2, /* API cannot be completed right now */
+ BTA_BUSY = 3,
+ BTA_NO_RESOURCES = 4,
+ BTA_WRONG_MODE = 5,
+} tBTA_STATUS;
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt
index 98398aa..46b6415 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt
@@ -21,13 +21,10 @@ package me.kavishdevar.librepods
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
-import android.bluetooth.BluetoothDevice.TRANSPORT_LE
-import android.bluetooth.BluetoothGatt
-import android.bluetooth.BluetoothGattCallback
-import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothManager
-import android.os.Build
+import android.bluetooth.BluetoothSocket
import android.os.Bundle
+import android.os.ParcelUuid
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -36,153 +33,364 @@ import androidx.annotation.RequiresPermission
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Button
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Text
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
+import me.kavishdevar.librepods.screens.EqualizerSettingsScreen
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import org.lsposed.hiddenapibypass.HiddenApiBypass
-import java.util.UUID
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import java.io.IOException
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
class CustomDevice : ComponentActivity() {
- @SuppressLint("MissingPermission", "CoroutineCreationDuringComposition")
+ private val TAG = "AirPodsAccessibilitySettings"
+ private var socket: BluetoothSocket? = null
+ private val deviceAddress = "28:2D:7F:C2:05:5B"
+ private val psm = 31
+ private val uuid: ParcelUuid = ParcelUuid.fromString("00000000-0000-0000-0000-00000000000")
+
+ // Data states
+ private val isConnected = mutableStateOf(false)
+ private val leftAmplification = mutableStateOf(1.0f)
+ private val leftTone = mutableStateOf(1.0f)
+ private val leftAmbientNoiseReduction = mutableStateOf(0.5f)
+ private val leftConversationBoost = mutableStateOf(false)
+ private val leftEQ = mutableStateOf(FloatArray(8) { 50.0f })
+
+ private val rightAmplification = mutableStateOf(1.0f)
+ private val rightTone = mutableStateOf(1.0f)
+ private val rightAmbientNoiseReduction = mutableStateOf(0.5f)
+ private val rightConversationBoost = mutableStateOf(false)
+ private val rightEQ = mutableStateOf(FloatArray(8) { 50.0f })
+
+ private val singleMode = mutableStateOf(false)
+ private val amplification = mutableStateOf(1.0f)
+ private val balance = mutableStateOf(0.5f)
+
+ private val retryCount = mutableStateOf(0)
+ private val showRetryButton = mutableStateOf(false)
+ private val maxRetries = 3
+
+ private var debounceJob: Job? = null
+
+ // Phone and Media EQ state
+ private val phoneMediaEQ = mutableStateOf(FloatArray(8) { 50.0f })
+
+ @SuppressLint("MissingPermission")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
LibrePodsTheme {
- val connect = remember { mutableStateOf(false) }
- Scaffold(
- modifier = Modifier.fillMaxSize(),
- topBar = {
- Column(
- modifier = Modifier.fillMaxWidth(),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text("Custom Device", style = MaterialTheme.typography.titleLarge)
- }
- }
- ) { innerPadding ->
- HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
- val manager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager
-// val device: BluetoothDevice = manager.adapter.getRemoteDevice("EC:D6:F4:3D:89:B8")
- val device: BluetoothDevice = manager.adapter.getRemoteDevice("E7:48:92:3B:7D:A5")
-// val socket = device.createInsecureL2capChannel(31)
+ val navController = rememberNavController()
-// val batteryLevel = remember { mutableStateOf("") }
-// socket.outputStream.write(byteArrayOf(0x12,0x3B,0x00,0x02, 0x00))
-// socket.outputStream.write(byteArrayOf(0x12, 0x3A, 0x00, 0x01, 0x00, 0x08,0x01))
-
- val gatt = device.connectGatt(this, true, object: BluetoothGattCallback() {
- override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
- if (status == BluetoothGatt.GATT_SUCCESS) {
- // Step 2: Iterate through the services and characteristics
- gatt.services.forEach { service ->
- Log.d("GATT", "Service UUID: ${service.uuid}")
- service.characteristics.forEach { characteristic ->
- characteristic.descriptors.forEach { descriptor ->
- Log.d("GATT", " Descriptor UUID: ${descriptor.uuid}: ${gatt.readDescriptor(descriptor)}")
- }
- }
- }
-
- }
- }
-
- override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
- if (newState == BluetoothGatt.STATE_CONNECTED) {
- Log.d("GATT", "Connected to GATT server")
- gatt.discoverServices() // Discover services after connection
- }
- }
-
- override fun onCharacteristicWrite(
- gatt: BluetoothGatt,
- characteristic: BluetoothGattCharacteristic,
- status: Int
- ) {
- if (status == BluetoothGatt.GATT_SUCCESS) {
- Log.d("BLE", "Write successful for UUID: ${characteristic.uuid}")
- } else {
- Log.e("BLE", "Write failed for UUID: ${characteristic.uuid}, status: $status")
- }
- }
- }, TRANSPORT_LE, 1)
-
- if (connect.value) {
- try {
- gatt.connect()
- }
- catch (e: Exception) {
- e.printStackTrace()
- }
- connect.value = false
- }
-
- Column (
- modifier = Modifier.padding(innerPadding),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- )
- {
- Button(
- onClick = { connect.value = true }
+ NavHost(navController = navController, startDestination = "main") {
+ composable("main") {
+ AccessibilitySettingsScreen(
+ navController = navController,
+ isConnected = isConnected.value,
+ leftAmplification = leftAmplification,
+ leftTone = leftTone,
+ leftAmbientNoiseReduction = leftAmbientNoiseReduction,
+ leftConversationBoost = leftConversationBoost,
+ rightAmplification = rightAmplification,
+ rightTone = rightTone,
+ rightAmbientNoiseReduction = rightAmbientNoiseReduction,
+ rightConversationBoost = rightConversationBoost,
+ singleMode = singleMode,
+ amplification = amplification,
+ balance = balance,
+ showRetryButton = showRetryButton.value,
+ onRetry = { CoroutineScope(Dispatchers.IO).launch { connectL2CAP() } },
+ onSettingsChanged = { sendAccessibilitySettings() }
+ )
+ }
+ composable("eq") {
+ EqualizerSettingsScreen(
+ navController = navController,
+ leftEQ = leftEQ,
+ rightEQ = rightEQ,
+ singleMode = singleMode,
+ onEQChanged = { sendAccessibilitySettings() },
+ phoneMediaEQ = phoneMediaEQ
)
- {
- Text("Connect")
- }
-
- Button(onClick = {
- val characteristicUuid = "94110001-6D9B-4225-A4F1-6A4A7F01B0DE"
- val value = byteArrayOf(0x01, 0x00, 0x00, 0x00, 0x00 ,0x00 ,0x01)
- sendWriteRequest(gatt, characteristicUuid, value)
-
- }) {
- Text("batteryLevel.value")
- }
}
}
}
}
- }
-}
-@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
-fun sendWriteRequest(
- gatt: BluetoothGatt,
- characteristicUuid: String,
- value: ByteArray
-) {
- // Retrieve the service containing the characteristic
- val service = gatt.services.find { service ->
- service.characteristics.any { it.uuid.toString() == characteristicUuid }
+ // Connect automatically
+ CoroutineScope(Dispatchers.IO).launch { connectL2CAP() }
}
- if (service == null) {
- Log.e("GATT", "Service containing characteristic UUID $characteristicUuid not found.")
- return
+ override fun onDestroy() {
+ super.onDestroy()
+ socket?.close()
}
- // Retrieve the characteristic
- val characteristic = service.getCharacteristic(UUID.fromString(characteristicUuid))
- if (characteristic == null) {
- Log.e("GATT", "Characteristic with UUID $characteristicUuid not found.")
- return
+ private suspend fun connectL2CAP() {
+ retryCount.value = 0
+ // Close any existing socket
+ socket?.close()
+ socket = null
+ while (retryCount.value < maxRetries) {
+ try {
+ Log.d(TAG, "Starting L2CAP connection setup, attempt ${retryCount.value + 1}")
+ HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
+ val manager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager
+ val device: BluetoothDevice = manager.adapter.getRemoteDevice(deviceAddress)
+ socket = createBluetoothSocket(device, psm)
+
+ withTimeout(5000L) {
+ socket?.connect()
+ }
+
+ withContext(Dispatchers.Main) {
+ isConnected.value = true
+ showRetryButton.value = false
+ Log.d(TAG, "L2CAP connection established successfully")
+ }
+
+ // Read current settings
+ readCurrentSettings()
+
+ // Start listening for responses
+ listenForData()
+
+ return
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to connect, attempt ${retryCount.value + 1}: ${e.message}")
+ retryCount.value++
+ if (retryCount.value < maxRetries) {
+ delay(2000) // Wait 2 seconds before retry
+ }
+ }
+ }
+
+ // After max retries
+ withContext(Dispatchers.Main) {
+ isConnected.value = false
+ showRetryButton.value = true
+ Log.e(TAG, "Failed to connect after $maxRetries attempts")
+ }
}
+ private fun createBluetoothSocket(device: BluetoothDevice, psm: Int): BluetoothSocket {
+ val type = 3 // L2CAP
+ val constructorSpecs = listOf(
+ arrayOf(device, type, true, true, 31, uuid),
+ arrayOf(device, type, 1, true, true, 31, uuid),
+ arrayOf(type, 1, true, true, device, 31, uuid),
+ arrayOf(type, true, true, device, 31, uuid)
+ )
- // Send the write request
- val success = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- gatt.writeCharacteristic(characteristic, value, BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT)
- } else {
- gatt.writeCharacteristic(characteristic)
+ val constructors = BluetoothSocket::class.java.declaredConstructors
+ Log.d(TAG, "BluetoothSocket has ${constructors.size} constructors")
+
+ var lastException: Exception? = null
+ var attemptedConstructors = 0
+
+ for ((index, params) in constructorSpecs.withIndex()) {
+ try {
+ Log.d(TAG, "Trying constructor signature #${index + 1}")
+ attemptedConstructors++
+ return HiddenApiBypass.newInstance(BluetoothSocket::class.java, *params) as BluetoothSocket
+ } catch (e: Exception) {
+ Log.e(TAG, "Constructor signature #${index + 1} failed: ${e.message}")
+ lastException = e
+ }
+ }
+
+ val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
+ Log.e(TAG, errorMessage)
+ throw lastException ?: IllegalStateException(errorMessage)
+ }
+
+ private fun readCurrentSettings() {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ Log.d(TAG, "Sending read settings command: 0A1800")
+ val readCommand = byteArrayOf(0x0A, 0x18, 0x00)
+ socket?.outputStream?.write(readCommand)
+ socket?.outputStream?.flush()
+ Log.d(TAG, "Read settings command sent")
+ } catch (e: IOException) {
+ Log.e(TAG, "Failed to send read command: ${e.message}")
+ }
+ }
+ }
+
+ private fun listenForData() {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val buffer = ByteArray(1024)
+ Log.d(TAG, "Started listening for incoming data")
+ while (socket?.isConnected == true) {
+ val bytesRead = socket?.inputStream?.read(buffer)
+ if (bytesRead != null && bytesRead > 0) {
+ val data = buffer.copyOfRange(0, bytesRead)
+ Log.d(TAG, "Received data: ${data.joinToString(" ") { "%02X".format(it) }}")
+ parseSettingsResponse(data)
+ } else if (bytesRead == -1) {
+ Log.d(TAG, "Connection closed by remote device")
+ withContext(Dispatchers.Main) {
+ isConnected.value = false
+ }
+ // Attempt to reconnect
+ connectL2CAP()
+ break
+ }
+ }
+ } catch (e: IOException) {
+ Log.e(TAG, "Connection lost: ${e.message}")
+ withContext(Dispatchers.Main) {
+ isConnected.value = false
+ }
+ // Close socket
+ socket?.close()
+ socket = null
+ // Attempt to reconnect
+ connectL2CAP()
+ }
+ }
+ }
+
+ private fun parseSettingsResponse(data: ByteArray) {
+ if (data.size < 2 || data[0] != 0x0B.toByte()) {
+ Log.d(TAG, "Not a settings response")
+ return
+ }
+
+ val settingsData = data.copyOfRange(1, data.size)
+ if (settingsData.size < 100) { // 25 floats * 4 bytes
+ Log.e(TAG, "Settings data too short: ${settingsData.size} bytes")
+ return
+ }
+
+ val buffer = ByteBuffer.wrap(settingsData).order(ByteOrder.LITTLE_ENDIAN)
+
+ // Global enabled
+ val enabled = buffer.float
+ Log.d(TAG, "Parsed enabled: $enabled")
+
+ // Left bud
+ val newLeftEQ = leftEQ.value.copyOf()
+ for (i in 0..7) {
+ newLeftEQ[i] = buffer.float
+ Log.d(TAG, "Parsed left EQ${i+1}: ${newLeftEQ[i]}")
+ }
+ leftEQ.value = newLeftEQ
+ if (singleMode.value) rightEQ.value = newLeftEQ
+
+ leftAmplification.value = buffer.float
+ Log.d(TAG, "Parsed left amplification: ${leftAmplification.value}")
+ leftTone.value = buffer.float
+ Log.d(TAG, "Parsed left tone: ${leftTone.value}")
+ if (singleMode.value) rightTone.value = leftTone.value
+ val leftConvFloat = buffer.float
+ leftConversationBoost.value = leftConvFloat > 0.5f
+ Log.d(TAG, "Parsed left conversation boost: $leftConvFloat (${leftConversationBoost.value})")
+ if (singleMode.value) rightConversationBoost.value = leftConversationBoost.value
+ leftAmbientNoiseReduction.value = buffer.float
+ Log.d(TAG, "Parsed left ambient noise reduction: ${leftAmbientNoiseReduction.value}")
+ if (singleMode.value) rightAmbientNoiseReduction.value = leftAmbientNoiseReduction.value
+
+ // Right bud
+ val newRightEQ = rightEQ.value.copyOf()
+ for (i in 0..7) {
+ newRightEQ[i] = buffer.float
+ Log.d(TAG, "Parsed right EQ${i+1}: ${newRightEQ[i]}")
+ }
+ rightEQ.value = newRightEQ
+
+ rightAmplification.value = buffer.float
+ Log.d(TAG, "Parsed right amplification: ${rightAmplification.value}")
+ rightTone.value = buffer.float
+ Log.d(TAG, "Parsed right tone: ${rightTone.value}")
+ val rightConvFloat = buffer.float
+ rightConversationBoost.value = rightConvFloat > 0.5f
+ Log.d(TAG, "Parsed right conversation boost: $rightConvFloat (${rightConversationBoost.value})")
+ rightAmbientNoiseReduction.value = buffer.float
+ Log.d(TAG, "Parsed right ambient noise reduction: ${rightAmbientNoiseReduction.value}")
+
+ Log.d(TAG, "Settings parsed successfully")
+
+ // Update single mode values if in single mode
+ if (singleMode.value) {
+ val avg = (leftAmplification.value + rightAmplification.value) / 2
+ amplification.value = avg.coerceIn(0f, 1f)
+ val diff = rightAmplification.value - leftAmplification.value
+ balance.value = (0.5f + diff / (2 * avg)).coerceIn(0f, 1f)
+ }
+ }
+
+ private fun sendAccessibilitySettings() {
+ if (!isConnected.value || socket == null) {
+ Log.w(TAG, "Not connected, cannot send settings")
+ return
+ }
+
+ debounceJob?.cancel()
+ debounceJob = CoroutineScope(Dispatchers.IO).launch {
+ delay(100)
+ try {
+ val buffer = ByteBuffer.allocate(103).order(ByteOrder.LITTLE_ENDIAN) // 3 header + 100 data bytes
+
+ buffer.put(0x12)
+ buffer.put(0x18)
+ buffer.put(0x00)
+ buffer.putFloat(1.0f) // enabled
+
+ // Left bud
+ for (eq in leftEQ.value) {
+ buffer.putFloat(eq)
+ }
+ buffer.putFloat(leftAmplification.value)
+ buffer.putFloat(leftTone.value)
+ buffer.putFloat(if (leftConversationBoost.value) 1.0f else 0.0f)
+ buffer.putFloat(leftAmbientNoiseReduction.value)
+
+ // Right bud
+ for (eq in rightEQ.value) {
+ buffer.putFloat(eq)
+ }
+ buffer.putFloat(rightAmplification.value)
+ buffer.putFloat(rightTone.value)
+ buffer.putFloat(if (rightConversationBoost.value) 1.0f else 0.0f)
+ buffer.putFloat(rightAmbientNoiseReduction.value)
+
+ val packet = buffer.array()
+ Log.d(TAG, "Packet length: ${packet.size}")
+ socket?.outputStream?.write(packet)
+ socket?.outputStream?.flush()
+ Log.d(TAG, "Accessibility settings sent: ${packet.joinToString(" ") { "%02X".format(it) }}")
+ } catch (e: IOException) {
+ Log.e(TAG, "Failed to send accessibility settings: ${e.message}")
+ withContext(Dispatchers.Main) {
+ isConnected.value = false
+ }
+ // Close socket
+ socket?.close()
+ socket = null
+ }
+ }
}
- Log.d("GATT", "Write request sent $success to UUID: $characteristicUuid")
}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySlider.kt
new file mode 100644
index 0000000..d111c0a
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySlider.kt
@@ -0,0 +1,138 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package me.kavishdevar.librepods.composables
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import me.kavishdevar.librepods.R
+import kotlin.math.roundToInt
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AccessibilitySlider(
+ label: String,
+ value: Float,
+ onValueChange: (Float) -> Unit,
+ valueRange: ClosedFloatingPointRange
+) {
+ val isDarkTheme = isSystemInDarkTheme()
+
+ val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
+ val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
+ val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
+ val labelTextColor = if (isDarkTheme) Color.White else Color.Black
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(
+ text = label,
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ color = labelTextColor,
+ fontFamily = androidx.compose.ui.text.font.FontFamily(Font(R.font.sf_pro))
+ )
+ )
+
+ Slider(
+ value = value,
+ onValueChange = onValueChange,
+ valueRange = valueRange,
+ onValueChangeFinished = {
+ // Round to 2 decimal places
+ onValueChange((value * 100).roundToInt() / 100f)
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(36.dp),
+ colors = SliderDefaults.colors(
+ thumbColor = thumbColor,
+ activeTrackColor = activeTrackColor,
+ inactiveTrackColor = trackColor
+ ),
+ thumb = {
+ Box(
+ modifier = Modifier
+ .size(24.dp)
+ .shadow(4.dp, CircleShape)
+ .background(thumbColor, CircleShape)
+ )
+ },
+ track = {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(12.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(4.dp)
+ .background(trackColor, RoundedCornerShape(4.dp))
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
+ .height(4.dp)
+ .background(activeTrackColor, RoundedCornerShape(4.dp))
+ )
+ }
+ }
+ )
+ }
+}
+
+@Preview
+@Composable
+fun AccessibilitySliderPreview() {
+ AccessibilitySlider(
+ label = "Test Slider",
+ value = 1.0f,
+ onValueChange = {},
+ valueRange = 0f..2f
+ )
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
new file mode 100644
index 0000000..824098c
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
@@ -0,0 +1,472 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package me.kavishdevar.librepods.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavController
+import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.composables.AccessibilitySlider
+import me.kavishdevar.librepods.composables.NavigationButton
+import me.kavishdevar.librepods.composables.StyledSwitch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AccessibilitySettingsScreen(
+ navController: NavController,
+ isConnected: Boolean,
+ leftAmplification: MutableState,
+ leftTone: MutableState,
+ leftAmbientNoiseReduction: MutableState,
+ leftConversationBoost: MutableState,
+ rightAmplification: MutableState,
+ rightTone: MutableState,
+ rightAmbientNoiseReduction: MutableState,
+ rightConversationBoost: MutableState,
+ singleMode: MutableState,
+ amplification: MutableState,
+ balance: MutableState,
+ showRetryButton: Boolean,
+ onRetry: () -> Unit,
+ onSettingsChanged: () -> Unit
+) {
+ val isDarkTheme = isSystemInDarkTheme()
+ val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
+ val cardBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ "Accessibility Settings",
+ style = TextStyle(
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Medium,
+ color = textColor,
+ fontFamily = androidx.compose.ui.text.font.FontFamily(Font(R.font.sf_pro))
+ )
+ )
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Transparent
+ )
+ )
+ },
+ containerColor = backgroundColor
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(horizontal = 16.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Retry Button if needed
+ if (!isConnected && showRetryButton) {
+ Button(
+ onClick = onRetry,
+ modifier = Modifier.fillMaxWidth(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color(0xFF007AFF)
+ )
+ ) {
+ Text("Retry Connection")
+ }
+ }
+
+ // Single Mode Switch
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
+ shape = RoundedCornerShape(14.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ "Single Mode",
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ color = textColor,
+ fontFamily = androidx.compose.ui.text.font.FontFamily(Font(R.font.sf_pro))
+ )
+ )
+ StyledSwitch(
+ checked = singleMode.value,
+ onCheckedChange = {
+ singleMode.value = it
+ if (it) {
+ // When switching to single mode, set amplification and balance
+ val avg = (leftAmplification.value + rightAmplification.value) / 2
+ amplification.value = avg.coerceIn(0f, 1f)
+ val diff = rightAmplification.value - leftAmplification.value
+ balance.value = (0.5f + diff / (2 * avg)).coerceIn(0f, 1f)
+ // Update left and right
+ val amp = amplification.value
+ val bal = balance.value
+ leftAmplification.value = amp * (1 + bal)
+ rightAmplification.value = amp * (2 - bal)
+ }
+ }
+ )
+ }
+ }
+
+ if (isConnected) {
+ if (singleMode.value) {
+ // Balance Slider for Amplification
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
+ shape = RoundedCornerShape(14.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ AccessibilitySlider(
+ label = "Amplification",
+ value = amplification.value,
+ onValueChange = {
+ amplification.value = it
+ val amp = it
+ val bal = balance.value
+ leftAmplification.value = amp * (1 + bal)
+ rightAmplification.value = amp * (2 - bal)
+ onSettingsChanged()
+ },
+ valueRange = 0f..1f
+ )
+
+ AccessibilitySlider(
+ label = "Balance",
+ value = balance.value,
+ onValueChange = {
+ balance.value = it
+ val amp = amplification.value
+ val bal = it
+ leftAmplification.value = amp * (1 + bal)
+ rightAmplification.value = amp * (2 - bal)
+ onSettingsChanged()
+ },
+ valueRange = 0f..1f
+ )
+ }
+ }
+
+ // Single Bud Settings Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
+ shape = RoundedCornerShape(14.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ "Bud Settings",
+ style = TextStyle(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ color = textColor,
+ fontFamily = androidx.compose.ui.text.font.FontFamily(
+ Font(R.font.sf_pro)
+ )
+ )
+ )
+
+ AccessibilitySlider(
+ label = "Tone",
+ value = leftTone.value,
+ onValueChange = {
+ leftTone.value = it
+ rightTone.value = it
+ onSettingsChanged()
+ },
+ valueRange = 0f..2f
+ )
+
+ AccessibilitySlider(
+ label = "Ambient Noise Reduction",
+ value = leftAmbientNoiseReduction.value,
+ onValueChange = {
+ leftAmbientNoiseReduction.value = it
+ rightAmbientNoiseReduction.value = it
+ onSettingsChanged()
+ },
+ valueRange = 0f..1f
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ "Conversation Boost",
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ color = textColor,
+ fontFamily = androidx.compose.ui.text.font.FontFamily(
+ Font(R.font.sf_pro)
+ )
+ )
+ )
+ StyledSwitch(
+ checked = leftConversationBoost.value,
+ onCheckedChange = {
+ leftConversationBoost.value = it
+ rightConversationBoost.value = it
+ onSettingsChanged()
+ }
+ )
+ }
+
+ NavigationButton(
+ to = "eq",
+ name = "Equalizer Settings",
+ navController = navController
+ )
+ }
+ }
+ } else {
+ // Left Bud Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
+ shape = RoundedCornerShape(14.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ "Left Bud",
+ style = TextStyle(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ color = textColor,
+ fontFamily = androidx.compose.ui.text.font.FontFamily(
+ Font(R.font.sf_pro)
+ )
+ )
+ )
+
+ AccessibilitySlider(
+ label = "Amplification",
+ value = leftAmplification.value,
+ onValueChange = {
+ leftAmplification.value = it
+ onSettingsChanged()
+ },
+ valueRange = 0f..2f
+ )
+
+ AccessibilitySlider(
+ label = "Tone",
+ value = leftTone.value,
+ onValueChange = {
+ leftTone.value = it
+ onSettingsChanged()
+ },
+ valueRange = 0f..2f
+ )
+
+ AccessibilitySlider(
+ label = "Ambient Noise Reduction",
+ value = leftAmbientNoiseReduction.value,
+ onValueChange = {
+ leftAmbientNoiseReduction.value = it
+ onSettingsChanged()
+ },
+ valueRange = 0f..1f
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ "Conversation Boost",
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ color = textColor,
+ fontFamily = androidx.compose.ui.text.font.FontFamily(
+ Font(R.font.sf_pro)
+ )
+ )
+ )
+ StyledSwitch(
+ checked = leftConversationBoost.value,
+ onCheckedChange = {
+ leftConversationBoost.value = it
+ onSettingsChanged()
+ }
+ )
+ }
+
+ NavigationButton(
+ to = "eq",
+ name = "Equalizer Settings",
+ navController = navController
+ )
+ }
+ }
+
+ // Right Bud Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
+ shape = RoundedCornerShape(14.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ "Right Bud",
+ style = TextStyle(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ color = textColor,
+ fontFamily = androidx.compose.ui.text.font.FontFamily(
+ Font(R.font.sf_pro)
+ )
+ )
+ )
+
+ AccessibilitySlider(
+ label = "Amplification",
+ value = rightAmplification.value,
+ onValueChange = {
+ rightAmplification.value = it
+ onSettingsChanged()
+ },
+ valueRange = 0f..2f
+ )
+
+ AccessibilitySlider(
+ label = "Tone",
+ value = rightTone.value,
+ onValueChange = {
+ rightTone.value = it
+ onSettingsChanged()
+ },
+ valueRange = 0f..2f
+ )
+
+ AccessibilitySlider(
+ label = "Ambient Noise Reduction",
+ value = rightAmbientNoiseReduction.value,
+ onValueChange = {
+ rightAmbientNoiseReduction.value = it
+ onSettingsChanged()
+ },
+ valueRange = 0f..1f
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ "Conversation Boost",
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ color = textColor,
+ fontFamily = androidx.compose.ui.text.font.FontFamily(
+ Font(R.font.sf_pro)
+ )
+ )
+ )
+ StyledSwitch(
+ checked = rightConversationBoost.value,
+ onCheckedChange = {
+ rightConversationBoost.value = it
+ onSettingsChanged()
+ }
+ )
+ }
+
+ NavigationButton(
+ to = "eq",
+ name = "Equalizer Settings",
+ navController = navController
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/EqualizerSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/EqualizerSettingsScreen.kt
new file mode 100644
index 0000000..8197c2d
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/EqualizerSettingsScreen.kt
@@ -0,0 +1,304 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+@file:OptIn(ExperimentalEncodingApi::class)
+
+package me.kavishdevar.librepods.screens
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavController
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.composables.AccessibilitySlider
+import me.kavishdevar.librepods.services.ServiceManager
+
+import kotlin.io.encoding.ExperimentalEncodingApi
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun EqualizerSettingsScreen(
+ navController: NavController,
+ leftEQ: MutableState,
+ rightEQ: MutableState,
+ singleMode: MutableState,
+ onEQChanged: () -> Unit,
+ phoneMediaEQ: MutableState
+) {
+ val isDarkTheme = isSystemInDarkTheme()
+ val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
+ val cardBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+
+ val aacpManager = ServiceManager.getService()!!.aacpManager
+
+ val debounceJob = remember { mutableStateOf(null) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ "Equalizer Settings",
+ style = TextStyle(
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Medium,
+ color = textColor,
+ fontFamily = androidx.compose.ui.text.font.FontFamily(
+ Font(R.font.sf_pro)
+ )
+ )
+ )
+ },
+ navigationIcon = {
+ TextButton(
+ onClick = { navController.popBackStack() },
+ shape = RoundedCornerShape(8.dp),
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.KeyboardArrowLeft,
+ contentDescription = "Back",
+ tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
+ modifier = Modifier.scale(1.5f)
+ )
+ Text(
+ "Accessibility",
+ style = TextStyle(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
+ fontFamily = androidx.compose.ui.text.font.FontFamily(
+ Font(R.font.sf_pro)
+ )
+ ),
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Transparent
+ )
+ )
+ },
+ containerColor = backgroundColor
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(horizontal = 16.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ if (singleMode.value) {
+ // Single Bud EQ Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
+ shape = RoundedCornerShape(14.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ "Equalizer",
+ style = TextStyle(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ color = textColor,
+ fontFamily = androidx.compose.ui.text.font.FontFamily(
+ Font(R.font.sf_pro)
+ )
+ )
+ )
+
+ for (i in 0..7) {
+ AccessibilitySlider(
+ label = "EQ${i + 1}",
+ value = leftEQ.value[i],
+ onValueChange = {
+ leftEQ.value = leftEQ.value.copyOf().apply { this[i] = it }
+ rightEQ.value = rightEQ.value.copyOf().apply { this[i] = it } // Sync to right
+ onEQChanged()
+ },
+ valueRange = 0f..100f
+ )
+ }
+ }
+ }
+ } else {
+ // Left Bud EQ Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
+ shape = RoundedCornerShape(14.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ "Left Bud Equalizer",
+ style = TextStyle(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ color = textColor,
+ fontFamily = androidx.compose.ui.text.font.FontFamily(
+ Font(R.font.sf_pro)
+ )
+ )
+ )
+
+ for (i in 0..7) {
+ AccessibilitySlider(
+ label = "EQ${i + 1}",
+ value = leftEQ.value[i],
+ onValueChange = {
+ leftEQ.value = leftEQ.value.copyOf().apply { this[i] = it }
+ onEQChanged()
+ },
+ valueRange = 0f..100f
+ )
+ }
+ }
+ }
+
+ // Right Bud EQ Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
+ shape = RoundedCornerShape(14.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ "Right Bud Equalizer",
+ style = TextStyle(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ color = textColor,
+ fontFamily = androidx.compose.ui.text.font.FontFamily(
+ Font(R.font.sf_pro)
+ )
+ )
+ )
+
+ for (i in 0..7) {
+ AccessibilitySlider(
+ label = "EQ${i + 1}",
+ value = rightEQ.value[i],
+ onValueChange = {
+ rightEQ.value = rightEQ.value.copyOf().apply { this[i] = it }
+ onEQChanged()
+ },
+ valueRange = 0f..100f
+ )
+ }
+ }
+ }
+ }
+
+ // Phone and Media EQ Card
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
+ shape = RoundedCornerShape(14.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ "Phone and Media Equalizer",
+ style = TextStyle(
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Medium,
+ color = textColor,
+ fontFamily = androidx.compose.ui.text.font.FontFamily(
+ Font(R.font.sf_pro)
+ )
+ )
+ )
+
+ for (i in 0..7) {
+ AccessibilitySlider(
+ label = "EQ${i + 1}",
+ value = phoneMediaEQ.value[i],
+ onValueChange = {
+ phoneMediaEQ.value = phoneMediaEQ.value.copyOf().apply { this[i] = it }
+ debounceJob.value?.cancel()
+ debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
+ delay(100)
+ aacpManager.sendPhoneMediaEQ(phoneMediaEQ.value)
+ }
+ },
+ valueRange = 0f..100f
+ )
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
index 7f651f6..34b6052 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
@@ -25,6 +25,8 @@ import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdenti
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressBudType.entries
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType.entries
import kotlin.io.encoding.ExperimentalEncodingApi
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
/**
* Manager class for Apple Accessory Communication Protocol (AACP)
@@ -36,18 +38,19 @@ class AACPManager {
private const val TAG = "AACPManager"
object Opcodes {
- const val SET_FEATURE_FLAGS: Byte = 0x4d
- const val REQUEST_NOTIFICATIONS: Byte = 0x0f
+ const val SET_FEATURE_FLAGS: Byte = 0x4D
+ const val REQUEST_NOTIFICATIONS: Byte = 0x0F
const val BATTERY_INFO: Byte = 0x04
const val CONTROL_COMMAND: Byte = 0x09
const val EAR_DETECTION: Byte = 0x06
- const val CONVERSATION_AWARENESS: Byte = 0x4b
- const val DEVICE_METADATA: Byte = 0x1d
+ const val CONVERSATION_AWARENESS: Byte = 0x4B
+ const val DEVICE_METADATA: Byte = 0x1D
const val RENAME: Byte = 0x1E
const val HEADTRACKING: Byte = 0x17
const val PROXIMITY_KEYS_REQ: Byte = 0x30
const val PROXIMITY_KEYS_RSP: Byte = 0x31
const val STEM_PRESS: Byte = 0x19
+ const val EQ_SETTINGS: Byte = 0x35
}
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
@@ -551,4 +554,42 @@ class AACPManager {
return false
}
}
-}
+
+ fun sendEQPacket(eqFloats: FloatArray, phone: Boolean, media: Boolean): Boolean {
+ val buffer = ByteBuffer.allocate(140).order(ByteOrder.LITTLE_ENDIAN)
+ buffer.put(0x04)
+ buffer.put(0x00)
+ buffer.put(0x04)
+ buffer.put(0x00)
+ buffer.put(0x53)
+ buffer.put(0x00)
+ buffer.put(0x84.toByte())
+ buffer.put(0x00)
+ buffer.put(0x02)
+ buffer.put(0x02)
+ buffer.put(if (phone) 0x01 else 0x00)
+ buffer.put(if (media) 0x01 else 0x00)
+ for (i in 0..7) {
+ buffer.putFloat(eqFloats[i])
+ }
+ while (buffer.hasRemaining()) {
+ buffer.put(0x00)
+ }
+ val packet = buffer.array()
+ return sendPacket(packet)
+ }
+
+ fun sendPhoneMediaEQ(eq: FloatArray, phone: Byte = 0x02.toByte(), media: Byte = 0x02.toByte()) {
+ if (eq.size != 8) throw IllegalArgumentException("EQ must be 8 floats")
+ val header = byteArrayOf(0x04.toByte(), 0x00.toByte(), 0x04.toByte(), 0x00.toByte(), 0x53.toByte(), 0x00.toByte(), 0x84.toByte(), 0x00.toByte(), 0x02.toByte(), 0x02.toByte(), phone, media)
+ val buffer = ByteBuffer.allocate(128).order(ByteOrder.LITTLE_ENDIAN)
+ for (block in 0..3) {
+ for (i in 0..7) {
+ buffer.putFloat(eq[i])
+ }
+ }
+ val payload = buffer.array()
+ val packet = header + payload
+ sendPacket(packet)
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt
index e6a28e8..e9a8c0f 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt
@@ -45,6 +45,7 @@ class RadareOffsetFinder(context: Context) {
private const val CFG_REQ_OFFSET_PROP = "persist.librepods.cfg_req_offset"
private const val CSM_CONFIG_OFFSET_PROP = "persist.librepods.csm_config_offset"
private const val PEER_INFO_REQ_OFFSET_PROP = "persist.librepods.peer_info_req_offset"
+ private const val SDP_OFFSET_PROP = "persist.librepods.sdp_offset"
private const val EXTRACT_DIR = "/"
private const val RADARE2_BIN_PATH = "$EXTRACT_DIR/data/local/tmp/aln_unzip/org.radare.radare2installer/radare2/bin"
@@ -77,7 +78,8 @@ class RadareOffsetFinder(context: Context) {
"setprop $HOOK_OFFSET_PROP '' && " +
"setprop $CFG_REQ_OFFSET_PROP '' && " +
"setprop $CSM_CONFIG_OFFSET_PROP '' && " +
- "setprop $PEER_INFO_REQ_OFFSET_PROP ''"
+ "setprop $PEER_INFO_REQ_OFFSET_PROP ''" +
+ "setprop $SDP_OFFSET_PROP ''"
))
val exitCode = process.waitFor()
@@ -422,6 +424,7 @@ class RadareOffsetFinder(context: Context) {
// findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup)
// findAndSaveL2cCsmConfigOffset(libraryPath, envSetup)
// findAndSaveL2cuSendPeerInfoReqOffset(libraryPath, envSetup)
+ findAndSaveSdpOffset(libraryPath, envSetup)
} catch (e: Exception) {
Log.e(TAG, "Failed to find function offset", e)
@@ -572,6 +575,51 @@ class RadareOffsetFinder(context: Context) {
}
}
+ private suspend fun findAndSaveSdpOffset(libraryPath: String, envSetup: String) = withContext(Dispatchers.IO) {
+ try {
+ val command = "$envSetup && $RADARE2_BIN_PATH/rabin2 -q -E $libraryPath | grep DmSetLocalDiRecord"
+ Log.d(TAG, "Running command: $command")
+
+ val process = Runtime.getRuntime().exec(arrayOf("su", "-c", command))
+ val reader = BufferedReader(InputStreamReader(process.inputStream))
+ val errorReader = BufferedReader(InputStreamReader(process.errorStream))
+
+ var line: String?
+ var offset = 0L
+
+ while (reader.readLine().also { line = it } != null) {
+ Log.d(TAG, "rabin2 output: $line")
+ if (line?.contains("DmSetLocalDiRecord") == true) {
+ val parts = line.split(" ")
+ if (parts.isNotEmpty() && parts[0].startsWith("0x")) {
+ offset = parts[0].substring(2).toLong(16)
+ Log.d(TAG, "Found DmSetLocalDiRecord offset at ${parts[0]}")
+ break
+ }
+ }
+ }
+
+ while (errorReader.readLine().also { line = it } != null) {
+ Log.d(TAG, "rabin2 error: $line")
+ }
+
+ val exitCode = process.waitFor()
+ if (exitCode != 0) {
+ Log.e(TAG, "rabin2 command failed with exit code $exitCode")
+ }
+
+ if (offset > 0L) {
+ val hexString = "0x${offset.toString(16)}"
+ Runtime.getRuntime().exec(arrayOf(
+ "su", "-c", "setprop $SDP_OFFSET_PROP $hexString"
+ )).waitFor()
+ Log.d(TAG, "Saved DmSetLocalDiRecord offset: $hexString")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to find or save DmSetLocalDiRecord offset", e)
+ }
+ }
+
private suspend fun saveOffset(offset: Long): Boolean = withContext(Dispatchers.IO) {
try {
val hexString = "0x${offset.toString(16)}"