android: fix rework ATT connection

This commit is contained in:
Kavish Devar
2026-05-30 12:09:05 +05:30
parent 571db0ebde
commit af4261485a
8 changed files with 143 additions and 117 deletions

View File

@@ -1143,7 +1143,7 @@ class AACPManager {
)
}
val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
val socket = BluetoothConnectionManager.getAACPSocket() ?: return false
if (socket.isConnected) {
socket.outputStream?.write(packet)

View File

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

View File

@@ -18,23 +18,22 @@
package me.kavishdevar.librepods.bluetooth
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.util.Log
object BluetoothConnectionManager {
private const val TAG = "BluetoothConnectionManager"
private var aacpSocket: BluetoothSocket? = null
private var attSocket: BluetoothSocket? = null
private var currentSocket: BluetoothSocket? = null
private var currentDevice: BluetoothDevice? = null
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
currentSocket = socket
currentDevice = device
Log.d(TAG, "Current connection set to device: ${device.address}")
fun setCurrentConnection(aacpSocket: BluetoothSocket?, attSocket: BluetoothSocket?) {
BluetoothConnectionManager.aacpSocket = aacpSocket
BluetoothConnectionManager.attSocket = attSocket
}
fun getCurrentSocket(): BluetoothSocket? {
return currentSocket
fun getAACPSocket(): BluetoothSocket? {
return aacpSocket
}
fun getATTSocket(): BluetoothSocket? {
return attSocket
}
}

View File

@@ -27,6 +27,7 @@ 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
@@ -138,7 +139,7 @@ fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
}
fun sendHearingAidSettings(
attManager: ATTManager,
// attManager: ATTManager,
hearingAidSettings: HearingAidSettings,
debounceJob: MutableState<Job?>
) {
@@ -146,7 +147,7 @@ fun sendHearingAidSettings(
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val currentData = attManager.read(ATTHandles.HEARING_AID)
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")
@@ -184,7 +185,7 @@ fun sendHearingAidSettings(
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
attManager.write(ATTHandles.HEARING_AID, currentData)
ATTManagerv2.writeCharacteristic(ATTHandles.HEARING_AID, currentData)
} catch (e: IOException) {
e.printStackTrace()
}

View File

@@ -56,12 +56,14 @@ 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.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration.Companion.milliseconds
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments"
@@ -74,7 +76,7 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
isSystemInDarkTheme()
val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
// val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
val state by viewModel.uiState.collectAsState()
@@ -175,20 +177,20 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
sendHearingAidSettings(hearingAidSettings.value, debounceJob)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
// 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 = attManager.read(ATTHandles.HEARING_AID)
val data = ATTManagerv2.readCharacteristic(ATTHandles.HEARING_AID) ?: return@LaunchedEffect
parsedSettings = parseHearingAidSettingsResponse(data = data)
if (parsedSettings != null) {
Log.d(TAG, "Parsed settings on attempt $attempt")
@@ -199,7 +201,7 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
delay(200.milliseconds)
}
if (parsedSettings != null) {

View File

@@ -62,6 +62,7 @@ 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
@@ -73,17 +74,17 @@ private const val TAG = "HearingAidAdjustments"
@Composable
fun UpdateHearingTestScreen() {
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 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 backdrop = rememberLayerBackdrop()
StyledScaffold(
@@ -167,11 +168,11 @@ fun UpdateHearingTestScreen() {
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
}
}
// DisposableEffect(Unit) {
// onDispose {
// attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
// }
// }
LaunchedEffect(
leftEQ.value,
@@ -214,20 +215,20 @@ fun UpdateHearingTestScreen() {
ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
sendHearingAidSettings(hearingAidSettings.value, debounceJob)
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
attManager.enableNotifications(ATTHandles.HEARING_AID)
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
// 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 = attManager.read(ATTHandles.HEARING_AID)
val data = ATTManagerv2.readCharacteristic(ATTHandles.HEARING_AID)?: byteArrayOf()
parsedSettings = parseHearingAidSettingsResponse(data = data)
if (parsedSettings != null) {
Log.d(TAG, "Parsed settings on attempt $attempt")

View File

@@ -40,6 +40,7 @@ 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.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
@@ -51,6 +52,7 @@ 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(
@@ -530,13 +532,7 @@ class AirPodsViewModel(
}
viewModelScope.launch(Dispatchers.IO) {
try {
if (service.attManager?.socket?.isConnected != true) {
service.attManager?.connect()
}
while (service.attManager?.socket?.isConnected != true) {
delay(250)
}
service.attManager?.write(handle, value)
ATTManagerv2.writeCharacteristic(handle, value)
} catch (e: Exception) {
e.printStackTrace()
}
@@ -545,19 +541,14 @@ class AirPodsViewModel(
fun refreshATT() {
viewModelScope.launch(Dispatchers.IO) {
val loudSoundReduction =
runCatching { service.attManager?.read(ATTHandles.LOUD_SOUND_REDUCTION) }.getOrNull()
val loudSoundReductionEnabled = loudSoundReduction?.size?.let {
if (it > 0) {
val loudSoundReduction = ATTManagerv2.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf()
val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) {
loudSoundReduction[0].toInt() == 1
} else false
}
val transparencyData =
runCatching { service.attManager?.read(ATTHandles.TRANSPARENCY) }.getOrNull()?: byteArrayOf()
val hearingAidData =
runCatching { service.attManager?.read(ATTHandles.HEARING_AID) }.getOrNull()?: byteArrayOf()
} else false
val transparencyData = ATTManagerv2.readCharacteristic(ATTHandles.TRANSPARENCY)?: byteArrayOf()
val hearingAidData = ATTManagerv2.readCharacteristic(ATTHandles.HEARING_AID)?:byteArrayOf()
_uiState.value = _uiState.value.copy(
loudSoundReductionEnabled = loudSoundReductionEnabled == true,
loudSoundReductionEnabled = loudSoundReductionEnabled,
transparencyData = transparencyData,
hearingAidData = hearingAidData
)
@@ -566,19 +557,9 @@ class AirPodsViewModel(
fun observeATT() {
viewModelScope.launch(Dispatchers.IO) {
if (service.attManager?.socket?.isConnected != true) {
service.attManager?.connect()
}
while (service.attManager?.socket?.isConnected != true) {
delay(1000)
}
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
while (true) {
refreshATT()
delay(15000)
delay(15000.milliseconds)
}
}
}

View File

@@ -129,6 +129,7 @@ import java.nio.ByteOrder
import java.time.LocalDateTime
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "AirPodsService"
@@ -151,7 +152,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var macAddress = ""
var localMac = ""
lateinit var aacpManager: AACPManager
var attManager: ATTManager? = null
var airpodsInstance: AirPodsInstance? = null
var cameraActive = false
private var disconnectedBecauseReversed = false
@@ -654,6 +654,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT")
addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED")
addAction("android.bluetooth.device.action.UUID")
}
connectionReceiver = object : BroadcastReceiver() {
@@ -691,8 +692,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// isConnectedLocally = false
popupShown = false
updateNotificationContent(false)
attManager?.disconnect()
attManager = null
aacpManager.disconnected()
BluetoothConnectionManager.getATTSocket()?.close()
BluetoothConnectionManager.setCurrentConnection(null, null)
}
}
}
@@ -2432,32 +2434,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
takeOver("music", manualTakeOverAfterReversed = true)
}
val bluetoothManager = getSystemService(BluetoothManager::class.java)
val bluetoothAdapter = bluetoothManager.adapter
bluetoothAdapter?.bondedDevices?.forEach { device ->
device.fetchUuidsWithSdp()
if (device.uuids != null) {
// Check for the AirPods service UUID
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
if (device.uuids.contains(uuid)) {
Log.d(TAG, "Found AirPods device: ${device.name} (${device.address})")
// Connect or do whatever you need
CoroutineScope(Dispatchers.IO).launch {
connectToSocket(bluetoothAdapter, device)
}
setMetadatas(device)
macAddress = device.address
sharedPreferences.edit {
putString("mac_address", macAddress)
}
}
}
}
return START_STICKY
}
@@ -2647,15 +2623,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
private fun createBluetoothSocket(
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
): BluetoothSocket {
val type = 3 // L2CAP
val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, true, 0x1001, uuid), // A16QPR3
arrayOf(device, type, true, true, 0x1001, uuid),
arrayOf(device, type, 1, true, true, 0x1001, uuid),
arrayOf(type, 1, true, true, device, 0x1001, uuid),
arrayOf(type, true, true, device, 0x1001, uuid)
arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
arrayOf(device, type, true, true, psm, uuid),
arrayOf(device, type, 1, true, true, psm, uuid),
arrayOf(type, 1, true, true, device, psm, uuid),
arrayOf(type, true, true, device, psm, uuid)
)
val constructors = BluetoothSocket::class.java.declaredConstructors
@@ -2701,7 +2677,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
// if (!isConnectedLocally) {
socket = try {
createBluetoothSocket(adapter, device, uuid)
createBluetoothSocket(adapter, device, uuid, 4097)
} catch (e: Exception) {
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
@@ -2710,20 +2686,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
try {
runBlocking {
withTimeout(5000L) {
withTimeout(5000.milliseconds) {
try {
socket.connect()
// isConnectedLocally = true
this@AirPodsService.device = device
BluetoothConnectionManager.setCurrentConnection(socket, device)
val xposedRemotePref = XposedRemotePrefProvider.create()
if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
if (attManager == null) {
attManager = ATTManager(adapter, device)
attManager!!.connect()
}
}
val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
createBluetoothSocket(
adapter,
device,
ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"),
31
)
} else null
attSocket?.connect()
BluetoothConnectionManager.setCurrentConnection(socket, attSocket)
// Create AirPodsInstance from stored config if available
if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) {
@@ -2928,7 +2906,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
socket.close()
// isConnectedLocally = false
aacpManager.disconnected()
attManager?.disconnect()
BluetoothConnectionManager.getATTSocket()?.close()
BluetoothConnectionManager.setCurrentConnection(null, null)
updateNotificationContent(false)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
setPackage(packageName)