android: add accessibility stuff

adds option for customizing transparency mode, amplification, tone, etc.
This commit is contained in:
Kavish Devar
2025-09-08 00:23:45 +05:30
parent 802c2e0220
commit 86551be86b
9 changed files with 1433 additions and 132 deletions

View File

@@ -90,13 +90,13 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<!-- <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="librepods"
android:host="add-magic-keys" />
</intent-filter>
</intent-filter> -->
</activity>
<activity

View File

@@ -24,6 +24,8 @@
#include <string>
#include <sys/system_properties.h>
#include "l2c_fcr_hook.h"
#include <cerrno>
#include <cstdlib>
#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<void*>(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;
}

View File

@@ -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;

View File

@@ -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")
}

View File

@@ -0,0 +1,138 @@
/*
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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<Float>
) {
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
)
}

View File

@@ -0,0 +1,472 @@
/*
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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<Float>,
leftTone: MutableState<Float>,
leftAmbientNoiseReduction: MutableState<Float>,
leftConversationBoost: MutableState<Boolean>,
rightAmplification: MutableState<Float>,
rightTone: MutableState<Float>,
rightAmbientNoiseReduction: MutableState<Float>,
rightConversationBoost: MutableState<Boolean>,
singleMode: MutableState<Boolean>,
amplification: MutableState<Float>,
balance: MutableState<Float>,
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))
}
}
}

View File

@@ -0,0 +1,304 @@
/*
* LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
@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<FloatArray>,
rightEQ: MutableState<FloatArray>,
singleMode: MutableState<Boolean>,
onEQChanged: () -> Unit,
phoneMediaEQ: MutableState<FloatArray>
) {
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<Job?>(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
)
}
}
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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)}"