android: implement setting hearing test results

This commit is contained in:
Kavish Devar
2025-10-16 13:11:21 +05:30
parent 942ff82382
commit a9b78efd80
14 changed files with 670 additions and 260 deletions

View File

@@ -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(

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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),

View File

@@ -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."

View File

@@ -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<Job?> = 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()
}
}
}

View File

@@ -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
)
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -0,0 +1,359 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Job?> = 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)
)
}
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -0,0 +1,190 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Job?>
) {
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()
}
}
}

View File

@@ -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"

View File

@@ -185,4 +185,13 @@
<string name="app_listener_service_label">Camera listener</string>
<string name="app_listener_service_description">Listener service for LibrePods to detect when the camera is active to activate camera control on AirPods.</string>
<string name="open_source_licenses">Open Source Licenses</string>
<string name="update_hearing_test">Update Hearing Test Result</string>
<string name="att_manager_is_null_try_reconnecting">ATT Manager is null, Try reconnecting.</string>
<string name="permissions_required">The following permissions are required to use the app. Please grant them to continue.</string>
<string name="shake_your_head_or_nod">Shake your head or nod!</string>
<string name="root_access_required">Root Access Required</string>
<string name="this_app_needs_root_access_to_hook_onto_the_bluetooth_library">This app needs root access to hook onto the Bluetooth library</string>
<string name="root_access_denied">Root access was denied. Please grant root permissions.</string>
<string name="troubleshooting_steps">Troubleshooting Steps</string>
<string name="hearing_test_value_instruction">Please enter the loss values in dbHL</string>
</resources>