diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt index 992d1e6..7973c75 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt @@ -110,7 +110,7 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) { if (preview) { batteryStatus.value = listOf( Battery(BatteryComponent.LEFT, 100, BatteryStatus.NOT_CHARGING), - Battery(BatteryComponent.RIGHT, 94, BatteryStatus.NOT_CHARGING), + Battery(BatteryComponent.RIGHT, 94, BatteryStatus.CHARGING), Battery(BatteryComponent.CASE, 40, BatteryStatus.CHARGING) ) previousBatteryStatus.value = batteryStatus.value @@ -177,7 +177,7 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) { } if (leftLevel > 0 && rightLevel > 0) { - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(16.dp)) } if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index cacf285..32ffbe9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -126,6 +126,8 @@ import java.nio.ByteOrder import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi +const val TAG = "AirPodsService" + object ServiceManager { @ExperimentalEncodingApi private var service: AirPodsService? = null @@ -152,6 +154,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var attManager: ATTManager? = null var cameraActive = false private var disconnectedBecauseReversed = false + private var otherDeviceTookOver = false data class ServiceConfig( var deviceName: String = "AirPods", var earDetectionEnabled: Boolean = true, @@ -220,17 +223,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sharedPreferences.edit { putString("mac_address", macAddress) } - Log.d("AirPodsBLEService", "BLE-only mode: stored MAC address ${device.address}") + Log.d(TAG, "BLE-only mode: stored MAC address ${device.address}") } if (device.connectionState == "Disconnected" && !config.bleOnlyMode) { - Log.d("AirPodsBLEService", "Seems no device has taken over, we will.") + Log.d(TAG, "Seems no device has taken over, we will.") val bluetoothManager = getSystemService(BluetoothManager::class.java) val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(sharedPreferences.getString( "mac_address", "") ?: "") connectToSocket(bluetoothDevice) } - Log.d("AirPodsBLEService", "Device status changed") + Log.d(TAG, "Device status changed") if (isConnectedLocally) return val leftLevel = bleManager.getMostRecentStatus()?.leftBattery?: 0 val rightLevel = bleManager.getMostRecentStatus()?.rightBattery?: 0 @@ -251,14 +254,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) { - Log.d("AirPodsService", "New address detected") + Log.d(TAG, "New address detected") } override fun onLidStateChanged( lidOpen: Boolean, ) { if (lidOpen) { - Log.d("AirPodsBLEService", "Lid opened") + Log.d(TAG, "Lid opened") showPopup( this@AirPodsService, getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro") ?: "AirPods" @@ -281,7 +284,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) sendBatteryBroadcast() } else { - Log.d("AirPodsBLEService", "Lid closed") + Log.d(TAG, "Lid closed") } } @@ -290,11 +293,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList leftInEar: Boolean, rightInEar: Boolean ) { - Log.d("AirPodsBLEService", "Ear state changed - Left: $leftInEar, Right: $rightInEar") + Log.d(TAG, "Ear state changed - Left: $leftInEar, Right: $rightInEar") // In BLE-only mode, ear detection is purely based on BLE data if (config.bleOnlyMode) { - Log.d("AirPodsBLEService", "BLE-only mode: ear detection from BLE data") + Log.d(TAG, "BLE-only mode: ear detection from BLE data") } } @@ -316,7 +319,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList caseCharging = caseCharging == true ) updateBattery() - Log.d("AirPodsBLEService", "Battery changed") + Log.d(TAG, "Battery changed") + } + + override fun onDeviceDisappeared() { + Log.d(TAG, "All disappeared") + updateNotificationContent( + false + ) } } @@ -349,7 +359,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList gestureDetector = null config.headGestures = false sharedPreferences.edit { putBoolean("head_gestures", false) } - Log.d("AirPodsService", "Head gestures disabled as device is running Android 9 or below") + Log.d(TAG, "Head gestures disabled as device is running Android 9 or below") } bleManager = BLEManager(this) @@ -504,7 +514,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, nextMode ) - Log.d("AirPodsService", "Cycling ANC mode from $currentMode to $nextMode (offListeningMode: $allowOffMode)") + Log.d(TAG, "Cycling ANC mode from $currentMode to $nextMode (offListeningMode: $allowOffMode)") } } } @@ -525,10 +535,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList MODE_PRIVATE ) ) - Log.d("AirPodsService", "Initializing CrossDevice") + Log.d(TAG, "Initializing CrossDevice") CoroutineScope(Dispatchers.IO).launch { CrossDevice.init(this@AirPodsService) - Log.d("AirPodsService", "CrossDevice initialized") + Log.d(TAG, "CrossDevice initialized") } sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) @@ -612,11 +622,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString()) if (!CrossDevice.isAvailable) { - Log.d("AirPodsService", "${config.deviceName} connected") + Log.d(TAG, "${config.deviceName} connected") CoroutineScope(Dispatchers.IO).launch { connectToSocket(device!!) } - Log.d("AirPodsService", "Setting metadata") + Log.d(TAG, "Setting metadata") setMetadatas(device!!) isConnectedLocally = true macAddress = device!!.address @@ -725,7 +735,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @Suppress("unused") fun cameraOpened() { - Log.d("AirPodsService", "Camera opened, gonna handle stem presses and take action if enabled") + Log.d(TAG, "Camera opened, gonna handle stem presses and take action if enabled") val isCameraShutterUsed = listOf( config.leftSinglePressAction, config.rightSinglePressAction, @@ -738,7 +748,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ).any { it == StemAction.CAMERA_SHUTTER } if (isCameraShutterUsed) { - Log.d("AirPodsService", "Camera opened, setting up stem actions") + Log.d(TAG, "Camera opened, setting up stem actions") cameraActive = true setupStemActions(isCameraActive = true) } @@ -755,7 +765,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList default: StemAction?, isCameraActive: Boolean = false ): Boolean { - Log.d("AirPodsService", "Checking if action $action is custom against default $default, camera active: $isCameraActive") + Log.d(TAG, "Checking if action $action is custom against default $default, camera active: $isCameraActive") return action != default && (action != StemAction.CAMERA_SHUTTER || isCameraActive) } @@ -773,7 +783,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList isCustomAction(config.rightTriplePressAction, triplePressDefault, isCameraActive) val longPressCustomized = isCustomAction(config.leftLongPressAction, longPressDefault, isCameraActive) || isCustomAction(config.rightLongPressAction, longPressDefault, isCameraActive) - Log.d("AirPodsService", "Setting up stem actions: " + + Log.d(TAG, "Setting up stem actions: " + "Single Press Customized: $singlePressCustomized, " + "Double Press Customized: $doublePressCustomized, " + "Triple Press Customized: $triplePressCustomized, " + @@ -867,9 +877,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Handler(Looper.getMainLooper()).postDelayed({ MediaController.recentlyLostOwnership = false }, 3000) - Log.d("AirPodsService", "ownership lost") + Log.d(TAG, "ownership lost") MediaController.sendPause() MediaController.pausedForOtherDevice = true + otherDeviceTookOver = true + disconnectAudio( + this@AirPodsService, + device + ) } } @@ -878,13 +893,18 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // handling reverse is a problem because we'd have to disconnect the audio, but there's no option connect audio again natively, so notification would have to be changed. I wish there was a way to just "change the audio output device". // (20 minutes later) i've done it nonetheless :] val senderName = aacpManager.connectedDevices.find { it.mac == sender }?.type ?: "Other device" - Log.d("AirPodsService", "other device has hijacked the connection, reasonReverseTapped: $reasonReverseTapped") + Log.d(TAG, "other device has hijacked the connection, reasonReverseTapped: $reasonReverseTapped") aacpManager.sendControlCommand( AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, byteArrayOf(0x00) ) + otherDeviceTookOver = true + disconnectAudio( + this@AirPodsService, + device + ) if (reasonReverseTapped) { - Log.d("AirPodsService", "reverse tapped, disconnecting audio") + Log.d(TAG, "reverse tapped, disconnecting audio") disconnectedBecauseReversed = true disconnectAudio(this@AirPodsService, device) showIsland( @@ -960,7 +980,7 @@ 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("AirPodsService", "Another device started playing audio, listening for audio config changes again") + Log.d(TAG, "Another device started playing audio, listening for audio config changes again") MediaController.pausedForOtherDevice = false } } @@ -977,7 +997,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList for (device in newDevices) { Log.d("AirPodsParser", "New connected device: ${device.mac}, info1: ${device.info1}, info2: ${device.info2})") - Log.d("AirPodsService", "Sending new Tipi packet for device ${device.mac}, and sending media info to the device") + Log.d(TAG, "Sending new Tipi packet for device ${device.mac}, and sending media info to the device") aacpManager.sendMediaInformationNewDevice(selfMacAddress = localMac, targetMacAddress = device.mac) aacpManager.sendAddTiPiDevice(selfMacAddress = localMac, targetMacAddress = device.mac) } @@ -1300,7 +1320,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var popupShown = false fun showPopup(service: Service, name: String) { if (!Settings.canDrawOverlays(service)) { - Log.d("AirPodsService", "No permission for SYSTEM_ALERT_WINDOW") + Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW") return } if (popupShown) { @@ -1315,9 +1335,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var islandWindow: IslandWindow? = null @SuppressLint("MissingPermission") fun showIsland(service: Service, batteryPercentage: Int, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false, otherDeviceName: String? = null) { - Log.d("AirPodsService", "Showing island window") + Log.d(TAG, "Showing island window") if (!Settings.canDrawOverlays(service)) { - Log.d("AirPodsService", "No permission for SYSTEM_ALERT_WINDOW") + Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW") return } CoroutineScope(Dispatchers.Main).launch { @@ -1742,7 +1762,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList notificationManager.notify(1, updatedNotification) notificationManager.cancel(2) } else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) { - Log.d("AirPodsService", " Socket not connected") + Log.d(TAG, " Socket not connected") showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") } } @@ -1916,7 +1936,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sendBroadcastAsUser(intent, UserHandle.getUserHandleForUid(-1)) } } catch (e: Exception) { - Log.e("AirPodsService", "Failed to send vendor-specific event: ${e.message}") + Log.e(TAG, "Failed to send vendor-specific event: ${e.message}") } // Broadcast battery level changes @@ -1932,7 +1952,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sendBroadcastAsUser(batteryIntent, UserHandle.getUserHandleForUid(-1)) } } catch (e: Exception) { - Log.e("AirPodsService", "Failed to send battery level broadcast: ${e.message}") + Log.e(TAG, "Failed to send battery level broadcast: ${e.message}") } // Update Android Settings Intelligence's battery widget @@ -1944,10 +1964,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList try { sendBroadcastAsUser(statusIntent, UserHandle.getUserHandleForUid(-1)) } catch (e: Exception) { - Log.e("AirPodsService", "Failed to send ASI battery level broadcast: ${e.message}") + Log.e(TAG, "Failed to send ASI battery level broadcast: ${e.message}") } - Log.d("AirPodsService", "Broadcast battery level $batteryUnified% to system") + Log.d(TAG, "Broadcast battery level $batteryUnified% to system") } private fun setMetadatas(d: BluetoothDevice) { @@ -2007,7 +2027,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList device.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD, "20".toByteArray() ) - Log.d("AirPodsService", "Metadata set: $metadataSet") + Log.d(TAG, "Metadata set: $metadataSet") } } @@ -2029,9 +2049,8 @@ 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("AirPodsService", "Received bluetooth connection broadcast") + Log.d(TAG, "Received bluetooth connection broadcast") if (ServiceManager.getService()?.isConnectedLocally == true) { - Log.d("AirPodsService", "Checking if audio should be connected") ServiceManager.getService()?.manuallyCheckForAudioSource() return } @@ -2057,11 +2076,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag") override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Log.d("AirPodsService", "Service started with intent action: ${intent?.action}") + Log.d(TAG, "Service started with intent action: ${intent?.action}") if (intent?.action == "me.kavishdevar.librepods.RECONNECT_AFTER_REVERSE") { - Log.d("AirPodsService", "reconnect after reversed received, taking over") + Log.d(TAG, "reconnect after reversed received, taking over") disconnectedBecauseReversed = false + otherDeviceTookOver = false takeOver("music", manualTakeOverAfterReversed = true) } @@ -2072,9 +2092,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun manuallyCheckForAudioSource() { val shouldResume = MediaController.getMusicActive() - if ((earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) || disconnectedBecauseReversed) { + if ((earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) || disconnectedBecauseReversed || otherDeviceTookOver) { Log.d( - "AirPodsService", + TAG, "For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again!" ) disconnectAudio(this, device, shouldResume = shouldResume) @@ -2095,22 +2115,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList aacpManager.sendHijackReversed( localMac ) + connectAudio( + this@AirPodsService, + device + ) + otherDeviceTookOver = false } - Log.d("AirPodsService", "owns connection: ${aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value?.get(0)?.toInt()}") + Log.d(TAG, "owns connection: ${aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value?.get(0)?.toInt()}") if (isConnectedLocally) { if (aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value[0]?.toInt() != 1 || (aacpManager.audioSource?.mac != localMac && aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE)) { if (disconnectedBecauseReversed) { if (manualTakeOverAfterReversed) { - Log.d("AirPodsService", "forcefully taking over despite reverse as user requested") - connectAudio(this, device) + Log.d(TAG, "forcefully taking over despite reverse as user requested") disconnectedBecauseReversed = false } else { - Log.d("AirPodsService", "connected locally, but can not hijack as other device had reversed") + Log.d(TAG, "connected locally, but can not hijack as other device had reversed") return } } - Log.d("AirPodsService", "already connected locally, hijacking connection by asking AirPods") + Log.d(TAG, "already connected locally, hijacking connection by asking AirPods") aacpManager.sendControlCommand( AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, 1 @@ -2124,34 +2148,42 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList aacpManager.sendHijackRequest( localMac ) + otherDeviceTookOver = false + connectAudio(this, device) showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!), IslandType.CONNECTED) CoroutineScope(Dispatchers.IO).launch { - delay(500) + delay(500) // a2dp takes time, and so does taking control + AirPods pause it for no reason after connecting if (takingOverFor == "music") { - MediaController.sendPlay() + Log.d(TAG, "Resuming music after taking control") + MediaController.sendPlay(replayWhenPaused = true) } else if (startHeadTrackingAgain) { - Log.d("AirPodsService", "Starting head tracking again after taking control") + Log.d(TAG, "Starting head tracking again after taking control") Handler(Looper.getMainLooper()).postDelayed({ startHeadTracking() }, 500) } + delay(1000) // should ideally have a callback when it's taken over because for some reason android doesn't dispatch when it's paused + if (takingOverFor == "music") { + Log.d(TAG, "resuming again just in case") + MediaController.sendPlay(force = true) + } } } else { - Log.d("AirPodsService", "Already connected locally and already own connection, skipping takeover") + Log.d(TAG, "Already connected locally and already own connection, skipping takeover") } return } if (CrossDevice.isAvailable) { - Log.d("AirPodsService", "CrossDevice is available, continuing") + Log.d(TAG, "CrossDevice is available, continuing") } else if (bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true) { - Log.d("AirPodsService", "At least one AirPod is in ear, continuing") + Log.d(TAG, "At least one AirPod is in ear, continuing") } else { - Log.d("AirPodsService", "CrossDevice not available and AirPods not in ear, skipping") + Log.d(TAG, "CrossDevice not available and AirPods not in ear, skipping") return } @@ -2162,7 +2194,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } if (!shouldTakeOverPState) { - Log.d("AirPodsService", "Not taking over audio, phone state takeover disabled") + Log.d(TAG, "Not taking over audio, phone state takeover disabled") return } @@ -2177,21 +2209,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } if (!shouldTakeOver) { - Log.d("AirPodsService", "Not taking over audio, airpods state takeover disabled") + Log.d(TAG, "Not taking over audio, airpods state takeover disabled") return } if (takingOverFor == "music") { - Log.d("AirPodsService", "Pausing music so that it doesn't play through speakers") + Log.d(TAG, "Pausing music so that it doesn't play through speakers") MediaController.pausedWhileTakingOver = true MediaController.sendPause(true) } else { handleIncomingCallOnceConnected = true } - Log.d("AirPodsService", "Taking over audio") + Log.d(TAG, "Taking over audio") CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet) - Log.d("AirPodsService", macAddress) + Log.d(TAG, macAddress) sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false) } device = getSystemService(BluetoothManager::class.java).adapter.bondedDevices.find { @@ -2201,7 +2233,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (device != null) { if (config.bleOnlyMode) { // In BLE-only mode, just show connecting status without actual L2CAP connection - Log.d("AirPodsService", "BLE-only mode: showing connecting status without L2CAP connection") + Log.d(TAG, "BLE-only mode: showing connecting status without L2CAP connection") updateNotificationContent( true, config.deviceName, @@ -2231,11 +2263,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) val constructors = BluetoothSocket::class.java.declaredConstructors - Log.d("AirPodsService", "BluetoothSocket has ${constructors.size} constructors:") + Log.d(TAG, "BluetoothSocket has ${constructors.size} constructors:") constructors.forEachIndexed { index, constructor -> val params = constructor.parameterTypes.joinToString(", ") { it.simpleName } - Log.d("AirPodsService", "Constructor $index: ($params)") + Log.d(TAG, "Constructor $index: ($params)") } var lastException: Exception? = null @@ -2243,31 +2275,31 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList for ((index, params) in constructorSpecs.withIndex()) { try { - Log.d("AirPodsService", "Trying constructor signature #${index + 1}") + Log.d(TAG, "Trying constructor signature #${index + 1}") attemptedConstructors++ return HiddenApiBypass.newInstance(BluetoothSocket::class.java, *params) as BluetoothSocket } catch (e: Exception) { - Log.e("AirPodsService", "Constructor signature #${index + 1} failed: ${e.message}") + Log.e(TAG, "Constructor signature #${index + 1} failed: ${e.message}") lastException = e } } val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures" - Log.e("AirPodsService", errorMessage) + Log.e(TAG, errorMessage) showSocketConnectionFailureNotification(errorMessage) throw lastException ?: IllegalStateException(errorMessage) } @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") fun connectToSocket(device: BluetoothDevice) { - Log.d("AirPodsService", " Connecting to socket") + Log.d(TAG, " Connecting to socket") HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") if (!isConnectedLocally && !CrossDevice.isAvailable) { socket = try { createBluetoothSocket(device, uuid) } catch (e: Exception) { - Log.e("AirPodsService", "Failed to create BluetoothSocket: ${e.message}") + Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}") showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.message}") return } @@ -2290,24 +2322,24 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList config.deviceName, batteryNotification.getBattery() ) - Log.d("AirPodsService", " Socket connected") + Log.d(TAG, " Socket connected") } catch (e: Exception) { - Log.d("AirPodsService", " Socket not connected") + Log.d(TAG, " Socket not connected") showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") throw e } } if (!socket.isConnected) { - Log.d("AirPodsService", " Socket not connected") + Log.d(TAG, " Socket not connected") showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") } } this@AirPodsService.device = device - socket.let { it -> + socket.let { aacpManager.sendPacket(aacpManager.createHandshakePacket()) aacpManager.sendSetFeatureFlagsPacket() aacpManager.sendNotificationRequest() - Log.d("AirPodsService", "Requesting proximity keys") + Log.d(TAG, "Requesting proximity keys") aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) CoroutineScope(Dispatchers.IO).launch { aacpManager.sendPacket(aacpManager.createHandshakePacket()) @@ -2373,12 +2405,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList isConnectedLocally = false socket.close() aacpManager.disconnected() + updateNotificationContent(false) sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED)) } } } catch (e: Exception) { e.printStackTrace() - Log.d("AirPodsService", "Failed to connect to socket: ${e.message}") + Log.d(TAG, "Failed to connect to socket: ${e.message}") showSocketConnectionFailureNotification("Failed to establish connection: ${e.message}") isConnectedLocally = false this@AirPodsService.device = device @@ -2391,7 +2424,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (!this::socket.isInitialized) return socket.close() MediaController.pausedWhileTakingOver = false - Log.d("AirPodsService", "Disconnected from AirPods, showing island.") + Log.d(TAG, "Disconnected from AirPods, showing island.") showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!), IslandType.MOVED_TO_REMOTE) val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter @@ -2447,7 +2480,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (profile == BluetoothProfile.A2DP) { try { if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) { - Log.d("AirPodsService", "Already disconnected from A2DP") + Log.d(TAG, "Already disconnected from A2DP") return } val method = @@ -2540,13 +2573,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } updateNotificationContent(true, name, batteryNotification.getBattery()) - Log.d("AirPodsService", "setName: $name") + Log.d(TAG, "setName: $name") } @SuppressLint("MissingPermission") override fun onDestroy() { clearPacketLogs() - Log.d("AirPodsService", "Service stopped is being destroyed for some reason!") + Log.d(TAG, "Service stopped is being destroyed for some reason!") sharedPreferences.unregisterOnSharedPreferenceChangeListener(this) @@ -2588,9 +2621,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val useAlternatePackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value?.get(0)?.toInt() != 1) { takeOver("call", startHeadTrackingAgain = true) - Log.d("AirPodsService", "Taking over for head tracking") + Log.d(TAG, "Taking over for head tracking") } else { - Log.w("AirPodsService", "Will not be taking over for head tracking, might not work.") + Log.w(TAG, "Will not be taking over for head tracking, might not work.") } if (useAlternatePackets) { aacpManager.sendDataPacket(aacpManager.createAlternateStartHeadTrackingPacket()) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt index a047d02..397dfa8 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt @@ -69,6 +69,7 @@ class BLEManager(private val context: Context) { fun onLidStateChanged(lidOpen: Boolean) fun onEarStateChanged(device: AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean) fun onBatteryChanged(device: AirPodsStatus) + fun onDeviceDisappeared() } private var mBluetoothLeScanner: BluetoothLeScanner? = null @@ -335,7 +336,7 @@ class BLEManager(private val context: Context) { val model = modelNames[modelId] ?: "Unknown ($modelId)" val status = data[5].toInt() and 0xFF - val flagsCase = data[7].toInt() and 0xFF +// val flagsCase = data[7].toInt() and 0xFF val lid = data[8].toInt() and 0xFF val color = colorNames[data[9].toInt()] ?: "Unknown" val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})" @@ -396,6 +397,10 @@ class BLEManager(private val context: Context) { deviceStatusMap.remove(device.key) Log.d(TAG, "Removed stale device from tracking: ${device.key}") } + + if (deviceStatusMap.isEmpty()) { + airPodsStatusListener?.onDeviceDisappeared() + } } private fun checkLidStateTimeout() { @@ -483,8 +488,8 @@ class BLEManager(private val context: Context) { companion object { private const val TAG = "AirPodsBLE" - private const val CLEANUP_INTERVAL_MS = 30000L - private const val STALE_DEVICE_TIMEOUT_MS = 60000L - private const val LID_CLOSE_TIMEOUT_MS = 2000L + private const val CLEANUP_INTERVAL_MS = 10000L + private const val STALE_DEVICE_TIMEOUT_MS = 15000L + private const val LID_CLOSE_TIMEOUT_MS = 2500L } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt index bd6df5d..0d143a7 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt @@ -653,7 +653,7 @@ class IslandWindow(private val context: Context) { try { context.unregisterReceiver(batteryReceiver) } catch (e: Exception) { - e.printStackTrace() +// e.printStackTrace() } ServiceManager.getService()?.islandOpen = false diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt index 5d9e07e..ecfd109 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt @@ -63,6 +63,9 @@ object MediaController { var recentlyLostOwnership: Boolean = false + private var lastPlayWithReplay: Boolean = false + private var lastPlayTime: Long = 0L + fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) { if (this::audioManager.isInitialized) { return @@ -101,6 +104,14 @@ object MediaController { val isActive = audioManager.isMusicActive Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia, isActive: $isActive, pausedForOtherDevice: $pausedForOtherDevice, lastKnownIsMusicActive: $lastKnownIsMusicActive") + if (!isActive && lastPlayWithReplay && now - lastPlayTime < 2500L) { + Log.d("MediaController", "Music paused shortly after play with replay; retrying play") + lastPlayWithReplay = false + sendPlay() + lastKnownIsMusicActive = true + return + } + if (now - lastPlaybackCallbackAt < PLAYBACK_DEBOUNCE_MS) { Log.d("MediaController", "Ignoring playback callback due to debounce (${now - lastPlaybackCallbackAt}ms)") lastPlaybackCallbackAt = now @@ -114,20 +125,42 @@ object MediaController { return } + Log.d("MediaController", "Configs received: ${configs?.size ?: 0} configurations") + val currentActiveContentTypes = configs?.flatMap { config -> + Log.d("MediaController", "Processing config: ${config}, audioAttributes: ${config.audioAttributes}") + config.audioAttributes?.let { attrs -> + val contentType = attrs.contentType + Log.d("MediaController", "Config content type: $contentType") + listOf(contentType) + } ?: run { + Log.d("MediaController", "Config has no audioAttributes") + emptyList() + } + }?.toSet() ?: emptySet() + + Log.d("MediaController", "Current active content types: $currentActiveContentTypes") + + val hasNewMusicOrMovie = currentActiveContentTypes.any { contentType -> + contentType == android.media.AudioAttributes.CONTENT_TYPE_MUSIC || + contentType == android.media.AudioAttributes.CONTENT_TYPE_MOVIE + } + + Log.d("MediaController", "Has new music or movie: $hasNewMusicOrMovie") + if (pausedForOtherDevice) { handler.removeCallbacks(clearPausedForOtherDeviceRunnable) handler.postDelayed(clearPausedForOtherDeviceRunnable, PAUSED_FOR_OTHER_DEVICE_CLEAR_MS) if (isActive) { Log.d("MediaController", "Detected play while pausedForOtherDevice; attempting to take over") - if (!recentlyLostOwnership) { + if (!recentlyLostOwnership && hasNewMusicOrMovie) { pausedForOtherDevice = false userPlayedTheMedia = true if (!pausedWhileTakingOver) { ServiceManager.getService()?.takeOver("music") } } else { - Log.d("MediaController", "Skipping take-over due to recent ownership loss") + Log.d("MediaController", "Skipping take-over due to recent ownership loss or no new music/movie") } } else { Log.d("MediaController", "Still not active while pausedForOtherDevice; will clear state after timeout") @@ -152,10 +185,10 @@ object MediaController { } Log.d("MediaController", "pausedWhileTakingOver: $pausedWhileTakingOver") - if (!pausedWhileTakingOver && isActive) { + if (!pausedWhileTakingOver && isActive && hasNewMusicOrMovie) { if (lastKnownIsMusicActive != true) { if (!recentlyLostOwnership) { - Log.d("MediaController", "Music is active and not pausedWhileTakingOver; requesting takeOver") + Log.d("MediaController", "Music/movie is active and not pausedWhileTakingOver; requesting takeOver") ServiceManager.getService()?.takeOver("music") } else { Log.d("MediaController", "Skipping take-over due to recent ownership loss") @@ -242,9 +275,13 @@ object MediaController { } @Synchronized - fun sendPlay() { - Log.d("MediaController", "Sending play with iPausedTheMedia: $iPausedTheMedia") - if (iPausedTheMedia) { + fun sendPlay(replayWhenPaused: Boolean = false, force: Boolean = false) { + Log.d("MediaController", "Sending play with iPausedTheMedia: $iPausedTheMedia, replayWhenPaused: $replayWhenPaused, force: $force") + if (replayWhenPaused) { + lastPlayWithReplay = true + lastPlayTime = SystemClock.uptimeMillis() + } + if (iPausedTheMedia || force) { // very creative, ik. thanks. Log.d("MediaController", "Sending play and setting userPlayedTheMedia to false") userPlayedTheMedia = false audioManager.dispatchMediaKeyEvent(