merge the a11 fix with local

This commit is contained in:
Kavish Devar
2025-05-19 17:24:53 +05:30
8 changed files with 238 additions and 75 deletions

View File

@@ -1,9 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools">
android:sharedUserId="android.uid.system"
android:sharedUserMaxSdkVersion="32"
tools:targetApi="33">
<uses-feature <uses-feature
android:name="android.hardware.telephony" android:name="android.hardware.telephony"
@@ -34,6 +31,9 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<protected-broadcast android:name="batterywidget.impl.action.update_bluetooth_data" /> <protected-broadcast android:name="batterywidget.impl.action.update_bluetooth_data" />
<application <application

View File

@@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods package me.kavishdevar.librepods
import android.annotation.SuppressLint import android.annotation.SuppressLint
@@ -27,6 +29,7 @@ import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.provider.Settings 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.AirPodsNotifications
import me.kavishdevar.librepods.utils.CrossDevice import me.kavishdevar.librepods.utils.CrossDevice
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver lateinit var connectionStatusReceiver: BroadcastReceiver
@@ -183,17 +187,30 @@ fun Main() {
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) } var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) } val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
val permissionState = rememberMultiplePermissionsState( val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
permissions = listOf( listOf(
"android.permission.BLUETOOTH_CONNECT", "android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_SCAN", "android.permission.BLUETOOTH_SCAN",
"android.permission.BLUETOOTH", "android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_ADMIN", "android.permission.BLUETOOTH_ADMIN",
"android.permission.BLUETOOTH_ADVERTISE", "android.permission.BLUETOOTH_ADVERTISE"
"android.permission.POST_NOTIFICATIONS",
"android.permission.READ_PHONE_STATE",
"android.permission.ANSWER_PHONE_CALLS",
) )
} 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<AirPodsService?>(null) } val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }

View File

@@ -0,0 +1,69 @@
#include <QByteArray>
// 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<char>(identifier));
packet.append(static_cast<char>(data1));
packet.append(static_cast<char>(data2));
packet.append(static_cast<char>(data3));
packet.append(static_cast<char>(data4));
return packet;
}
// Parse activated/not activated
inline std::optional<bool> parseActive(const QByteArray &data)
{
if (!data.startsWith(ControlCommand::HEADER))
return std::nullopt;
quint8 statusByte = static_cast<quint8>(data.at(7));
switch (statusByte)
{
case 0x01: // Enabled
return true;
case 0x02: // Disabled
return false;
default:
return std::nullopt;
}
}
}
template <quint8 CommandId>
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<bool> parseState(const QByteArray &data)
{
return ControlCommand::parseActive(data);
}
};
template <quint8 CommandId>
const QByteArray BasicControlCommand<CommandId>::HEADER = ControlCommand::HEADER + static_cast<char>(CommandId);
template <quint8 CommandId>
const QByteArray BasicControlCommand<CommandId>::ENABLED = create(0x01);
template <quint8 CommandId>
const QByteArray BasicControlCommand<CommandId>::DISABLED = create(0x02);

View File

