android: add hearing aid adjustments

This commit is contained in:
Kavish Devar
2025-09-22 14:54:54 +05:30
parent ce229bec6e
commit 4751f70579
7 changed files with 505 additions and 324 deletions

View File

@@ -0,0 +1,180 @@
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
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.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@Composable
fun ConfirmationDialog(
showDialog: MutableState<Boolean>,
title: String,
message: String,
confirmText: String = "Enable",
dismissText: String = "Cancel",
onConfirm: () -> Unit,
onDismiss: () -> Unit = { showDialog.value = false },
hazeState: HazeState,
isDarkTheme: Boolean,
textColor: Color,
activeTrackColor: Color
) {
if (showDialog.value) {
Dialog(onDismissRequest = { showDialog.value = false }) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(if (isDarkTheme) Color(0xFF1C1C1E).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f), RoundedCornerShape(14.dp))
.clip(RoundedCornerShape(14.dp))
.hazeEffect(hazeState, CupertinoMaterials.regular())
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp))
Text(
title,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(12.dp))
Text(
message,
style = TextStyle(
fontSize = 14.sp,
color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.fillMaxWidth()
)
var leftPressed by remember { mutableStateOf(false) }
var rightPressed by remember { mutableStateOf(false) }
val pressedColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
val position = event.changes.first().position
val width = size.width.toFloat()
val height = size.height.toFloat()
val isWithinBounds = position.y >= 0 && position.y <= height
val isLeft = position.x < width / 2
event.changes.first().consume()
when (event.type) {
PointerEventType.Press -> {
if (isWithinBounds) {
leftPressed = isLeft
rightPressed = !isLeft
} else {
leftPressed = false
rightPressed = false
}
}
PointerEventType.Move -> {
if (isWithinBounds) {
leftPressed = isLeft
rightPressed = !isLeft
} else {
leftPressed = false
rightPressed = false
}
}
PointerEventType.Release -> {
if (isWithinBounds) {
if (leftPressed) {
onDismiss()
} else if (rightPressed) {
onConfirm()
}
}
leftPressed = false
rightPressed = false
}
}
}
}
},
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(if (leftPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center
) {
Text(dismissText, color = activeTrackColor)
}
Box(
modifier = Modifier
.width(1.dp)
.fillMaxHeight()
.background(Color(0x40888888))
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.background(if (rightPressed) pressedColor else Color.Transparent),
contentAlignment = Alignment.Center
) {
Text(confirmText, color = activeTrackColor)
}
}
}
}
}
}
}

View File

