android: improve connection handling

This commit is contained in:
Kavish Devar
2025-10-26 20:06:06 +05:30
parent cea09b208a
commit 55768beb7c
8 changed files with 115 additions and 48 deletions

View File

@@ -15,7 +15,7 @@ android {
minSdk = 28
targetSdk = 36
versionCode = 8
versionName = "0.2.0-alpha"
versionName = "0.2.0-beta.1"
}
buildTypes {
@@ -86,4 +86,4 @@ aboutLibraries {
excludeFields = listOf("generated")
outputFile = file("src/main/res/raw/aboutlibraries.json")
}
}
}

View File

@@ -20,8 +20,6 @@ package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
@@ -35,17 +33,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -59,8 +50,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
@@ -88,7 +77,6 @@ import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledDropdown
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle

View File

@@ -67,7 +67,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.highlight.Highlight
@@ -231,7 +230,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
val instance = service.airpodsInstance
if (instance == null) {
Text("Error: AirPods instance is null")
return@StyledScaffold
return@StyledScaffold
}
val capabilities = instance.model.capabilities
LazyColumn(
@@ -267,7 +266,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "noise_control") { NoiseControlSettings(service = service) }
}
if (capabilities.contains(Capability.STEM_CONFIG)) {
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "press_hold") { PressAndHoldSettings(navController = navController) }
@@ -370,7 +369,9 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
Spacer(Modifier.height(32.dp))
StyledButton(
onClick = { navController.navigate("troubleshooting") },
backdrop = backdrop
backdrop = backdrop,
modifier = Modifier
.fillMaxWidth(0.9f)
) {
Text(
text = "Troubleshoot Connection",
@@ -378,7 +379,26 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isSystemInDarkTheme()) Color.White else Color.Black
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
)
}
Spacer(Modifier.height(16.dp))
StyledButton(
onClick = {
service.reconnectFromSavedMac()
},
backdrop = backdrop,
modifier = Modifier
.fillMaxWidth(0.9f)
) {
Text(
text = stringResource(R.string.reconnect_to_last_device),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isSystemInDarkTheme()) Color.White else Color.Black
)
)
}

View File

@@ -54,9 +54,9 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -97,7 +97,7 @@ fun HearingAidScreen(navController: NavController) {
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
}
var hazeStateS = rememberHazeState()
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
StyledScaffold(
title = stringResource(R.string.hearing_aid),
@@ -112,7 +112,7 @@ fun HearingAidScreen(navController: NavController) {
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
hazeStateS = hazeState
hazeStateS.value = hazeState
Spacer(modifier = Modifier.height(spacerHeight))
val hearingAidListener = remember {
@@ -288,7 +288,7 @@ fun HearingAidScreen(navController: NavController) {
}
}
},
hazeState = hazeStateS,
hazeState = hazeStateS.value,
// backdrop = backdrop
)
}

View File

