android: implement the accessiblity settings page

This commit is contained in:
Kavish Devar
2025-09-11 12:21:23 +05:30
parent fa00620b5b
commit c53356f77e
13 changed files with 857 additions and 1020 deletions

View File

@@ -19,70 +19,19 @@
package me.kavishdevar.librepods
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
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
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
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 dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
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.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
@Suppress("PrivatePropertyName")
@ExperimentalHazeMaterialsApi
class CustomDevice : ComponentActivity() {
private val TAG = "AirPodsAccessibilitySettings"
private var socket: BluetoothSocket? = null
private val deviceAddress = "28:2D:7F:C2:05:5B"
private val uuid: ParcelUuid = ParcelUuid.fromString("00000000-0000-0000-0000-00000000000")
// Data states
private val isConnected = mutableStateOf(false)
private val leftAmplification = mutableFloatStateOf(1.0f)
private val leftTone = mutableFloatStateOf(1.0f)
private val leftAmbientNoiseReduction = mutableFloatStateOf(0.5f)
private val leftConversationBoost = mutableStateOf(false)
private val leftEQ = mutableStateOf(FloatArray(8) { 50.0f })
private val rightAmplification = mutableFloatStateOf(1.0f)
private val rightTone = mutableFloatStateOf(1.0f)
private val rightAmbientNoiseReduction = mutableFloatStateOf(0.5f)
private val rightConversationBoost = mutableStateOf(false)
private val rightEQ = mutableStateOf(FloatArray(8) { 50.0f })
private val singleMode = mutableStateOf(false)
private val amplification = mutableFloatStateOf(1.0f)
private val balance = mutableFloatStateOf(0.5f)
private val retryCount = mutableIntStateOf(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)
@@ -93,294 +42,14 @@ class CustomDevice : ComponentActivity() {
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
)
AccessibilitySettingsScreen()
}
}
}
}
// Connect automatically
CoroutineScope(Dispatchers.IO).launch { connectL2CAP() }
}
override fun onDestroy() {
super.onDestroy()
socket?.close()
}
@SuppressLint("MissingPermission")
private suspend fun connectL2CAP() {
retryCount.intValue = 0
// Close any existing socket
socket?.close()
socket = null
while (retryCount.intValue < maxRetries) {
try {
Log.d(TAG, "Starting L2CAP connection setup, attempt ${retryCount.intValue + 1}")
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val manager = getSystemService(BLUETOOTH_SERVICE) as BluetoothManager
val device: BluetoothDevice = manager.adapter.getRemoteDevice(deviceAddress)
socket = createBluetoothSocket(device)
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.intValue + 1}: ${e.message}")
retryCount.intValue++
if (retryCount.intValue < 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): 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)
)
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.floatValue = buffer.float
Log.d(TAG, "Parsed left amplification: ${leftAmplification.floatValue}")
leftTone.floatValue = buffer.float
Log.d(TAG, "Parsed left tone: ${leftTone.floatValue}")
if (singleMode.value) rightTone.floatValue = leftTone.floatValue
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.floatValue = buffer.float
Log.d(TAG, "Parsed left ambient noise reduction: ${leftAmbientNoiseReduction.floatValue}")
if (singleMode.value) rightAmbientNoiseReduction.floatValue = leftAmbientNoiseReduction.floatValue
// 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.floatValue = buffer.float
Log.d(TAG, "Parsed right amplification: ${rightAmplification.floatValue}")
rightTone.floatValue = buffer.float
Log.d(TAG, "Parsed right tone: ${rightTone.floatValue}")
val rightConvFloat = buffer.float
rightConversationBoost.value = rightConvFloat > 0.5f
Log.d(TAG, "Parsed right conversation boost: $rightConvFloat (${rightConversationBoost.value})")
rightAmbientNoiseReduction.floatValue = buffer.float
Log.d(TAG, "Parsed right ambient noise reduction: ${rightAmbientNoiseReduction.floatValue}")
Log.d(TAG, "Settings parsed successfully")
// Update single mode values if in single mode
if (singleMode.value) {
val avg = (leftAmplification.floatValue + rightAmplification.floatValue) / 2
amplification.floatValue = avg.coerceIn(0f, 1f)
val diff = rightAmplification.floatValue - leftAmplification.floatValue
balance.floatValue = (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.floatValue)
buffer.putFloat(leftTone.floatValue)
buffer.putFloat(if (leftConversationBoost.value) 1.0f else 0.0f)
buffer.putFloat(leftAmbientNoiseReduction.floatValue)
// Right bud
for (eq in rightEQ.value) {
buffer.putFloat(eq)
}
buffer.putFloat(rightAmplification.floatValue)
buffer.putFloat(rightTone.floatValue)
buffer.putFloat(if (rightConversationBoost.value) 1.0f else 0.0f)
buffer.putFloat(rightAmbientNoiseReduction.floatValue)
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
}
}
}
}

