mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-05-31 13:33:02 +00:00
android: move ATT code to viewmodel from screens and enable notifications
This commit is contained in:
@@ -466,7 +466,7 @@ fun Main() {
|
|||||||
OpenSourceLicensesScreen(navController)
|
OpenSourceLicensesScreen(navController)
|
||||||
}
|
}
|
||||||
composable("update_hearing_test") {
|
composable("update_hearing_test") {
|
||||||
if (airPodsViewModel != null) UpdateHearingTestScreen()
|
if (airPodsViewModel != null) UpdateHearingTestScreen(airPodsViewModel)
|
||||||
}
|
}
|
||||||
composable("version_info") {
|
composable("version_info") {
|
||||||
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
|
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
|
||||||
|
|||||||
@@ -16,247 +16,196 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* This is a very basic ATT (Attribute Protocol) implementation. I have only implemented
|
|
||||||
* what is necessary for LibrePods to function, i.e. reading and writing characteristics,
|
|
||||||
* and receiving notifications. It is not a complete implementation of the ATT protocol.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.bluetooth
|
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 android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
private const val TAG = "ATTManager"
|
||||||
|
|
||||||
enum class ATTHandles(val value: Int) {
|
enum class ATTHandles(val value: Int) {
|
||||||
TRANSPARENCY(0x18),
|
TRANSPARENCY(0x18),
|
||||||
LOUD_SOUND_REDUCTION(0x1B),
|
LOUD_SOUND_REDUCTION(0x1B),
|
||||||
HEARING_AID(0x2A),
|
HEARING_AID(0x2A)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ATTCCCDHandles(val value: Int) {
|
enum class ATTCCCDHandles(val value: Int) {
|
||||||
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
|
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
|
||||||
LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1),
|
// LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work
|
||||||
HEARING_AID(ATTHandles.HEARING_AID.value + 1),
|
HEARING_AID(ATTHandles.HEARING_AID.value + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) {
|
class ATTManagerv2 {
|
||||||
companion object {
|
val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
|
||||||
private const val OPCODE_READ_REQUEST: Byte = 0x0A
|
|
||||||
private const val OPCODE_WRITE_REQUEST: Byte = 0x12
|
private val responseQueues = ConcurrentHashMap<Byte, LinkedBlockingQueue<ByteArray>>()
|
||||||
private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B
|
|
||||||
|
private val readerRunning = AtomicBoolean(false)
|
||||||
|
private var readerThread: Thread? = null
|
||||||
|
|
||||||
|
private var onNotificationReceived: ((handle: Byte, value: ByteArray) -> Unit)? = null
|
||||||
|
|
||||||
|
fun startReader() {
|
||||||
|
if (readerRunning.getAndSet(true)) return
|
||||||
|
|
||||||
|
readerThread = Thread {
|
||||||
|
try {
|
||||||
|
runReaderLoop()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "reader thread crashed: ${t.message}", t)
|
||||||
|
} finally {
|
||||||
|
readerRunning.set(false)
|
||||||
|
Log.d(TAG, "reader thread stopped")
|
||||||
|
}
|
||||||
|
}.also { it.name = "ATT-Reader"; it.isDaemon = true; it.start() }
|
||||||
|
Log.d(TAG, "reader started")
|
||||||
}
|
}
|
||||||
private val id = System.identityHashCode(this)
|
|
||||||
@Suppress("PrivatePropertyName")
|
|
||||||
private val TAG = "ATTManager[$id]"
|
|
||||||
var socket: BluetoothSocket? = null
|
|
||||||
private var input: InputStream? = null
|
|
||||||
private var output: OutputStream? = null
|
|
||||||
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
|
|
||||||
private var notificationJob: Job? = null
|
|
||||||
|
|
||||||
// queue for non-notification PDUs (responses to requests)
|
fun stopReader() {
|
||||||
private val responses = LinkedBlockingQueue<ByteArray>()
|
readerRunning.set(false)
|
||||||
|
readerThread?.interrupt()
|
||||||
|
readerThread = null
|
||||||
|
}
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) {
|
||||||
fun connect() {
|
onNotificationReceived = listener
|
||||||
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
}
|
||||||
|
|
||||||
if (socket == null) {
|
fun enableNotification(handle: ATTCCCDHandles) {
|
||||||
Log.d(TAG, "Socket doesn't exist, creating")
|
writeCharacteristic(handle.value.toByte(), byteArrayOf(0x01))
|
||||||
try {
|
}
|
||||||
socket = createBluetoothSocket(adapter, device, uuid)
|
|
||||||
} catch (e: Exception) {
|
fun getCharacteristic(handle: ATTHandles): ByteArray? {
|
||||||
Log.w(TAG, "Failed to create socket")
|
val storedValue = characteristicList[handle]
|
||||||
e.printStackTrace()
|
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
|
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 {
|
Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
while (socket?.isConnected == true) {
|
} catch (e: Exception) {
|
||||||
try {
|
Log.e(TAG, "error writing characteristic: ${e.message}")
|
||||||
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)
|
fun disconnected() {
|
||||||
val value = pdu.copyOfRange(3, pdu.size)
|
characteristicList.clear()
|
||||||
listeners[handle]?.forEach { listener ->
|
stopReader()
|
||||||
try {
|
val socket = BluetoothConnectionManager.getATTSocket() ?: return
|
||||||
listener(value)
|
try {
|
||||||
Log.d(TAG, "Dispatched notification for handle $handle to listener, with value ${value.joinToString(" ") { String.format("%02X", it) }}")
|
socket.close()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Error in listener for handle $handle: ${e.message}")
|
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 {
|
} else {
|
||||||
// not a notification -> treat as a response for pending request(s)
|
Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
responses.put(pdu)
|
|
||||||
}
|
}
|
||||||
} 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) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Constructor signature #${index + 1} failed: ${e.message}")
|
Log.e(TAG, "error in reader loop: ${e.message}", e)
|
||||||
lastException = e
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
|
readerRunning.set(false)
|
||||||
Log.e(TAG, errorMessage)
|
}
|
||||||
throw lastException ?: IllegalStateException(errorMessage)
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,8 +26,6 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTManager
|
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTManagerv2
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
@@ -139,15 +137,15 @@ fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendHearingAidSettings(
|
fun sendHearingAidSettings(
|
||||||
// attManager: ATTManager,
|
currentData: ByteArray,
|
||||||
hearingAidSettings: HearingAidSettings,
|
hearingAidSettings: HearingAidSettings,
|
||||||
debounceJob: MutableState<Job?>
|
debounceJob: MutableState<Job?>,
|
||||||
|
sender: (ATTHandles, ByteArray) -> Unit
|
||||||
) {
|
) {
|
||||||
debounceJob.value?.cancel()
|
debounceJob.value?.cancel()
|
||||||
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
|
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
|
||||||
delay(100)
|
delay(100)
|
||||||
try {
|
try {
|
||||||
val currentData = ATTManagerv2.readCharacteristic(ATTHandles.HEARING_AID)?: return@launch
|
|
||||||
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
if (currentData.size < 104) {
|
if (currentData.size < 104) {
|
||||||
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
|
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) }}")
|
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) {
|
} catch (e: IOException) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
|
||||||
val enabled = buffer.float
|
val enabled = buffer.float
|
||||||
|
|||||||
@@ -32,13 +32,12 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -48,24 +47,17 @@ import dev.chrisbanes.haze.HazeState
|
|||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import me.kavishdevar.librepods.R
|
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.AACPManager
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTManagerv2
|
|
||||||
import me.kavishdevar.librepods.data.HearingAidSettings
|
import me.kavishdevar.librepods.data.HearingAidSettings
|
||||||
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||||
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
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 me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import java.io.IOException
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
|
||||||
private const val TAG = "HearingAidAdjustments"
|
private const val TAG = "HearingAidAdjustments"
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
@SuppressLint("DefaultLocale")
|
||||||
@@ -76,14 +68,83 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
|||||||
isSystemInDarkTheme()
|
isSystemInDarkTheme()
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val hazeState = remember { HazeState() }
|
val hazeState = remember { HazeState() }
|
||||||
// val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
|
|
||||||
|
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.adjustments)
|
val debounceJob = remember { mutableStateOf<Job?>(null) }
|
||||||
) { spacerHeight ->
|
|
||||||
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.hazeSource(hazeState)
|
.hazeSource(hazeState)
|
||||||
@@ -95,136 +156,6 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
|||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
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(
|
StyledSlider(
|
||||||
label = stringResource(R.string.amplification),
|
label = stringResource(R.string.amplification),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
@@ -237,7 +168,6 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
|||||||
independent = true,
|
independent = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.swipe_to_control_amplification),
|
label = stringResource(R.string.swipe_to_control_amplification),
|
||||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
|||||||
@@ -259,6 +259,10 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
||||||
|
if (parsed == null) {
|
||||||
|
Log.w(TAG, "transparency parse failed")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
val disabledSettings = parsed.copy(enabled = false)
|
val disabledSettings = parsed.copy(enabled = false)
|
||||||
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
|
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
@@ -64,17 +65,14 @@ import com.kyant.backdrop.backdrops.layerBackdrop
|
|||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import me.kavishdevar.librepods.BuildConfig
|
|
||||||
import me.kavishdevar.librepods.R
|
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.TransparencySettings
|
||||||
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
||||||
import me.kavishdevar.librepods.data.sendTransparencySettings
|
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 me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import java.io.IOException
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private const val TAG = "TransparencySettings"
|
private const val TAG = "TransparencySettings"
|
||||||
@@ -112,19 +110,26 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
Spacer(modifier = Modifier.height(topPadding))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
val enabled = remember { mutableStateOf(false) }
|
val enabled = rememberSaveable { mutableStateOf(false) }
|
||||||
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
|
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||||
val eq = remember { mutableStateOf(FloatArray(8)) }
|
val eq = rememberSaveable(
|
||||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
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 initialized = rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
|
||||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
val transparencySettings = remember {
|
val transparencySettings = remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -153,23 +158,9 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
toneSliderValue.floatValue,
|
toneSliderValue.floatValue,
|
||||||
conversationBoostEnabled.value,
|
conversationBoostEnabled.value,
|
||||||
ambientNoiseReductionSliderValue.floatValue,
|
ambientNoiseReductionSliderValue.floatValue,
|
||||||
eq.value,
|
eq.value
|
||||||
initialLoadComplete.value,
|
|
||||||
initialReadSucceeded.value
|
|
||||||
) {
|
) {
|
||||||
if (!initialLoadComplete.value) {
|
if (!initialized.value) return@LaunchedEffect
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
transparencySettings.value = TransparencySettings(
|
transparencySettings.value = TransparencySettings(
|
||||||
enabled = enabled.value,
|
enabled = enabled.value,
|
||||||
leftEQ = eq.value,
|
leftEQ = eq.value,
|
||||||
@@ -189,59 +180,20 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
|
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(state.transparencyData) {
|
||||||
Log.d(TAG, "Connecting to ATT...")
|
val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
|
||||||
try {
|
Log.d(TAG, "Initial transparency settings: $parsedSettings")
|
||||||
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
|
enabled.value = parsedSettings.enabled
|
||||||
try {
|
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
||||||
Log.d(TAG, "Found AACPManager, reading cached EQ data")
|
balanceSliderValue.floatValue = parsedSettings.balance
|
||||||
val aacpEQ = state.eqData
|
toneSliderValue.floatValue = parsedSettings.leftTone
|
||||||
if (aacpEQ.isNotEmpty()) {
|
ambientNoiseReductionSliderValue.floatValue =
|
||||||
eq.value = aacpEQ.copyOf()
|
parsedSettings.leftAmbientNoiseReduction
|
||||||
phoneMediaEQ.value = aacpEQ.copyOf()
|
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||||
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
|
||||||
} else {
|
eq.value = parsedSettings.leftEQ.copyOf()
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
initialized.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.vendorIdHook) {
|
if (state.vendorIdHook) {
|
||||||
|
|||||||
@@ -35,13 +35,14 @@ import androidx.compose.foundation.verticalScroll
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -57,35 +58,19 @@ import com.kyant.backdrop.backdrops.layerBackdrop
|
|||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import me.kavishdevar.librepods.R
|
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.HearingAidSettings
|
||||||
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||||
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
||||||
import java.io.IOException
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
|
||||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
|
||||||
private const val TAG = "HearingAidAdjustments"
|
private const val TAG = "HearingAidAdjustments"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateHearingTestScreen() {
|
fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) {
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
// val attManager = ServiceManager.getService()?.attManager
|
val state by viewModel.uiState.collectAsState()
|
||||||
// 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 backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.hearing_test)
|
title = stringResource(R.string.hearing_test)
|
||||||
@@ -113,18 +98,31 @@ fun UpdateHearingTestScreen() {
|
|||||||
),
|
),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
val tone = remember { mutableFloatStateOf(0.5f) }
|
val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val ambientNoiseReduction = remember { mutableFloatStateOf(0.0f) }
|
val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||||
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
|
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val leftAmplification = remember { mutableFloatStateOf(0.5f) }
|
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val rightAmplification = remember { mutableFloatStateOf(0.5f) }
|
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||||
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
|
val leftEQ = rememberSaveable(
|
||||||
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
|
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 debounceJob = remember { mutableStateOf<Job?>(null) }
|
||||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
val hearingAidSettings = remember {
|
val hearingAidSettings = remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -146,59 +144,35 @@ fun UpdateHearingTestScreen() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val hearingAidATTListener = remember {
|
LaunchedEffect(state.hearingAidData) {
|
||||||
object : (ByteArray) -> Unit {
|
val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
|
||||||
override fun invoke(value: ByteArray) {
|
if (parsed != null) {
|
||||||
val parsed = parseHearingAidSettingsResponse(value)
|
leftEQ.value = parsed.leftEQ.copyOf()
|
||||||
if (parsed != null) {
|
rightEQ.value = parsed.rightEQ.copyOf()
|
||||||
leftEQ.value = parsed.leftEQ.copyOf()
|
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||||
rightEQ.value = parsed.rightEQ.copyOf()
|
tone.floatValue = parsed.leftTone
|
||||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
|
||||||
tone.floatValue = parsed.leftTone
|
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||||
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
|
leftAmplification.floatValue = parsed.leftAmplification
|
||||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
rightAmplification.floatValue = parsed.rightAmplification
|
||||||
leftAmplification.floatValue = parsed.leftAmplification
|
initialized.value = true
|
||||||
rightAmplification.floatValue = parsed.rightAmplification
|
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
} else {
|
||||||
} else {
|
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// DisposableEffect(Unit) {
|
|
||||||
// onDispose {
|
|
||||||
// attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
leftEQ.value,
|
leftEQ.value,
|
||||||
rightEQ.value,
|
rightEQ.value,
|
||||||
conversationBoostEnabled.value,
|
conversationBoostEnabled.value,
|
||||||
initialLoadComplete.value,
|
|
||||||
initialReadSucceeded.value,
|
|
||||||
leftAmplification.floatValue,
|
leftAmplification.floatValue,
|
||||||
rightAmplification.floatValue,
|
rightAmplification.floatValue,
|
||||||
tone.floatValue,
|
tone.floatValue,
|
||||||
ambientNoiseReduction.floatValue,
|
ambientNoiseReduction.floatValue,
|
||||||
ownVoiceAmplification.floatValue
|
ownVoiceAmplification.floatValue
|
||||||
) {
|
) {
|
||||||
if (!initialLoadComplete.value) {
|
if (!initialized.value) return@LaunchedEffect
|
||||||
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(
|
hearingAidSettings.value = HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
@@ -215,55 +189,7 @@ fun UpdateHearingTestScreen() {
|
|||||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
)
|
)
|
||||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||||
sendHearingAidSettings(hearingAidSettings.value, debounceJob)
|
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val frequencies =
|
val frequencies =
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import androidx.core.content.edit
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -39,8 +38,8 @@ import me.kavishdevar.librepods.BuildConfig
|
|||||||
import me.kavishdevar.librepods.billing.BillingManager
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
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.ATTHandles
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTManagerv2
|
|
||||||
import me.kavishdevar.librepods.data.AirPodsInstance
|
import me.kavishdevar.librepods.data.AirPodsInstance
|
||||||
import me.kavishdevar.librepods.data.AirPodsModels
|
import me.kavishdevar.librepods.data.AirPodsModels
|
||||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
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.StemAction
|
||||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
import me.kavishdevar.librepods.services.AirPodsService
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
@Suppress("ArrayInDataClass")
|
@Suppress("ArrayInDataClass")
|
||||||
data class AirPodsUiState(
|
data class AirPodsUiState(
|
||||||
@@ -147,6 +145,7 @@ class AirPodsViewModel(
|
|||||||
loadSharedPreferences()
|
loadSharedPreferences()
|
||||||
setupControlObservers()
|
setupControlObservers()
|
||||||
loadControlList()
|
loadControlList()
|
||||||
|
loadATT()
|
||||||
observeATT()
|
observeATT()
|
||||||
observeSharedPreferences()
|
observeSharedPreferences()
|
||||||
observeBilling()
|
observeBilling()
|
||||||
@@ -527,27 +526,36 @@ class AirPodsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
|
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
|
||||||
if (handle == ATTHandles.LOUD_SOUND_REDUCTION) {
|
when (handle) {
|
||||||
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
|
// 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) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
ATTManagerv2.writeCharacteristic(handle, value)
|
service.attManager.writeCharacteristic(handle, value)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshATT() {
|
fun loadATT() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
val loudSoundReduction = service.attManager.getCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf()
|
||||||
val loudSoundReduction = ATTManagerv2.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf()
|
val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) {
|
||||||
val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) {
|
loudSoundReduction[0].toInt() == 1
|
||||||
loudSoundReduction[0].toInt() == 1
|
} else false
|
||||||
} else false
|
val hearingAidData = service.attManager.getCharacteristic(ATTHandles.HEARING_AID) ?: byteArrayOf()
|
||||||
val transparencyData = ATTManagerv2.readCharacteristic(ATTHandles.TRANSPARENCY)?: byteArrayOf()
|
val transparencyData = service.attManager.getCharacteristic(ATTHandles.TRANSPARENCY) ?: byteArrayOf()
|
||||||
val hearingAidData = ATTManagerv2.readCharacteristic(ATTHandles.HEARING_AID)?:byteArrayOf()
|
_uiState.update {
|
||||||
_uiState.value = _uiState.value.copy(
|
it.copy(
|
||||||
loudSoundReductionEnabled = loudSoundReductionEnabled,
|
loudSoundReductionEnabled = loudSoundReductionEnabled,
|
||||||
transparencyData = transparencyData,
|
transparencyData = transparencyData,
|
||||||
hearingAidData = hearingAidData
|
hearingAidData = hearingAidData
|
||||||
@@ -557,9 +565,30 @@ class AirPodsViewModel(
|
|||||||
|
|
||||||
fun observeATT() {
|
fun observeATT() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
while (true) {
|
service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
|
||||||
refreshATT()
|
service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
|
||||||
delay(15000.milliseconds)
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,9 @@ import me.kavishdevar.librepods.MainActivity
|
|||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
|
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.BLEManager
|
||||||
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
||||||
import me.kavishdevar.librepods.data.AirPodsInstance
|
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 me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.time.LocalDateTime
|
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
@@ -152,6 +153,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
var macAddress = ""
|
var macAddress = ""
|
||||||
var localMac = ""
|
var localMac = ""
|
||||||
lateinit var aacpManager: AACPManager
|
lateinit var aacpManager: AACPManager
|
||||||
|
lateinit var attManager: ATTManagerv2
|
||||||
var airpodsInstance: AirPodsInstance? = null
|
var airpodsInstance: AirPodsInstance? = null
|
||||||
var cameraActive = false
|
var cameraActive = false
|
||||||
private var disconnectedBecauseReversed = false
|
private var disconnectedBecauseReversed = false
|
||||||
@@ -380,6 +382,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
aacpManager = AACPManager()
|
aacpManager = AACPManager()
|
||||||
initializeAACPManagerCallback()
|
initializeAACPManagerCallback()
|
||||||
|
|
||||||
|
attManager = ATTManagerv2()
|
||||||
|
|
||||||
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
||||||
|
|
||||||
localMac = config.selfMacAddress
|
localMac = config.selfMacAddress
|
||||||
@@ -693,7 +697,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
popupShown = false
|
popupShown = false
|
||||||
updateNotificationContent(false)
|
updateNotificationContent(false)
|
||||||
aacpManager.disconnected()
|
aacpManager.disconnected()
|
||||||
BluetoothConnectionManager.getATTSocket()?.close()
|
attManager.disconnected()
|
||||||
BluetoothConnectionManager.setCurrentConnection(null, null)
|
BluetoothConnectionManager.setCurrentConnection(null, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2702,6 +2706,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
} else null
|
} else null
|
||||||
attSocket?.connect()
|
attSocket?.connect()
|
||||||
BluetoothConnectionManager.setCurrentConnection(socket, attSocket)
|
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
|
// Create AirPodsInstance from stored config if available
|
||||||
if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) {
|
if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) {
|
||||||
@@ -2906,7 +2919,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
socket.close()
|
socket.close()
|
||||||
// isConnectedLocally = false
|
// isConnectedLocally = false
|
||||||
aacpManager.disconnected()
|
aacpManager.disconnected()
|
||||||
BluetoothConnectionManager.getATTSocket()?.close()
|
attManager.disconnected()
|
||||||
BluetoothConnectionManager.setCurrentConnection(null, null)
|
BluetoothConnectionManager.setCurrentConnection(null, null)
|
||||||
updateNotificationContent(false)
|
updateNotificationContent(false)
|
||||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||||
|
|||||||
Reference in New Issue
Block a user