@@ -193,7 +193,7 @@ fun TroubleshootingScreen(navController: NavController) {
LaunchedEffect(currentStep) {
instructionText = when (currentStep) {
0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings."
1 -> "Please put your AirPods in the case and close it, so they disconnect completely."
1 -> "Please put your AirPods in the case and close it, so they disconnectForCD completely."
2 -> "Preparing to collect logs... Please wait."
3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done."
4 -> "Log collection complete! You can now save or share the logs."

View File

@@ -88,9 +88,9 @@ import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.constants.isHeadTrackingData
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.AirPodsInstance
import me.kavishdevar.librepods.utils.AirPodsModels
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.BLEManager
import me.kavishdevar.librepods.utils.BluetoothConnectionManager
import me.kavishdevar.librepods.utils.CrossDevice
@@ -229,6 +229,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private var handleIncomingCallOnceConnected = false
lateinit var bleManager: BLEManager
private lateinit var socket: BluetoothSocket
private val bleStatusListener = object : BLEManager.AirPodsStatusListener {
@SuppressLint("NewApi")
override fun onDeviceStatusChanged(
@@ -973,7 +976,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
config.airpodsVersion3 = deviceInformation.version3
config.airpodsHardwareRevision = deviceInformation.hardwareRevision
config.airpodsUpdaterIdentifier = deviceInformation.updaterIdentifier
val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber)
if (model != null) {
airpodsInstance = AirPodsInstance(
@@ -1032,8 +1035,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
byteArrayOf(0x00)
)
// this also means that the other device has start playing the audio, and if that's true, we can again start listening for audio config changes
Log.d(TAG, "Another device started playing audio, listening for audio config changes again")
MediaController.pausedForOtherDevice = false
// Log.d(TAG, "Another device started playing audio, listening for audio config changes again")
// MediaController.pausedForOtherDevice = false
// future me: what the heck is this? this just means it will not be taking over again if audio source doesn't change???
}
}
@@ -1842,7 +1846,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.notify(1, updatedNotification)
notificationManager.cancel(2)
} else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
}
}
@@ -2135,10 +2138,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val name = context?.getSharedPreferences("settings", MODE_PRIVATE)
?.getString("name", bluetoothDevice?.name)
if (bluetoothDevice != null && action != null && !action.isEmpty()) {
Log.d(TAG, "Received bluetooth connection broadcast")
Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
if (ServiceManager.getService()?.isConnectedLocally == true) {
Log.d(TAG, "Device is already connected locally, ignoring broadcast")
ServiceManager.getService()?.manuallyCheckForAudioSource()
Log.d(TAG, "Device is already connected locally, checking if we should keep audio connected")
if (ServiceManager.getService()?.socket?.isConnected == true) ServiceManager.getService()?.manuallyCheckForAudioSource() else Log.d(TAG, "We're not connected, ignoring")
return
}
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
@@ -2175,14 +2178,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return START_STICKY
}
private lateinit var socket: BluetoothSocket
fun manuallyCheckForAudioSource() {
val shouldResume = MediaController.getMusicActive()
if ((earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) || disconnectedBecauseReversed || otherDeviceTookOver) {
val shouldResume = MediaController.getMusicActive() // todo: for some reason we lose this info after disconnecting, probably android dispatches some event. haven't investigated yet.
if (airpodsInstance == null) return
Log.d(TAG, "disconnectedBecauseReversed: $disconnectedBecauseReversed, otherDeviceTookOver: $otherDeviceTookOver")
if ((earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) || disconnectedBecauseReversed || otherDeviceTookOver) {
Log.d(
TAG,
"For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again!"
"For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again! I will resume: $shouldResume"
)
disconnectAudio(this, device, shouldResume = shouldResume)
}
@@ -2378,7 +2381,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
fun connectToSocket(device: BluetoothDevice) {
fun connectToSocket(device: BluetoothDevice, manual: Boolean = false) {
Log.d(TAG, "<LogCollector:Start> Connecting to socket")
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
@@ -2387,7 +2390,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
createBluetoothSocket(device, uuid)
} catch (e: Exception) {
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.message}")
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
return
}
@@ -2431,15 +2434,29 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
Log.d(TAG, "<LogCollector:Complete:Success> Socket connected")
} catch (e: Exception) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
throw e
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected, ${e.message}")
if (manual) {
sendToast(
"Couldn't connect to socket: ${e.localizedMessage}"
)
} else {
showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}")
}
return@withTimeout
// throw e // lol how did i not catch this before... gonna comment this line instead of removing to preserve history
}
}
if (!socket.isConnected) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
}
if (!socket.isConnected) {
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
if (manual) {
sendToast(
"Couldn't connect to socket: timeout."
)
} else {
showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout")
}
return
}
this@AirPodsService.device = device
socket.let {
@@ -2519,7 +2536,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} catch (e: Exception) {
e.printStackTrace()
Log.d(TAG, "Failed to connect to socket: ${e.message}")
showSocketConnectionFailureNotification("Failed to establish connection: ${e.message}")
showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}")
isConnectedLocally = false
this@AirPodsService.device = device
updateNotificationContent(false)
@@ -2527,7 +2544,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
fun disconnect() {
fun disconnectForCD() {
if (!this::socket.isInitialized) return
socket.close()
MediaController.pausedWhileTakingOver = false
@@ -2552,6 +2569,33 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
CrossDevice.isAvailable = true
}
fun disconnectAirPods() {
if (!this::socket.isInitialized) return
socket.close()
isConnectedLocally = false
aacpManager.disconnected()
attManager?.disconnect()
updateNotificationContent(false)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) {
val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
MediaController.sendPause()
}
}
bluetoothAdapter.closeProfileProxy(profile, proxy)
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP)
Log.d(TAG, "Disconnected AirPods upon user request")
}
val earDetectionNotification = AirPodsNotifications.EarDetection()
val ancNotification = AirPodsNotifications.ANC()
val batteryNotification = AirPodsNotifications.BatteryNotification()
@@ -2750,6 +2794,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
isHeadTrackingActive = false
}
@SuppressLint("MissingPermission")
fun reconnectFromSavedMac(){
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
device = bluetoothAdapter.bondedDevices.find {
it.address == macAddress
}
if (device != null) {
CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device!!, manual = true)
}
}
}
}
private fun Int.dpToPx(): Int {

View File

@@ -200,7 +200,7 @@ object CrossDevice {
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
break
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
ServiceManager.getService()?.disconnect()
ServiceManager.getService()?.disconnectForCD()
disconnectionRequested = true
CoroutineScope(Dispatchers.IO).launch {
delay(1000)

View File

@@ -204,4 +204,6 @@
<string name="ppe">EN 352 Protection</string>
<string name="workspace_use_description">EN 352 Protection limits the maximum level of media to 82 dBA, and meets applicable EN 352 Standard requirements for personal hearing protection.</string>
<string name="environmental_noise">Environmental Noise</string>
<string name="reconnect_to_last_device">Reconnect to last connected device</string>
<string name="disconnect">Disconnect</string>
</resources>