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(); }