@@ -70,14 +70,10 @@ fun LoudSoundReductionSwitch() {
for (attempt in 1..3) {
try {
val data = attManager.read(ATTHandles.LOUD_SOUND_REDUCTION)
if (data.size == 2) {
loudSoundReductionEnabled = data[1].toInt() != 0
Log.d("LoudSoundReduction", "Read attempt $attempt: enabled=${loudSoundReductionEnabled}")
parsed = true
break
} else {
Log.d("LoudSoundReduction", "Read attempt $attempt returned empty data")
}
loudSoundReductionEnabled = data[0].toInt() != 0
Log.d("LoudSoundReduction", "Read attempt $attempt: enabled=${loudSoundReductionEnabled}")
parsed = true
break
} catch (e: Exception) {
Log.w("LoudSoundReduction", "Read attempt $attempt failed: ${e.message}")
}

View File

@@ -106,6 +106,9 @@ import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import me.kavishdevar.librepods.utils.TransparencySettings
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.sendTransparencySettings
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -1136,182 +1139,6 @@ fun AccessibilityToggle(text: String, mutableState: MutableState<Boolean>, indep
}
}
private data class TransparencySettings (
val enabled: Boolean,
val leftEQ: FloatArray,
val rightEQ: FloatArray,
val leftAmplification: Float,
val rightAmplification: Float,
val leftTone: Float,
val rightTone: Float,
val leftConversationBoost: Boolean,
val rightConversationBoost: Boolean,
val leftAmbientNoiseReduction: Float,
val rightAmbientNoiseReduction: Float,
val netAmplification: Float,
val balance: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TransparencySettings
if (enabled != other.enabled) return false
if (leftAmplification != other.leftAmplification) return false
if (rightAmplification != other.rightAmplification) return false
if (leftTone != other.leftTone) return false
if (rightTone != other.rightTone) return false
if (leftConversationBoost != other.leftConversationBoost) return false
if (rightConversationBoost != other.rightConversationBoost) return false
if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false
if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false
if (!leftEQ.contentEquals(other.leftEQ)) return false
if (!rightEQ.contentEquals(other.rightEQ)) return false
return true
}
override fun hashCode(): Int {
var result = enabled.hashCode()
result = 31 * result + leftAmplification.hashCode()
result = 31 * result + rightAmplification.hashCode()
result = 31 * result + leftTone.hashCode()
result = 31 * result + rightTone.hashCode()
result = 31 * result + leftConversationBoost.hashCode()
result = 31 * result + rightConversationBoost.hashCode()
result = 31 * result + leftAmbientNoiseReduction.hashCode()
result = 31 * result + rightAmbientNoiseReduction.hashCode()
result = 31 * result + leftEQ.contentHashCode()
result = 31 * result + rightEQ.contentHashCode()
return result
}
}
private fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
val settingsData = data.copyOfRange(1, data.size)
val buffer = ByteBuffer.wrap(settingsData).order(ByteOrder.LITTLE_ENDIAN)
val enabled = buffer.float
Log.d(TAG, "Parsed enabled: $enabled")
val leftEQ = FloatArray(8)
for (i in 0..7) {
leftEQ[i] = buffer.float
Log.d(TAG, "Parsed left EQ${i+1}: ${leftEQ[i]}")
}
val leftAmplification = buffer.float
Log.d(TAG, "Parsed left amplification: $leftAmplification")
val leftTone = buffer.float
Log.d(TAG, "Parsed left tone: $leftTone")
val leftConvFloat = buffer.float
val leftConversationBoost = leftConvFloat > 0.5f
Log.d(TAG, "Parsed left conversation boost: $leftConvFloat ($leftConversationBoost)")
val leftAmbientNoiseReduction = buffer.float
Log.d(TAG, "Parsed left ambient noise reduction: $leftAmbientNoiseReduction")
val rightEQ = FloatArray(8)
for (i in 0..7) {
rightEQ[i] = buffer.float
Log.d(TAG, "Parsed right EQ${i+1}: ${rightEQ[i]}")
}
val rightAmplification = buffer.float
Log.d(TAG, "Parsed right amplification: $rightAmplification")
val rightTone = buffer.float
Log.d(TAG, "Parsed right tone: $rightTone")
val rightConvFloat = buffer.float
val rightConversationBoost = rightConvFloat > 0.5f
Log.d(TAG, "Parsed right conversation boost: $rightConvFloat ($rightConversationBoost)")
val rightAmbientNoiseReduction = buffer.float
Log.d(TAG, "Parsed right ambient noise reduction: $rightAmbientNoiseReduction")
Log.d(TAG, "Settings parsed successfully")
val avg = (leftAmplification + rightAmplification) / 2
val amplification = avg.coerceIn(-1f, 1f)
val diff = rightAmplification - leftAmplification
val balance = diff.coerceIn(-1f, 1f)
return TransparencySettings(
enabled = enabled > 0.5f,
leftEQ = leftEQ,
rightEQ = rightEQ,
leftAmplification = leftAmplification,
rightAmplification = rightAmplification,
leftTone = leftTone,
rightTone = rightTone,
leftConversationBoost = leftConversationBoost,
rightConversationBoost = rightConversationBoost,
leftAmbientNoiseReduction = leftAmbientNoiseReduction,
rightAmbientNoiseReduction = rightAmbientNoiseReduction,
netAmplification = amplification,
balance = balance
)
}
private fun sendTransparencySettings(
attManager: ATTManager,
transparencySettings: TransparencySettings
) {
debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val buffer = ByteBuffer.allocate(100).order(ByteOrder.LITTLE_ENDIAN)
Log.d(TAG,
"Sending settings: $transparencySettings"
)
buffer.putFloat(if (transparencySettings.enabled) 1.0f else 0.0f)
for (eq in transparencySettings.leftEQ) {
buffer.putFloat(eq)
}
buffer.putFloat(transparencySettings.leftAmplification)
buffer.putFloat(transparencySettings.leftTone)
buffer.putFloat(if (transparencySettings.leftConversationBoost) 1.0f else 0.0f)
buffer.putFloat(transparencySettings.leftAmbientNoiseReduction)
for (eq in transparencySettings.rightEQ) {
buffer.putFloat(eq)
}
buffer.putFloat(transparencySettings.rightAmplification)
buffer.putFloat(transparencySettings.rightTone)
buffer.putFloat(if (transparencySettings.rightConversationBoost) 1.0f else 0.0f)
buffer.putFloat(transparencySettings.rightAmbientNoiseReduction)
val data = buffer.array()
attManager.write(
ATTHandles.TRANSPARENCY,
value = data
)
} catch (e: IOException) {
e.printStackTrace()
}
}
}
// Debounced send helper for phone/media EQ (if needed elsewhere)
private fun sendPhoneMediaEQ(aacpManager: me.kavishdevar.librepods.utils.AACPManager?, eq: FloatArray, phoneEnabled: Boolean, mediaEnabled: Boolean) {
phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
if (aacpManager == null) {
Log.w(TAG, "AACPManger is null; cannot send phone/media EQ")
return@launch
}
val phoneByte = if (phoneEnabled) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEnabled) 0x01.toByte() else 0x02.toByte()
aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
} catch (e: Exception) {
Log.w(TAG, "Error in sendPhoneMediaEQ: ${e.message}")
}
}
}
private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.05f): Float {
val nearest = points.minByOrNull { kotlin.math.abs(it - value) } ?: value
return if (kotlin.math.abs(nearest - value) <= threshold) nearest else value
@@ -1369,3 +1196,22 @@ private fun DropdownMenuComponent(
}
}
}
// Debounced send helper for phone/media EQ (if needed elsewhere)
private fun sendPhoneMediaEQ(aacpManager: me.kavishdevar.librepods.utils.AACPManager?, eq: FloatArray, phoneEnabled: Boolean, mediaEnabled: Boolean) {
phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
if (aacpManager == null) {
Log.w(TAG, "AACPManger is null; cannot send phone/media EQ")
return@launch
}
val phoneByte = if (phoneEnabled) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEnabled) 0x01.toByte() else 0x02.toByte()
aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
} catch (e: Exception) {
Log.w(TAG, "Error in sendPhoneMediaEQ: ${e.message}")
}
}
}

