mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-02-02 16:19:10 +00:00
android: add basic multidevice capabilities
use at your own risk, may or may not work
This commit is contained in:
@@ -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" />
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
11
android/app/src/main/res/drawable/ic_undo.xml
Normal file
11
android/app/src/main/res/drawable/ic_undo.xml
Normal 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>
|
||||
5
android/app/src/main/res/drawable/ic_undo_button_bg.xml
Normal file
5
android/app/src/main/res/drawable/ic_undo_button_bg.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user