From bf941a69bd9b76c613e3f9afbb19f1000f1c0465 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 11 Apr 2026 07:45:21 -0300 Subject: [PATCH 1/5] fix(linux): replace Qt Bluetooth with raw L2CAP socket and fix disconnect handling - Implement L2CAPSocket using raw POSIX sockets (AF_BLUETOOTH/BTPROTO_L2CAP) to bypass BlueZ DBus PSM limitations that caused ServiceNotFoundError - Separate airpodsConnected (UI presence) from airpodsCommandReady (control socket) to prevent false Disconnected state when A2DP audio is still active - Emit airPodsStatusChanged after A2DP activation so UI reflects actual state - Fix double class qualifier in TrayIconManager::updateBatteryStatus --- linux/airpods_packets.h | 95 ++++ linux/deviceinfo.hpp | 177 +++++++- linux/main.cpp | 927 +++++++++++++++++++++++++++++++++++--- linux/trayiconmanager.cpp | 50 +- linux/trayiconmanager.h | 6 +- 5 files changed, 1167 insertions(+), 88 deletions(-) diff --git a/linux/airpods_packets.h b/linux/airpods_packets.h index 94153a484..ad5e9e032 100644 --- a/linux/airpods_packets.h +++ b/linux/airpods_packets.h @@ -238,6 +238,101 @@ namespace AirPodsPackets } } + // Case Charging Sounds (AirPods Pro 2 / AirPods 4 only) + namespace CaseChargingSounds + { + static QByteArray getPacket(bool enabled) + { + // 12 3A 00 01 00 08 [00=On, 01=Off] + QByteArray packet = QByteArray::fromHex("123A00010008"); + packet.append(enabled ? static_cast(0x00) : static_cast(0x01)); + return packet; + } + } + + // Stem Long Press Configuration + // Bitmask: bit0=Off(0x01), bit1=ANC(0x02), bit2=Transparency(0x04), bit3=Adaptive(0x08) + // Min 2 bits must be set. Must be re-sent on every connection. + namespace StemLongPress + { + static const QByteArray HEADER = ControlCommand::HEADER + static_cast(0x1A); + static const quint8 BIT_OFF = 0x01; + static const quint8 BIT_ANC = 0x02; + static const quint8 BIT_TRANSPARENCY = 0x04; + static const quint8 BIT_ADAPTIVE = 0x08; + + static QByteArray getPacket(quint8 modes) + { + return ControlCommand::createCommand(0x1A, modes); + } + + inline std::optional parseModes(const QByteArray &data) + { + if (!data.startsWith(HEADER) || data.size() < 8) + return std::nullopt; + return static_cast(data.at(7)); + } + } + + // Customize Transparency Mode (per-bud EQ + parameters as IEEE 754 floats LE) + namespace CustomizeTransparency + { + static const QByteArray HEADER = QByteArray::fromHex("121800"); + + struct BudSettings { + float eq[8] = {0,0,0,0,0,0,0,0}; // 0-100 + float amplification = 0.0f; // 0-2 + float tone = 0.0f; // 0-2 + float conversationBoost = 0.0f; // 0 or 1 + float ambientNoise = 0.0f; // 0-1 + }; + + static QByteArray getPacket(bool enabled, const BudSettings &left, const BudSettings &right) + { + QByteArray packet = HEADER; + + auto appendF = [&](float f) { + char bytes[4]; + memcpy(bytes, &f, 4); + packet.append(bytes, 4); + }; + + appendF(enabled ? 1.0f : 0.0f); + + for (const BudSettings *b : {&left, &right}) { + for (int i = 0; i < 8; i++) appendF(b->eq[i]); + appendF(b->amplification); + appendF(b->tone); + appendF(b->conversationBoost); + appendF(b->ambientNoise); + } + + return packet; + } + } + + // Headphone Accommodation (8-band EQ for Phone/Media, uint16 LE per band, triplicated) + namespace HeadphoneAccommodation + { + static QByteArray getPacket(bool phoneEnabled, bool mediaEnabled, const QList &eq8) + { + QByteArray packet = QByteArray::fromHex("04000400530084000202"); + packet.append(phoneEnabled ? static_cast(0x01) : static_cast(0x02)); + packet.append(mediaEnabled ? static_cast(0x01) : static_cast(0x02)); + + QByteArray eqBytes; + for (int i = 0; i < 8; i++) { + quint16 v = static_cast((i < eq8.size()) ? eq8[i] : 0); + eqBytes.append(static_cast(v & 0xFF)); + eqBytes.append(static_cast((v >> 8) & 0xFF)); + } + packet.append(eqBytes); + packet.append(eqBytes); + packet.append(eqBytes); + return packet; + } + } + // Parsing Headers namespace Parse { diff --git a/linux/deviceinfo.hpp b/linux/deviceinfo.hpp index a3ac8affd..c74c5db65 100644 --- a/linux/deviceinfo.hpp +++ b/linux/deviceinfo.hpp @@ -13,7 +13,9 @@ class DeviceInfo : public QObject { Q_OBJECT Q_PROPERTY(QString batteryStatus READ batteryStatus WRITE setBatteryStatus NOTIFY batteryStatusChanged) + Q_PROPERTY(QString earStatusSummary READ earStatusSummary NOTIFY primaryChanged) Q_PROPERTY(int noiseControlMode READ noiseControlModeInt WRITE setNoiseControlModeInt NOTIFY noiseControlModeChangedInt) + Q_PROPERTY(QString noiseControlLabel READ noiseControlLabel NOTIFY noiseControlModeChangedInt) Q_PROPERTY(bool conversationalAwareness READ conversationalAwareness WRITE setConversationalAwareness NOTIFY conversationalAwarenessChanged) Q_PROPERTY(bool hearingAidEnabled READ hearingAidEnabled WRITE setHearingAidEnabled NOTIFY hearingAidEnabledChanged) Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged) @@ -27,8 +29,19 @@ class DeviceInfo : public QObject Q_PROPERTY(bool leftPodInEar READ isLeftPodInEar NOTIFY primaryChanged) Q_PROPERTY(bool rightPodInEar READ isRightPodInEar NOTIFY primaryChanged) Q_PROPERTY(QString bluetoothAddress READ bluetoothAddress WRITE setBluetoothAddress NOTIFY bluetoothAddressChanged) - Q_PROPERTY(QString magicAccIRK READ magicAccIRKHex CONSTANT) - Q_PROPERTY(QString magicAccEncKey READ magicAccEncKeyHex CONSTANT) + Q_PROPERTY(QString magicAccIRK READ magicAccIRKHex NOTIFY magicCloudKeysChanged) + Q_PROPERTY(QString magicAccEncKey READ magicAccEncKeyHex NOTIFY magicCloudKeysChanged) + Q_PROPERTY(bool hasMagicCloudKeys READ hasMagicCloudKeys NOTIFY magicCloudKeysChanged) + // New features + Q_PROPERTY(bool allowOffOption READ allowOffOption WRITE setAllowOffOption NOTIFY allowOffOptionChanged) + Q_PROPERTY(bool volumeSwipeEnabled READ volumeSwipeEnabled WRITE setVolumeSwipeEnabled NOTIFY volumeSwipeEnabledChanged) + Q_PROPERTY(int volumeSwipeInterval READ volumeSwipeInterval WRITE setVolumeSwipeInterval NOTIFY volumeSwipeIntervalChanged) + Q_PROPERTY(bool adaptiveVolumeEnabled READ adaptiveVolumeEnabled WRITE setAdaptiveVolumeEnabled NOTIFY adaptiveVolumeEnabledChanged) + Q_PROPERTY(bool caseChargingSoundsEnabled READ caseChargingSoundsEnabled WRITE setCaseChargingSoundsEnabled NOTIFY caseChargingSoundsEnabledChanged) + Q_PROPERTY(int stemLongPressModes READ stemLongPressModes WRITE setStemLongPressModes NOTIFY stemLongPressModesChanged) + Q_PROPERTY(bool customizeTransparencyEnabled READ customizeTransparencyEnabled WRITE setCustomizeTransparencyEnabled NOTIFY customizeTransparencyEnabledChanged) + Q_PROPERTY(bool headphoneAccomPhoneEnabled READ headphoneAccomPhoneEnabled WRITE setHeadphoneAccomPhoneEnabled NOTIFY headphoneAccomChanged) + Q_PROPERTY(bool headphoneAccomMediaEnabled READ headphoneAccomMediaEnabled WRITE setHeadphoneAccomMediaEnabled NOTIFY headphoneAccomChanged) public: explicit DeviceInfo(QObject *parent = nullptr) : QObject(parent), m_battery(new Battery(this)), m_earDetection(new EarDetection(this)) { @@ -57,6 +70,22 @@ class DeviceInfo : public QObject } int noiseControlModeInt() const { return static_cast(noiseControlMode()); } void setNoiseControlModeInt(int mode) { setNoiseControlMode(static_cast(mode)); } + QString noiseControlLabel() const + { + switch (noiseControlMode()) + { + case NoiseControlMode::Off: + return QStringLiteral("Off"); + case NoiseControlMode::NoiseCancellation: + return QStringLiteral("Noise Cancellation"); + case NoiseControlMode::Transparency: + return QStringLiteral("Transparency"); + case NoiseControlMode::Adaptive: + return QStringLiteral("Adaptive"); + } + + return QStringLiteral("Unknown"); + } bool conversationalAwareness() const { return m_conversationalAwareness; } void setConversationalAwareness(bool enabled) @@ -121,12 +150,27 @@ class DeviceInfo : public QObject } QByteArray magicAccIRK() const { return m_magicAccIRK; } - void setMagicAccIRK(const QByteArray &irk) { m_magicAccIRK = irk; } + void setMagicAccIRK(const QByteArray &irk) + { + if (m_magicAccIRK != irk) + { + m_magicAccIRK = irk; + emit magicCloudKeysChanged(); + } + } QString magicAccIRKHex() const { return QString::fromUtf8(m_magicAccIRK.toHex()); } QByteArray magicAccEncKey() const { return m_magicAccEncKey; } - void setMagicAccEncKey(const QByteArray &key) { m_magicAccEncKey = key; } + void setMagicAccEncKey(const QByteArray &key) + { + if (m_magicAccEncKey != key) + { + m_magicAccEncKey = key; + emit magicCloudKeysChanged(); + } + } QString magicAccEncKeyHex() const { return QString::fromUtf8(m_magicAccEncKey.toHex()); } + bool hasMagicCloudKeys() const { return m_magicAccIRK.size() == 16 && m_magicAccEncKey.size() == 16; } QString modelNumber() const { return m_modelNumber; } void setModelNumber(const QString &modelNumber) { m_modelNumber = modelNumber; } @@ -156,6 +200,42 @@ class DeviceInfo : public QObject if (getBattery()->getPrimaryPod() == Battery::Component::Right) return getEarDetection()->isPrimaryInEar(); else return getEarDetection()->isSecondaryInEar(); } + QString earStatusSummary() const + { + auto formatStatus = [](EarDetection::EarDetectionStatus status) -> QString + { + switch (status) + { + case EarDetection::EarDetectionStatus::InEar: + return QStringLiteral("in ear"); + case EarDetection::EarDetectionStatus::NotInEar: + return QStringLiteral("out of ear"); + case EarDetection::EarDetectionStatus::InCase: + return QStringLiteral("in case"); + case EarDetection::EarDetectionStatus::Disconnected: + default: + return QStringLiteral("disconnected"); + } + }; + + const EarDetection::EarDetectionStatus leftStatus = + getBattery()->getPrimaryPod() == Battery::Component::Left + ? getEarDetection()->getprimaryStatus() + : getEarDetection()->getsecondaryStatus(); + const EarDetection::EarDetectionStatus rightStatus = + getBattery()->getPrimaryPod() == Battery::Component::Right + ? getEarDetection()->getprimaryStatus() + : getEarDetection()->getsecondaryStatus(); + + if (leftStatus == EarDetection::EarDetectionStatus::Disconnected && + rightStatus == EarDetection::EarDetectionStatus::Disconnected) + { + return QStringLiteral("Waiting for live status from AirPods"); + } + + return QStringLiteral("Left %1, Right %2") + .arg(formatStatus(leftStatus), formatStatus(rightStatus)); + } bool adaptiveModeActive() const { return noiseControlMode() == NoiseControlMode::Adaptive; } @@ -194,17 +274,71 @@ class DeviceInfo : public QObject void updateBatteryStatus() { - int leftLevel = getBattery()->getState(Battery::Component::Left).level; - int rightLevel = getBattery()->getState(Battery::Component::Right).level; - int caseLevel = getBattery()->getState(Battery::Component::Case).level; - if (getBattery()->getPrimaryPod() == Battery::Component::Headset) { - int headsetLevel = getBattery()->getState(Battery::Component::Headset).level; - setBatteryStatus(QString("Headset: %1%").arg(headsetLevel)); - } else { - setBatteryStatus(QString("Left: %1%, Right: %2%, Case: %3%").arg(leftLevel).arg(rightLevel).arg(caseLevel)); + QStringList parts; + + if (getBattery()->getPrimaryPod() == Battery::Component::Headset) + { + if (getBattery()->isHeadsetAvailable()) + { + parts << QStringLiteral("Headset: %1%").arg(getBattery()->getState(Battery::Component::Headset).level); + } + } + else + { + if (getBattery()->isLeftPodAvailable()) + { + parts << QStringLiteral("Left: %1%").arg(getBattery()->getState(Battery::Component::Left).level); + } + + if (getBattery()->isRightPodAvailable()) + { + parts << QStringLiteral("Right: %1%").arg(getBattery()->getState(Battery::Component::Right).level); + } + } + + if (getBattery()->isCaseAvailable()) + { + parts << QStringLiteral("Case: %1%").arg(getBattery()->getState(Battery::Component::Case).level); + } + + if (parts.isEmpty()) + { + setBatteryStatus(QStringLiteral("Battery status unavailable")); + } + else + { + setBatteryStatus(parts.join(QStringLiteral(", "))); } } + // New feature getters/setters + bool allowOffOption() const { return m_allowOffOption; } + void setAllowOffOption(bool v) { if (m_allowOffOption != v) { m_allowOffOption = v; emit allowOffOptionChanged(v); } } + + bool volumeSwipeEnabled() const { return m_volumeSwipeEnabled; } + void setVolumeSwipeEnabled(bool v) { if (m_volumeSwipeEnabled != v) { m_volumeSwipeEnabled = v; emit volumeSwipeEnabledChanged(v); } } + + int volumeSwipeInterval() const { return m_volumeSwipeInterval; } + void setVolumeSwipeInterval(int v) { if (m_volumeSwipeInterval != v) { m_volumeSwipeInterval = v; emit volumeSwipeIntervalChanged(v); } } + + bool adaptiveVolumeEnabled() const { return m_adaptiveVolumeEnabled; } + void setAdaptiveVolumeEnabled(bool v) { if (m_adaptiveVolumeEnabled != v) { m_adaptiveVolumeEnabled = v; emit adaptiveVolumeEnabledChanged(v); } } + + bool caseChargingSoundsEnabled() const { return m_caseChargingSoundsEnabled; } + void setCaseChargingSoundsEnabled(bool v) { if (m_caseChargingSoundsEnabled != v) { m_caseChargingSoundsEnabled = v; emit caseChargingSoundsEnabledChanged(v); } } + + int stemLongPressModes() const { return m_stemLongPressModes; } + void setStemLongPressModes(int v) { if (m_stemLongPressModes != v) { m_stemLongPressModes = v; emit stemLongPressModesChanged(v); } } + + bool customizeTransparencyEnabled() const { return m_customizeTransparencyEnabled; } + void setCustomizeTransparencyEnabled(bool v) { if (m_customizeTransparencyEnabled != v) { m_customizeTransparencyEnabled = v; emit customizeTransparencyEnabledChanged(v); } } + + bool headphoneAccomPhoneEnabled() const { return m_headphoneAccomPhoneEnabled; } + void setHeadphoneAccomPhoneEnabled(bool v) { if (m_headphoneAccomPhoneEnabled != v) { m_headphoneAccomPhoneEnabled = v; emit headphoneAccomChanged(); } } + + bool headphoneAccomMediaEnabled() const { return m_headphoneAccomMediaEnabled; } + void setHeadphoneAccomMediaEnabled(bool v) { if (m_headphoneAccomMediaEnabled != v) { m_headphoneAccomMediaEnabled = v; emit headphoneAccomChanged(); } } + signals: void batteryStatusChanged(const QString &status); void noiseControlModeChanged(NoiseControlMode mode); @@ -217,6 +351,15 @@ class DeviceInfo : public QObject void oneBudANCModeChanged(bool enabled); void modelChanged(); void bluetoothAddressChanged(const QString &address); + void magicCloudKeysChanged(); + void allowOffOptionChanged(bool enabled); + void volumeSwipeEnabledChanged(bool enabled); + void volumeSwipeIntervalChanged(int interval); + void adaptiveVolumeEnabledChanged(bool enabled); + void caseChargingSoundsEnabledChanged(bool enabled); + void stemLongPressModesChanged(int modes); + void customizeTransparencyEnabledChanged(bool enabled); + void headphoneAccomChanged(); private: QString m_batteryStatus; @@ -234,4 +377,14 @@ class DeviceInfo : public QObject QString m_manufacturer; QString m_bluetoothAddress; EarDetection *m_earDetection; + // New features + bool m_allowOffOption = false; + bool m_volumeSwipeEnabled = false; + int m_volumeSwipeInterval = 30; + bool m_adaptiveVolumeEnabled = false; + bool m_caseChargingSoundsEnabled = true; + int m_stemLongPressModes = 0x06; // ANC + Transparency by default + bool m_customizeTransparencyEnabled = false; + bool m_headphoneAccomPhoneEnabled = false; + bool m_headphoneAccomMediaEnabled = false; }; diff --git a/linux/main.cpp b/linux/main.cpp index 7b1826b49..91b197cd5 100644 --- a/linux/main.cpp +++ b/linux/main.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include "airpods_packets.h" @@ -31,13 +32,146 @@ #include "QRCodeImageProvider.hpp" #include "systemsleepmonitor.hpp" +#include +#include +#include +#include +#include +#include + using namespace AirpodsTrayApp::Enums; Q_LOGGING_CATEGORY(librepods, "librepods") +// Raw L2CAP socket wrapper that connects directly by PSM, bypassing BlueZ DBus SDP lookup +class L2CAPSocket : public QObject { + Q_OBJECT +public: + explicit L2CAPSocket(QObject *parent = nullptr) : QObject(parent) {} + ~L2CAPSocket() { doClose(); } + + bool isOpen() const { return m_fd >= 0; } + QBluetoothAddress peerAddress() const { return m_peerAddress; } + QString errorString() const { return m_errorString; } + + void connectToService(const QBluetoothAddress &address, quint16 psm) { + doClose(); + m_peerAddress = address; + + m_fd = ::socket(AF_BLUETOOTH, SOCK_SEQPACKET, BTPROTO_L2CAP); + if (m_fd < 0) { + m_errorString = QString::fromUtf8(strerror(errno)); + QTimer::singleShot(0, this, [this]() { emit errorOccurred(); }); + return; + } + + int flags = fcntl(m_fd, F_GETFL, 0); + fcntl(m_fd, F_SETFL, flags | O_NONBLOCK); + + struct sockaddr_l2 addr = {}; + addr.l2_family = AF_BLUETOOTH; + addr.l2_psm = htobs(psm); + + const QString macStr = address.toString(); + const QStringList parts = macStr.split(QLatin1Char(':')); + if (parts.size() == 6) { + for (int i = 0; i < 6; i++) + addr.l2_bdaddr.b[i] = static_cast(parts[5 - i].toUInt(nullptr, 16)); + } + + const int ret = ::connect(m_fd, reinterpret_cast(&addr), sizeof(addr)); + if (ret == 0) { + setupReadNotifier(); + emit connected(); + } else if (errno == EINPROGRESS) { + m_writeNotifier = new QSocketNotifier(m_fd, QSocketNotifier::Write, this); + connect(m_writeNotifier, &QSocketNotifier::activated, this, &L2CAPSocket::onWriteReady); + } else { + m_errorString = QString::fromUtf8(strerror(errno)); + doClose(); + QTimer::singleShot(0, this, [this]() { emit errorOccurred(); }); + } + } + + qint64 write(const QByteArray &data) { + if (m_fd < 0) return -1; + return static_cast(::write(m_fd, data.constData(), static_cast(data.size()))); + } + + QByteArray readAll() { + if (m_fd < 0) return {}; + QByteArray result; + char buf[4096]; + ssize_t n; + while ((n = ::recv(m_fd, buf, sizeof(buf), MSG_DONTWAIT)) > 0) + result.append(buf, static_cast(n)); + return result; + } + + void close() { doClose(); } + +signals: + void connected(); + void disconnected(); + void readyRead(); + void errorOccurred(); + +private slots: + void onWriteReady() { + delete m_writeNotifier; + m_writeNotifier = nullptr; + + int err = 0; + socklen_t errLen = sizeof(err); + getsockopt(m_fd, SOL_SOCKET, SO_ERROR, &err, &errLen); + + if (err != 0) { + m_errorString = QString::fromUtf8(strerror(err)); + doClose(); + emit errorOccurred(); + return; + } + + setupReadNotifier(); + emit connected(); + } + + void onReadReady() { + char buf[1]; + const ssize_t n = ::recv(m_fd, buf, 1, MSG_PEEK | MSG_DONTWAIT); + if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK)) { + doClose(); + emit disconnected(); + return; + } + emit readyRead(); + } + +private: + void setupReadNotifier() { + m_readNotifier = new QSocketNotifier(m_fd, QSocketNotifier::Read, this); + connect(m_readNotifier, &QSocketNotifier::activated, this, &L2CAPSocket::onReadReady); + } + + void doClose() { + delete m_readNotifier; + m_readNotifier = nullptr; + delete m_writeNotifier; + m_writeNotifier = nullptr; + if (m_fd >= 0) { ::close(m_fd); m_fd = -1; } + } + + int m_fd = -1; + QSocketNotifier *m_readNotifier = nullptr; + QSocketNotifier *m_writeNotifier = nullptr; + QBluetoothAddress m_peerAddress; + QString m_errorString; +}; + class AirPodsTrayApp : public QObject { Q_OBJECT - Q_PROPERTY(bool airpodsConnected READ areAirpodsConnected NOTIFY airPodsStatusChanged) + Q_PROPERTY(bool airpodsConnected READ isAirpodsAvailable NOTIFY airPodsStatusChanged) + Q_PROPERTY(bool airpodsCommandReady READ airpodsCommandReady NOTIFY airPodsCommandReadyChanged) Q_PROPERTY(int earDetectionBehavior READ earDetectionBehavior WRITE setEarDetectionBehavior NOTIFY earDetectionBehaviorChanged) Q_PROPERTY(bool crossDeviceEnabled READ crossDeviceEnabled WRITE setCrossDeviceEnabled NOTIFY crossDeviceEnabledChanged) Q_PROPERTY(AutoStartManager *autoStartManager READ autoStartManager CONSTANT) @@ -46,7 +180,10 @@ class AirPodsTrayApp : public QObject { Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT) Q_PROPERTY(DeviceInfo *deviceInfo READ deviceInfo CONSTANT) Q_PROPERTY(QString phoneMacStatus READ phoneMacStatus NOTIFY phoneMacStatusChanged) + Q_PROPERTY(bool phoneConnected READ isPhoneConnected NOTIFY phoneConnectionChanged) Q_PROPERTY(bool hearingAidEnabled READ hearingAidEnabled WRITE setHearingAidEnabled NOTIFY hearingAidEnabledChanged) + Q_PROPERTY(QString hearingAidSetupStatus READ hearingAidSetupStatus NOTIFY hearingAidSetupStatusChanged) + Q_PROPERTY(QString headTrackingStatus READ headTrackingStatus NOTIFY headTrackingStatusChanged) public: AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr) @@ -67,10 +204,17 @@ class AirPodsTrayApp : public QObject { connect(trayManager, &TrayIconManager::noiseControlChanged, this, &AirPodsTrayApp::setNoiseControlMode); connect(trayManager, &TrayIconManager::conversationalAwarenessToggled, this, &AirPodsTrayApp::setConversationalAwareness); connect(m_deviceInfo, &DeviceInfo::batteryStatusChanged, trayManager, &TrayIconManager::updateBatteryStatus); + connect(m_deviceInfo, &DeviceInfo::bluetoothAddressChanged, this, [this]() { updateHearingAidSetupStatus(); }); + connect(m_deviceInfo, &DeviceInfo::bluetoothAddressChanged, this, [this]() { updateHeadTrackingStatus(); }); connect(m_deviceInfo, &DeviceInfo::noiseControlModeChanged, trayManager, &TrayIconManager::updateNoiseControlState); connect(m_deviceInfo, &DeviceInfo::conversationalAwarenessChanged, trayManager, &TrayIconManager::updateConversationalAwareness); + connect(this, &AirPodsTrayApp::airPodsCommandReadyChanged, this, [this]() { + trayManager->setAirPodsControlsEnabled(airpodsCommandReady()); + }); connect(trayManager, &TrayIconManager::notificationsEnabledChanged, this, &AirPodsTrayApp::saveNotificationsEnabled); connect(trayManager, &TrayIconManager::notificationsEnabledChanged, this, &AirPodsTrayApp::notificationsEnabledChanged); + connect(this, &AirPodsTrayApp::airPodsStatusChanged, this, [this]() { updateHearingAidSetupStatus(); }); + connect(this, &AirPodsTrayApp::airPodsStatusChanged, this, [this]() { updateHeadTrackingStatus(); }); // Initialize MediaController and connect signals mediaController = new MediaController(this); @@ -109,6 +253,7 @@ class AirPodsTrayApp : public QObject { mediaController->setConnectedDeviceMacAddress(formattedAddress); mediaController->activateA2dpProfile(); LOG_INFO("A2DP profile activation attempted for AirPods found on startup"); + emit airPodsStatusChanged(); }); return; } @@ -116,6 +261,9 @@ class AirPodsTrayApp : public QObject { initializeDBus(); initializeBluetooth(); + updateHearingAidSetupStatus(); + updateHeadTrackingStatus(); + trayManager->setAirPodsControlsEnabled(airpodsCommandReady()); } ~AirPodsTrayApp() { @@ -126,7 +274,15 @@ class AirPodsTrayApp : public QObject { delete phoneSocket; } - bool areAirpodsConnected() const { return socket && socket->isOpen() && socket->state() == QBluetoothSocket::SocketState::ConnectedState; } + bool areAirpodsConnected() const { return socket && socket->isOpen(); } + bool isAirpodsAudioConnected() const + { + return mediaController && + !m_deviceInfo->bluetoothAddress().isEmpty() && + mediaController->isActiveOutputDeviceAirPods(); + } + bool isAirpodsAvailable() const { return areAirpodsConnected() || isAirpodsAudioConnected(); } + bool airpodsCommandReady() const { return areAirpodsConnected() && m_airPodsCommandReady; } int earDetectionBehavior() const { return mediaController->getEarDetectionBehavior(); } bool crossDeviceEnabled() const { return CrossDevice.isEnabled; } AutoStartManager *autoStartManager() const { return m_autoStartManager; } @@ -136,9 +292,242 @@ class AirPodsTrayApp : public QObject { bool hideOnStart() const { return m_hideOnStart; } DeviceInfo *deviceInfo() const { return m_deviceInfo; } QString phoneMacStatus() const { return m_phoneMacStatus; } + bool isPhoneConnected() const { return phoneSocket && phoneSocket->isOpen(); } bool hearingAidEnabled() const { return m_deviceInfo->hearingAidEnabled(); } + QString hearingAidSetupStatus() const { return m_hearingAidSetupStatus; } + QString headTrackingStatus() const { return m_headTrackingStatus; } private: + struct TerminalLauncher + { + QString executable; + QStringList argumentsPrefix; + }; + + QString findHeadTrackingScript() const + { + const QString appDir = QCoreApplication::applicationDirPath(); + const QStringList candidates = { + QDir(appDir).filePath(QStringLiteral("head-tracking/gestures.py")), + QDir(appDir).filePath(QStringLiteral("../head-tracking/gestures.py")), + QDir(appDir).filePath(QStringLiteral("../share/librepods/head-tracking/gestures.py")), + QStringLiteral("/usr/share/librepods/head-tracking/gestures.py"), + QStringLiteral("/usr/local/share/librepods/head-tracking/gestures.py") + }; + + for (const QString &candidate : candidates) + { + const QString normalizedPath = QDir::cleanPath(candidate); + if (QFileInfo::exists(normalizedPath)) + { + return normalizedPath; + } + } + + return QString(); + } + + QString findHearingAidAdjustmentsScript() const + { + const QString appDir = QCoreApplication::applicationDirPath(); + const QStringList candidates = { + QDir(appDir).filePath(QStringLiteral("hearing-aid-adjustments.py")), + QDir(appDir).filePath(QStringLiteral("../hearing-aid-adjustments.py")), + QDir(appDir).filePath(QStringLiteral("../share/librepods/hearing-aid-adjustments.py")), + QStringLiteral("/usr/share/librepods/hearing-aid-adjustments.py"), + QStringLiteral("/usr/local/share/librepods/hearing-aid-adjustments.py") + }; + + for (const QString &candidate : candidates) + { + const QString normalizedPath = QDir::cleanPath(candidate); + if (QFileInfo::exists(normalizedPath)) + { + return normalizedPath; + } + } + + return QString(); + } + + QString normalizedPhoneMac() const + { + const QString rawMac = QString::fromUtf8(qgetenv("PHONE_MAC_ADDRESS")).trimmed(); + if (rawMac.isEmpty()) + { + return QString(); + } + + QString cleanedMac = rawMac; + cleanedMac.remove(QRegularExpression(QStringLiteral("[^0-9A-Fa-f]"))); + if (cleanedMac.size() != 12) + { + return QString(); + } + + QStringList octets; + octets.reserve(6); + for (int index = 0; index < cleanedMac.size(); index += 2) + { + octets << cleanedMac.mid(index, 2).toUpper(); + } + + const QString normalizedMac = octets.join(QLatin1Char(':')); + const QBluetoothAddress address(normalizedMac); + if (address.isNull() || normalizedMac == QStringLiteral("00:00:00:00:00:00")) + { + return QString(); + } + + return normalizedMac; + } + + void setAirPodsCommandReady(bool ready) + { + if (m_airPodsCommandReady == ready) + { + return; + } + + m_airPodsCommandReady = ready; + emit airPodsCommandReadyChanged(); + } + + QString findPythonInterpreter() const + { + const QStringList interpreters = {QStringLiteral("python3"), QStringLiteral("python")}; + for (const QString &interpreter : interpreters) + { + const QString executable = QStandardPaths::findExecutable(interpreter); + if (!executable.isEmpty()) + { + return executable; + } + } + + return QString(); + } + + QList availableTerminalLaunchers() const + { + return { + {QStringLiteral("x-terminal-emulator"), {QStringLiteral("-e")}}, + {QStringLiteral("kgx"), {QStringLiteral("--")}}, + {QStringLiteral("gnome-terminal"), {QStringLiteral("--")}}, + {QStringLiteral("konsole"), {QStringLiteral("-e")}}, + {QStringLiteral("xfce4-terminal"), {QStringLiteral("-e")}}, + {QStringLiteral("mate-terminal"), {QStringLiteral("-e")}}, + {QStringLiteral("kitty"), {QStringLiteral("-e")}}, + {QStringLiteral("alacritty"), {QStringLiteral("-e")}}, + {QStringLiteral("wezterm"), {QStringLiteral("start"), QStringLiteral("--always-new-process"), QStringLiteral("--")}}, + {QStringLiteral("xterm"), {QStringLiteral("-e")}} + }; + } + + void setHeadTrackingStatus(const QString &status) + { + if (m_headTrackingStatus != status) + { + m_headTrackingStatus = status; + emit headTrackingStatusChanged(); + } + } + + void setHearingAidSetupStatus(const QString &status) + { + if (m_hearingAidSetupStatus != status) + { + m_hearingAidSetupStatus = status; + emit hearingAidSetupStatusChanged(); + } + } + + void updateHearingAidSetupStatus() + { + if (findHearingAidAdjustmentsScript().isEmpty()) + { + setHearingAidSetupStatus(QStringLiteral("Advanced adjustments script not found")); + return; + } + + if (!areAirpodsConnected() || m_deviceInfo->bluetoothAddress().isEmpty()) + { + setHearingAidSetupStatus(QStringLiteral("Connect your AirPods to open advanced adjustments")); + return; + } + + setHearingAidSetupStatus(QStringLiteral("Ready to adjust hearing aid/transparency for %1").arg(m_deviceInfo->bluetoothAddress())); + } + + void updateHeadTrackingStatus() + { + if (findHeadTrackingScript().isEmpty()) + { + setHeadTrackingStatus(QStringLiteral("Head tracking scripts not found")); + return; + } + + if (!areAirpodsConnected() || m_deviceInfo->bluetoothAddress().isEmpty()) + { + setHeadTrackingStatus(QStringLiteral("Connect your AirPods to test head gestures")); + return; + } + + setHeadTrackingStatus(QStringLiteral("Ready to open head gesture detector for %1").arg(m_deviceInfo->bluetoothAddress())); + } + + void updatePhoneMacStatusFromConfiguration() + { + const QString configuredMac = QString::fromUtf8(qgetenv("PHONE_MAC_ADDRESS")).trimmed(); + const QString validMac = normalizedPhoneMac(); + + if (!CrossDevice.isEnabled) + { + if (validMac.isEmpty()) + { + updatePhoneMacStatus(configuredMac.isEmpty() + ? QStringLiteral("Cross-device disabled. Set a phone MAC to enable it.") + : QStringLiteral("Cross-device disabled. Fix the phone MAC before enabling it.")); + return; + } + + updatePhoneMacStatus(QStringLiteral("Cross-device disabled. Ready to connect to %1 when enabled.").arg(validMac)); + return; + } + + if (validMac.isEmpty()) + { + updatePhoneMacStatus(configuredMac.isEmpty() + ? QStringLiteral("Cross-device needs a valid phone MAC before it can connect.") + : QStringLiteral("Cross-device skipped: invalid phone MAC `%1`.").arg(configuredMac)); + return; + } + + updatePhoneMacStatus(QStringLiteral("Cross-device configured for %1").arg(validMac)); + } + + bool startInTerminal(const QString &program, const QStringList &arguments, const QString &workingDirectory) const + { + for (const TerminalLauncher &launcher : availableTerminalLaunchers()) + { + const QString terminalPath = QStandardPaths::findExecutable(launcher.executable); + if (terminalPath.isEmpty()) + { + continue; + } + + QStringList terminalArguments = launcher.argumentsPrefix; + terminalArguments << program; + terminalArguments << arguments; + if (QProcess::startDetached(terminalPath, terminalArguments, workingDirectory)) + { + return true; + } + } + + return false; + } + bool debugMode; bool isConnectedLocally = false; @@ -208,12 +597,20 @@ public slots: void setConversationalAwareness(bool enabled) { + if (m_deviceInfo->conversationalAwareness() == enabled) + { + LOG_INFO("Conversational awareness is already " << (enabled ? "enabled" : "disabled")); + return; + } + LOG_INFO("Setting conversational awareness to: " << (enabled ? "enabled" : "disabled")); QByteArray packet = enabled ? AirPodsPackets::ConversationalAwareness::ENABLED : AirPodsPackets::ConversationalAwareness::DISABLED; - writePacketToSocket(packet, "Conversational awareness packet written: "); - m_deviceInfo->setConversationalAwareness(enabled); + if (writePacketToSocket(packet, "Conversational awareness packet written: ")) + { + m_deviceInfo->setConversationalAwareness(enabled); + } } void setOneBudANCMode(bool enabled) @@ -238,6 +635,97 @@ public slots: } } + void setAllowOffOption(bool enabled) + { + QByteArray packet = enabled ? AirPodsPackets::AllowOffOption::ENABLED + : AirPodsPackets::AllowOffOption::DISABLED; + if (writePacketToSocket(packet, "Allow Off Option packet written: ")) + m_deviceInfo->setAllowOffOption(enabled); + } + + void setVolumeSwipeEnabled(bool enabled) + { + QByteArray packet = enabled ? AirPodsPackets::VolumeSwipe::ENABLED + : AirPodsPackets::VolumeSwipe::DISABLED; + if (writePacketToSocket(packet, "Volume Swipe packet written: ")) + m_deviceInfo->setVolumeSwipeEnabled(enabled); + } + + void setVolumeSwipeInterval(int interval) + { + interval = qBound(0, interval, 100); + QByteArray packet = AirPodsPackets::VolumeSwipe::getIntervalPacket(static_cast(interval)); + if (writePacketToSocket(packet, "Volume Swipe interval packet written: ")) + m_deviceInfo->setVolumeSwipeInterval(interval); + } + + void setAdaptiveVolumeEnabled(bool enabled) + { + QByteArray packet = enabled ? AirPodsPackets::AdaptiveVolume::ENABLED + : AirPodsPackets::AdaptiveVolume::DISABLED; + if (writePacketToSocket(packet, "Adaptive Volume packet written: ")) + m_deviceInfo->setAdaptiveVolumeEnabled(enabled); + } + + void setCaseChargingSoundsEnabled(bool enabled) + { + QByteArray packet = AirPodsPackets::CaseChargingSounds::getPacket(enabled); + if (writePacketToSocket(packet, "Case Charging Sounds packet written: ")) + m_deviceInfo->setCaseChargingSoundsEnabled(enabled); + } + + void setStemLongPressModes(int modes) + { + // Clamp to valid range; require at least 2 bits set + quint8 m = static_cast(modes & 0x0F); + if (__builtin_popcount(m) < 2) return; + QByteArray packet = AirPodsPackets::StemLongPress::getPacket(m); + if (writePacketToSocket(packet, "Stem Long Press packet written: ")) { + m_deviceInfo->setStemLongPressModes(m); + m_settings->setValue("stemLongPressModes", m); + } + } + + void setCustomizeTransparencyEnabled(bool enabled) + { + m_deviceInfo->setCustomizeTransparencyEnabled(enabled); + sendCustomizeTransparency(); + } + + // Called from QML with per-bud float arrays + Q_INVOKABLE void applyCustomizeTransparency( + bool enabled, + QList leftEq, qreal leftAmp, qreal leftTone, bool leftConv, qreal leftAnr, + QList rightEq, qreal rightAmp, qreal rightTone, bool rightConv, qreal rightAnr) + { + using namespace AirPodsPackets::CustomizeTransparency; + BudSettings left, right; + for (int i = 0; i < 8 && i < leftEq.size(); i++) left.eq[i] = static_cast(leftEq[i]); + for (int i = 0; i < 8 && i < rightEq.size(); i++) right.eq[i] = static_cast(rightEq[i]); + left.amplification = static_cast(leftAmp); + left.tone = static_cast(leftTone); + left.conversationBoost = leftConv ? 1.0f : 0.0f; + left.ambientNoise = static_cast(leftAnr); + right.amplification = static_cast(rightAmp); + right.tone = static_cast(rightTone); + right.conversationBoost = rightConv ? 1.0f : 0.0f; + right.ambientNoise = static_cast(rightAnr); + + m_transpLeft = left; + m_transpRight = right; + m_deviceInfo->setCustomizeTransparencyEnabled(enabled); + sendCustomizeTransparency(); + } + + Q_INVOKABLE void applyHeadphoneAccommodation(bool phoneEnabled, bool mediaEnabled, QList eq8) + { + m_deviceInfo->setHeadphoneAccomPhoneEnabled(phoneEnabled); + m_deviceInfo->setHeadphoneAccomMediaEnabled(mediaEnabled); + m_headphoneEq = eq8; + QByteArray packet = AirPodsPackets::HeadphoneAccommodation::getPacket(phoneEnabled, mediaEnabled, eq8); + writePacketToSocket(packet, "Headphone Accommodation packet written: "); + } + void setRetryAttempts(int attempts) { if (m_retryAttempts != attempts) @@ -322,49 +810,101 @@ public slots: return; } + if (enabled && normalizedPhoneMac().isEmpty()) + { + LOG_WARN("Cross-device not enabled because no valid phone MAC is configured"); + updatePhoneMacStatusFromConfiguration(); + return; + } + CrossDevice.isEnabled = enabled; saveCrossDeviceEnabled(); + updatePhoneMacStatusFromConfiguration(); + + if (!enabled && phoneSocket) + { + phoneSocket->close(); + phoneSocket->deleteLater(); + phoneSocket = nullptr; + updatePhoneConnectionState(); + } + connectToPhone(); emit crossDeviceEnabledChanged(enabled); } void setPhoneMac(const QString &mac) { - if (mac.isEmpty()) { + const QString trimmedMac = mac.trimmed(); + if (trimmedMac.isEmpty()) { LOG_WARN("Empty MAC provided, ignoring"); - m_phoneMacStatus = QStringLiteral("No MAC provided (ignoring)"); - emit phoneMacStatusChanged(); + qputenv("PHONE_MAC_ADDRESS", QByteArray()); + if (parent) { + parent->rootContext()->setContextProperty("PHONE_MAC_ADDRESS", QString()); + } + + if (phoneSocket) { + phoneSocket->close(); + phoneSocket->deleteLater(); + phoneSocket = nullptr; + updatePhoneConnectionState(); + } + + if (CrossDevice.isEnabled) + { + CrossDevice.isEnabled = false; + saveCrossDeviceEnabled(); + emit crossDeviceEnabledChanged(false); + } + + updatePhoneMacStatusFromConfiguration(); return; } - // Basic MAC address validation (accepts formats like AA:BB:CC:DD:EE:FF, AABBCCDDEEFF, AA-BB-CC-DD-EE-FF) - QRegularExpression re("^([0-9A-Fa-f]{2}([-:]?)){5}[0-9A-Fa-f]{2}$"); - if (!re.match(mac).hasMatch()) { - LOG_ERROR("Invalid MAC address format: " << mac); - m_phoneMacStatus = QStringLiteral("Invalid MAC: ") + mac; - emit phoneMacStatusChanged(); + QString cleanedMac = trimmedMac; + cleanedMac.remove(QRegularExpression(QStringLiteral("[^0-9A-Fa-f]"))); + if (cleanedMac.size() != 12) { + LOG_ERROR("Invalid MAC address format: " << trimmedMac); + updatePhoneMacStatus(QStringLiteral("Invalid MAC: %1").arg(trimmedMac)); return; } - // Set environment variable for the running process - qputenv("PHONE_MAC_ADDRESS", mac.toUtf8()); - LOG_INFO("PHONE_MAC_ADDRESS environment variable set to: " << mac); + QStringList octets; + octets.reserve(6); + for (int index = 0; index < cleanedMac.size(); index += 2) + { + octets << cleanedMac.mid(index, 2).toUpper(); + } + const QString normalizedMac = octets.join(QLatin1Char(':')); + if (QBluetoothAddress(normalizedMac).isNull() || normalizedMac == QStringLiteral("00:00:00:00:00:00")) + { + LOG_ERROR("Invalid MAC address value: " << trimmedMac); + updatePhoneMacStatus(QStringLiteral("Invalid MAC: %1").arg(trimmedMac)); + return; + } - m_phoneMacStatus = QStringLiteral("Updated MAC: ") + mac; - emit phoneMacStatusChanged(); + qputenv("PHONE_MAC_ADDRESS", normalizedMac.toUtf8()); + LOG_INFO("PHONE_MAC_ADDRESS environment variable set to: " << normalizedMac); // Update QML context property so UI placeholders reflect the new value if (parent) { - parent->rootContext()->setContextProperty("PHONE_MAC_ADDRESS", mac); + parent->rootContext()->setContextProperty("PHONE_MAC_ADDRESS", normalizedMac); } + updatePhoneMacStatusFromConfiguration(); + // If a phone socket exists, restart connection using the new MAC - if (phoneSocket && phoneSocket->isOpen()) { + if (phoneSocket) { phoneSocket->close(); phoneSocket->deleteLater(); phoneSocket = nullptr; + updatePhoneConnectionState(); + } + + if (CrossDevice.isEnabled) + { + connectToPhone(); } - connectToPhone(); } void updatePhoneMacStatus(const QString &status) @@ -373,21 +913,136 @@ public slots: emit phoneMacStatusChanged(); } + void updatePhoneConnectionState() + { + emit phoneConnectionChanged(); + } + + void openHearingAidAdjustments() + { + if (!areAirpodsConnected() || m_deviceInfo->bluetoothAddress().isEmpty()) + { + setHearingAidSetupStatus(QStringLiteral("Connect your AirPods to open advanced adjustments")); + return; + } + + const QString scriptPath = findHearingAidAdjustmentsScript(); + if (scriptPath.isEmpty()) + { + setHearingAidSetupStatus(QStringLiteral("Advanced adjustments script not found")); + return; + } + + const QFileInfo scriptInfo(scriptPath); + const QStringList interpreters = {QStringLiteral("python3"), QStringLiteral("python")}; + for (const QString &interpreter : interpreters) + { + if (QProcess::startDetached(interpreter, QStringList() << scriptInfo.filePath() << m_deviceInfo->bluetoothAddress(), scriptInfo.absolutePath())) + { + setHearingAidSetupStatus(QStringLiteral("Opened advanced adjustments for %1").arg(m_deviceInfo->bluetoothAddress())); + return; + } + } + + setHearingAidSetupStatus(QStringLiteral("Failed to launch advanced adjustments. Check Python and PyQt5.")); + } + + void openHeadTrackingGestures() + { + if (!areAirpodsConnected() || m_deviceInfo->bluetoothAddress().isEmpty()) + { + setHeadTrackingStatus(QStringLiteral("Connect your AirPods to test head gestures")); + return; + } + + const QString scriptPath = findHeadTrackingScript(); + if (scriptPath.isEmpty()) + { + setHeadTrackingStatus(QStringLiteral("Head tracking scripts not found")); + return; + } + + const QString interpreter = findPythonInterpreter(); + if (interpreter.isEmpty()) + { + setHeadTrackingStatus(QStringLiteral("Python not found. Install python3 to run head gesture detection.")); + return; + } + + const QFileInfo scriptInfo(scriptPath); + const QStringList arguments = {scriptInfo.filePath(), m_deviceInfo->bluetoothAddress()}; + if (startInTerminal(interpreter, arguments, scriptInfo.absolutePath())) + { + setHeadTrackingStatus(QStringLiteral("Opened head gesture detector for %1").arg(m_deviceInfo->bluetoothAddress())); + return; + } + + setHeadTrackingStatus(QStringLiteral("No supported terminal emulator found. Run `%1 %2 %3` manually.") + .arg(QFileInfo(interpreter).fileName(), scriptInfo.filePath(), m_deviceInfo->bluetoothAddress())); + } + + void reconnectPhoneRelay() + { + if (!CrossDevice.isEnabled) + { + updatePhoneMacStatusFromConfiguration(); + return; + } + + const QString validPhoneMac = normalizedPhoneMac(); + if (validPhoneMac.isEmpty()) + { + updatePhoneMacStatusFromConfiguration(); + return; + } + + if (phoneSocket) + { + phoneSocket->close(); + phoneSocket->deleteLater(); + phoneSocket = nullptr; + updatePhoneConnectionState(); + } + + updatePhoneMacStatus(QStringLiteral("Retrying cross-device relay to %1...").arg(validPhoneMac)); + connectToPhone(); + } + void setHearingAidEnabled(bool enabled) { + if (m_deviceInfo->hearingAidEnabled() == enabled) + { + LOG_INFO("Hearing aid is already " << (enabled ? "enabled" : "disabled")); + return; + } + LOG_INFO("Setting hearing aid to: " << (enabled ? "enabled" : "disabled")); QByteArray packet = enabled ? AirPodsPackets::HearingAid::ENABLED : AirPodsPackets::HearingAid::DISABLED; - writePacketToSocket(packet, "Hearing aid packet written: "); - m_deviceInfo->setHearingAidEnabled(enabled); + if (writePacketToSocket(packet, "Hearing aid packet written: ")) + { + m_deviceInfo->setHearingAidEnabled(enabled); + } } - bool writePacketToSocket(const QByteArray &packet, const QString &logMessage) + bool writePacketToSocket(const QByteArray &packet, const QString &logMessage, bool requireReady = true) { if (socket && socket->isOpen()) { - socket->write(packet); + if (requireReady && !m_airPodsCommandReady) + { + LOG_WARN("AirPods socket connected but commands are not ready yet"); + return false; + } + + const qint64 bytesWritten = socket->write(packet); + if (bytesWritten != packet.size()) + { + LOG_ERROR("Failed to queue full packet to socket. Expected " << packet.size() << " bytes, wrote " << bytesWritten); + return false; + } + LOG_DEBUG(logMessage << packet.toHex()); return true; } @@ -480,7 +1135,7 @@ private slots: void sendHandshake() { LOG_INFO("Connected to device, sending initial packets"); - writePacketToSocket(AirPodsPackets::Connection::HANDSHAKE, "Handshake packet written: "); + writePacketToSocket(AirPodsPackets::Connection::HANDSHAKE, "Handshake packet written: ", false); } void bluezDeviceConnected(const QString &address, const QString &name) @@ -499,6 +1154,7 @@ private slots: mediaController->setConnectedDeviceMacAddress(formattedAddress); mediaController->activateA2dpProfile(); LOG_INFO("A2DP profile activation attempted for newly connected device"); + emit airPodsStatusChanged(); } }); } @@ -506,6 +1162,7 @@ private slots: void onDeviceDisconnected(const QBluetoothAddress &address) { LOG_INFO("Device disconnected: " << address.toString()); + setAirPodsCommandReady(false); if (socket) { LOG_WARN("Socket is still open, closing it"); @@ -532,8 +1189,24 @@ private slots: void bluezDeviceDisconnected(const QString &address, const QString &name) { + Q_UNUSED(name); if (address == m_deviceInfo->bluetoothAddress()) { + if (isAirpodsAudioConnected()) + { + if (areAirpodsConnected()) + { + LOG_WARN("Ignoring BlueZ disconnect because AirPods are still the active audio output and the command channel is still connected: " << address); + } + else + { + LOG_WARN("AirPods are still the active audio output, but the command channel is no longer connected. Keeping UI connected and disabling command controls: " << address); + setAirPodsCommandReady(false); + } + emit airPodsStatusChanged(); + return; + } + onDeviceDisconnected(QBluetoothAddress(address)); } else { LOG_WARN("Disconnected device does not match connected device: " << address << " != " << m_deviceInfo->bluetoothAddress()); @@ -607,8 +1280,8 @@ private slots: } LOG_INFO("Connecting to device: " << device.name()); + setAirPodsCommandReady(false); - // Clean up any existing socket if (socket) { socket->close(); @@ -616,24 +1289,31 @@ private slots: socket = nullptr; } - QBluetoothSocket *localSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol); + L2CAPSocket *localSocket = new L2CAPSocket(this); socket = localSocket; - // Connection handler - auto handleConnection = [this, localSocket]() + connect(localSocket, &L2CAPSocket::connected, this, [this, localSocket]() { - 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)); }); + connect(localSocket, &L2CAPSocket::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)); + }); + emit airPodsStatusChanged(); sendHandshake(); - }; + }); - // Error handler with retry - auto handleError = [this, device, localSocket](QBluetoothSocket::SocketError error) + connect(localSocket, &L2CAPSocket::disconnected, this, [this]() { - LOG_ERROR("Socket error: " << error << ", " << localSocket->errorString()); + setAirPodsCommandReady(false); + emit airPodsStatusChanged(); + }); + + connect(localSocket, &L2CAPSocket::errorOccurred, this, [this, device, localSocket]() + { + setAirPodsCommandReady(false); + LOG_ERROR("Socket error: " << localSocket->errorString()); static int retryCount = 0; if (retryCount < m_retryAttempts) @@ -641,21 +1321,17 @@ private slots: retryCount++; LOG_INFO("Retrying connection (attempt " << retryCount << ")"); QTimer::singleShot(1500, this, [this, device]() - { connectToDevice(device); }); + { connectToDevice(device); }); } else { - LOG_ERROR("Failed to connect after 3 attempts"); + LOG_ERROR("Failed to connect after " << m_retryAttempts << " attempts"); retryCount = 0; } - }; - - connect(localSocket, &QBluetoothSocket::connected, this, handleConnection); - connect(localSocket, QOverload::of(&QBluetoothSocket::errorOccurred), - this, handleError); + }); - localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a")); m_deviceInfo->setBluetoothAddress(device.address().toString()); + localSocket->connectToService(device.address(), quint16(0x1001)); notifyAndroidDevice(); } @@ -663,18 +1339,25 @@ private slots: { LOG_DEBUG("Received: " << data.toHex()); + if (!data.isEmpty()) + { + setAirPodsCommandReady(true); + } + if (data.startsWith(AirPodsPackets::Parse::HANDSHAKE_ACK)) { - writePacketToSocket(AirPodsPackets::Connection::SET_SPECIFIC_FEATURES, "Set specific features packet written: "); + writePacketToSocket(AirPodsPackets::Connection::SET_SPECIFIC_FEATURES, "Set specific features packet written: ", false); } else if (data.startsWith(AirPodsPackets::Parse::FEATURES_ACK)) { - writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: "); + writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: ", false); QTimer::singleShot(2000, this, [this]() { if (m_deviceInfo->batteryStatus().isEmpty()) { - writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: "); + writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: ", false); } + // Restore stem long press config on every connection (AirPods forget it) + sendStemLongPressConfig(); }); } // Magic Cloud Keys Response @@ -753,6 +1436,34 @@ private slots: LOG_INFO("One Bud ANC mode received: " << m_deviceInfo->oneBudANCMode()); } } + else if (data.startsWith(AirPodsPackets::AllowOffOption::HEADER)) { + if (auto value = AirPodsPackets::AllowOffOption::parseState(data)) + { + m_deviceInfo->setAllowOffOption(value.value()); + LOG_INFO("Allow Off Option received: " << value.value()); + } + } + else if (data.startsWith(AirPodsPackets::VolumeSwipe::HEADER)) { + if (auto value = AirPodsPackets::VolumeSwipe::parseState(data)) + { + m_deviceInfo->setVolumeSwipeEnabled(value.value()); + LOG_INFO("Volume Swipe received: " << value.value()); + } + } + else if (data.startsWith(AirPodsPackets::AdaptiveVolume::HEADER)) { + if (auto value = AirPodsPackets::AdaptiveVolume::parseState(data)) + { + m_deviceInfo->setAdaptiveVolumeEnabled(value.value()); + LOG_INFO("Adaptive Volume received: " << value.value()); + } + } + else if (data.startsWith(AirPodsPackets::StemLongPress::HEADER)) { + if (auto modes = AirPodsPackets::StemLongPress::parseModes(data)) + { + m_deviceInfo->setStemLongPressModes(modes.value()); + LOG_INFO("Stem Long Press modes received: " << modes.value()); + } + } else { LOG_DEBUG("Unrecognized packet format: " << data.toHex()); @@ -761,6 +1472,7 @@ private slots: void connectToPhone() { if (!CrossDevice.isEnabled) { + updatePhoneMacStatusFromConfiguration(); return; } @@ -768,16 +1480,28 @@ private slots: LOG_INFO("Already connected to the phone"); return; } - QBluetoothAddress phoneAddress("00:00:00:00:00:00"); // Default address, will be overwritten if PHONE_MAC_ADDRESS is set - QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); - if (!env.value("PHONE_MAC_ADDRESS").isEmpty()) + const QString validPhoneMac = normalizedPhoneMac(); + if (validPhoneMac.isEmpty()) { - phoneAddress = QBluetoothAddress(env.value("PHONE_MAC_ADDRESS")); + LOG_WARN("Skipping cross-device connection because no valid phone MAC is configured"); + updatePhoneMacStatusFromConfiguration(); + return; } + + if (phoneSocket) + { + phoneSocket->close(); + phoneSocket->deleteLater(); + phoneSocket = nullptr; + } + + QBluetoothAddress phoneAddress(validPhoneMac); phoneSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol); connect(phoneSocket, &QBluetoothSocket::connected, this, [this]() { LOG_INFO("Connected to phone"); + updatePhoneMacStatus(QStringLiteral("Connected to phone for cross-device relay")); + updatePhoneConnectionState(); if (!lastBatteryStatus.isEmpty()) { phoneSocket->write(lastBatteryStatus); LOG_DEBUG("Sent last battery status to phone: " << lastBatteryStatus.toHex()); @@ -790,9 +1514,17 @@ private slots: connect(phoneSocket, QOverload::of(&QBluetoothSocket::errorOccurred), this, [this](QBluetoothSocket::SocketError error) { LOG_ERROR("Phone socket error: " << error << ", " << phoneSocket->errorString()); + updatePhoneMacStatus(QStringLiteral("Cross-device connection failed: %1").arg(phoneSocket->errorString())); + updatePhoneConnectionState(); + }); + + connect(phoneSocket, &QBluetoothSocket::disconnected, this, [this]() { + updatePhoneMacStatusFromConfiguration(); + updatePhoneConnectionState(); }); phoneSocket->connectToService(phoneAddress, QBluetoothUuid("1abbb9a4-10e4-4000-a75c-8953c5471342")); + updatePhoneMacStatus(QStringLiteral("Connecting to phone %1 for cross-device relay...").arg(validPhoneMac)); } void relayPacketToPhone(const QByteArray &packet) @@ -815,11 +1547,10 @@ private slots: if (packet.startsWith(AirPodsPackets::Phone::NOTIFICATION)) { QByteArray airpodsPacket = packet.mid(4); - if (socket && socket->isOpen()) { - socket->write(airpodsPacket); + if (writePacketToSocket(airpodsPacket, "Relayed packet to AirPods: ")) { LOG_DEBUG("Relayed packet to AirPods: " << airpodsPacket.toHex()); } else { - LOG_ERROR("Socket is not open, cannot relay packet to AirPods"); + LOG_ERROR("Socket is not ready, cannot relay packet to AirPods"); } } else if (packet.startsWith(AirPodsPackets::Phone::CONNECTED)) @@ -859,11 +1590,10 @@ private slots: } else { - if (socket && socket->isOpen()) { - socket->write(packet); + if (writePacketToSocket(packet, "Relayed packet to AirPods: ")) { LOG_DEBUG("Relayed packet to AirPods: " << packet.toHex()); } else { - LOG_ERROR("Socket is not open, cannot relay packet to AirPods"); + LOG_ERROR("Socket is not ready, cannot relay packet to AirPods"); } } } @@ -949,7 +1679,46 @@ private slots: } void loadMainModule() { + if (!parent) + { + return; + } + + const auto showExistingWindow = [this]() -> bool + { + if (parent->rootObjects().isEmpty()) + { + return false; + } + + if (auto *window = qobject_cast(parent->rootObjects().first())) + { + window->setVisible(true); + window->show(); + window->raise(); + window->requestActivate(); + return true; + } + + QObject *rootObject = parent->rootObjects().first(); + if (rootObject) + { + rootObject->setProperty("visible", true); + QMetaObject::invokeMethod(rootObject, "raise"); + QMetaObject::invokeMethod(rootObject, "requestActivate"); + return true; + } + + return false; + }; + + if (showExistingWindow()) + { + return; + } + parent->load(QUrl(QStringLiteral("qrc:/linux/Main.qml"))); + showExistingWindow(); } signals: @@ -962,16 +1731,38 @@ private slots: void modelChanged(); void primaryChanged(); void airPodsStatusChanged(); + void airPodsCommandReadyChanged(); void earDetectionBehaviorChanged(int behavior); void crossDeviceEnabledChanged(bool enabled); void notificationsEnabledChanged(bool enabled); void retryAttemptsChanged(int attempts); void oneBudANCModeChanged(bool enabled); void phoneMacStatusChanged(); + void phoneConnectionChanged(); void hearingAidEnabledChanged(bool enabled); + void hearingAidSetupStatusChanged(); + void headTrackingStatusChanged(); private: - QBluetoothSocket *socket = nullptr; + void sendCustomizeTransparency() + { + using namespace AirPodsPackets::CustomizeTransparency; + QByteArray packet = getPacket(m_deviceInfo->customizeTransparencyEnabled(), m_transpLeft, m_transpRight); + writePacketToSocket(packet, "Customize Transparency packet written: "); + } + + void sendStemLongPressConfig() + { + int modes = m_settings->value("stemLongPressModes", 0x06).toInt(); + m_deviceInfo->setStemLongPressModes(modes); + if (__builtin_popcount(static_cast(modes)) >= 2) { + QByteArray packet = AirPodsPackets::StemLongPress::getPacket(static_cast(modes)); + writePacketToSocket(packet, "Stem Long Press config restored: ", false); + } + } + + // Data members + L2CAPSocket *socket = nullptr; QBluetoothSocket *phoneSocket = nullptr; QByteArray lastBatteryStatus; QByteArray lastEarDetectionStatus; @@ -985,7 +1776,13 @@ private slots: DeviceInfo *m_deviceInfo; BleManager *m_bleManager; SystemSleepMonitor *m_systemSleepMonitor = nullptr; + bool m_airPodsCommandReady = false; QString m_phoneMacStatus; + QString m_hearingAidSetupStatus; + QString m_headTrackingStatus; + AirPodsPackets::CustomizeTransparency::BudSettings m_transpLeft; + AirPodsPackets::CustomizeTransparency::BudSettings m_transpRight; + QList m_headphoneEq = QList(8, 0); }; int main(int argc, char *argv[]) { @@ -997,14 +1794,18 @@ int main(int argc, char *argv[]) { // Try to load translation from various locations QStringList translationPaths = { + QCoreApplication::applicationDirPath(), QCoreApplication::applicationDirPath() + "/translations", QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + "/librepods/translations", "/usr/share/librepods/translations", "/usr/local/share/librepods/translations" }; + // Try full locale (e.g. es_AR), then language-only fallback (e.g. es) + const QString langCode = locale.left(2); for (const QString &path : translationPaths) { - if (translator->load("librepods_" + locale, path)) { + if (translator->load("librepods_" + locale, path) || + (langCode != locale && translator->load("librepods_" + langCode, path))) { app.installTranslator(translator); break; } @@ -1053,8 +1854,6 @@ int main(int argc, char *argv[]) { QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); QString phoneMacEnv = env.value("PHONE_MAC_ADDRESS", ""); engine.rootContext()->setContextProperty("PHONE_MAC_ADDRESS", phoneMacEnv); - // Initialize the visible status in the GUI - trayApp->updatePhoneMacStatus(phoneMacEnv.isEmpty() ? QStringLiteral("No phone MAC set") : phoneMacEnv); } engine.addImageProvider("qrcode", new QRCodeImageProvider()); diff --git a/linux/trayiconmanager.cpp b/linux/trayiconmanager.cpp index 738feecf1..63640fdec 100644 --- a/linux/trayiconmanager.cpp +++ b/linux/trayiconmanager.cpp @@ -8,6 +8,7 @@ #include #include #include +#include using namespace AirpodsTrayApp::Enums; @@ -34,7 +35,7 @@ void TrayIconManager::showNotification(const QString &title, const QString &mess trayIcon->showMessage(title, message, QSystemTrayIcon::Information, 3000); } -void TrayIconManager::TrayIconManager::updateBatteryStatus(const QString &status) +void TrayIconManager::updateBatteryStatus(const QString &status) { trayIcon->setToolTip(tr("Battery Status: ") + status); updateIconFromBattery(status); @@ -54,6 +55,17 @@ void TrayIconManager::updateConversationalAwareness(bool enabled) caToggleAction->setChecked(enabled); } +void TrayIconManager::setAirPodsControlsEnabled(bool enabled) +{ + for (QAction *action : m_airPodsControlActions) + { + if (action) + { + action->setEnabled(enabled); + } + } +} + void TrayIconManager::setupMenuActions() { // Open action @@ -73,6 +85,7 @@ void TrayIconManager::setupMenuActions() caToggleAction = new QAction(tr("Toggle Conversational Awareness"), trayMenu); caToggleAction->setCheckable(true); trayMenu->addAction(caToggleAction); + m_airPodsControlActions.append(caToggleAction); connect(caToggleAction, &QAction::triggered, this, [this](bool checked) { emit conversationalAwarenessToggled(checked); }); @@ -93,10 +106,13 @@ void TrayIconManager::setupMenuActions() action->setData((int)option.second); noiseControlGroup->addAction(action); trayMenu->addAction(action); + m_airPodsControlActions.append(action); connect(action, &QAction::triggered, this, [this, mode = option.second]() { emit noiseControlChanged(mode); }); } + setAirPodsControlsEnabled(false); + trayMenu->addSeparator(); // Quit action @@ -113,18 +129,31 @@ void TrayIconManager::updateIconFromBattery(const QString &status) if (!status.isEmpty()) { - // Parse the battery status string - QStringList parts = status.split(", "); - if (parts.size() >= 2) { - leftLevel = parts[0].split(": ")[1].replace("%", "").toInt(); - rightLevel = parts[1].split(": ")[1].replace("%", "").toInt(); - minLevel = (leftLevel == 0) ? rightLevel : (rightLevel == 0) ? leftLevel - : qMin(leftLevel, rightLevel); - } else if (parts.size() == 1) { - minLevel = parts[0].split(": ")[1].replace("%", "").toInt(); + const QRegularExpression batteryPattern(QStringLiteral("(\\d+)%")); + QRegularExpressionMatchIterator iterator = batteryPattern.globalMatch(status); + QList levels; + while (iterator.hasNext()) + { + const QRegularExpressionMatch match = iterator.next(); + levels.append(match.captured(1).toInt()); + } + + if (!levels.isEmpty()) + { + minLevel = levels.first(); + for (int level : levels) + { + minLevel = qMin(minLevel, level); + } } } + if (status.isEmpty() || minLevel <= 0) + { + trayIcon->setIcon(QIcon(":/icons/assets/airpods.png")); + return; + } + QPixmap pixmap(32, 32); pixmap.fill(Qt::transparent); QPainter painter(&pixmap); @@ -143,4 +172,3 @@ void TrayIconManager::onTrayIconActivated(QSystemTrayIcon::ActivationReason reas emit trayClicked(); } } - diff --git a/linux/trayiconmanager.h b/linux/trayiconmanager.h index 25c153048..80e9e78f5 100644 --- a/linux/trayiconmanager.h +++ b/linux/trayiconmanager.h @@ -1,5 +1,6 @@ #include #include +#include #include "enums.h" @@ -21,6 +22,8 @@ class TrayIconManager : public QObject void updateConversationalAwareness(bool enabled); + void setAirPodsControlsEnabled(bool enabled); + void showNotification(const QString &title, const QString &message); bool notificationsEnabled() const { return m_notificationsEnabled; } @@ -50,6 +53,7 @@ private slots: QMenu *trayMenu; QAction *caToggleAction; QActionGroup *noiseControlGroup; + QList m_airPodsControlActions; bool m_notificationsEnabled = true; void setupMenuActions(); @@ -62,4 +66,4 @@ private slots: void conversationalAwarenessToggled(bool enabled); void openApp(); void openSettings(); -}; \ No newline at end of file +}; From 000d50e0179bcf7484ceb7a8673124a1c56beb6f Mon Sep 17 00:00:00 2001 From: john Date: Sat, 11 Apr 2026 07:45:29 -0300 Subject: [PATCH 2/5] fix(linux): normalize MAC address for Bluetooth audio device lookup Normalize MAC to lowercase hex-only before comparing against bluez_card and bluez_output names in PulseAudio/PipeWire, fixing cases where colon vs underscore or uppercase differences caused lookup failures --- linux/media/mediacontroller.cpp | 34 ++++++++++++++++++++++++---- linux/media/mediacontroller.h | 4 ++-- linux/media/pulseaudiocontroller.cpp | 18 +++++++++++++-- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/linux/media/mediacontroller.cpp b/linux/media/mediacontroller.cpp index 078129c5a..2eed22116 100644 --- a/linux/media/mediacontroller.cpp +++ b/linux/media/mediacontroller.cpp @@ -11,6 +11,15 @@ #include #include +namespace { +QString normalizeMacForAudioLookup(const QString &value) +{ + QString normalized = value; + normalized.remove(QRegularExpression(QStringLiteral("[^0-9A-Fa-f]"))); + return normalized.toLower(); +} +} + MediaController::MediaController(QObject *parent) : QObject(parent) { m_pulseAudio = new PulseAudioController(this); if (!m_pulseAudio->initialize()) @@ -94,10 +103,13 @@ void MediaController::followMediaChanges() { }); } -bool MediaController::isActiveOutputDeviceAirPods() { +bool MediaController::isActiveOutputDeviceAirPods() const { QString defaultSink = m_pulseAudio->getDefaultSink(); LOG_DEBUG("Default sink: " << defaultSink); - return defaultSink.contains(connectedDeviceMacAddress); + + const QString normalizedSink = normalizeMacForAudioLookup(defaultSink); + const QString normalizedMac = normalizeMacForAudioLookup(connectedDeviceMacAddress); + return !normalizedMac.isEmpty() && normalizedSink.contains(normalizedMac); } void MediaController::handleConversationalAwareness(const QByteArray &data) { @@ -197,7 +209,19 @@ bool MediaController::restartWirePlumber() { } void MediaController::activateA2dpProfile() { - if (connectedDeviceMacAddress.isEmpty() || m_deviceOutputName.isEmpty()) { + if (connectedDeviceMacAddress.isEmpty()) { + LOG_WARN("Connected device MAC address is empty, cannot activate A2DP profile"); + return; + } + + // Card may have temporarily disappeared during BT reconnection — refresh if needed + if (m_deviceOutputName.isEmpty()) { + m_deviceOutputName = getAudioDeviceName(); + if (!m_deviceOutputName.isEmpty()) + LOG_INFO("Device output name refreshed: " << m_deviceOutputName); + } + + if (m_deviceOutputName.isEmpty()) { LOG_WARN("Connected device MAC address or output name is empty, cannot activate A2DP profile"); return; } @@ -242,7 +266,7 @@ void MediaController::removeAudioOutputDevice() { } void MediaController::setConnectedDeviceMacAddress(const QString &macAddress) { - connectedDeviceMacAddress = macAddress; + connectedDeviceMacAddress = macAddress.trimmed(); m_deviceOutputName = getAudioDeviceName(); m_cachedA2dpProfile.clear(); LOG_INFO("Device output name set to: " << m_deviceOutputName); @@ -416,4 +440,4 @@ QString MediaController::getAudioDeviceName() LOG_ERROR("No matching Bluetooth card found for MAC address: " << connectedDeviceMacAddress); } return cardName; -} \ No newline at end of file +} diff --git a/linux/media/mediacontroller.h b/linux/media/mediacontroller.h index 0a064400a..f0bba8815 100644 --- a/linux/media/mediacontroller.h +++ b/linux/media/mediacontroller.h @@ -33,7 +33,7 @@ class MediaController : public QObject void handleEarDetection(EarDetection*); void followMediaChanges(); - bool isActiveOutputDeviceAirPods(); + bool isActiveOutputDeviceAirPods() const; void handleConversationalAwareness(const QByteArray &data); void activateA2dpProfile(); void removeAudioOutputDevice(); @@ -67,4 +67,4 @@ class MediaController : public QObject QString m_cachedA2dpProfile; }; -#endif // MEDIACONTROLLER_H \ No newline at end of file +#endif // MEDIACONTROLLER_H diff --git a/linux/media/pulseaudiocontroller.cpp b/linux/media/pulseaudiocontroller.cpp index d1535d0ee..2b2c726aa 100644 --- a/linux/media/pulseaudiocontroller.cpp +++ b/linux/media/pulseaudiocontroller.cpp @@ -1,7 +1,17 @@ #include "pulseaudiocontroller.h" #include "logger.h" +#include #include +namespace { +QString normalizeMacForAudioLookup(const QString &value) +{ + QString normalized = value; + normalized.remove(QRegularExpression(QStringLiteral("[^0-9A-Fa-f]"))); + return normalized.toLower(); +} +} + PulseAudioController::PulseAudioController(QObject *parent) : QObject(parent), m_mainloop(nullptr), m_context(nullptr), m_initialized(false) { @@ -198,12 +208,15 @@ QString PulseAudioController::getCardNameForDevice(const QString &macAddress) { if (!m_initialized) return QString(); + const QString normalizedTargetMac = normalizeMacForAudioLookup(macAddress); + if (normalizedTargetMac.isEmpty()) return QString(); + struct CallbackData { QString cardName; QString targetMac; pa_threaded_mainloop *mainloop; } data; - data.targetMac = macAddress; + data.targetMac = normalizedTargetMac; data.mainloop = m_mainloop; auto callback = [](pa_context *c, const pa_card_info *info, int eol, void *userdata) { @@ -216,7 +229,8 @@ QString PulseAudioController::getCardNameForDevice(const QString &macAddress) if (info) { QString name = QString::fromUtf8(info->name); - if (name.startsWith("bluez") && name.contains(d->targetMac)) + const QString normalizedCardName = normalizeMacForAudioLookup(name); + if (name.startsWith("bluez") && normalizedCardName.contains(d->targetMac)) { d->cardName = name; pa_threaded_mainloop_signal(d->mainloop, 0); From 19c117882515452b4b842219714750569f7b738f Mon Sep 17 00:00:00 2001 From: john Date: Sat, 11 Apr 2026 07:45:34 -0300 Subject: [PATCH 3/5] fix(linux): fix Quick Controls handlers, responsive layout, and visual states - Replace onCheckedChanged with onClicked on Switch controls to avoid bidirectional binding loops with backend-synced properties - Implement responsive home layout: adaptive padding, Battery row as Flow, noise mode selector switches to ComboBox on narrow widths - Unify disabled/active visual states across switches and SegmentedControl --- linux/Main.qml | 1360 ++++++++++++++++++++++++++++++++---- linux/SegmentedControl.qml | 34 +- 2 files changed, 1244 insertions(+), 150 deletions(-) diff --git a/linux/Main.qml b/linux/Main.qml index c7b235c33..fefc80cb4 100644 --- a/linux/Main.qml +++ b/linux/Main.qml @@ -6,10 +6,33 @@ import QtQuick.Controls 2.15 ApplicationWindow { id: mainWindow visible: !airPodsTrayApp.hideOnStart - width: 400 - height: 300 + width: 420 + height: 420 + minimumWidth: 320 + minimumHeight: 360 + color: "#f4f5f7" title: "LibrePods" objectName: "mainWindowObject" + readonly property color cardBackgroundColor: "#fbfbfc" + readonly property color cardBorderColor: "#d9dde3" + readonly property color primaryTextColor: "#1f2933" + readonly property color secondaryTextColor: "#4f5965" + readonly property color statusRowBackgroundColor: "#eef1f5" + readonly property color controlAccentColor: "#2563eb" + readonly property color controlInactiveColor: "#d6dce4" + readonly property color controlBorderMutedColor: "#c5ced8" + readonly property color controlDisabledFillColor: "#e9edf2" + readonly property color controlDisabledAccentColor: "#b9c4d2" + readonly property color controlDisabledTextColor: "#8b96a3" + + Component.onCompleted: { + if (!airPodsTrayApp.hideOnStart) { + visible = true + show() + raise() + requestActivate() + } + } onClosing: mainWindow.visible = false @@ -63,113 +86,854 @@ ApplicationWindow { Component { id: mainPage Item { - Column { + id: mainPageRoot + readonly property int pagePadding: width < 360 ? 12 : 20 + + ScrollView { + id: mainScrollView anchors.fill: parent - spacing: 20 - padding: 20 - - // Connection status indicator (Apple-like pill shape) - Rectangle { - anchors.horizontalCenter: parent.horizontalCenter - anchors.topMargin: 10 - width: 120 - height: 24 - radius: 12 - color: airPodsTrayApp.airpodsConnected ? "#30D158" : "#FF453A" - opacity: 0.8 - visible: !airPodsTrayApp.airpodsConnected + contentWidth: availableWidth + clip: true - Label { - anchors.centerIn: parent - text: airPodsTrayApp.airpodsConnected ? qsTr("Connected") : qsTr("Disconnected") - color: "white" - font.pixelSize: 12 - font.weight: Font.Medium - } - } + Item { + id: mainScrollContent + width: Math.max(0, mainScrollView.availableWidth) + implicitHeight: mainContent.implicitHeight + (mainPageRoot.pagePadding * 2) + + Column { + id: mainContent + x: mainPageRoot.pagePadding + y: mainPageRoot.pagePadding + width: Math.max(0, parent.width - (mainPageRoot.pagePadding * 2)) + spacing: 18 - // Battery Indicator Row - Row { - anchors.horizontalCenter: parent.horizontalCenter - spacing: 8 - - PodColumn { - visible: airPodsTrayApp.deviceInfo.battery.leftPodAvailable - inEar: airPodsTrayApp.deviceInfo.leftPodInEar - iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon - batteryLevel: airPodsTrayApp.deviceInfo.battery.leftPodLevel - isCharging: airPodsTrayApp.deviceInfo.battery.leftPodCharging - indicator: "L" - } + Frame { + width: parent.width + padding: 18 - PodColumn { - visible: airPodsTrayApp.deviceInfo.battery.rightPodAvailable - inEar: airPodsTrayApp.deviceInfo.rightPodInEar - iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon - batteryLevel: airPodsTrayApp.deviceInfo.battery.rightPodLevel - isCharging: airPodsTrayApp.deviceInfo.battery.rightPodCharging - indicator: "R" - } + background: Rectangle { + radius: 14 + color: mainWindow.cardBackgroundColor + border.color: mainWindow.cardBorderColor + } - PodColumn { - visible: airPodsTrayApp.deviceInfo.battery.caseAvailable - inEar: true - iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.caseIcon - batteryLevel: airPodsTrayApp.deviceInfo.battery.caseLevel - isCharging: airPodsTrayApp.deviceInfo.battery.caseCharging - } + Column { + width: parent.width + spacing: 10 + + Label { + width: parent.width + font.pixelSize: 24 + font.bold: true + wrapMode: Text.WordWrap + text: airPodsTrayApp.deviceInfo.deviceName !== "" + ? airPodsTrayApp.deviceInfo.deviceName + : qsTr("LibrePods") + } + + Rectangle { + width: statusLabel.implicitWidth + 20 + height: 28 + radius: 14 + color: airPodsTrayApp.airpodsConnected ? "#30D158" : "#FF453A" + opacity: 0.9 - PodColumn { - visible: airPodsTrayApp.deviceInfo.battery.headsetAvailable - inEar: true - iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon - batteryLevel: airPodsTrayApp.deviceInfo.battery.headsetLevel - isCharging: airPodsTrayApp.deviceInfo.battery.headsetCharging + Label { + id: statusLabel + anchors.centerIn: parent + text: airPodsTrayApp.airpodsConnected ? qsTr("Connected") : qsTr("Disconnected") + color: "white" + font.pixelSize: 12 + font.weight: Font.Medium + } + } + + Label { + visible: airPodsTrayApp.airpodsConnected + width: parent.width + wrapMode: Text.WordWrap + color: mainWindow.secondaryTextColor + text: airPodsTrayApp.airpodsCommandReady + ? qsTr("Noise control ready. Current mode: ") + airPodsTrayApp.deviceInfo.noiseControlLabel + : qsTr("Audio is still connected. Waiting for the AirPods control channel before sending commands.") + } + + Label { + visible: !airPodsTrayApp.airpodsConnected + width: parent.width + wrapMode: Text.WordWrap + color: mainWindow.secondaryTextColor + text: qsTr("Reconnect your AirPods to see live battery, ear detection, and quick controls here.") + } + } } - } - SegmentedControl { - anchors.horizontalCenter: parent.horizontalCenter - model: [qsTr("Off"), qsTr("Noise Cancellation"), qsTr("Transparency"), qsTr("Adaptive")] - currentIndex: airPodsTrayApp.deviceInfo.noiseControlMode - onCurrentIndexChanged: airPodsTrayApp.setNoiseControlModeInt(currentIndex) - visible: airPodsTrayApp.airpodsConnected - } + Frame { + visible: airPodsTrayApp.airpodsConnected + width: parent.width + padding: 18 + + background: Rectangle { + radius: 14 + color: mainWindow.cardBackgroundColor + border.color: mainWindow.cardBorderColor + } + + Column { + width: parent.width + spacing: 12 - Slider { - visible: airPodsTrayApp.deviceInfo.adaptiveModeActive - from: 0 - to: 100 - stepSize: 1 - value: airPodsTrayApp.deviceInfo.adaptiveNoiseLevel - - Timer { - id: debounceTimer - interval: 500 - onTriggered: if (!parent.pressed) airPodsTrayApp.setAdaptiveNoiseLevel(parent.value) + Label { + text: qsTr("Battery") + font.bold: true + } + + Flow { + id: batteryFlow + width: parent.width + spacing: 8 + + PodColumn { + visible: airPodsTrayApp.deviceInfo.battery.leftPodAvailable + inEar: airPodsTrayApp.deviceInfo.leftPodInEar + iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon + batteryLevel: airPodsTrayApp.deviceInfo.battery.leftPodLevel + isCharging: airPodsTrayApp.deviceInfo.battery.leftPodCharging + indicator: "L" + } + + PodColumn { + visible: airPodsTrayApp.deviceInfo.battery.rightPodAvailable + inEar: airPodsTrayApp.deviceInfo.rightPodInEar + iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon + batteryLevel: airPodsTrayApp.deviceInfo.battery.rightPodLevel + isCharging: airPodsTrayApp.deviceInfo.battery.rightPodCharging + indicator: "R" + } + + PodColumn { + visible: airPodsTrayApp.deviceInfo.battery.caseAvailable + inEar: true + iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.caseIcon + batteryLevel: airPodsTrayApp.deviceInfo.battery.caseLevel + isCharging: airPodsTrayApp.deviceInfo.battery.caseCharging + } + + PodColumn { + visible: airPodsTrayApp.deviceInfo.battery.headsetAvailable + inEar: true + iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon + batteryLevel: airPodsTrayApp.deviceInfo.battery.headsetLevel + isCharging: airPodsTrayApp.deviceInfo.battery.headsetCharging + } + } + + Label { + width: parent.width + wrapMode: Text.WordWrap + color: mainWindow.secondaryTextColor + text: airPodsTrayApp.deviceInfo.batteryStatus + } + } } - onPressedChanged: if (!pressed) airPodsTrayApp.setAdaptiveNoiseLevel(value) - onValueChanged: if (pressed) debounceTimer.restart() + Frame { + visible: airPodsTrayApp.airpodsConnected + width: parent.width + padding: 18 - Label { - text: qsTr("Adaptive Noise Level: ") + parent.value - anchors.top: parent.bottom + background: Rectangle { + radius: 14 + color: mainWindow.cardBackgroundColor + border.color: mainWindow.cardBorderColor + } + + Column { + id: quickControlsColumn + width: parent.width + spacing: 14 + readonly property bool compactMode: width < 360 + readonly property bool controlsReady: airPodsTrayApp.airpodsCommandReady + + function syncQuickControlsFromDeviceInfo() { + if (noiseControlSegment.currentIndex !== airPodsTrayApp.deviceInfo.noiseControlMode) + noiseControlSegment.currentIndex = airPodsTrayApp.deviceInfo.noiseControlMode + if (noiseControlCombo.currentIndex !== airPodsTrayApp.deviceInfo.noiseControlMode) + noiseControlCombo.currentIndex = airPodsTrayApp.deviceInfo.noiseControlMode + if (!adaptiveNoiseSlider.pressed && adaptiveNoiseSlider.value !== airPodsTrayApp.deviceInfo.adaptiveNoiseLevel) + adaptiveNoiseSlider.value = airPodsTrayApp.deviceInfo.adaptiveNoiseLevel + if (conversationalAwarenessSwitch.checked !== airPodsTrayApp.deviceInfo.conversationalAwareness) + conversationalAwarenessSwitch.checked = airPodsTrayApp.deviceInfo.conversationalAwareness + if (hearingAidSwitch.checked !== airPodsTrayApp.deviceInfo.hearingAidEnabled) + hearingAidSwitch.checked = airPodsTrayApp.deviceInfo.hearingAidEnabled + if (oneBudAncSwitch.checked !== airPodsTrayApp.deviceInfo.oneBudANCMode) + oneBudAncSwitch.checked = airPodsTrayApp.deviceInfo.oneBudANCMode + if (allowOffSwitch.checked !== airPodsTrayApp.deviceInfo.allowOffOption) + allowOffSwitch.checked = airPodsTrayApp.deviceInfo.allowOffOption + if (adaptiveVolumeSwitch.checked !== airPodsTrayApp.deviceInfo.adaptiveVolumeEnabled) + adaptiveVolumeSwitch.checked = airPodsTrayApp.deviceInfo.adaptiveVolumeEnabled + if (volSwipeSwitch.checked !== airPodsTrayApp.deviceInfo.volumeSwipeEnabled) + volSwipeSwitch.checked = airPodsTrayApp.deviceInfo.volumeSwipeEnabled + if (!volSwipeIntervalSlider.pressed && volSwipeIntervalSlider.value !== airPodsTrayApp.deviceInfo.volumeSwipeInterval) + volSwipeIntervalSlider.value = airPodsTrayApp.deviceInfo.volumeSwipeInterval + if (caseChargingSwitch.checked !== airPodsTrayApp.deviceInfo.caseChargingSoundsEnabled) + caseChargingSwitch.checked = airPodsTrayApp.deviceInfo.caseChargingSoundsEnabled + stemLongPressSection.stemModes = airPodsTrayApp.deviceInfo.stemLongPressModes + } + + Component.onCompleted: syncQuickControlsFromDeviceInfo() + + Connections { + target: airPodsTrayApp.deviceInfo + + function onNoiseControlModeChangedInt(mode) { + if (noiseControlSegment.currentIndex !== mode) + noiseControlSegment.currentIndex = mode + if (noiseControlCombo.currentIndex !== mode) + noiseControlCombo.currentIndex = mode + } + + function onAdaptiveNoiseLevelChanged(level) { + if (!adaptiveNoiseSlider.pressed && adaptiveNoiseSlider.value !== level) + adaptiveNoiseSlider.value = level + } + + function onConversationalAwarenessChanged(enabled) { + if (conversationalAwarenessSwitch.checked !== enabled) + conversationalAwarenessSwitch.checked = enabled + } + + function onHearingAidEnabledChanged(enabled) { + if (hearingAidSwitch.checked !== enabled) + hearingAidSwitch.checked = enabled + } + + function onOneBudANCModeChanged(enabled) { + if (oneBudAncSwitch.checked !== enabled) + oneBudAncSwitch.checked = enabled + } + + function onAllowOffOptionChanged(enabled) { + if (allowOffSwitch.checked !== enabled) + allowOffSwitch.checked = enabled + } + + function onAdaptiveVolumeEnabledChanged(enabled) { + if (adaptiveVolumeSwitch.checked !== enabled) + adaptiveVolumeSwitch.checked = enabled + } + + function onVolumeSwipeEnabledChanged(enabled) { + if (volSwipeSwitch.checked !== enabled) + volSwipeSwitch.checked = enabled + } + + function onVolumeSwipeIntervalChanged(interval) { + if (!volSwipeIntervalSlider.pressed && volSwipeIntervalSlider.value !== interval) + volSwipeIntervalSlider.value = interval + } + + function onCaseChargingSoundsEnabledChanged(enabled) { + if (caseChargingSwitch.checked !== enabled) + caseChargingSwitch.checked = enabled + } + } + + Label { + text: qsTr("Quick Controls") + font.bold: true + } + + Label { + width: parent.width + wrapMode: Text.WordWrap + color: mainWindow.secondaryTextColor + text: airPodsTrayApp.airpodsCommandReady + ? qsTr("Core controls are ready. Changes apply immediately to the connected AirPods.") + : qsTr("Controls stay disabled until the AirPods command channel finishes initializing.") + } + + SegmentedControl { + id: noiseControlSegment + visible: !quickControlsColumn.compactMode + width: parent.width + model: [qsTr("Off"), qsTr("Noise Cancellation"), qsTr("Transparency"), qsTr("Adaptive")] + currentIndex: airPodsTrayApp.deviceInfo.noiseControlMode + enabled: quickControlsColumn.controlsReady + accentColor: mainWindow.controlAccentColor + disabledBackgroundColor: mainWindow.controlDisabledFillColor + disabledSelectedColor: mainWindow.controlDisabledAccentColor + disabledTextColor: mainWindow.controlDisabledTextColor + onActivated: index => airPodsTrayApp.setNoiseControlModeInt(index) + } + + ComboBox { + id: noiseControlCombo + visible: quickControlsColumn.compactMode + width: parent.width + model: [qsTr("Off"), qsTr("Noise Cancellation"), qsTr("Transparency"), qsTr("Adaptive")] + currentIndex: airPodsTrayApp.deviceInfo.noiseControlMode + enabled: quickControlsColumn.controlsReady + opacity: enabled ? 1 : 0.7 + onActivated: airPodsTrayApp.setNoiseControlModeInt(currentIndex) + } + + Column { + visible: airPodsTrayApp.deviceInfo.adaptiveModeActive + width: parent.width + spacing: 6 + + Label { + width: parent.width + wrapMode: Text.WordWrap + text: qsTr("Adaptive Noise Level: ") + adaptiveNoiseSlider.value + color: quickControlsColumn.controlsReady ? mainWindow.secondaryTextColor : mainWindow.controlDisabledTextColor + } + + Slider { + id: adaptiveNoiseSlider + width: parent.width + enabled: quickControlsColumn.controlsReady + opacity: enabled ? 1 : 0.65 + from: 0 + to: 100 + stepSize: 1 + value: airPodsTrayApp.deviceInfo.adaptiveNoiseLevel + + Timer { + id: debounceTimer + interval: 500 + onTriggered: if (!parent.pressed) airPodsTrayApp.setAdaptiveNoiseLevel(parent.value) + } + + onPressedChanged: if (!pressed) airPodsTrayApp.setAdaptiveNoiseLevel(value) + onValueChanged: if (pressed) debounceTimer.restart() + } + } + + Item { + width: parent.width + implicitHeight: Math.max(conversationalAwarenessLabel.implicitHeight, conversationalAwarenessSwitch.implicitHeight) + + Label { + id: conversationalAwarenessLabel + width: Math.max(0, parent.width - conversationalAwarenessSwitch.implicitWidth - 12) + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + wrapMode: Text.WordWrap + text: qsTr("Conversational Awareness") + color: conversationalAwarenessSwitch.enabled ? mainWindow.primaryTextColor : mainWindow.controlDisabledTextColor + } + + Switch { + id: conversationalAwarenessSwitch + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: airPodsTrayApp.deviceInfo.conversationalAwareness + enabled: quickControlsColumn.controlsReady + leftPadding: 0 + rightPadding: 0 + implicitWidth: 46 + implicitHeight: 28 + contentItem: Item {} + + indicator: Rectangle { + implicitWidth: 46 + implicitHeight: 28 + radius: height / 2 + color: conversationalAwarenessSwitch.enabled + ? (conversationalAwarenessSwitch.checked ? mainWindow.controlAccentColor : mainWindow.controlInactiveColor) + : (conversationalAwarenessSwitch.checked ? mainWindow.controlDisabledAccentColor : mainWindow.controlDisabledFillColor) + border.color: conversationalAwarenessSwitch.enabled + ? (conversationalAwarenessSwitch.checked ? mainWindow.controlAccentColor : mainWindow.controlBorderMutedColor) + : mainWindow.controlBorderMutedColor + + Rectangle { + width: 22 + height: 22 + radius: 11 + x: conversationalAwarenessSwitch.checked ? parent.width - width - 3 : 3 + y: 3 + color: "#ffffff" + border.color: "#d4dbe3" + + Behavior on x { + NumberAnimation { + duration: 120 + easing.type: Easing.OutCubic + } + } + } + } + + onClicked: airPodsTrayApp.setConversationalAwareness(checked) + } + } + + Item { + width: parent.width + implicitHeight: Math.max(hearingAidLabel.implicitHeight, hearingAidSwitch.implicitHeight) + + Label { + id: hearingAidLabel + width: Math.max(0, parent.width - hearingAidSwitch.implicitWidth - 12) + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + wrapMode: Text.WordWrap + text: qsTr("Hearing Aid") + color: hearingAidSwitch.enabled ? mainWindow.primaryTextColor : mainWindow.controlDisabledTextColor + } + + Switch { + id: hearingAidSwitch + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: airPodsTrayApp.deviceInfo.hearingAidEnabled + enabled: quickControlsColumn.controlsReady + leftPadding: 0 + rightPadding: 0 + implicitWidth: 46 + implicitHeight: 28 + contentItem: Item {} + + indicator: Rectangle { + implicitWidth: 46 + implicitHeight: 28 + radius: height / 2 + color: hearingAidSwitch.enabled + ? (hearingAidSwitch.checked ? mainWindow.controlAccentColor : mainWindow.controlInactiveColor) + : (hearingAidSwitch.checked ? mainWindow.controlDisabledAccentColor : mainWindow.controlDisabledFillColor) + border.color: hearingAidSwitch.enabled + ? (hearingAidSwitch.checked ? mainWindow.controlAccentColor : mainWindow.controlBorderMutedColor) + : mainWindow.controlBorderMutedColor + + Rectangle { + width: 22 + height: 22 + radius: 11 + x: hearingAidSwitch.checked ? parent.width - width - 3 : 3 + y: 3 + color: "#ffffff" + border.color: "#d4dbe3" + + Behavior on x { + NumberAnimation { + duration: 120 + easing.type: Easing.OutCubic + } + } + } + } + + onClicked: airPodsTrayApp.setHearingAidEnabled(checked) + } + } + + Item { + width: parent.width + implicitHeight: Math.max(oneBudAncLabel.implicitHeight, oneBudAncSwitch.implicitHeight) + + Label { + id: oneBudAncLabel + width: Math.max(0, parent.width - oneBudAncSwitch.implicitWidth - 12) + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + wrapMode: Text.WordWrap + text: qsTr("One Bud ANC Mode") + color: oneBudAncSwitch.enabled ? mainWindow.primaryTextColor : mainWindow.controlDisabledTextColor + } + + Switch { + id: oneBudAncSwitch + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: airPodsTrayApp.deviceInfo.oneBudANCMode + enabled: quickControlsColumn.controlsReady + leftPadding: 0 + rightPadding: 0 + implicitWidth: 46 + implicitHeight: 28 + contentItem: Item {} + + indicator: Rectangle { + implicitWidth: 46 + implicitHeight: 28 + radius: height / 2 + color: oneBudAncSwitch.enabled + ? (oneBudAncSwitch.checked ? mainWindow.controlAccentColor : mainWindow.controlInactiveColor) + : (oneBudAncSwitch.checked ? mainWindow.controlDisabledAccentColor : mainWindow.controlDisabledFillColor) + border.color: oneBudAncSwitch.enabled + ? (oneBudAncSwitch.checked ? mainWindow.controlAccentColor : mainWindow.controlBorderMutedColor) + : mainWindow.controlBorderMutedColor + + Rectangle { + width: 22 + height: 22 + radius: 11 + x: oneBudAncSwitch.checked ? parent.width - width - 3 : 3 + y: 3 + color: "#ffffff" + border.color: "#d4dbe3" + + Behavior on x { + NumberAnimation { + duration: 120 + easing.type: Easing.OutCubic + } + } + } + } + + onClicked: airPodsTrayApp.setOneBudANCMode(checked) + + ToolTip { + visible: parent.hovered + text: qsTr("Enable ANC when using one AirPod\n(More noise reduction, but uses more battery)") + delay: 500 + } + } + } + + Item { + width: parent.width + implicitHeight: Math.max(allowOffLabel.implicitHeight, allowOffSwitch.implicitHeight) + + Label { + id: allowOffLabel + width: Math.max(0, parent.width - allowOffSwitch.implicitWidth - 12) + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + wrapMode: Text.WordWrap + text: qsTr("Allow Off in Noise Control Cycle") + color: allowOffSwitch.enabled ? mainWindow.primaryTextColor : mainWindow.controlDisabledTextColor + } + + Switch { + id: allowOffSwitch + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: airPodsTrayApp.deviceInfo.allowOffOption + enabled: quickControlsColumn.controlsReady + leftPadding: 0; rightPadding: 0 + implicitWidth: 46; implicitHeight: 28 + contentItem: Item {} + indicator: Rectangle { + implicitWidth: 46; implicitHeight: 28; radius: height / 2 + color: allowOffSwitch.enabled ? (allowOffSwitch.checked ? mainWindow.controlAccentColor : mainWindow.controlInactiveColor) : (allowOffSwitch.checked ? mainWindow.controlDisabledAccentColor : mainWindow.controlDisabledFillColor) + border.color: allowOffSwitch.enabled ? (allowOffSwitch.checked ? mainWindow.controlAccentColor : mainWindow.controlBorderMutedColor) : mainWindow.controlBorderMutedColor + Rectangle { + width: 22; height: 22; radius: 11; y: 3; color: "#ffffff"; border.color: "#d4dbe3" + x: allowOffSwitch.checked ? parent.width - width - 3 : 3 + Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + } + } + onClicked: airPodsTrayApp.setAllowOffOption(checked) + } + } + + Item { + width: parent.width + implicitHeight: Math.max(adaptiveVolumeLabel.implicitHeight, adaptiveVolumeSwitch.implicitHeight) + + Label { + id: adaptiveVolumeLabel + width: Math.max(0, parent.width - adaptiveVolumeSwitch.implicitWidth - 12) + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + wrapMode: Text.WordWrap + text: qsTr("Adaptive Volume") + color: adaptiveVolumeSwitch.enabled ? mainWindow.primaryTextColor : mainWindow.controlDisabledTextColor + } + + Switch { + id: adaptiveVolumeSwitch + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: airPodsTrayApp.deviceInfo.adaptiveVolumeEnabled + enabled: quickControlsColumn.controlsReady + leftPadding: 0; rightPadding: 0 + implicitWidth: 46; implicitHeight: 28 + contentItem: Item {} + indicator: Rectangle { + implicitWidth: 46; implicitHeight: 28; radius: height / 2 + color: adaptiveVolumeSwitch.enabled ? (adaptiveVolumeSwitch.checked ? mainWindow.controlAccentColor : mainWindow.controlInactiveColor) : (adaptiveVolumeSwitch.checked ? mainWindow.controlDisabledAccentColor : mainWindow.controlDisabledFillColor) + border.color: adaptiveVolumeSwitch.enabled ? (adaptiveVolumeSwitch.checked ? mainWindow.controlAccentColor : mainWindow.controlBorderMutedColor) : mainWindow.controlBorderMutedColor + Rectangle { + width: 22; height: 22; radius: 11; y: 3; color: "#ffffff"; border.color: "#d4dbe3" + x: adaptiveVolumeSwitch.checked ? parent.width - width - 3 : 3 + Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + } + } + onClicked: airPodsTrayApp.setAdaptiveVolumeEnabled(checked) + } + } + + Column { + width: parent.width + spacing: 6 + + Item { + width: parent.width + implicitHeight: Math.max(volSwipeLabel.implicitHeight, volSwipeSwitch.implicitHeight) + + Label { + id: volSwipeLabel + width: Math.max(0, parent.width - volSwipeSwitch.implicitWidth - 12) + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + wrapMode: Text.WordWrap + text: qsTr("Volume Swipe") + color: volSwipeSwitch.enabled ? mainWindow.primaryTextColor : mainWindow.controlDisabledTextColor + } + + Switch { + id: volSwipeSwitch + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: airPodsTrayApp.deviceInfo.volumeSwipeEnabled + enabled: quickControlsColumn.controlsReady + leftPadding: 0; rightPadding: 0 + implicitWidth: 46; implicitHeight: 28 + contentItem: Item {} + indicator: Rectangle { + implicitWidth: 46; implicitHeight: 28; radius: height / 2 + color: volSwipeSwitch.enabled ? (volSwipeSwitch.checked ? mainWindow.controlAccentColor : mainWindow.controlInactiveColor) : (volSwipeSwitch.checked ? mainWindow.controlDisabledAccentColor : mainWindow.controlDisabledFillColor) + border.color: volSwipeSwitch.enabled ? (volSwipeSwitch.checked ? mainWindow.controlAccentColor : mainWindow.controlBorderMutedColor) : mainWindow.controlBorderMutedColor + Rectangle { + width: 22; height: 22; radius: 11; y: 3; color: "#ffffff"; border.color: "#d4dbe3" + x: volSwipeSwitch.checked ? parent.width - width - 3 : 3 + Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + } + } + onClicked: airPodsTrayApp.setVolumeSwipeEnabled(checked) + } + } + + Column { + visible: volSwipeSwitch.checked + width: parent.width + spacing: 4 + + Label { + text: qsTr("Swipe Sensitivity: ") + volSwipeIntervalSlider.value + color: quickControlsColumn.controlsReady ? mainWindow.secondaryTextColor : mainWindow.controlDisabledTextColor + } + + Slider { + id: volSwipeIntervalSlider + width: parent.width + enabled: quickControlsColumn.controlsReady + opacity: enabled ? 1 : 0.65 + from: 0; to: 100; stepSize: 1 + value: airPodsTrayApp.deviceInfo.volumeSwipeInterval + + Timer { + id: volSwipeDebounce + interval: 500 + onTriggered: if (!parent.pressed) airPodsTrayApp.setVolumeSwipeInterval(parent.value) + } + + onPressedChanged: if (!pressed) airPodsTrayApp.setVolumeSwipeInterval(value) + onValueChanged: if (pressed) volSwipeDebounce.restart() + } + } + } + + Item { + width: parent.width + implicitHeight: Math.max(caseChargingLabel.implicitHeight, caseChargingSwitch.implicitHeight) + + Label { + id: caseChargingLabel + width: Math.max(0, parent.width - caseChargingSwitch.implicitWidth - 12) + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + wrapMode: Text.WordWrap + text: qsTr("Case Charging Sounds") + color: caseChargingSwitch.enabled ? mainWindow.primaryTextColor : mainWindow.controlDisabledTextColor + } + + Switch { + id: caseChargingSwitch + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: airPodsTrayApp.deviceInfo.caseChargingSoundsEnabled + enabled: quickControlsColumn.controlsReady + leftPadding: 0; rightPadding: 0 + implicitWidth: 46; implicitHeight: 28 + contentItem: Item {} + indicator: Rectangle { + implicitWidth: 46; implicitHeight: 28; radius: height / 2 + color: caseChargingSwitch.enabled ? (caseChargingSwitch.checked ? mainWindow.controlAccentColor : mainWindow.controlInactiveColor) : (caseChargingSwitch.checked ? mainWindow.controlDisabledAccentColor : mainWindow.controlDisabledFillColor) + border.color: caseChargingSwitch.enabled ? (caseChargingSwitch.checked ? mainWindow.controlAccentColor : mainWindow.controlBorderMutedColor) : mainWindow.controlBorderMutedColor + Rectangle { + width: 22; height: 22; radius: 11; y: 3; color: "#ffffff"; border.color: "#d4dbe3" + x: caseChargingSwitch.checked ? parent.width - width - 3 : 3 + Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + } + } + onClicked: airPodsTrayApp.setCaseChargingSoundsEnabled(checked) + } + } + + Column { + id: stemLongPressSection + width: parent.width + spacing: 4 + property int stemModes: airPodsTrayApp.deviceInfo.stemLongPressModes + + Connections { + target: airPodsTrayApp.deviceInfo + function onStemLongPressModesChanged(modes) { + stemLongPressSection.stemModes = modes + } + } + + function toggleBit(bit, on) { + var m = on ? (stemModes | bit) : (stemModes & ~bit) + var count = 0; var tmp = m + while (tmp) { count += (tmp & 1); tmp = tmp >>> 1 } + if (count >= 2) airPodsTrayApp.setStemLongPressModes(m) + } + + Label { + width: parent.width + wrapMode: Text.WordWrap + text: qsTr("Press-and-Hold Stem Cycles Through:") + color: quickControlsColumn.controlsReady ? mainWindow.primaryTextColor : mainWindow.controlDisabledTextColor + } + + Flow { + width: parent.width + spacing: 4 + opacity: quickControlsColumn.controlsReady ? 1.0 : 0.65 + + CheckBox { + text: qsTr("Off") + checked: stemLongPressSection.stemModes & 0x01 + enabled: quickControlsColumn.controlsReady + onClicked: stemLongPressSection.toggleBit(0x01, checked) + } + CheckBox { + text: qsTr("Noise Cancellation") + checked: stemLongPressSection.stemModes & 0x02 + enabled: quickControlsColumn.controlsReady + onClicked: stemLongPressSection.toggleBit(0x02, checked) + } + CheckBox { + text: qsTr("Transparency") + checked: stemLongPressSection.stemModes & 0x04 + enabled: quickControlsColumn.controlsReady + onClicked: stemLongPressSection.toggleBit(0x04, checked) + } + CheckBox { + text: qsTr("Adaptive") + checked: stemLongPressSection.stemModes & 0x08 + enabled: quickControlsColumn.controlsReady + onClicked: stemLongPressSection.toggleBit(0x08, checked) + } + } + + Label { + color: mainWindow.secondaryTextColor + font.pixelSize: 11 + text: qsTr("Select at least 2 modes") + } + } + + Label { + visible: quickControlsColumn.compactMode + width: parent.width + wrapMode: Text.WordWrap + color: mainWindow.secondaryTextColor + text: qsTr("Tip: if the segmented noise control feels cramped on a narrow window, LibrePods switches to a dropdown automatically.") + } + } } - } - Switch { - visible: airPodsTrayApp.airpodsConnected - text: qsTr("Conversational Awareness") - checked: airPodsTrayApp.deviceInfo.conversationalAwareness - onCheckedChanged: airPodsTrayApp.setConversationalAwareness(checked) - } + Frame { + visible: airPodsTrayApp.airpodsConnected + width: parent.width + padding: 18 + + background: Rectangle { + radius: 14 + color: mainWindow.cardBackgroundColor + border.color: mainWindow.cardBorderColor + } + + Column { + width: parent.width + spacing: 12 + + Label { + text: qsTr("Live Status") + font.bold: true + } + + Label { + width: parent.width + wrapMode: Text.WordWrap + color: mainWindow.secondaryTextColor + text: qsTr("Current device signals and setup state update here while the AirPods are connected.") + } + + Rectangle { + id: earDetectionStatusCard + width: parent.width + height: earDetectionStatusContent.implicitHeight + 24 + radius: 10 + color: mainWindow.statusRowBackgroundColor + + Column { + id: earDetectionStatusContent + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Label { + text: qsTr("Ear Detection") + font.bold: true + } + + Label { + width: parent.width + wrapMode: Text.WordWrap + color: mainWindow.secondaryTextColor + text: airPodsTrayApp.deviceInfo.earStatusSummary + } + } + } + + Rectangle { + id: hearingAidStatusCard + width: parent.width + height: hearingAidStatusContent.implicitHeight + 24 + radius: 10 + color: mainWindow.statusRowBackgroundColor - Switch { - visible: airPodsTrayApp.airpodsConnected - text: qsTr("Hearing Aid") - checked: airPodsTrayApp.deviceInfo.hearingAidEnabled - onCheckedChanged: airPodsTrayApp.setHearingAidEnabled(checked) + Column { + id: hearingAidStatusContent + anchors.fill: parent + anchors.margins: 12 + spacing: 4 + + Label { + text: qsTr("Advanced Hearing Aid Setup") + font.bold: true + } + + Label { + width: parent.width + wrapMode: Text.WordWrap + color: mainWindow.secondaryTextColor + text: airPodsTrayApp.hearingAidSetupStatus + } + } + } + } + } + } } } @@ -185,19 +949,28 @@ ApplicationWindow { } } - Component { - id: settingsPage - Page { - id: settingsPageItem - title: qsTr("Settings") + Component { + id: settingsPage + Page { + id: settingsPageItem + title: qsTr("Settings") - ScrollView { - anchors.fill: parent + ScrollView { + id: settingsScrollView + anchors.fill: parent + contentWidth: availableWidth + clip: true + + Item { + width: Math.max(0, settingsScrollView.availableWidth) + implicitHeight: settingsContent.implicitHeight + 40 - Column { - width: parent.width - spacing: 20 - padding: 20 + Column { + id: settingsContent + x: 20 + y: 20 + width: Math.max(0, parent.width - 40) + spacing: 20 Label { text: qsTr("Settings") @@ -219,39 +992,125 @@ ApplicationWindow { currentIndex: airPodsTrayApp.earDetectionBehavior onActivated: airPodsTrayApp.earDetectionBehavior = currentIndex } + + Label { + width: parent.width + wrapMode: Text.WordWrap + color: "#666666" + text: qsTr("Current ear detection: ") + airPodsTrayApp.deviceInfo.earStatusSummary + } } - Switch { - text: qsTr("Cross-Device Connectivity with Android") - checked: airPodsTrayApp.crossDeviceEnabled - onCheckedChanged: { - airPodsTrayApp.setCrossDeviceEnabled(checked) + Frame { + width: settingsContent.width + + Column { + width: settingsContent.width + spacing: 8 + + Label { + width: settingsContent.width + wrapMode: Text.WordWrap + font.bold: true + text: qsTr("Cross-Device / Handoff") + } + + Label { + width: settingsContent.width + wrapMode: Text.WordWrap + text: qsTr("Linux can relay packets to the Android app over Bluetooth, request handoff when local playback starts, and export Magic Cloud Keys as a QR for another device.") + } + + Switch { + text: qsTr("Cross-Device Connectivity with Android") + checked: airPodsTrayApp.crossDeviceEnabled + onClicked: airPodsTrayApp.setCrossDeviceEnabled(checked) + } + + Row { + width: settingsContent.width + spacing: 10 + + TextField { + id: newPhoneMacField + width: Math.max(0, settingsContent.width - 220) + placeholderText: (PHONE_MAC_ADDRESS !== "" ? PHONE_MAC_ADDRESS : "00:00:00:00:00:00") + maximumLength: 32 + } + + Button { + text: qsTr("Save Phone MAC") + onClicked: airPodsTrayApp.setPhoneMac(newPhoneMacField.text) + } + + Button { + text: qsTr("Clear") + onClicked: { + newPhoneMacField.text = "" + airPodsTrayApp.setPhoneMac("") + } + } + } + + Label { + width: settingsContent.width + wrapMode: Text.WordWrap + color: "#666666" + text: airPodsTrayApp.phoneMacStatus + } + + Label { + width: settingsContent.width + wrapMode: Text.WordWrap + color: "#666666" + text: airPodsTrayApp.phoneConnected + ? qsTr("Phone relay status: connected") + : qsTr("Phone relay status: disconnected") + } + + Row { + spacing: 10 + + Button { + text: qsTr("Reconnect Phone Relay") + enabled: airPodsTrayApp.crossDeviceEnabled + onClicked: airPodsTrayApp.reconnectPhoneRelay() + } + + Button { + text: qsTr("Fetch Magic Cloud Keys") + enabled: airPodsTrayApp.airpodsCommandReady + onClicked: airPodsTrayApp.initiateMagicPairing() + } + } + + Label { + width: settingsContent.width + wrapMode: Text.WordWrap + color: "#666666" + text: airPodsTrayApp.deviceInfo.hasMagicCloudKeys + ? qsTr("Magic Cloud Keys: ready to export as QR") + : qsTr("Magic Cloud Keys: not fetched yet. Connect AirPods and use 'Fetch Magic Cloud Keys'.") + } + + Button { + text: qsTr("Show Magic Cloud Keys QR") + enabled: airPodsTrayApp.deviceInfo.hasMagicCloudKeys + onClicked: keysQrDialog.show() + } } } Switch { text: qsTr("Auto-Start on Login") checked: airPodsTrayApp.autoStartManager.autoStartEnabled - onCheckedChanged: airPodsTrayApp.autoStartManager.autoStartEnabled = checked + onClicked: airPodsTrayApp.autoStartManager.autoStartEnabled = checked } Switch { text: qsTr("Enable System Notifications") checked: airPodsTrayApp.notificationsEnabled - onCheckedChanged: airPodsTrayApp.notificationsEnabled = checked - } - - Switch { - visible: airPodsTrayApp.airpodsConnected - text: qsTr("One Bud ANC Mode") - checked: airPodsTrayApp.deviceInfo.oneBudANCMode - onCheckedChanged: airPodsTrayApp.deviceInfo.oneBudANCMode = checked - - ToolTip { - visible: parent.hovered - text: qsTr("Enable ANC when using one AirPod\n(More noise reduction, but uses more battery)") - delay: 500 - } + onClicked: airPodsTrayApp.notificationsEnabled = checked } Row { @@ -280,36 +1139,253 @@ ApplicationWindow { Button { text: qsTr("Rename") + enabled: airPodsTrayApp.airpodsCommandReady && newNameField.text.length > 0 onClicked: airPodsTrayApp.renameAirPods(newNameField.text) } } - Row { - spacing: 10 + Frame { + width: settingsContent.width visible: airPodsTrayApp.airpodsConnected - TextField { - id: newPhoneMacField - placeholderText: (PHONE_MAC_ADDRESS !== "" ? PHONE_MAC_ADDRESS : "00:00:00:00:00:00") - maximumLength: 32 - } + Column { + width: settingsContent.width + spacing: 8 - Button { - text: qsTr("Change Phone MAC") - onClicked: airPodsTrayApp.setPhoneMac(newPhoneMacField.text) + Label { + width: settingsContent.width + wrapMode: Text.WordWrap + font.bold: true + text: qsTr("Customize Transparency Mode") + } + + Label { + width: settingsContent.width + wrapMode: Text.WordWrap + color: "#666666" + text: qsTr("Adjust how transparency mode sounds. Press Apply to send settings to the AirPods.") + } + + Switch { + id: customTranspEnabledSwitch + text: qsTr("Enabled") + checked: airPodsTrayApp.deviceInfo.customizeTransparencyEnabled + } + + Label { font.bold: true; text: qsTr("Left Bud") } + + Item { + width: parent.width; height: 28 + Label { id: lAmpLbl; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Amplification:"); width: 110 } + Label { id: lAmpVal; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: (leftAmpSlider.value / 50.0).toFixed(2); width: 36; horizontalAlignment: Text.AlignRight } + Slider { id: leftAmpSlider; from: 0; to: 100; stepSize: 1; value: 50; anchors.left: lAmpLbl.right; anchors.right: lAmpVal.left; anchors.verticalCenter: parent.verticalCenter; anchors.leftMargin: 8; anchors.rightMargin: 4 } + } + + Item { + width: parent.width; height: 28 + Label { id: lToneLbl; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Tone:"); width: 110 } + Label { id: lToneVal; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: (leftToneSlider.value / 50.0).toFixed(2); width: 36; horizontalAlignment: Text.AlignRight } + Slider { id: leftToneSlider; from: 0; to: 100; stepSize: 1; value: 50; anchors.left: lToneLbl.right; anchors.right: lToneVal.left; anchors.verticalCenter: parent.verticalCenter; anchors.leftMargin: 8; anchors.rightMargin: 4 } + } + + Item { + width: parent.width; height: 28 + Label { anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Conversation Boost:"); width: 110 } + CheckBox { id: leftConvBoost; anchors.left: parent.left; anchors.leftMargin: 118; anchors.verticalCenter: parent.verticalCenter } + } + + Item { + width: parent.width; height: 28 + Label { id: lAnrLbl; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Ambient Noise:"); width: 110 } + Label { id: lAnrVal; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: (leftAmbientSlider.value / 100.0).toFixed(2); width: 36; horizontalAlignment: Text.AlignRight } + Slider { id: leftAmbientSlider; from: 0; to: 100; stepSize: 1; value: 0; anchors.left: lAnrLbl.right; anchors.right: lAnrVal.left; anchors.verticalCenter: parent.verticalCenter; anchors.leftMargin: 8; anchors.rightMargin: 4 } + } + + Label { font.bold: true; text: qsTr("Right Bud") } + + Item { + width: parent.width; height: 28 + Label { id: rAmpLbl; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Amplification:"); width: 110 } + Label { id: rAmpVal; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: (rightAmpSlider.value / 50.0).toFixed(2); width: 36; horizontalAlignment: Text.AlignRight } + Slider { id: rightAmpSlider; from: 0; to: 100; stepSize: 1; value: 50; anchors.left: rAmpLbl.right; anchors.right: rAmpVal.left; anchors.verticalCenter: parent.verticalCenter; anchors.leftMargin: 8; anchors.rightMargin: 4 } + } + + Item { + width: parent.width; height: 28 + Label { id: rToneLbl; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Tone:"); width: 110 } + Label { id: rToneVal; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: (rightToneSlider.value / 50.0).toFixed(2); width: 36; horizontalAlignment: Text.AlignRight } + Slider { id: rightToneSlider; from: 0; to: 100; stepSize: 1; value: 50; anchors.left: rToneLbl.right; anchors.right: rToneVal.left; anchors.verticalCenter: parent.verticalCenter; anchors.leftMargin: 8; anchors.rightMargin: 4 } + } + + Item { + width: parent.width; height: 28 + Label { anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Conversation Boost:"); width: 110 } + CheckBox { id: rightConvBoost; anchors.left: parent.left; anchors.leftMargin: 118; anchors.verticalCenter: parent.verticalCenter } + } + + Item { + width: parent.width; height: 28 + Label { id: rAnrLbl; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Ambient Noise:"); width: 110 } + Label { id: rAnrVal; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: (rightAmbientSlider.value / 100.0).toFixed(2); width: 36; horizontalAlignment: Text.AlignRight } + Slider { id: rightAmbientSlider; from: 0; to: 100; stepSize: 1; value: 0; anchors.left: rAnrLbl.right; anchors.right: rAnrVal.left; anchors.verticalCenter: parent.verticalCenter; anchors.leftMargin: 8; anchors.rightMargin: 4 } + } + + Button { + text: qsTr("Apply Transparency Settings") + enabled: airPodsTrayApp.airpodsCommandReady + onClicked: airPodsTrayApp.applyCustomizeTransparency( + customTranspEnabledSwitch.checked, + [], leftAmpSlider.value / 50.0, leftToneSlider.value / 50.0, leftConvBoost.checked, leftAmbientSlider.value / 100.0, + [], rightAmpSlider.value / 50.0, rightToneSlider.value / 50.0, rightConvBoost.checked, rightAmbientSlider.value / 100.0 + ) + } } } + Frame { + width: settingsContent.width + visible: airPodsTrayApp.airpodsConnected + + Column { + width: settingsContent.width + spacing: 8 + + Label { + width: settingsContent.width + wrapMode: Text.WordWrap + font.bold: true + text: qsTr("Headphone Accommodation") + } + + Label { + width: settingsContent.width + wrapMode: Text.WordWrap + color: "#666666" + text: qsTr("Amplifies soft sounds and adjusts frequencies to suit your hearing. Press Apply to send.") + } + + Switch { + id: hpAccomPhoneSwitch + text: qsTr("Enable for Phone Calls") + checked: airPodsTrayApp.deviceInfo.headphoneAccomPhoneEnabled + } + + Switch { + id: hpAccomMediaSwitch + text: qsTr("Enable for Media") + checked: airPodsTrayApp.deviceInfo.headphoneAccomMediaEnabled + } + + Label { + font.bold: true + text: qsTr("8-Band EQ") + } + + Item { width: parent.width; height: 28 + Label { id: eq0Lbl; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Band 1:"); width: 60 } + Label { id: eq0Val; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: hpEq0.value; width: 28; horizontalAlignment: Text.AlignRight } + Slider { id: hpEq0; from: 0; to: 100; stepSize: 1; value: 0; anchors.left: eq0Lbl.right; anchors.right: eq0Val.left; anchors.verticalCenter: parent.verticalCenter; anchors.leftMargin: 8; anchors.rightMargin: 4 } + } + Item { width: parent.width; height: 28 + Label { id: eq1Lbl; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Band 2:"); width: 60 } + Label { id: eq1Val; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: hpEq1.value; width: 28; horizontalAlignment: Text.AlignRight } + Slider { id: hpEq1; from: 0; to: 100; stepSize: 1; value: 0; anchors.left: eq1Lbl.right; anchors.right: eq1Val.left; anchors.verticalCenter: parent.verticalCenter; anchors.leftMargin: 8; anchors.rightMargin: 4 } + } + Item { width: parent.width; height: 28 + Label { id: eq2Lbl; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Band 3:"); width: 60 } + Label { id: eq2Val; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: hpEq2.value; width: 28; horizontalAlignment: Text.AlignRight } + Slider { id: hpEq2; from: 0; to: 100; stepSize: 1; value: 0; anchors.left: eq2Lbl.right; anchors.right: eq2Val.left; anchors.verticalCenter: parent.verticalCenter; anchors.leftMargin: 8; anchors.rightMargin: 4 } + } + Item { width: parent.width; height: 28 + Label { id: eq3Lbl; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Band 4:"); width: 60 } + Label { id: eq3Val; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: hpEq3.value; width: 28; horizontalAlignment: Text.AlignRight } + Slider { id: hpEq3; from: 0; to: 100; stepSize: 1; value: 0; anchors.left: eq3Lbl.right; anchors.right: eq3Val.left; anchors.verticalCenter: parent.verticalCenter; anchors.leftMargin: 8; anchors.rightMargin: 4 } + } + Item { width: parent.width; height: 28 + Label { id: eq4Lbl; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Band 5:"); width: 60 } + Label { id: eq4Val; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: hpEq4.value; width: 28; horizontalAlignment: Text.AlignRight } + Slider { id: hpEq4; from: 0; to: 100; stepSize: 1; value: 0; anchors.left: eq4Lbl.right; anchors.right: eq4Val.left; anchors.verticalCenter: parent.verticalCenter; anchors.leftMargin: 8; anchors.rightMargin: 4 } + } + Item { width: parent.width; height: 28 + Label { id: eq5Lbl; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Band 6:"); width: 60 } + Label { id: eq5Val; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: hpEq5.value; width: 28; horizontalAlignment: Text.AlignRight } + Slider { id: hpEq5; from: 0; to: 100; stepSize: 1; value: 0; anchors.left: eq5Lbl.right; anchors.right: eq5Val.left; anchors.verticalCenter: parent.verticalCenter; anchors.leftMargin: 8; anchors.rightMargin: 4 } + } + Item { width: parent.width; height: 28 + Label { id: eq6Lbl; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Band 7:"); width: 60 } + Label { id: eq6Val; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: hpEq6.value; width: 28; horizontalAlignment: Text.AlignRight } + Slider { id: hpEq6; from: 0; to: 100; stepSize: 1; value: 0; anchors.left: eq6Lbl.right; anchors.right: eq6Val.left; anchors.verticalCenter: parent.verticalCenter; anchors.leftMargin: 8; anchors.rightMargin: 4 } + } + Item { width: parent.width; height: 28 + Label { id: eq7Lbl; anchors.left: parent.left; anchors.verticalCenter: parent.verticalCenter; text: qsTr("Band 8:"); width: 60 } + Label { id: eq7Val; anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; text: hpEq7.value; width: 28; horizontalAlignment: Text.AlignRight } + Slider { id: hpEq7; from: 0; to: 100; stepSize: 1; value: 0; anchors.left: eq7Lbl.right; anchors.right: eq7Val.left; anchors.verticalCenter: parent.verticalCenter; anchors.leftMargin: 8; anchors.rightMargin: 4 } + } - Button { - text: qsTr("Show Magic Cloud Keys QR") - onClicked: keysQrDialog.show() + Button { + text: qsTr("Apply Headphone Accommodation") + enabled: airPodsTrayApp.airpodsCommandReady + onClicked: airPodsTrayApp.applyHeadphoneAccommodation( + hpAccomPhoneSwitch.checked, hpAccomMediaSwitch.checked, + [hpEq0.value, hpEq1.value, hpEq2.value, hpEq3.value, + hpEq4.value, hpEq5.value, hpEq6.value, hpEq7.value] + ) + } + } } - KeysQRDialog { - id: keysQrDialog - encKey: airPodsTrayApp.deviceInfo.magicAccEncKey - irk: airPodsTrayApp.deviceInfo.magicAccIRK + Frame { + width: settingsContent.width + + Column { + width: settingsContent.width + spacing: 8 + + Label { + width: settingsContent.width + wrapMode: Text.WordWrap + font.bold: true + text: qsTr("Head Tracking / Gestures") + } + + Label { + width: settingsContent.width + wrapMode: Text.WordWrap + text: qsTr("Linux now exposes the existing Python gesture detector so you can test nod and shake detection for the connected AirPods without touching Android.") + } + + Label { + width: settingsContent.width + wrapMode: Text.WordWrap + text: qsTr("Current device: ") + (airPodsTrayApp.deviceInfo.bluetoothAddress !== "" ? airPodsTrayApp.deviceInfo.bluetoothAddress : qsTr("No Bluetooth address detected yet")) + } + + Button { + text: qsTr("Open Head Gesture Detector") + enabled: airPodsTrayApp.deviceInfo.bluetoothAddress !== "" + onClicked: airPodsTrayApp.openHeadTrackingGestures() + } + + Label { + width: settingsContent.width + wrapMode: Text.WordWrap + color: "#666666" + text: airPodsTrayApp.headTrackingStatus + } + + Label { + width: settingsContent.width + wrapMode: Text.WordWrap + color: "#666666" + text: qsTr("Requirements: Python 3, a terminal emulator, and the Python bluetooth dependency used by the existing `head-tracking` scripts. This phase only launches the current detector; it does not add inline visualization, alternate packets, or multi-device handoff.") + } + } + } + KeysQRDialog { + id: keysQrDialog + encKey: airPodsTrayApp.deviceInfo.magicAccEncKey + irk: airPodsTrayApp.deviceInfo.magicAccIRK + } } } } diff --git a/linux/SegmentedControl.qml b/linux/SegmentedControl.qml index b959e37f2..64820f703 100644 --- a/linux/SegmentedControl.qml +++ b/linux/SegmentedControl.qml @@ -6,14 +6,20 @@ import QtQuick.Controls 2.15 Control { id: root + signal activated(int index) + // Properties property var model: ["Option 1", "Option 2"] // Default model property int currentIndex: 0 + property color accentColor: palette.highlight + property color disabledBackgroundColor: "#eef1f5" + property color disabledSelectedColor: "#b9c4d2" + property color disabledTextColor: "#8b96a3" // Colors using system palette - readonly property color backgroundColor: palette.light - readonly property color selectedColor: palette.highlight - readonly property color textColor: palette.buttonText + readonly property color backgroundColor: root.enabled ? palette.light : root.disabledBackgroundColor + readonly property color selectedColor: root.enabled ? root.accentColor : root.disabledSelectedColor + readonly property color textColor: root.enabled ? palette.buttonText : root.disabledTextColor readonly property color selectedTextColor: palette.highlightedText // System palette @@ -52,6 +58,7 @@ Control { // Removed: width: (root.availableWidth - (root.model.length - 1) * root.padding) / root.model.length height: root.availableHeight focusPolicy: Qt.NoFocus // Let the root control handle focus + enabled: root.enabled // Add explicit text color contentItem: Text { @@ -81,6 +88,7 @@ Control { onClicked: { if (root.currentIndex !== index) { root.currentIndex = index; + root.activated(index); } } } @@ -92,23 +100,33 @@ Control { if (event.key === Qt.Key_Left) { if (root.currentIndex > 0) { root.currentIndex--; + root.activated(root.currentIndex); event.accepted = true; } } else if (event.key === Qt.Key_Right) { if (root.currentIndex < root.model.length - 1) { root.currentIndex++; + root.activated(root.currentIndex); event.accepted = true; } } else if (event.key === Qt.Key_Home) { - root.currentIndex = 0; - event.accepted = true; + if (root.currentIndex !== 0) { + root.currentIndex = 0; + root.activated(root.currentIndex); + event.accepted = true; + } } else if (event.key === Qt.Key_End) { - root.currentIndex = root.model.length - 1; - event.accepted = true; + const lastIndex = root.model.length - 1; + if (root.currentIndex !== lastIndex) { + root.currentIndex = lastIndex; + root.activated(root.currentIndex); + event.accepted = true; + } } else if (event.key >= Qt.Key_1 && event.key <= Qt.Key_9) { const index = event.key - Qt.Key_1; - if (index <= root.model.length) { + if (index < root.model.length && root.currentIndex !== index) { root.currentIndex = index; + root.activated(root.currentIndex); event.accepted = true; } } From b5c798357517b35f0c295238ed36f5c413cf46b3 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 11 Apr 2026 07:45:39 -0300 Subject: [PATCH 4/5] feat(linux): add Spanish translation and install head-tracking scripts - Add librepods_es.ts with full Spanish (es/es_AR) UI translations - Install hearing-aid-adjustments.py and head-tracking scripts to share/librepods on system install - Add argparse support to gestures.py for MAC address argument --- head-tracking/gestures.py | 9 +- linux/CMakeLists.txt | 8 + linux/translations/librepods_es.ts | 391 +++++++++++++++++++++++++++++ 3 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 linux/translations/librepods_es.ts diff --git a/head-tracking/gestures.py b/head-tracking/gestures.py index a598409de..9d230b8ba 100644 --- a/head-tracking/gestures.py +++ b/head-tracking/gestures.py @@ -1,3 +1,4 @@ +import argparse import logging import statistics import time @@ -347,6 +348,10 @@ def start_detection(self) -> None: log.info(f"{Colors.GREEN}Gesture detection complete.{Colors.RESET}") if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Detect AirPods head gestures") + parser.add_argument("mac_address", nargs="?", help="Bluetooth MAC address of the AirPods") + args = parser.parse_args() + print(f"{Colors.BG_BLACK}{Colors.CYAN}╔════════════════════════════════════════╗{Colors.RESET}") print(f"{Colors.BG_BLACK}{Colors.CYAN}║ AirPods Head Gesture Detector ║{Colors.RESET}") print(f"{Colors.BG_BLACK}{Colors.CYAN}╚════════════════════════════════════════╝{Colors.RESET}") @@ -355,4 +360,6 @@ def start_detection(self) -> None: print(f"{Colors.RED}• NO: {Colors.WHITE}shaking head left and right{Colors.RESET}\n") detector: GestureDetector = GestureDetector() - detector.start_detection() \ No newline at end of file + if args.mac_address: + detector.bt_addr = args.mac_address + detector.start_detection() diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 43051ce69..7e7d93283 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -14,6 +14,7 @@ qt_standard_project_setup() # Translation files set(TS_FILES translations/librepods_tr.ts + translations/librepods_es.ts ) qt_add_executable(librepods @@ -100,6 +101,13 @@ install(FILES assets/me.kavishdevar.librepods.desktop DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/applications") install(FILES assets/librepods.svg DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/icons/hicolor/scalable/apps") +install(FILES hearing-aid-adjustments.py + DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/librepods") +install(FILES + ../head-tracking/gestures.py + ../head-tracking/connection_manager.py + ../head-tracking/colors.py + DESTINATION "${CMAKE_INSTALL_DATAROOTDIR}/librepods/head-tracking") # Translation support qt_add_translations(librepods diff --git a/linux/translations/librepods_es.ts b/linux/translations/librepods_es.ts new file mode 100644 index 000000000..494990d58 --- /dev/null +++ b/linux/translations/librepods_es.ts @@ -0,0 +1,391 @@ + + + + + Main + + LibrePods + LibrePods + + + Connected + Conectado + + + Disconnected + Desconectado + + + Noise control ready. Current mode: + Control de ruido listo. Modo actual: + + + Audio is still connected. Waiting for the AirPods control channel before sending commands. + El audio sigue conectado. Esperando el canal de control de los AirPods antes de enviar comandos. + + + Reconnect your AirPods to see live battery, ear detection, and quick controls here. + Reconectá los AirPods para ver batería en tiempo real, detección de oído y controles rápidos. + + + Battery + Batería + + + Quick Controls + Controles rápidos + + + Core controls are ready. Changes apply immediately to the connected AirPods. + Los controles están listos. Los cambios se aplican de inmediato a los AirPods conectados. + + + Controls stay disabled until the AirPods command channel finishes initializing. + Los controles permanecen deshabilitados hasta que el canal de comandos termine de inicializarse. + + + Off + Apagado + + + Noise Cancellation + Cancelación de ruido + + + Transparency + Transparencia + + + Adaptive + Adaptativo + + + Adaptive Noise Level: + Nivel de ruido adaptativo: + + + Conversational Awareness + Detección de conversación + + + Hearing Aid + Audífono + + + One Bud ANC Mode + Modo ANC con un solo auricular + + + Enable ANC when using one AirPod +(More noise reduction, but uses more battery) + Habilitar ANC con un solo AirPod +(Más reducción de ruido, pero consume más batería) + + + Tip: if the segmented noise control feels cramped on a narrow window, LibrePods switches to a dropdown automatically. + Tip: si el control de ruido segmentado se ve apretado en una ventana angosta, LibrePods cambia automáticamente a un menú desplegable. + + + Live Status + Estado en tiempo real + + + Current device signals and setup state update here while the AirPods are connected. + Las señales del dispositivo y el estado de configuración se actualizan aquí mientras los AirPods están conectados. + + + Ear Detection + Detección de oído + + + Advanced Hearing Aid Setup + Configuración avanzada de audífono + + + Settings + Configuración + + + Pause Behavior When Removing AirPods: + Pausa al quitar los AirPods: + + + One Removed + Uno quitado + + + Both Removed + Ambos quitados + + + Never + Nunca + + + Current ear detection: + Detección de oído actual: + + + Cross-Device / Handoff + Multi-dispositivo / Handoff + + + Linux can relay packets to the Android app over Bluetooth, request handoff when local playback starts, and export Magic Cloud Keys as a QR for another device. + Linux puede retransmitir paquetes a la app de Android por Bluetooth, solicitar handoff al iniciar reproducción local y exportar las Magic Cloud Keys como QR para otro dispositivo. + + + Cross-Device Connectivity with Android + Conectividad multi-dispositivo con Android + + + Save Phone MAC + Guardar MAC del teléfono + + + Clear + Limpiar + + + Phone relay status: connected + Estado del relay del teléfono: conectado + + + Phone relay status: disconnected + Estado del relay del teléfono: desconectado + + + Reconnect Phone Relay + Reconectar relay del teléfono + + + Fetch Magic Cloud Keys + Obtener Magic Cloud Keys + + + Magic Cloud Keys: not fetched yet. Connect AirPods and use 'Fetch Magic Cloud Keys'. + Magic Cloud Keys: no obtenidas aún. Conectá los AirPods y usá 'Obtener Magic Cloud Keys'. + + + Magic Cloud Keys: ready to export as QR + Magic Cloud Keys: listas para exportar como QR + + + Show Magic Cloud Keys QR + Mostrar QR de Magic Cloud Keys + + + Auto-Start on Login + Inicio automático al iniciar sesión + + + Enable System Notifications + Habilitar notificaciones del sistema + + + Bluetooth Retry Attempts: + Intentos de reconexión Bluetooth: + + + Rename + Renombrar + + + Head Tracking / Gestures + Head Tracking / Gestos + + + Linux now exposes the existing Python gesture detector so you can test nod and shake detection for the connected AirPods without touching Android. + Linux expone el detector de gestos en Python existente para probar detección de cabeceo y sacudida sin tocar Android. + + + Current device: + Dispositivo actual: + + + No Bluetooth address detected yet + No se detectó ninguna dirección Bluetooth aún + + + Open Head Gesture Detector + Abrir detector de gestos de cabeza + + + Requirements: Python 3, a terminal emulator, and the Python bluetooth dependency used by the existing `head-tracking` scripts. This phase only launches the current detector; it does not add inline visualization, alternate packets, or multi-device handoff. + Requisitos: Python 3, un emulador de terminal y la dependencia Python de Bluetooth usada por los scripts `head-tracking` existentes. Esta fase solo lanza el detector actual; no agrega visualización inline, paquetes alternativos ni handoff multi-dispositivo. + + + Allow Off in Noise Control Cycle + Permitir Apagado en el ciclo de control de ruido + + + Adaptive Volume + Volumen adaptativo + + + Volume Swipe + Control de volumen por deslizamiento + + + Swipe Sensitivity: + Sensibilidad de deslizamiento: + + + Case Charging Sounds + Sonidos de carga del estuche + + + Press-and-Hold Stem Cycles Through: + Mantener presionado el stem alterna entre: + + + Select at least 2 modes + Seleccioná al menos 2 modos + + + Customize Transparency Mode + Personalizar modo Transparencia + + + Adjust how transparency mode sounds. Press Apply to send settings to the AirPods. + Ajustá cómo suena el modo Transparencia. Presioná Aplicar para enviar los ajustes a los AirPods. + + + Enabled + Activado + + + Left Bud + Auricular izquierdo + + + Right Bud + Auricular derecho + + + Amplification: + Amplificación: + + + Tone: + Tono: + + + Conversation Boost: + Potenciador de conversación: + + + Ambient Noise: + Ruido ambiental: + + + Apply Transparency Settings + Aplicar ajustes de Transparencia + + + Headphone Accommodation + Adaptación de auriculares + + + Amplifies soft sounds and adjusts frequencies to suit your hearing. Press Apply to send. + Amplifica sonidos suaves y ajusta frecuencias según tu audición. Presioná Aplicar para enviar. + + + Enable for Phone Calls + Activar para llamadas + + + Enable for Media + Activar para contenido multimedia + + + 8-Band EQ + Ecualizador de 8 bandas + + + Band 1: + Banda 1: + + + Band 2: + Banda 2: + + + Band 3: + Banda 3: + + + Band 4: + Banda 4: + + + Band 5: + Banda 5: + + + Band 6: + Banda 6: + + + Band 7: + Banda 7: + + + Band 8: + Banda 8: + + + Apply Headphone Accommodation + Aplicar adaptación de auriculares + + + + TrayIconManager + + Battery Status: + Batería: + + + Open + Abrir + + + Settings + Configuración + + + Toggle Conversational Awareness + Alternar detección de conversación + + + Adaptive + Adaptativo + + + Transparency + Transparencia + + + Noise Cancellation + Cancelación de ruido + + + Off + Apagado + + + Quit + Salir + + + + AirPodsTrayApp + + AirPods Disconnected + AirPods desconectados + + + Your AirPods have been disconnected + Tus AirPods se desconectaron + + + From f95f6fe14a63c872f2a8111862084d5ee3e72121 Mon Sep 17 00:00:00 2001 From: john Date: Sat, 11 Apr 2026 07:45:43 -0300 Subject: [PATCH 5/5] docs(linux): update README with build and installation instructions --- linux/README.md | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/linux/README.md b/linux/README.md index b86c22aef..8328bfa53 100644 --- a/linux/README.md +++ b/linux/README.md @@ -160,10 +160,10 @@ librepods-ctl noise:transparency ## Hearing Aid -To use hearing aid features, you need to have an audiogram. To enable/disable hearing aid, you can use the toggle in the main app. But, to adjust the settings and set the audiogram, you need to use a different script which is located in this folder as `hearing_aid.py`. You can run it with: +To use hearing aid features, you need to have an audiogram. To enable/disable hearing aid, you can use the toggle in the main app. The Linux UI also exposes a button to launch the advanced adjustments tool for the currently connected AirPods. Under the hood it uses the separate script in this folder, `hearing-aid-adjustments.py`, which you can also run manually with: ```bash -python3 hearing_aid.py +python3 hearing-aid-adjustments.py ``` The script will load the current settings from the AirPods and allow you to adjust them. You can set the audiogram by providing the values for 8 frequencies (250Hz, 500Hz, 1kHz, 2kHz, 3kHz, 4kHz, 6kHz, 8kHz) for both left and right ears. There are also options to adjust amplification, balance, tone, ambient noise reduction, own voice amplification, and conversation boost. @@ -189,3 +189,34 @@ It is possible that the AirPods disconnect after a short period of time and play ### Why a separate script? Because I discovered that QBluetooth doesn't support connecting to a socket with its PSM, only a UUID can be used. I could add a dependency on BlueZ, but then having two bluetooth interfaces seems unnecessary. So, I decided to use a separate script for hearing aid features. In the future, QBluetooth will be replaced with BlueZ native calls, and then everything will be in one application. + +## Head Tracking / Gestures + +Linux now exposes the existing head gesture detector from the `head-tracking/` folder through the Settings UI. When AirPods are connected, use `Open Head Gesture Detector` to launch the Python script in a terminal for the current Bluetooth MAC address. + +You can also run it manually: + +```bash +python3 head-tracking/gestures.py +``` + +Requirements: + +- Python 3 +- A terminal emulator available in `PATH` +- The Python bluetooth dependency used by the head-tracking scripts (`pybluez` from `head-tracking/requirements.txt`) + +## Multi-Device / Handoff + +Linux already includes a Bluetooth relay to the Android app using the phone MAC address and the cross-device UUID `1abbb9a4-10e4-4000-a75c-8953c5471342`. The Settings UI now makes that flow safer to validate: + +- The phone MAC can be configured even before AirPods are connected. +- The UI shows whether the phone relay is currently connected. +- You can manually retry the phone relay connection. +- You can fetch Magic Cloud Keys from connected AirPods and only open the QR export once both keys are available. + +Current limitations: + +- Linux still depends on the Android app implementing the server side of the relay protocol. +- Linux can request Magic Cloud Keys and export them as QR, but it does not import them back from a QR or push them to Android automatically. +- The existing handoff protocol is still packet relay plus disconnect requests; this phase does not redesign the transport.