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) { override fun onOwnershipChangeReceived(owns: Boolean) {
if (!owns) { if (!owns) {
MediaController.recentlyLostOwnership = true
Handler(Looper.getMainLooper()).postDelayed({
MediaController.recentlyLostOwnership = false
}, 3000)
Log.d("AirPodsService", "ownership lost") Log.d("AirPodsService", "ownership lost")
MediaController.sendPause() MediaController.sendPause()
MediaController.pausedForOtherDevice = true 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. // 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". // 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 :] // (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("AirPodsService", "other device has hijacked the connection, reasonReverseTapped: $reasonReverseTapped")
aacpManager.sendControlCommand( aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
@@ -895,7 +900,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
this@AirPodsService, this@AirPodsService,
(batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0), (batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0),
IslandType.MOVED_TO_OTHER_DEVICE, IslandType.MOVED_TO_OTHER_DEVICE,
reversed = true reversed = true,
otherDeviceName = senderName
) )
} }
if (!aacpManager.owns) { if (!aacpManager.owns) {
@@ -903,19 +909,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
this@AirPodsService, this@AirPodsService,
(batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0), (batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0),
IslandType.MOVED_TO_OTHER_DEVICE, IslandType.MOVED_TO_OTHER_DEVICE,
reversed = reasonReverseTapped reversed = reasonReverseTapped,
otherDeviceName = senderName
) )
} }
MediaController.sendPause() MediaController.sendPause()
} }
override fun onShowNearbyUI() { override fun onShowNearbyUI(sender: String) {
showIsland( val senderName = aacpManager.connectedDevices.find { it.mac == sender }?.type ?: "Other device"
this@AirPodsService, showIsland(
(batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0), this@AirPodsService,
IslandType.MOVED_TO_OTHER_DEVICE, (batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0),
reversed = false IslandType.MOVED_TO_OTHER_DEVICE,
) reversed = false,
otherDeviceName = senderName
)
} }
override fun onDeviceMetadataReceived(deviceMetadata: ByteArray) { override fun onDeviceMetadataReceived(deviceMetadata: ByteArray) {
@@ -1316,7 +1325,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var islandOpen = false var islandOpen = false
var islandWindow: IslandWindow? = null var islandWindow: IslandWindow? = null
@SuppressLint("MissingPermission") @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") Log.d("AirPodsService", "Showing island window")
if (!Settings.canDrawOverlays(service)) { if (!Settings.canDrawOverlays(service)) {
Log.d("AirPodsService", "No permission for SYSTEM_ALERT_WINDOW") Log.d("AirPodsService", "No permission for SYSTEM_ALERT_WINDOW")
@@ -1324,7 +1333,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
islandWindow = IslandWindow(service.applicationContext) 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( data class ConnectedDevice(
val mac: String, val mac: String,
val info1: Byte, val info1: Byte,
val info2: Byte val info2: Byte,
var type: String?
) )
} }
@@ -242,8 +243,8 @@ class AACPManager {
fun onAudioSourceReceived(audioSource: ByteArray) fun onAudioSourceReceived(audioSource: ByteArray)
fun onOwnershipChangeReceived(owns: Boolean) fun onOwnershipChangeReceived(owns: Boolean)
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>) fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
fun onOwnershipToFalseRequest(reasonReverseTapped: Boolean) fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
fun onShowNearbyUI() fun onShowNearbyUI(sender: String)
} }
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> { fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
@@ -521,11 +522,21 @@ class AACPManager {
Opcodes.SMART_ROUTING_RESP -> { Opcodes.SMART_ROUTING_RESP -> {
val packetString = packet.decodeToString() 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")) { if (packetString.contains("SetOwnershipToFalse")) {
callback?.onOwnershipToFalseRequest(packetString.contains("ReverseBannerTapped")) callback?.onOwnershipToFalseRequest(sender, packetString.contains("ReverseBannerTapped"))
} }
if (packetString.contains("ShowNearbyUI")) { if (packetString.contains("ShowNearbyUI")) {
callback?.onShowNearbyUI() callback?.onShowNearbyUI(sender)
} }
} }
@@ -544,7 +555,7 @@ class AACPManager {
) )
return return
} }
// first 4 bytes AACP header, next two bytes opcode, next to bytes identifer
eqOnMedia = (packet[10] == 0x01.toByte()) eqOnMedia = (packet[10] == 0x01.toByte())
eqOnPhone = (packet[11] == 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. // 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 eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq4 = ByteBuffer.wrap(packet, 108, 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) } eqData = FloatArray(8) { i -> eq1.get(i) }
Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia") 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 { fun createMediaInformationNewDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray {
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
val buffer = ByteBuffer.allocate(112) val buffer = ByteBuffer.allocate(116)
buffer.put( buffer.put(
targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() 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(byteArrayOf(0x01, 0xE5.toByte(), 0x4A))
buffer.put("playingApp".toByteArray()) buffer.put("playingApp".toByteArray())
buffer.put(0x42) buffer.put(0x42)
@@ -775,8 +786,8 @@ class AACPManager {
buffer.put(selfMacAddress.toByteArray()) buffer.put(selfMacAddress.toByteArray())
buffer.put(0x46) buffer.put(0x46)
buffer.put("btName".toByteArray()) buffer.put("btName".toByteArray())
buffer.put(0x43) buffer.put(0x47)
buffer.put("And".toByteArray()) buffer.put("Android".toByteArray())
buffer.put(0x58) buffer.put(0x58)
buffer.put("otherDevice".toByteArray()) buffer.put("otherDevice".toByteArray())
buffer.put("AudioCategory".toByteArray()) buffer.put("AudioCategory".toByteArray())
@@ -805,8 +816,6 @@ class AACPManager {
buffer.put( buffer.put(
targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray()
) )
// 620001E54A6C6F63616C73636F7265306446726561736F6E4848696A61636B763251617564696F526F7574696E6753636F7265312D015F617564696F526F7574696E675365744F776E657273686970546F46616C7365014B72656D6F746573636F7265A5
buffer.put(byteArrayOf(0x62, 0x00)) buffer.put(byteArrayOf(0x62, 0x00))
buffer.put(byteArrayOf(0x01, 0xE5.toByte())) buffer.put(byteArrayOf(0x01, 0xE5.toByte()))
buffer.put(0x4A) buffer.put(0x4A)
@@ -854,16 +863,16 @@ class AACPManager {
streamingState: Boolean = true streamingState: Boolean = true
): ByteArray { ): ByteArray {
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
val buffer = ByteBuffer.allocate(134) val buffer = ByteBuffer.allocate(138)
buffer.put( buffer.put(
targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray()
) )
buffer.put( buffer.put(
byteArrayOf( byteArrayOf(
0x7E, 0x82.toByte(), // related to the length
0x00 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(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant
buffer.put("PlayingApp".toByteArray()) buffer.put("PlayingApp".toByteArray())
buffer.put(byteArrayOf(0x56)) // 'V', seems like a identifier or a separator buffer.put(byteArrayOf(0x56)) // 'V', seems like a identifier or a separator
@@ -877,8 +886,8 @@ class AACPManager {
buffer.put(0x51) // 'Q' buffer.put(0x51) // 'Q'
buffer.put(selfMacAddress.toByteArray()) // self MAC buffer.put(selfMacAddress.toByteArray()) // self MAC
buffer.put("btName".toByteArray()) // self name buffer.put("btName".toByteArray()) // self name
buffer.put(0x44) // 'D' buffer.put(0x47) // '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("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(0x58) // 'X'
buffer.put("otherDevice".toByteArray()) buffer.put("otherDevice".toByteArray())
buffer.put("AudioCategory".toByteArray()) buffer.put("AudioCategory".toByteArray())
@@ -973,11 +982,11 @@ class AACPManager {
fun createAddTiPiDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray { fun createAddTiPiDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray {
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
val buffer = ByteBuffer.allocate(86) val buffer = ByteBuffer.allocate(90)
buffer.put( buffer.put(
targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray() 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(byteArrayOf(0x01, 0xE5.toByte()))
buffer.put(0x48) // 'H' buffer.put(0x48) // 'H'
buffer.put("idleTime".toByteArray()) buffer.put("idleTime".toByteArray())
@@ -989,8 +998,8 @@ class AACPManager {
buffer.put(selfMacAddress.toByteArray()) buffer.put(selfMacAddress.toByteArray())
buffer.put(0x46) buffer.put(0x46)
buffer.put("btName".toByteArray()) buffer.put("btName".toByteArray())
buffer.put(0x43) buffer.put(0x47)
buffer.put("And".toByteArray()) buffer.put("Android".toByteArray())
buffer.put(0x50) buffer.put(0x50)
buffer.put("nearbyAudioScore".toByteArray()) buffer.put("nearbyAudioScore".toByteArray())
buffer.put(byteArrayOf(0x0E)) buffer.put(byteArrayOf(0x0E))
@@ -1164,13 +1173,13 @@ class AACPManager {
val mac = macBytes.joinToString(":") { "%02X".format(it) } val mac = macBytes.joinToString(":") { "%02X".format(it) }
val info1 = data[offset + 6] val info1 = data[offset + 6]
val info2 = data[offset + 7] 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 offset += 8
} }
return devices return devices
} }
fun sendSomePacketIDontKnowWhatItIs() { fun sendSomePacketIDontKnowWhatItIs() {
// 2900 00ff ffff ffff ffff -- enables setting EQ // 2900 00ff ffff ffff ffff -- enables setting EQ
sendDataPacket( sendDataPacket(

View File

@@ -165,7 +165,7 @@ class IslandWindow(private val context: Context) {
@SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag", @SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag",
"SetTextI18n" "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 if (ServiceManager.getService()?.islandOpen == true) return
else ServiceManager.getService()?.islandOpen = true else ServiceManager.getService()?.islandOpen = true
@@ -352,19 +352,22 @@ class IslandWindow(private val context: Context) {
when (type) { when (type) {
IslandType.CONNECTED -> { 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 -> { 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 -> { 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 -> { 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) { 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 { } 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 conversationalAwarenessVolume: Int = 2
private var conversationalAwarenessPauseMusic: Boolean = false private var conversationalAwarenessPauseMusic: Boolean = false
var recentlyLostOwnership: Boolean = false
fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) { fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) {
if (this::audioManager.isInitialized) { if (this::audioManager.isInitialized) {
return return
@@ -118,10 +120,14 @@ object MediaController {
if (isActive) { if (isActive) {
Log.d("MediaController", "Detected play while pausedForOtherDevice; attempting to take over") Log.d("MediaController", "Detected play while pausedForOtherDevice; attempting to take over")
pausedForOtherDevice = false if (!recentlyLostOwnership) {
userPlayedTheMedia = true pausedForOtherDevice = false
if (!pausedWhileTakingOver) { userPlayedTheMedia = true
ServiceManager.getService()?.takeOver("music") if (!pausedWhileTakingOver) {
ServiceManager.getService()?.takeOver("music")
}
} else {
Log.d("MediaController", "Skipping take-over due to recent ownership loss")
} }
} else { } else {
Log.d("MediaController", "Still not active while pausedForOtherDevice; will clear state after timeout") Log.d("MediaController", "Still not active while pausedForOtherDevice; will clear state after timeout")
@@ -148,8 +154,12 @@ object MediaController {
Log.d("MediaController", "pausedWhileTakingOver: $pausedWhileTakingOver") Log.d("MediaController", "pausedWhileTakingOver: $pausedWhileTakingOver")
if (!pausedWhileTakingOver && isActive) { if (!pausedWhileTakingOver && isActive) {
if (lastKnownIsMusicActive != true) { if (lastKnownIsMusicActive != true) {
Log.d("MediaController", "Music is active and not pausedWhileTakingOver; requesting takeOver") if (!recentlyLostOwnership) {
ServiceManager.getService()?.takeOver("music") 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_connected_remote_text">Connected to Linux</string>
<string name="island_taking_over_text">Connected</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_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="island_moved_to_other_device_reversed_text">Reconnect from notification</string>
<string name="head_tracking">Head Tracking</string> <string name="head_tracking">Head Tracking</string>
<string name="head_gestures_details">Nod to answer calls, and shake your head to decline.</string> <string name="head_gestures_details">Nod to answer calls, and shake your head to decline.</string>