View File

@@ -154,9 +154,6 @@ fun AccessibilitySettings() {
},
textColor = textColor
)
SinglePodANCSwitch()
VolumeControlSwitch()
}
}

View File

@@ -42,9 +42,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
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 me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -111,7 +113,7 @@ fun ConversationalAwarenessSwitch() {
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Lowers media volume and reduces background noise when you start speaking to other people.",
text = stringResource(R.string.conversational_awareness_description),
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,

View File

@@ -0,0 +1,128 @@
/*
* 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.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.ATTManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun LoudSoundReductionSwitch(attManager: ATTManager) {
var loudSoundReductionEnabled by remember {
mutableStateOf(
false
)
}
LaunchedEffect(Unit) {
while (attManager.socket?.isConnected != true) {
delay(100)
}
attManager.read(0x1b)
}
LaunchedEffect(loudSoundReductionEnabled) {
if (attManager.socket?.isConnected != true) return@LaunchedEffect
attManager.write(0x1b, if (loudSoundReductionEnabled) byteArrayOf(1) else byteArrayOf(0))
}
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(14.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(horizontal = 12.dp, vertical = 12.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
loudSoundReductionEnabled = !loudSoundReductionEnabled
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = stringResource(R.string.loud_sound_reduction),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.loud_sound_reduction_description),
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
StyledSwitch(
checked = loudSoundReductionEnabled,
onCheckedChange = {
loudSoundReductionEnabled = it
},
)
}
}

View File

@@ -50,7 +50,7 @@ import androidx.navigation.NavController
@Composable
fun NavigationButton(to: String, name: String, navController: NavController) {
fun NavigationButton(to: String, name: String, navController: NavController, onClick: (() -> Unit)? = null) {
val isDarkTheme = isSystemInDarkTheme()
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
@@ -67,7 +67,7 @@ fun NavigationButton(to: String, name: String, navController: NavController) {
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
navController.navigate(to)
if (onClick != null) onClick() else navController.navigate(to)
}
)
}
@@ -79,7 +79,7 @@ fun NavigationButton(to: String, name: String, navController: NavController) {
)
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = { navController.navigate(to) },
onClick = { if (onClick != null) onClick() else navController.navigate(to) },
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Transparent,
contentColor = if (isDarkTheme) Color.White else Color.Black

View File

@@ -77,7 +77,7 @@ fun ToneVolumeSlider() {
Row(
modifier = Modifier
.fillMaxWidth(),
.fillMaxWidth(0.95f),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {

View File

@@ -91,6 +91,7 @@ import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.CustomDevice
import me.kavishdevar.librepods.composables.AccessibilitySettings
import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView
@@ -401,7 +402,10 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
// Only show debug when not in BLE-only mode
if (!bleOnlyMode) {
Spacer(modifier = Modifier.height(16.dp))
AccessibilitySettings()
NavigationButton(to = "", "Accessibility", navController = navController, onClick = {
val intent = Intent(context, CustomDevice::class.java)
context.startActivity(intent)
})
Spacer(modifier = Modifier.height(16.dp))
NavigationButton("debug", "Debug", navController)

View File

@@ -1,300 +0,0 @@
/*
* 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.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.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.mutableStateOf
import androidx.compose.runtime.remember
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

@@ -2318,6 +2318,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
delay(200)
aacpManager.sendNotificationRequest()
delay(200)
aacpManager.sendSomePacketIDontKnowWhatItIs()
delay(200)
aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value+AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte())
if (!handleIncomingCallOnceConnected) startHeadTracking() else handleIncomingCall()
Handler(Looper.getMainLooper()).postDelayed({

View File

@@ -1107,6 +1107,19 @@ class AACPManager {
return devices
}
fun sendSomePacketIDontKnowWhatItIs() {
// 2900 00ff ffff ffff ffff
sendDataPacket(
byteArrayOf(
0x29, 0x00,
0x00, 0xFF.toByte(),
0xFF.toByte(), 0xFF.toByte(),
0xFF.toByte(), 0xFF.toByte(),
0xFF.toByte(), 0xFF.toByte(),
)
)
}
fun disconnected() {
Log.d(TAG, "Disconnected, clearing state")
controlCommandStatusList.clear()

View File

@@ -0,0 +1,112 @@
package me.kavishdevar.librepods.utils
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid
import android.util.Log
import org.lsposed.hiddenapibypass.HiddenApiBypass
import java.io.InputStream
import java.io.OutputStream
class ATTManager(private val device: BluetoothDevice) {
companion object {
private const val TAG = "ATTManager"
private const val OPCODE_READ_REQUEST: Byte = 0x0A
private const val OPCODE_WRITE_REQUEST: Byte = 0x12
}
var socket: BluetoothSocket? = null
private var input: InputStream? = null
private var output: OutputStream? = null
@SuppressLint("MissingPermission")
fun connect() {
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
socket = createBluetoothSocket(device, uuid)
socket!!.connect()
input = socket!!.inputStream
output = socket!!.outputStream
Log.d(TAG, "Connected to ATT")
}
fun disconnect() {
try {
socket?.close()
} catch (e: Exception) {
Log.w(TAG, "Error closing socket: ${e.message}")
}
}
fun read(handle: Int): ByteArray {
val lsb = (handle and 0xFF).toByte()
val msb = ((handle shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_READ_REQUEST, lsb, msb)
writeRaw(pdu)
return readRaw()
}
fun write(handle: Int, value: ByteArray) {
val lsb = (handle and 0xFF).toByte()
val msb = ((handle shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
writeRaw(pdu)
readRaw() // usually a Write Response (0x13)
}
private fun writeRaw(pdu: ByteArray) {
output?.write(pdu)
output?.flush()
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
}
private fun readRaw(): ByteArray {
val inp = input ?: throw IllegalStateException("Not connected")
val buffer = ByteArray(512)
val len = inp.read(buffer)
if (len <= 0) throw IllegalStateException("No data read from ATT socket")
val data = buffer.copyOfRange(0, len)
Log.wtf(TAG, "Read ${data.size} bytes from ATT")
Log.d(TAG, "readRaw: ${data.joinToString(" ") { String.format("%02X", it) }}")
return data
}
private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): 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)
)
val constructors = BluetoothSocket::class.java.declaredConstructors
Log.d("ATTManager", "BluetoothSocket has ${constructors.size} constructors:")
constructors.forEachIndexed { index, constructor ->
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
Log.d("ATTManager", "Constructor $index: ($params)")
}
var lastException: Exception? = null
var attemptedConstructors = 0
for ((index, params) in constructorSpecs.withIndex()) {
try {
Log.d("ATTManager", "Trying constructor signature #${index + 1}")
attemptedConstructors++
return HiddenApiBypass.newInstance(BluetoothSocket::class.java, *params) as BluetoothSocket
} catch (e: Exception) {
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
lastException = e
}
}
val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
Log.e("ATTManager", errorMessage)
throw lastException ?: IllegalStateException(errorMessage)
}
}

View File

@@ -1,4 +1,4 @@
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name" translatable="false">LibrePods</string>
<string name="app_description" translatable="false">Liberate your AirPods from Apple\'s ecosystem.</string>
<string name="title_activity_custom_device" translatable="false">GATT Testing</string>
@@ -82,4 +82,7 @@
<string name="takeover_media_start">Starting media playback</string>
<string name="takeover_media_start_desc">Your phone starts playing media</string>
<string name="undo">Undo</string>
<string name="customize_transparency_mode_description">You can customize Transparency mode for your AirPods Pro to help you hear what\'s around you.</string>
<string name="loud_sound_reduction_description">AirPods Pro automatically reduce your exposure to loud environmental noises when in Transparency and Adaptive mode.</string>
<string name="loud_sound_reduction">Loud Sound Reduction</string>
</resources>