android: add basic multidevice capabilities

use at your own risk, may or may not work
This commit is contained in:
Kavish Devar
2025-09-10 10:03:52 +05:30
parent d1bf5407c9
commit df9f443173
10 changed files with 1268 additions and 426 deletions

View File

@@ -30,9 +30,10 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.LOCAL_MAC_ADDRESS"
tools:ignore="ProtectedPermissions" />
<protected-broadcast android:name="batterywidget.impl.action.update_bluetooth_data" />

View File

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

View File

@@ -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<AACPManager.Companion.ConnectedDevice>) {
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 {

View File

@@ -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<ControlCommandStatus> = mutableListOf<ControlCommandStatus>()
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf()
var controlCommandStatusList: MutableList<ControlCommandStatus> =
mutableListOf<ControlCommandStatus>()
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> =
mutableMapOf()
var owns: Boolean = false
private set
var oldConnectedDevices: List<ConnectedDevice> = listOf()
private set
var connectedDevices: List<ConnectedDevice> = 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<ConnectedDevice>)
fun onOwnershipToFalseRequest(reasonReverseTapped: Boolean)
fun onShowNearbyUI()
}
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
@@ -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<ProximityKeyType, ByteArray> {
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)
}
}
fun parseAudioSourceResponse(data: ByteArray): Pair<String, AudioSourceType> {
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<ConnectedDevice> {
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<ConnectedDevice>()
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
}
}

View File

@@ -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<TextView>(R.id.island_device_name).text = name
val actionButton = islandView.findViewById<ImageButton>(R.id.island_action_button)
val batteryBg = islandView.findViewById<ProgressBar>(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<TextView>(R.id.island_battery_text).visibility = View.GONE
islandView.findViewById<ProgressBar>(R.id.island_battery_progress).visibility = View.GONE
batteryBg.visibility = View.GONE
} else {
actionButton.visibility = View.GONE
islandView.findViewById<TextView>(R.id.island_battery_text).visibility = View.VISIBLE
islandView.findViewById<ProgressBar>(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<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
}
IslandType.MOVED_TO_OTHER_DEVICE -> {
if (reversed) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_other_device_reversed_text)
} else {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_other_device_text)
}
}
}
val videoView = islandView.findViewById<VideoView>(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

View File

@@ -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<AudioPlaybackConfiguration>?) {
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 {
}
})
}
}
}

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M280,760L280,680L564,680Q627,680 673.5,640Q720,600 720,540Q720,480 673.5,440Q627,400 564,400L312,400L416,504L360,560L160,360L360,160L416,216L312,320L564,320Q661,320 730.5,383Q800,446 800,540Q800,634 730.5,697Q661,760 564,760L280,760Z"/>
</vector>

View File

@@ -0,0 +1,5 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size android:width="64dp" android:height="64dp" />
<solid android:color="#2F2F2F" />
</shape>

View File

@@ -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">
<VideoView
android:id="@+id/island_video_view"
@@ -66,9 +68,11 @@
android:id="@+id/island_battery_container"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center">
android:gravity="center"
android:clipChildren="false">
<ProgressBar
android:id="@+id/island_battery_bg"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="84dp"
android:layout_height="84dp"
@@ -101,5 +105,20 @@
android:textSize="16sp"
android:textStyle="bold"
tools:ignore="HardcodedText" />
<ImageButton
android:id="@+id/island_action_button"
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_gravity="center"
android:translationX="-12dp"
android:background="@drawable/ic_undo_button_bg"
android:contentDescription="Undo button"
android:scaleType="centerInside"
android:src="@drawable/ic_undo"
android:tint="@android:color/white"
android:elevation="8dp"
android:translationZ="8dp"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>

View File

@@ -45,8 +45,10 @@
<string name="noise_control_widget_description">Control Noise Control Mode directly from your Home Screen.</string>
<string name="island_connected_text">Connected</string>
<string name="island_connected_remote_text">Connected to Linux</string>
<string name="island_taking_over_text">Moved to phone</string>
<string name="island_taking_over_text">Connected</string>
<string name="island_moved_to_remote_text">Moved to Linux</string>
<string name="island_moved_to_other_device_text">Moved to other device</string>
<string name="island_moved_to_other_device_reversed_text">Reconnect from notification</string>
<string name="head_tracking">Head Tracking</string>
<string name="head_gestures_details">Nod to answer calls, and shake your head to decline.</string>
<string name="general_settings_header">General</string>