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 b11d1dc..f32fee1 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 @@ -872,16 +872,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList override fun onOwnershipChangeReceived(owns: Boolean) { if (!owns) { + MediaController.recentlyLostOwnership = true + Handler(Looper.getMainLooper()).postDelayed({ + MediaController.recentlyLostOwnership = false + }, 3000) Log.d("AirPodsService", "ownership lost") MediaController.sendPause() MediaController.pausedForOtherDevice = true } } - override fun onOwnershipToFalseRequest(reasonReverseTapped: Boolean) { + override fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean) { // TODO: Show a reverse button, but that's a lot of effort -- i'd have to change the UI too, which i hate doing, and handle other device's reverses too, and disconnect audio etc... so for now, just pause the audio and show the island without asking to reverse. // 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") aacpManager.sendControlCommand( AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, @@ -895,7 +900,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList this@AirPodsService, (batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0), IslandType.MOVED_TO_OTHER_DEVICE, - reversed = true + reversed = true, + otherDeviceName = senderName ) } if (!aacpManager.owns) { @@ -903,19 +909,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList this@AirPodsService, (batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0), IslandType.MOVED_TO_OTHER_DEVICE, - reversed = reasonReverseTapped + reversed = reasonReverseTapped, + otherDeviceName = senderName ) } MediaController.sendPause() } - override fun onShowNearbyUI() { - showIsland( - this@AirPodsService, - (batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0), - IslandType.MOVED_TO_OTHER_DEVICE, - reversed = false - ) + override fun onShowNearbyUI(sender: String) { + val senderName = aacpManager.connectedDevices.find { it.mac == sender }?.type ?: "Other device" + showIsland( + this@AirPodsService, + (batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0), + IslandType.MOVED_TO_OTHER_DEVICE, + reversed = false, + otherDeviceName = senderName + ) } override fun onDeviceMetadataReceived(deviceMetadata: ByteArray) { @@ -1316,7 +1325,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var islandOpen = false var islandWindow: IslandWindow? = null @SuppressLint("MissingPermission") - fun showIsland(service: Service, batteryPercentage: Int, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false) { + fun showIsland(service: Service, batteryPercentage: Int, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false, otherDeviceName: String? = null) { Log.d("AirPodsService", "Showing island window") if (!Settings.canDrawOverlays(service)) { Log.d("AirPodsService", "No permission for SYSTEM_ALERT_WINDOW") @@ -1324,7 +1333,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } CoroutineScope(Dispatchers.Main).launch { islandWindow = IslandWindow(service.applicationContext) - islandWindow!!.show(sharedPreferences.getString("name", "AirPods Pro").toString(), batteryPercentage, this@AirPodsService, type, reversed) + islandWindow!!.show(sharedPreferences.getString("name", "AirPods Pro").toString(), batteryPercentage, this@AirPodsService, type, reversed, otherDeviceName) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt index f704c9a..02e9993 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt @@ -174,7 +174,8 @@ class AACPManager { data class ConnectedDevice( val mac: String, val info1: Byte, - val info2: Byte + val info2: Byte, + var type: String? ) } @@ -242,8 +243,8 @@ class AACPManager { fun onAudioSourceReceived(audioSource: ByteArray) fun onOwnershipChangeReceived(owns: Boolean) fun onConnectedDevicesReceived(connectedDevices: List) - fun onOwnershipToFalseRequest(reasonReverseTapped: Boolean) - fun onShowNearbyUI() + fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean) + fun onShowNearbyUI(sender: String) } fun parseStemPressResponse(data: ByteArray): Pair { @@ -521,11 +522,21 @@ class AACPManager { Opcodes.SMART_ROUTING_RESP -> { val packetString = packet.decodeToString() + val sender = packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) } + + if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) { + val nameStartIndex = packetString.indexOf("btName") + 7 + val nameEndIndex = if (packetString.contains("other")) (packetString.indexOf("otherDevice") - 2) else (packetString.indexOf("nearbyAudio") - 2) + val name = packet.sliceArray(nameStartIndex..nameEndIndex).decodeToString() + connectedDevices.find { it.mac == sender }?.type = name + Log.d(TAG, "Device $sender is named $name") + } + Log.d(TAG, "Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}") if (packetString.contains("SetOwnershipToFalse")) { - callback?.onOwnershipToFalseRequest(packetString.contains("ReverseBannerTapped")) + callback?.onOwnershipToFalseRequest(sender, packetString.contains("ReverseBannerTapped")) } if (packetString.contains("ShowNearbyUI")) { - callback?.onShowNearbyUI() + callback?.onShowNearbyUI(sender) } } @@ -544,7 +555,7 @@ class AACPManager { ) return } - // first 4 bytes AACP header, next two bytes opcode, next to bytes identifer + eqOnMedia = (packet[10] == 0x01.toByte()) eqOnPhone = (packet[11] == 0x01.toByte()) // there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media enabled. just directly the EQ... weird. @@ -554,7 +565,7 @@ class AACPManager { val eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() val eq4 = ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() - // for now, just take the first EQ + // for now, taking just the first EQ eqData = FloatArray(8) { i -> eq1.get(i) } Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia") } @@ -756,11 +767,11 @@ class AACPManager { fun createMediaInformationNewDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray { val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(112) + val buffer = ByteBuffer.allocate(116) buffer.put( targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() ) - buffer.put(byteArrayOf(0x68, 0x00)) + buffer.put(byteArrayOf(0x6C, 0x00)) buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) buffer.put("playingApp".toByteArray()) buffer.put(0x42) @@ -775,8 +786,8 @@ class AACPManager { buffer.put(selfMacAddress.toByteArray()) buffer.put(0x46) buffer.put("btName".toByteArray()) - buffer.put(0x43) - buffer.put("And".toByteArray()) + buffer.put(0x47) + buffer.put("Android".toByteArray()) buffer.put(0x58) buffer.put("otherDevice".toByteArray()) buffer.put("AudioCategory".toByteArray()) @@ -805,8 +816,6 @@ class AACPManager { buffer.put( targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() ) - // 620001E54A6C6F63616C73636F7265306446726561736F6E4848696A61636B763251617564696F526F7574696E6753636F7265312D015F617564696F526F7574696E675365744F776E657273686970546F46616C7365014B72656D6F746573636F7265A5 - buffer.put(byteArrayOf(0x62, 0x00)) buffer.put(byteArrayOf(0x01, 0xE5.toByte())) buffer.put(0x4A) @@ -854,16 +863,16 @@ class AACPManager { streamingState: Boolean = true ): ByteArray { val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(134) + val buffer = ByteBuffer.allocate(138) buffer.put( targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() ) buffer.put( byteArrayOf( - 0x7E, + 0x82.toByte(), // related to the length 0x00 ) - ) // something to do with the length, can't confirm, but changing causes airpods to soft reset + ) buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant buffer.put("PlayingApp".toByteArray()) buffer.put(byteArrayOf(0x56)) // 'V', seems like a identifier or a separator @@ -877,8 +886,8 @@ class AACPManager { buffer.put(0x51) // 'Q' buffer.put(selfMacAddress.toByteArray()) // self MAC buffer.put("btName".toByteArray()) // self name - buffer.put(0x44) // 'D' - buffer.put("iPho".toByteArray()) // if set to iPad, shows "Moved to iPad, but most likely we're running on a phone. setting to anything else of the same length will show iPhone instead. + buffer.put(0x47) // 'D' + buffer.put("Android".toByteArray()) // if set to iPad, shows "Moved to iPad", but most likely we're running on a phone. setting to anything else of the same length will show iPhone instead. buffer.put(0x58) // 'X' buffer.put("otherDevice".toByteArray()) buffer.put("AudioCategory".toByteArray()) @@ -973,11 +982,11 @@ class AACPManager { fun createAddTiPiDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray { val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) - val buffer = ByteBuffer.allocate(86) + val buffer = ByteBuffer.allocate(90) buffer.put( targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() ) - buffer.put(byteArrayOf(0x4E, 0x00)) + buffer.put(byteArrayOf(0x52, 0x00)) buffer.put(byteArrayOf(0x01, 0xE5.toByte())) buffer.put(0x48) // 'H' buffer.put("idleTime".toByteArray()) @@ -989,8 +998,8 @@ class AACPManager { buffer.put(selfMacAddress.toByteArray()) buffer.put(0x46) buffer.put("btName".toByteArray()) - buffer.put(0x43) - buffer.put("And".toByteArray()) + buffer.put(0x47) + buffer.put("Android".toByteArray()) buffer.put(0x50) buffer.put("nearbyAudioScore".toByteArray()) buffer.put(byteArrayOf(0x0E)) @@ -1164,13 +1173,13 @@ class AACPManager { val mac = macBytes.joinToString(":") { "%02X".format(it) } val info1 = data[offset + 6] val info2 = data[offset + 7] - devices.add(ConnectedDevice(mac, info1, info2)) + val existingDevice = devices.find { it.mac == mac } + devices.add(ConnectedDevice(mac, info1, info2, existingDevice?.type)) offset += 8 } return devices } - fun sendSomePacketIDontKnowWhatItIs() { // 2900 00ff ffff ffff ffff -- enables setting EQ sendDataPacket( 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 d8ef502..0e57c4b 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 @@ -165,7 +165,7 @@ class IslandWindow(private val context: Context) { @SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag", "SetTextI18n" ) - fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false) { + fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false, otherDeviceName: String? = null) { if (ServiceManager.getService()?.islandOpen == true) return else ServiceManager.getService()?.islandOpen = true @@ -352,19 +352,22 @@ class IslandWindow(private val context: Context) { when (type) { IslandType.CONNECTED -> { - islandView.findViewById(R.id.island_connected_text).text = getString(context, R.string.island_connected_text) + islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_connected_text) } IslandType.TAKING_OVER -> { - islandView.findViewById(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text) + islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_taking_over_text) } IslandType.MOVED_TO_REMOTE -> { - islandView.findViewById(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text) + islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_moved_to_remote_text) } IslandType.MOVED_TO_OTHER_DEVICE -> { + if (otherDeviceName == null || otherDeviceName.isEmpty()) { + e("IslandWindow", "Other device name is null or empty for MOVED_TO_OTHER_DEVICE type") + } if (reversed) { - islandView.findViewById(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_other_device_reversed_text) + islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_moved_to_other_device_reversed_text) } else { - islandView.findViewById(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_other_device_text) + islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_moved_to_other_device_text, otherDeviceName) } } } 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 631673b..5d9e07e 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 @@ -61,6 +61,8 @@ object MediaController { private var conversationalAwarenessVolume: Int = 2 private var conversationalAwarenessPauseMusic: Boolean = false + var recentlyLostOwnership: Boolean = false + fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) { if (this::audioManager.isInitialized) { return @@ -118,10 +120,14 @@ object MediaController { if (isActive) { Log.d("MediaController", "Detected play while pausedForOtherDevice; attempting to take over") - pausedForOtherDevice = false - userPlayedTheMedia = true - if (!pausedWhileTakingOver) { - ServiceManager.getService()?.takeOver("music") + if (!recentlyLostOwnership) { + pausedForOtherDevice = false + userPlayedTheMedia = true + if (!pausedWhileTakingOver) { + ServiceManager.getService()?.takeOver("music") + } + } else { + Log.d("MediaController", "Skipping take-over due to recent ownership loss") } } else { Log.d("MediaController", "Still not active while pausedForOtherDevice; will clear state after timeout") @@ -148,8 +154,12 @@ object MediaController { Log.d("MediaController", "pausedWhileTakingOver: $pausedWhileTakingOver") if (!pausedWhileTakingOver && isActive) { if (lastKnownIsMusicActive != true) { - Log.d("MediaController", "Music is active and not pausedWhileTakingOver; requesting takeOver") - ServiceManager.getService()?.takeOver("music") + if (!recentlyLostOwnership) { + Log.d("MediaController", "Music is active and not pausedWhileTakingOver; requesting takeOver") + ServiceManager.getService()?.takeOver("music") + } else { + Log.d("MediaController", "Skipping take-over due to recent ownership loss") + } } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a520e9d..3d84861 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -47,7 +47,7 @@ Connected to Linux Connected Moved to Linux - Moved to other device + Moved to %1$s Reconnect from notification Head Tracking Nod to answer calls, and shake your head to decline.