From 0f50eab7881171f5fc25068763a2ba77105eca5c Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Sat, 30 May 2026 16:59:36 +0530 Subject: [PATCH] android: move ATT code to viewmodel from screens and enable notifications --- .../me/kavishdevar/librepods/MainActivity.kt | 2 +- .../librepods/bluetooth/ATTManager.kt | 373 ++++++++---------- .../librepods/bluetooth/ATTManagerv2.kt | 63 --- .../kavishdevar/librepods/data/HearingAid.kt | 10 +- .../librepods/data/Transparency.kt | 3 +- .../screens/HearingAidAdjustmentsScreen.kt | 228 ++++------- .../presentation/screens/HearingAidScreen.kt | 4 + .../screens/TransparencySettingsScreen.kt | 126 ++---- .../screens/UpdateHearingTestScreen.kt | 172 +++----- .../viewmodel/AirPodsViewModel.kt | 65 ++- .../librepods/services/AirPodsService.kt | 21 +- 11 files changed, 403 insertions(+), 664 deletions(-) delete mode 100644 android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManagerv2.kt 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 {