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

@@ -18,454 +18,661 @@
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.width
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.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.draw.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
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.composables.NavigationButton
import me.kavishdevar.librepods.composables.LoudSoundReductionSwitch
import me.kavishdevar.librepods.composables.SinglePodANCSwitch
import me.kavishdevar.librepods.composables.StyledSwitch
import me.kavishdevar.librepods.composables.ToneVolumeSlider
import me.kavishdevar.librepods.composables.VolumeControlSwitch
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTManager
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class)
var debounceJob: Job? = null
const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::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
) {
fun AccessibilitySettingsScreen() {
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 verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val snackbarHostState = remember { SnackbarHostState() }
val attManager = ATTManager(ServiceManager.getService()?.device?: throw IllegalStateException("No device connected"))
DisposableEffect(attManager) {
onDispose {
Log.d(TAG, "Disconnecting from ATT...")
try {
attManager.disconnect()
} catch (e: Exception) {
Log.w(TAG, "Error while disconnecting ATTManager: ${e.message}")
}
}
}
Scaffold(
containerColor = if (isSystemInDarkTheme()) Color(
0xFF000000
) else Color(
0xFFF2F2F7
),
topBar = {
TopAppBar(
val darkMode = isSystemInDarkTheme()
val mDensity = remember { mutableFloatStateOf(1f) }
CenterAlignedTopAppBar(
title = {
Text(
"Accessibility Settings",
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))
color = if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
},
colors = TopAppBarDefaults.topAppBarColors(
modifier = Modifier
.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha =
if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
})
.drawBehind {
mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (verticalScrollState.value > 60.dp.value * density) {
drawLine(
if (darkMode) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
)
)
},
containerColor = backgroundColor
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
.verticalScroll(rememberScrollState()),
.verticalScroll(verticalScrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Retry Button if needed
if (!isConnected && showRetryButton) {
Button(
onClick = onRetry,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF007AFF)
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = remember { mutableStateOf(false) }
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val eq = remember { mutableStateOf(FloatArray(8)) }
// Flag to prevent sending default settings to device while we are loading device state
val initialLoadComplete = remember { mutableStateOf(false) }
// Ensure we actually read device properties before allowing writes.
// Try up to 3 times silently; mark success only if parse succeeds.
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableStateOf(0) }
// Populate a single stored representation for convenience (kept for debug/logging)
val transparencySettings = remember {
mutableStateOf(
TransparencySettings(
enabled = enabled.value,
leftEQ = eq.value,
rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue
)
) {
Text("Retry Connection")
)
}
LaunchedEffect(enabled.value, amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, eq.value, initialLoadComplete.value, initialReadSucceeded.value) {
// Do not send updates until we have populated UI from the device
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
// Do not send until we've successfully read the device properties at least once.
if (!initialReadSucceeded.value) {
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
return@LaunchedEffect
}
transparencySettings.value = TransparencySettings(
enabled = enabled.value,
leftEQ = eq.value,
rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue
)
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
sendTransparencySettings(attManager, transparencySettings.value)
}
// Move initial connect / read here so we can populate the UI state variables above.
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
attManager.connect()
while (attManager.socket?.isConnected != true) {
delay(100)
}
var parsedSettings: TransparencySettings? = null
// Try up to 3 read attempts silently
for (attempt in 1..3) {
initialReadAttempts.value = attempt
try {
val data = attManager.read(0x18)
parsedSettings = parseTransparencySettingsResponse(data = data)
if (parsedSettings != null) {
Log.d(TAG, "Parsed settings on attempt $attempt")
break
} else {
Log.d(TAG, "Parsing returned null on attempt $attempt")
}
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial transparency settings: $parsedSettings")
// Populate UI states from device values without triggering a send (initialReadSucceeded is set below)
enabled.value = parsedSettings.enabled
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
eq.value = parsedSettings.leftEQ.copyOf()
initialReadSucceeded.value = true
} else {
Log.d(TAG, "Failed to read/parse initial transparency settings after ${initialReadAttempts.value} attempts")
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
// mark load complete (UI may be editable), but writes remain blocked until a successful read
initialLoadComplete.value = true
}
}
// 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
) {
AccessibilityToggle(
text = "Transparency Mode",
mutableState = enabled,
independent = true
)
Text(
"Single Mode",
text = stringResource(R.string.customize_transparency_mode_description),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor,
fontFamily = androidx.compose.ui.text.font.FontFamily(Font(R.font.sf_pro))
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
),
modifier = Modifier
.padding(horizontal = 2.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Customize Transparency Mode".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
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)
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(8.dp)
) {
AccessibilitySlider(
label = "Amplification",
value = amplification.value,
valueRange = 0f..1f,
value = amplificationSliderValue.floatValue,
onValueChange = {
amplification.value = it
val amp = it
val bal = balance.value
leftAmplification.value = amp * (1 + bal)
rightAmplification.value = amp * (2 - bal)
onSettingsChanged()
amplificationSliderValue.floatValue = it
},
valueRange = 0f..1f
)
AccessibilitySlider(
label = "Balance",
value = balance.value,
valueRange = 0f..1f,
value = balanceSliderValue.floatValue,
onValueChange = {
balance.value = it
val amp = amplification.value
val bal = it
leftAmplification.value = amp * (1 + bal)
rightAmplification.value = amp * (2 - bal)
onSettingsChanged()
balanceSliderValue.floatValue = it
},
valueRange = 0f..1f
)
AccessibilitySlider(
label = "Tone",
valueRange = 0f..1f,
value = toneSliderValue.floatValue,
onValueChange = {
toneSliderValue.floatValue = it
},
)
AccessibilitySlider(
label = "Ambient Noise Reduction",
valueRange = 0f..1f,
value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it
},
)
AccessibilityToggle(
text = "Conversation Boost",
mutableState = conversationBoostEnabled
)
}
}
// Single Bud Settings Card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
shape = RoundedCornerShape(14.dp)
) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "AUDIO",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(top = 2.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
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",
text = "Tone Volume",
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()
}
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Light,
color = textColor
),
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxWidth()
)
ToneVolumeSlider()
SinglePodANCSwitch()
VolumeControlSwitch()
LoudSoundReductionSwitch(attManager)
}
Spacer(modifier = Modifier.height(2.dp))
NavigationButton(
to = "eq",
name = "Equalizer Settings",
navController = navController
Text(
text = "Equalizer".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
}
}
} else {
// Left Bud Card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
shape = RoundedCornerShape(14.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween
) {
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
)
for (i in 0 until 8) {
val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
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)
.height(32.dp)
) {
Text(
"Right Bud",
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
text = String.format("%.2f", eqValue.floatValue),
fontSize = 12.sp,
color = textColor,
fontFamily = androidx.compose.ui.text.font.FontFamily(
Font(R.font.sf_pro)
)
)
modifier = Modifier.padding(bottom = 4.dp)
)
AccessibilitySlider(
label = "Amplification",
value = rightAmplification.value,
onValueChange = {
rightAmplification.value = it
onSettingsChanged()
Slider(
value = eqValue.floatValue,
onValueChange = { newVal ->
eqValue.floatValue = newVal
val newEQ = eq.value.copyOf()
newEQ[i] = eqValue.floatValue
eq.value = newEQ
},
valueRange = 0f..2f
valueRange = 0f..1f,
modifier = Modifier
.fillMaxWidth(0.9f)
)
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,
text = "Band ${i + 1}",
fontSize = 12.sp,
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
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@Composable
fun AccessibilityToggle(text: String, mutableState: MutableState<Boolean>, independent: Boolean = false) {
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))
val textColor = if (isDarkTheme) Color.White else Color.Black
val boxPaddings = if (independent) 2.dp else 4.dp
val cornerShape = if (independent) RoundedCornerShape(14.dp) else RoundedCornerShape(0.dp)
Box (
modifier = Modifier
.padding(vertical = boxPaddings)
.background(animatedBackgroundColor, cornerShape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
mutableState.value = !mutableState.value
}
)
},
)
{
val rowHeight = if (independent) 55.dp else 50.dp
val rowPadding = if (independent) 12.dp else 4.dp
Row(
modifier = Modifier
.fillMaxWidth()
.height(rowHeight)
.padding(horizontal = rowPadding),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = text,
modifier = Modifier.weight(1f),
fontSize = 16.sp,
color = textColor
)
StyledSwitch(
checked = mutableState.value,
onCheckedChange = {
mutableState.value = it
},
)
}
}
}
data class TransparencySettings (
val enabled: Boolean,
val leftEQ: FloatArray,
val rightEQ: FloatArray,
val leftAmplification: Float,
val rightAmplification: Float,
val leftTone: Float,
val rightTone: Float,
val leftConversationBoost: Boolean,
val rightConversationBoost: Boolean,
val leftAmbientNoiseReduction: Float,
val rightAmbientNoiseReduction: Float,
val netAmplification: Float,
val balance: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TransparencySettings
if (enabled != other.enabled) return false
if (leftAmplification != other.leftAmplification) return false
if (rightAmplification != other.rightAmplification) return false
if (leftTone != other.leftTone) return false
if (rightTone != other.rightTone) return false
if (leftConversationBoost != other.leftConversationBoost) return false
if (rightConversationBoost != other.rightConversationBoost) return false
if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false
if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false
if (!leftEQ.contentEquals(other.leftEQ)) return false
if (!rightEQ.contentEquals(other.rightEQ)) return false
return true
}
override fun hashCode(): Int {
var result = enabled.hashCode()
result = 31 * result + leftAmplification.hashCode()
result = 31 * result + rightAmplification.hashCode()
result = 31 * result + leftTone.hashCode()
result = 31 * result + rightTone.hashCode()
result = 31 * result + leftConversationBoost.hashCode()
result = 31 * result + rightConversationBoost.hashCode()
result = 31 * result + leftAmbientNoiseReduction.hashCode()
result = 31 * result + rightAmbientNoiseReduction.hashCode()
result = 31 * result + leftEQ.contentHashCode()
result = 31 * result + rightEQ.contentHashCode()
return result
}
}
private fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
val settingsData = data.copyOfRange(1, data.size)
val buffer = ByteBuffer.wrap(settingsData).order(ByteOrder.LITTLE_ENDIAN)
val enabled = buffer.float
Log.d(TAG, "Parsed enabled: $enabled")
// Left bud
val leftEQ = FloatArray(8)
for (i in 0..7) {
leftEQ[i] = buffer.float
Log.d(TAG, "Parsed left EQ${i+1}: ${leftEQ[i]}")
}
val leftAmplification = buffer.float
Log.d(TAG, "Parsed left amplification: $leftAmplification")
val leftTone = buffer.float
Log.d(TAG, "Parsed left tone: $leftTone")
val leftConvFloat = buffer.float
val leftConversationBoost = leftConvFloat > 0.5f
Log.d(TAG, "Parsed left conversation boost: $leftConvFloat ($leftConversationBoost)")
val leftAmbientNoiseReduction = buffer.float
Log.d(TAG, "Parsed left ambient noise reduction: $leftAmbientNoiseReduction")
val rightEQ = FloatArray(8)
for (i in 0..7) {
rightEQ[i] = buffer.float
Log.d(TAG, "Parsed right EQ${i+1}: ${rightEQ[i]}")
}
val rightAmplification = buffer.float
Log.d(TAG, "Parsed right amplification: $rightAmplification")
val rightTone = buffer.float
Log.d(TAG, "Parsed right tone: $rightTone")
val rightConvFloat = buffer.float
val rightConversationBoost = rightConvFloat > 0.5f
Log.d(TAG, "Parsed right conversation boost: $rightConvFloat ($rightConversationBoost)")
val rightAmbientNoiseReduction = buffer.float
Log.d(TAG, "Parsed right ambient noise reduction: $rightAmbientNoiseReduction")
Log.d(TAG, "Settings parsed successfully")
val avg = (leftAmplification + rightAmplification) / 2
val amplification = avg.coerceIn(0f, 1f)
val diff = rightAmplification - leftAmplification
val balance = (0.5f + diff / (2 * avg)).coerceIn(0f, 1f)
return TransparencySettings(
enabled = enabled > 0.5f,
leftEQ = leftEQ,
rightEQ = rightEQ,
leftAmplification = leftAmplification,
rightAmplification = rightAmplification,
leftTone = leftTone,
rightTone = rightTone,
leftConversationBoost = leftConversationBoost,
rightConversationBoost = rightConversationBoost,
leftAmbientNoiseReduction = leftAmbientNoiseReduction,
rightAmbientNoiseReduction = rightAmbientNoiseReduction,
netAmplification = amplification,
balance = balance
)
}
private fun sendTransparencySettings(
attManager: ATTManager,
transparencySettings: TransparencySettings
) {
debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val buffer = ByteBuffer.allocate(100).order(ByteOrder.LITTLE_ENDIAN) // 100 data bytes
Log.d(TAG,
"Sending settings: $transparencySettings"
)
buffer.putFloat(if (transparencySettings.enabled) 1.0f else 0.0f)
for (eq in transparencySettings.leftEQ) {
buffer.putFloat(eq)
}
buffer.putFloat(transparencySettings.leftAmplification)
buffer.putFloat(transparencySettings.leftTone)
buffer.putFloat(if (transparencySettings.leftConversationBoost) 1.0f else 0.0f)
buffer.putFloat(transparencySettings.leftAmbientNoiseReduction)
for (eq in transparencySettings.rightEQ) {
buffer.putFloat(eq)
}
buffer.putFloat(transparencySettings.rightAmplification)
buffer.putFloat(transparencySettings.rightTone)
buffer.putFloat(if (transparencySettings.rightConversationBoost) 1.0f else 0.0f)
buffer.putFloat(transparencySettings.rightAmbientNoiseReduction)
val data = buffer.array()
attManager.write(
0x18,
value = data
)
} catch (e: IOException) {
e.printStackTrace()
}
}
}

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>