mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-02-02 08:09:14 +00:00
android: implement setting hearing test results
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user