diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f3f1867..393d63c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> + + . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods import android.annotation.SuppressLint @@ -27,6 +29,7 @@ import android.content.Intent import android.content.ServiceConnection import android.content.SharedPreferences import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.IBinder import android.provider.Settings @@ -113,6 +116,7 @@ import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.utils.AirPodsNotifications import me.kavishdevar.librepods.utils.CrossDevice import me.kavishdevar.librepods.utils.RadareOffsetFinder +import kotlin.io.encoding.ExperimentalEncodingApi lateinit var serviceConnection: ServiceConnection lateinit var connectionStatusReceiver: BroadcastReceiver @@ -183,17 +187,30 @@ fun Main() { var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) } val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) } - val permissionState = rememberMultiplePermissionsState( - permissions = listOf( + val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + listOf( "android.permission.BLUETOOTH_CONNECT", "android.permission.BLUETOOTH_SCAN", "android.permission.BLUETOOTH", "android.permission.BLUETOOTH_ADMIN", - "android.permission.BLUETOOTH_ADVERTISE", - "android.permission.POST_NOTIFICATIONS", - "android.permission.READ_PHONE_STATE", - "android.permission.ANSWER_PHONE_CALLS", + "android.permission.BLUETOOTH_ADVERTISE" ) + } else { + listOf( + "android.permission.BLUETOOTH", + "android.permission.BLUETOOTH_ADMIN", + "android.permission.ACCESS_FINE_LOCATION" + ) + } + val otherPermissions = listOf( + "android.permission.POST_NOTIFICATIONS", + "android.permission.READ_PHONE_STATE", + "android.permission.ANSWER_PHONE_CALLS" + ) + val allPermissions = bluetoothPermissions + otherPermissions + + val permissionState = rememberMultiplePermissionsState( + permissions = allPermissions ) val airPodsService = remember { mutableStateOf(null) } diff --git a/linux/BasicControlCommand.hpp b/linux/BasicControlCommand.hpp new file mode 100644 index 0000000..747068e --- /dev/null +++ b/linux/BasicControlCommand.hpp @@ -0,0 +1,69 @@ +#include + +// Control Command Header +namespace ControlCommand +{ + static const QByteArray HEADER = QByteArray::fromHex("040004000900"); + + // Helper function to create control command packets + static QByteArray createCommand(quint8 identifier, quint8 data1 = 0x00, quint8 data2 = 0x00, + quint8 data3 = 0x00, quint8 data4 = 0x00) + { + QByteArray packet = HEADER; + packet.append(static_cast(identifier)); + packet.append(static_cast(data1)); + packet.append(static_cast(data2)); + packet.append(static_cast(data3)); + packet.append(static_cast(data4)); + return packet; + } + + // Parse activated/not activated + inline std::optional parseActive(const QByteArray &data) + { + if (!data.startsWith(ControlCommand::HEADER)) + return std::nullopt; + + quint8 statusByte = static_cast(data.at(7)); + switch (statusByte) + { + case 0x01: // Enabled + return true; + case 0x02: // Disabled + return false; + default: + return std::nullopt; + } + } +} + +template +struct BasicControlCommand +{ + static constexpr quint8 ID = CommandId; + static const QByteArray HEADER; + + static const QByteArray ENABLED; + static const QByteArray DISABLED; + + static QByteArray create(quint8 data1 = 0x00, quint8 data2 = 0x00, + quint8 data3 = 0x00, quint8 data4 = 0x00) + { + return ControlCommand::createCommand(ID, data1, data2, data3, data4); + } + + // Basically returns the byte at the index 7 + static std::optional parseState(const QByteArray &data) + { + return ControlCommand::parseActive(data); + } +}; + +template +const QByteArray BasicControlCommand::HEADER = ControlCommand::HEADER + static_cast(CommandId); + +template +const QByteArray BasicControlCommand::ENABLED = create(0x01); + +template +const QByteArray BasicControlCommand::DISABLED = create(0x02); \ No newline at end of file diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index 50363a6..186b108 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -22,6 +22,7 @@ qt_add_executable(applinux BluetoothMonitor.cpp BluetoothMonitor.h autostartmanager.hpp + BasicControlCommand.hpp ) qt_add_qml_module(applinux diff --git a/linux/Main.qml b/linux/Main.qml index de3f502..28e9eee 100644 --- a/linux/Main.qml +++ b/linux/Main.qml @@ -226,6 +226,19 @@ ApplicationWindow { onCheckedChanged: airPodsTrayApp.notificationsEnabled = checked } + Switch { + visible: airPodsTrayApp.airpodsConnected + text: "One Bud ANC Mode" + checked: airPodsTrayApp.oneBudANCMode + onCheckedChanged: airPodsTrayApp.oneBudANCMode = checked + + ToolTip { + visible: parent.hovered + text: "Enable ANC when using one AirPod\n(More noise reduction, but uses more battery)" + delay: 500 + } + } + Row { spacing: 5 Label { diff --git a/linux/airpods_packets.h b/linux/airpods_packets.h index 8148e92..e7a4db1 100644 --- a/linux/airpods_packets.h +++ b/linux/airpods_packets.h @@ -3,18 +3,21 @@ #define AIRPODS_PACKETS_H #include +#include + #include "enums.h" +#include "BasicControlCommand.hpp" namespace AirPodsPackets { // Noise Control Mode Packets namespace NoiseControl { - static const QByteArray HEADER = QByteArray::fromHex("0400040009000D"); // Added for parsing - static const QByteArray OFF = HEADER + QByteArray::fromHex("01000000"); - static const QByteArray NOISE_CANCELLATION = HEADER + QByteArray::fromHex("02000000"); - static const QByteArray TRANSPARENCY = HEADER + QByteArray::fromHex("03000000"); - static const QByteArray ADAPTIVE = HEADER + QByteArray::fromHex("04000000"); + static const QByteArray HEADER = ControlCommand::HEADER + 0x0D; + static const QByteArray OFF = ControlCommand::createCommand(0x0D, 0x01); + static const QByteArray NOISE_CANCELLATION = ControlCommand::createCommand(0x0D, 0x02); + static const QByteArray TRANSPARENCY = ControlCommand::createCommand(0x0D, 0x03); + static const QByteArray ADAPTIVE = ControlCommand::createCommand(0x0D, 0x04); static const QByteArray getPacketForMode(AirpodsTrayApp::Enums::NoiseControlMode mode) { @@ -35,30 +38,71 @@ namespace AirPodsPackets } } - // Conversational Awareness Packets + // One Bud ANC Mode + namespace OneBudANCMode + { + using Type = BasicControlCommand<0x1B>; + static const QByteArray ENABLED = Type::ENABLED; + static const QByteArray DISABLED = Type::DISABLED; + static const QByteArray HEADER = Type::HEADER; + inline std::optional parseState(const QByteArray &data) { return Type::parseState(data); } + } + + // Volume Swipe (partial - still needs custom interval function) + namespace VolumeSwipe + { + using Type = BasicControlCommand<0x25>; + static const QByteArray ENABLED = Type::ENABLED; + static const QByteArray DISABLED = Type::DISABLED; + static const QByteArray HEADER = Type::HEADER; + inline std::optional parseState(const QByteArray &data) { return Type::parseState(data); } + + // Keep custom interval function + static QByteArray getIntervalPacket(quint8 interval) + { + return ControlCommand::createCommand(0x23, interval); + } + } + + // Adaptive Volume Config + namespace AdaptiveVolume + { + using Type = BasicControlCommand<0x26>; + static const QByteArray ENABLED = Type::ENABLED; + static const QByteArray DISABLED = Type::DISABLED; + static const QByteArray HEADER = Type::HEADER; + inline std::optional parseState(const QByteArray &data) { return Type::parseState(data); } + } + + // Conversational Awareness namespace ConversationalAwareness { - static const QByteArray HEADER = QByteArray::fromHex("04000400090028"); // For command/status - static const QByteArray ENABLED = HEADER + QByteArray::fromHex("01000000"); // Command to enable - static const QByteArray DISABLED = HEADER + QByteArray::fromHex("02000000"); // Command to disable - static const QByteArray DATA_HEADER = QByteArray::fromHex("040004004B00020001"); // For received speech level data + using Type = BasicControlCommand<0x28>; + static const QByteArray ENABLED = Type::ENABLED; + static const QByteArray DISABLED = Type::DISABLED; + static const QByteArray HEADER = Type::HEADER; + static const QByteArray DATA_HEADER = QByteArray::fromHex("040004004B00020001"); + inline std::optional parseState(const QByteArray &data) { return Type::parseState(data); } + } - static std::optional parseCAState(const QByteArray &data) - { - // Extract the status byte (index 7) - quint8 statusByte = static_cast(data.at(HEADER.size())); // HEADER.size() is 7 + // Hearing Assist + namespace HearingAssist + { + using Type = BasicControlCommand<0x33>; + static const QByteArray ENABLED = Type::ENABLED; + static const QByteArray DISABLED = Type::DISABLED; + static const QByteArray HEADER = Type::HEADER; + inline std::optional parseState(const QByteArray &data) { return Type::parseState(data); } + } - // Interpret the status byte - switch (statusByte) - { - case 0x01: // Enabled - return true; - case 0x02: // Disabled - return false; - default: - return std::nullopt; - } - } + // Allow Off Option + namespace AllowOffOption + { + using Type = BasicControlCommand<0x34>; + static const QByteArray ENABLED = Type::ENABLED; + static const QByteArray DISABLED = Type::DISABLED; + static const QByteArray HEADER = Type::HEADER; + inline std::optional parseState(const QByteArray &data) { return Type::parseState(data); } } // Connection Packets @@ -118,65 +162,37 @@ namespace AirPodsPackets { MagicCloudKeys keys; - // Expected size: header (7 bytes) + (1 (tag) + 2 (length) + 1 (reserved) + 16 (value)) * 2 = 47 bytes. - if (data.size() < 47) + if (data.size() < 47 || !data.startsWith(MAGIC_CLOUD_KEYS_HEADER)) { - return keys; // or handle error as needed + return keys; } - // Check header - if (!data.startsWith(MAGIC_CLOUD_KEYS_HEADER)) - { - return keys; // header mismatch - } + int index = MAGIC_CLOUD_KEYS_HEADER.size(); - int index = MAGIC_CLOUD_KEYS_HEADER.size(); // Start after header (index 7) - - // --- TLV Block 1 (MagicAccIRK) --- - // Tag should be 0x01 + // First TLV block (MagicAccIRK) if (static_cast(data.at(index)) != 0x01) - { - return keys; // unexpected tag - } + return keys; index += 1; - // Read length (2 bytes, big-endian) quint16 len1 = (static_cast(data.at(index)) << 8) | static_cast(data.at(index + 1)); if (len1 != 16) - { - return keys; // invalid length - } - index += 2; + return keys; + index += 3; // Skip length (2 bytes) and reserved byte (1 byte) - // Skip reserved byte - index += 1; - - // Extract MagicAccIRK (16 bytes) keys.magicAccIRK = data.mid(index, 16); index += 16; - // --- TLV Block 2 (MagicAccEncKey) --- - // Tag should be 0x04 + // Second TLV block (MagicAccEncKey) if (static_cast(data.at(index)) != 0x04) - { - return keys; // unexpected tag - } + return keys; index += 1; - // Read length (2 bytes, big-endian) quint16 len2 = (static_cast(data.at(index)) << 8) | static_cast(data.at(index + 1)); if (len2 != 16) - { - return keys; // invalid length - } - index += 2; + return keys; + index += 3; // Skip length (2 bytes) and reserved byte (1 byte) - // Skip reserved byte - index += 1; - - // Extract MagicAccEncKey (16 bytes) keys.magicAccEncKey = data.mid(index, 16); - index += 16; return keys; } diff --git a/linux/main.cpp b/linux/main.cpp index 92fb39f..925757f 100644 --- a/linux/main.cpp +++ b/linux/main.cpp @@ -37,6 +37,7 @@ class AirPodsTrayApp : public QObject { Q_PROPERTY(bool notificationsEnabled READ notificationsEnabled WRITE setNotificationsEnabled NOTIFY notificationsEnabledChanged) Q_PROPERTY(int retryAttempts READ retryAttempts WRITE setRetryAttempts NOTIFY retryAttemptsChanged) Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT) + Q_PROPERTY(bool oneBudANCMode READ oneBudANCMode WRITE setOneBudANCMode NOTIFY oneBudANCModeChanged) public: AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr) @@ -146,6 +147,7 @@ public: void setNotificationsEnabled(bool enabled) { trayManager->setNotificationsEnabled(enabled); } int retryAttempts() const { return m_retryAttempts; } bool hideOnStart() const { return m_hideOnStart; } + bool oneBudANCMode() const { return m_oneBudANCMode; } private: bool debugMode; @@ -227,6 +229,29 @@ public slots: emit conversationalAwarenessChanged(enabled); } + void setOneBudANCMode(bool enabled) + { + if (m_oneBudANCMode == enabled) + { + LOG_INFO("One Bud ANC mode is already " << (enabled ? "enabled" : "disabled")); + return; + } + + LOG_INFO("Setting One Bud ANC mode to: " << (enabled ? "enabled" : "disabled")); + QByteArray packet = enabled ? AirPodsPackets::OneBudANCMode::ENABLED + : AirPodsPackets::OneBudANCMode::DISABLED; + + if (writePacketToSocket(packet, "One Bud ANC mode packet written: ")) + { + m_oneBudANCMode = enabled; + emit oneBudANCModeChanged(enabled); + } + else + { + LOG_ERROR("Failed to send One Bud ANC mode command: socket not open"); + } + } + void setRetryAttempts(int attempts) { if (m_retryAttempts != attempts) @@ -440,6 +465,7 @@ private slots: trayManager->showNotification( tr("AirPods Disconnected"), tr("Your AirPods have been disconnected")); + trayManager->resetTrayIcon(); } void bluezDeviceDisconnected(const QString &address, const QString &name) @@ -628,7 +654,7 @@ private slots: } // Get CA state else if (data.startsWith(AirPodsPackets::ConversationalAwareness::HEADER)) { - auto result = AirPodsPackets::ConversationalAwareness::parseCAState(data); + auto result = AirPodsPackets::ConversationalAwareness::parseState(data); if (result.has_value()) { m_conversationalAwareness = result.value(); LOG_INFO("Conversational awareness state received: " << m_conversationalAwareness); @@ -697,6 +723,19 @@ private slots: } emit airPodsStatusChanged(); } + else if (data.startsWith(AirPodsPackets::OneBudANCMode::HEADER)) { + auto result = AirPodsPackets::OneBudANCMode::parseState(data); + if (result.has_value()) + { + m_oneBudANCMode = result.value(); + LOG_INFO("One Bud ANC mode received: " << m_conversationalAwareness); + emit oneBudANCModeChanged(m_conversationalAwareness); + } + else + { + LOG_ERROR("Failed to parse One Bud ANC mode"); + } + } else { LOG_DEBUG("Unrecognized packet format: " << data.toHex()); @@ -926,6 +965,7 @@ signals: void crossDeviceEnabledChanged(bool enabled); void notificationsEnabledChanged(bool enabled); void retryAttemptsChanged(int attempts); + void oneBudANCModeChanged(bool enabled); private: QBluetoothSocket *socket = nullptr; @@ -953,6 +993,7 @@ private: bool m_secoundaryInEar = false; QByteArray m_magicAccIRK; QByteArray m_magicAccEncKey; + bool m_oneBudANCMode = false; }; int main(int argc, char *argv[]) { diff --git a/linux/trayiconmanager.h b/linux/trayiconmanager.h index a6384e3..25c1530 100644 --- a/linux/trayiconmanager.h +++ b/linux/trayiconmanager.h @@ -33,6 +33,12 @@ public: } } + void resetTrayIcon() + { + trayIcon->setIcon(QIcon(":/icons/assets/airpods.png")); + trayIcon->setToolTip(""); + } + signals: void notificationsEnabledChanged(bool enabled);