android: use device name sent by the connected device in island

This commit is contained in:
Kavish Devar
2025-09-19 16:27:32 +05:30
parent 5c9beeb26d
commit 032b94e3ae
5 changed files with 80 additions and 49 deletions

View File

@@ -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)
}
}

View File

@@ -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<ConnectedDevice>)
fun onOwnershipToFalseRequest(reasonReverseTapped: Boolean)
fun onShowNearbyUI()
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
fun onShowNearbyUI(sender: String)
}
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
@@ -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(

View File

@@ -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<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_text)
islandView.findViewById<TextView>(R.id.island_connected_text).text = context.getString(R.string.island_connected_text)
}
IslandType.TAKING_OVER -> {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text)
islandView.findViewById<TextView>(R.id.island_connected_text).text = context.getString(R.string.island_taking_over_text)
}
IslandType.MOVED_TO_REMOTE -> {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
islandView.findViewById<TextView>(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<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_other_device_reversed_text)
islandView.findViewById<TextView>(R.id.island_connected_text).text = context.getString(R.string.island_moved_to_other_device_reversed_text)
} else {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_other_device_text)
islandView.findViewById<TextView>(R.id.island_connected_text).text = context.getString(R.string.island_moved_to_other_device_text, otherDeviceName)
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -47,7 +47,7 @@
<string name="island_connected_remote_text">Connected to Linux</string>
<string name="island_taking_over_text">Connected</string>
<string name="island_moved_to_remote_text">Moved to Linux</string>
<string name="island_moved_to_other_device_text">Moved to other device</string>
<string name="island_moved_to_other_device_text">Moved to %1$s</string>
<string name="island_moved_to_other_device_reversed_text">Reconnect from notification</string>
<string name="head_tracking">Head Tracking</string>
<string name="head_gestures_details">Nod to answer calls, and shake your head to decline.</string>