android: move ATT code to viewmodel from screens and enable notifications

This commit is contained in:
Kavish Devar
2026-05-30 16:59:36 +05:30
parent 1381022b2e
commit 0f50eab788
11 changed files with 403 additions and 664 deletions

View File

@@ -466,7 +466,7 @@ fun Main() {
OpenSourceLicensesScreen(navController)
}
composable("update_hearing_test") {
if (airPodsViewModel != null) UpdateHearingTestScreen()
if (airPodsViewModel != null) UpdateHearingTestScreen(airPodsViewModel)
}
composable("version_info") {
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)

View File

@@ -16,247 +16,196 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/* This is a very basic ATT (Attribute Protocol) implementation. I have only implemented
* what is necessary for LibrePods to function, i.e. reading and writing characteristics,
* and receiving notifications. It is not a complete implementation of the ATT protocol.
*/
package me.kavishdevar.librepods.bluetooth
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
private const val TAG = "ATTManager"
enum class ATTHandles(val value: Int) {
TRANSPARENCY(0x18),
LOUD_SOUND_REDUCTION(0x1B),
HEARING_AID(0x2A),
HEARING_AID(0x2A)
}
enum class ATTCCCDHandles(val value: Int) {
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1),
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
// LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work
HEARING_AID(ATTHandles.HEARING_AID.value + 1)
}
class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) {
companion object {
private const val OPCODE_READ_REQUEST: Byte = 0x0A
private const val OPCODE_WRITE_REQUEST: Byte = 0x12
private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B
class ATTManagerv2 {
val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
private val responseQueues = ConcurrentHashMap<Byte, LinkedBlockingQueue<ByteArray>>()
private val readerRunning = AtomicBoolean(false)
private var readerThread: Thread? = null
private var onNotificationReceived: ((handle: Byte, value: ByteArray) -> Unit)? = null
fun startReader() {
if (readerRunning.getAndSet(true)) return
readerThread = Thread {
try {
runReaderLoop()
} catch (t: Throwable) {
Log.e(TAG, "reader thread crashed: ${t.message}", t)
} finally {
readerRunning.set(false)
Log.d(TAG, "reader thread stopped")
}
}.also { it.name = "ATT-Reader"; it.isDaemon = true; it.start() }
Log.d(TAG, "reader started")
}
private val id = System.identityHashCode(this)
@Suppress("PrivatePropertyName")
private val TAG = "ATTManager[$id]"
var socket: BluetoothSocket? = null
private var input: InputStream? = null
private var output: OutputStream? = null
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
private var notificationJob: Job? = null
// queue for non-notification PDUs (responses to requests)
private val responses = LinkedBlockingQueue<ByteArray>()
fun stopReader() {
readerRunning.set(false)
readerThread?.interrupt()
readerThread = null
}
@SuppressLint("MissingPermission")
fun connect() {
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) {
onNotificationReceived = listener
}
if (socket == null) {
Log.d(TAG, "Socket doesn't exist, creating")
try {
socket = createBluetoothSocket(adapter, device, uuid)
} catch (e: Exception) {
Log.w(TAG, "Failed to create socket")
e.printStackTrace()
fun enableNotification(handle: ATTCCCDHandles) {
writeCharacteristic(handle.value.toByte(), byteArrayOf(0x01))
}
fun getCharacteristic(handle: ATTHandles): ByteArray? {
val storedValue = characteristicList[handle]
return if (storedValue?.isNotEmpty() != true) {
readCharacteristic(handle)
} else storedValue
}
fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? {
val socket = BluetoothConnectionManager.getATTSocket() ?: return null
try {
val output = socket.outputStream
val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00)
synchronized(output) {
output.write(pdu)
output.flush()
}
Log.d(TAG, "sending read request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
val resp = waitForResponse(0x0B, timeoutMillis) ?: run {
Log.e(TAG, "Timeout waiting for Read Response (0x0B) for handle ${handle.value}")
return null
}
Log.d(TAG, "read response: ${resp.joinToString(" ") { String.format("%02X", it) }}")
val value = resp.copyOfRange(1, resp.size)
characteristicList[handle] = value
return value
} catch (e: Exception) {
Log.e(TAG, "error reading characteristic: ${e.message}")
return null
}
}
fun writeCharacteristic(handle: ATTHandles, data: ByteArray, timeoutMillis: Long = 2000) {
characteristicList[handle] = data
writeCharacteristic(handle.value.toByte(), data, timeoutMillis)
}
fun writeCharacteristic(handle: Byte, data: ByteArray, timeoutMillis: Long = 2000) {
val socket = BluetoothConnectionManager.getATTSocket() ?: return
try {
val output = socket.outputStream
val pdu = byteArrayOf(0x12, handle, 0x00) + data // 0x00 for LE
synchronized(output) {
output.write(pdu)
output.flush()
}
Log.d(TAG, "sending write request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
val resp = waitForResponse(0x13, timeoutMillis) ?: run {
Log.e(TAG, "timeout waiting for response (0x13) for handle ${String.format("%02X", handle)}")
return
}
}
if (socket?.isConnected != true) {
Log.d(TAG, "Connection to socket")
try {
socket!!.connect()
} catch (e: Exception) {
Log.w(TAG, "ATT socket failed to connect")
e.printStackTrace()
return
}
}
input = socket!!.inputStream
output = socket!!.outputStream
Log.d(TAG, "Connected to ATT")
notificationJob = CoroutineScope(Dispatchers.IO).launch {
while (socket?.isConnected == true) {
try {
val pdu = readPDU()
if (pdu.isNotEmpty() && pdu[0] == OPCODE_HANDLE_VALUE_NTF) {
// notification -> dispatch to listeners
val handle = (pdu[1].toInt() and 0xFF) or ((pdu[2].toInt() and 0xFF) shl 8)
val value = pdu.copyOfRange(3, pdu.size)
listeners[handle]?.forEach { listener ->
try {
listener(value)
Log.d(TAG, "Dispatched notification for handle $handle to listener, with value ${value.joinToString(" ") { String.format("%02X", it) }}")
} catch (e: Exception) {
Log.w(TAG, "Error in listener for handle $handle: ${e.message}")
}
Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}")
} catch (e: Exception) {
Log.e(TAG, "error writing characteristic: ${e.message}")
}
}
fun disconnected() {
characteristicList.clear()
stopReader()
val socket = BluetoothConnectionManager.getATTSocket() ?: return
try {
socket.close()
} catch (e: Exception) {
Log.w(TAG, "error closing socket: ${e.message}")
}
Log.d(TAG, "ATT disconnected")
}
private fun runReaderLoop() {
val socket = BluetoothConnectionManager.getATTSocket() ?: run {
Log.w(TAG, "ATT socket not available. stopping reader")
readerRunning.set(false)
return
}
val input = socket.inputStream
val buffer = ByteArray(512)
while (readerRunning.get()) {
try {
val len = input.read(buffer)
if (len == -1) {
Log.w(TAG, "ATT input stream ended")
break
}
val data = buffer.copyOfRange(0, len)
if (data.isEmpty()) continue
val opcode = data[0]
Log.d(TAG, "pdu received ${data.joinToString(" ") { String.format("%02X", it) }}")
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
queue.offer(data)
if (opcode == 0x1B.toByte()) {
if (data.size >= 3) {
val handle = data[1]
val value = if (data.size > 3) data.copyOfRange(3, data.size) else ByteArray(0)
Log.d(TAG, "notification/indication handle=0x${String.format("%02X", handle)} value=${value.toHexString()}")
try {
onNotificationReceived?.invoke(handle, value)
} catch (t: Throwable) {
Log.e(TAG, "onNotificationReceived threw: ${t.message}", t)
}
} else {
// not a notification -> treat as a response for pending request(s)
responses.put(pdu)
Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
}
} catch (e: Exception) {
Log.w(TAG, "Error reading notification/response: ${e.message}")
if (socket?.isConnected != true) break
}
}
}
if (output != null) {
Log.d(TAG, "sending read req for hearing aid declaration")
output?.write(byteArrayOf(0x0A, 0x29, 0x00))
}
}
fun disconnect() {
try {
notificationJob?.cancel()
socket?.close()
} catch (e: Exception) {
Log.w(TAG, "Error closing socket: ${e.message}")
}
}
fun registerListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
listeners.getOrPut(handle.value) { mutableListOf() }.add(listener)
}
fun unregisterListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
listeners[handle.value]?.remove(listener)
}
fun enableNotifications(handle: ATTHandles) {
write(ATTCCCDHandles.valueOf(handle.name), byteArrayOf(0x01, 0x00))
}
fun read(handle: ATTHandles): ByteArray {
val lsb = (handle.value and 0xFF).toByte()
val msb = ((handle.value shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_READ_REQUEST, lsb, msb)
writeRaw(pdu)
// wait for response placed into responses queue by the reader coroutine
return readResponse()
}
fun write(handle: ATTHandles, value: ByteArray) {
val lsb = (handle.value and 0xFF).toByte()
val msb = ((handle.value shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
writeRaw(pdu)
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
try {
readResponse()
} catch (e: Exception) {
Log.w(TAG, "No write response received: ${e.message}")
}
}
fun write(handle: ATTCCCDHandles, value: ByteArray) {
val lsb = (handle.value and 0xFF).toByte()
val msb = ((handle.value shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
writeRaw(pdu)
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
try {
readResponse()
} catch (e: Exception) {
Log.w(TAG, "No write response received: ${e.message}")
}
}
private fun writeRaw(pdu: ByteArray) {
if (output == null) return
output?.write(pdu)
output?.flush()
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
}
// rename / specialize: read raw PDU directly from input stream (blocking)
private fun readPDU(): ByteArray {
val inp = input ?: throw IllegalStateException("Not connected")
val buffer = ByteArray(512)
val len = inp.read(buffer)
if (len == -1) {
disconnect()
throw IllegalStateException("End of stream reached")
}
val data = buffer.copyOfRange(0, len)
Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}")
return data
}
// wait for a response PDU produced by the background reader
private fun readResponse(timeoutMs: Long = 2000): ByteArray {
try {
val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS)
?: throw IllegalStateException("No response read from ATT socket within $timeoutMs ms")
Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}")
return resp.copyOfRange(1, resp.size)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw IllegalStateException("Interrupted while waiting for ATT response", e)
}
}
private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
val type = 3 // L2CAP
val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, true, 31, uuid),
arrayOf(device, type, true, true, 31, uuid),
arrayOf(device, type, 1, true, true, 31, uuid),
arrayOf(type, 1, true, true, device, 31, uuid),
arrayOf(type, true, true, device, 31, uuid)
)
val constructors = BluetoothSocket::class.java.declaredConstructors
Log.d(TAG, "BluetoothSocket has ${constructors.size} constructors:")
constructors.forEachIndexed { index, constructor ->
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
Log.d(TAG, "Constructor $index: ($params)")
}
var lastException: Exception? = null
var attemptedConstructors = 0
for ((index, params) in constructorSpecs.withIndex()) {
try {
Log.d(TAG, "Trying constructor signature #${index + 1}")
attemptedConstructors++
val paramTypes = params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
constructor.isAccessible = true
return constructor.newInstance(*params) as BluetoothSocket
} catch (e: Exception) {
Log.e(TAG, "Constructor signature #${index + 1} failed: ${e.message}")
lastException = e
Log.e(TAG, "error in reader loop: ${e.message}", e)
break
}
}
val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
Log.e(TAG, errorMessage)
throw lastException ?: IllegalStateException(errorMessage)
readerRunning.set(false)
}
private fun waitForResponse(opcode: Byte, timeoutMillis: Long): ByteArray? {
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
return try {
queue.poll(timeoutMillis, TimeUnit.MILLISECONDS)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
}

View File

@@ -1,63 +0,0 @@
package me.kavishdevar.librepods.bluetooth
import android.util.Log
private const val TAG = "ATTManagerv2"
// the random disconnects were because of ATT, apparently. seems like we will have to accept no notifications for external changes (mainly amplification in hearing aid)
object ATTManagerv2 {
fun readCharacteristic(handle: ATTHandles): ByteArray? {
val socket = BluetoothConnectionManager.getATTSocket()?: return null
try {
// socket.connect()
val input = socket.inputStream
val output = socket.outputStream
val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00)
output.write(pdu)
output.flush()
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
val buffer = ByteArray(512)
val len = input.read(buffer)
if (len == -1) {
throw IllegalStateException("End of stream reached")
}
val data = buffer.copyOfRange(0, len)
// socket.close()
if (data[0] != 0x0B.toByte()) {
throw IllegalStateException("Invalid response: ${data.joinToString(" ") { String.format("%02X", it) }}")
}
Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}")
return data.copyOfRange(1, data.size)
} catch (e: Exception) {
Log.e(TAG, "Error reading characteristic: ${e.message}")
return null
}
}
fun writeCharacteristic(handle: ATTHandles, data: ByteArray) {
val socket = BluetoothConnectionManager.getATTSocket()?: return
try {
// socket.connect()
val input = socket.inputStream
val output = socket.outputStream
val pdu = byteArrayOf(0x12, handle.value.toByte(), 0x00) + data // 0x0 because LE
output.write(pdu)
output.flush()
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
val buffer = ByteArray(512)
val len = input.read(buffer)
if (len == -1) {
throw IllegalStateException("End of stream reached")
}
val resp = buffer.copyOfRange(0, len)
// socket.close()
if (!resp.contentEquals(byteArrayOf(0x13))) {
throw IllegalStateException("Invalid response: ${resp.joinToString(" ") { String.format("%02X", it) }}")
}
Log.d(TAG, "readPDU: ${resp.joinToString(" ") { String.format("%02X", it) }}")
} catch (e: Exception) {
Log.e(TAG, "Error writing characteristic: ${e.message}")
}
}
}

View File

@@ -26,8 +26,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManager
import me.kavishdevar.librepods.bluetooth.ATTManagerv2
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -139,15 +137,15 @@ fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
}
fun sendHearingAidSettings(
// attManager: ATTManager,
currentData: ByteArray,
hearingAidSettings: HearingAidSettings,
debounceJob: MutableState<Job?>
debounceJob: MutableState<Job?>,
sender: (ATTHandles, ByteArray) -> Unit
) {
debounceJob.value?.cancel()
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val currentData = ATTManagerv2.readCharacteristic(ATTHandles.HEARING_AID)?: return@launch
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")
@@ -185,7 +183,7 @@ fun sendHearingAidSettings(
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
ATTManagerv2.writeCharacteristic(ATTHandles.HEARING_AID, currentData)
sender(ATTHandles.HEARING_AID, currentData)
} catch (e: IOException) {
e.printStackTrace()
}

View File

@@ -83,7 +83,8 @@ data class TransparencySettings(
}
}
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
if (data.size < 50) return null // 50 is arbitrary, too lazy to count
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
val enabled = buffer.float

View File

@@ -32,13 +32,12 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -48,24 +47,17 @@ import dev.chrisbanes.haze.HazeState
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.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManagerv2
import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration.Companion.milliseconds
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@@ -76,14 +68,83 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
isSystemInDarkTheme()
val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
// val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.adjustments)
) { spacerHeight ->
val debounceJob = remember { mutableStateOf<Job?>(null) }
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val leftEQ = rememberSaveable { mutableStateOf(FloatArray(8)) }
val rightEQ = rememberSaveable { mutableStateOf(FloatArray(8)) }
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val initialized = rememberSaveable { mutableStateOf(false) }
val hearingAidSettings = remember { mutableStateOf(
HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = 0f,
rightAmplification = 0f,
leftTone = 0f,
rightTone = 0f,
leftConversationBoost = false,
rightConversationBoost = false,
leftAmbientNoiseReduction = 0f,
rightAmbientNoiseReduction = 0f,
netAmplification = 0f,
balance = 0f,
ownVoiceAmplification = 0f
)
) }
LaunchedEffect(state.hearingAidData) {
parseHearingAidSettingsResponse(state.hearingAidData)?.let { parsed ->
amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone
ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
initialized.value = true
}
}
LaunchedEffect(
amplificationSliderValue.floatValue,
balanceSliderValue.floatValue,
toneSliderValue.floatValue,
conversationBoostEnabled.value,
ambientNoiseReductionSliderValue.floatValue,
ownVoiceAmplification.floatValue
) {
if (!initialized.value) return@LaunchedEffect
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
}
StyledScaffold(title = stringResource(R.string.adjustments)) { spacerHeight ->
Column(
modifier = Modifier
.hazeSource(hazeState)
@@ -95,136 +156,6 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
) {
Spacer(modifier = Modifier.height(spacerHeight))
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
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 = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
)
}
val hearingAidATTListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseHearingAidSettingsResponse(value)
if (parsed != null) {
amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone
ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
}
}
}
}
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
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 = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue,
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(hearingAidSettings.value, debounceJob)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
// attManager.enableNotifications(ATTHandles.HEARING_AID)
// attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = ATTManagerv2.readCharacteristic(ATTHandles.HEARING_AID) ?: return@LaunchedEffect
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.milliseconds)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf()
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
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
}
}
StyledSlider(
label = stringResource(R.string.amplification),
valueRange = -1f..1f,
@@ -237,7 +168,6 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
independent = true,
)
StyledToggle(
label = stringResource(R.string.swipe_to_control_amplification),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),