View File

@@ -115,7 +115,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
private var phoneMediaDebounceJob: Job? = null
private const val TAG = "AccessibilitySettings"
private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@@ -201,6 +201,7 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val eq = remember { mutableStateOf(FloatArray(8)) }
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val phoneEQEnabled = remember { mutableStateOf(false) }
@@ -214,7 +215,6 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
val HearingAidSettings = remember {
mutableStateOf(
HearingAidSettings(
enabled = enabled.value,
leftEQ = eq.value,
rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
@@ -226,7 +226,8 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue
balance = balanceSliderValue.floatValue,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
)
}
@@ -250,6 +251,26 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
}
}
val hearingAidATTListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseHearingAidSettingsResponse(value)
if (parsed != null) {
amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone
ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost
eq.value = parsed.leftEQ.copyOf()
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
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)
@@ -259,10 +280,11 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
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(enabled.value, amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, eq.value, initialLoadComplete.value, initialReadSucceeded.value) {
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
@@ -274,7 +296,6 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
}
HearingAidSettings.value = HearingAidSettings(
enabled = enabled.value,
leftEQ = eq.value,
rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
@@ -286,23 +307,18 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue
balance = balanceSliderValue.floatValue,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d("HearingAidSettings", "Updated settings: ${HearingAidSettings.value}")
// sendHearingAidSettings(attManager, HearingAidSettings.value)
}
DisposableEffect(Unit) {
onDispose {
// attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener)
}
Log.d(TAG, "Updated settings: ${HearingAidSettings.value}")
sendHearingAidSettings(attManager, HearingAidSettings.value)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
// attManager.enableNotifications(ATTHandles.TRANSPARENCY)
// attManager.registerListener(ATTHandles.TRANSPARENCY, transparencyListener)
attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
try {
if (aacpManager != null) {
@@ -324,12 +340,11 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
}
/*
var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) {
initialReadAttempts.value = attempt
try {
val data = attManager.read(ATTHandles.TRANSPARENCY)
val data = attManager.read(ATTHandles.HEARING_AID)
parsedSettings = parseHearingAidSettingsResponse(data = data)
if (parsedSettings != null) {
Log.d(TAG, "Parsed settings on attempt $attempt")
@@ -344,19 +359,18 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
}
if (parsedSettings != null) {
Log.d(TAG, "Initial transparency settings: $parsedSettings")
enabled.value = parsedSettings.enabled
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
eq.value = parsedSettings.leftEQ.copyOf()
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
initialReadSucceeded.value = true
} else {
Log.d(TAG, "Failed to read/parse initial transparency settings after ${initialReadAttempts.value} attempts")
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.value} attempts")
}
*/
} catch (e: IOException) {
e.printStackTrace()
} finally {
@@ -364,26 +378,6 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
}
}
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(150)
val manager = ServiceManager.getService()?.aacpManager
if (manager == null) {
Log.w(TAG, "Cannot write EQ: AACPManager not available")
return@launch
}
try {
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
Log.d(TAG, "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})")
manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
} catch (e: Exception) {
Log.w(TAG, "Error sending phone/media EQ: ${e.message}")
}
}
}
val isDarkThemeLocal = isSystemInDarkTheme()
var backgroundColorHA by remember { mutableStateOf(if (isDarkThemeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColorHA by animateColorAsState(targetValue = backgroundColorHA, animationSpec = tween(durationMillis = 500))
@@ -577,7 +571,7 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.less),
@@ -619,7 +613,6 @@ fun HearingAidAdjustmentsScreen(navController: NavController) {
}
private data class HearingAidSettings(
val enabled: Boolean,
val leftEQ: FloatArray,
val rightEQ: FloatArray,
val leftAmplification: Float,
@@ -631,7 +624,8 @@ private data class HearingAidSettings(
val leftAmbientNoiseReduction: Float,
val rightAmbientNoiseReduction: Float,
val netAmplification: Float,
val balance: Float
val balance: Float,
val ownVoiceAmplification: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -639,7 +633,6 @@ private data class HearingAidSettings(
other as HearingAidSettings
if (enabled != other.enabled) return false
if (leftAmplification != other.leftAmplification) return false
if (rightAmplification != other.rightAmplification) return false
if (leftTone != other.leftTone) return false
@@ -650,13 +643,13 @@ private data class HearingAidSettings(
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 = enabled.hashCode()
result = 31 * result + leftAmplification.hashCode()
var result = leftAmplification.hashCode()
result = 31 * result + rightAmplification.hashCode()
result = 31 * result + leftTone.hashCode()
result = 31 * result + rightTone.hashCode()
@@ -666,49 +659,40 @@ private data class HearingAidSettings(
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? {
val settingsData = data.copyOfRange(1, data.size)
val buffer = ByteBuffer.wrap(settingsData).order(ByteOrder.LITTLE_ENDIAN)
if (data.size < 104) return null
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
val enabled = buffer.float
Log.d(TAG, "Parsed enabled: $enabled")
val phoneEnabled = buffer.get() == 0x01.toByte()
val mediaEnabled = buffer.get() == 0x01.toByte()
buffer.getShort() // skip 0x60 0x00
val leftEQ = FloatArray(8)
for (i in 0..7) {
leftEQ[i] = buffer.float
Log.d(TAG, "Parsed left EQ${i+1}: ${leftEQ[i]}")
}
val leftAmplification = buffer.float
Log.d(TAG, "Parsed left amplification: $leftAmplification")
val leftTone = buffer.float
Log.d(TAG, "Parsed left tone: $leftTone")
val leftConvFloat = buffer.float
val leftConversationBoost = leftConvFloat > 0.5f
Log.d(TAG, "Parsed left conversation boost: $leftConvFloat ($leftConversationBoost)")
val leftAmbientNoiseReduction = buffer.float
Log.d(TAG, "Parsed left ambient noise reduction: $leftAmbientNoiseReduction")
val rightEQ = FloatArray(8)
for (i in 0..7) {
rightEQ[i] = buffer.float
Log.d(TAG, "Parsed right EQ${i+1}: $rightEQ[i]")
}
val rightAmplification = buffer.float
Log.d(TAG, "Parsed right amplification: $rightAmplification")
val rightTone = buffer.float
Log.d(TAG, "Parsed right tone: $rightTone")
val rightConvFloat = buffer.float
val rightConversationBoost = rightConvFloat > 0.5f
Log.d(TAG, "Parsed right conversation boost: $rightConvFloat ($rightConversationBoost)")
val rightAmbientNoiseReduction = buffer.float
Log.d(TAG, "Parsed right ambient noise reduction: $rightAmbientNoiseReduction")
Log.d(TAG, "Settings parsed successfully")
val ownVoiceAmplification = buffer.float
val avg = (leftAmplification + rightAmplification) / 2
val amplification = avg.coerceIn(-1f, 1f)
@@ -716,7 +700,6 @@ private fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings
val balance = diff.coerceIn(-1f, 1f)
return HearingAidSettings(
enabled = enabled > 0.5f,
leftEQ = leftEQ,
rightEQ = rightEQ,
leftAmplification = leftAmplification,
@@ -728,7 +711,8 @@ private fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings
leftAmbientNoiseReduction = leftAmbientNoiseReduction,
rightAmbientNoiseReduction = rightAmbientNoiseReduction,
netAmplification = amplification,
balance = balance
balance = balance,
ownVoiceAmplification = ownVoiceAmplification
)
}
@@ -740,55 +724,37 @@ private fun sendHearingAidSettings(
debounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val buffer = ByteBuffer.allocate(100).order(ByteOrder.LITTLE_ENDIAN)
Log.d(TAG,
"Sending settings: $HearingAidSettings"
)
buffer.putFloat(if (HearingAidSettings.enabled) 1.0f else 0.0f)
for (eq in HearingAidSettings.leftEQ) {
buffer.putFloat(eq)
}
buffer.putFloat(HearingAidSettings.leftAmplification)
buffer.putFloat(HearingAidSettings.leftTone)
buffer.putFloat(if (HearingAidSettings.leftConversationBoost) 1.0f else 0.0f)
buffer.putFloat(HearingAidSettings.leftAmbientNoiseReduction)
for (eq in HearingAidSettings.rightEQ) {
buffer.putFloat(eq)
}
buffer.putFloat(HearingAidSettings.rightAmplification)
buffer.putFloat(HearingAidSettings.rightTone)
buffer.putFloat(if (HearingAidSettings.rightConversationBoost) 1.0f else 0.0f)
buffer.putFloat(HearingAidSettings.rightAmbientNoiseReduction)
val data = buffer.array()
attManager.write(
ATTHandles.TRANSPARENCY,
value = data
)
} catch (e: IOException) {
e.printStackTrace()
}
}
}
private fun sendPhoneMediaEQ(aacpManager: me.kavishdevar.librepods.utils.AACPManager?, eq: FloatArray, phoneEnabled: Boolean, mediaEnabled: Boolean) {
phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
if (aacpManager == null) {
Log.w(TAG, "AACPManger is null; cannot send phone/media EQ")
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 phoneByte = if (phoneEnabled) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEnabled) 0x01.toByte() else 0x02.toByte()
aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
} catch (e: Exception) {
Log.w(TAG, "Error in sendPhoneMediaEQ: ${e.message}")
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

@@ -59,6 +59,7 @@ import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -71,14 +72,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@@ -101,6 +103,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AccessibilitySlider
import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.composables.LoudSoundReductionSwitch
import me.kavishdevar.librepods.composables.SinglePodANCSwitch
import me.kavishdevar.librepods.composables.StyledSwitch
@@ -111,6 +114,9 @@ import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import me.kavishdevar.librepods.utils.TransparencySettings
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.sendTransparencySettings
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -143,6 +149,14 @@ fun HearingAidScreen(navController: NavController) {
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
val showDialog = remember { mutableStateOf(false) }
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()))
}
Scaffold(
containerColor = if (isSystemInDarkTheme()) Color(
0xFF000000
@@ -202,12 +216,6 @@ fun HearingAidScreen(navController: NavController) {
.verticalScroll(verticalScrollState),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
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) {
@@ -239,20 +247,20 @@ fun HearingAidScreen(navController: NavController) {
fun onChange(value: Boolean) {
if (value) {
// Enable and enroll if not enrolled
val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte()
if (!enrolled) {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) // Enroll and enable
} else {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) // Enable
}
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte()) // Enable assist
showDialog.value = true
} else {
// Disable both, keep enrolled
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02)) // Disable
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte()) // Disable assist
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02))
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte())
hearingAidEnabled.value = value
}
hearingAidEnabled.value = value
}
fun onAdjustPhoneChange(value: Boolean) {
adjustPhoneEnabled.value = value
}
fun onAdjustMediaChange(value: Boolean) {
adjustMediaEnabled.value = value
}
Text(
@@ -374,7 +382,7 @@ fun HearingAidScreen(navController: NavController) {
backgroundColorAdjustMedia = if (isDarkThemeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
adjustMediaEnabled.value = !adjustMediaEnabled.value
onAdjustMediaChange(!adjustMediaEnabled.value)
}
)
},
@@ -393,7 +401,7 @@ fun HearingAidScreen(navController: NavController) {
StyledSwitch(
checked = adjustMediaEnabled.value,
onCheckedChange = {
adjustMediaEnabled.value = it
onAdjustMediaChange(it)
},
)
}
@@ -419,7 +427,7 @@ fun HearingAidScreen(navController: NavController) {
backgroundColorAdjustPhone = if (isDarkThemeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
adjustPhoneEnabled.value = !adjustPhoneEnabled.value
onAdjustPhoneChange(!adjustPhoneEnabled.value)
}
)
},
@@ -438,11 +446,47 @@ fun HearingAidScreen(navController: NavController) {
StyledSwitch(
checked = adjustPhoneEnabled.value,
onCheckedChange = {
adjustPhoneEnabled.value = it
onAdjustPhoneChange(it)
},
)
}
}
}
}
ConfirmationDialog(
showDialog = showDialog,
title = "Enable Hearing Aid",
message = "Enabling Hearing Aid will disable Headphone Accommodation and Customized Transparency Mode.",
confirmText = "Enable",
dismissText = "Cancel",
onConfirm = {
showDialog.value = false
val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte()
if (!enrolled) {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
} else {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
}
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte())
hearingAidEnabled.value = true
// Disable transparency mode
CoroutineScope(Dispatchers.IO).launch {
try {
val data = attManager.read(ATTHandles.TRANSPARENCY)
val parsed = parseTransparencySettingsResponse(data)
if (parsed != null) {
val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(attManager, disabledSettings)
}
} catch (e: Exception) {
Log.e(TAG, "Error disabling transparency: ${e.message}")
}
}
},
hazeState = hazeState,
isDarkTheme = isDarkTheme,
textColor = textColor,
activeTrackColor = activeTrackColor
)
}

