From df9f443173fcc6719cb7e40619385f233dfbaeb0 Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Wed, 10 Sep 2025 10:03:52 +0530 Subject: [PATCH] android: add basic multidevice capabilities use at your own risk, may or may not work --- android/app/src/main/AndroidManifest.xml | 3 +- .../screens/AirPodsSettingsScreen.kt | 6 +- .../librepods/services/AirPodsService.kt | 863 +++++++++++------- .../librepods/utils/AACPManager.kt | 644 +++++++++++-- .../librepods/utils/IslandWindow.kt | 47 +- .../librepods/utils/MediaController.kt | 88 +- android/app/src/main/res/drawable/ic_undo.xml | 11 + .../main/res/drawable/ic_undo_button_bg.xml | 5 + .../app/src/main/res/layout/island_window.xml | 23 +- android/app/src/main/res/values/strings.xml | 4 +- 10 files changed, 1268 insertions(+), 426 deletions(-) create mode 100644 android/app/src/main/res/drawable/ic_undo.xml create mode 100644 android/app/src/main/res/drawable/ic_undo_button_bg.xml 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