View File

@@ -259,6 +259,10 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
return@launch
}
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
if (parsed == null) {
Log.w(TAG, "transparency parse failed")
return@launch
}
val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
} catch (e: Exception) {

View File

@@ -46,9 +46,10 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
@@ -64,17 +65,14 @@ 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.delay
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.data.TransparencySettings
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "TransparencySettings"
@@ -112,19 +110,26 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
Spacer(modifier = Modifier.height(topPadding))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = remember { mutableStateOf(false) }
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val eq = remember { mutableStateOf(FloatArray(8)) }
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val enabled = rememberSaveable { mutableStateOf(false) }
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val eq = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) { mutableStateOf(FloatArray(8)) }
val phoneMediaEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) { mutableStateOf(FloatArray(8) { 0.5f }) }
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val initialized = rememberSaveable { mutableStateOf(false) }
val transparencySettings = remember {
mutableStateOf(
@@ -153,23 +158,9 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
toneSliderValue.floatValue,
conversationBoostEnabled.value,
ambientNoiseReductionSliderValue.floatValue,
eq.value,
initialLoadComplete.value,
initialReadSucceeded.value
eq.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
}
if (!initialized.value) return@LaunchedEffect
transparencySettings.value = TransparencySettings(
enabled = enabled.value,
leftEQ = eq.value,
@@ -189,59 +180,20 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
try {
Log.d(TAG, "Found AACPManager, reading cached EQ data")
val aacpEQ = state.eqData
if (aacpEQ.isNotEmpty()) {
eq.value = aacpEQ.copyOf()
phoneMediaEQ.value = aacpEQ.copyOf()
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
} else {
Log.d(TAG, "AACPManager EQ data empty")
}
} catch (e: Exception) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
}
var parsedSettings: TransparencySettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = state.transparencyData
parsedSettings = parseTransparencySettingsResponse(data = data)
Log.d(TAG, "Parsed settings on attempt $attempt")
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial transparency settings: $parsedSettings")
enabled.value = parsedSettings.enabled
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue =
parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
eq.value = parsedSettings.leftEQ.copyOf()
initialReadSucceeded.value = true
} else {
Log.d(
TAG,
"Failed to read/parse initial transparency settings after ${initialReadAttempts.intValue} attempts"
)
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
initialLoadComplete.value = true
LaunchedEffect(state.transparencyData) {
val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
Log.d(TAG, "Initial transparency settings: $parsedSettings")
enabled.value = parsedSettings.enabled
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue =
parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
eq.value = parsedSettings.leftEQ.copyOf()
}
initialized.value = true
}
if (state.vendorIdHook) {

View File

@@ -35,13 +35,14 @@ import androidx.compose.foundation.verticalScroll
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -57,35 +58,19 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManagerv2
import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings
import java.io.IOException
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments"
@Composable
fun UpdateHearingTestScreen() {
fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) {
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 state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.hearing_test)
@@ -113,18 +98,31 @@ fun UpdateHearingTestScreen() {
),
textAlign = TextAlign.Center,
)
val tone = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReduction = remember { mutableFloatStateOf(0.0f) }
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
val leftAmplification = remember { mutableFloatStateOf(0.5f) }
val rightAmplification = remember { mutableFloatStateOf(0.5f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
val leftEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) {
mutableStateOf(FloatArray(8))
}
val rightEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) {
mutableStateOf(FloatArray(8))
}
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
val debounceJob = remember { mutableStateOf<Job?>(null) }
val initialized = rememberSaveable { mutableStateOf(false) }
val hearingAidSettings = remember {
mutableStateOf(
@@ -146,59 +144,35 @@ fun UpdateHearingTestScreen() {
)
}
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
tone.floatValue = parsed.leftTone
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
leftAmplification.floatValue = parsed.leftAmplification
rightAmplification.floatValue = parsed.rightAmplification
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
}
}
LaunchedEffect(state.hearingAidData) {
val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
if (parsed != null) {
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
conversationBoostEnabled.value = parsed.leftConversationBoost
tone.floatValue = parsed.leftTone
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
leftAmplification.floatValue = parsed.leftAmplification
rightAmplification.floatValue = parsed.rightAmplification
initialized.value = true
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
}
}
// DisposableEffect(Unit) {
// onDispose {
// attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
// }
// }
LaunchedEffect(
leftEQ.value,
rightEQ.value,
conversationBoostEnabled.value,
initialLoadComplete.value,
initialReadSucceeded.value,
leftAmplification.floatValue,
rightAmplification.floatValue,
tone.floatValue,
ambientNoiseReduction.floatValue,
ownVoiceAmplification.floatValue
) {
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
}
if (!initialized.value) return@LaunchedEffect
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
@@ -215,55 +189,7 @@ fun UpdateHearingTestScreen() {
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(hearingAidSettings.value, debounceJob)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
// attManager.enableNotifications(ATTHandles.HEARING_AID)
// attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = ATTManagerv2.readCharacteristic(ATTHandles.HEARING_AID)?: byteArrayOf()
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
tone.floatValue = parsedSettings.leftTone
ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
leftAmplification.floatValue = parsedSettings.leftAmplification
rightAmplification.floatValue = parsedSettings.rightAmplification
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
}
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
}
val frequencies =

