From 899d12a56c4b1839cc16e9dea9a298f10ea3d84b Mon Sep 17 00:00:00 2001 From: Bissam Iftikhar Date: Fri, 17 Jul 2026 22:56:58 +0500 Subject: [PATCH 1/2] linux: track battery status per device in TrayIconManager Replace the single global tooltip string with a small per-device registry keyed by Bluetooth address. Each entry stores the device's last known name and formatted battery status. The tooltip lists every known device (active device first); with a single device the tooltip format is unchanged. The icon percentage is driven only by the active device, and resetTrayIcon() now clears the registry as well. --- linux/trayiconmanager.cpp | 122 ++++++++++++++++++++++++++++++++++++++ linux/trayiconmanager.h | 30 ++++++++-- 2 files changed, 146 insertions(+), 6 deletions(-) diff --git a/linux/trayiconmanager.cpp b/linux/trayiconmanager.cpp index 738feecf1..6c4496398 100644 --- a/linux/trayiconmanager.cpp +++ b/linux/trayiconmanager.cpp @@ -40,6 +40,128 @@ void TrayIconManager::TrayIconManager::updateBatteryStatus(const QString &status updateIconFromBattery(status); } +void TrayIconManager::setActiveDevice(const QString &deviceKey) +{ + if (m_activeDeviceKey == deviceKey) + return; + + m_activeDeviceKey = deviceKey; + + int index = findDeviceEntry(deviceKey); + if (index >= 0) + { + updateIconFromBattery(m_deviceEntries.at(index).status); + } + else + { + // No battery data for this device yet; don't keep showing the + // previous device's level on the icon + trayIcon->setIcon(QIcon(":/icons/assets/airpods.png")); + } + renderBatteryTooltip(); +} + +void TrayIconManager::updateDeviceBattery(const QString &deviceKey, const QString &name, const QString &status) +{ + if (deviceKey.isEmpty() || status.isEmpty()) + return; + + int index = findDeviceEntry(deviceKey); + if (index < 0) + { + m_deviceEntries.append({deviceKey, name, status}); + } + else + { + if (!name.isEmpty()) + m_deviceEntries[index].name = name; + m_deviceEntries[index].status = status; + } + + if (deviceKey == m_activeDeviceKey) + updateIconFromBattery(status); + renderBatteryTooltip(); +} + +void TrayIconManager::updateDeviceName(const QString &deviceKey, const QString &name) +{ + int index = findDeviceEntry(deviceKey); + if (index < 0 || name.isEmpty() || m_deviceEntries.at(index).name == name) + return; + + m_deviceEntries[index].name = name; + renderBatteryTooltip(); +} + +void TrayIconManager::removeDevice(const QString &deviceKey) +{ + int index = findDeviceEntry(deviceKey); + if (index < 0) + return; + + m_deviceEntries.removeAt(index); + + if (deviceKey == m_activeDeviceKey) + { + m_activeDeviceKey.clear(); + trayIcon->setIcon(QIcon(":/icons/assets/airpods.png")); + } + renderBatteryTooltip(); +} + +void TrayIconManager::resetTrayIcon() +{ + m_deviceEntries.clear(); + m_activeDeviceKey.clear(); + trayIcon->setIcon(QIcon(":/icons/assets/airpods.png")); + trayIcon->setToolTip(""); +} + +int TrayIconManager::findDeviceEntry(const QString &deviceKey) const +{ + for (int i = 0; i < m_deviceEntries.size(); ++i) + { + if (m_deviceEntries.at(i).key == deviceKey) + return i; + } + return -1; +} + +void TrayIconManager::renderBatteryTooltip() +{ + if (m_deviceEntries.isEmpty()) + { + trayIcon->setToolTip(""); + return; + } + + if (m_deviceEntries.size() == 1) + { + // Keep the familiar single-device format + trayIcon->setToolTip(tr("Battery Status: ") + m_deviceEntries.first().status); + return; + } + + // Multiple devices: one line per device, active device first + QStringList lines; + auto appendEntry = [&lines, this](const DeviceBatteryEntry &entry) + { + const QString label = entry.name.isEmpty() ? tr("AirPods") : entry.name; + lines.append(label + ": " + entry.status); + }; + + const int activeIndex = findDeviceEntry(m_activeDeviceKey); + if (activeIndex >= 0) + appendEntry(m_deviceEntries.at(activeIndex)); + for (int i = 0; i < m_deviceEntries.size(); ++i) + { + if (i != activeIndex) + appendEntry(m_deviceEntries.at(i)); + } + + trayIcon->setToolTip(lines.join(QStringLiteral("\n"))); +} + void TrayIconManager::updateNoiseControlState(NoiseControlMode mode) { QList actions = noiseControlGroup->actions(); diff --git a/linux/trayiconmanager.h b/linux/trayiconmanager.h index 25c153048..1e2cf55ee 100644 --- a/linux/trayiconmanager.h +++ b/linux/trayiconmanager.h @@ -1,4 +1,6 @@ +#include #include +#include #include #include "enums.h" @@ -17,6 +19,14 @@ class TrayIconManager : public QObject void updateBatteryStatus(const QString &status); + // Per-device battery tracking. Devices are keyed by their Bluetooth + // address so several connected AirPods can be shown at once; the active + // device (the one the app has an AACP connection to) drives the icon. + void setActiveDevice(const QString &deviceKey); + void updateDeviceBattery(const QString &deviceKey, const QString &name, const QString &status); + void updateDeviceName(const QString &deviceKey, const QString &name); + void removeDevice(const QString &deviceKey); + void updateNoiseControlState(AirpodsTrayApp::Enums::NoiseControlMode); void updateConversationalAwareness(bool enabled); @@ -33,11 +43,7 @@ class TrayIconManager : public QObject } } - void resetTrayIcon() - { - trayIcon->setIcon(QIcon(":/icons/assets/airpods.png")); - trayIcon->setToolTip(""); - } + void resetTrayIcon(); signals: void notificationsEnabledChanged(bool enabled); @@ -46,20 +52,32 @@ private slots: void onTrayIconActivated(QSystemTrayIcon::ActivationReason reason); private: + struct DeviceBatteryEntry + { + QString key; // Bluetooth address of the device + QString name; // last known device name + QString status; // formatted battery status string + }; + QSystemTrayIcon *trayIcon; QMenu *trayMenu; QAction *caToggleAction; QActionGroup *noiseControlGroup; bool m_notificationsEnabled = true; + QList m_deviceEntries; + QString m_activeDeviceKey; void setupMenuActions(); void updateIconFromBattery(const QString &status); + int findDeviceEntry(const QString &deviceKey) const; + void renderBatteryTooltip(); + signals: void trayClicked(); void noiseControlChanged(AirpodsTrayApp::Enums::NoiseControlMode); void conversationalAwarenessToggled(bool enabled); void openApp(); void openSettings(); -}; \ No newline at end of file +}; From 9435efff66bfa7285fc44cb10999f9145f8a3a24 Mon Sep 17 00:00:00 2001 From: Bissam Iftikhar Date: Fri, 17 Jul 2026 23:10:35 +0500 Subject: [PATCH 2/2] linux(fix): show battery for all connected AirPods in the tray Previously the tray tracked a single global battery status, so with two AirPods connected the most recent update simply overwrote the other device's status, and BLE broadcasts matching the stored IRK could override the AACP-connected device's state with data from a different (paired but not connected) device. - Battery updates are now recorded per device (keyed by Bluetooth address) and the tooltip lists every known device, active device first. A device's entry is only removed when that device disconnects. - Switching the AACP connection to another device resets DeviceInfo so the new device doesn't inherit the previous device's name/battery; the previous device's last known status stays visible in the tray. - BLE broadcasts are ignored while an AACP connection is up: the live connection is authoritative, and an IRK-matched broadcast may belong to another paired device. - The BlueZ device name is used as the tray label until the AACP metadata packet provides the canonical name. Fixes #670 --- linux/main.cpp | 38 ++++++++++++++++++++++++++++++++++++-- linux/trayiconmanager.cpp | 6 ------ linux/trayiconmanager.h | 2 -- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/linux/main.cpp b/linux/main.cpp index 7b1826b49..54e4fc79e 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); + connect(m_deviceInfo, &DeviceInfo::batteryStatusChanged, this, [this](const QString &status) { + // Track battery per device so an update from one device doesn't + // wipe out the others in the tray (#670). Empty statuses come from + // DeviceInfo::reset(); tray entries are removed on disconnect instead. + const QString address = m_deviceInfo->bluetoothAddress(); + if (address.isEmpty() || status.isEmpty()) + return; + trayManager->setActiveDevice(address); + trayManager->updateDeviceBattery(address, m_deviceInfo->deviceName(), status); + }); + connect(m_deviceInfo, &DeviceInfo::deviceNameChanged, this, [this](const QString &name) { + trayManager->updateDeviceName(m_deviceInfo->bluetoothAddress(), name); + }); connect(m_deviceInfo, &DeviceInfo::noiseControlModeChanged, trayManager, &TrayIconManager::updateNoiseControlState); connect(m_deviceInfo, &DeviceInfo::conversationalAwarenessChanged, trayManager, &TrayIconManager::updateConversationalAwareness); connect(trayManager, &TrayIconManager::notificationsEnabledChanged, this, &AirPodsTrayApp::saveNotificationsEnabled); @@ -527,7 +539,7 @@ private slots: trayManager->showNotification( tr("AirPods Disconnected"), tr("Your AirPods have been disconnected")); - trayManager->resetTrayIcon(); + trayManager->removeDevice(address.toString()); } void bluezDeviceDisconnected(const QString &address, const QString &name) @@ -537,6 +549,9 @@ private slots: onDeviceDisconnected(QBluetoothAddress(address)); } else { LOG_WARN("Disconnected device does not match connected device: " << address << " != " << m_deviceInfo->bluetoothAddress()); + // Still drop its entry from the tray in case it was tracked as a + // previously active device + trayManager->removeDevice(address); } } @@ -608,6 +623,16 @@ private slots: LOG_INFO("Connecting to device: " << device.name()); + // Switching to a different device: clear the previous device's state + // so the new one doesn't inherit its name/battery. The previous + // device's last known status stays visible in the tray until it + // actually disconnects. + if (!m_deviceInfo->bluetoothAddress().isEmpty() + && m_deviceInfo->bluetoothAddress() != device.address().toString()) + { + m_deviceInfo->reset(); + } + // Clean up any existing socket if (socket) { @@ -656,6 +681,8 @@ private slots: localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a")); m_deviceInfo->setBluetoothAddress(device.address().toString()); + if (!device.name().isEmpty()) + m_deviceInfo->setDeviceName(device.name()); // BlueZ name until AACP metadata arrives notifyAndroidDevice(); } @@ -876,6 +903,13 @@ private slots: void bleDeviceFound(const BleInfo &device) { + // While an AACP connection is up it is the authoritative source for + // battery and ear-detection state. BLE broadcasts are anonymized, so a + // broadcast matching the stored IRK may belong to another (paired but + // not connected) device and must not override the connected one (#670). + 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 6c4496398..76bccfa04 100644 --- a/linux/trayiconmanager.cpp +++ b/linux/trayiconmanager.cpp @@ -34,12 +34,6 @@ void TrayIconManager::showNotification(const QString &title, const QString &mess trayIcon->showMessage(title, message, QSystemTrayIcon::Information, 3000); } -void TrayIconManager::TrayIconManager::updateBatteryStatus(const QString &status) -{ - trayIcon->setToolTip(tr("Battery Status: ") + status); - updateIconFromBattery(status); -} - void TrayIconManager::setActiveDevice(const QString &deviceKey) { if (m_activeDeviceKey == deviceKey) diff --git a/linux/trayiconmanager.h b/linux/trayiconmanager.h index 1e2cf55ee..cfe893704 100644 --- a/linux/trayiconmanager.h +++ b/linux/trayiconmanager.h @@ -17,8 +17,6 @@ class TrayIconManager : public QObject public: explicit TrayIconManager(QObject *parent = nullptr); - void updateBatteryStatus(const QString &status); - // Per-device battery tracking. Devices are keyed by their Bluetooth // address so several connected AirPods can be shown at once; the active // device (the one the app has an AACP connection to) drives the icon.