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 738feecf1..76bccfa04 100644 --- a/linux/trayiconmanager.cpp +++ b/linux/trayiconmanager.cpp @@ -34,10 +34,126 @@ 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::setActiveDevice(const QString &deviceKey) { - trayIcon->setToolTip(tr("Battery Status: ") + status); - updateIconFromBattery(status); + 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) diff --git a/linux/trayiconmanager.h b/linux/trayiconmanager.h index 25c153048..cfe893704 100644 --- a/linux/trayiconmanager.h +++ b/linux/trayiconmanager.h @@ -1,4 +1,6 @@ +#include #include +#include #include #include "enums.h" @@ -15,7 +17,13 @@ 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. + 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); @@ -33,11 +41,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 +50,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 +};