some progress on cross-device, and new dynamic island thingy!

This commit is contained in:
Kavish Devar
2025-01-30 03:49:44 +05:30
parent 8b57a97a54
commit b6966f8c39
19 changed files with 730 additions and 472 deletions

View File

@@ -23,6 +23,7 @@
android:usesPermissionFlags="neverForLocation"
tools:ignore="UnusedAttribute" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<application
android:allowBackup="true"

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
@@ -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<AirPodsService?>(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() {
}
}
}
}
}

View File

@@ -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<Set<String>>(emptySet())
val packetLogsFlow: StateFlow<Set<String>> 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>(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()
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<TextView>(R.id.island_battery_text).text = "$batteryPercentage%"
islandView.findViewById<TextView>(R.id.island_device_name).text = name
islandView.setOnClickListener {
ServiceManager.getService()?.startMainActivity()
close()
}
if (takingOver) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_taking_over_text)
} else if (CrossDevice.isAvailable) {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_remote_text)
} else {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_connected_text)
}
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
batteryProgressBar.progress = batteryPercentage
batteryProgressBar.isIndeterminate = false
val videoView = islandView.findViewById<VideoView>(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<VideoView>(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()
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
@@ -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 {
}
})
}
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
@@ -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<ImageButton>(R.id.close_button)
.setOnClickListener {
close()
}
mView.findViewById<ImageButton>(R.id.close_button).setOnClickListener {
close()
}
val ll = mView.findViewById<LinearLayout>(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<TextView>(R.id.name).text = name
val vid = mView.findViewById<VideoView>(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())
}
}
}
}

View File

@@ -0,0 +1,9 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#000000"/>
<corners android:radius="56dp"/>
<padding android:left="4dp" android:top="4dp" android:right="4dp" android:bottom="4dp"/>
</shape>
</item>
</layer-list>

View File

@@ -0,0 +1,12 @@
<rotate
xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="270"
android:toDegrees="270">
<shape
android:shape="ring"
android:innerRadiusRatio="3.0"
android:thickness="4dp"
android:useLevel="true">
<solid android:color="#0f4524" />
</shape>
</rotate>

View File

@@ -0,0 +1,11 @@
<rotate
xmlns:android="http://schemas.android.com/apk/res/android"
android:fromDegrees="270"
android:toDegrees="270">
<shape
android:shape="ring"
android:innerRadiusRatio="3.0"
android:thickness="4dp" >
<solid android:color="#1ceb72" />
</shape>
</rotate>

View File

@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/island_window_layout"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="16dp"
android:layout_weight="0.95"
android:background="@drawable/island_background"
android:elevation="4dp"
android:gravity="center"
android:minHeight="115dp"
android:orientation="horizontal"
android:outlineAmbientShadowColor="#4EFFFFFF"
android:outlineSpotShadowColor="#4EFFFFFF"
android:padding="8dp">
<VideoView
android:id="@+id/island_video_view"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_marginStart="8dp"
android:importantForAccessibility="no" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_margin="0dp"
android:layout_weight="1"
android:gravity="bottom"
android:orientation="vertical"
android:padding="12dp">
<TextView
android:id="@+id/island_connected_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
android:fontFamily="@font/sf_pro"
android:gravity="bottom"
android:padding="0dp"
android:text="@string/island_connected_text"
android:textColor="#707072"
android:includeFontPadding="false"
android:lineSpacingExtra="0dp"
android:lineSpacingMultiplier="1"
android:textSize="16sp" />
<TextView
android:id="@+id/island_device_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
android:fontFamily="@font/sf_pro"
android:gravity="bottom"
android:padding="0dp"
android:text="AirPods Pro"
android:textColor="@color/white"
android:textSize="24sp"
android:includeFontPadding="false"
android:lineSpacingExtra="0dp"
android:lineSpacingMultiplier="1"
tools:ignore="HardcodedText" />
</LinearLayout>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="center">
<ProgressBar
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="84dp"
android:layout_height="84dp"
android:layout_gravity="center"
android:indeterminate="false"
android:max="100"
android:progress="100"
android:progressDrawable="@drawable/island_battery_background" />
<ProgressBar
android:id="@+id/island_battery_progress"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="84dp"
android:layout_height="84dp"
android:layout_gravity="center"
android:indeterminate="false"
android:max="100"
android:progress="50"
android:progressDrawable="@drawable/island_battery_progress" />
<TextView
android:id="@+id/island_battery_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:fontFamily="@font/sf_pro"
android:gravity="center"
android:text="50%"
android:textColor="#1ceb72"
android:textSize="16sp"
android:textStyle="bold"
tools:ignore="HardcodedText" />
</FrameLayout>
</LinearLayout>

Binary file not shown.

View File

