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
33 changes: 31 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);
// 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);
Expand Down Expand Up @@ -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");
Expand All @@ -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)
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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());
Expand Down
213 changes: 174 additions & 39 deletions linux/trayiconmanager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,115 +11,249 @@

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<QAction *> actions = noiseControlGroup->actions();
for (QAction *action : actions)
{
action->setChecked(action->data().toInt() == (int)mode);
}
}

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<QString, NoiseControlMode> 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();
}
Expand All @@ -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();
}
}

Loading