diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
index e46563bc..065781cc 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
@@ -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)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt
index 8f851775..4dac27fa 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt
@@ -16,247 +16,196 @@
along with this program. If not, see .
*/
- /* 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()
+
+ private val responseQueues = ConcurrentHashMap>()
+
+ 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 Unit>>()
- private var notificationJob: Job? = null
- // queue for non-notification PDUs (responses to requests)
- private val responses = LinkedBlockingQueue()
+ 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
+ }
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManagerv2.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManagerv2.kt
deleted file mode 100644
index 2348336c..00000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManagerv2.kt
+++ /dev/null
@@ -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}")
- }
- }
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt
index d0b64441..2e416f5c 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt
@@ -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
+ debounceJob: MutableState,
+ 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()
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/Transparency.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/Transparency.kt
index 29afefbb..c43e1cff 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/data/Transparency.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/data/Transparency.kt
@@ -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
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt
index 7a41019a..108e19c0 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt
@@ -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 = 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(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(),
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt
index 0568664d..3cb72ce4 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt
@@ -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) {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt
index fd7a8937..52677121 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt
@@ -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) {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt
index 4a4c534f..175a830c 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt
@@ -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 = 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(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 =
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt
index 9570cb21..08807de3 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt
@@ -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)
+ }
+ }
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
index 4f5eee67..881b68be 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
@@ -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 {