diff --git a/README.md b/README.md index 36b926c..1e4978d 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,28 @@ Other devices might work too. Features like ear detection and battery should be Check the [pinned issue](https://github.com/kavishdevar/aln/issues/20) for a list. -## Linux — Deprecated, awaiting a rewrite! -ANY ISSUES ABOUT THE LINUX VERSION WILL BE CLOSED. +## CrossDevice Stuff + +> [!IMPORTANT] +> This feature is still in development and might not work as expected. No support is provided for this feature. + +### Features + +- **Battery Status**: Get battery status on your Android device when you connect your AirPods to your Linux device. +- **Control AirPods**: Control your AirPods from your Android device when connected to your Linux device, like changing the noise control mode, toggling conversational awareness, and more. +- **Automatic Device Switching**: Automatically switch between your Linux and Android device when you connect your AirPods to one of them. + +> [!NOTE] +> All this currently works only one way, Linux to Android, i.e. if you connect your AirPods to your Linux device, the features mentioned. The Android app can automaticaly connect to your AirPods when *receiving calls* or *starting media playback*. + +| | | | +|-------------------|-------------------|-------------------| +| Connected to Linux, all features of the app available (setting Noise Control mode, battery status, etc.). ![Connected Remotely](/android/imgs/cd-connected-remotely-island.png) | Call received or media started playing; phone took over audio and disconnected AirPods from linux. ![Moved to Phone](/android/imgs/cd-moved-to-phone-island.png) | | + +## Linux — Deprecated, rewrite WIP! + +> No support will be provided for the old version of the Linux app. The new version is still in development and might not work as expected. No support is provided for the new version either. + Check out the README file in [linux](/linux) folder for more info. This tray app communicates with a daemon with the help of a UNIX socket. The daemon is responsible for the actual communication with the AirPods. The tray app is just a frontend for the daemon, that does ear-detection, conversational awareness, setting the noise-cancellation mode, and more. @@ -39,6 +59,7 @@ https://github.com/user-attachments/assets/eb7eebc2-fecf-410d-a363-0a5fd3a7af30 | ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) | | ![Battery Notification](/android/imgs/notification.png) | ![Popup](/android/imgs/popup.png) | ![QuickSetting Tile](/android/imgs/qstile.png) | | ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations](/android/imgs/customizations.png) | +| ![audio-popup](/android/imgs/audio-connected-island.png) | | | ### Installation 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 4986f71..1fe9222 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 @@ -797,6 +797,8 @@ class AirPodsService : Service() { Log.d("AirPodsService", "Taking over audio") CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet) Log.d("AirPodsService", macAddress) + CrossDevice.isAvailable = false + sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false) } device = getSystemService(BluetoothManager::class.java).adapter.bondedDevices.find { it.address == macAddress } @@ -823,9 +825,7 @@ class AirPodsService : Service() { 0x1001, uuid ) as BluetoothSocket - } catch ( - e: Exception - ) { + } catch (e: Exception) { e.printStackTrace() try { socket = HiddenApiBypass.newInstance( @@ -838,9 +838,7 @@ class AirPodsService : Service() { 0x1001, uuid ) as BluetoothSocket - } catch ( - e: Exception - ) { + } catch (e: Exception) { e.printStackTrace() } } @@ -850,10 +848,6 @@ class AirPodsService : Service() { this@AirPodsService.device = device isConnectedLocally = true socket.let { it -> - // sometimes doesn't work ;-; - // i though i move it to the coroutine - // but, the socket sometimes disconnects if i don't send a packet outside of the routine first - // so, sending *again*, with a delay, in the coroutine it.outputStream.write(Enums.HANDSHAKE.value) it.outputStream.flush() it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value) @@ -861,7 +855,6 @@ class AirPodsService : Service() { it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value) it.outputStream.flush() CoroutineScope(Dispatchers.IO).launch { - // this is so stupid, why does it disconnect if i don't send a packet outside of the coroutine first it.outputStream.write(Enums.HANDSHAKE.value) it.outputStream.flush() delay(200) @@ -871,7 +864,6 @@ class AirPodsService : Service() { it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value) it.outputStream.flush() delay(200) - // just in case this doesn't work, send all three after 5 seconds again Handler(Looper.getMainLooper()).postDelayed({ it.outputStream.write(Enums.HANDSHAKE.value) it.outputStream.flush() @@ -898,6 +890,11 @@ class AirPodsService : Service() { val bytes = buffer.copyOfRange(0, bytesRead) val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } CrossDevice.sendReceivedPacket(bytes) + updateNotificationContent( + true, + sharedPreferences.getString("name", device.name), + batteryNotification.getBattery() + ) Log.d("AirPods Data", "Data received: $formattedHex") } else if (bytesRead == -1) { Log.d("AirPods Service", "Socket closed (bytesRead = -1)") 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 17efaae..713ee6d 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 @@ -84,6 +84,7 @@ object CrossDevice { @SuppressLint("MissingPermission") private fun startServer() { CoroutineScope(Dispatchers.IO).launch { + if (!bluetoothAdapter.isEnabled) return@launch serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid) Log.d("CrossDevice", "Server started") while (serverSocket != null) { @@ -237,4 +238,4 @@ object CrossDevice { logPacket(byteArray, "Sent") Log.d("CrossDevice", "Sent packet to remote device") } -} +} \ No newline at end of file diff --git a/android/imgs/audio-connected-island.png b/android/imgs/audio-connected-island.png new file mode 100644 index 0000000..bb942ae Binary files /dev/null and b/android/imgs/audio-connected-island.png differ diff --git a/android/imgs/cd-connected-remotely-island.png b/android/imgs/cd-connected-remotely-island.png new file mode 100644 index 0000000..017bd70 Binary files /dev/null and b/android/imgs/cd-connected-remotely-island.png differ diff --git a/android/imgs/cd-moved-to-phone-island.png b/android/imgs/cd-moved-to-phone-island.png new file mode 100644 index 0000000..464c90e Binary files /dev/null and b/android/imgs/cd-moved-to-phone-island.png differ diff --git a/android/imgs/transitions.mp4 b/android/imgs/transitions.mp4 index bd7221a..7b3feab 100644 Binary files a/android/imgs/transitions.mp4 and b/android/imgs/transitions.mp4 differ diff --git a/linux.old/test_l2.py b/linux.old/test_l2.py deleted file mode 100644 index 3049676..0000000 --- a/linux.old/test_l2.py +++ /dev/null @@ -1,26 +0,0 @@ -# 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 . - -import bluetooth - -address="28:2D:7F:C2:05:5B" - -try: - sock = bluetooth.BluetoothSocket(bluetooth.L2CAP) - sock.connect((address, 0x1001)) - sock.send(b"\x00\x00\x04\x00\x01\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x00") -except bluetooth.btcommon.BluetoothError as e: - print(f"Error: {e}") diff --git a/linux/main.cpp b/linux/main.cpp index 17d7b4c..6760fc7 100644 --- a/linux/main.cpp +++ b/linux/main.cpp @@ -51,7 +51,17 @@ public: trayIcon = new QSystemTrayIcon(QIcon(":/icons/airpods.png")); trayMenu = new QMenu(); + // Initialize conversational awareness state + bool caState = loadConversationalAwarenessState(); QAction *caToggleAction = new QAction("Toggle Conversational Awareness", trayMenu); + caToggleAction->setCheckable(true); + caToggleAction->setChecked(caState); + connect(caToggleAction, &QAction::triggered, this, [this, caToggleAction]() { + bool newState = !caToggleAction->isChecked(); + setConversationalAwareness(newState); + saveConversationalAwarenessState(newState); + caToggleAction->setChecked(newState); + }); trayMenu->addAction(caToggleAction); QAction *offAction = new QAction("Off", trayMenu); @@ -120,6 +130,9 @@ public: } else { LOG_WARN("Service record not found, waiting for BLE broadcast"); } + + // Set up device listening + listenForDeviceConnections(); } public slots: @@ -203,7 +216,7 @@ public slots: } void updateBatteryTooltip(const QString &status) { - trayIcon->setToolTip(status); + trayIcon->setToolTip("Battery Status: " + status); } void updateTrayIcon(const QString &status) { @@ -600,6 +613,38 @@ public slots: QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data)); } + void listenForDeviceConnections() { + QDBusConnection systemBus = QDBusConnection::systemBus(); + systemBus.connect(QString(), QString(), "org.freedesktop.DBus.Properties", "PropertiesChanged", this, SLOT(onDevicePropertiesChanged(QString, QVariantMap, QStringList))); + systemBus.connect(QString(), QString(), "org.freedesktop.DBus.ObjectManager", "InterfacesAdded", this, SLOT(onInterfacesAdded(QString, QVariantMap))); + } + + void onDevicePropertiesChanged(QString interface, QVariantMap changed, QStringList invalidated) { + if (changed.contains("Connected") && changed["Connected"].toBool()) { + QString path = interface.split("/").last(); + QString addr = path.replace("_", ":").replace("dev:", ""); + QBluetoothAddress btAddress(addr); + QBluetoothDeviceInfo device(btAddress, "", 0); + if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) { + connectToDevice(device); + } + } + } + + void onInterfacesAdded(QString path, QVariantMap interfaces) { + if (interfaces.contains("org.bluez.Device1")) { + QVariantMap deviceProps = interfaces["org.bluez.Device1"].toMap(); + if (deviceProps.contains("Connected") && deviceProps["Connected"].toBool()) { + QString addr = deviceProps["Address"].toString(); + QBluetoothAddress btAddress(addr); + QBluetoothDeviceInfo device(btAddress, "", 0); + if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) { + connectToDevice(device); + } + } + } + } + public: void followMediaChanges() { QProcess *playerctlProcess = new QProcess(this); connect(playerctlProcess, &QProcess::readyReadStandardOutput, this, [this, playerctlProcess]() {