diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a6a5ae4..e7f26a1 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -30,9 +30,10 @@
-
+
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
index 97bdaeb..c6e68ec 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
@@ -387,9 +387,6 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
)
-
- Spacer(modifier = Modifier.height(16.dp))
- AccessibilitySettings()
}
Spacer(modifier = Modifier.height(16.dp))
@@ -403,6 +400,9 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
// Only show debug when not in BLE-only mode
if (!bleOnlyMode) {
+ Spacer(modifier = Modifier.height(16.dp))
+ AccessibilitySettings()
+
Spacer(modifier = Modifier.height(16.dp))
NavigationButton("debug", "Debug", navController)
}
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 f8cb408..f6ab057 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
@@ -143,8 +143,10 @@ object ServiceManager {
@ExperimentalEncodingApi
class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener {
var macAddress = ""
+ var localMac = ""
lateinit var aacpManager: AACPManager
var cameraActive = false
+ private var disconnectedBecauseReversed = false
data class ServiceConfig(
var deviceName: String = "AirPods",
var earDetectionEnabled: Boolean = true,
@@ -311,10 +313,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
updateBattery()
Log.d("AirPodsBLEService", "Battery changed")
}
-
}
+
override fun onCreate() {
super.onCreate()
+
sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE)
inMemoryLogs.addAll(sharedPreferencesLogs.getStringSet(packetLogKey, emptySet()) ?: emptySet())
@@ -327,9 +330,345 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
initializeAACPManagerCallback()
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
+
+ val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "settings", "get", "secure", "bluetooth_address"))
+ val output = process.inputStream.bufferedReader().use { it.readLine() }
+ localMac = output.trim()
+
+ ServiceManager.setService(this)
+ startForegroundNotification()
+ initGestureDetector()
+
+ bleManager = BLEManager(this)
+ bleManager.setAirPodsStatusListener(bleStatusListener)
+
+ sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
+
+ with(sharedPreferences) {
+ val editor = edit()
+
+ if (!contains("conversational_awareness_pause_music")) editor.putBoolean("conversational_awareness_pause_music", false)
+ if (!contains("personalized_volume")) editor.putBoolean("personalized_volume", false)
+ if (!contains("automatic_ear_detection")) editor.putBoolean("automatic_ear_detection", true)
+ if (!contains("long_press_nc")) editor.putBoolean("long_press_nc", true)
+ if (!contains("off_listening_mode")) editor.putBoolean("off_listening_mode", false)
+ if (!contains("show_phone_battery_in_widget")) editor.putBoolean("show_phone_battery_in_widget", true)
+ if (!contains("single_anc")) editor.putBoolean("single_anc", true)
+ if (!contains("long_press_transparency")) editor.putBoolean("long_press_transparency", true)
+ if (!contains("conversational_awareness")) editor.putBoolean("conversational_awareness", true)
+ if (!contains("relative_conversational_awareness_volume")) editor.putBoolean("relative_conversational_awareness_volume", true)
+ if (!contains("long_press_adaptive")) editor.putBoolean("long_press_adaptive", true)
+ if (!contains("loud_sound_reduction")) editor.putBoolean("loud_sound_reduction", true)
+ if (!contains("long_press_off")) editor.putBoolean("long_press_off", false)
+ if (!contains("volume_control")) editor.putBoolean("volume_control", true)
+ if (!contains("head_gestures")) editor.putBoolean("head_gestures", true)
+ if (!contains("disconnect_when_not_wearing")) editor.putBoolean("disconnect_when_not_wearing", false)
+
+ // AirPods state-based takeover
+ if (!contains("takeover_when_disconnected")) editor.putBoolean("takeover_when_disconnected", true)
+ if (!contains("takeover_when_idle")) editor.putBoolean("takeover_when_idle", true)
+ if (!contains("takeover_when_music")) editor.putBoolean("takeover_when_music", false)
+ if (!contains("takeover_when_call")) editor.putBoolean("takeover_when_call", true)
+
+ // Phone state-based takeover
+ if (!contains("takeover_when_ringing_call")) editor.putBoolean("takeover_when_ringing_call", true)
+ if (!contains("takeover_when_media_start")) editor.putBoolean("takeover_when_media_start", true)
+
+ if (!contains("adaptive_strength")) editor.putInt("adaptive_strength", 51)
+ if (!contains("tone_volume")) editor.putInt("tone_volume", 75)
+ if (!contains("conversational_awareness_volume")) editor.putInt("conversational_awareness_volume", 43)
+
+ if (!contains("textColor")) editor.putLong("textColor", -1L)
+
+ if (!contains("qs_click_behavior")) editor.putString("qs_click_behavior", "cycle")
+ if (!contains("name")) editor.putString("name", "AirPods")
+ if (!contains("ble_only_mode")) editor.putBoolean("ble_only_mode", false)
+
+ if (!contains("left_single_press_action")) editor.putString("left_single_press_action",
+ StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name)
+ if (!contains("right_single_press_action")) editor.putString("right_single_press_action",
+ StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name)
+ if (!contains("left_double_press_action")) editor.putString("left_double_press_action",
+ StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name)
+ if (!contains("right_double_press_action")) editor.putString("right_double_press_action",
+ StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name)
+ if (!contains("left_triple_press_action")) editor.putString("left_triple_press_action",
+ StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name)
+ if (!contains("right_triple_press_action")) editor.putString("right_triple_press_action",
+ StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name)
+ if (!contains("left_long_press_action")) editor.putString("left_long_press_action",
+ StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name)
+ if (!contains("right_long_press_action")) editor.putString("right_long_press_action",
+ StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name)
+
+ editor.apply()
+ }
+
+ initializeConfig()
+
+ ancModeReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == "me.kavishdevar.librepods.SET_ANC_MODE") {
+ if (intent.hasExtra("mode")) {
+ val mode = intent.getIntExtra("mode", -1)
+ if (mode in 1..4) {
+ aacpManager.sendControlCommand(
+ AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
+ mode
+ )
+ }
+ } else {
+ val currentMode = ancNotification.status
+ val allowOffModeValue = aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION }
+ val allowOffMode = allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte()
+
+ val nextMode = if (allowOffMode) {
+ when (currentMode) {
+ 1 -> 2
+ 2 -> 3
+ 3 -> 4
+ 4 -> 1
+ else -> 1
+ }
+ } else {
+ when (currentMode) {
+ 1 -> 2
+ 2 -> 3
+ 3 -> 4
+ 4 -> 2
+ else -> 2
+ }
+ }
+
+ aacpManager.sendControlCommand(
+ AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
+ nextMode
+ )
+ Log.d("AirPodsService", "Cycling ANC mode from $currentMode to $nextMode (offListeningMode: $allowOffMode)")
+ }
+ }
+ }
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(ancModeReceiver, ancModeFilter, RECEIVER_EXPORTED)
+ } else {
+ registerReceiver(ancModeReceiver, ancModeFilter)
+ }
+ val audioManager =
+ this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
+ MediaController.initialize(
+ audioManager,
+ this@AirPodsService.getSharedPreferences(
+ "settings",
+ MODE_PRIVATE
+ )
+ )
+ Log.d("AirPodsService", "Initializing CrossDevice")
+ CoroutineScope(Dispatchers.IO).launch {
+ CrossDevice.init(this@AirPodsService)
+ Log.d("AirPodsService", "CrossDevice initialized")
+ }
+
+ sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
+ macAddress = sharedPreferences.getString("mac_address", "") ?: ""
+
+ telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
+ phoneStateListener = object : PhoneStateListener() {
+ @SuppressLint("SwitchIntDef", "NewApi")
+ override fun onCallStateChanged(state: Int, phoneNumber: String?) {
+ super.onCallStateChanged(state, phoneNumber)
+ when (state) {
+ TelephonyManager.CALL_STATE_RINGING -> {
+ val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true
+ if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(Dispatchers.IO).launch {
+ takeOver("call")
+ }
+ if (config.headGestures) {
+ callNumber = phoneNumber
+ handleIncomingCall()
+ }
+ }
+ TelephonyManager.CALL_STATE_OFFHOOK -> {
+ val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true
+ if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(
+ Dispatchers.IO).launch {
+ takeOver("call")
+ }
+ isInCall = true
+ }
+ TelephonyManager.CALL_STATE_IDLE -> {
+ isInCall = false
+ callNumber = null
+ gestureDetector?.stopDetection()
+ }
+ }
+ }
+ }
+ telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
+
+ if (config.showPhoneBatteryInWidget) {
+ widgetMobileBatteryEnabled = true
+ val batteryChangedIntentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
+ batteryChangedIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(
+ BatteryChangedIntentReceiver,
+ batteryChangedIntentFilter,
+ RECEIVER_EXPORTED
+ )
+ } else {
+ registerReceiver(BatteryChangedIntentReceiver, batteryChangedIntentFilter)
+ }
+ }
+ val serviceIntentFilter = IntentFilter().apply {
+ addAction("android.bluetooth.device.action.ACL_CONNECTED")
+ addAction("android.bluetooth.device.action.ACL_DISCONNECTED")
+ addAction("android.bluetooth.device.action.BOND_STATE_CHANGED")
+ addAction("android.bluetooth.device.action.NAME_CHANGED")
+ addAction("android.bluetooth.adapter.action.CONNECTION_STATE_CHANGED")
+ addAction("android.bluetooth.adapter.action.STATE_CHANGED")
+ addAction("android.bluetooth.headset.profile.action.CONNECTION_STATE_CHANGED")
+ addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT")
+ addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
+ addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED")
+ }
+
+ connectionReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) {
+ device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra("device", BluetoothDevice::class.java)!!
+ } else {
+ intent.getParcelableExtra("device") as BluetoothDevice?
+ }
+
+ if (config.deviceName == "AirPods" && device?.name != null) {
+ config.deviceName = device?.name ?: "AirPods"
+ sharedPreferences.edit { putString("name", config.deviceName) }
+ }
+
+ Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
+ if (!CrossDevice.isAvailable && !config.bleOnlyMode) {
+ Log.d("AirPodsService", "${config.deviceName} connected")
+ CoroutineScope(Dispatchers.IO).launch {
+ connectToSocket(device!!)
+ }
+ Log.d("AirPodsService", "Setting metadata")
+ setMetadatas(device!!)
+ isConnectedLocally = true
+ macAddress = device!!.address
+ sharedPreferences.edit {
+ putString("mac_address", macAddress)
+ }
+ } else if (config.bleOnlyMode) {
+ Log.d("AirPodsService", "BLE-only mode: skipping L2CAP connection")
+ macAddress = device!!.address
+ sharedPreferences.edit {
+ putString("mac_address", macAddress)
+ }
+ }
+ } else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
+ device = null
+ isConnectedLocally = false
+ popupShown = false
+ updateNotificationContent(false)
+ }
+ }
+ }
+ val showIslandReceiver = object: BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == "me.kavishdevar.librepods.cross_device_island") {
+ showIsland(this@AirPodsService, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!))
+ } else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
+ try {
+ context?.unregisterReceiver(this)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ }
+ }
+
+ val showIslandIntentFilter = IntentFilter().apply {
+ addAction("me.kavishdevar.librepods.cross_device_island")
+ addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(showIslandReceiver, showIslandIntentFilter, RECEIVER_EXPORTED)
+ } else {
+ registerReceiver(showIslandReceiver, showIslandIntentFilter)
+ }
+
+ val deviceIntentFilter = IntentFilter().apply {
+ addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
+ addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(connectionReceiver, deviceIntentFilter, RECEIVER_EXPORTED)
+ registerReceiver(bluetoothReceiver, serviceIntentFilter, RECEIVER_EXPORTED)
+ } else {
+ registerReceiver(connectionReceiver, deviceIntentFilter)
+ registerReceiver(bluetoothReceiver, serviceIntentFilter)
+ }
+
+ val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
+
+ bluetoothAdapter.bondedDevices.forEach { device ->
+ device.fetchUuidsWithSdp()
+ if (device.uuids != null) {
+ if (device.uuids.contains(ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
+ bluetoothAdapter.getProfileProxy(
+ this,
+ object : BluetoothProfile.ServiceListener {
+ override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
+ if (profile == BluetoothProfile.A2DP) {
+ val connectedDevices = proxy.connectedDevices
+ if (connectedDevices.isNotEmpty()) {
+ if (!CrossDevice.isAvailable && !config.bleOnlyMode) {
+ CoroutineScope(Dispatchers.IO).launch {
+ connectToSocket(device)
+ }
+ setMetadatas(device)
+ macAddress = device.address
+ sharedPreferences.edit {
+ putString("mac_address", macAddress)
+ }
+ } else if (config.bleOnlyMode) {
+ Log.d("AirPodsService", "BLE-only mode: skipping L2CAP connection")
+ macAddress = device.address
+ sharedPreferences.edit {
+ putString("mac_address", macAddress)
+ }
+ }
+ this@AirPodsService.sendBroadcast(
+ Intent(AirPodsNotifications.AIRPODS_CONNECTED)
+ )
+ }
+ }
+ bluetoothAdapter.closeProfileProxy(profile, proxy)
+ }
+
+ override fun onServiceDisconnected(profile: Int) {}
+ },
+ BluetoothProfile.A2DP
+ )
+ }
+ }
+ }
+
+ if (!isConnectedLocally && !CrossDevice.isAvailable) {
+ clearPacketLogs()
+ }
+
+ CoroutineScope(Dispatchers.IO).launch {
+ bleManager.startScanning()
+ }
}
- fun cameraOpened() {
+ fun cameraOpened() {
Log.d("AirPodsService", "Camera opened, gonna handle stem presses and take action if enabled")
val isCameraShutterUsed = listOf(
config.leftSinglePressAction,
@@ -347,7 +686,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
cameraActive = true
setupStemActions(isCameraActive = true)
}
- }
+ }
fun cameraClosed() {
cameraActive = false
@@ -465,6 +804,54 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
+ override fun onOwnershipChangeReceived(owns: Boolean) {
+ if (!owns) {
+ Log.d("AirPodsService", "ownership lost")
+ MediaController.sendPause()
+ MediaController.pausedForOtherDevice = true
+ }
+ }
+
+ override fun onOwnershipToFalseRequest(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 :]
+ Log.d("AirPodsService", "other device has hijacked the connection, reasonReverseTapped: $reasonReverseTapped")
+ aacpManager.sendControlCommand(
+ AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
+ byteArrayOf(0x00)
+ )
+ if (reasonReverseTapped) {
+ Log.d("AirPodsService", "reverse tapped, disconnecting audio")
+ disconnectedBecauseReversed = true
+ disconnectAudio(this@AirPodsService, 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 = reasonReverseTapped
+ )
+ }
+ if (!aacpManager.owns) {
+ 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 = reasonReverseTapped
+ )
+ }
+ 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 onDeviceMetadataReceived(deviceMetadata: ByteArray) {
}
@@ -498,7 +885,37 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
action?.let { executeStemAction(it) }
}
+ override fun onAudioSourceReceived(audioSource: ByteArray) {
+ Log.d("AirPodsParser", "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}")
+ if (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && aacpManager.audioSource?.mac != localMac) {
+ Log.d("AirPodsParser", "Audio source is another device, better to give up aacp control")
+ aacpManager.sendControlCommand(
+ AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
+ 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")
+ MediaController.pausedForOtherDevice = false
+ }
+ }
+ override fun onConnectedDevicesReceived(connectedDevices: List) {
+ for (device in connectedDevices) {
+ Log.d("AirPodsParser", "Connected device: ${device.mac}, info1: ${device.info1}, info2: ${device.info2})")
+ }
+ val newDevices = connectedDevices.filter { newDevice ->
+ val notInOld = aacpManager.oldConnectedDevices?.none { oldDevice -> oldDevice.mac == newDevice.mac } ?: true
+ val notLocal = newDevice.mac != localMac
+ notInOld && notLocal
+ }
+
+ 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")
+ aacpManager.sendMediaInformationNewDevice(selfMacAddress = localMac, targetMacAddress = device.mac)
+ aacpManager.sendAddTiPiDevice(selfMacAddress = localMac, targetMacAddress = device.mac)
+ }
+ }
override fun onUnknownPacketReceived(packet: ByteArray) {
Log.d("AACPManager", "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}")
}
@@ -840,7 +1257,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) {
+ fun showIsland(service: Service, batteryPercentage: Int, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false) {
Log.d("AirPodsService", "Showing island window")
if (!Settings.canDrawOverlays(service)) {
Log.d("AirPodsService", "No permission for SYSTEM_ALERT_WINDOW")
@@ -848,7 +1265,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)
+ islandWindow!!.show(sharedPreferences.getString("name", "AirPods Pro").toString(), batteryPercentage, this@AirPodsService, type, reversed)
}
}
@@ -1206,7 +1623,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return
}
if (connected && (config.bleOnlyMode || socket.isConnected)) {
- updatedNotification = NotificationCompat.Builder(this, "airpods_connection_status")
+ val updatedNotificationBuilder = NotificationCompat.Builder(this, "airpods_connection_status")
.setSmallIcon(R.drawable.airpods)
.setContentTitle(airpodsName ?: config.deviceName)
.setContentText(
@@ -1239,10 +1656,25 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
.setCategory(Notification.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
- .build()
+
+ if (disconnectedBecauseReversed) {
+ updatedNotificationBuilder.addAction(
+ R.drawable.ic_bluetooth,
+ "Reconnect",
+ PendingIntent.getService(
+ this,
+ 0,
+ Intent(this, AirPodsService::class.java).apply {
+ action = "me.kavishdevar.librepods.RECONNECT_AFTER_REVERSE"
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ )
+ }
+
+ val updatedNotification = updatedNotificationBuilder.build()
notificationManager.notify(2, updatedNotification)
-
notificationManager.cancel(1)
} else if (!connected) {
updatedNotification = NotificationCompat.Builder(this, "background_service_status")
@@ -1576,337 +2008,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")
- ServiceManager.setService(this)
- startForegroundNotification()
- initGestureDetector()
+ Log.d("AirPodsService", "Service started with intent action: ${intent?.action}")
- bleManager = BLEManager(this)
- bleManager.setAirPodsStatusListener(bleStatusListener)
-
- sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
-
- with(sharedPreferences) {
- val editor = edit()
-
- if (!contains("conversational_awareness_pause_music")) editor.putBoolean("conversational_awareness_pause_music", false)
- if (!contains("personalized_volume")) editor.putBoolean("personalized_volume", false)
- if (!contains("automatic_ear_detection")) editor.putBoolean("automatic_ear_detection", true)
- if (!contains("long_press_nc")) editor.putBoolean("long_press_nc", true)
- if (!contains("off_listening_mode")) editor.putBoolean("off_listening_mode", false)
- if (!contains("show_phone_battery_in_widget")) editor.putBoolean("show_phone_battery_in_widget", true)
- if (!contains("single_anc")) editor.putBoolean("single_anc", true)
- if (!contains("long_press_transparency")) editor.putBoolean("long_press_transparency", true)
- if (!contains("conversational_awareness")) editor.putBoolean("conversational_awareness", true)
- if (!contains("relative_conversational_awareness_volume")) editor.putBoolean("relative_conversational_awareness_volume", true)
- if (!contains("long_press_adaptive")) editor.putBoolean("long_press_adaptive", true)
- if (!contains("loud_sound_reduction")) editor.putBoolean("loud_sound_reduction", true)
- if (!contains("long_press_off")) editor.putBoolean("long_press_off", false)
- if (!contains("volume_control")) editor.putBoolean("volume_control", true)
- if (!contains("head_gestures")) editor.putBoolean("head_gestures", true)
- if (!contains("disconnect_when_not_wearing")) editor.putBoolean("disconnect_when_not_wearing", false)
-
- // AirPods state-based takeover
- if (!contains("takeover_when_disconnected")) editor.putBoolean("takeover_when_disconnected", true)
- if (!contains("takeover_when_idle")) editor.putBoolean("takeover_when_idle", true)
- if (!contains("takeover_when_music")) editor.putBoolean("takeover_when_music", false)
- if (!contains("takeover_when_call")) editor.putBoolean("takeover_when_call", true)
-
- // Phone state-based takeover
- if (!contains("takeover_when_ringing_call")) editor.putBoolean("takeover_when_ringing_call", true)
- if (!contains("takeover_when_media_start")) editor.putBoolean("takeover_when_media_start", true)
-
- if (!contains("adaptive_strength")) editor.putInt("adaptive_strength", 51)
- if (!contains("tone_volume")) editor.putInt("tone_volume", 75)
- if (!contains("conversational_awareness_volume")) editor.putInt("conversational_awareness_volume", 43)
-
- if (!contains("textColor")) editor.putLong("textColor", -1L)
-
- if (!contains("qs_click_behavior")) editor.putString("qs_click_behavior", "cycle")
- if (!contains("name")) editor.putString("name", "AirPods")
- if (!contains("ble_only_mode")) editor.putBoolean("ble_only_mode", false)
-
- if (!contains("left_single_press_action")) editor.putString("left_single_press_action",
- StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name)
- if (!contains("right_single_press_action")) editor.putString("right_single_press_action",
- StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name)
- if (!contains("left_double_press_action")) editor.putString("left_double_press_action",
- StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name)
- if (!contains("right_double_press_action")) editor.putString("right_double_press_action",
- StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name)
- if (!contains("left_triple_press_action")) editor.putString("left_triple_press_action",
- StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name)
- if (!contains("right_triple_press_action")) editor.putString("right_triple_press_action",
- StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name)
- if (!contains("left_long_press_action")) editor.putString("left_long_press_action",
- StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name)
- if (!contains("right_long_press_action")) editor.putString("right_long_press_action",
- StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name)
-
- editor.apply()
- }
-
- initializeConfig()
-
- ancModeReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context?, intent: Intent?) {
- if (intent?.action == "me.kavishdevar.librepods.SET_ANC_MODE") {
- if (intent.hasExtra("mode")) {
- val mode = intent.getIntExtra("mode", -1)
- if (mode in 1..4) {
- aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
- mode
- )
- }
- } else {
- val currentMode = ancNotification.status
- val allowOffModeValue = aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION }
- val allowOffMode = allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte()
-
- val nextMode = if (allowOffMode) {
- when (currentMode) {
- 1 -> 2
- 2 -> 3
- 3 -> 4
- 4 -> 1
- else -> 1
- }
- } else {
- when (currentMode) {
- 1 -> 2
- 2 -> 3
- 3 -> 4
- 4 -> 2
- else -> 2
- }
- }
-
- aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
- nextMode
- )
- Log.d("AirPodsService", "Cycling ANC mode from $currentMode to $nextMode (offListeningMode: $allowOffMode)")
- }
- }
- }
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- registerReceiver(ancModeReceiver, ancModeFilter, RECEIVER_EXPORTED)
- } else {
- registerReceiver(ancModeReceiver, ancModeFilter)
- }
- val audioManager =
- this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
- MediaController.initialize(
- audioManager,
- this@AirPodsService.getSharedPreferences(
- "settings",
- MODE_PRIVATE
- )
- )
- Log.d("AirPodsService", "Initializing CrossDevice")
- CoroutineScope(Dispatchers.IO).launch {
- CrossDevice.init(this@AirPodsService)
- Log.d("AirPodsService", "CrossDevice initialized")
- }
-
- sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
- macAddress = sharedPreferences.getString("mac_address", "") ?: ""
-
- telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
- phoneStateListener = object : PhoneStateListener() {
- @SuppressLint("SwitchIntDef")
- override fun onCallStateChanged(state: Int, phoneNumber: String?) {
- super.onCallStateChanged(state, phoneNumber)
- when (state) {
- TelephonyManager.CALL_STATE_RINGING -> {
- val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true
- if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(Dispatchers.IO).launch {
- takeOver("call")
- }
- if (config.headGestures) {
- callNumber = phoneNumber
- handleIncomingCall()
- }
- }
- TelephonyManager.CALL_STATE_OFFHOOK -> {
- val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true
- if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(
- Dispatchers.IO).launch {
- takeOver("call")
- }
- isInCall = true
- }
- TelephonyManager.CALL_STATE_IDLE -> {
- isInCall = false
- callNumber = null
- gestureDetector?.stopDetection()
- }
- }
- }
- }
- telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
-
- if (config.showPhoneBatteryInWidget) {
- widgetMobileBatteryEnabled = true
- val batteryChangedIntentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
- batteryChangedIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- registerReceiver(
- BatteryChangedIntentReceiver,
- batteryChangedIntentFilter,
- RECEIVER_EXPORTED
- )
- } else {
- registerReceiver(BatteryChangedIntentReceiver, batteryChangedIntentFilter)
- }
- }
- val serviceIntentFilter = IntentFilter().apply {
- addAction("android.bluetooth.device.action.ACL_CONNECTED")
- addAction("android.bluetooth.device.action.ACL_DISCONNECTED")
- addAction("android.bluetooth.device.action.BOND_STATE_CHANGED")
- addAction("android.bluetooth.device.action.NAME_CHANGED")
- addAction("android.bluetooth.adapter.action.CONNECTION_STATE_CHANGED")
- addAction("android.bluetooth.adapter.action.STATE_CHANGED")
- addAction("android.bluetooth.headset.profile.action.CONNECTION_STATE_CHANGED")
- addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT")
- addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
- addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED")
- }
-
- connectionReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context?, intent: Intent?) {
- if (intent?.action == AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) {
- device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- intent.getParcelableExtra("device", BluetoothDevice::class.java)!!
- } else {
- intent.getParcelableExtra("device") as BluetoothDevice?
- }
-
- if (config.deviceName == "AirPods" && device?.name != null) {
- config.deviceName = device?.name ?: "AirPods"
- sharedPreferences.edit { putString("name", config.deviceName) }
- }
-
- Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
- if (!CrossDevice.isAvailable && !config.bleOnlyMode) {
- Log.d("AirPodsService", "${config.deviceName} connected")
- CoroutineScope(Dispatchers.IO).launch {
- connectToSocket(device!!)
- }
- Log.d("AirPodsService", "Setting metadata")
- setMetadatas(device!!)
- isConnectedLocally = true
- macAddress = device!!.address
- sharedPreferences.edit {
- putString("mac_address", macAddress)
- }
- } else if (config.bleOnlyMode) {
- Log.d("AirPodsService", "BLE-only mode: skipping L2CAP connection")
- macAddress = device!!.address
- sharedPreferences.edit {
- putString("mac_address", macAddress)
- }
- }
- } else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
- device = null
- isConnectedLocally = false
- popupShown = false
- updateNotificationContent(false)
- }
- }
- }
- val showIslandReceiver = object: BroadcastReceiver() {
- override fun onReceive(context: Context?, intent: Intent?) {
- if (intent?.action == "me.kavishdevar.librepods.cross_device_island") {
- showIsland(this@AirPodsService, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!))
- } else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
- try {
- context?.unregisterReceiver(this)
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
- }
- }
-
- val showIslandIntentFilter = IntentFilter().apply {
- addAction("me.kavishdevar.librepods.cross_device_island")
- addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- registerReceiver(showIslandReceiver, showIslandIntentFilter, RECEIVER_EXPORTED)
- } else {
- registerReceiver(showIslandReceiver, showIslandIntentFilter)
- }
-
- val deviceIntentFilter = IntentFilter().apply {
- addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
- addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- registerReceiver(connectionReceiver, deviceIntentFilter, RECEIVER_EXPORTED)
- registerReceiver(bluetoothReceiver, serviceIntentFilter, RECEIVER_EXPORTED)
- } else {
- registerReceiver(connectionReceiver, deviceIntentFilter)
- registerReceiver(bluetoothReceiver, serviceIntentFilter)
- }
-
- val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
-
- bluetoothAdapter.bondedDevices.forEach { device ->
- device.fetchUuidsWithSdp()
- if (device.uuids != null) {
- if (device.uuids.contains(ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
- bluetoothAdapter.getProfileProxy(
- this,
- object : BluetoothProfile.ServiceListener {
- override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
- if (profile == BluetoothProfile.A2DP) {
- val connectedDevices = proxy.connectedDevices
- if (connectedDevices.isNotEmpty()) {
- if (!CrossDevice.isAvailable && !config.bleOnlyMode) {
- CoroutineScope(Dispatchers.IO).launch {
- connectToSocket(device)
- }
- setMetadatas(device)
- macAddress = device.address
- sharedPreferences.edit {
- putString("mac_address", macAddress)
- }
- } else if (config.bleOnlyMode) {
- Log.d("AirPodsService", "BLE-only mode: skipping L2CAP connection")
- macAddress = device.address
- sharedPreferences.edit {
- putString("mac_address", macAddress)
- }
- }
- this@AirPodsService.sendBroadcast(
- Intent(AirPodsNotifications.AIRPODS_CONNECTED)
- )
- }
- }
- bluetoothAdapter.closeProfileProxy(profile, proxy)
- }
-
- override fun onServiceDisconnected(profile: Int) {}
- },
- BluetoothProfile.A2DP
- )
- }
- }
- }
-
- if (!isConnectedLocally && !CrossDevice.isAvailable) {
- clearPacketLogs()
- }
-
- CoroutineScope(Dispatchers.IO).launch {
- bleManager.startScanning()
+ if (intent?.action == "me.kavishdevar.librepods.RECONNECT_AFTER_REVERSE") {
+ Log.d("AirPodsService", "reconnect after reversed received, taking over")
+ disconnectedBecauseReversed = false
+ takeOver("music", manualTakeOverAfterReversed = true)
}
return START_STICKY
@@ -1916,7 +2023,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun manuallyCheckForAudioSource() {
val shouldResume = MediaController.getMusicActive()
- if (earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) {
+ if ((earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) || disconnectedBecauseReversed) {
Log.d(
"AirPodsService",
"For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again!"
@@ -1926,10 +2033,67 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
@RequiresApi(Build.VERSION_CODES.R)
- @SuppressLint("MissingPermission")
- fun takeOver(takingOverFor: String) {
+ @SuppressLint("MissingPermission", "HardwareIds")
+ fun takeOver(takingOverFor: String, manualTakeOverAfterReversed: Boolean = false, startHeadTrackingAgain: Boolean = false) {
+ if (takingOverFor == "reverse") {
+ aacpManager.sendControlCommand(
+ AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
+ 1
+ )
+ aacpManager.sendMediaInformataion(
+ localMac
+ )
+ aacpManager.sendHijackReversed(
+ localMac
+ )
+ }
+ Log.d("AirPodsService", "owns connection: ${aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value?.get(0)?.toInt()}")
if (isConnectedLocally) {
- Log.d("AirPodsService", "Already connected locally, skipping")
+ 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)
+ disconnectedBecauseReversed = false
+ } else {
+ Log.d("AirPodsService", "connected locally, but can not hijack as other device had reversed")
+ return
+ }
+ }
+
+ Log.d("AirPodsService", "already connected locally, hijacking connection by asking AirPods")
+ aacpManager.sendControlCommand(
+ AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
+ 1
+ )
+ aacpManager.sendMediaInformataion(
+ localMac
+ )
+ aacpManager.sendSmartRoutingShowUI(
+ localMac
+ )
+ aacpManager.sendHijackRequest(
+ localMac
+ )
+ 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)
+ if (takingOverFor == "music") {
+ MediaController.sendPlay()
+ } else if (startHeadTrackingAgain) {
+ Log.d("AirPodsService", "Starting head tracking again after taking control")
+ if (sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false)) {
+ aacpManager.sendDataPacket(aacpManager.createAlternateStartHeadTrackingPacket())
+ } else {
+ aacpManager.sendStartHeadTracking()
+ }
+ }
+ }
+ } else {
+ Log.d("AirPodsService", "Already connected locally and already own connection, skipping takeover")
+ }
return
}
@@ -1972,7 +2136,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (takingOverFor == "music") {
Log.d("AirPodsService", "Pausing music so that it doesn't play through speakers")
- MediaController.pausedForCrossDevice = true
+ MediaController.pausedWhileTakingOver = true
MediaController.sendPause(true)
} else {
handleIncomingCallOnceConnected = true
@@ -2004,7 +2168,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
isConnectedLocally = true
}
}
-
showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!),
IslandType.TAKING_OVER)
@@ -2150,6 +2313,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} else if (bytesRead == -1) {
Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
+ aacpManager.disconnected()
return@launch
}
}
@@ -2157,6 +2321,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
Log.d("AirPods Service", "Socket closed")
isConnectedLocally = false
socket.close()
+ aacpManager.disconnected()
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
}
}
@@ -2174,7 +2339,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun disconnect() {
if (!this::socket.isInitialized) return
socket.close()
- MediaController.pausedForCrossDevice = false
+ MediaController.pausedWhileTakingOver = false
Log.d("AirPodsService", "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)
@@ -2285,7 +2450,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
- if (MediaController.pausedForCrossDevice) {
+ if (MediaController.pausedWhileTakingOver) {
MediaController.sendPlay()
}
}
@@ -2374,6 +2539,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun startHeadTracking() {
isHeadTrackingActive = true
val useAlternatePackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ takeOver("call", startHeadTrackingAgain = true)
+ Log.d("AirPodsService", "Taking over for head tracking")
+ } else {
+ Log.w("AirPodsService", "Will not be taking over for head tracking, might not work.")
+ }
if (useAlternatePackets) {
aacpManager.sendDataPacket(aacpManager.createAlternateStartHeadTrackingPacket())
} else {
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 34b6052..3eeebb1 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
@@ -21,12 +21,9 @@
package me.kavishdevar.librepods.utils
import android.util.Log
-import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers.entries
-import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressBudType.entries
-import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType.entries
-import kotlin.io.encoding.ExperimentalEncodingApi
import java.nio.ByteBuffer
import java.nio.ByteOrder
+import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Manager class for Apple Accessory Communication Protocol (AACP)
@@ -50,7 +47,13 @@ class AACPManager {
const val PROXIMITY_KEYS_REQ: Byte = 0x30
const val PROXIMITY_KEYS_RSP: Byte = 0x31
const val STEM_PRESS: Byte = 0x19
- const val EQ_SETTINGS: Byte = 0x35
+ const val EQ_DATA: Byte = 0x53
+ const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1
+ const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2
+ const val SMART_ROUTING: Byte = 0x10
+ const val TIPI_3: Byte = 0x0C // Don't know this one
+ const val SMART_ROUTING_RESP: Byte = 0x11
+ const val SEND_CONNECTED_MAC: Byte = 0x14
}
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
@@ -78,7 +81,7 @@ class AACPManager {
}
}
-// @Suppress("unused")
+ // @Suppress("unused")
enum class ControlCommandIdentifiers(val value: Byte) {
MIC_MODE(0x01),
BUTTON_SEND_MODE(0x05),
@@ -109,7 +112,9 @@ class AACPManager {
SIRI_MULTITONE_CONFIG(0x32),
HEARING_ASSIST_CONFIG(0x33),
ALLOW_OFF_OPTION(0x34),
- STEM_CONFIG(0x39);
+ STEM_CONFIG(0x39),
+ OWNS_CONNECTION(0x06);
+
companion object {
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
entries.find { it.value == byte }
@@ -122,7 +127,8 @@ class AACPManager {
companion object {
fun fromByte(byte: Byte): ProximityKeyType =
- ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
+ ProximityKeyType.entries.find { it.value == byte }
+ ?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
}
}
@@ -147,15 +153,55 @@ class AACPManager {
entries.find { it.value == byte }
}
}
+
+ enum class AudioSourceType(val value: Byte) {
+ NONE(0x00),
+ CALL(0x01),
+ MEDIA(0x02);
+
+ companion object {
+ fun fromByte(byte: Byte): AudioSourceType? =
+ entries.find { it.value == byte }
+ }
+ }
+
+ data class AudioSource(
+ val mac: String,
+ val type: AudioSourceType
+ )
+
+ data class ConnectedDevice(
+ val mac: String,
+ val info1: Byte,
+ val info2: Byte
+ )
}
- var controlCommandStatusList: MutableList = mutableListOf()
- var controlCommandListeners: MutableMap> = mutableMapOf()
+
+ var controlCommandStatusList: MutableList =
+ mutableListOf()
+ var controlCommandListeners: MutableMap> =
+ mutableMapOf()
+
+ var owns: Boolean = false
+ private set
+
+ var oldConnectedDevices: List = listOf()
+ private set
+
+ var connectedDevices: List = listOf()
+ private set
+
+ var audioSource: AudioSource? = null
+ private set
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
return controlCommandStatusList.find { it.identifier == identifier }
}
- private fun setControlCommandStatusValue(identifier: ControlCommandIdentifiers, value: ByteArray) {
+ private fun setControlCommandStatusValue(
+ identifier: ControlCommandIdentifiers,
+ value: ByteArray
+ ) {
val existingStatus = getControlCommandStatus(identifier)
if (existingStatus == value) {
controlCommandStatusList.remove(existingStatus)
@@ -167,6 +213,10 @@ class AACPManager {
listener.onControlCommandReceived(ControlCommand(identifier.value, value))
}
controlCommandStatusList.add(ControlCommandStatus(identifier, value))
+
+ if (identifier == ControlCommandIdentifiers.OWNS_CONNECTION) {
+ owns = value.isNotEmpty() && value[0] == 0x01.toByte()
+ }
}
interface PacketCallback {
@@ -179,6 +229,11 @@ class AACPManager {
fun onUnknownPacketReceived(packet: ByteArray)
fun onProximityKeysReceived(proximityKeys: ByteArray)
fun onStemPressReceived(stemPress: ByteArray)
+ fun onAudioSourceReceived(audioSource: ByteArray)
+ fun onOwnershipChangeReceived(owns: Boolean)
+ fun onConnectedDevicesReceived(connectedDevices: List)
+ fun onOwnershipToFalseRequest(reasonReverseTapped: Boolean)
+ fun onShowNearbyUI()
}
fun parseStemPressResponse(data: ByteArray): Pair {
@@ -189,8 +244,10 @@ class AACPManager {
if (data[4] != Opcodes.STEM_PRESS) {
throw IllegalArgumentException("Data array does not start with STEM_PRESS opcode")
}
- val type = StemPressType.fromByte(data[6]) ?: throw IllegalArgumentException("Unknown Stem Press Type: ${data[5]}")
- val bud = StemPressBudType.fromByte(data[7]) ?: throw IllegalArgumentException("Unknown Stem Press Bud Type: ${data[6]}")
+ val type = StemPressType.fromByte(data[6])
+ ?: throw IllegalArgumentException("Unknown Stem Press Type: ${data[5]}")
+ val bud = StemPressBudType.fromByte(data[7])
+ ?: throw IllegalArgumentException("Unknown Stem Press Bud Type: ${data[6]}")
return Pair(type, bud)
}
@@ -198,7 +255,10 @@ class AACPManager {
fun onControlCommandReceived(controlCommand: ControlCommand)
}
- fun registerControlCommandListener(identifier: ControlCommandIdentifiers, callback: ControlCommandListener) {
+ fun registerControlCommandListener(
+ identifier: ControlCommandIdentifiers,
+ callback: ControlCommandListener
+ ) {
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
}
@@ -249,7 +309,10 @@ class AACPManager {
}
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
- val controlPacket = createControlCommandPacket(identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02))
+ val controlPacket = createControlCommandPacket(
+ identifier,
+ if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
+ )
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
@@ -267,7 +330,10 @@ class AACPManager {
}
fun parseProximityKeysResponse(data: ByteArray): Map {
- Log.d(TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}")
+ Log.d(
+ TAG,
+ "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
+ )
if (data.size < 4) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
}
@@ -293,7 +359,12 @@ class AACPManager {
System.arraycopy(data, offset, key, 0, keyLength)
keys[ProximityKeyType.fromByte(keyType)] = key
offset += keyLength
- Log.d(TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${key.joinToString(" ") { "%02X".format(it) }}")
+ Log.d(
+ TAG,
+ "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
+ key.joinToString(" ") { "%02X".format(it) }
+ }"
+ )
}
return keys
}
@@ -312,11 +383,21 @@ class AACPManager {
@OptIn(ExperimentalStdlibApi::class)
fun receivePacket(packet: ByteArray) {
if (!packet.toHexString().startsWith("04000400")) {
- Log.w(TAG, "Received packet does not start with expected header: ${packet.joinToString(" ") { "%02X".format(it) }}")
+ Log.w(
+ TAG,
+ "Received packet does not start with expected header: ${
+ packet.joinToString(" ") {
+ "%02X".format(it)
+ }
+ }"
+ )
return
}
if (packet.size < 6) {
- Log.w(TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
+ Log.w(
+ TAG,
+ "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
+ )
return
}
@@ -326,50 +407,111 @@ class AACPManager {
Opcodes.BATTERY_INFO -> {
callback?.onBatteryInfoReceived(packet)
}
+
Opcodes.CONTROL_COMMAND -> {
val controlCommand = ControlCommand.fromByteArray(packet)
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return,
controlCommand.value
)
- Log.d(TAG, "Control command received: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
- Log.d(TAG, "Control command list is now: ${
- controlCommandStatusList.joinToString(", ") { "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${it.value.joinToString(" ") { "%02X".format(it) }}" }
+ Log.d(
+ TAG,
+ "Control command received: ${controlCommand.identifier.toHexString()} - ${
+ controlCommand.value.joinToString(" ") { "%02X".format(it) }
+ }"
+ )
+ Log.d(
+ TAG, "Control command list is now: ${
+ controlCommandStatusList.joinToString(", ") {
+ "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
+ it.value.joinToString(
+ " "
+ ) { "%02X".format(it) }
+ }"
+ }
}")
- val controlCommandIdentifier = ControlCommandIdentifiers.fromByte(controlCommand.identifier)
+ val controlCommandIdentifier =
+ ControlCommandIdentifiers.fromByte(controlCommand.identifier)
if (controlCommandIdentifier != null) {
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
listener.onControlCommandReceived(controlCommand)
}
} else {
- Log.w(TAG, "Unknown control command identifier: ${controlCommand.identifier.toHexString()}")
+ Log.w(
+ TAG,
+ "Unknown control command identifier: ${controlCommand.identifier.toHexString()}"
+ )
+ }
+
+ if (controlCommandIdentifier == ControlCommandIdentifiers.OWNS_CONNECTION) {
+ callback?.onOwnershipChangeReceived(owns)
}
callback?.onControlCommandReceived(packet)
}
+
Opcodes.EAR_DETECTION -> {
callback?.onEarDetectionReceived(packet)
}
+
Opcodes.CONVERSATION_AWARENESS -> {
callback?.onConversationAwarenessReceived(packet)
}
+
Opcodes.DEVICE_METADATA -> {
callback?.onDeviceMetadataReceived(packet)
}
+
Opcodes.HEADTRACKING -> {
if (packet.size < 70) {
- Log.w(TAG, "Received HEADTRACKING packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
+ Log.w(
+ TAG,
+ "Received HEADTRACKING packet too short: ${
+ packet.joinToString(" ") {
+ "%02X".format(it)
+ }
+ }"
+ )
return
}
callback?.onHeadTrackingReceived(packet)
}
+
Opcodes.PROXIMITY_KEYS_RSP -> {
callback?.onProximityKeysReceived(packet)
}
+
Opcodes.STEM_PRESS -> {
callback?.onStemPressReceived(packet)
}
+
+ Opcodes.AUDIO_SOURCE -> {
+ try {
+ val (mac, type) = parseAudioSourceResponse(packet)
+ audioSource = AudioSource(mac, type)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error parsing audio source response: ${e.message}")
+ }
+ callback?.onAudioSourceReceived(packet)
+ }
+
+ Opcodes.CONNECTED_DEVICES -> {
+ oldConnectedDevices = connectedDevices
+ connectedDevices = parseConnectedDevicesResponse(packet)
+ callback?.onConnectedDevicesReceived(connectedDevices)
+ }
+
+ Opcodes.SMART_ROUTING_RESP -> {
+ val packetString = packet.decodeToString()
+ if (packetString.contains("SetOwnershipToFalse")) {
+ callback?.onOwnershipToFalseRequest(packetString.contains("ReverseBannerTapped"))
+ }
+ if (packetString.contains("ShowNearbyUI")) {
+ callback?.onShowNearbyUI()
+ }
+ }
+
else -> {
callback?.onUnknownPacketReceived(packet)
}
@@ -412,7 +554,28 @@ class AACPManager {
fun createStartHeadTrackingPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
val data = byteArrayOf(
- 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00,
+ 0x00,
+ 0x00,
+ 0x10,
+ 0x00,
+ 0x10,
+ 0x00,
+ 0x08,
+ 0xA1.toByte(),
+ 0x02,
+ 0x42,
+ 0x0B,
+ 0x08,
+ 0x0E,
+ 0x10,
+ 0x02,
+ 0x1A,
+ 0x05,
+ 0x01,
+ 0x40,
+ 0x9C.toByte(),
+ 0x00,
+ 0x00,
)
return opcode + data
}
@@ -420,7 +583,27 @@ class AACPManager {
fun createAlternateStartHeadTrackingPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
val data = byteArrayOf(
- 0x00, 0x00, 0x10, 0x00, 0x0F, 0x00, 0x08, 0x73, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00
+ 0x00,
+ 0x00,
+ 0x10,
+ 0x00,
+ 0x0F,
+ 0x00,
+ 0x08,
+ 0x73,
+ 0x42,
+ 0x0B,
+ 0x08,
+ 0x10,
+ 0x10,
+ 0x02,
+ 0x1A,
+ 0x05,
+ 0x01,
+ 0x40,
+ 0x9C.toByte(),
+ 0x00,
+ 0x00
)
return opcode + data
}
@@ -432,7 +615,29 @@ class AACPManager {
fun createStopHeadTrackingPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
val data = byteArrayOf(
- 0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E, 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
+ 0x00,
+ 0x00,
+ 0x10,
+ 0x00,
+ 0x11,
+ 0x00,
+ 0x08,
+ 0x7E,
+ 0x10,
+ 0x02,
+ 0x42,
+ 0x0B,
+ 0x08,
+ 0x4E,
+ 0x10,
+ 0x02,
+ 0x1A,
+ 0x05,
+ 0x01,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00
)
return opcode + data
}
@@ -440,7 +645,27 @@ class AACPManager {
fun createAlternateStopHeadTrackingPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
val data = byteArrayOf(
- 0x00, 0x00, 0x10, 0x00, 0x0F, 0x00, 0x08, 0x75, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
+ 0x00,
+ 0x00,
+ 0x10,
+ 0x00,
+ 0x0F,
+ 0x00,
+ 0x08,
+ 0x75,
+ 0x42,
+ 0x0B,
+ 0x08,
+ 0x10,
+ 0x10,
+ 0x02,
+ 0x1A,
+ 0x05,
+ 0x01,
+ 0x00,
+ 0x00,
+ 0x00,
+ 0x00
)
return opcode + data
}
@@ -462,6 +687,254 @@ class AACPManager {
return packet
}
+ fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
+ if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
+ throw IllegalArgumentException("MAC address must be 6 bytes")
+ }
+ Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: ${targetMacAddress}")
+ Log.d(TAG, "Sending Media Information packet to ${targetMacAddress}")
+ return sendDataPacket(createMediaInformationNewDevicePacket(selfMacAddress, targetMacAddress))
+ }
+
+ fun createMediaInformationNewDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray {
+ val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
+ val buffer = ByteBuffer.allocate(112)
+ buffer.put(
+ targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray()
+ )
+ buffer.put(byteArrayOf(0x68, 0x00))
+ buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A))
+ buffer.put("playingApp".toByteArray())
+ buffer.put(0x42)
+ buffer.put("NA".toByteArray())
+ buffer.put(0x52)
+ buffer.put("hostStreamingState".toByteArray())
+ buffer.put(0x42)
+ buffer.put("NO".toByteArray())
+ buffer.put(0x49)
+ buffer.put("btAddress".toByteArray())
+ buffer.put(0x51)
+ buffer.put(selfMacAddress.toByteArray())
+ buffer.put(0x46)
+ buffer.put("btName".toByteArray())
+ buffer.put(0x43)
+ buffer.put("And".toByteArray())
+ buffer.put(0x58)
+ buffer.put("otherDevice".toByteArray())
+ buffer.put("AudioCategory".toByteArray())
+ buffer.put(byteArrayOf(0x30, 0x64))
+
+ return opcode + buffer.array()
+ }
+
+ fun sendHijackRequest(selfMacAddress: String): Boolean {
+ if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
+ throw IllegalArgumentException("MAC address must be 6 bytes")
+ }
+ var success = false
+ for (connectedDevice in connectedDevices) {
+ if (connectedDevice.mac != selfMacAddress) {
+ Log.d(TAG, "Sending Hijack Request packet to ${connectedDevice.mac}")
+ success = sendDataPacket(createHijackRequestPacket(connectedDevice.mac)) && success
+ }
+ }
+ return success
+ }
+
+ fun createHijackRequestPacket(targetMacAddress: String): ByteArray {
+ val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
+ val buffer = ByteBuffer.allocate(106)
+ 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)
+ buffer.put("localscore".toByteArray())
+ buffer.put(byteArrayOf(0x30, 0x64))
+ buffer.put(0x46)
+ buffer.put("reason".toByteArray())
+ buffer.put(0x48)
+ buffer.put("Hijackv2".toByteArray())
+ buffer.put(0x51)
+ buffer.put("audioRoutingScore".toByteArray())
+ buffer.put(byteArrayOf(0x31, 0x2D, 0x01, 0x5F))
+ buffer.put("audioRoutingSetOwnershipToFalse".toByteArray())
+ buffer.put(0x01)
+ buffer.put(0x4B)
+ buffer.put("remotescore".toByteArray())
+ buffer.put(0xA5.toByte())
+
+ return opcode + buffer.array()
+ }
+
+ fun sendMediaInformataion(selfMacAddress: String, streamingState: Boolean = false): Boolean {
+ if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
+ throw IllegalArgumentException("MAC address must be 6 bytes")
+ }
+ Log.d(TAG, "SELFMAC: ${selfMacAddress}")
+ val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac
+ Log.d(TAG, "Sending Media Information packet to ${targetMac ?: "unknown device"}")
+ return sendDataPacket(
+ createMediaInformationPacket(
+ selfMacAddress,
+ targetMac ?: return false,
+ streamingState
+ )
+ )
+ }
+
+ fun createMediaInformationPacket(
+ selfMacAddress: String,
+ targetMacAddress: String,
+ streamingState: Boolean = true
+ ): ByteArray {
+ val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
+ val buffer = ByteBuffer.allocate(134)
+ buffer.put(
+ targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray()
+ )
+ buffer.put(
+ byteArrayOf(
+ 0x7E,
+ 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
+ buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason
+ buffer.put(byteArrayOf(0x52)) // 'R'
+ buffer.put("HostStreamingState".toByteArray())
+ buffer.put(byteArrayOf(0x42)) // 'B'
+ buffer.put((if (streamingState) "YES" else "NO").toByteArray()) // streaming state
+ buffer.put(0x49) // 'I'
+ buffer.put("btAddress".toByteArray()) // self MAC
+ 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(0x58) // 'X'
+ buffer.put("otherDevice".toByteArray())
+ buffer.put("AudioCategory".toByteArray())
+ buffer.put(byteArrayOf(0x31, 0x2D, 0x01))
+
+ return opcode+buffer.array()
+ }
+
+ fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean {
+ if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
+ throw IllegalArgumentException("MAC address must be 6 bytes")
+ }
+
+ val targetMac = connectedDevices.find { it.mac != selfMacAddress }?.mac
+ if (targetMac == null) {
+ Log.w(TAG, "Cannot send Smart Routing Show UI packet: No connected device found")
+ return false
+ }
+ Log.d(TAG, "Sending Smart Routing Show UI packet to $targetMac")
+ return sendDataPacket(createSmartRoutingShowUIPacket(targetMac))
+ }
+
+ fun createSmartRoutingShowUIPacket(targetMacAddress: String): ByteArray {
+ val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
+ val buffer = ByteBuffer.allocate(134)
+ buffer.put(
+ targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray()
+ )
+ buffer.put(byteArrayOf(0x7E, 0x00))
+ buffer.put(byteArrayOf(0x01, 0xE6.toByte(), 0x5B))
+ buffer.put("SmartRoutingKeyShowNearbyUI".toByteArray())
+ buffer.put(0x01) // separator?
+ buffer.put(0x4A)
+ buffer.put("localscore".toByteArray())
+ buffer.put(0x31, 0x2D)
+ buffer.put(0x01)
+ buffer.put(0x46)
+ buffer.put("reasonHhijackv2".toByteArray())
+ buffer.put(0x51.toByte())
+ buffer.put("audioRoutingScore".toByteArray())
+ buffer.put(0xA2.toByte())
+ buffer.put(0x5F)
+ buffer.put("audioRoutingSetOwnershipToFalse".toByteArray())
+ buffer.put(0x01)
+ buffer.put(0x4B)
+ buffer.put("remotescore".toByteArray())
+ buffer.put(0xA2.toByte())
+ return opcode + buffer.array()
+ }
+
+ fun sendHijackReversed(selfMacAddress: String): Boolean {
+ var success = false
+ for (connectedDevice in connectedDevices) {
+ if (connectedDevice.mac != selfMacAddress) {
+ Log.d(TAG, "Sending Hijack Reversed packet to ${connectedDevice.mac}")
+ success = sendDataPacket(createHijackReversedPacket(connectedDevice.mac)) && success
+ }
+ }
+ return success
+ }
+
+ fun createHijackReversedPacket(targetMacAddress: String): ByteArray {
+ val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
+ val buffer = ByteBuffer.allocate(97)
+ buffer.put(
+ targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray()
+ )
+ buffer.put(byteArrayOf(0x59, 0x00))
+ buffer.put(byteArrayOf(0x01, 0xE3.toByte()))
+ buffer.put(0x5F)
+ buffer.put("audioRoutingSetOwnershipToFalse".toByteArray())
+ buffer.put(0x01)
+ buffer.put(0x59)
+ buffer.put("audioRoutingShowReverseUI".toByteArray())
+ buffer.put(0x01)
+ buffer.put(0x46)
+ buffer.put("reason".toByteArray())
+ buffer.put(0x53)
+ buffer.put("ReverseBannerTapped".toByteArray())
+
+ return opcode + buffer.array()
+ }
+
+
+ fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
+ if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
+ throw IllegalArgumentException("MAC address must be 6 bytes")
+ }
+
+ Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress")
+ return sendDataPacket(createAddTiPiDevicePacket(selfMacAddress, targetMacAddress))
+ }
+
+ fun createAddTiPiDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray {
+ val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
+ val buffer = ByteBuffer.allocate(86)
+ buffer.put(
+ targetMacAddress.split(":").map { it.toInt(16).toByte() }.toByteArray().reversedArray()
+ )
+ buffer.put(byteArrayOf(0x4E, 0x00))
+ buffer.put(byteArrayOf(0x01, 0xE5.toByte()))
+ buffer.put(0x48) // 'H'
+ buffer.put("idleTime".toByteArray())
+ buffer.put(byteArrayOf(0x08, 0x47))
+ buffer.put("newTipi".toByteArray())
+ buffer.put(byteArrayOf(0x01, 0x49))
+ buffer.put("btAddress".toByteArray())
+ buffer.put(0x51)
+ buffer.put(selfMacAddress.toByteArray())
+ buffer.put(0x46)
+ buffer.put("btName".toByteArray())
+ buffer.put(0x43)
+ buffer.put("And".toByteArray())
+ buffer.put(0x50)
+ buffer.put("nearbyAudioScore".toByteArray())
+ buffer.put(byteArrayOf(0x0E))
+ return opcode + buffer.array()
+ }
data class ControlCommand(
val identifier: Byte,
@@ -503,8 +976,9 @@ class AACPManager {
val value = ByteArray(4)
System.arraycopy(data, 3, value, 0, 4)
- val trimmedValue = value.takeWhile { it != 0x00.toByte() }.toByteArray()
- return ControlCommand(identifier, trimmedValue)
+ val trimmedValue = value.dropLastWhile { it == 0x00.toByte() }.toByteArray()
+ val finalValue = if (trimmedValue.isEmpty()) byteArrayOf(0x00) else trimmedValue
+ return ControlCommand(identifier, finalValue)
}
}
}
@@ -526,14 +1000,19 @@ class AACPManager {
)
}
- @OptIn(ExperimentalStdlibApi::class)
- fun sendPacket(packet: ByteArray): Boolean {
+ @OptIn(ExperimentalStdlibApi::class)
+ fun sendPacket(packet: ByteArray): Boolean {
try {
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
if (packet[4] == Opcodes.CONTROL_COMMAND) {
val controlCommand = ControlCommand.fromByteArray(packet)
- Log.d(TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
+ Log.d(
+ TAG,
+ "Control command: ${controlCommand.identifier.toHexString()} - ${
+ controlCommand.value.joinToString(" ") { "%02X".format(it) }
+ }"
+ )
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
controlCommand.value
@@ -555,33 +1034,22 @@ class AACPManager {
}
}
- fun sendEQPacket(eqFloats: FloatArray, phone: Boolean, media: Boolean): Boolean {
- val buffer = ByteBuffer.allocate(140).order(ByteOrder.LITTLE_ENDIAN)
- buffer.put(0x04)
- buffer.put(0x00)
- buffer.put(0x04)
- buffer.put(0x00)
- buffer.put(0x53)
- buffer.put(0x00)
- buffer.put(0x84.toByte())
- buffer.put(0x00)
- buffer.put(0x02)
- buffer.put(0x02)
- buffer.put(if (phone) 0x01 else 0x00)
- buffer.put(if (media) 0x01 else 0x00)
- for (i in 0..7) {
- buffer.putFloat(eqFloats[i])
- }
- while (buffer.hasRemaining()) {
- buffer.put(0x00)
- }
- val packet = buffer.array()
- return sendPacket(packet)
- }
-
fun sendPhoneMediaEQ(eq: FloatArray, phone: Byte = 0x02.toByte(), media: Byte = 0x02.toByte()) {
if (eq.size != 8) throw IllegalArgumentException("EQ must be 8 floats")
- val header = byteArrayOf(0x04.toByte(), 0x00.toByte(), 0x04.toByte(), 0x00.toByte(), 0x53.toByte(), 0x00.toByte(), 0x84.toByte(), 0x00.toByte(), 0x02.toByte(), 0x02.toByte(), phone, media)
+ val header = byteArrayOf(
+ 0x04.toByte(),
+ 0x00.toByte(),
+ 0x04.toByte(),
+ 0x00.toByte(),
+ 0x53.toByte(),
+ 0x00.toByte(),
+ 0x84.toByte(),
+ 0x00.toByte(),
+ 0x02.toByte(),
+ 0x02.toByte(),
+ phone,
+ media
+ )
val buffer = ByteBuffer.allocate(128).order(ByteOrder.LITTLE_ENDIAN)
for (block in 0..3) {
for (i in 0..7) {
@@ -592,4 +1060,60 @@ class AACPManager {
val packet = header + payload
sendPacket(packet)
}
-}
\ No newline at end of file
+
+ fun parseAudioSourceResponse(data: ByteArray): Pair {
+ Log.d(TAG, "Parsing Audio Source Response: ${data.joinToString(" ") { "%02X".format(it) }}")
+ if (data.size < 9) {
+ throw IllegalArgumentException("Data array too short to parse Audio Source Response")
+ }
+ if (data[4] != Opcodes.AUDIO_SOURCE) {
+ throw IllegalArgumentException("Data array does not start with AUDIO_SOURCE opcode")
+ }
+ val macBytes = data.sliceArray(6..11).reversedArray()
+ val mac = macBytes.joinToString(":") { "%02X".format(it) }
+ val typeByte = data[12]
+ val type = AudioSourceType.fromByte(typeByte)
+ ?: throw IllegalArgumentException("Unknown Audio Source Type: $typeByte")
+ return Pair(mac, type)
+ }
+
+ fun parseConnectedDevicesResponse(data: ByteArray): List {
+ Log.d(
+ TAG,
+ "Parsing Connected Devices Response: ${data.joinToString(" ") { "%02X".format(it) }}"
+ )
+ if (data.size < 8) {
+ throw IllegalArgumentException("Data array too short to parse Connected Devices Response")
+ }
+ if (data[4] != Opcodes.CONNECTED_DEVICES) {
+ throw IllegalArgumentException("Data array does not start with CONNECTED_DEVICES opcode")
+ }
+ val deviceCount = data[8].toInt()
+ val devices = mutableListOf()
+
+ var offset = 9
+ for (i in 0 until deviceCount) {
+ if (offset + 8 > data.size) {
+ throw IllegalArgumentException("Data array too short to parse all connected devices")
+ }
+ val macBytes = data.sliceArray(offset until offset + 6)
+ val mac = macBytes.joinToString(":") { "%02X".format(it) }
+ val info1 = data[offset + 6]
+ val info2 = data[offset + 7]
+ devices.add(ConnectedDevice(mac, info1, info2))
+ offset += 8
+ }
+
+ return devices
+ }
+
+ fun disconnected() {
+ Log.d(TAG, "Disconnected, clearing state")
+ controlCommandStatusList.clear()
+ controlCommandListeners.clear()
+ owns = false
+ oldConnectedDevices = listOf()
+ connectedDevices = listOf()
+ audioSource = null
+ }
+}
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 978294e..6010a26 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
@@ -49,6 +49,7 @@ import android.view.animation.AnticipateOvershootInterpolator
import android.view.animation.DecelerateInterpolator
import android.view.animation.OvershootInterpolator
import android.widget.FrameLayout
+import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
@@ -70,6 +71,7 @@ enum class IslandType {
CONNECTED,
TAKING_OVER,
MOVED_TO_REMOTE,
+ MOVED_TO_OTHER_DEVICE,
}
class IslandWindow(private val context: Context) {
@@ -156,7 +158,7 @@ class IslandWindow(private val context: Context) {
}
@SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag")
- fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
+ fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false) {
if (ServiceManager.getService()?.islandOpen == true) return
else ServiceManager.getService()?.islandOpen = true
@@ -197,6 +199,24 @@ class IslandWindow(private val context: Context) {
batteryProgressBar.isIndeterminate = false
islandView.findViewById(R.id.island_device_name).text = name
+ val actionButton = islandView.findViewById(R.id.island_action_button)
+ val batteryBg = islandView.findViewById(R.id.island_battery_bg)
+ if (type == IslandType.MOVED_TO_OTHER_DEVICE && !reversed) {
+ actionButton.visibility = View.VISIBLE
+ actionButton.setOnClickListener {
+ ServiceManager.getService()?.takeOver("reverse")
+ close()
+ }
+ islandView.findViewById(R.id.island_battery_text).visibility = View.GONE
+ islandView.findViewById(R.id.island_battery_progress).visibility = View.GONE
+ batteryBg.visibility = View.GONE
+ } else {
+ actionButton.visibility = View.GONE
+ islandView.findViewById(R.id.island_battery_text).visibility = View.VISIBLE
+ islandView.findViewById(R.id.island_battery_progress).visibility = View.VISIBLE
+ batteryBg.visibility = View.VISIBLE
+ }
+
val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
batteryIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -331,6 +351,13 @@ class IslandWindow(private val context: Context) {
IslandType.MOVED_TO_REMOTE -> {
islandView.findViewById(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
}
+ IslandType.MOVED_TO_OTHER_DEVICE -> {
+ if (reversed) {
+ islandView.findViewById(R.id.island_connected_text).text = getString(context, 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)
+ }
+ }
}
val videoView = islandView.findViewById(R.id.island_video_view)
@@ -604,6 +631,10 @@ class IslandWindow(private val context: Context) {
}
fun close() {
+ if (Looper.myLooper() != Looper.getMainLooper()) {
+ Handler(Looper.getMainLooper()).post { close() }
+ return
+ }
try {
if (isClosing) return
isClosing = true
@@ -647,7 +678,15 @@ class IslandWindow(private val context: Context) {
}
private fun cleanupAndRemoveView() {
- containerView.visibility = View.GONE
+ if (Looper.myLooper() != Looper.getMainLooper()) {
+ Handler(Looper.getMainLooper()).post { cleanupAndRemoveView() }
+ return
+ }
+ try {
+ containerView.visibility = View.GONE
+ } catch (e: Exception) {
+ e("IslandWindow", "Error setting visibility: $e")
+ }
try {
if (containerView.parent != null) {
windowManager.removeView(containerView)
@@ -662,6 +701,10 @@ class IslandWindow(private val context: Context) {
}
fun forceClose() {
+ if (Looper.myLooper() != Looper.getMainLooper()) {
+ Handler(Looper.getMainLooper()).post { forceClose() }
+ return
+ }
try {
if (isClosing) return
isClosing = true
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 c719349..aa55c15 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
@@ -26,6 +26,7 @@ import android.media.AudioPlaybackConfiguration
import android.os.Build
import android.os.Handler
import android.os.Looper
+import android.os.SystemClock
import android.util.Log
import android.view.KeyEvent
import androidx.annotation.RequiresApi
@@ -41,7 +42,20 @@ object MediaController {
private val handler = Handler(Looper.getMainLooper())
private lateinit var preferenceChangeListener: SharedPreferences.OnSharedPreferenceChangeListener
- var pausedForCrossDevice = false
+ var pausedWhileTakingOver = false
+ var pausedForOtherDevice = false
+
+ private var lastSelfActionAt: Long = 0L
+ private const val SELF_ACTION_IGNORE_MS = 800L
+ private const val PLAYBACK_DEBOUNCE_MS = 300L
+ private var lastPlaybackCallbackAt: Long = 0L
+ private var lastKnownIsMusicActive: Boolean? = null
+
+ private const val PAUSED_FOR_OTHER_DEVICE_CLEAR_MS = 500L
+ private val clearPausedForOtherDeviceRunnable = Runnable {
+ pausedForOtherDevice = false
+ Log.d("MediaController", "Cleared pausedForOtherDevice after timeout, resuming normal playback monitoring")
+ }
private var relativeVolume: Boolean = false
private var conversationalAwarenessVolume: Int = 2
@@ -81,17 +95,65 @@ object MediaController {
@RequiresApi(Build.VERSION_CODES.R)
override fun onPlaybackConfigChanged(configs: MutableList?) {
super.onPlaybackConfigChanged(configs)
- Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia")
+ val now = SystemClock.uptimeMillis()
+ val isActive = audioManager.isMusicActive
+ Log.d("MediaController", "Playback config changed, iPausedTheMedia: $iPausedTheMedia, isActive: $isActive, pausedForOtherDevice: $pausedForOtherDevice, lastKnownIsMusicActive: $lastKnownIsMusicActive")
+
+ if (now - lastPlaybackCallbackAt < PLAYBACK_DEBOUNCE_MS) {
+ Log.d("MediaController", "Ignoring playback callback due to debounce (${now - lastPlaybackCallbackAt}ms)")
+ lastPlaybackCallbackAt = now
+ return
+ }
+ lastPlaybackCallbackAt = now
+
+ if (now - lastSelfActionAt < SELF_ACTION_IGNORE_MS) {
+ Log.d("MediaController", "Ignoring playback callback because it's likely caused by our own action (${now - lastSelfActionAt}ms since last self-action)")
+ lastKnownIsMusicActive = isActive
+ return
+ }
+
+ 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")
+ pausedForOtherDevice = false
+ userPlayedTheMedia = true
+ if (!pausedWhileTakingOver) {
+ ServiceManager.getService()?.takeOver("music")
+ }
+ } else {
+ Log.d("MediaController", "Still not active while pausedForOtherDevice; will clear state after timeout")
+ }
+
+ lastKnownIsMusicActive = isActive
+ return
+ }
+
if (configs != null && !iPausedTheMedia) {
- Log.d("MediaController", "Seems like the user changed the state of media themselves, now I won't play until the ear detection pauses it.")
+ ServiceManager.getService()?.aacpManager?.sendMediaInformataion(
+ ServiceManager.getService()?.localMac ?: return,
+ isActive
+ )
+ Log.d("MediaController", "User changed media state themselves; will wait for ear detection pause before auto-play")
handler.postDelayed({
userPlayedTheMedia = audioManager.isMusicActive
- }, 7) // i have no idea why android sends an event a hundred times after the user does something.
+ if (audioManager.isMusicActive) {
+ pausedForOtherDevice = false
+ }
+ }, 7)
}
- Log.d("MediaController", "pausedforcrossdevice: $pausedForCrossDevice")
- if (!pausedForCrossDevice && audioManager.isMusicActive) {
- ServiceManager.getService()?.takeOver("music")
+
+ 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")
+ }
}
+
+ lastKnownIsMusicActive = isActive
}
}
@@ -126,6 +188,7 @@ object MediaController {
KeyEvent.KEYCODE_MEDIA_PREVIOUS
)
)
+ lastSelfActionAt = SystemClock.uptimeMillis()
}
@Synchronized
@@ -143,6 +206,7 @@ object MediaController {
KeyEvent.KEYCODE_MEDIA_NEXT
)
)
+ lastSelfActionAt = SystemClock.uptimeMillis()
}
@Synchronized
@@ -163,6 +227,7 @@ object MediaController {
KeyEvent.KEYCODE_MEDIA_PAUSE
)
)
+ lastSelfActionAt = SystemClock.uptimeMillis()
}
}
@@ -184,14 +249,15 @@ object MediaController {
KeyEvent.KEYCODE_MEDIA_PLAY
)
)
+ lastSelfActionAt = SystemClock.uptimeMillis()
}
if (!audioManager.isMusicActive) {
Log.d("MediaController", "Setting iPausedTheMedia to false")
iPausedTheMedia = false
}
- if (pausedForCrossDevice) {
- Log.d("MediaController", "Setting pausedForCrossDevice to false")
- pausedForCrossDevice = false
+ if (pausedWhileTakingOver) {
+ Log.d("MediaController", "Setting pausedWhileTakingOver to false")
+ pausedWhileTakingOver = false
}
}
@@ -245,4 +311,4 @@ object MediaController {
}
})
}
-}
+}
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable/ic_undo.xml b/android/app/src/main/res/drawable/ic_undo.xml
new file mode 100644
index 0000000..a3f745b
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_undo.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/android/app/src/main/res/drawable/ic_undo_button_bg.xml b/android/app/src/main/res/drawable/ic_undo_button_bg.xml
new file mode 100644
index 0000000..c238ba1
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_undo_button_bg.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/android/app/src/main/res/layout/island_window.xml b/android/app/src/main/res/layout/island_window.xml
index fd8b8ef..5cb1e14 100644
--- a/android/app/src/main/res/layout/island_window.xml
+++ b/android/app/src/main/res/layout/island_window.xml
@@ -12,7 +12,9 @@
android:orientation="horizontal"
android:outlineAmbientShadowColor="#4EFFFFFF"
android:outlineSpotShadowColor="#4EFFFFFF"
- android:padding="8dp">
+ android:padding="8dp"
+ android:clipToPadding="false"
+ android:clipChildren="false">
+ android:gravity="center"
+ android:clipChildren="false">
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 7f5fdb0..3c3413d 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -45,8 +45,10 @@
Control Noise Control Mode directly from your Home Screen.
Connected
Connected to Linux
- Moved to phone
+ Connected
Moved to Linux
+ Moved to other device
+ Reconnect from notification
Head Tracking
Nod to answer calls, and shake your head to decline.
General