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