View File

@@ -29,7 +29,6 @@ import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -39,8 +38,8 @@ import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManagerv2
import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.data.AirPodsModels
import me.kavishdevar.librepods.data.AirPodsNotifications
@@ -52,7 +51,6 @@ import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.services.AirPodsService
import kotlin.time.Duration.Companion.milliseconds
@Suppress("ArrayInDataClass")
data class AirPodsUiState(
@@ -147,6 +145,7 @@ class AirPodsViewModel(
loadSharedPreferences()
setupControlObservers()
loadControlList()
loadATT()
observeATT()
observeSharedPreferences()
observeBilling()
@@ -527,27 +526,36 @@ class AirPodsViewModel(
}
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
if (handle == ATTHandles.LOUD_SOUND_REDUCTION) {
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
when (handle) {
// ideally should be using a different viewmodel for ATT based things because there are a lot of values, and I am not going to add all to this state, but there's loudsoundreduction.
ATTHandles.LOUD_SOUND_REDUCTION -> {
_uiState.value = _uiState.value.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01)
}
ATTHandles.HEARING_AID -> {
_uiState.value = _uiState.value.copy(hearingAidData = value)
}
ATTHandles.TRANSPARENCY -> {
_uiState.value = _uiState.value.copy(transparencyData = value)
}
}
viewModelScope.launch(Dispatchers.IO) {
try {
ATTManagerv2.writeCharacteristic(handle, value)
service.attManager.writeCharacteristic(handle, value)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun refreshATT() {
viewModelScope.launch(Dispatchers.IO) {
val loudSoundReduction = ATTManagerv2.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf()
val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) {
loudSoundReduction[0].toInt() == 1
} else false
val transparencyData = ATTManagerv2.readCharacteristic(ATTHandles.TRANSPARENCY)?: byteArrayOf()
val hearingAidData = ATTManagerv2.readCharacteristic(ATTHandles.HEARING_AID)?:byteArrayOf()
_uiState.value = _uiState.value.copy(
fun loadATT() {
val loudSoundReduction = service.attManager.getCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf()
val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) {
loudSoundReduction[0].toInt() == 1
} else false
val hearingAidData = service.attManager.getCharacteristic(ATTHandles.HEARING_AID) ?: byteArrayOf()
val transparencyData = service.attManager.getCharacteristic(ATTHandles.TRANSPARENCY) ?: byteArrayOf()
_uiState.update {
it.copy(
loudSoundReductionEnabled = loudSoundReductionEnabled,
transparencyData = transparencyData,
hearingAidData = hearingAidData
@@ -557,9 +565,30 @@ class AirPodsViewModel(
fun observeATT() {
viewModelScope.launch(Dispatchers.IO) {
while (true) {
refreshATT()
delay(15000.milliseconds)
service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
// service.attManager.enableNotification(ATTCCCDHandles.LOUD_SOUND_REDUCTION)
}
service.attManager.setOnNotificationReceived { handle, value ->
when (handle) {
ATTHandles.LOUD_SOUND_REDUCTION.value.toByte() -> {
val loudSoundReductionEnabled = if (value.isNotEmpty()) {
value[0].toInt() == 1
} else false
_uiState.update {
it.copy(loudSoundReductionEnabled = loudSoundReductionEnabled)
}
}
ATTHandles.HEARING_AID.value.toByte() -> {
_uiState.update {
it.copy(hearingAidData = value)
}
}
ATTHandles.TRANSPARENCY.value.toByte() -> {
_uiState.update {
it.copy(transparencyData = value)
}
}
}
}
}

View File

@@ -85,7 +85,9 @@ import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.bluetooth.ATTManager
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManagerv2
import me.kavishdevar.librepods.bluetooth.BLEManager
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.data.AirPodsInstance
@@ -126,7 +128,6 @@ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.time.LocalDateTime
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration.Companion.milliseconds
@@ -152,6 +153,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var macAddress = ""
var localMac = ""
lateinit var aacpManager: AACPManager
lateinit var attManager: ATTManagerv2
var airpodsInstance: AirPodsInstance? = null
var cameraActive = false
private var disconnectedBecauseReversed = false
@@ -380,6 +382,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
aacpManager = AACPManager()
initializeAACPManagerCallback()
attManager = ATTManagerv2()
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
localMac = config.selfMacAddress
@@ -693,7 +697,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
popupShown = false
updateNotificationContent(false)
aacpManager.disconnected()
BluetoothConnectionManager.getATTSocket()?.close()
attManager.disconnected()
BluetoothConnectionManager.setCurrentConnection(null, null)
}
}
@@ -2702,6 +2706,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} else null
attSocket?.connect()
BluetoothConnectionManager.setCurrentConnection(socket, attSocket)
if (attSocket != null) {
attManager.startReader()
attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION)
attManager.readCharacteristic(ATTHandles.TRANSPARENCY)
attManager.readCharacteristic(ATTHandles.HEARING_AID)
attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
// attManager.enableNotification(ATTCCCDHandles.LOUD_SOUND_REDUCTION)
attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
}
// Create AirPodsInstance from stored config if available
if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) {
@@ -2906,7 +2919,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
socket.close()
// isConnectedLocally = false
aacpManager.disconnected()
BluetoothConnectionManager.getATTSocket()?.close()
attManager.disconnected()
BluetoothConnectionManager.setCurrentConnection(null, null)
updateNotificationContent(false)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {