diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 791481b..b8a480b 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -23,6 +23,7 @@
android:usesPermissionFlags="neverForLocation"
tools:ignore="UnusedAttribute" />
+
.
*/
@@ -135,7 +135,8 @@ fun Main() {
permissions = listOf(
"android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_SCAN",
- "android.permission.POST_NOTIFICATIONS"
+ "android.permission.POST_NOTIFICATIONS",
+ "android.permission.READ_PHONE_STATE"
)
)
val airPodsService = remember { mutableStateOf(null) }
@@ -308,7 +309,6 @@ fun Main() {
isConnected.value = true
}
} else {
- // Permission is not granted, request it
Column (
modifier = Modifier.padding(24.dp),
){
@@ -325,4 +325,4 @@ fun Main() {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/aln/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/aln/services/AirPodsService.kt
index e0ab20a..4986f71 100644
--- a/android/app/src/main/java/me/kavishdevar/aln/services/AirPodsService.kt
+++ b/android/app/src/main/java/me/kavishdevar/aln/services/AirPodsService.kt
@@ -50,6 +50,8 @@ import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.ParcelUuid
+import android.telephony.PhoneStateListener
+import android.telephony.TelephonyManager
import android.util.Log
import android.util.TypedValue
import android.view.View
@@ -76,9 +78,10 @@ import me.kavishdevar.aln.utils.BatteryStatus
import me.kavishdevar.aln.utils.CrossDevice
import me.kavishdevar.aln.utils.CrossDevicePackets
import me.kavishdevar.aln.utils.Enums
+import me.kavishdevar.aln.utils.IslandWindow
import me.kavishdevar.aln.utils.LongPressPackets
import me.kavishdevar.aln.utils.MediaController
-import me.kavishdevar.aln.utils.Window
+import me.kavishdevar.aln.utils.PopupWindow
import me.kavishdevar.aln.widgets.BatteryWidget
import me.kavishdevar.aln.widgets.NoiseControlWidget
import org.lsposed.hiddenapibypass.HiddenApiBypass
@@ -114,7 +117,7 @@ object ServiceManager {
// @Suppress("unused")
class AirPodsService : Service() {
- private var macAddress = ""
+ var macAddress = ""
inner class LocalBinder : Binder() {
fun getService(): AirPodsService = this@AirPodsService
@@ -126,6 +129,9 @@ class AirPodsService : Service() {
private val _packetLogsFlow = MutableStateFlow>(emptySet())
val packetLogsFlow: StateFlow> get() = _packetLogsFlow
+ private lateinit var telephonyManager: TelephonyManager
+ private lateinit var phoneStateListener: PhoneStateListener
+
override fun onCreate() {
super.onCreate()
sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE)
@@ -166,10 +172,25 @@ class AirPodsService : Service() {
if (popupShown) {
return
}
- val window = Window(service.applicationContext)
- window.open(name, batteryNotification)
+ val popupWindow = PopupWindow(service.applicationContext)
+ popupWindow.open(name, batteryNotification)
popupShown = true
}
+ var islandOpen = false
+ var islandWindow: IslandWindow? = null
+ @SuppressLint("MissingPermission")
+ fun showIsland(service: Service, batteryPercentage: Int, takingOver: Boolean = false) {
+ Log.d("AirPodsService", "Showing island window")
+ islandWindow = IslandWindow(service.applicationContext)
+ islandWindow!!.show(sharedPreferences.getString("name", "AirPods Pro").toString(), batteryPercentage, this, takingOver)
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ fun startMainActivity() {
+ val intent = Intent(this, MainActivity::class.java)
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ startActivity(intent)
+ }
@Suppress("ClassName")
private object bluetoothReceiver : BroadcastReceiver() {
@@ -220,23 +241,7 @@ class AirPodsService : Service() {
object BatteryChangedIntentReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent) {
if (intent.action == Intent.ACTION_BATTERY_CHANGED) {
- val level = intent.getIntExtra("level", 0)
- val scale = intent.getIntExtra("scale", 100)
- val batteryPct = level * 100 / scale
- val charging = intent.getIntExtra(
- BatteryManager.EXTRA_STATUS,
- -1
- ) == BatteryManager.BATTERY_STATUS_CHARGING
- if (ServiceManager.getService()?.widgetMobileBatteryEnabled == true) {
- val appWidgetManager = AppWidgetManager.getInstance(context)
- val componentName = ComponentName(context!!, BatteryWidget::class.java)
- val widgetIds = appWidgetManager.getAppWidgetIds(componentName)
- val remoteViews = RemoteViews(context.packageName, R.layout.battery_widget)
- remoteViews.setTextViewText(R.id.phone_battery_widget, "$batteryPct%")
- remoteViews.setProgressBar(R.id.phone_battery_progress, 100, batteryPct, false)
-
- appWidgetManager.updateAppWidget(widgetIds, remoteViews)
- }
+ ServiceManager.getService()?.updateBatteryWidget()
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context?.unregisterReceiver(this)
@@ -568,13 +573,55 @@ class AirPodsService : Service() {
Log.d("AirPodsService", "Service started")
ServiceManager.setService(this)
startForegroundNotification()
-
+ val audioManager =
+ this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
+ MediaController.initialize(
+ audioManager,
+ this@AirPodsService.getSharedPreferences(
+ "settings",
+ MODE_PRIVATE
+ )
+ )
Log.d("AirPodsService", "Initializing CrossDevice")
- CrossDevice.init(this)
- Log.d("AirPodsService", "CrossDevice initialized")
+ 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 -> {
+ if (CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) takeOver()
+ }
+ TelephonyManager.CALL_STATE_OFFHOOK -> {
+ if (CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) takeOver()
+ }
+ }
+ }
+ }
+ telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
+
+ if (sharedPreferences.getBoolean("show_phone_battery_in_widget", true)) {
+ 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")
@@ -605,13 +652,16 @@ class AirPodsService : Service() {
putString("name", name)
}
}
- Log.d("AirPodsQuickSwitchServices", CrossDevice.isAvailable.toString())
- if (!CrossDevice.checkAirPodsConnectionStatus()) {
+ Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
+ if (!CrossDevice.isAvailable) {
Log.d("AirPodsService", "$name connected")
showPopup(this@AirPodsService, name.toString())
connectToSocket(device!!)
isConnectedLocally = true
macAddress = device!!.address
+ sharedPreferences.edit {
+ putString("mac_address", macAddress)
+ }
updateNotificationContent(
true,
name.toString(),
@@ -626,7 +676,30 @@ class AirPodsService : Service() {
}
}
}
+ val showIslandReceiver = object: BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == "me.kavishdevar.aln.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.Companion.DISCONNECT_RECEIVERS) {
+ try {
+ context?.unregisterReceiver(this)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ }
+ }
+ val showIslandIntentFilter = IntentFilter().apply {
+ addAction("me.kavishdevar.aln.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.Companion.AIRPODS_CONNECTION_DETECTED)
@@ -641,11 +714,6 @@ class AirPodsService : Service() {
registerReceiver(bluetoothReceiver, serviceIntentFilter)
}
- widgetMobileBatteryEnabled = getSharedPreferences("settings", MODE_PRIVATE).getBoolean(
- "show_phone_battery_in_widget",
- true
- )
-
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
if (bluetoothAdapter.isEnabled) {
CoroutineScope(Dispatchers.IO).launch {
@@ -682,8 +750,12 @@ class AirPodsService : Service() {
if (profile == BluetoothProfile.A2DP) {
val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
- if (!CrossDevice.checkAirPodsConnectionStatus()) {
+ if (!CrossDevice.isAvailable) {
connectToSocket(device)
+ macAddress = device.address
+ sharedPreferences.edit {
+ putString("mac_address", macAddress)
+ }
}
this@AirPodsService.sendBroadcast(
Intent(AirPodsNotifications.Companion.AIRPODS_CONNECTED)
@@ -720,12 +792,27 @@ class AirPodsService : Service() {
}
}
+ @SuppressLint("MissingPermission")
+ fun takeOver() {
+ Log.d("AirPodsService", "Taking over audio")
+ CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet)
+ Log.d("AirPodsService", macAddress)
+ device = getSystemService(BluetoothManager::class.java).adapter.bondedDevices.find {
+ it.address == macAddress
+ }
+ if (device != null) {
+ connectToSocket(device!!)
+ connectAudio(this, device)
+ }
+ showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!), true)
+ }
+
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
fun connectToSocket(device: BluetoothDevice) {
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
- if (isConnectedLocally != true) {
+ if (isConnectedLocally != true && !CrossDevice.isAvailable) {
try {
socket = HiddenApiBypass.newInstance(
BluetoothSocket::class.java,
@@ -799,15 +886,6 @@ class AirPodsService : Service() {
)
while (socket.isConnected == true) {
socket.let {
- val audioManager =
- this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
- MediaController.initialize(
- audioManager,
- this@AirPodsService.getSharedPreferences(
- "settings",
- MODE_PRIVATE
- )
- )
val buffer = ByteArray(1024)
val bytesRead = it.inputStream.read(buffer)
var data: ByteArray = byteArrayOf()
@@ -852,11 +930,16 @@ class AirPodsService : Service() {
} else {
data[0] == 0x00.toByte() && data[1] == 0x00.toByte()
}
-
val newInEarData = listOf(
data[0] == 0x00.toByte(),
data[1] == 0x00.toByte()
)
+ if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf(false, false) && islandWindow?.isVisible != true) {
+ showIsland(this@AirPodsService, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!))
+ }
+ if (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) {
+ islandWindow?.close()
+ }
if (newInEarData.contains(true) && inEarData == listOf(
false,
false
@@ -1296,6 +1379,9 @@ class AirPodsService : Service() {
e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
+ if (MediaController.pausedForCrossDevice) {
+ MediaController.sendPlay()
+ }
}
}
}
@@ -1521,6 +1607,7 @@ class AirPodsService : Service() {
} catch (e: Exception) {
e.printStackTrace()
}
+ telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
super.onDestroy()
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/CrossDevice.kt b/android/app/src/main/java/me/kavishdevar/aln/utils/CrossDevice.kt
index 9815070..17efaae 100644
--- a/android/app/src/main/java/me/kavishdevar/aln/utils/CrossDevice.kt
+++ b/android/app/src/main/java/me/kavishdevar/aln/utils/CrossDevice.kt
@@ -1,3 +1,22 @@
+/*
+ * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
+ *
+ * Copyright (C) 2024 Kavish Devar
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+
package me.kavishdevar.aln.utils
import android.annotation.SuppressLint
@@ -10,6 +29,7 @@ import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.BluetoothLeAdvertiser
import android.content.Context
+import android.content.Intent
import android.content.SharedPreferences
import android.os.ParcelUuid
import android.util.Log
@@ -44,25 +64,28 @@ object CrossDevice {
var batteryBytes: ByteArray = byteArrayOf()
var ancBytes: ByteArray = byteArrayOf()
private lateinit var sharedPreferences: SharedPreferences
- private const val packetLogKey = "packet_log"
+ private const val PACKET_LOG_KEY = "packet_log"
+ private var earDetectionStatus = listOf(false, false)
@SuppressLint("MissingPermission")
fun init(context: Context) {
- Log.d("AirPodsQuickSwitchService", "Initializing CrossDevice")
- sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE)
- sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
- this.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
- this.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
- startAdvertising()
- startServer()
- initialized = true
+ CoroutineScope(Dispatchers.IO).launch {
+ Log.d("CrossDevice", "Initializing CrossDevice")
+ sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE)
+ sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
+ this@CrossDevice.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
+ this@CrossDevice.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
+ startAdvertising()
+ startServer()
+ initialized = true
+ }
}
@SuppressLint("MissingPermission")
- fun startServer() {
- serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
- Log.d("AirPodsQuickSwitchService", "Server started")
+ private fun startServer() {
CoroutineScope(Dispatchers.IO).launch {
+ serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
+ Log.d("CrossDevice", "Server started")
while (serverSocket != null) {
try {
val socket = serverSocket!!.accept()
@@ -76,29 +99,31 @@ object CrossDevice {
@SuppressLint("MissingPermission")
private fun startAdvertising() {
- val settings = AdvertiseSettings.Builder()
- .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
- .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
- .setConnectable(true)
- .build()
+ CoroutineScope(Dispatchers.IO).launch {
+ val settings = AdvertiseSettings.Builder()
+ .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
+ .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
+ .setConnectable(true)
+ .build()
- val data = AdvertiseData.Builder()
- .setIncludeDeviceName(true)
- .addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
- .addServiceUuid(ParcelUuid(uuid))
- .build()
+ val data = AdvertiseData.Builder()
+ .setIncludeDeviceName(true)
+ .addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
+ .addServiceUuid(ParcelUuid(uuid))
+ .build()
- bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
- Log.d("AirPodsQuickSwitchService", "BLE Advertising started")
+ bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
+ Log.d("CrossDevice", "BLE Advertising started")
+ }
}
private val advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
- Log.d("AirPodsQuickSwitchService", "BLE Advertising started successfully")
+ Log.d("CrossDevice", "BLE Advertising started successfully")
}
override fun onStartFailure(errorCode: Int) {
- Log.e("AirPodsQuickSwitchService", "BLE Advertising failed with error code: $errorCode")
+ Log.e("CrossDevice", "BLE Advertising failed with error code: $errorCode")
}
}
@@ -113,9 +138,9 @@ object CrossDevice {
}
fun sendReceivedPacket(packet: ByteArray) {
- Log.d("AirPodsQuickSwitchService", "Sending packet to remote device")
- if (clientSocket == null) {
- Log.d("AirPodsQuickSwitchService", "Client socket is null")
+ Log.d("CrossDevice", "Sending packet to remote device")
+ if (clientSocket == null || clientSocket!!.outputStream != null) {
+ Log.d("CrossDevice", "Client socket is null")
return
}
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
@@ -124,14 +149,14 @@ object CrossDevice {
private fun logPacket(packet: ByteArray, source: String) {
val packetHex = packet.joinToString(" ") { "%02X".format(it) }
val logEntry = "$source: $packetHex"
- val logs = sharedPreferences.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet() ?: mutableSetOf()
+ val logs = sharedPreferences.getStringSet(PACKET_LOG_KEY, mutableSetOf())?.toMutableSet() ?: mutableSetOf()
logs.add(logEntry)
- sharedPreferences.edit().putStringSet(packetLogKey, logs).apply()
+ sharedPreferences.edit().putStringSet(PACKET_LOG_KEY, logs).apply()
}
@SuppressLint("MissingPermission")
private fun handleClientConnection(socket: BluetoothSocket) {
- Log.d("AirPodsQuickSwitchService", "Client connected")
+ Log.d("CrossDevice", "Client connected")
clientSocket = socket
val inputStream = socket.inputStream
val buffer = ByteArray(1024)
@@ -141,7 +166,7 @@ object CrossDevice {
bytes = inputStream.read(buffer)
val packet = buffer.copyOf(bytes)
logPacket(packet, "Relay")
- Log.d("AirPodsQuickSwitchService", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
+ Log.d("CrossDevice", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
if (bytes == -1) {
break
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet)) {
@@ -153,36 +178,49 @@ object CrossDevice {
isAvailable = false
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", false).apply()
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_BATTERY_BYTES.packet)) {
- Log.d("AirPodsQuickSwitchService", "Received battery request, battery data: ${batteryBytes.joinToString("") { "%02x".format(it) }}")
+ Log.d("CrossDevice", "Received battery request, battery data: ${batteryBytes.joinToString("") { "%02x".format(it) }}")
sendRemotePacket(batteryBytes)
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) {
- Log.d("AirPodsQuickSwitchService", "Received ANC request")
+ Log.d("CrossDevice", "Received ANC request")
sendRemotePacket(ancBytes)
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)) {
- Log.d("AirPodsQuickSwitchService", "Received connection status request")
+ Log.d("CrossDevice", "Received connection status request")
sendRemotePacket(if (ServiceManager.getService()?.isConnectedLocally == true) CrossDevicePackets.AIRPODS_CONNECTED.packet else CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
- }
- else {
+ } else {
if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
isAvailable = true
sharedPreferences.edit().putBoolean("CrossDeviceIsAvailable", true).apply()
- val trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
- Log.d("AirPodsQuickSwitchService", "Received relayed packet, with ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | ${ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket)}")
- Log.d("AirPodsQuickSwitchService", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
+ var trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
+ Log.d("CrossDevice", "Received relayed packet, with ${sharedPreferences.getBoolean("CrossDeviceIsAvailable", false)} | ${ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket)}")
+ Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
if (ServiceManager.getService()?.isConnectedLocally == true) {
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
ServiceManager.getService()?.sendPacket(packetInHex)
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
batteryBytes = trimmedPacket
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
- Log.d("AirPodsQuickSwitchService", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}")
+ Log.d("CrossDevice", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}")
ServiceManager.getService()?.updateBatteryWidget()
ServiceManager.getService()?.sendBatteryBroadcast()
ServiceManager.getService()?.sendBatteryNotification()
} else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {
ServiceManager.getService()?.ancNotification?.setStatus(trimmedPacket)
ServiceManager.getService()?.sendANCBroadcast()
+ ServiceManager.getService()?.updateNoiseControlWidget()
ancBytes = trimmedPacket
+ } else if (ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket) == true) {
+ Log.d("CrossDevice", "Ear detection data: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
+ ServiceManager.getService()?.earDetectionNotification?.setStatus(trimmedPacket)
+ val newEarDetectionStatus = listOf(
+ ServiceManager.getService()?.earDetectionNotification?.status?.get(0) == 0x00.toByte(),
+ ServiceManager.getService()?.earDetectionNotification?.status?.get(1) == 0x00.toByte()
+ )
+ if (earDetectionStatus == listOf(false, false) && newEarDetectionStatus.contains(true)) {
+ ServiceManager.getService()?.applicationContext?.sendBroadcast(
+ Intent("me.kavishdevar.aln.cross_device_island")
+ )
+ }
+ earDetectionStatus = newEarDetectionStatus
}
}
}
@@ -190,31 +228,13 @@ object CrossDevice {
}
fun sendRemotePacket(byteArray: ByteArray) {
- if (clientSocket == null) {
- Log.d("AirPodsQuickSwitchService", "Client socket is null")
+ if (clientSocket == null || clientSocket!!.outputStream == null) {
+ Log.d("CrossDevice", "Client socket is null")
return
}
clientSocket?.outputStream?.write(byteArray)
clientSocket?.outputStream?.flush()
logPacket(byteArray, "Sent")
- Log.d("AirPodsQuickSwitchService", "Sent packet to remote device")
+ Log.d("CrossDevice", "Sent packet to remote device")
}
-
- fun checkAirPodsConnectionStatus(): Boolean {
- Log.d("AirPodsQuickSwitchService", "Checking AirPods connection status")
- if (clientSocket == null) {
- Log.d("AirPodsQuickSwitchService", "Client socket is null - linux probably not connected.")
- return false
- }
- return try {
- clientSocket?.outputStream?.write(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)
- val buffer = ByteArray(1024)
- val bytes = clientSocket?.inputStream?.read(buffer) ?: -1
- val packet = buffer.copyOf(bytes)
- packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)
- } catch (e: IOException) {
- Log.e("AirPodsQuickSwitchService", "Error checking connection status", e)
- false
- }
- }
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/aln/utils/IslandWindow.kt
new file mode 100644
index 0000000..d3ae6d4
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/aln/utils/IslandWindow.kt
@@ -0,0 +1,148 @@
+/*
+ * AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
+ *
+ * Copyright (C) 2024 Kavish Devar
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package me.kavishdevar.aln.utils
+
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
+import android.animation.ObjectAnimator
+import android.animation.PropertyValuesHolder
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.res.Resources
+import android.graphics.PixelFormat
+import android.net.Uri
+import android.os.Handler
+import android.os.Looper
+import android.util.Log.e
+import android.view.Gravity
+import android.view.LayoutInflater
+import android.view.View
+import android.view.WindowManager
+import android.view.animation.AnticipateOvershootInterpolator
+import android.widget.ProgressBar
+import android.widget.TextView
+import android.widget.VideoView
+import androidx.core.content.ContextCompat.getString
+import me.kavishdevar.aln.R
+import me.kavishdevar.aln.services.ServiceManager
+
+class IslandWindow(context: Context) {
+ private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
+ @SuppressLint("InflateParams")
+ private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
+ private var isClosing = false
+
+ val isVisible: Boolean
+ get() = islandView.parent != null && islandView.visibility == View.VISIBLE
+
+ @SuppressLint("SetTextI18n")
+ fun show(name: String, batteryPercentage: Int, context: Context, takingOver: Boolean) {
+ if (ServiceManager.getService()?.islandOpen == true) return
+ else ServiceManager.getService()?.islandOpen = true
+
+ val displayMetrics = Resources.getSystem().displayMetrics
+ val width = (displayMetrics.widthPixels * 0.95).toInt()
+
+ val params = WindowManager.LayoutParams(
+ width,
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
+ PixelFormat.TRANSLUCENT
+ ).apply {
+ gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
+ }
+
+ islandView.visibility = View.VISIBLE
+ islandView.findViewById(R.id.island_battery_text).text = "$batteryPercentage%"
+ islandView.findViewById(R.id.island_device_name).text = name
+
+ islandView.setOnClickListener {
+ ServiceManager.getService()?.startMainActivity()
+ close()
+ }
+
+ if (takingOver) {
+ islandView.findViewById(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text)
+ } else if (CrossDevice.isAvailable) {
+ islandView.findViewById(R.id.island_connected_text).text = getString(context, R.string.island_connected_remote_text)
+ } else {
+ islandView.findViewById(R.id.island_connected_text).text = getString(context, R.string.island_connected_text)
+ }
+
+ val batteryProgressBar = islandView.findViewById(R.id.island_battery_progress)
+ batteryProgressBar.progress = batteryPercentage
+ batteryProgressBar.isIndeterminate = false
+
+ val videoView = islandView.findViewById(R.id.island_video_view)
+ val videoUri = Uri.parse("android.resource://me.kavishdevar.aln/${R.raw.island}")
+ videoView.setVideoURI(videoUri)
+ videoView.setOnPreparedListener { mediaPlayer ->
+ mediaPlayer.isLooping = true
+ videoView.start()
+ }
+
+ windowManager.addView(islandView, params)
+
+ val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
+ val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
+ val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
+ ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
+ duration = 700
+ interpolator = AnticipateOvershootInterpolator()
+ start()
+ }
+ Handler(Looper.getMainLooper()).postDelayed({
+ close()
+ }, 4500)
+ }
+
+ fun close() {
+ try {
+ if (isClosing) return
+ isClosing = true
+
+ ServiceManager.getService()?.islandOpen = false
+
+ val videoView = islandView.findViewById(R.id.island_video_view)
+ videoView.stopPlayback()
+ val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 0.5f)
+ val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 0.5f)
+ val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f, -200f)
+ ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
+ duration = 700
+ interpolator = AnticipateOvershootInterpolator()
+ addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ islandView.visibility = View.GONE
+ try {
+ windowManager.removeView(islandView)
+ } catch (e: Exception) {
+ e("IslandWindow", "Error removing view: $e")
+ }
+ isClosing = false
+ }
+ })
+ start()
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/MediaController.kt b/android/app/src/main/java/me/kavishdevar/aln/utils/MediaController.kt
index cfb44ff..24f08f5 100644
--- a/android/app/src/main/java/me/kavishdevar/aln/utils/MediaController.kt
+++ b/android/app/src/main/java/me/kavishdevar/aln/utils/MediaController.kt
@@ -1,17 +1,17 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
- *
+ *
* Copyright (C) 2024 Kavish Devar
- *
+ *
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
- *
+ *
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
- *
+ *
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
@@ -25,6 +25,7 @@ import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.KeyEvent
+import me.kavishdevar.aln.services.ServiceManager
object MediaController {
private var initialVolume: Int? = null
@@ -34,14 +35,19 @@ object MediaController {
private lateinit var sharedPreferences: SharedPreferences
private val handler = Handler(Looper.getMainLooper())
+ var pausedForCrossDevice = false
+
private var relativeVolume: Boolean = false
private var conversationalAwarenessVolume: Int = 1/12
private var conversationalAwarenessPauseMusic: Boolean = false
fun initialize(audioManager: AudioManager, sharedPreferences: SharedPreferences) {
+ if (this::audioManager.isInitialized) {
+ return
+ }
this.audioManager = audioManager
this.sharedPreferences = sharedPreferences
-
+ Log.d("MediaController", "Initializing MediaController")
relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false)
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 100/12)
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false)
@@ -74,6 +80,14 @@ object MediaController {
userPlayedTheMedia = audioManager.isMusicActive
}, 7) // i have no idea why android sends an event a hundred times after the user does something.
}
+ Log.d("MediaController", "Ear detection status: ${ServiceManager.getService()?.earDetectionNotification?.status}, music active: ${audioManager.isMusicActive} and cross device available: ${CrossDevice.isAvailable}")
+ if (!pausedForCrossDevice && CrossDevice.isAvailable && ServiceManager.getService()?.earDetectionNotification?.status?.contains(0x00) == true && audioManager.isMusicActive) {
+ if (ServiceManager.getService()?.isConnectedLocally == false) {
+ sendPause(true)
+ pausedForCrossDevice = true
+ }
+ ServiceManager.getService()?.takeOver()
+ }
}
}
@@ -161,4 +175,4 @@ object MediaController {
}
})
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/aln/utils/Window.kt b/android/app/src/main/java/me/kavishdevar/aln/utils/PopupWindow.kt
similarity index 82%
rename from android/app/src/main/java/me/kavishdevar/aln/utils/Window.kt
rename to android/app/src/main/java/me/kavishdevar/aln/utils/PopupWindow.kt
index 87bd24c..714625e 100644
--- a/android/app/src/main/java/me/kavishdevar/aln/utils/Window.kt
+++ b/android/app/src/main/java/me/kavishdevar/aln/utils/PopupWindow.kt
@@ -1,17 +1,17 @@
/*
* AirPods like Normal (ALN) - Bringing Apple-only features to Linux and Android for seamless AirPods functionality!
- *
+ *
* Copyright (C) 2024 Kavish Devar
- *
+ *
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
- *
+ *
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
- *
+ *
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
@@ -44,7 +44,7 @@ import kotlinx.coroutines.launch
import me.kavishdevar.aln.R
@SuppressLint("InflateParams", "ClickableViewAccessibility")
-class Window (context: Context) {
+class PopupWindow(context: Context) {
private val mView: View
@Suppress("DEPRECATION")
@@ -56,13 +56,12 @@ class Window (context: Context) {
gravity = Gravity.BOTTOM
dimAmount = 0.3f
flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
- WindowManager.LayoutParams.FLAG_FULLSCREEN or
- WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
- WindowManager.LayoutParams.FLAG_DIM_BEHIND or
- WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
+ WindowManager.LayoutParams.FLAG_FULLSCREEN or
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
+ WindowManager.LayoutParams.FLAG_DIM_BEHIND or
+ WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
}
-
private val mWindowManager: WindowManager
init {
@@ -72,14 +71,13 @@ class Window (context: Context) {
mParams.y = 0
mParams.gravity = Gravity.BOTTOM
- mView.setOnClickListener(View.OnClickListener {
+ mView.setOnClickListener {
close()
- })
+ }
- mView.findViewById(R.id.close_button)
- .setOnClickListener {
- close()
- }
+ mView.findViewById(R.id.close_button).setOnClickListener {
+ close()
+ }
val ll = mView.findViewById(R.id.linear_layout)
ll.setOnClickListener {
@@ -88,11 +86,11 @@ class Window (context: Context) {
@Suppress("DEPRECATION")
mView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
- View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
- View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
- View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
- View.SYSTEM_UI_FLAG_FULLSCREEN or
- View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
+ View.SYSTEM_UI_FLAG_FULLSCREEN or
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
mView.setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_DOWN) {
@@ -116,7 +114,6 @@ class Window (context: Context) {
try {
if (mView.windowToken == null) {
if (mView.parent == null) {
- // Add the view initially off-screen
mWindowManager.addView(mView, mParams)
mView.findViewById(R.id.name).text = name
val vid = mView.findViewById(R.id.video)
@@ -143,14 +140,13 @@ class Window (context: Context) {
"\uDBC3\uDE6C ${it.level}%"
} ?: ""
- // Slide-up animation
val displayMetrics = mView.context.resources.displayMetrics
val screenHeight = displayMetrics.heightPixels
- mView.translationY = screenHeight.toFloat() // Start below the screen
+ mView.translationY = screenHeight.toFloat()
ObjectAnimator.ofFloat(mView, "translationY", 0f).apply {
- duration = 500 // Animation duration in milliseconds
- interpolator = DecelerateInterpolator() // Smooth deceleration
+ duration = 500
+ interpolator = DecelerateInterpolator()
start()
}
@@ -168,8 +164,8 @@ class Window (context: Context) {
fun close() {
try {
ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
- duration = 500 // Animation duration in milliseconds
- interpolator = AccelerateInterpolator() // Smooth acceleration
+ duration = 500
+ interpolator = AccelerateInterpolator()
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
try {
@@ -185,4 +181,4 @@ class Window (context: Context) {
Log.d("PopupService", e.toString())
}
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/res/drawable/island_background.xml b/android/app/src/main/res/drawable/island_background.xml
new file mode 100644
index 0000000..e10700b
--- /dev/null
+++ b/android/app/src/main/res/drawable/island_background.xml
@@ -0,0 +1,9 @@
+
+ -
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/island_battery_background.xml b/android/app/src/main/res/drawable/island_battery_background.xml
new file mode 100644
index 0000000..941c1ea
--- /dev/null
+++ b/android/app/src/main/res/drawable/island_battery_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/island_battery_progress.xml b/android/app/src/main/res/drawable/island_battery_progress.xml
new file mode 100644
index 0000000..2025f90
--- /dev/null
+++ b/android/app/src/main/res/drawable/island_battery_progress.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/island_window.xml b/android/app/src/main/res/layout/island_window.xml
new file mode 100644
index 0000000..6341dea
--- /dev/null
+++ b/android/app/src/main/res/layout/island_window.xml
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/raw/island.mp4 b/android/app/src/main/res/raw/island.mp4
new file mode 100644
index 0000000..398e11b
Binary files /dev/null and b/android/app/src/main/res/raw/island.mp4 differ
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index e524187..e4e0a9e 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1,45 +1,48 @@
- ALN
- GATT Testing
- See your AirPods battery status right from your home screen!
- Accessibility
- Tone Volume
- Audio
- Adaptive Audio
- Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.
- Buds
- Case
- Test
- Name
- Noise Control
- Off
- Transparency
- Adaptive
- Noise Cancellation
- Press and Hold AirPods
- Left
- Right
- Adjusts the volume of media in response to your environment
- Conversational Awareness
- Lowers media volume and reduces background noise when you start speaking to other people.
- Personalized Volume
- Adjusts the volume of media in response to your environment.
- Less Noise
- More Noise
- Noise Cancellation with Single AirPod
- Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.
- Volume Control
- Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.
- AirPods not connected
- Please connect your AirPods to access settings. If you\'re stuck here, then try reopening the app again after closing it from the recents.\n(DO NOT KILL THE APP!)
- Back
- App Settings
- Conversational Awareness
- Relative volume
- Reduces to a percentage of the current volume instead of the maximum volume.
- Pause Music
- When you start speaking, music will be paused.
- EXAMPLE
- Add widget
- Control Noise Control Mode directly from your Home Screen.
+ ALN
+ GATT Testing
+ See your AirPods battery status right from your home screen!
+ Accessibility
+ Tone Volume
+ Audio
+ Adaptive Audio
+ Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.
+ Buds
+ Case
+ Test
+ Name
+ Noise Control
+ Off
+ Transparency
+ Adaptive
+ Noise Cancellation
+ Press and Hold AirPods
+ Left
+ Right
+ Adjusts the volume of media in response to your environment
+ Conversational Awareness
+ Lowers media volume and reduces background noise when you start speaking to other people.
+ Personalized Volume
+ Adjusts the volume of media in response to your environment.
+ Less Noise
+ More Noise
+ Noise Cancellation with Single AirPod
+ Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.
+ Volume Control
+ Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.
+ AirPods not connected
+ Please connect your AirPods to access settings. If you\'re stuck here, then try reopening the app again after closing it from the recents.\n(DO NOT KILL THE APP!)
+ Back
+ App Settings
+ Conversational Awareness
+ Relative volume
+ Reduces to a percentage of the current volume instead of the maximum volume.
+ Pause Music
+ When you start speaking, music will be paused.
+ EXAMPLE
+ Add widget
+ Control Noise Control Mode directly from your Home Screen.
+ Connected
+ Connected to Linux
+ Moved to phone
diff --git a/linux/AirPodsTrayApp.h b/linux/AirPodsTrayApp.h
deleted file mode 100644
index 92afff8..0000000
--- a/linux/AirPodsTrayApp.h
+++ /dev/null
@@ -1,53 +0,0 @@
-#pragma once
-
-#include
-#include
-#include
-#include
-#include
-#include
-#include "BluetoothHandler.h"
-
-class AirPodsTrayApp : public QObject {
- Q_OBJECT
-
-public:
- AirPodsTrayApp();
-
-public slots:
- void connectToDevice(const QString &address);
- void showAvailableDevices();
- void setNoiseControlMode(int mode);
- void setConversationalAwareness(bool enabled);
- void updateNoiseControlMenu(int mode);
- void updateBatteryTooltip(const QString &status);
- void updateTrayIcon(const QString &status);
- void handleEarDetection(const QString &status);
-
-private slots:
- void onTrayIconActivated(QSystemTrayIcon::ActivationReason reason);
- void onDeviceDiscovered(const QBluetoothDeviceInfo &device);
- void onDiscoveryFinished();
- void onDeviceConnected(const QBluetoothAddress &address);
- void onDeviceDisconnected(const QBluetoothAddress &address);
- void onPhoneDataReceived();
-
-signals:
- void noiseControlModeChanged(int mode);
- void earDetectionStatusChanged(const QString &status);
- void batteryStatusChanged(const QString &status);
-
-private:
- void initializeMprisInterface();
- void connectToPhone();
- void relayPacketToPhone(const QByteArray &packet);
- void handlePhonePacket(const QByteArray &packet);
-
- QSystemTrayIcon *trayIcon;
- QMenu *trayMenu;
- QBluetoothDeviceDiscoveryAgent *discoveryAgent;
- QBluetoothSocket *socket = nullptr;
- QBluetoothSocket *phoneSocket = nullptr;
- QDBusInterface *mprisInterface;
- QString connectedDeviceMacAddress;
-};
diff --git a/linux/BluetoothHandler.cpp b/linux/BluetoothHandler.cpp
deleted file mode 100644
index 75ad478..0000000
--- a/linux/BluetoothHandler.cpp
+++ /dev/null
@@ -1,109 +0,0 @@
-#include "BluetoothHandler.h"
-#include "PacketDefinitions.h"
-#include
-
-Q_LOGGING_CATEGORY(bluetoothHandler, "bluetoothHandler")
-
-#define LOG_INFO(msg) qCInfo(bluetoothHandler) << "\033[32m" << msg << "\033[0m"
-#define LOG_WARN(msg) qCWarning(bluetoothHandler) << "\033[33m" << msg << "\033[0m"
-#define LOG_ERROR(msg) qCCritical(bluetoothHandler) << "\033[31m" << msg << "\033[0m"
-#define LOG_DEBUG(msg) qCDebug(bluetoothHandler) << "\033[34m" << msg << "\033[0m"
-
-BluetoothHandler::BluetoothHandler() {
- discoveryAgent = new QBluetoothDeviceDiscoveryAgent();
- discoveryAgent->setLowEnergyDiscoveryTimeout(5000);
-
- connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &BluetoothHandler::onDeviceDiscovered);
- connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &BluetoothHandler::onDiscoveryFinished);
- discoveryAgent->start();
- LOG_INFO("BluetoothHandler initialized and started device discovery");
-}
-
-void BluetoothHandler::connectToDevice(const QBluetoothDeviceInfo &device) {
- if (socket && socket->isOpen() && socket->peerAddress() == device.address()) {
- LOG_INFO("Already connected to the device: " << device.name());
- return;
- }
-
- LOG_INFO("Connecting to device: " << device.name());
- QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol);
- connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() {
- LOG_INFO("Connected to device, sending initial packets");
- discoveryAgent->stop();
-
- QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000");
- QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000");
- QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff");
-
- qint64 bytesWritten = localSocket->write(handshakePacket);
- LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten);
-
- QByteArray airpodsConnectedPacket = QByteArray::fromHex("000400010001");
- phoneSocket->write(airpodsConnectedPacket);
- LOG_DEBUG("AIRPODS_CONNECTED packet written: " << airpodsConnectedPacket.toHex());
-
- connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) {
- LOG_INFO("Bytes written: " << bytes);
- if (bytes > 0) {
- static int step = 0;
- switch (step) {
- case 0:
- localSocket->write(setSpecificFeaturesPacket);
- LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
- step++;
- break;
- case 1:
- localSocket->write(requestNotificationsPacket);
- LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex());
- step++;
- break;
- }
- }
- });
-
- connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() {
- QByteArray data = localSocket->readAll();
- LOG_DEBUG("Data received: " << data.toHex());
- parseData(data);
- relayPacketToPhone(data);
- });
- });
-
- connect(localSocket, QOverload::of(&QBluetoothSocket::errorOccurred), this, [this, localSocket](QBluetoothSocket::SocketError error) {
- LOG_ERROR("Socket error: " << error << ", " << localSocket->errorString());
- });
-
- localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
- socket = localSocket;
- connectedDeviceMacAddress = device.address().toString().replace(":", "_");
-}
-
-void BluetoothHandler::parseData(const QByteArray &data) {
- LOG_DEBUG("Parsing data: " << data.toHex() << "Size: " << data.size());
- if (data.size() == 11 && data.startsWith(QByteArray::fromHex("0400040009000D"))) {
- int mode = data[7] - 1;
- LOG_INFO("Noise control mode: " << mode);
- if (mode >= 0 && mode <= 3) {
- emit noiseControlModeChanged(mode);
- } else {
- LOG_ERROR("Invalid noise control mode value received: " << mode);
- }
- } else if (data.size() == 8 && data.startsWith(QByteArray::fromHex("040004000600"))) {
- bool primaryInEar = data[6] == 0x00;
- bool secondaryInEar = data[7] == 0x00;
- QString earDetectionStatus = QString("Primary: %1, Secondary: %2")
- .arg(primaryInEar ? "In Ear" : "Out of Ear")
- .arg(secondaryInEar ? "In Ear" : "Out of Ear");
- LOG_INFO("Ear detection status: " << earDetectionStatus);
- emit earDetectionStatusChanged(earDetectionStatus);
- } else if (data.size() == 22 && data.startsWith(QByteArray::fromHex("040004000400"))) {
- int leftLevel = data[9];
- int rightLevel = data[14];
- int caseLevel = data[19];
- QString batteryStatus = QString("Left: %1%, Right: %2%, Case: %3%")
- .arg(leftLevel)
- .arg(rightLevel)
- .arg(caseLevel);
- LOG_INFO("Battery status: " << batteryStatus);
- emit batteryStatusChanged(batteryStatus);
- } else if (data.size() == 10 &&
\ No newline at end of file
diff --git a/linux/BluetoothHandler.h b/linux/BluetoothHandler.h
deleted file mode 100644
index 56215c5..0000000
--- a/linux/BluetoothHandler.h
+++ /dev/null
@@ -1,23 +0,0 @@
-#pragma once
-
-#include
-#include
-#include
-
-class BluetoothHandler : public QObject {
- Q_OBJECT
-
-public:
- BluetoothHandler();
- void connectToDevice(const QBluetoothDeviceInfo &device);
- void parseData(const QByteArray &data);
-
-signals:
- void noiseControlModeChanged(int mode);
- void earDetectionStatusChanged(const QString &status);
- void batteryStatusChanged(const QString &status);
-
-private:
- QBluetoothSocket *socket = nullptr;
- QBluetoothDeviceDiscoveryAgent *discoveryAgent;
-};
diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt
index 6262b5d..eb764af 100644
--- a/linux/CMakeLists.txt
+++ b/linux/CMakeLists.txt
@@ -10,9 +10,6 @@ qt_standard_project_setup(REQUIRES 6.5)
qt_add_executable(applinux
main.cpp
- AirPodsTrayApp.cpp
- BluetoothHandler.cpp
- PacketDefinitions.cpp
)
qt_add_qml_module(applinux
diff --git a/linux/Main.qml b/linux/Main.qml
index afd100e..f5863b4 100644
--- a/linux/Main.qml
+++ b/linux/Main.qml
@@ -21,12 +21,14 @@ ApplicationWindow {
text: "Battery Status: "
id: batteryStatus
objectName: "batteryStatus"
+ color: "#ffffff"
}
Text {
text: "Ear Detection Status: "
id: earDetectionStatus
objectName: "earDetectionStatus"
+ color: "#ffffff"
}
ComboBox {
diff --git a/linux/main.cpp b/linux/main.cpp
index 10bad6c..17d7b4c 100644
--- a/linux/main.cpp
+++ b/linux/main.cpp
@@ -39,7 +39,6 @@ Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
#define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0"
-// Define Manufacturer Specific Data Identifier
#define MANUFACTURER_ID 0x1234
#define MANUFACTURER_DATA "ALN_AirPods"
@@ -92,7 +91,7 @@ public:
connect(trayIcon, &QSystemTrayIcon::activated, this, &AirPodsTrayApp::onTrayIconActivated);
discoveryAgent = new QBluetoothDeviceDiscoveryAgent();
- discoveryAgent->setLowEnergyDiscoveryTimeout(5000);
+ discoveryAgent->setLowEnergyDiscoveryTimeout(15000);
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &AirPodsTrayApp::onDeviceDiscovered);
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &AirPodsTrayApp::onDiscoveryFinished);
@@ -114,12 +113,10 @@ public:
initializeMprisInterface();
connect(phoneSocket, &QBluetoothSocket::readyRead, this, &AirPodsTrayApp::onPhoneDataReceived);
- // After starting discovery, check if service record exists
QDBusInterface iface("org.bluez", "/org/bluez", "org.bluez.Adapter1");
QDBusReply reply = iface.call("GetServiceRecords", QString::fromUtf8("74ec2172-0bad-4d01-8f77-997b2be0722a"));
if (reply.isValid()) {
LOG_INFO("Service record found, proceeding with connection");
- // Proceed with existing connection logic
} else {
LOG_WARN("Service record not found, waiting for BLE broadcast");
}
@@ -236,7 +233,7 @@ public slots:
bool secondaryInEar = parts[1].contains("In Ear");
if (primaryInEar && secondaryInEar) {
- if (wasPausedByApp) {
+ if (wasPausedByApp && isActiveOutputDeviceAirPods()) {
QProcess::execute("playerctl", QStringList() << "play");
LOG_INFO("Resumed playback via Playerctl");
wasPausedByApp = false;
@@ -245,15 +242,17 @@ public slots:
activateA2dpProfile();
} else {
LOG_INFO("At least one AirPod is out of ear");
- QProcess process;
- process.start("playerctl", QStringList() << "status");
- process.waitForFinished();
- QString playbackStatus = process.readAllStandardOutput().trimmed();
- LOG_DEBUG("Playback status: " << playbackStatus);
- if (playbackStatus == "Playing") {
- QProcess::execute("playerctl", QStringList() << "pause");
- LOG_INFO("Paused playback via Playerctl");
- wasPausedByApp = true;
+ if (isActiveOutputDeviceAirPods()) {
+ QProcess process;
+ process.start("playerctl", QStringList() << "status");
+ process.waitForFinished();
+ QString playbackStatus = process.readAllStandardOutput().trimmed();
+ LOG_DEBUG("Playback status: " << playbackStatus);
+ if (playbackStatus == "Playing") {
+ QProcess::execute("playerctl", QStringList() << "pause");
+ LOG_INFO("Paused playback via Playerctl");
+ wasPausedByApp = true;
+ }
}
if (!primaryInEar && !secondaryInEar) {
removeAudioOutputDevice();
@@ -308,7 +307,6 @@ public slots:
QByteArray manufacturerData = device.manufacturerData(MANUFACTURER_ID);
if (manufacturerData.startsWith(MANUFACTURER_DATA)) {
LOG_INFO("Detected AirPods via BLE manufacturer data");
- // Initiate RFComm connection
connectToDevice(device.address().toString());
}
LOG_INFO("Device discovered: " << device.name() << " (" << device.address().toString() << ")");
@@ -320,7 +318,6 @@ public slots:
void onDiscoveryFinished() {
LOG_INFO("Device discovery finished");
- // Restart discovery to continuously listen for broadcasts
discoveryAgent->start();
const QList discoveredDevices = discoveryAgent->discoveredDevices();
for (const QBluetoothDeviceInfo &device : discoveredDevices) {
@@ -359,79 +356,70 @@ public slots:
LOG_INFO("Already connected to the device: " << device.name());
return;
}
-
- LOG_INFO("Checking connection status with phone before connecting to device: " << device.name());
- QByteArray connectionStatusRequest = QByteArray::fromHex("00020003");
- if (phoneSocket && phoneSocket->isOpen()) {
- phoneSocket->write(connectionStatusRequest);
- LOG_DEBUG("Connection status request packet written: " << connectionStatusRequest.toHex());
- connect(phoneSocket, &QBluetoothSocket::readyRead, this, [this, device]() {
- QByteArray data = phoneSocket->read(4);
- LOG_DEBUG("Data received from phone: " << data.toHex());
- if (data == QByteArray::fromHex("00010001")) {
- LOG_INFO("AirPods are already connected");
- disconnect(phoneSocket, &QBluetoothSocket::readyRead, nullptr, nullptr);
- } else if (data == QByteArray::fromHex("00010000")) {
- LOG_INFO("AirPods are disconnected, proceeding with connection");
- disconnect(phoneSocket, &QBluetoothSocket::readyRead, nullptr, nullptr);
-
- QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol);
- connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() {
- LOG_INFO("Connected to device, sending initial packets");
- discoveryAgent->stop();
-
- QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000");
- QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000");
- QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff");
-
- qint64 bytesWritten = localSocket->write(handshakePacket);
- LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten);
-
- QByteArray airpodsConnectedPacket = QByteArray::fromHex("000400010001");
- phoneSocket->write(airpodsConnectedPacket);
- LOG_DEBUG("AIRPODS_CONNECTED packet written: " << airpodsConnectedPacket.toHex());
-
- connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) {
- LOG_INFO("Bytes written: " << bytes);
- if (bytes > 0) {
- static int step = 0;
- switch (step) {
- case 0:
- localSocket->write(setSpecificFeaturesPacket);
- LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
- step++;
- break;
- case 1:
- localSocket->write(requestNotificationsPacket);
- LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex());
- step++;
- break;
- }
- }
- });
-
- connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() {
- QByteArray data = localSocket->readAll();
- LOG_DEBUG("Data received: " << data.toHex());
- parseData(data);
- relayPacketToPhone(data);
- });
- });
-
- connect(localSocket, QOverload::of(&QBluetoothSocket::errorOccurred), this, [this, localSocket](QBluetoothSocket::SocketError error) {
- LOG_ERROR("Socket error: " << error << ", " << localSocket->errorString());
- });
-
- localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
- socket = localSocket;
- connectedDeviceMacAddress = device.address().toString().replace(":", "_");
+
+ LOG_INFO("Connecting to device: " << device.name());
+ QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::RfcommProtocol);
+ connect(localSocket, &QBluetoothSocket::connected, this, [this, localSocket]() {
+ LOG_INFO("Connected to device, sending initial packets");
+ discoveryAgent->stop();
+
+ QByteArray handshakePacket = QByteArray::fromHex("00000400010002000000000000000000");
+ QByteArray setSpecificFeaturesPacket = QByteArray::fromHex("040004004d00ff00000000000000");
+ QByteArray requestNotificationsPacket = QByteArray::fromHex("040004000f00ffffffffff");
+
+ qint64 bytesWritten = localSocket->write(handshakePacket);
+ LOG_DEBUG("Handshake packet written: " << handshakePacket.toHex() << ", bytes written: " << bytesWritten);
+ localSocket->write(setSpecificFeaturesPacket);
+ LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
+ localSocket->write(requestNotificationsPacket);
+ LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex());
+ connect(localSocket, &QBluetoothSocket::bytesWritten, this, [this, localSocket, setSpecificFeaturesPacket, requestNotificationsPacket](qint64 bytes) {
+ LOG_INFO("Bytes written: " << bytes);
+ if (bytes > 0) {
+ static int step = 0;
+ switch (step) {
+ case 0:
+ localSocket->write(setSpecificFeaturesPacket);
+ LOG_DEBUG("Set specific features packet written: " << setSpecificFeaturesPacket.toHex());
+ step++;
+ break;
+ case 1:
+ localSocket->write(requestNotificationsPacket);
+ LOG_DEBUG("Request notifications packet written: " << requestNotificationsPacket.toHex());
+ step++;
+ break;
+ }
}
});
- } else {
- LOG_ERROR("Phone socket is not open, cannot send connection status request");
- }
+
+ connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() {
+ QByteArray data = localSocket->readAll();
+ LOG_DEBUG("Data received: " << data.toHex());
+ QMetaObject::invokeMethod(this, "parseData", Qt::QueuedConnection, Q_ARG(QByteArray, data));
+ QMetaObject::invokeMethod(this, "relayPacketToPhone", Qt::QueuedConnection, Q_ARG(QByteArray, data));
+ });
+
+ QTimer::singleShot(500, this, [localSocket, setSpecificFeaturesPacket, requestNotificationsPacket]() {
+ if (localSocket->isOpen()) {
+ localSocket->write(setSpecificFeaturesPacket);
+ LOG_DEBUG("Resent set specific features packet: " << setSpecificFeaturesPacket.toHex());
+ localSocket->write(requestNotificationsPacket);
+ LOG_DEBUG("Resent request notifications packet: " << requestNotificationsPacket.toHex());
+ } else {
+ LOG_WARN("Socket is not open, cannot resend packets");
+ }
+ });
+ });
+
+ connect(localSocket, QOverload::of(&QBluetoothSocket::errorOccurred), this, [this, localSocket](QBluetoothSocket::SocketError error) {
+ LOG_ERROR("Socket error: " << error << ", " << localSocket->errorString());
+ });
+
+ localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
+ socket = localSocket;
+ connectedDeviceMacAddress = device.address().toString().replace(":", "_");
}
-
+
void parseData(const QByteArray &data) {
LOG_DEBUG("Parsing data: " << data.toHex() << "Size: " << data.size());
if (data.size() == 11 && data.startsWith(QByteArray::fromHex("0400040009000D"))) {
@@ -474,7 +462,7 @@ public slots:
LOG_INFO("Conversational awareness: " << (lowered ? "enabled" : "disabled"));
if (lowered) {
- if (initialVolume == -1) {
+ if (initialVolume == -1 && isActiveOutputDeviceAirPods()) {
QProcess process;
process.start("pactl", QStringList() << "get-sink-volume" << "@DEFAULT_SINK@");
process.waitForFinished();
@@ -492,7 +480,7 @@ public slots:
QProcess::execute("pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@" << QString::number(initialVolume * 0.20) + "%");
LOG_INFO("Volume lowered to 0.20 of initial which is " << initialVolume * 0.20 << "%");
} else {
- if (initialVolume != -1) {
+ if (initialVolume != -1 && isActiveOutputDeviceAirPods()) {
QProcess::execute("pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@" << QString::number(initialVolume) + "%");
LOG_INFO("Volume restored to " << initialVolume << "%");
initialVolume = -1;
@@ -500,6 +488,15 @@ public slots:
}
}
+ bool isActiveOutputDeviceAirPods() {
+ QProcess process;
+ process.start("pactl", QStringList() << "get-default-sink");
+ process.waitForFinished();
+ QString output = process.readAllStandardOutput().trimmed();
+ LOG_DEBUG("Default sink: " << output);
+ return output.contains("bluez_card." + connectedDeviceMacAddress);
+ }
+
void initializeMprisInterface() {
QStringList services = QDBusConnection::sessionBus().interface()->registeredServiceNames();
QString mprisService;
@@ -560,7 +557,7 @@ public slots:
void handlePhonePacket(const QByteArray &packet) {
if (packet.startsWith(QByteArray::fromHex("00040001"))) {
- QByteArray airpodsPacket = packet.mid(4); // Remove the header
+ QByteArray airpodsPacket = packet.mid(4);
if (socket && socket->isOpen()) {
socket->write(airpodsPacket);
LOG_DEBUG("Relayed packet to AirPods: " << airpodsPacket.toHex());
@@ -569,15 +566,24 @@ public slots:
}
} else if (packet.startsWith(QByteArray::fromHex("00010001"))) {
LOG_INFO("AirPods connected");
- // Handle AirPods connected
} else if (packet.startsWith(QByteArray::fromHex("00010000"))) {
LOG_INFO("AirPods disconnected");
- // Handle AirPods disconnected
} else if (packet.startsWith(QByteArray::fromHex("00020003"))) {
LOG_INFO("Connection status request received");
QByteArray response = (socket && socket->isOpen()) ? QByteArray::fromHex("00010001") : QByteArray::fromHex("00010000");
phoneSocket->write(response);
LOG_DEBUG("Sent connection status response: " << response.toHex());
+ } else if (packet.startsWith(QByteArray::fromHex("00020000"))) {
+ LOG_INFO("Disconnect request received");
+ if (socket && socket->isOpen()) {
+ socket->close();
+ LOG_INFO("Disconnected from AirPods");
+ QProcess process;
+ process.start("bluetoothctl", QStringList() << "disconnect" << connectedDeviceMacAddress.replace("_", ":"));
+ process.waitForFinished();
+ QString output = process.readAllStandardOutput().trimmed();
+ LOG_INFO("Bluetoothctl output: " << output);
+ }
} else {
if (socket && socket->isOpen()) {
socket->write(packet);
@@ -591,7 +597,37 @@ public slots:
void onPhoneDataReceived() {
QByteArray data = phoneSocket->readAll();
LOG_DEBUG("Data received from phone: " << data.toHex());
- handlePhonePacket(data);
+ QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data));
+ }
+
+ public: void followMediaChanges() {
+ QProcess *playerctlProcess = new QProcess(this);
+ connect(playerctlProcess, &QProcess::readyReadStandardOutput, this, [this, playerctlProcess]() {
+ QString output = playerctlProcess->readAllStandardOutput().trimmed();
+ LOG_DEBUG("Playerctl output: " << output);
+ if (output == "Playing" && isPhoneConnected()) {
+ LOG_INFO("Media started playing, connecting to AirPods");
+ connectToAirPods();
+ }
+ });
+ playerctlProcess->start("playerctl", QStringList() << "metadata" << "--follow" << "status");
+ }
+
+ bool isPhoneConnected() {
+ return phoneSocket && phoneSocket->isOpen();
+ }
+
+ void connectToAirPods() {
+ QBluetoothLocalDevice localDevice;
+ const QList connectedDevices = localDevice.connectedDevices();
+ for (const QBluetoothAddress &address : connectedDevices) {
+ QBluetoothDeviceInfo device(address, "", 0);
+ if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
+ connectToDevice(device);
+ return;
+ }
+ }
+ LOG_WARN("AirPods not found among connected devices");
}
signals:
@@ -673,6 +709,8 @@ int main(int argc, char *argv[]) {
}
});
+ trayApp.followMediaChanges();
+
return app.exec();
}