mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-04-28 00:56:07 +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.Context.MODE_PRIVATE
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -74,11 +73,9 @@ import androidx.compose.material3.Card
|
|||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
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.toArgb
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.res.vectorResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
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.MultiplePermissionsState
|
||||||
import com.google.accompanist.permissions.isGranted
|
import com.google.accompanist.permissions.isGranted
|
||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
import me.kavishdevar.librepods.composables.StyledIconButton
|
||||||
|
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
|
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
|
||||||
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
|
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
|
||||||
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
|
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.RenameScreen
|
||||||
import me.kavishdevar.librepods.screens.TransparencySettingsScreen
|
import me.kavishdevar.librepods.screens.TransparencySettingsScreen
|
||||||
import me.kavishdevar.librepods.screens.TroubleshootingScreen
|
import me.kavishdevar.librepods.screens.TroubleshootingScreen
|
||||||
|
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||||
import me.kavishdevar.librepods.utils.CrossDevice
|
|
||||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
@@ -406,6 +405,9 @@ fun Main() {
|
|||||||
composable("open_source_licenses") {
|
composable("open_source_licenses") {
|
||||||
OpenSourceLicensesScreen(navController)
|
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)),
|
exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.TopStart)
|
.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(
|
StyledIconButton(
|
||||||
onClick = { navController.popBackStack() },
|
onClick = { navController.popBackStack() },
|
||||||
@@ -556,7 +561,7 @@ fun PermissionsScreen(
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "The following permissions are required to use the app. Please grant them to continue.",
|
text = stringResource(R.string.permissions_required),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
@@ -733,7 +738,11 @@ fun PermissionCard(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(40.dp)
|
.size(40.dp)
|
||||||
.clip(RoundedCornerShape(8.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
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ import androidx.compose.ui.tooling.preview.Preview
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
@@ -473,31 +472,13 @@ fun StyledToggle(
|
|||||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
val attManager = ServiceManager.getService()?.attManager ?: return
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
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)) }
|
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
attManager.enableNotifications(attHandle)
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
if (sharedPreferenceKey != null && sharedPreferences != null) {
|
||||||
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
|
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.services.AirPodsService
|
||||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
|
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@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 darkMode = isSystemInDarkTheme()
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = deviceName.text,
|
title = deviceName.text,
|
||||||
actionButtons = listOf(
|
actionButtons = listOf(
|
||||||
@@ -218,26 +230,14 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.hazeSource(hazeState)
|
.hazeSource(hazeState)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
) {
|
) {
|
||||||
item { Spacer(modifier = Modifier.height(spacerHeight)) }
|
item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) }
|
||||||
item {
|
item(key = "battery") {
|
||||||
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())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BatteryView(service = service)
|
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(
|
NavigationButton(
|
||||||
to = "rename",
|
to = "rename",
|
||||||
name = stringResource(R.string.name),
|
name = stringResource(R.string.name),
|
||||||
@@ -246,49 +246,55 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
|||||||
independent = true
|
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(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
item { NavigationButton(to = "hearing_aid", name = stringResource(R.string.hearing_aid), navController = navController) }
|
item(key = "noise_control") { NoiseControlSettings(service = service) }
|
||||||
|
|
||||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
item { NoiseControlSettings(service = service) }
|
item(key = "press_hold") { PressAndHoldSettings(navController = navController) }
|
||||||
|
|
||||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
item { PressAndHoldSettings(navController = navController) }
|
item(key = "call_control") { CallControlSettings(hazeState = hazeState) }
|
||||||
|
|
||||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
item { CallControlSettings(hazeState = hazeState) }
|
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(key = "spacer_audio") { 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 = "audio") { AudioSettings(navController = navController) }
|
||||||
|
|
||||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
item { AudioSettings(navController = navController) }
|
item(key = "connection") { ConnectionSettings() }
|
||||||
|
|
||||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
item { ConnectionSettings() }
|
item(key = "microphone") { MicrophoneSettings(hazeState) }
|
||||||
|
|
||||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
item { MicrophoneSettings(hazeState) }
|
item(key = "sleep_detection") {
|
||||||
|
|
||||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item {
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.sleep_detection),
|
label = stringResource(R.string.sleep_detection),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
|
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
item {
|
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)) }
|
||||||
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(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
item { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) }
|
item(key = "accessibility") { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) }
|
||||||
|
|
||||||
item { Spacer(modifier = Modifier.height(16.dp)) }
|
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
item {
|
item(key = "off_listening") {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.off_listening_mode),
|
label = stringResource(R.string.off_listening_mode),
|
||||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
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
|
// 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(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
item { NavigationButton("debug", "Debug", navController) }
|
item(key = "debug") { NavigationButton("debug", "Debug", navController) }
|
||||||
item { Spacer(Modifier.height(24.dp)) }
|
item(key = "spacer_bottom") { Spacer(Modifier.height(24.dp)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
import me.kavishdevar.librepods.composables.NavigationButton
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
import me.kavishdevar.librepods.composables.StyledSlider
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.composables.StyledToggle
|
||||||
|
|||||||
@@ -221,9 +221,10 @@ fun HeadTrackingScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
|
||||||
StyledButton(
|
StyledButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
gestureText = "Shake your head or nod!"
|
gestureText = gestureTextValue
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
|
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
|
||||||
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
|
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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -46,26 +47,22 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledSlider
|
import me.kavishdevar.librepods.composables.StyledSlider
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.composables.StyledToggle
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
import me.kavishdevar.librepods.utils.AACPManager
|
import me.kavishdevar.librepods.utils.AACPManager
|
||||||
import me.kavishdevar.librepods.utils.ATTHandles
|
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.io.IOException
|
||||||
import java.nio.ByteBuffer
|
|
||||||
import java.nio.ByteOrder
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private var debounceJob: Job? = null
|
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
||||||
private const val TAG = "HearingAidAdjustments"
|
private const val TAG = "HearingAidAdjustments"
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
@SuppressLint("DefaultLocale")
|
||||||
@@ -73,7 +70,7 @@ private const val TAG = "HearingAidAdjustments"
|
|||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
|
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
isSystemInDarkTheme()
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val hazeState = remember { HazeState() }
|
val hazeState = remember { HazeState() }
|
||||||
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
|
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
|
||||||
@@ -210,7 +207,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
|
|||||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
)
|
)
|
||||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||||
sendHearingAidSettings(attManager, hearingAidSettings.value)
|
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
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 androidx.navigation.NavController
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.HazeState
|
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.ConfirmationDialog
|
import me.kavishdevar.librepods.composables.ConfirmationDialog
|
||||||
import me.kavishdevar.librepods.composables.NavigationButton
|
import me.kavishdevar.librepods.composables.NavigationButton
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||||
import me.kavishdevar.librepods.composables.StyledToggle
|
import me.kavishdevar.librepods.composables.StyledToggle
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
import me.kavishdevar.librepods.services.ServiceManager
|
||||||
@@ -83,7 +82,6 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val hazeState = remember { HazeState() }
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val attManager = ServiceManager.getService()?.attManager ?: return
|
val attManager = ServiceManager.getService()?.attManager ?: return
|
||||||
|
|
||||||
@@ -102,7 +100,7 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.hearing_aid),
|
title = stringResource(R.string.hearing_aid),
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
) { spacerHeight ->
|
) { spacerHeight, hazeState ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
@@ -127,9 +125,9 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaAssistEnabled = remember { mutableStateOf(false) }
|
// val mediaAssistEnabled = remember { mutableStateOf(false) }
|
||||||
val adjustMediaEnabled = remember { mutableStateOf(false) }
|
// val adjustMediaEnabled = remember { mutableStateOf(false) }
|
||||||
val adjustPhoneEnabled = remember { mutableStateOf(false) }
|
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
|
||||||
@@ -154,13 +152,13 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
initialLoad.value = false
|
initialLoad.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onAdjustPhoneChange(value: Boolean) {
|
// fun onAdjustPhoneChange(value: Boolean) {
|
||||||
// TODO
|
// // TODO
|
||||||
}
|
// }
|
||||||
|
|
||||||
fun onAdjustMediaChange(value: Boolean) {
|
// fun onAdjustMediaChange(value: Boolean) {
|
||||||
// TODO
|
// // TODO
|
||||||
}
|
// }
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.hearing_aid),
|
text = stringResource(R.string.hearing_aid),
|
||||||
@@ -213,6 +211,13 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
NavigationButton(
|
||||||
|
to = "update_hearing_test",
|
||||||
|
name = stringResource(R.string.update_hearing_test),
|
||||||
|
navController,
|
||||||
|
independent = true
|
||||||
|
)
|
||||||
|
|
||||||
// not implemented yet
|
// not implemented yet
|
||||||
|
|
||||||
// StyledToggle(
|
// StyledToggle(
|
||||||
@@ -280,7 +285,7 @@ fun HearingAidScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hazeState = hazeState,
|
hazeState = rememberHazeState(),
|
||||||
// backdrop = backdrop
|
// backdrop = backdrop
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.Font
|
import androidx.compose.ui.text.font.Font
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
@@ -201,7 +202,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
|
|||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Root Access Required",
|
text = stringResource(R.string.root_access_required),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 22.sp,
|
fontSize = 22.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
@@ -214,7 +215,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
|
|||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Text(
|
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(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
@@ -227,7 +228,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
|
|||||||
if (rootCheckFailed) {
|
if (rootCheckFailed) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Root access was denied. Please grant root permissions.",
|
text = stringResource(R.string.root_access_denied),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.composables.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.composables.StyledScaffold
|
import me.kavishdevar.librepods.composables.StyledScaffold
|
||||||
import me.kavishdevar.librepods.utils.LogCollector
|
import me.kavishdevar.librepods.utils.LogCollector
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -370,7 +369,7 @@ fun TroubleshootingScreen(navController: NavController) {
|
|||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "TROUBLESHOOTING STEPS",
|
text = stringResource(R.string.troubleshooting_steps),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Light,
|
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() {
|
private fun cleanupStaleDevices() {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS
|
val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS
|
||||||
|
val hadDevices = deviceStatusMap.isNotEmpty()
|
||||||
|
|
||||||
val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff }
|
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}")
|
Log.d(TAG, "Removed stale device from tracking: ${device.key}")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deviceStatusMap.isEmpty()) {
|
if (hadDevices && deviceStatusMap.isEmpty()) {
|
||||||
airPodsStatusListener?.onDeviceDisappeared()
|
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:layout_marginTop="16dp"
|
||||||
android:fontFamily="@font/sf_pro"
|
android:fontFamily="@font/sf_pro"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:text="Kavish's AirPods Pro"
|
android:text="AirPods Pro"
|
||||||
android:textColor="@color/popup_text"
|
android:textColor="@color/popup_text"
|
||||||
|
|
||||||
android:textSize="28sp"
|
android:textSize="28sp"
|
||||||
|
|||||||
@@ -185,4 +185,13 @@
|
|||||||
<string name="app_listener_service_label">Camera listener</string>
|
<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="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="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>
|
</resources>
|
||||||
|
|||||||
Reference in New Issue
Block a user