Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions linux/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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());
Expand Down
122 changes: 119 additions & 3 deletions linux/trayiconmanager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 23 additions & 7 deletions linux/trayiconmanager.h
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#include <QList>
#include <QObject>
#include <QString>
#include <QSystemTrayIcon>

#include "enums.h"
Expand All @@ -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);

Expand All @@ -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);
Expand All @@ -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<DeviceBatteryEntry> 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();
};
};