From 86551be86b2976526aad1cb39fc5a57432fb4173 Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Mon, 8 Sep 2025 00:23:45 +0530 Subject: [PATCH] android: add accessibility stuff adds option for customizing transparency mode, amplification, tone, etc. --- android/app/src/main/AndroidManifest.xml | 4 +- android/app/src/main/cpp/l2c_fcr_hook.cpp | 68 +++ android/app/src/main/cpp/l2c_fcr_hook.h | 22 + .../librepods/CustomDeviceActivity.kt | 456 ++++++++++++----- .../composables/AccessibilitySlider.kt | 138 +++++ .../screens/AccessibilitySettingsScreen.kt | 472 ++++++++++++++++++ .../screens/EqualizerSettingsScreen.kt | 304 +++++++++++ .../librepods/utils/AACPManager.kt | 51 +- .../librepods/utils/RadareOffsetFinder.kt | 50 +- 9 files changed, 1433 insertions(+), 132 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySlider.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/screens/EqualizerSettingsScreen.kt 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)}"