diff --git a/linux/main.cpp b/linux/main.cpp index 7b1826b49..d563c8462 100644 --- a/linux/main.cpp +++ b/linux/main.cpp @@ -66,7 +66,19 @@ class AirPodsTrayApp : public QObject { connect(trayManager, &TrayIconManager::openSettings, this, &AirPodsTrayApp::onOpenSettings); connect(trayManager, &TrayIconManager::noiseControlChanged, this, &AirPodsTrayApp::setNoiseControlMode); connect(trayManager, &TrayIconManager::conversationalAwarenessToggled, this, &AirPodsTrayApp::setConversationalAwareness); - connect(m_deviceInfo, &DeviceInfo::batteryStatusChanged, trayManager, &TrayIconManager::updateBatteryStatus); + // Per-device battery wiring: key = bluetooth address, name = device name + connect(m_deviceInfo, &DeviceInfo::batteryStatusChanged, trayManager, [this](const QString &status) { + const QString addr = m_deviceInfo->bluetoothAddress(); + if (addr.isEmpty() || status.isEmpty()) + return; // reset() signals — ignore them + trayManager->setActiveDevice(addr); + trayManager->updateDeviceBattery(addr, m_deviceInfo->deviceName(), status); + }); + connect(m_deviceInfo, &DeviceInfo::deviceNameChanged, trayManager, [this](const QString &name) { + const QString addr = m_deviceInfo->bluetoothAddress(); + if (!addr.isEmpty()) + trayManager->updateDeviceName(addr, name); + }); connect(m_deviceInfo, &DeviceInfo::noiseControlModeChanged, trayManager, &TrayIconManager::updateNoiseControlState); connect(m_deviceInfo, &DeviceInfo::conversationalAwarenessChanged, trayManager, &TrayIconManager::updateConversationalAwareness); connect(trayManager, &TrayIconManager::notificationsEnabledChanged, this, &AirPodsTrayApp::saveNotificationsEnabled); @@ -506,6 +518,12 @@ private slots: void onDeviceDisconnected(const QBluetoothAddress &address) { LOG_INFO("Device disconnected: " << address.toString()); + + // Remove this device from the tray registry so any other still-connected + // device keeps its entry. Only fall back to resetTrayIcon() if no devices remain. + const QString addrStr = address.toString(); + trayManager->removeDevice(addrStr); + if (socket) { LOG_WARN("Socket is still open, closing it"); @@ -527,7 +545,6 @@ private slots: trayManager->showNotification( tr("AirPods Disconnected"), tr("Your AirPods have been disconnected")); - trayManager->resetTrayIcon(); } void bluezDeviceDisconnected(const QString &address, const QString &name) @@ -608,6 +625,11 @@ private slots: LOG_INFO("Connecting to device: " << device.name()); + if (device.address().toString() != m_deviceInfo->bluetoothAddress()) + { + m_deviceInfo->reset(); + } + // Clean up any existing socket if (socket) { @@ -656,6 +678,7 @@ private slots: localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a")); m_deviceInfo->setBluetoothAddress(device.address().toString()); + m_deviceInfo->setDeviceName(device.name()); notifyAndroidDevice(); } @@ -876,6 +899,12 @@ private slots: void bleDeviceFound(const BleInfo &device) { + // If a live AACP/L2CAP connection is up, the broadcast may be from a + // *different* paired device whose IRK happens to match (BLE addresses are + // anonymised). The socket is authoritative — ignore BLE updates while connected. + if (areAirpodsConnected()) + return; + if (BLEUtils::isValidIrkRpa(m_deviceInfo->magicAccIRK(), device.address)) { m_deviceInfo->setModel(device.modelName); auto decryptet = BLEUtils::decryptLastBytes(device.encryptedPayload, m_deviceInfo->magicAccEncKey()); diff --git a/linux/trayiconmanager.cpp b/linux/trayiconmanager.cpp index 738feecf1..866b35e3b 100644 --- a/linux/trayiconmanager.cpp +++ b/linux/trayiconmanager.cpp @@ -11,42 +11,165 @@ using namespace AirpodsTrayApp::Enums; +// --------------------------------------------------------------------------- +// Construction +// --------------------------------------------------------------------------- + TrayIconManager::TrayIconManager(QObject *parent) : QObject(parent) { - // Initialize tray icon trayIcon = new QSystemTrayIcon(QIcon(":/icons/assets/airpods.png"), this); trayMenu = new QMenu(); - // Setup basic menu actions setupMenuActions(); - // Connect signals trayIcon->setContextMenu(trayMenu); connect(trayIcon, &QSystemTrayIcon::activated, this, &TrayIconManager::onTrayIconActivated); trayIcon->show(); } -void TrayIconManager::showNotification(const QString &title, const QString &message) +// --------------------------------------------------------------------------- +// Per-device registry helpers +// --------------------------------------------------------------------------- + +int TrayIconManager::findDevice(const QString &key) const { - if (!m_notificationsEnabled) + for (int i = 0; i < m_devices.size(); ++i) + if (m_devices[i].key == key) + return i; + return -1; +} + +void TrayIconManager::updateDeviceBattery(const QString &key, + const QString &name, + const QString &status) +{ + if (key.isEmpty() || status.isEmpty()) return; - trayIcon->showMessage(title, message, QSystemTrayIcon::Information, 3000); + + int idx = findDevice(key); + if (idx == -1) { + // New device – append it + DeviceEntry entry; + entry.key = key; + entry.name = name.isEmpty() ? tr("AirPods") : name; + entry.status = status; + m_devices.append(entry); + } else { + if (!name.isEmpty()) + m_devices[idx].name = name; + m_devices[idx].status = status; + } + + // If no active device is set yet, promote this one + if (m_activeDeviceKey.isEmpty()) + m_activeDeviceKey = key; + + // Update numeric icon only for the active device + if (key == m_activeDeviceKey) + updateIconFromBattery(status); + + renderTooltip(); +} + +void TrayIconManager::updateDeviceName(const QString &key, const QString &name) +{ + if (key.isEmpty() || name.isEmpty()) + return; + + int idx = findDevice(key); + if (idx != -1) + m_devices[idx].name = name; + + renderTooltip(); +} + +void TrayIconManager::setActiveDevice(const QString &key) +{ + if (m_activeDeviceKey == key) + return; + + m_activeDeviceKey = key; + + int idx = findDevice(key); + if (idx != -1) + updateIconFromBattery(m_devices[idx].status); + + renderTooltip(); +} + +void TrayIconManager::removeDevice(const QString &key) +{ + int idx = findDevice(key); + if (idx == -1) + return; + + m_devices.removeAt(idx); + + // If we removed the active device, promote the first remaining one (if any) + if (m_activeDeviceKey == key) { + m_activeDeviceKey.clear(); + if (!m_devices.isEmpty()) { + m_activeDeviceKey = m_devices.first().key; + updateIconFromBattery(m_devices.first().status); + } else { + trayIcon->setIcon(QIcon(":/icons/assets/airpods.png")); + } + } + + renderTooltip(); +} + +// --------------------------------------------------------------------------- +// Tooltip rendering +// --------------------------------------------------------------------------- + +void TrayIconManager::renderTooltip() +{ + if (m_devices.isEmpty()) { + trayIcon->setToolTip(""); + return; + } + + if (m_devices.size() == 1) { + // Preserve original single-device format for existing users + trayIcon->setToolTip(tr("Battery Status: ") + m_devices.first().status); + return; + } + + // Multi-device: one line per device, active first + QStringList lines; + // Active device first + for (const DeviceEntry &e : m_devices) { + if (e.key == m_activeDeviceKey) { + QString label = e.name.isEmpty() ? tr("AirPods") : e.name; + lines.prepend(label + ": " + e.status); + } else { + QString label = e.name.isEmpty() ? tr("AirPods") : e.name; + lines.append(label + ": " + e.status); + } + } + trayIcon->setToolTip(lines.join("\n")); } -void TrayIconManager::TrayIconManager::updateBatteryStatus(const QString &status) +// --------------------------------------------------------------------------- +// Backwards-compat wrapper (single-device path) +// --------------------------------------------------------------------------- + +void TrayIconManager::updateBatteryStatus(const QString &status) { - trayIcon->setToolTip(tr("Battery Status: ") + status); - updateIconFromBattery(status); + updateDeviceBattery("default", "", status); } +// --------------------------------------------------------------------------- +// Noise control / CA +// --------------------------------------------------------------------------- + void TrayIconManager::updateNoiseControlState(NoiseControlMode mode) { QList actions = noiseControlGroup->actions(); for (QAction *action : actions) - { action->setChecked(action->data().toInt() == (int)mode); - } } void TrayIconManager::updateConversationalAwareness(bool enabled) @@ -54,72 +177,83 @@ void TrayIconManager::updateConversationalAwareness(bool enabled) caToggleAction->setChecked(enabled); } +// --------------------------------------------------------------------------- +// Notification +// --------------------------------------------------------------------------- + +void TrayIconManager::showNotification(const QString &title, const QString &message) +{ + if (!m_notificationsEnabled) + return; + trayIcon->showMessage(title, message, QSystemTrayIcon::Information, 3000); +} + +// --------------------------------------------------------------------------- +// Menu setup +// --------------------------------------------------------------------------- + void TrayIconManager::setupMenuActions() { - // Open action QAction *openAction = new QAction(tr("Open"), trayMenu); trayMenu->addAction(openAction); - connect(openAction, &QAction::triggered, qApp, [this](){emit openApp();}); - - // Settings Menu + connect(openAction, &QAction::triggered, qApp, [this]() { emit openApp(); }); QAction *settingsMenu = new QAction(tr("Settings"), trayMenu); trayMenu->addAction(settingsMenu); - connect(settingsMenu, &QAction::triggered, qApp, [this](){emit openSettings();}); + connect(settingsMenu, &QAction::triggered, qApp, [this]() { emit openSettings(); }); trayMenu->addSeparator(); - // Conversational Awareness Toggle caToggleAction = new QAction(tr("Toggle Conversational Awareness"), trayMenu); caToggleAction->setCheckable(true); trayMenu->addAction(caToggleAction); - connect(caToggleAction, &QAction::triggered, this, [this](bool checked) - { emit conversationalAwarenessToggled(checked); }); + connect(caToggleAction, &QAction::triggered, this, + [this](bool checked) { emit conversationalAwarenessToggled(checked); }); trayMenu->addSeparator(); - // Noise Control Options noiseControlGroup = new QActionGroup(trayMenu); const QPair noiseOptions[] = { - {tr("Adaptive"), NoiseControlMode::Adaptive}, - {tr("Transparency"), NoiseControlMode::Transparency}, + {tr("Adaptive"), NoiseControlMode::Adaptive}, + {tr("Transparency"), NoiseControlMode::Transparency}, {tr("Noise Cancellation"), NoiseControlMode::NoiseCancellation}, - {tr("Off"), NoiseControlMode::Off}}; + {tr("Off"), NoiseControlMode::Off}}; - for (auto option : noiseOptions) - { + for (auto option : noiseOptions) { QAction *action = new QAction(option.first, trayMenu); action->setCheckable(true); action->setData((int)option.second); noiseControlGroup->addAction(action); trayMenu->addAction(action); - connect(action, &QAction::triggered, this, [this, mode = option.second]() - { emit noiseControlChanged(mode); }); + connect(action, &QAction::triggered, this, + [this, mode = option.second]() { emit noiseControlChanged(mode); }); } trayMenu->addSeparator(); - // Quit action QAction *quitAction = new QAction(tr("Quit"), trayMenu); trayMenu->addAction(quitAction); connect(quitAction, &QAction::triggered, qApp, &QApplication::quit); } +// --------------------------------------------------------------------------- +// Icon painter (unchanged logic — feeds only the active device's status) +// --------------------------------------------------------------------------- + void TrayIconManager::updateIconFromBattery(const QString &status) { - int leftLevel = 0; + int leftLevel = 0; int rightLevel = 0; - int minLevel = 0; + int minLevel = 0; - if (!status.isEmpty()) - { - // Parse the battery status string + if (!status.isEmpty()) { QStringList parts = status.split(", "); if (parts.size() >= 2) { - leftLevel = parts[0].split(": ")[1].replace("%", "").toInt(); + leftLevel = parts[0].split(": ")[1].replace("%", "").toInt(); rightLevel = parts[1].split(": ")[1].replace("%", "").toInt(); - minLevel = (leftLevel == 0) ? rightLevel : (rightLevel == 0) ? leftLevel - : qMin(leftLevel, rightLevel); + minLevel = (leftLevel == 0) ? rightLevel + : (rightLevel == 0) ? leftLevel + : qMin(leftLevel, rightLevel); } else if (parts.size() == 1) { minLevel = parts[0].split(": ")[1].replace("%", "").toInt(); } @@ -136,11 +270,12 @@ void TrayIconManager::updateIconFromBattery(const QString &status) trayIcon->setIcon(QIcon(pixmap)); } +// --------------------------------------------------------------------------- +// Tray click +// --------------------------------------------------------------------------- + void TrayIconManager::onTrayIconActivated(QSystemTrayIcon::ActivationReason reason) { if (reason == QSystemTrayIcon::Trigger) - { emit trayClicked(); - } } - diff --git a/linux/trayiconmanager.h b/linux/trayiconmanager.h index 25c153048..e5098bcf0 100644 --- a/linux/trayiconmanager.h +++ b/linux/trayiconmanager.h @@ -1,5 +1,9 @@ +#pragma once + #include #include +#include +#include #include "enums.h" @@ -15,6 +19,26 @@ class TrayIconManager : public QObject public: explicit TrayIconManager(QObject *parent = nullptr); + // ------------------------------------------------------------------ + // Per-device battery API (replaces the old single updateBatteryStatus) + // ------------------------------------------------------------------ + + /// Insert-or-update a device entry; also updates the icon and tooltip. + void updateDeviceBattery(const QString &key, const QString &name, const QString &status); + + /// Refresh the display name of an already-registered device. + void updateDeviceName(const QString &key, const QString &name); + + /// Mark which device drives the numeric battery icon. + void setActiveDevice(const QString &key); + + /// Remove a device from the registry (call on disconnect). + void removeDevice(const QString &key); + + // ------------------------------------------------------------------ + // Kept for backwards-compat — wraps updateDeviceBattery with a + // synthetic key ("default") so single-device users see no change. + // ------------------------------------------------------------------ void updateBatteryStatus(const QString &status); void updateNoiseControlState(AirpodsTrayApp::Enums::NoiseControlMode); @@ -35,31 +59,45 @@ class TrayIconManager : public QObject void resetTrayIcon() { + m_devices.clear(); + m_activeDeviceKey.clear(); trayIcon->setIcon(QIcon(":/icons/assets/airpods.png")); trayIcon->setToolTip(""); } signals: void notificationsEnabledChanged(bool enabled); + void trayClicked(); + void noiseControlChanged(AirpodsTrayApp::Enums::NoiseControlMode); + void conversationalAwarenessToggled(bool enabled); + void openApp(); + void openSettings(); private slots: void onTrayIconActivated(QSystemTrayIcon::ActivationReason reason); private: + // Per-device record ------------------------------------------------ + struct DeviceEntry { + QString key; ///< Bluetooth address (authoritative ID) + QString name; ///< Human-readable label, e.g. "AirPods Pro 2" + QString status; ///< Formatted battery string, e.g. "L: 80%, R: 100%, C: --%" + }; + + QList m_devices; + QString m_activeDeviceKey; + + // Helpers ---------------------------------------------------------- + int findDevice(const QString &key) const; ///< Returns index or -1 + void renderTooltip(); + void updateIconFromBattery(const QString &status); + + // Qt widgets ------------------------------------------------------- QSystemTrayIcon *trayIcon; - QMenu *trayMenu; - QAction *caToggleAction; - QActionGroup *noiseControlGroup; - bool m_notificationsEnabled = true; + QMenu *trayMenu; + QAction *caToggleAction; + QActionGroup *noiseControlGroup; + bool m_notificationsEnabled = true; void setupMenuActions(); - - void updateIconFromBattery(const QString &status); - -signals: - void trayClicked(); - void noiseControlChanged(AirpodsTrayApp::Enums::NoiseControlMode); - void conversationalAwarenessToggled(bool enabled); - void openApp(); - void openSettings(); }; \ No newline at end of file