mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-01-28 22:01:50 +00:00
955 lines
41 KiB
C++
955 lines
41 KiB
C++
#include <QApplication>
|
|
#include <QQmlApplicationEngine>
|
|
#include <QSystemTrayIcon>
|
|
#include <QMenu>
|
|
#include <QAction>
|
|
#include <QActionGroup>
|
|
#include <QBluetoothDeviceDiscoveryAgent>
|
|
#include <QBluetoothLocalDevice>
|
|
#include <QBluetoothServer>
|
|
#include <QBluetoothSocket>
|
|
#include <QQuickWindow>
|
|
#include <QDebug>
|
|
#include <QInputDialog>
|
|
#include <QQmlContext>
|
|
#include <QLoggingCategory>
|
|
#include <QThread>
|
|
#include <QTimer>
|
|
#include <QPainter>
|
|
#include <QPalette>
|
|
#include <QDBusInterface>
|
|
#include <QDBusReply>
|
|
#include <QDBusConnectionInterface>
|
|
#include <QProcess>
|
|
#include <QRegularExpression>
|
|
#include <QFile>
|
|
#include <QTextStream>
|
|
#include <QStandardPaths>
|
|
#include <QBluetoothServer>
|
|
#include <QBluetoothSocket>
|
|
#include <QBluetoothDeviceDiscoveryAgent>
|
|
#include <QBluetoothLocalDevice>
|
|
#include <QBluetoothUuid>
|
|
#include <QDBusConnection>
|
|
#include <QDBusInterface>
|
|
#include <QDBusReply>
|
|
#include <QDBusMessage>
|
|
#include <QDBusPendingCallWatcher>
|
|
|
|
Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
|
|
|
|
#define LOG_INFO(msg) qCInfo(airpodsApp) << "\033[32m" << msg << "\033[0m"
|
|
#define LOG_WARN(msg) qCWarning(airpodsApp) << "\033[33m" << msg << "\033[0m"
|
|
#define LOG_ERROR(msg) qCCritical(airpodsApp) << "\033[31m" << msg << "\033[0m"
|
|
#define LOG_DEBUG(msg) qCDebug(airpodsApp) << "\033[34m" << msg << "\033[0m"
|
|
|
|
#define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0"
|
|
|
|
#define MANUFACTURER_ID 0x1234
|
|
#define MANUFACTURER_DATA "ALN_AirPods"
|
|
|
|
class AirPodsTrayApp : public QObject {
|
|
Q_OBJECT
|
|
|
|
public:
|
|
AirPodsTrayApp(bool debugMode) : debugMode(debugMode) {
|
|
if (debugMode) {
|
|
QLoggingCategory::setFilterRules("airpodsApp.debug=true");
|
|
} else {
|
|
QLoggingCategory::setFilterRules("airpodsApp.debug=false");
|
|
}
|
|
LOG_INFO("Initializing AirPodsTrayApp");
|
|
trayIcon = new QSystemTrayIcon(QIcon(":/icons/airpods.png"));
|
|
trayMenu = new QMenu();
|
|
|
|
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);
|
|
QAction *transparencyAction = new QAction("Transparency", trayMenu);
|
|
QAction *adaptiveAction = new QAction("Adaptive", trayMenu);
|
|
QAction *noiseCancellationAction = new QAction("Noise Cancellation", trayMenu);
|
|
|
|
offAction->setCheckable(true);
|
|
transparencyAction->setCheckable(true);
|
|
adaptiveAction->setCheckable(true);
|
|
noiseCancellationAction->setCheckable(true);
|
|
|
|
trayMenu->addAction(offAction);
|
|
trayMenu->addAction(transparencyAction);
|
|
trayMenu->addAction(adaptiveAction);
|
|
trayMenu->addAction(noiseCancellationAction);
|
|
|
|
QActionGroup *noiseControlGroup = new QActionGroup(trayMenu);
|
|
noiseControlGroup->addAction(offAction);
|
|
noiseControlGroup->addAction(transparencyAction);
|
|
noiseControlGroup->addAction(adaptiveAction);
|
|
noiseControlGroup->addAction(noiseCancellationAction);
|
|
|
|
connect(offAction, &QAction::triggered, this, [this]() { setNoiseControlMode(0); });
|
|
connect(transparencyAction, &QAction::triggered, this, [this]() { setNoiseControlMode(2); });
|
|
connect(adaptiveAction, &QAction::triggered, this, [this]() { setNoiseControlMode(3); });
|
|
connect(noiseCancellationAction, &QAction::triggered, this, [this]() { setNoiseControlMode(1); });
|
|
|
|
connect(this, &AirPodsTrayApp::noiseControlModeChanged, this, &AirPodsTrayApp::updateNoiseControlMenu);
|
|
connect(this, &AirPodsTrayApp::batteryStatusChanged, this, &AirPodsTrayApp::updateBatteryTooltip);
|
|
connect(this, &AirPodsTrayApp::batteryStatusChanged, this, &AirPodsTrayApp::updateTrayIcon);
|
|
connect(this, &AirPodsTrayApp::earDetectionStatusChanged, this, &AirPodsTrayApp::handleEarDetection);
|
|
|
|
trayIcon->setContextMenu(trayMenu);
|
|
trayIcon->show();
|
|
|
|
connect(trayIcon, &QSystemTrayIcon::activated, this, &AirPodsTrayApp::onTrayIconActivated);
|
|
|
|
discoveryAgent = new QBluetoothDeviceDiscoveryAgent();
|
|
discoveryAgent->setLowEnergyDiscoveryTimeout(15000);
|
|
|
|
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::deviceDiscovered, this, &AirPodsTrayApp::onDeviceDiscovered);
|
|
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::finished, this, &AirPodsTrayApp::onDiscoveryFinished);
|
|
discoveryAgent->start();
|
|
LOG_INFO("AirPodsTrayApp initialized and started device discovery");
|
|
|
|
QBluetoothLocalDevice localDevice;
|
|
connect(&localDevice, &QBluetoothLocalDevice::deviceConnected, this, &AirPodsTrayApp::onDeviceConnected);
|
|
connect(&localDevice, &QBluetoothLocalDevice::deviceDisconnected, this, &AirPodsTrayApp::onDeviceDisconnected);
|
|
|
|
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;
|
|
}
|
|
}
|
|
initializeMprisInterface();
|
|
connect(phoneSocket, &QBluetoothSocket::readyRead, this, &AirPodsTrayApp::onPhoneDataReceived);
|
|
|
|
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");
|
|
} else {
|
|
LOG_WARN("Service record not found, waiting for BLE broadcast");
|
|
}
|
|
|
|
listenForDeviceConnections();
|
|
initializeDBus();
|
|
initializeBluetooth();
|
|
}
|
|
|
|
~AirPodsTrayApp() {
|
|
delete trayIcon;
|
|
delete trayMenu;
|
|
delete discoveryAgent;
|
|
delete bluezInterface;
|
|
delete mprisInterface;
|
|
delete socket;
|
|
delete phoneSocket;
|
|
}
|
|
|
|
private:
|
|
bool debugMode;
|
|
bool isConnectedLocally = false;
|
|
struct {
|
|
bool isAvailable = true;
|
|
} CrossDevice;
|
|
|
|
void initializeDBus() {
|
|
QDBusConnection systemBus = QDBusConnection::systemBus();
|
|
if (!systemBus.isConnected()) {
|
|
}
|
|
|
|
bluezInterface = new QDBusInterface("org.bluez",
|
|
"/",
|
|
"org.freedesktop.DBus.ObjectManager",
|
|
systemBus,
|
|
this);
|
|
|
|
if (!bluezInterface->isValid()) {
|
|
LOG_ERROR("Failed to connect to org.bluez DBus interface.");
|
|
return;
|
|
}
|
|
|
|
connect(systemBus.interface(), &QDBusConnectionInterface::NameOwnerChanged,
|
|
this, &AirPodsTrayApp::onNameOwnerChanged);
|
|
|
|
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)));
|
|
|
|
QDBusMessage msg = bluezInterface->call("GetManagedObjects");
|
|
if (msg.type() == QDBusMessage::ErrorMessage) {
|
|
LOG_ERROR("Error getting managed objects: " << msg.errorMessage());
|
|
return;
|
|
}
|
|
|
|
QVariantMap objects = qdbus_cast<QVariantMap>(msg.arguments().at(0));
|
|
for (auto it = objects.begin(); it != objects.end(); ++it) {
|
|
if (it.key().startsWith("/org/bluez/hci0/dev_")) {
|
|
LOG_INFO("Existing device: " << it.key());
|
|
}
|
|
}
|
|
QDBusConnection::systemBus().registerObject("/me/kavishdevar/aln", this);
|
|
QDBusConnection::systemBus().registerService("me.kavishdevar.aln");
|
|
}
|
|
|
|
void notifyAndroidDevice() {
|
|
if (phoneSocket && phoneSocket->isOpen()) {
|
|
QByteArray notificationPacket = QByteArray::fromHex("00040001");
|
|
phoneSocket->write(notificationPacket);
|
|
LOG_DEBUG("Sent notification packet to Android: " << notificationPacket.toHex());
|
|
} else {
|
|
LOG_WARN("Phone socket is not open, cannot send notification packet");
|
|
}
|
|
}
|
|
|
|
void onNameOwnerChanged(const QString &name, const QString &oldOwner, const QString &newOwner) {
|
|
if (name == "org.bluez") {
|
|
if (newOwner.isEmpty()) {
|
|
LOG_WARN("BlueZ has been stopped.");
|
|
} else {
|
|
LOG_INFO("BlueZ started.");
|
|
}
|
|
}
|
|
}
|
|
|
|
void onDevicePropertiesChanged(const QString &interface, const QVariantMap &changed, const QStringList &invalidated) {
|
|
if (interface != "org.bluez.Device1")
|
|
return;
|
|
|
|
if (changed.contains("Connected")) {
|
|
bool connected = changed.value("Connected").toBool();
|
|
QString devicePath = sender()->objectName();
|
|
LOG_INFO(QString("Device %1 connected: %2").arg(devicePath, connected ? "Yes" : "No"));
|
|
|
|
if (connected) {
|
|
const QBluetoothAddress address = QBluetoothAddress(devicePath.split("/").last().replace("_", ":"));
|
|
QBluetoothDeviceInfo device(address, "", 0);
|
|
if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
|
|
connectToDevice(device);
|
|
}
|
|
} else {
|
|
disconnectDevice(devicePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
void disconnectDevice(const QString &devicePath) {
|
|
LOG_INFO("Disconnecting device at " << devicePath);
|
|
}
|
|
|
|
QDBusInterface *bluezInterface = nullptr;
|
|
|
|
public slots:
|
|
void connectToDevice(const QString &address) {
|
|
LOG_INFO("Connecting to device with address: " << address);
|
|
QBluetoothAddress btAddress(address);
|
|
QBluetoothDeviceInfo device(btAddress, "", 0);
|
|
connectToDevice(device);
|
|
}
|
|
|
|
void showAvailableDevices() {
|
|
LOG_INFO("Showing available devices");
|
|
QStringList devices;
|
|
const QList<QBluetoothDeviceInfo> discoveredDevices = discoveryAgent->discoveredDevices();
|
|
for (const QBluetoothDeviceInfo &device : discoveredDevices) {
|
|
devices << device.address().toString() + " - " + device.name();
|
|
}
|
|
bool ok;
|
|
QString selectedDevice = QInputDialog::getItem(nullptr, "Select Device", "Devices:", devices, 0, false, &ok);
|
|
if (ok && !selectedDevice.isEmpty()) {
|
|
QString address = selectedDevice.split(" - ").first();
|
|
connectToDevice(address);
|
|
}
|
|
}
|
|
|
|
void setNoiseControlMode(int mode) {
|
|
LOG_INFO("Setting noise control mode to: " << mode);
|
|
QByteArray packet;
|
|
switch (mode) {
|
|
case 0:
|
|
packet = QByteArray::fromHex("0400040009000D01000000");
|
|
break;
|
|
case 1:
|
|
packet = QByteArray::fromHex("0400040009000D02000000");
|
|
break;
|
|
case 2:
|
|
packet = QByteArray::fromHex("0400040009000D03000000");
|
|
break;
|
|
case 3:
|
|
packet = QByteArray::fromHex("0400040009000D04000000");
|
|
break;
|
|
}
|
|
if (socket && socket->isOpen()) {
|
|
socket->write(packet);
|
|
LOG_DEBUG("Noise control mode packet written: " << packet.toHex());
|
|
} else {
|
|
LOG_ERROR("Socket is not open, cannot write noise control mode packet");
|
|
}
|
|
}
|
|
|
|
void setConversationalAwareness(bool enabled) {
|
|
LOG_INFO("Setting conversational awareness to: " << (enabled ? "enabled" : "disabled"));
|
|
QByteArray packet = enabled ? QByteArray::fromHex("0400040009002801000000") : QByteArray::fromHex("0400040009002802000000");
|
|
if (socket && socket->isOpen()) {
|
|
socket->write(packet);
|
|
LOG_DEBUG("Conversational awareness packet written: " << packet.toHex());
|
|
} else {
|
|
LOG_ERROR("Socket is not open, cannot write conversational awareness packet");
|
|
}
|
|
}
|
|
|
|
void updateNoiseControlMenu(int mode) {
|
|
QList<QAction *> actions = trayMenu->actions();
|
|
for (QAction *action : actions) {
|
|
action->setChecked(false);
|
|
}
|
|
switch (mode) {
|
|
case 0:
|
|
actions[0]->setChecked(true);
|
|
break;
|
|
case 1:
|
|
actions[3]->setChecked(true);
|
|
break;
|
|
case 2:
|
|
actions[1]->setChecked(true);
|
|
break;
|
|
case 3:
|
|
actions[2]->setChecked(true);
|
|
break;
|
|
}
|
|
}
|
|
|
|
void updateBatteryTooltip(const QString &status) {
|
|
trayIcon->setToolTip("Battery Status: " + status);
|
|
}
|
|
|
|
void updateTrayIcon(const QString &status) {
|
|
QStringList parts = status.split(", ");
|
|
int leftLevel = parts[0].split(": ")[1].replace("%", "").toInt();
|
|
int rightLevel = parts[1].split(": ")[1].replace("%", "").toInt();
|
|
int minLevel = qMin(leftLevel, rightLevel);
|
|
|
|
QPixmap pixmap(32, 32);
|
|
pixmap.fill(Qt::transparent);
|
|
|
|
QPainter painter(&pixmap);
|
|
QColor textColor = QApplication::palette().color(QPalette::WindowText);
|
|
painter.setPen(textColor);
|
|
painter.setFont(QFont("Arial", 12, QFont::Bold));
|
|
painter.drawText(pixmap.rect(), Qt::AlignCenter, QString::number(minLevel) + "%");
|
|
painter.end();
|
|
|
|
trayIcon->setIcon(QIcon(pixmap));
|
|
}
|
|
|
|
void handleEarDetection(const QString &status) {
|
|
static bool wasPausedByApp = false;
|
|
|
|
QStringList parts = status.split(", ");
|
|
bool primaryInEar = parts[0].contains("In Ear");
|
|
bool secondaryInEar = parts[1].contains("In Ear");
|
|
|
|
LOG_DEBUG("Ear detection status: primaryInEar=" << primaryInEar << ", secondaryInEar=" << secondaryInEar << isActiveOutputDeviceAirPods());
|
|
if (primaryInEar || secondaryInEar) {
|
|
LOG_INFO("At least one AirPod is in ear");
|
|
activateA2dpProfile();
|
|
} else {
|
|
LOG_INFO("Both AirPods are out of ear");
|
|
removeAudioOutputDevice();
|
|
}
|
|
|
|
if (primaryInEar && secondaryInEar) {
|
|
if (wasPausedByApp && isActiveOutputDeviceAirPods()) {
|
|
int result = QProcess::execute("playerctl", QStringList() << "play");
|
|
LOG_DEBUG("Executed 'playerctl play' with result: " << result);
|
|
if (result == 0) {
|
|
LOG_INFO("Resumed playback via Playerctl");
|
|
wasPausedByApp = false;
|
|
} else {
|
|
LOG_ERROR("Failed to resume playback via Playerctl");
|
|
}
|
|
}
|
|
} else {
|
|
if (isActiveOutputDeviceAirPods()) {
|
|
QProcess process;
|
|
process.start("playerctl", QStringList() << "status");
|
|
process.waitForFinished();
|
|
QString playbackStatus = process.readAllStandardOutput().trimmed();
|
|
LOG_DEBUG("Playback status: " << playbackStatus);
|
|
if (playbackStatus == "Playing") {
|
|
int result = QProcess::execute("playerctl", QStringList() << "pause");
|
|
LOG_DEBUG("Executed 'playerctl pause' with result: " << result);
|
|
if (result == 0) {
|
|
LOG_INFO("Paused playback via Playerctl");
|
|
wasPausedByApp = true;
|
|
} else {
|
|
LOG_ERROR("Failed to pause playback via Playerctl");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void activateA2dpProfile() {
|
|
LOG_INFO("Activating A2DP profile for AirPods");
|
|
int result = QProcess::execute("pactl", QStringList() << "set-card-profile" << "bluez_card." + connectedDeviceMacAddress.replace(":", "_") << "a2dp-sink");
|
|
if (result != 0) {
|
|
LOG_ERROR("Failed to activate A2DP profile");
|
|
}
|
|
}
|
|
|
|
void removeAudioOutputDevice() {
|
|
LOG_INFO("Removing AirPods as audio output device");
|
|
int result = QProcess::execute("pactl", QStringList() << "set-card-profile" << "bluez_card." + connectedDeviceMacAddress.replace(":", "_") << "off");
|
|
if (result != 0) {
|
|
LOG_ERROR("Failed to remove AirPods as audio output device");
|
|
}
|
|
}
|
|
|
|
bool loadConversationalAwarenessState() {
|
|
QFile file(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/ca_state.txt");
|
|
if (file.open(QIODevice::ReadOnly)) {
|
|
QTextStream in(&file);
|
|
QString state = in.readLine();
|
|
file.close();
|
|
return state == "true";
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void saveConversationalAwarenessState(bool state) {
|
|
QFile file(QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + "/ca_state.txt");
|
|
if (file.open(QIODevice::WriteOnly)) {
|
|
QTextStream out(&file);
|
|
out << (state ? "true" : "false");
|
|
file.close();
|
|
}
|
|
}
|
|
private slots:
|
|
void onTrayIconActivated(QSystemTrayIcon::ActivationReason reason) {
|
|
if (reason == QSystemTrayIcon::Trigger) {
|
|
LOG_INFO("Tray icon activated");
|
|
QQuickWindow *window = qobject_cast<QQuickWindow *>(
|
|
QGuiApplication::topLevelWindows().constFirst());
|
|
if (window) {
|
|
window->show();
|
|
window->raise();
|
|
window->requestActivate();
|
|
}
|
|
}
|
|
}
|
|
|
|
void onDeviceDiscovered(const QBluetoothDeviceInfo &device) {
|
|
QByteArray manufacturerData = device.manufacturerData(MANUFACTURER_ID);
|
|
if (manufacturerData.startsWith(MANUFACTURER_DATA)) {
|
|
LOG_INFO("Detected AirPods via BLE manufacturer data");
|
|
connectToDevice(device.address().toString());
|
|
}
|
|
LOG_INFO("Device discovered: " << device.name() << " (" << device.address().toString() << ")");
|
|
if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
|
|
LOG_DEBUG("Found AirPods device: " + device.name());
|
|
connectToDevice(device);
|
|
}
|
|
}
|
|
|
|
void onDiscoveryFinished() {
|
|
LOG_INFO("Device discovery finished");
|
|
discoveryAgent->start();
|
|
const QList<QBluetoothDeviceInfo> discoveredDevices = discoveryAgent->discoveredDevices();
|
|
for (const QBluetoothDeviceInfo &device : discoveredDevices) {
|
|
if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
|
|
connectToDevice(device);
|
|
return;
|
|
}
|
|
}
|
|
LOG_WARN("No device with the specified UUID found");
|
|
}
|
|
|
|
void onDeviceConnected(const QBluetoothAddress &address) {
|
|
LOG_INFO("Device connected: " << address.toString());
|
|
QBluetoothDeviceInfo device(address, "", 0);
|
|
if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
|
|
connectToDevice(device);
|
|
}
|
|
}
|
|
|
|
void onDeviceDisconnected(const QBluetoothAddress &address) {
|
|
LOG_INFO("Device disconnected: " << address.toString());
|
|
if (socket) {
|
|
LOG_WARN("Socket is still open, closing it");
|
|
socket->close();
|
|
socket = nullptr;
|
|
}
|
|
if (phoneSocket && phoneSocket->isOpen()) {
|
|
QByteArray airpodsDisconnectedPacket = QByteArray::fromHex("00010000");
|
|
phoneSocket->write(airpodsDisconnectedPacket);
|
|
LOG_DEBUG("AIRPODS_DISCONNECTED packet written: " << airpodsDisconnectedPacket.toHex());
|
|
}
|
|
}
|
|
|
|
void 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);
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
|
|
connect(localSocket, &QBluetoothSocket::readyRead, this, [this, localSocket]() {
|
|
QByteArray data = localSocket->readAll();
|
|
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(":", "_");
|
|
notifyAndroidDevice();
|
|
}
|
|
|
|
QString getEarStatus(char value)
|
|
{
|
|
return (value == 0x00) ? "In Ear" : (value == 0x01) ? "Out of Ear"
|
|
: "In case";
|
|
}
|
|
|
|
void parseData(const QByteArray &data) {
|
|
LOG_DEBUG("Received: " << data.toHex());
|
|
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"))) {
|
|
char primary = data[6];
|
|
char secondary = data[7];
|
|
QString earDetectionStatus = QString("Primary: %1, Secondary: %2")
|
|
.arg(getEarStatus(primary), getEarStatus(secondary));
|
|
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 && data.startsWith(QByteArray::fromHex("040004004B00020001"))) {
|
|
LOG_INFO("Received conversational awareness data");
|
|
handleConversationalAwareness(data);
|
|
}
|
|
}
|
|
|
|
void handleConversationalAwareness(const QByteArray &data) {
|
|
LOG_DEBUG("Handling conversational awareness data: " << data.toHex());
|
|
static int initialVolume = -1;
|
|
bool lowered = data[9] == 0x01;
|
|
LOG_INFO("Conversational awareness: " << (lowered ? "enabled" : "disabled"));
|
|
|
|
if (lowered) {
|
|
if (initialVolume == -1 && isActiveOutputDeviceAirPods()) {
|
|
QProcess process;
|
|
process.start("pactl", QStringList() << "get-sink-volume" << "@DEFAULT_SINK@");
|
|
process.waitForFinished();
|
|
QString output = process.readAllStandardOutput();
|
|
QRegularExpression re("front-left: \\d+ /\\s*(\\d+)%");
|
|
QRegularExpressionMatch match = re.match(output);
|
|
if (match.hasMatch()) {
|
|
LOG_DEBUG("Matched: " << match.captured(1));
|
|
initialVolume = match.captured(1).toInt();
|
|
} else {
|
|
LOG_ERROR("Failed to parse initial volume from output: " << output);
|
|
return;
|
|
}
|
|
}
|
|
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 && isActiveOutputDeviceAirPods()) {
|
|
QProcess::execute("pactl", QStringList() << "set-sink-volume" << "@DEFAULT_SINK@" << QString::number(initialVolume) + "%");
|
|
LOG_INFO("Volume restored to " << initialVolume << "%");
|
|
initialVolume = -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
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(connectedDeviceMacAddress.replace(":", "_"));
|
|
}
|
|
|
|
void initializeMprisInterface() {
|
|
QStringList services = QDBusConnection::sessionBus().interface()->registeredServiceNames();
|
|
QString mprisService;
|
|
|
|
foreach (const QString &service, services) {
|
|
if (service.startsWith("org.mpris.MediaPlayer2.") && service != "org.mpris.MediaPlayer2") {
|
|
mprisService = service;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!mprisService.isEmpty()) {
|
|
mprisInterface = new QDBusInterface(mprisService,
|
|
"/org/mpris/MediaPlayer2",
|
|
"org.mpris.MediaPlayer2.Player",
|
|
QDBusConnection::sessionBus(),
|
|
this);
|
|
if (!mprisInterface->isValid()) {
|
|
LOG_ERROR("Failed to initialize MPRIS interface for service: " << mprisService);
|
|
} else {
|
|
LOG_INFO("Connected to MPRIS service: " << mprisService);
|
|
}
|
|
} else {
|
|
LOG_WARN("No active MPRIS media players found");
|
|
}
|
|
connectToPhone();
|
|
}
|
|
|
|
void connectToPhone() {
|
|
if (phoneSocket && phoneSocket->isOpen()) {
|
|
LOG_INFO("Already connected to the phone");
|
|
return;
|
|
}
|
|
|
|
QBluetoothAddress phoneAddress(PHONE_MAC_ADDRESS);
|
|
phoneSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol);
|
|
connect(phoneSocket, &QBluetoothSocket::connected, this, [this]() {
|
|
LOG_INFO("Connected to phone");
|
|
if (!lastBatteryStatus.isEmpty()) {
|
|
phoneSocket->write(lastBatteryStatus);
|
|
LOG_DEBUG("Sent last battery status to phone: " << lastBatteryStatus.toHex());
|
|
}
|
|
if (!lastEarDetectionStatus.isEmpty()) {
|
|
phoneSocket->write(lastEarDetectionStatus);
|
|
LOG_DEBUG("Sent last ear detection status to phone: " << lastEarDetectionStatus.toHex());
|
|
}
|
|
});
|
|
|
|
connect(phoneSocket, QOverload<QBluetoothSocket::SocketError>::of(&QBluetoothSocket::errorOccurred), this, [this](QBluetoothSocket::SocketError error) {
|
|
LOG_ERROR("Phone socket error: " << error << ", " << phoneSocket->errorString());
|
|
});
|
|
|
|
phoneSocket->connectToService(phoneAddress, QBluetoothUuid("1abbb9a4-10e4-4000-a75c-8953c5471342"));
|
|
}
|
|
|
|
void relayPacketToPhone(const QByteArray &packet) {
|
|
if (phoneSocket && phoneSocket->isOpen()) {
|
|
QByteArray header = QByteArray::fromHex("00040001");
|
|
phoneSocket->write(header + packet);
|
|
} else {
|
|
connectToPhone();
|
|
LOG_WARN("Phone socket is not open, cannot relay packet");
|
|
}
|
|
}
|
|
|
|
void handlePhonePacket(const QByteArray &packet) {
|
|
if (packet.startsWith(QByteArray::fromHex("00040001"))) {
|
|
QByteArray airpodsPacket = packet.mid(4);
|
|
if (socket && socket->isOpen()) {
|
|
socket->write(airpodsPacket);
|
|
LOG_DEBUG("Relayed packet to AirPods: " << airpodsPacket.toHex());
|
|
} else {
|
|
LOG_ERROR("Socket is not open, cannot relay packet to AirPods");
|
|
}
|
|
} else if (packet.startsWith(QByteArray::fromHex("00010001"))) {
|
|
LOG_INFO("AirPods connected");
|
|
isConnectedLocally = true;
|
|
CrossDevice.isAvailable = false;
|
|
} else if (packet.startsWith(QByteArray::fromHex("00010000"))) {
|
|
LOG_INFO("AirPods disconnected");
|
|
isConnectedLocally = false;
|
|
CrossDevice.isAvailable = true;
|
|
} 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);
|
|
isConnectedLocally = false;
|
|
CrossDevice.isAvailable = true;
|
|
}
|
|
} else {
|
|
if (socket && socket->isOpen()) {
|
|
socket->write(packet);
|
|
LOG_DEBUG("Relayed packet to AirPods: " << packet.toHex());
|
|
} else {
|
|
LOG_ERROR("Socket is not open, cannot relay packet to AirPods");
|
|
}
|
|
}
|
|
}
|
|
|
|
void onPhoneDataReceived() {
|
|
QByteArray data = phoneSocket->readAll();
|
|
LOG_DEBUG("Data received from phone: " << data.toHex());
|
|
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 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]() {
|
|
QString output = playerctlProcess->readAllStandardOutput().trimmed();
|
|
LOG_DEBUG("Playerctl output: " << output);
|
|
if (output == "Playing" && isPhoneConnected()) {
|
|
LOG_INFO("Media started playing, sending disconnect request to Android and taking over audio");
|
|
sendDisconnectRequestToAndroid();
|
|
connectToAirPods(true);
|
|
}
|
|
});
|
|
playerctlProcess->start("playerctl", QStringList() << "--follow" << "status");
|
|
}
|
|
|
|
void sendDisconnectRequestToAndroid() {
|
|
if (phoneSocket && phoneSocket->isOpen()) {
|
|
QByteArray disconnectRequest = QByteArray::fromHex("00020000");
|
|
phoneSocket->write(disconnectRequest);
|
|
LOG_DEBUG("Sent disconnect request to Android: " << disconnectRequest.toHex());
|
|
} else {
|
|
LOG_WARN("Phone socket is not open, cannot send disconnect request");
|
|
}
|
|
}
|
|
|
|
bool isPhoneConnected() {
|
|
return phoneSocket && phoneSocket->isOpen();
|
|
}
|
|
|
|
void connectToAirPods(bool force) {
|
|
if (force) {
|
|
LOG_INFO("Forcing connection to AirPods");
|
|
QProcess process;
|
|
process.start("bluetoothctl", QStringList() << "connect" << connectedDeviceMacAddress.replace("_", ":"));
|
|
process.waitForFinished();
|
|
QString output = process.readAllStandardOutput().trimmed();
|
|
LOG_INFO("Bluetoothctl output: " << output);
|
|
if (output.contains("Connection successful")) {
|
|
LOG_INFO("Connection successful, proceeding with L2CAP connection");
|
|
QBluetoothAddress btAddress(connectedDeviceMacAddress.replace("_", ":"));
|
|
forceL2capConnection(btAddress);
|
|
} else {
|
|
LOG_ERROR("Connection failed, cannot proceed with L2CAP connection");
|
|
}
|
|
}
|
|
QBluetoothLocalDevice localDevice;
|
|
const QList<QBluetoothAddress> connectedDevices = localDevice.connectedDevices();
|
|
for (const QBluetoothAddress &address : connectedDevices) {
|
|
QBluetoothDeviceInfo device(address, "", 0);
|
|
LOG_DEBUG("Connected device: " << device.name() << " (" << device.address().toString() << ")");
|
|
if (device.serviceUuids().contains(QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
|
|
connectToDevice(device);
|
|
return;
|
|
}
|
|
}
|
|
LOG_WARN("AirPods not found among connected devices");
|
|
}
|
|
|
|
void forceL2capConnection(const QBluetoothAddress &address) {
|
|
LOG_INFO("Retrying L2CAP connection for up to 10 seconds...");
|
|
QBluetoothDeviceInfo device(address, "", 0);
|
|
QElapsedTimer timer;
|
|
timer.start();
|
|
while (timer.elapsed() < 10000) {
|
|
QProcess bcProcess;
|
|
bcProcess.start("bluetoothctl", QStringList() << "connect" << address.toString());
|
|
bcProcess.waitForFinished();
|
|
QString output = bcProcess.readAllStandardOutput().trimmed();
|
|
LOG_INFO("Bluetoothctl output: " << output);
|
|
if (output.contains("Connection successful")) {
|
|
connectToDevice(device);
|
|
QThread::sleep(1);
|
|
if (socket && socket->isOpen()) {
|
|
LOG_INFO("Successfully connected to device: " << address.toString());
|
|
return;
|
|
}
|
|
} else {
|
|
LOG_WARN("Connection attempt failed, retrying...");
|
|
}
|
|
}
|
|
LOG_ERROR("Failed to connect to device within 10 seconds: " << address.toString());
|
|
}
|
|
|
|
void initializeBluetooth() {
|
|
connectToPhone();
|
|
}
|
|
|
|
signals:
|
|
void noiseControlModeChanged(int mode);
|
|
void earDetectionStatusChanged(const QString &status);
|
|
void batteryStatusChanged(const QString &status);
|
|
|
|
private:
|
|
QSystemTrayIcon *trayIcon;
|
|
QMenu *trayMenu;
|
|
QBluetoothDeviceDiscoveryAgent *discoveryAgent;
|
|
QBluetoothSocket *socket = nullptr;
|
|
QBluetoothSocket *phoneSocket = nullptr;
|
|
QDBusInterface *mprisInterface;
|
|
QString connectedDeviceMacAddress;
|
|
QByteArray lastBatteryStatus;
|
|
QByteArray lastEarDetectionStatus;
|
|
};
|
|
|
|
int main(int argc, char *argv[]) {
|
|
QApplication app(argc, argv);
|
|
|
|
bool debugMode = false;
|
|
for (int i = 1; i < argc; ++i) {
|
|
if (QString(argv[i]) == "--debug") {
|
|
debugMode = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
QQmlApplicationEngine engine;
|
|
AirPodsTrayApp trayApp(debugMode);
|
|
engine.rootContext()->setContextProperty("airPodsTrayApp", &trayApp);
|
|
engine.loadFromModule("linux", "Main");
|
|
|
|
QObject::connect(&trayApp, &AirPodsTrayApp::noiseControlModeChanged, &engine, [&engine](int mode) {
|
|
QObject *rootObject = engine.rootObjects().constFirst();
|
|
|
|
if (rootObject) {
|
|
QObject *noiseControlMode = rootObject->findChild<QObject*>("noiseControlMode");
|
|
if (noiseControlMode) {
|
|
if (mode >= 0 && mode <= 3) {
|
|
QMetaObject::invokeMethod(noiseControlMode, "setCurrentIndex", Q_ARG(int, mode));
|
|
} else {
|
|
LOG_ERROR("Invalid mode value: " << mode);
|
|
}
|
|
}
|
|
} else {
|
|
LOG_ERROR("Root object not found");
|
|
}
|
|
});
|
|
|
|
QObject::connect(&trayApp, &AirPodsTrayApp::earDetectionStatusChanged, [&engine](const QString &status) {
|
|
QObject *rootObject = engine.rootObjects().first();
|
|
if (rootObject) {
|
|
QObject *earDetectionStatus = rootObject->findChild<QObject*>("earDetectionStatus");
|
|
if (earDetectionStatus) {
|
|
earDetectionStatus->setProperty("text", "Ear Detection Status: " + status);
|
|
}
|
|
} else {
|
|
LOG_ERROR("Root object not found");
|
|
}
|
|
});
|
|
|
|
QObject::connect(&trayApp, &AirPodsTrayApp::batteryStatusChanged, [&engine](const QString &status) {
|
|
QObject *rootObject = engine.rootObjects().first();
|
|
if (rootObject) {
|
|
QObject *batteryStatus = rootObject->findChild<QObject*>("batteryStatus");
|
|
if (batteryStatus) {
|
|
batteryStatus->setProperty("text", "Battery Status: " + status);
|
|
}
|
|
} else {
|
|
LOG_ERROR("Root object not found");
|
|
}
|
|
});
|
|
|
|
trayApp.followMediaChanges();
|
|
|
|
return app.exec();
|
|
}
|
|
|
|
|
|
#include "main.moc" |