@@ -1,45 +1,48 @@
<resources>
<string name="app_name" translatable="false">ALN</string>
<string name="title_activity_custom_device" translatable="false">GATT Testing</string>
<string name="app_widget_description">See your AirPods battery status right from your home screen!</string>
<string name="accessibility">Accessibility</string>
<string name="tone_volume">Tone Volume</string>
<string name="audio">Audio</string>
<string name="adaptive_audio">Adaptive Audio</string>
<string name="adaptive_audio_description">Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.</string>
<string name="buds">Buds</string>
<string name="case_alt">Case</string>
<string name="test">Test</string>
<string name="name">Name</string>
<string name="noise_control">Noise Control</string>
<string name="off">Off</string>
<string name="transparency">Transparency</string>
<string name="adaptive">Adaptive</string>
<string name="noise_cancellation">Noise Cancellation</string>
<string name="press_and_hold_airpods">Press and Hold AirPods</string>
<string name="left">Left</string>
<string name="right">Right</string>
<string name="adjusts_volume">Adjusts the volume of media in response to your environment</string>
<string name="conversational_awareness">Conversational Awareness</string>
<string name="conversational_awareness_description">Lowers media volume and reduces background noise when you start speaking to other people.</string>
<string name="personalized_volume">Personalized Volume</string>
<string name="personalized_volume_description">Adjusts the volume of media in response to your environment.</string>
<string name="less_noise">Less Noise</string>
<string name="more_noise">More Noise</string>
<string name="noise_cancellation_single_airpod">Noise Cancellation with Single AirPod</string>
<string name="noise_cancellation_single_airpod_description">Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.</string>
<string name="volume_control">Volume Control</string>
<string name="volume_control_description">Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.</string>
<string name="airpods_not_connected">AirPods not connected</string>
<string name="airpods_not_connected_description">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!)</string>
<string name="back">Back</string>
<string name="app_settings">App Settings</string>
<string name="conversational_awareness_customization">Conversational Awareness</string>
<string name="relative_conversational_awareness_volume">Relative volume</string>
<string name="relative_conversational_awareness_volume_description">Reduces to a percentage of the current volume instead of the maximum volume.</string>
<string name="conversational_awareness_pause_music">Pause Music</string>
<string name="conversational_awareness_pause_music_description">When you start speaking, music will be paused.</string>
<string name="appwidget_text">EXAMPLE</string>
<string name="add_widget">Add widget</string>
<string name="noise_control_widget_description">Control Noise Control Mode directly from your Home Screen.</string>
<string name="app_name" translatable="false">ALN</string>
<string name="title_activity_custom_device" translatable="false">GATT Testing</string>
<string name="app_widget_description">See your AirPods battery status right from your home screen!</string>
<string name="accessibility">Accessibility</string>
<string name="tone_volume">Tone Volume</string>
<string name="audio">Audio</string>
<string name="adaptive_audio">Adaptive Audio</string>
<string name="adaptive_audio_description">Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.</string>
<string name="buds">Buds</string>
<string name="case_alt">Case</string>
<string name="test">Test</string>
<string name="name">Name</string>
<string name="noise_control">Noise Control</string>
<string name="off">Off</string>
<string name="transparency">Transparency</string>
<string name="adaptive">Adaptive</string>
<string name="noise_cancellation">Noise Cancellation</string>
<string name="press_and_hold_airpods">Press and Hold AirPods</string>
<string name="left">Left</string>
<string name="right">Right</string>
<string name="adjusts_volume">Adjusts the volume of media in response to your environment</string>
<string name="conversational_awareness">Conversational Awareness</string>
<string name="conversational_awareness_description">Lowers media volume and reduces background noise when you start speaking to other people.</string>
<string name="personalized_volume">Personalized Volume</string>
<string name="personalized_volume_description">Adjusts the volume of media in response to your environment.</string>
<string name="less_noise">Less Noise</string>
<string name="more_noise">More Noise</string>
<string name="noise_cancellation_single_airpod">Noise Cancellation with Single AirPod</string>
<string name="noise_cancellation_single_airpod_description">Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.</string>
<string name="volume_control">Volume Control</string>
<string name="volume_control_description">Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.</string>
<string name="airpods_not_connected">AirPods not connected</string>
<string name="airpods_not_connected_description">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!)</string>
<string name="back">Back</string>
<string name="app_settings">App Settings</string>
<string name="conversational_awareness_customization">Conversational Awareness</string>
<string name="relative_conversational_awareness_volume">Relative volume</string>
<string name="relative_conversational_awareness_volume_description">Reduces to a percentage of the current volume instead of the maximum volume.</string>
<string name="conversational_awareness_pause_music">Pause Music</string>
<string name="conversational_awareness_pause_music_description">When you start speaking, music will be paused.</string>
<string name="appwidget_text">EXAMPLE</string>
<string name="add_widget">Add widget</string>
<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>
</resources>

View File

@@ -1,53 +0,0 @@
#pragma once
#include <QObject>
#include <QSystemTrayIcon>
#include <QMenu>
#include <QBluetoothDeviceDiscoveryAgent>
#include <QBluetoothSocket>
#include <QDBusInterface>
#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;
};

View File

@@ -1,109 +0,0 @@
#include "BluetoothHandler.h"
#include "PacketDefinitions.h"
#include <QLoggingCategory>
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<QBluetoothSocket::SocketError>::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 &&

View File

@@ -1,23 +0,0 @@
#pragma once
#include <QBluetoothDeviceInfo>
#include <QBluetoothSocket>
#include <QBluetoothDeviceDiscoveryAgent>
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;
};

View File

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

View File

@@ -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 {

View File

@@ -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<QVariant> 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<QBluetoothDeviceInfo> 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<QBluetoothSocket::SocketError>::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<QBluetoothSocket::SocketError>::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<QBluetoothAddress> 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();
}