@@ -22,6 +22,7 @@ qt_add_executable(applinux
BluetoothMonitor.cpp BluetoothMonitor.cpp
BluetoothMonitor.h BluetoothMonitor.h
autostartmanager.hpp autostartmanager.hpp
BasicControlCommand.hpp
) )
qt_add_qml_module(applinux qt_add_qml_module(applinux

View File

@@ -226,6 +226,19 @@ ApplicationWindow {
onCheckedChanged: airPodsTrayApp.notificationsEnabled = checked 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 { Row {
spacing: 5 spacing: 5
Label { Label {

View File

@@ -3,18 +3,21 @@
#define AIRPODS_PACKETS_H #define AIRPODS_PACKETS_H
#include <QByteArray> #include <QByteArray>
#include <optional>
#include "enums.h" #include "enums.h"
#include "BasicControlCommand.hpp"
namespace AirPodsPackets namespace AirPodsPackets
{ {
// Noise Control Mode Packets // Noise Control Mode Packets
namespace NoiseControl namespace NoiseControl
{ {
static const QByteArray HEADER = QByteArray::fromHex("0400040009000D"); // Added for parsing static const QByteArray HEADER = ControlCommand::HEADER + 0x0D;
static const QByteArray OFF = HEADER + QByteArray::fromHex("01000000"); static const QByteArray OFF = ControlCommand::createCommand(0x0D, 0x01);
static const QByteArray NOISE_CANCELLATION = HEADER + QByteArray::fromHex("02000000"); static const QByteArray NOISE_CANCELLATION = ControlCommand::createCommand(0x0D, 0x02);
static const QByteArray TRANSPARENCY = HEADER + QByteArray::fromHex("03000000"); static const QByteArray TRANSPARENCY = ControlCommand::createCommand(0x0D, 0x03);
static const QByteArray ADAPTIVE = HEADER + QByteArray::fromHex("04000000"); static const QByteArray ADAPTIVE = ControlCommand::createCommand(0x0D, 0x04);
static const QByteArray getPacketForMode(AirpodsTrayApp::Enums::NoiseControlMode mode) 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<bool> 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<bool> 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<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
}
// Conversational Awareness
namespace ConversationalAwareness namespace ConversationalAwareness
{ {
static const QByteArray HEADER = QByteArray::fromHex("04000400090028"); // For command/status using Type = BasicControlCommand<0x28>;
static const QByteArray ENABLED = HEADER + QByteArray::fromHex("01000000"); // Command to enable static const QByteArray ENABLED = Type::ENABLED;
static const QByteArray DISABLED = HEADER + QByteArray::fromHex("02000000"); // Command to disable static const QByteArray DISABLED = Type::DISABLED;
static const QByteArray DATA_HEADER = QByteArray::fromHex("040004004B00020001"); // For received speech level data static const QByteArray HEADER = Type::HEADER;
static const QByteArray DATA_HEADER = QByteArray::fromHex("040004004B00020001");
inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
}
static std::optional<bool> parseCAState(const QByteArray &data) // Hearing Assist
{ namespace HearingAssist
// Extract the status byte (index 7) {
quint8 statusByte = static_cast<quint8>(data.at(HEADER.size())); // HEADER.size() is 7 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<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
}
// Interpret the status byte // Allow Off Option
switch (statusByte) namespace AllowOffOption
{ {
case 0x01: // Enabled using Type = BasicControlCommand<0x34>;
return true; static const QByteArray ENABLED = Type::ENABLED;
case 0x02: // Disabled static const QByteArray DISABLED = Type::DISABLED;
return false; static const QByteArray HEADER = Type::HEADER;
default: inline std::optional<bool> parseState(const QByteArray &data) { return Type::parseState(data); }
return std::nullopt;
}
}
} }
// Connection Packets // Connection Packets
@@ -118,65 +162,37 @@ namespace AirPodsPackets
{ {
MagicCloudKeys keys; MagicCloudKeys keys;
// Expected size: header (7 bytes) + (1 (tag) + 2 (length) + 1 (reserved) + 16 (value)) * 2 = 47 bytes. if (data.size() < 47 || !data.startsWith(MAGIC_CLOUD_KEYS_HEADER))
if (data.size() < 47)
{ {
return keys; // or handle error as needed return keys;
} }
// Check header int index = MAGIC_CLOUD_KEYS_HEADER.size();
if (!data.startsWith(MAGIC_CLOUD_KEYS_HEADER))
{
return keys; // header mismatch
}
int index = MAGIC_CLOUD_KEYS_HEADER.size(); // Start after header (index 7) // First TLV block (MagicAccIRK)
// --- TLV Block 1 (MagicAccIRK) ---
// Tag should be 0x01
if (static_cast<quint8>(data.at(index)) != 0x01) if (static_cast<quint8>(data.at(index)) != 0x01)
{ return keys;
return keys; // unexpected tag
}
index += 1; index += 1;
// Read length (2 bytes, big-endian)
quint16 len1 = (static_cast<quint8>(data.at(index)) << 8) | static_cast<quint8>(data.at(index + 1)); quint16 len1 = (static_cast<quint8>(data.at(index)) << 8) | static_cast<quint8>(data.at(index + 1));
if (len1 != 16) if (len1 != 16)
{ return keys;
return keys; // invalid length index += 3; // Skip length (2 bytes) and reserved byte (1 byte)
}
index += 2;
// Skip reserved byte
index += 1;
// Extract MagicAccIRK (16 bytes)
keys.magicAccIRK = data.mid(index, 16); keys.magicAccIRK = data.mid(index, 16);
index += 16; index += 16;
// --- TLV Block 2 (MagicAccEncKey) --- // Second TLV block (MagicAccEncKey)
// Tag should be 0x04
if (static_cast<quint8>(data.at(index)) != 0x04) if (static_cast<quint8>(data.at(index)) != 0x04)
{ return keys;
return keys; // unexpected tag
}
index += 1; index += 1;
// Read length (2 bytes, big-endian)
quint16 len2 = (static_cast<quint8>(data.at(index)) << 8) | static_cast<quint8>(data.at(index + 1)); quint16 len2 = (static_cast<quint8>(data.at(index)) << 8) | static_cast<quint8>(data.at(index + 1));
if (len2 != 16) if (len2 != 16)
{ return keys;
return keys; // invalid length index += 3; // Skip length (2 bytes) and reserved byte (1 byte)
}
index += 2;
// Skip reserved byte
index += 1;
// Extract MagicAccEncKey (16 bytes)
keys.magicAccEncKey = data.mid(index, 16); keys.magicAccEncKey = data.mid(index, 16);
index += 16;
return keys; return keys;
} }

View File

@@ -37,6 +37,7 @@ class AirPodsTrayApp : public QObject {
Q_PROPERTY(bool notificationsEnabled READ notificationsEnabled WRITE setNotificationsEnabled NOTIFY notificationsEnabledChanged) Q_PROPERTY(bool notificationsEnabled READ notificationsEnabled WRITE setNotificationsEnabled NOTIFY notificationsEnabledChanged)
Q_PROPERTY(int retryAttempts READ retryAttempts WRITE setRetryAttempts NOTIFY retryAttemptsChanged) Q_PROPERTY(int retryAttempts READ retryAttempts WRITE setRetryAttempts NOTIFY retryAttemptsChanged)
Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT) Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT)
Q_PROPERTY(bool oneBudANCMode READ oneBudANCMode WRITE setOneBudANCMode NOTIFY oneBudANCModeChanged)
public: public:
AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr) AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr)
@@ -146,6 +147,7 @@ public:
void setNotificationsEnabled(bool enabled) { trayManager->setNotificationsEnabled(enabled); } void setNotificationsEnabled(bool enabled) { trayManager->setNotificationsEnabled(enabled); }
int retryAttempts() const { return m_retryAttempts; } int retryAttempts() const { return m_retryAttempts; }
bool hideOnStart() const { return m_hideOnStart; } bool hideOnStart() const { return m_hideOnStart; }
bool oneBudANCMode() const { return m_oneBudANCMode; }
private: private:
bool debugMode; bool debugMode;
@@ -227,6 +229,29 @@ public slots:
emit conversationalAwarenessChanged(enabled); 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) void setRetryAttempts(int attempts)
{ {
if (m_retryAttempts != attempts) if (m_retryAttempts != attempts)
@@ -440,6 +465,7 @@ private slots:
trayManager->showNotification( trayManager->showNotification(
tr("AirPods Disconnected"), tr("AirPods Disconnected"),
tr("Your AirPods have been disconnected")); tr("Your AirPods have been disconnected"));
trayManager->resetTrayIcon();
} }
void bluezDeviceDisconnected(const QString &address, const QString &name) void bluezDeviceDisconnected(const QString &address, const QString &name)
@@ -628,7 +654,7 @@ private slots:
} }
// Get CA state // Get CA state
else if (data.startsWith(AirPodsPackets::ConversationalAwareness::HEADER)) { else if (data.startsWith(AirPodsPackets::ConversationalAwareness::HEADER)) {
auto result = AirPodsPackets::ConversationalAwareness::parseCAState(data); auto result = AirPodsPackets::ConversationalAwareness::parseState(data);
if (result.has_value()) { if (result.has_value()) {
m_conversationalAwareness = result.value(); m_conversationalAwareness = result.value();
LOG_INFO("Conversational awareness state received: " << m_conversationalAwareness); LOG_INFO("Conversational awareness state received: " << m_conversationalAwareness);
@@ -697,6 +723,19 @@ private slots:
} }
emit airPodsStatusChanged(); 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 else
{ {
LOG_DEBUG("Unrecognized packet format: " << data.toHex()); LOG_DEBUG("Unrecognized packet format: " << data.toHex());
@@ -926,6 +965,7 @@ signals:
void crossDeviceEnabledChanged(bool enabled); void crossDeviceEnabledChanged(bool enabled);
void notificationsEnabledChanged(bool enabled); void notificationsEnabledChanged(bool enabled);
void retryAttemptsChanged(int attempts); void retryAttemptsChanged(int attempts);
void oneBudANCModeChanged(bool enabled);
private: private:
QBluetoothSocket *socket = nullptr; QBluetoothSocket *socket = nullptr;
@@ -953,6 +993,7 @@ private:
bool m_secoundaryInEar = false; bool m_secoundaryInEar = false;
QByteArray m_magicAccIRK; QByteArray m_magicAccIRK;
QByteArray m_magicAccEncKey; QByteArray m_magicAccEncKey;
bool m_oneBudANCMode = false;
}; };
int main(int argc, char *argv[]) { int main(int argc, char *argv[]) {

View File

@@ -33,6 +33,12 @@ public:
} }
} }
void resetTrayIcon()
{
trayIcon->setIcon(QIcon(":/icons/assets/airpods.png"));
trayIcon->setToolTip("");
}
signals: signals:
void notificationsEnabledChanged(bool enabled); void notificationsEnabledChanged(bool enabled);