View File

@@ -39,8 +39,8 @@ import java.util.concurrent.TimeUnit
enum class ATTHandles(val value: Int) {
TRANSPARENCY(0x18),
LOUD_SOUND_REDUCTION(0x1b),
HEARING_AID(0x2a),
LOUD_SOUND_REDUCTION(0x1B),
HEARING_AID(0x2A),
}
enum class ATTCCCDHandles(val value: Int) {
@@ -85,7 +85,7 @@ class ATTManager(private val device: BluetoothDevice) {
if (pdu.isNotEmpty() && pdu[0] == OPCODE_HANDLE_VALUE_NTF) {
// notification -> dispatch to listeners
val handle = (pdu[1].toInt() and 0xFF) or ((pdu[2].toInt() and 0xFF) shl 8)
val value = pdu.copyOfRange(2, pdu.size)
val value = pdu.copyOfRange(3, pdu.size)
listeners[handle]?.forEach { listener ->
try {
listener(value)
@@ -191,7 +191,7 @@ class ATTManager(private val device: BluetoothDevice) {
throw IllegalStateException("No response read from ATT socket within $timeoutMs ms")
}
Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}")
return resp
return resp.copyOfRange(1, resp.size)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw IllegalStateException("Interrupted while waiting for ATT response", e)

View File

@@ -0,0 +1,149 @@
package me.kavishdevar.librepods.utils
import android.util.Log
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 = "TransparencyUtils"
data class TransparencySettings(
val enabled: Boolean,
val leftEQ: FloatArray,
val rightEQ: FloatArray,
val leftAmplification: Float,
val rightAmplification: Float,
val leftTone: Float,
val rightTone: Float,
val leftConversationBoost: Boolean,
val rightConversationBoost: Boolean,
val leftAmbientNoiseReduction: Float,
val rightAmbientNoiseReduction: Float,
val netAmplification: Float,
val balance: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as TransparencySettings
if (enabled != other.enabled) return false
if (leftAmplification != other.leftAmplification) return false
if (rightAmplification != other.rightAmplification) return false
if (leftTone != other.leftTone) return false
if (rightTone != other.rightTone) return false
if (leftConversationBoost != other.leftConversationBoost) return false
if (rightConversationBoost != other.rightConversationBoost) return false
if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false
if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false
if (!leftEQ.contentEquals(other.leftEQ)) return false
if (!rightEQ.contentEquals(other.rightEQ)) return false
return true
}
override fun hashCode(): Int {
var result = enabled.hashCode()
result = 31 * result + leftAmplification.hashCode()
result = 31 * result + rightAmplification.hashCode()
result = 31 * result + leftTone.hashCode()
result = 31 * result + rightTone.hashCode()
result = 31 * result + leftConversationBoost.hashCode()
result = 31 * result + rightConversationBoost.hashCode()
result = 31 * result + leftAmbientNoiseReduction.hashCode()
result = 31 * result + rightAmbientNoiseReduction.hashCode()
result = 31 * result + leftEQ.contentHashCode()
result = 31 * result + rightEQ.contentHashCode()
return result
}
}
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
val settingsData = data
val buffer = ByteBuffer.wrap(settingsData).order(ByteOrder.LITTLE_ENDIAN)
val enabled = buffer.float
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 avg = (leftAmplification + rightAmplification) / 2
val amplification = avg.coerceIn(-1f, 1f)
val diff = rightAmplification - leftAmplification
val balance = diff.coerceIn(-1f, 1f)
return TransparencySettings(
enabled = enabled > 0.5f,
leftEQ = leftEQ,
rightEQ = rightEQ,
leftAmplification = leftAmplification,
rightAmplification = rightAmplification,
leftTone = leftTone,
rightTone = rightTone,
leftConversationBoost = leftConversationBoost,
rightConversationBoost = rightConversationBoost,
leftAmbientNoiseReduction = leftAmbientNoiseReduction,
rightAmbientNoiseReduction = rightAmbientNoiseReduction,
netAmplification = amplification,
balance = balance
)
}
private var debounceJob: Job? = null
fun sendTransparencySettings(attManager: ATTManager, transparencySettings: TransparencySettings) {
debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val buffer = ByteBuffer.allocate(100).order(ByteOrder.LITTLE_ENDIAN)
buffer.putFloat(if (transparencySettings.enabled) 1.0f else 0.0f)
for (eq in transparencySettings.leftEQ) {
buffer.putFloat(eq)
}
buffer.putFloat(transparencySettings.leftAmplification)
buffer.putFloat(transparencySettings.leftTone)
buffer.putFloat(if (transparencySettings.leftConversationBoost) 1.0f else 0.0f)
buffer.putFloat(transparencySettings.leftAmbientNoiseReduction)
for (eq in transparencySettings.rightEQ) {
buffer.putFloat(eq)
}
buffer.putFloat(transparencySettings.rightAmplification)
buffer.putFloat(transparencySettings.rightTone)
buffer.putFloat(if (transparencySettings.rightConversationBoost) 1.0f else 0.0f)
buffer.putFloat(transparencySettings.rightAmbientNoiseReduction)
val data = buffer.array()
attManager.write(ATTHandles.TRANSPARENCY, value = data)
} catch (e: IOException) {
e.printStackTrace()
}
}
}