diff --git a/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt
index 27e4679..bb9c9d8 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/CustomDeviceActivity.kt
@@ -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
- }
- }
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt
index 42c942b..ac870f2 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt
@@ -154,9 +154,6 @@ fun AccessibilitySettings() {
},
textColor = textColor
)
-
- SinglePodANCSwitch()
- VolumeControlSwitch()
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt
index 8c2aa7d..668bdad 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt
@@ -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,
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt
new file mode 100644
index 0000000..1eecf4d
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt
@@ -0,0 +1,128 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+@file:OptIn(ExperimentalEncodingApi::class)
+
+package me.kavishdevar.librepods.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
+ },
+ )
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt
index 28e71bf..3b4bbf3 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt
@@ -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
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt
index 38e190e..c9db361 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt
@@ -77,7 +77,7 @@ fun ToneVolumeSlider() {
Row(
modifier = Modifier
- .fillMaxWidth(),
+ .fillMaxWidth(0.95f),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
index f6d1fc2..fb94720 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
@@ -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,
- leftTone: MutableState,
- leftAmbientNoiseReduction: MutableState,
- leftConversationBoost: MutableState,
- rightAmplification: MutableState,
- rightTone: MutableState,
- rightAmbientNoiseReduction: MutableState,
- rightConversationBoost: MutableState,
- singleMode: MutableState,
- amplification: MutableState,
- balance: MutableState,
- showRetryButton: Boolean,
- onRetry: () -> Unit,
- onSettingsChanged: () -> Unit
-) {
+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)
+ AccessibilityToggle(
+ text = "Transparency Mode",
+ mutableState = enabled,
+ independent = true
+ )
+ Text(
+ text = stringResource(R.string.customize_transparency_mode_description),
+ style = TextStyle(
+ 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)
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(backgroundColor, RoundedCornerShape(14.dp))
+ .padding(8.dp)
) {
- Row(
+ AccessibilitySlider(
+ label = "Amplification",
+ valueRange = 0f..1f,
+ value = amplificationSliderValue.floatValue,
+ onValueChange = {
+ amplificationSliderValue.floatValue = it
+ },
+ )
+ AccessibilitySlider(
+ label = "Balance",
+ valueRange = 0f..1f,
+ value = balanceSliderValue.floatValue,
+ onValueChange = {
+ balanceSliderValue.floatValue = it
+ },
+ )
+ 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
+ )
+ }
+ 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()
+ .background(backgroundColor, RoundedCornerShape(14.dp))
+ .padding(top = 2.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "Tone Volume",
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontFamily = FontFamily(Font(R.font.sf_pro)),
+ fontWeight = FontWeight.Light,
+ color = textColor
+ ),
modifier = Modifier
+ .padding(horizontal = 8.dp, vertical = 4.dp)
.fillMaxWidth()
- .padding(16.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- "Single Mode",
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
+ )
+ ToneVolumeSlider()
+ SinglePodANCSwitch()
+ VolumeControlSwitch()
+ LoudSoundReductionSwitch(attManager)
+ }
+ Spacer(modifier = Modifier.height(2.dp))
+
+ 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)
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(backgroundColor, RoundedCornerShape(14.dp))
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ for (i in 0 until 8) {
+ val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(32.dp)
+ ) {
+ Text(
+ 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)
)
- )
- 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(
+ Slider(
+ value = eqValue.floatValue,
+ onValueChange = { newVal ->
+ eqValue.floatValue = newVal
+ val newEQ = eq.value.copyOf()
+ newEQ[i] = eqValue.floatValue
+ eq.value = newEQ
+ },
+ valueRange = 0f..1f,
modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- AccessibilitySlider(
- label = "Amplification",
- value = amplification.value,
- onValueChange = {
- amplification.value = it
- val amp = it
- val bal = balance.value
- leftAmplification.value = amp * (1 + bal)
- rightAmplification.value = amp * (2 - bal)
- onSettingsChanged()
- },
- valueRange = 0f..1f
- )
+ .fillMaxWidth(0.9f)
+ )
- AccessibilitySlider(
- label = "Balance",
- value = balance.value,
- onValueChange = {
- balance.value = it
- val amp = amplification.value
- val bal = it
- leftAmplification.value = amp * (1 + bal)
- rightAmplification.value = amp * (2 - bal)
- onSettingsChanged()
- },
- valueRange = 0f..1f
- )
- }
- }
-
- // Single Bud Settings Card
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
- shape = RoundedCornerShape(14.dp)
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- "Bud Settings",
- style = TextStyle(
- fontSize = 18.sp,
- fontWeight = FontWeight.Medium,
- color = textColor,
- fontFamily = androidx.compose.ui.text.font.FontFamily(
- Font(R.font.sf_pro)
- )
- )
- )
-
- AccessibilitySlider(
- label = "Tone",
- value = leftTone.value,
- onValueChange = {
- leftTone.value = it
- rightTone.value = it
- onSettingsChanged()
- },
- valueRange = 0f..2f
- )
-
- AccessibilitySlider(
- label = "Ambient Noise Reduction",
- value = leftAmbientNoiseReduction.value,
- onValueChange = {
- leftAmbientNoiseReduction.value = it
- rightAmbientNoiseReduction.value = it
- onSettingsChanged()
- },
- valueRange = 0f..1f
- )
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- "Conversation Boost",
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
- color = textColor,
- fontFamily = androidx.compose.ui.text.font.FontFamily(
- Font(R.font.sf_pro)
- )
- )
- )
- StyledSwitch(
- checked = leftConversationBoost.value,
- onCheckedChange = {
- leftConversationBoost.value = it
- rightConversationBoost.value = it
- onSettingsChanged()
- }
- )
- }
-
- NavigationButton(
- to = "eq",
- name = "Equalizer Settings",
- navController = navController
- )
- }
- }
- } else {
- // Left Bud Card
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
- shape = RoundedCornerShape(14.dp)
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- "Left Bud",
- style = TextStyle(
- fontSize = 18.sp,
- fontWeight = FontWeight.Medium,
- color = textColor,
- fontFamily = androidx.compose.ui.text.font.FontFamily(
- Font(R.font.sf_pro)
- )
- )
- )
-
- AccessibilitySlider(
- label = "Amplification",
- value = leftAmplification.value,
- onValueChange = {
- leftAmplification.value = it
- onSettingsChanged()
- },
- valueRange = 0f..2f
- )
-
- AccessibilitySlider(
- label = "Tone",
- value = leftTone.value,
- onValueChange = {
- leftTone.value = it
- onSettingsChanged()
- },
- valueRange = 0f..2f
- )
-
- AccessibilitySlider(
- label = "Ambient Noise Reduction",
- value = leftAmbientNoiseReduction.value,
- onValueChange = {
- leftAmbientNoiseReduction.value = it
- onSettingsChanged()
- },
- valueRange = 0f..1f
- )
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- "Conversation Boost",
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
- color = textColor,
- fontFamily = androidx.compose.ui.text.font.FontFamily(
- Font(R.font.sf_pro)
- )
- )
- )
- StyledSwitch(
- checked = leftConversationBoost.value,
- onCheckedChange = {
- leftConversationBoost.value = it
- onSettingsChanged()
- }
- )
- }
-
- NavigationButton(
- to = "eq",
- name = "Equalizer Settings",
- navController = navController
- )
- }
- }
-
- // Right Bud Card
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
- shape = RoundedCornerShape(14.dp)
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- "Right Bud",
- style = TextStyle(
- fontSize = 18.sp,
- fontWeight = FontWeight.Medium,
- color = textColor,
- fontFamily = androidx.compose.ui.text.font.FontFamily(
- Font(R.font.sf_pro)
- )
- )
- )
-
- AccessibilitySlider(
- label = "Amplification",
- value = rightAmplification.value,
- onValueChange = {
- rightAmplification.value = it
- onSettingsChanged()
- },
- valueRange = 0f..2f
- )
-
- AccessibilitySlider(
- label = "Tone",
- value = rightTone.value,
- onValueChange = {
- rightTone.value = it
- onSettingsChanged()
- },
- valueRange = 0f..2f
- )
-
- AccessibilitySlider(
- label = "Ambient Noise Reduction",
- value = rightAmbientNoiseReduction.value,
- onValueChange = {
- rightAmbientNoiseReduction.value = it
- onSettingsChanged()
- },
- valueRange = 0f..1f
- )
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- "Conversation Boost",
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
- color = textColor,
- fontFamily = androidx.compose.ui.text.font.FontFamily(
- Font(R.font.sf_pro)
- )
- )
- )
- StyledSwitch(
- checked = rightConversationBoost.value,
- onCheckedChange = {
- rightConversationBoost.value = it
- onSettingsChanged()
- }
- )
- }
-
- NavigationButton(
- to = "eq",
- name = "Equalizer Settings",
- navController = navController
- )
- }
+ Text(
+ text = "Band ${i + 1}",
+ fontSize = 12.sp,
+ color = textColor,
+ modifier = Modifier.padding(top = 4.dp)
+ )
}
}
}
-
Spacer(modifier = Modifier.height(16.dp))
}
}
}
+
+
+@Composable
+fun AccessibilityToggle(text: String, mutableState: MutableState, 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()
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
index c6e68ec..99df81f 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
@@ -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)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/EqualizerSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/EqualizerSettingsScreen.kt
deleted file mode 100644
index 5b77ebb..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/EqualizerSettingsScreen.kt
+++ /dev/null
@@ -1,300 +0,0 @@
-/*
- * LibrePods - AirPods liberated from Apple’s ecosystem
- *
- * Copyright (C) 2025 LibrePods contributors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, either version 3 of the License.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-@file:OptIn(ExperimentalEncodingApi::class)
-
-package me.kavishdevar.librepods.screens
-
-import androidx.compose.foundation.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,
- rightEQ: MutableState,
- singleMode: MutableState,
- onEQChanged: () -> Unit,
- phoneMediaEQ: MutableState
-) {
- val isDarkTheme = isSystemInDarkTheme()
- val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
- val cardBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- val textColor = if (isDarkTheme) Color.White else Color.Black
-
- val aacpManager = ServiceManager.getService()!!.aacpManager
-
- val debounceJob = remember { mutableStateOf(null) }
-
- Scaffold(
- topBar = {
- TopAppBar(
- title = {
- Text(
- "Equalizer Settings",
- style = TextStyle(
- fontSize = 20.sp,
- fontWeight = FontWeight.Medium,
- color = textColor,
- fontFamily = androidx.compose.ui.text.font.FontFamily(
- Font(R.font.sf_pro)
- )
- )
- )
- },
- navigationIcon = {
- TextButton(
- onClick = { navController.popBackStack() },
- shape = RoundedCornerShape(8.dp),
- ) {
- Icon(
- Icons.AutoMirrored.Filled.KeyboardArrowLeft,
- contentDescription = "Back",
- tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
- modifier = Modifier.scale(1.5f)
- )
- Text(
- "Accessibility",
- style = TextStyle(
- fontSize = 18.sp,
- fontWeight = FontWeight.Medium,
- color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
- fontFamily = androidx.compose.ui.text.font.FontFamily(
- Font(R.font.sf_pro)
- )
- ),
- )
- }
- },
- colors = TopAppBarDefaults.topAppBarColors(
- containerColor = Color.Transparent
- )
- )
- },
- containerColor = backgroundColor
- ) { paddingValues ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(paddingValues)
- .padding(horizontal = 16.dp)
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- if (singleMode.value) {
- // Single Bud EQ Card
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
- shape = RoundedCornerShape(14.dp)
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- "Equalizer",
- style = TextStyle(
- fontSize = 18.sp,
- fontWeight = FontWeight.Medium,
- color = textColor,
- fontFamily = androidx.compose.ui.text.font.FontFamily(
- Font(R.font.sf_pro)
- )
- )
- )
-
- for (i in 0..7) {
- AccessibilitySlider(
- label = "EQ${i + 1}",
- value = leftEQ.value[i],
- onValueChange = {
- leftEQ.value = leftEQ.value.copyOf().apply { this[i] = it }
- rightEQ.value = rightEQ.value.copyOf().apply { this[i] = it } // Sync to right
- onEQChanged()
- },
- valueRange = 0f..100f
- )
- }
- }
- }
- } else {
- // Left Bud EQ Card
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
- shape = RoundedCornerShape(14.dp)
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- "Left Bud Equalizer",
- style = TextStyle(
- fontSize = 18.sp,
- fontWeight = FontWeight.Medium,
- color = textColor,
- fontFamily = androidx.compose.ui.text.font.FontFamily(
- Font(R.font.sf_pro)
- )
- )
- )
-
- for (i in 0..7) {
- AccessibilitySlider(
- label = "EQ${i + 1}",
- value = leftEQ.value[i],
- onValueChange = {
- leftEQ.value = leftEQ.value.copyOf().apply { this[i] = it }
- onEQChanged()
- },
- valueRange = 0f..100f
- )
- }
- }
- }
-
- // Right Bud EQ Card
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
- shape = RoundedCornerShape(14.dp)
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- "Right Bud Equalizer",
- style = TextStyle(
- fontSize = 18.sp,
- fontWeight = FontWeight.Medium,
- color = textColor,
- fontFamily = androidx.compose.ui.text.font.FontFamily(
- Font(R.font.sf_pro)
- )
- )
- )
-
- for (i in 0..7) {
- AccessibilitySlider(
- label = "EQ${i + 1}",
- value = rightEQ.value[i],
- onValueChange = {
- rightEQ.value = rightEQ.value.copyOf().apply { this[i] = it }
- onEQChanged()
- },
- valueRange = 0f..100f
- )
- }
- }
- }
- }
-
- // Phone and Media EQ Card
- Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = cardBackgroundColor),
- shape = RoundedCornerShape(14.dp)
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Text(
- "Phone and Media Equalizer",
- style = TextStyle(
- fontSize = 18.sp,
- fontWeight = FontWeight.Medium,
- color = textColor,
- fontFamily = androidx.compose.ui.text.font.FontFamily(
- Font(R.font.sf_pro)
- )
- )
- )
-
- for (i in 0..7) {
- AccessibilitySlider(
- label = "EQ${i + 1}",
- value = phoneMediaEQ.value[i],
- onValueChange = {
- phoneMediaEQ.value = phoneMediaEQ.value.copyOf().apply { this[i] = it }
- debounceJob.value?.cancel()
- debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
- delay(100)
- aacpManager.sendPhoneMediaEQ(phoneMediaEQ.value)
- }
- },
- valueRange = 0f..100f
- )
- }
- }
- }
- }
- }
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
index 71374d6..b11d1dc 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
@@ -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({
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
index 342db7a..9141285 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
@@ -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()
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt
new file mode 100644
index 0000000..2939c33
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt
@@ -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)
+ }
+}
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 25458ed..a520e9d 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1,4 +1,4 @@
-
+
LibrePods
Liberate your AirPods from Apple\'s ecosystem.
GATT Testing
@@ -82,4 +82,7 @@
Starting media playback
Your phone starts playing media
Undo
+ You can customize Transparency mode for your AirPods Pro to help you hear what\'s around you.
+ AirPods Pro automatically reduce your exposure to loud environmental noises when in Transparency and Adaptive mode.
+ Loud Sound Reduction