From a9b78efd8044ca30e5d2f0a47baf2d31564b7314 Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Thu, 16 Oct 2025 13:11:21 +0530 Subject: [PATCH] android: implement setting hearing test results --- .../me/kavishdevar/librepods/MainActivity.kt | 29 +- .../librepods/composables/StyledToggle.kt | 25 +- .../screens/AirPodsSettingsScreen.kt | 100 ++--- .../librepods/screens/AppSettingsScreen.kt | 3 +- .../librepods/screens/HeadTrackingScreen.kt | 3 +- .../screens/HearingAidAdjustmentsScreen.kt | 164 +------- .../librepods/screens/HearingAidScreen.kt | 33 +- .../librepods/screens/Onboarding.kt | 7 +- .../screens/TroubleshootingScreen.kt | 3 +- .../screens/UpdateHearingTestScreen.kt | 359 ++++++++++++++++++ .../kavishdevar/librepods/utils/BLEManager.kt | 3 +- .../librepods/utils/HearingAidEnums.kt | 190 +++++++++ .../app/src/main/res/layout/popup_window.xml | 2 +- android/app/src/main/res/values/strings.xml | 9 + 14 files changed, 670 insertions(+), 260 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/screens/UpdateHearingTestScreen.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/utils/HearingAidEnums.kt diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index 03df7ea..5c2a2bb 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -27,7 +27,6 @@ import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.Intent import android.content.ServiceConnection -import android.content.SharedPreferences import android.net.Uri import android.os.Build import android.os.Bundle @@ -74,11 +73,9 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -94,6 +91,8 @@ import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font @@ -111,11 +110,11 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.MultiplePermissionsState import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberMultiplePermissionsState -import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.composables.StyledIconButton +import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen import me.kavishdevar.librepods.screens.AirPodsSettingsScreen @@ -131,9 +130,9 @@ import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen import me.kavishdevar.librepods.screens.RenameScreen import me.kavishdevar.librepods.screens.TransparencySettingsScreen import me.kavishdevar.librepods.screens.TroubleshootingScreen +import me.kavishdevar.librepods.screens.UpdateHearingTestScreen import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.ui.theme.LibrePodsTheme -import me.kavishdevar.librepods.utils.CrossDevice import me.kavishdevar.librepods.utils.RadareOffsetFinder import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -310,7 +309,7 @@ fun Main() { val context = LocalContext.current val navController = rememberNavController() - + Box ( modifier = Modifier .fillMaxSize() @@ -406,6 +405,9 @@ fun Main() { composable("open_source_licenses") { OpenSourceLicensesScreen(navController) } + composable("update_hearing_test") { + UpdateHearingTestScreen(navController) + } } } @@ -424,7 +426,10 @@ fun Main() { exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)), modifier = Modifier .align(Alignment.TopStart) - .padding(start = 8.dp, top = (context.resources.configuration.screenHeightDp.dp * 0.05f).value.dp) + .padding( + start = 8.dp, + top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp + ) ) { StyledIconButton( onClick = { navController.popBackStack() }, @@ -556,7 +561,7 @@ fun PermissionsScreen( Spacer(modifier = Modifier.height(8.dp)) Text( - text = "The following permissions are required to use the app. Please grant them to continue.", + text = stringResource(R.string.permissions_required), style = TextStyle( fontSize = 16.sp, fontWeight = FontWeight.Normal, @@ -733,7 +738,11 @@ fun PermissionCard( modifier = Modifier .size(40.dp) .clip(RoundedCornerShape(8.dp)) - .background(if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(alpha = 0.15f)), + .background( + if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy( + alpha = 0.15f + ) + ), contentAlignment = Alignment.Center ) { Icon( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt index 2dd21d9..2afd64b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt @@ -59,7 +59,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.edit -import kotlinx.coroutines.delay import me.kavishdevar.librepods.R import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.utils.AACPManager @@ -473,30 +472,12 @@ fun StyledToggle( val attManager = ServiceManager.getService()?.attManager ?: return val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black - var checked by remember { mutableStateOf(false) } + val checkedValue = attManager.read(attHandle).getOrNull(0)?.toInt() + var checked by remember { mutableStateOf(checkedValue !=0) } var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) - LaunchedEffect(Unit) { - attManager.enableNotifications(attHandle) - - var parsed = false - for (attempt in 1..3) { - try { - val data = attManager.read(attHandle) - checked = data[0].toInt() != 0 - Log.d("StyledToggle", "Read attempt $attempt for $label: enabled=$checked") - parsed = true - break - } catch (e: Exception) { - Log.w("StyledToggle", "Read attempt $attempt for $label failed: ${e.message}") - } - delay(200) - } - if (!parsed) { - Log.d("StyledToggle", "Failed to read state for $label after 3 attempts") - } - } + attManager.enableNotifications(attHandle) if (sharedPreferenceKey != null && sharedPreferences != null) { checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked) 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 f891f05..00ca9e9 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 me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.RadareOffsetFinder import kotlin.io.encoding.ExperimentalEncodingApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @@ -196,8 +197,19 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, } } } + + LaunchedEffect(service) { + service.let { + it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { + putParcelableArrayListExtra("data", ArrayList(it.getBattery())) + }) + it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply { + putExtra("data", it.getANC()) + }) + } + } + val darkMode = isSystemInDarkTheme() - val backdrop = rememberLayerBackdrop() StyledScaffold( title = deviceName.text, actionButtons = listOf( @@ -218,26 +230,14 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, .fillMaxSize() .hazeSource(hazeState) .padding(horizontal = 16.dp) - .layerBackdrop(backdrop) ) { - item { Spacer(modifier = Modifier.height(spacerHeight)) } - item { - LaunchedEffect(service) { - service.let { - it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { - putParcelableArrayListExtra("data", ArrayList(it.getBattery())) - }) - it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply { - putExtra("data", it.getANC()) - }) - } - } - + item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) } + item(key = "battery") { BatteryView(service = service) } - item { Spacer(modifier = Modifier.height(32.dp)) } + item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) } - item { + item(key = "name") { NavigationButton( to = "rename", name = stringResource(R.string.name), @@ -246,49 +246,55 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, independent = true ) } + val actAsAppleDeviceHookEnabled = RadareOffsetFinder.isSdpOffsetAvailable() + if (actAsAppleDeviceHookEnabled) { + item(key = "spacer_hearing_aid") { Spacer(modifier = Modifier.height(32.dp)) } + item(key = "hearing_aid") { + NavigationButton( + to = "hearing_aid", + name = stringResource(R.string.hearing_aid), + navController = navController + ) + } + } - item { Spacer(modifier = Modifier.height(32.dp)) } - item { NavigationButton(to = "hearing_aid", name = stringResource(R.string.hearing_aid), navController = navController) } + item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "noise_control") { NoiseControlSettings(service = service) } - item { Spacer(modifier = Modifier.height(16.dp)) } - item { NoiseControlSettings(service = service) } + item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "press_hold") { PressAndHoldSettings(navController = navController) } - item { Spacer(modifier = Modifier.height(16.dp)) } - item { PressAndHoldSettings(navController = navController) } + item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "call_control") { CallControlSettings(hazeState = hazeState) } - item { Spacer(modifier = Modifier.height(16.dp)) } - item { CallControlSettings(hazeState = hazeState) } + item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "camera_control") { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) } - item { Spacer(modifier = Modifier.height(16.dp)) } - item { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) } + item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "audio") { AudioSettings(navController = navController) } - item { Spacer(modifier = Modifier.height(16.dp)) } - item { AudioSettings(navController = navController) } + item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "connection") { ConnectionSettings() } - item { Spacer(modifier = Modifier.height(16.dp)) } - item { ConnectionSettings() } + item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "microphone") { MicrophoneSettings(hazeState) } - item { Spacer(modifier = Modifier.height(16.dp)) } - item { MicrophoneSettings(hazeState) } - - item { Spacer(modifier = Modifier.height(16.dp)) } - item { + item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "sleep_detection") { StyledToggle( label = stringResource(R.string.sleep_detection), controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG ) } - item { Spacer(modifier = Modifier.height(16.dp)) } - item { - NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off)) - } + item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "head_tracking") { NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off)) } - item { Spacer(modifier = Modifier.height(16.dp)) } - item { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) } + item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "accessibility") { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) } - item { Spacer(modifier = Modifier.height(16.dp)) } - item { + item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "off_listening") { StyledToggle( label = stringResource(R.string.off_listening_mode), controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION, @@ -298,9 +304,9 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, // an about card- everything but the version number is unknown - will add later if i find out - item { Spacer(modifier = Modifier.height(16.dp)) } - item { NavigationButton("debug", "Debug", navController) } - item { Spacer(Modifier.height(24.dp)) } + item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "debug") { NavigationButton("debug", "Debug", navController) } + item(key = "spacer_bottom") { Spacer(Modifier.height(24.dp)) } } } else { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt index 3e1a7da..5dc2714 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt @@ -80,7 +80,6 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.launch import me.kavishdevar.librepods.R import me.kavishdevar.librepods.composables.NavigationButton -import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledToggle @@ -292,7 +291,7 @@ fun AppSettingsScreen(navController: NavController) { ) Spacer(modifier = Modifier.height(16.dp)) - + NavigationButton( to = "", title = stringResource(R.string.camera_control), diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt index 2a4762a..6dcf521 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt @@ -221,9 +221,10 @@ fun HeadTrackingScreen(navController: NavController) { } } } + val gestureTextValue = stringResource(R.string.shake_your_head_or_nod) StyledButton( onClick = { - gestureText = "Shake your head or nod!" + gestureText = gestureTextValue coroutineScope.launch { val accepted = ServiceManager.getService()?.testHeadGestures() ?: false gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected." diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt index 25ea238..34cb875 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -46,26 +47,22 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource 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.StyledIconButton import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.ATTHandles -import me.kavishdevar.librepods.utils.ATTManager +import me.kavishdevar.librepods.utils.HearingAidSettings +import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse +import me.kavishdevar.librepods.utils.sendHearingAidSettings import java.io.IOException -import java.nio.ByteBuffer -import java.nio.ByteOrder import kotlin.io.encoding.ExperimentalEncodingApi -private var debounceJob: Job? = null +private var debounceJob: MutableState = mutableStateOf(null) private const val TAG = "HearingAidAdjustments" @SuppressLint("DefaultLocale") @@ -73,7 +70,7 @@ private const val TAG = "HearingAidAdjustments" @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @Composable fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) { - val isDarkTheme = isSystemInDarkTheme() + isSystemInDarkTheme() val verticalScrollState = rememberScrollState() val hazeState = remember { HazeState() } val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available") @@ -210,7 +207,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController ownVoiceAmplification = ownVoiceAmplification.floatValue ) Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") - sendHearingAidSettings(attManager, hearingAidSettings.value) + sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob) } LaunchedEffect(Unit) { @@ -342,150 +339,3 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController } } } - -private data class HearingAidSettings( - 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, - val ownVoiceAmplification: Float -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as HearingAidSettings - - 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 - if (ownVoiceAmplification != other.ownVoiceAmplification) return false - - return true - } - - override fun hashCode(): Int { - var 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() - result = 31 * result + ownVoiceAmplification.hashCode() - return result - } -} - -private fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? { - if (data.size < 104) return null - val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) - - buffer.get() // skip 0x02 - buffer.get() // skip 0x02 - buffer.getShort() // skip 0x60 0x00 - - val leftEQ = FloatArray(8) - for (i in 0..7) { - leftEQ[i] = buffer.float - } - val leftAmplification = buffer.float - val leftTone = buffer.float - val leftConvFloat = buffer.float - val leftConversationBoost = leftConvFloat > 0.5f - val leftAmbientNoiseReduction = buffer.float - - val rightEQ = FloatArray(8) - for (i in 0..7) { - rightEQ[i] = buffer.float - } - val rightAmplification = buffer.float - val rightTone = buffer.float - val rightConvFloat = buffer.float - val rightConversationBoost = rightConvFloat > 0.5f - val rightAmbientNoiseReduction = buffer.float - - val ownVoiceAmplification = buffer.float - - val avg = (leftAmplification + rightAmplification) / 2 - val amplification = avg.coerceIn(-1f, 1f) - val diff = rightAmplification - leftAmplification - val balance = diff.coerceIn(-1f, 1f) - - return HearingAidSettings( - leftEQ = leftEQ, - rightEQ = rightEQ, - leftAmplification = leftAmplification, - rightAmplification = rightAmplification, - leftTone = leftTone, - rightTone = rightTone, - leftConversationBoost = leftConversationBoost, - rightConversationBoost = rightConversationBoost, - leftAmbientNoiseReduction = leftAmbientNoiseReduction, - rightAmbientNoiseReduction = rightAmbientNoiseReduction, - netAmplification = amplification, - balance = balance, - ownVoiceAmplification = ownVoiceAmplification - ) -} - -private fun sendHearingAidSettings( - attManager: ATTManager, - hearingAidSettings: HearingAidSettings -) { - debounceJob?.cancel() - debounceJob = CoroutineScope(Dispatchers.IO).launch { - delay(100) - try { - val currentData = attManager.read(ATTHandles.HEARING_AID) - Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}") - if (currentData.size < 104) { - Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings") - return@launch - } - val buffer = ByteBuffer.wrap(currentData).order(ByteOrder.LITTLE_ENDIAN) - - // for some reason - buffer.put(2, 0x64) - - // Left ear adjustments - buffer.putFloat(36, hearingAidSettings.leftAmplification) - buffer.putFloat(40, hearingAidSettings.leftTone) - buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f) - buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction) - - // Right ear adjustments - buffer.putFloat(84, hearingAidSettings.rightAmplification) - buffer.putFloat(88, hearingAidSettings.rightTone) - buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f) - buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction) - - // Own voice amplification - buffer.putFloat(100, hearingAidSettings.ownVoiceAmplification) - - Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}") - - attManager.write(ATTHandles.HEARING_AID, currentData) - } catch (e: IOException) { - e.printStackTrace() - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt index 47ae845..90e1063 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt @@ -54,16 +54,15 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import me.kavishdevar.librepods.R import me.kavishdevar.librepods.composables.ConfirmationDialog import me.kavishdevar.librepods.composables.NavigationButton -import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.services.ServiceManager @@ -83,7 +82,6 @@ fun HearingAidScreen(navController: NavController) { val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black val verticalScrollState = rememberScrollState() - val hazeState = remember { HazeState() } val snackbarHostState = remember { SnackbarHostState() } val attManager = ServiceManager.getService()?.attManager ?: return @@ -102,7 +100,7 @@ fun HearingAidScreen(navController: NavController) { StyledScaffold( title = stringResource(R.string.hearing_aid), snackbarHostState = snackbarHostState, - ) { spacerHeight -> + ) { spacerHeight, hazeState -> Column( modifier = Modifier .layerBackdrop(backdrop) @@ -127,9 +125,9 @@ fun HearingAidScreen(navController: NavController) { } } - val mediaAssistEnabled = remember { mutableStateOf(false) } - val adjustMediaEnabled = remember { mutableStateOf(false) } - val adjustPhoneEnabled = remember { mutableStateOf(false) } +// val mediaAssistEnabled = remember { mutableStateOf(false) } +// val adjustMediaEnabled = remember { mutableStateOf(false) } +// val adjustPhoneEnabled = remember { mutableStateOf(false) } LaunchedEffect(Unit) { aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) @@ -154,13 +152,13 @@ fun HearingAidScreen(navController: NavController) { initialLoad.value = false } - fun onAdjustPhoneChange(value: Boolean) { - // TODO - } +// fun onAdjustPhoneChange(value: Boolean) { +// // TODO +// } - fun onAdjustMediaChange(value: Boolean) { - // TODO - } +// fun onAdjustMediaChange(value: Boolean) { +// // TODO +// } Text( text = stringResource(R.string.hearing_aid), @@ -213,6 +211,13 @@ fun HearingAidScreen(navController: NavController) { ) Spacer(modifier = Modifier.height(16.dp)) + NavigationButton( + to = "update_hearing_test", + name = stringResource(R.string.update_hearing_test), + navController, + independent = true + ) + // not implemented yet // StyledToggle( @@ -280,7 +285,7 @@ fun HearingAidScreen(navController: NavController) { } } }, - hazeState = hazeState, + hazeState = rememberHazeState(), // backdrop = backdrop ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt index 717cd57..b8365ce 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt @@ -63,6 +63,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.platform.LocalContext +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 @@ -201,7 +202,7 @@ fun Onboarding(navController: NavController, activityContext: Context) { Spacer(modifier = Modifier.height(24.dp)) Text( - text = "Root Access Required", + text = stringResource(R.string.root_access_required), style = TextStyle( fontSize = 22.sp, fontWeight = FontWeight.Bold, @@ -214,7 +215,7 @@ fun Onboarding(navController: NavController, activityContext: Context) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = "This app needs root access to hook onto the Bluetooth library", + text = stringResource(R.string.this_app_needs_root_access_to_hook_onto_the_bluetooth_library), style = TextStyle( fontSize = 16.sp, fontWeight = FontWeight.Normal, @@ -227,7 +228,7 @@ fun Onboarding(navController: NavController, activityContext: Context) { if (rootCheckFailed) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Root access was denied. Please grant root permissions.", + text = stringResource(R.string.root_access_denied), style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Normal, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt index d3c0d94..e1598ef 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt @@ -94,7 +94,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.utils.LogCollector import java.io.File @@ -370,7 +369,7 @@ fun TroubleshootingScreen(navController: NavController) { Spacer(modifier = Modifier.height(16.dp)) Text( - text = "TROUBLESHOOTING STEPS", + text = stringResource(R.string.troubleshooting_steps), style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Light, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/UpdateHearingTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/UpdateHearingTestScreen.kt new file mode 100644 index 0000000..d13b99f --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/UpdateHearingTestScreen.kt @@ -0,0 +1,359 @@ +/* + * 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 . + */ + +package me.kavishdevar.librepods.screens + +import android.annotation.SuppressLint +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.utils.ATTHandles +import me.kavishdevar.librepods.utils.HearingAidSettings +import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse +import me.kavishdevar.librepods.utils.sendHearingAidSettings +import java.io.IOException +import kotlin.io.encoding.ExperimentalEncodingApi + +private var debounceJob: MutableState = mutableStateOf(null) +private const val TAG = "HearingAidAdjustments" + +@SuppressLint("DefaultLocale") +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@Composable +fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) { + val verticalScrollState = rememberScrollState() + val attManager = ServiceManager.getService()?.attManager + if (attManager == null) { + Text( + text = stringResource(R.string.att_manager_is_null_try_reconnecting), + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + textAlign = TextAlign.Center + ) + return + } + + val aacpManager = remember { ServiceManager.getService()?.aacpManager } + val backdrop = rememberLayerBackdrop() + StyledScaffold( + title = stringResource(R.string.adjustments) + ) { spacerHeight, hazeState -> + Column( + modifier = Modifier + .hazeSource(hazeState) + .fillMaxSize() + .layerBackdrop(backdrop) + .verticalScroll(verticalScrollState) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(spacerHeight)) + + Text( + text = stringResource(R.string.hearing_test_value_instruction), + fontSize = 16.sp, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + + val conversationBoostEnabled = remember { mutableStateOf(false) } + val leftEQ = remember { mutableStateOf(FloatArray(8)) } + val rightEQ = remember { mutableStateOf(FloatArray(8)) } + + val initialLoadComplete = remember { mutableStateOf(false) } + val initialReadSucceeded = remember { mutableStateOf(false) } + val initialReadAttempts = remember { mutableIntStateOf(0) } + + val hearingAidSettings = remember { + mutableStateOf( + HearingAidSettings( + leftEQ = leftEQ.value, + rightEQ = rightEQ.value, + leftAmplification = 0.5f, + rightAmplification = 0.5f, + leftTone = 0.5f, + rightTone = 0.5f, + leftConversationBoost = conversationBoostEnabled.value, + rightConversationBoost = conversationBoostEnabled.value, + leftAmbientNoiseReduction = 0.0f, + rightAmbientNoiseReduction = 0.0f, + netAmplification = 0.5f, + balance = 0.5f, + ownVoiceAmplification = 0.5f + ) + ) + } + + val hearingAidEnabled = remember { + val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } + val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } + mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())) + } + + val hearingAidListener = remember { + object : AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value || + controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) { + val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } + val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } + hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()) + } + } + } + } + + val hearingAidATTListener = remember { + object : (ByteArray) -> Unit { + override fun invoke(value: ByteArray) { + val parsed = parseHearingAidSettingsResponse(value) + if (parsed != null) { + leftEQ.value = parsed.leftEQ.copyOf() + rightEQ.value = parsed.rightEQ.copyOf() + conversationBoostEnabled.value = parsed.leftConversationBoost + Log.d(TAG, "Updated hearing aid settings from notification") + } else { + Log.w(TAG, "Failed to parse hearing aid settings from notification") + } + } + } + } + + LaunchedEffect(Unit) { + aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) + aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener) + } + + DisposableEffect(Unit) { + onDispose { + aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener) + aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener) + attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener) + } + } + + LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value) { + if (!initialLoadComplete.value) { + Log.d(TAG, "Initial device load not complete - skipping send") + return@LaunchedEffect + } + + if (!initialReadSucceeded.value) { + Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds") + return@LaunchedEffect + } + + hearingAidSettings.value = HearingAidSettings( + leftEQ = leftEQ.value, + rightEQ = rightEQ.value, + leftAmplification = 0.5f, + rightAmplification = 0.5f, + leftTone = 0.5f, + rightTone = 0.5f, + leftConversationBoost = conversationBoostEnabled.value, + rightConversationBoost = conversationBoostEnabled.value, + leftAmbientNoiseReduction = 0.0f, + rightAmbientNoiseReduction = 0.0f, + netAmplification = 0.5f, + balance = 0.5f, + ownVoiceAmplification = 0.5f + ) + Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") + sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob) + } + + LaunchedEffect(Unit) { + Log.d(TAG, "Connecting to ATT...") + try { + attManager.enableNotifications(ATTHandles.HEARING_AID) + attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener) + + try { + if (aacpManager != null) { + Log.d(TAG, "Found AACPManager, reading cached EQ data") + val aacpEQ = aacpManager.eqData + if (aacpEQ.isNotEmpty()) { + leftEQ.value = aacpEQ.copyOf() + rightEQ.value = aacpEQ.copyOf() + Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}") + } else { + Log.d(TAG, "AACPManager EQ data empty") + } + } else { + Log.d(TAG, "No AACPManager available") + } + } catch (e: Exception) { + Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}") + } + + var parsedSettings: HearingAidSettings? = null + for (attempt in 1..3) { + initialReadAttempts.intValue = attempt + try { + val data = attManager.read(ATTHandles.HEARING_AID) + parsedSettings = parseHearingAidSettingsResponse(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 hearing aid settings: $parsedSettings") + leftEQ.value = parsedSettings.leftEQ.copyOf() + rightEQ.value = parsedSettings.rightEQ.copyOf() + conversationBoostEnabled.value = parsedSettings.leftConversationBoost + initialReadSucceeded.value = true + } else { + Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts") + } + } catch (e: IOException) { + e.printStackTrace() + } finally { + initialLoadComplete.value = true + } + } + + val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz") + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Spacer(modifier = Modifier.width(60.dp)) + Text( + text = stringResource(R.string.left), + fontSize = 18.sp, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + Text( + text = stringResource(R.string.right), + fontSize = 18.sp, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + } + + frequencies.forEachIndexed { index, freq -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = freq, + modifier = Modifier + .width(60.dp) + .align(Alignment.CenterVertically), + textAlign = TextAlign.End, + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + ) + OutlinedTextField( + value = leftEQ.value[index].toString(), + onValueChange = { newValue -> + val parsed = newValue.toFloatOrNull() + if (parsed != null) { + val newArray = leftEQ.value.copyOf() + newArray[index] = parsed + leftEQ.value = newArray + } + }, +// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = TextStyle( + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontSize = 14.sp + ), + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = rightEQ.value[index].toString(), + onValueChange = { newValue -> + val parsed = newValue.toFloatOrNull() + if (parsed != null) { + val newArray = rightEQ.value.copyOf() + newArray[index] = parsed + rightEQ.value = newArray + } + }, +// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + textStyle = TextStyle( + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontSize = 14.sp + ), + modifier = Modifier.weight(1f) + ) + } + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt index 397dfa8..5553e21 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt @@ -390,6 +390,7 @@ class BLEManager(private val context: Context) { private fun cleanupStaleDevices() { val now = System.currentTimeMillis() val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS + val hadDevices = deviceStatusMap.isNotEmpty() val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff } @@ -398,7 +399,7 @@ class BLEManager(private val context: Context) { Log.d(TAG, "Removed stale device from tracking: ${device.key}") } - if (deviceStatusMap.isEmpty()) { + if (hadDevices && deviceStatusMap.isEmpty()) { airPodsStatusListener?.onDeviceDisappeared() } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/HearingAidEnums.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/HearingAidEnums.kt new file mode 100644 index 0000000..b405f84 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/HearingAidEnums.kt @@ -0,0 +1,190 @@ +/* + * 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 . + */ + +package me.kavishdevar.librepods.utils + +import android.util.Log +import androidx.compose.runtime.MutableState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder + +private const val TAG = "HearingAidUtils" + +data class HearingAidSettings( + 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, + val ownVoiceAmplification: Float +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as HearingAidSettings + + 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 + if (ownVoiceAmplification != other.ownVoiceAmplification) return false + + return true + } + + override fun hashCode(): Int { + var 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() + result = 31 * result + ownVoiceAmplification.hashCode() + return result + } +} + +fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? { + if (data.size < 104) return null + val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN) + + buffer.get() // skip 0x02 + buffer.get() // skip 0x02 + buffer.getShort() // skip 0x60 0x00 + + val leftEQ = FloatArray(8) + for (i in 0..7) { + leftEQ[i] = buffer.float + } + val leftAmplification = buffer.float + val leftTone = buffer.float + val leftConvFloat = buffer.float + val leftConversationBoost = leftConvFloat > 0.5f + val leftAmbientNoiseReduction = buffer.float + + val rightEQ = FloatArray(8) + for (i in 0..7) { + rightEQ[i] = buffer.float + } + val rightAmplification = buffer.float + val rightTone = buffer.float + val rightConvFloat = buffer.float + val rightConversationBoost = rightConvFloat > 0.5f + val rightAmbientNoiseReduction = buffer.float + + val ownVoiceAmplification = buffer.float + + val avg = (leftAmplification + rightAmplification) / 2 + val amplification = avg.coerceIn(-1f, 1f) + val diff = rightAmplification - leftAmplification + val balance = diff.coerceIn(-1f, 1f) + + return HearingAidSettings( + leftEQ = leftEQ, + rightEQ = rightEQ, + leftAmplification = leftAmplification, + rightAmplification = rightAmplification, + leftTone = leftTone, + rightTone = rightTone, + leftConversationBoost = leftConversationBoost, + rightConversationBoost = rightConversationBoost, + leftAmbientNoiseReduction = leftAmbientNoiseReduction, + rightAmbientNoiseReduction = rightAmbientNoiseReduction, + netAmplification = amplification, + balance = balance, + ownVoiceAmplification = ownVoiceAmplification + ) +} + +fun sendHearingAidSettings( + attManager: ATTManager, + hearingAidSettings: HearingAidSettings, + debounceJob: MutableState +) { + debounceJob.value?.cancel() + debounceJob.value = CoroutineScope(Dispatchers.IO).launch { + delay(100) + try { + val currentData = attManager.read(ATTHandles.HEARING_AID) + Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}") + if (currentData.size < 104) { + Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings") + return@launch + } + val buffer = ByteBuffer.wrap(currentData).order(ByteOrder.LITTLE_ENDIAN) + + // for some reason + buffer.put(2, 0x64) + + // Left EQ + for (i in 0..7) { + buffer.putFloat(4 + i * 4, hearingAidSettings.leftEQ[i]) + } + + // Left ear adjustments + buffer.putFloat(36, hearingAidSettings.leftAmplification) + buffer.putFloat(40, hearingAidSettings.leftTone) + buffer.putFloat(44, if (hearingAidSettings.leftConversationBoost) 1.0f else 0.0f) + buffer.putFloat(48, hearingAidSettings.leftAmbientNoiseReduction) + + // Right EQ + for (i in 0..7) { + buffer.putFloat(52 + i * 4, hearingAidSettings.rightEQ[i]) + } + + // Right ear adjustments + buffer.putFloat(84, hearingAidSettings.rightAmplification) + buffer.putFloat(88, hearingAidSettings.rightTone) + buffer.putFloat(92, if (hearingAidSettings.rightConversationBoost) 1.0f else 0.0f) + buffer.putFloat(96, hearingAidSettings.rightAmbientNoiseReduction) + + // Own voice amplification + buffer.putFloat(100, hearingAidSettings.ownVoiceAmplification) + + Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}") + + attManager.write(ATTHandles.HEARING_AID, currentData) + } catch (e: IOException) { + e.printStackTrace() + } + } +} diff --git a/android/app/src/main/res/layout/popup_window.xml b/android/app/src/main/res/layout/popup_window.xml index f8d2d76..f00068d 100644 --- a/android/app/src/main/res/layout/popup_window.xml +++ b/android/app/src/main/res/layout/popup_window.xml @@ -24,7 +24,7 @@ android:layout_marginTop="16dp" android:fontFamily="@font/sf_pro" android:gravity="center" - android:text="Kavish's AirPods Pro" + android:text="AirPods Pro" android:textColor="@color/popup_text" android:textSize="28sp" diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 7d72a46..659320a 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -185,4 +185,13 @@ Camera listener Listener service for LibrePods to detect when the camera is active to activate camera control on AirPods. Open Source Licenses + Update Hearing Test Result + ATT Manager is null, Try reconnecting. + The following permissions are required to use the app. Please grant them to continue. + Shake your head or nod! + Root Access Required + This app needs root access to hook onto the Bluetooth library + Root access was denied. Please grant root permissions. + Troubleshooting Steps + Please enter the loss values in dbHL