37 Commits

Author SHA1 Message Date
Kavish Devar
f547cc13c0 linux: fix conversational awareness check for phone MAC 2025-09-03 03:28:59 +05:30
TCH
11fa9180e2 CI: add linux ci (#196) 2025-09-03 03:23:15 +05:30
Kavish Devar
73e55a02d6 add instructions for windows for prox keys script 2025-09-03 03:21:41 +05:30
Kavish Devar
325ef1e953 add cross-platform tool for retrieving proxmity key 2025-09-03 03:17:25 +05:30
qlenlen
5e30531514 i18n: add chinese translation (#200) 2025-09-03 03:01:23 +05:30
Kavish Devar
75fa80c17e android,linux: fix random volume jumps (#192)
* android: update feature flags packet data byte to remove adaptive volume
* linux: update feature flags to prevent volume jumps
2025-08-25 21:25:29 +05:30
TCH
eb1b633aff [Linux] Let users edit Phone bluetooth MAC via GUI (#195)
initial commit
2025-08-25 17:45:36 +02:00
Naveen M K
dde5d1e808 linux: update rename function call for airPods (#191)
previously it was failing with an error that renameAirPods is not a function
This should fix that as the function is defined in airPodsTrayApp
2025-08-25 17:55:06 +05:30
TCH
598bd3d7d8 linux: update logging tag (#194)
change linux logs name to librepods
2025-08-25 17:50:29 +05:30
Kavish Devar
46071f17d7 android: remove broken volume panel hook
Removed volume panel hooking logic and related classes.
2025-08-25 17:41:53 +05:30
Kavish Devar
13ab2d1feb docs: add more control commands ('25)
auto-pause when sleeping, system siri message, set recv raw gestures, 2 eq settings, in-case tone volume, allow auto-connect, disable button input
2025-08-14 10:33:52 +05:30
Raspberrynani
72a7637863 linux: fixed build instructions for ubuntu (#183)
Fixed build instructions

Ubuntu/Debian should use libssl-dev as libssl-devel does not exist
2025-07-15 17:30:30 +05:30
Kavish Devar
24686da1f3 android: add ability to launch digital assistant on long press (#180)
* Initial plan

* Implement BLE-only mode toggle and basic functionality

Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com>

* Fix BLE-only mode compatibility issues and enhance MAC address handling

Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com>

* Address BLE-only mode feedback: hide renaming, add ear detection warning, ensure default is false

Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com>

* android: add support for invoking digital assistant on long press

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-07-13 19:51:37 +05:30
Copilot
d9359cd81a android: add option for alternate head tracking packets (#176)
* Initial plan

* Add option for alternate head tracking packets

Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: kavishdevar <46088622+kavishdevar@users.noreply.github.com>
2025-07-11 10:11:55 +05:30
Mario Borna Mjertan
db563fa75f Fixes and improvements to the Linux readme (#168)
* Add qt development header list for Fedora

* Add openssl development header dependency to Linux readme

* PHONE_MAC_ADDRESS is now an environment variable, not a main.h constant

* The build target is librepods, not applinux
2025-07-05 00:15:21 +05:30
Tim Gromeyer
fb3c8c73a4 [Linux] Start/Stop BLE scan when going to sleep 2025-06-24 13:30:46 +02:00
Tim Gromeyer
05c0a7c88b [Linux] Rename to Librepods 2025-06-16 11:46:50 +02:00
Tim Gromeyer
96ee2410e8 [Linux] Autostart im hidden mode 2025-06-15 12:03:23 +02:00
Tim Gromeyer
c0d915666b [Linux] Remember battery state (closes #149) 2025-06-09 10:54:02 +02:00
Tim Gromeyer
91ffaaa972 [Linux] DBus fixes 2025-06-08 22:06:58 +02:00
Tim Gromeyer
48ae249405 [Linux] Don't use playerctl for current media state 2025-06-08 18:47:47 +02:00
Tim Gromeyer
aaf82c9738 [Linux] Play/Pause via DBus 2025-06-08 18:47:47 +02:00
Tim Gromeyer
38d6f8ceae [Linux] Use DBus for following media playback change 2025-06-08 18:47:47 +02:00
Tim Gromeyer
5754dbfb16 [Linux] New ear detection implementation (#145)
* New ear detection implementation

* [Linux] Improved case battery detection when not connected
2025-06-07 09:19:14 +02:00
Kavish Devar
3b20540c34 android: fix duplicate screenshot in readme 2025-06-05 18:25:43 +05:30
Kavish Devar
595797c703 android: hook libbluetooth_qti.so too 2025-06-05 18:03:09 +05:30
Tim Gromeyer
2e782ba051 [Linux] Fix noise control mode cycling 2025-06-05 10:43:50 +02:00
Tim Gromeyer
3023c706bf [Linux] Fix Adaptive mode not working 2025-06-05 10:43:50 +02:00
Kavish Devar
0d582d890b android: add irk and encryption key from a qr 2025-06-05 13:13:49 +05:30
Tim Gromeyer
9b907fdec4 [Linux] Fix battery sometimes showing 127% (#143) 2025-06-05 09:16:04 +02:00
Tim Gromeyer
43d703423a [Linux] Add Qr-Code to sync irk and enc key (#142) 2025-06-05 09:03:29 +02:00
Kavish Devar
dcb25e2e52 [ImgBot] Optimize images 2025-06-04 17:15:18 +05:30
ImgBotApp
31397f055e [ImgBot] Optimize images
*Total -- 1,009.27kb -> 800.74kb (20.66%)

/imgs/banner.png -- 266.07kb -> 199.11kb (25.17%)
/android/imgs/debug.png -- 174.22kb -> 131.86kb (24.31%)
/android/imgs/customizations-2.png -- 211.58kb -> 162.48kb (23.2%)
/android/imgs/customizations-1.png -- 209.27kb -> 162.84kb (22.19%)
/android/app/src/main/res/drawable/pro_2_right.png -- 36.47kb -> 34.80kb (4.58%)
/android/app/src/main/res/drawable/pro_2_left.png -- 34.88kb -> 33.36kb (4.38%)
/linux/assets/airpods.png -- 76.78kb -> 76.30kb (0.63%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2025-06-04 11:34:04 +00:00
Tim Gromeyer
070713540a [Linux] Fix build 2025-06-04 13:32:53 +02:00
Tim Gromeyer
6574e52195 [Linux] Compress images 2025-06-04 13:32:53 +02:00
Tim Gromeyer
c4633d6871 [Linux] Read AirPods state from BLE broadcast when not connected (#138)
* Fix possible build error

* [Linux] Read AirPods state from BLE broadcast when not connected

* SImplify

* Remove old code

* Remove old code

* Maintain charging state when state is unknown

* Simplify

* Remove unused var
2025-06-04 13:10:55 +02:00
Kavish Devar
5dc7e512ae update readme 2025-06-03 20:35:29 +05:30
76 changed files with 3873 additions and 1186 deletions

36
.github/workflows/ci-linux.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Build LibrePods Linux
on:
push:
branches:
- '*'
jobs:
build-linux:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential cmake ninja-build \
qt6-base-dev qt6-declarative-dev qt6-svg-dev \
qt6-tools-dev qt6-tools-dev-tools qt6-connectivity-dev \
libxkbcommon-dev
- name: Build project
working-directory: linux
run: |
mkdir build
cd build
cmake .. -G Ninja
ninja
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: librepods-linux
path: linux/build/librepods

View File

@@ -1,4 +1,2 @@
## btl2capfix v0.0.3
- ([#34](https://github.com/kavishdevar/librepods/pull/34)) @devnoname120 Add on-device libbluetooth patcher using a Magisk/KernelSU module (arm64-only)
_[See more here](https://github.com/kavishdevar/librepods/releases)_
## LibrePods root module changelog
_[See here](https://github.com/kavishdevar/librepods/releases)_

View File

@@ -8,6 +8,7 @@
[![GitHub license](https://img.shields.io/github/license/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/blob/main/LICENSE)
[![GitHub contributors](https://img.shields.io/github/contributors/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/graphs/contributors)
## What is LibrePods?
LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem.
@@ -61,8 +62,8 @@ For installation and detailed info, see the [Linux README](/linux/README.md).
|-------------------|-------------------|-------------------|
| ![Settings 1](/android/imgs/settings-1.png) | ![Settings 2](/android/imgs/settings-2.png) | ![Debug Screen](/android/imgs/debug.png) |
| ![Battery Notification and QS Tile for NC Mode](/android/imgs/notification-and-qs.png) | ![Popup](/android/imgs/popup.png) | ![Head Tracking and Gestures](/android/imgs/head-tracking-and-gestures.png) |
| ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations](/android/imgs/customizations.png) |
| ![audio-popup](/android/imgs/audio-connected-island.png) | | |
| ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations 1](/android/imgs/customizations-1.png) |
| ![Customizations 2](/android/imgs/customizations-2.png) | ![audio-popup](/android/imgs/audio-connected-island.png) | |
#### Root Requirement
@@ -149,3 +150,5 @@ GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program over [here](/LICENSE). If not, see <https://www.gnu.org/licenses/>.
All trademarks, logos, and brand names are the property of their respective owners. Use of them does not imply any affiliation with or endorsement by them. All AirPods images, symbols, and the SF Pro font are the property of Apple Inc.

View File

@@ -90,6 +90,13 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="librepods"
android:host="add-magic-keys" />
</intent-filter>
</activity>
<activity

View File

@@ -303,15 +303,15 @@ uintptr_t getModuleBase(const char *module_name) {
return base_addr;
}
bool findAndHookFunction([[maybe_unused]] const char *library_path) {
bool findAndHookFunction(const char *library_name) {
if (!hook_func) {
LOGE("Hook function not initialized");
return false;
}
uintptr_t base_addr = getModuleBase("libbluetooth_jni.so");
uintptr_t base_addr = getModuleBase(library_name);
if (!base_addr) {
LOGE("Failed to get base address of libbluetooth_jni.so");
LOGE("Failed to get base address of %s", library_name);
return false;
}
@@ -397,11 +397,18 @@ bool findAndHookFunction([[maybe_unused]] const char *library_path) {
void on_library_loaded(const char *name, [[maybe_unused]] void *handle) {
if (strstr(name, "libbluetooth_jni.so")) {
LOGI("Detected Bluetooth library: %s", name);
LOGI("Detected Bluetooth JNI library: %s", name);
bool hooked = findAndHookFunction(name);
bool hooked = findAndHookFunction("libbluetooth_jni.so");
if (!hooked) {
LOGE("Failed to hook Bluetooth library function");
LOGE("Failed to hook Bluetooth JNI library function");
}
} else if (strstr(name, "libbluetooth_qti.so")) {
LOGI("Detected Bluetooth QTI library: %s", name);
bool hooked = findAndHookFunction("libbluetooth_qti.so");
if (!hooked) {
LOGE("Failed to hook Bluetooth QTI library function");
}
}
}
@@ -413,5 +420,4 @@ NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
hook_func = entries->hook_func;
return on_library_loaded;
}
}

View File

@@ -34,6 +34,7 @@ import android.os.Bundle
import android.os.IBinder
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -103,6 +104,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.MultiplePermissionsState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.DebugScreen
@@ -113,9 +115,9 @@ import me.kavishdevar.librepods.screens.RenameScreen
import me.kavishdevar.librepods.screens.TroubleshootingScreen
import me.kavishdevar.librepods.services.AirPodsService
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.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection
@@ -140,6 +142,8 @@ class MainActivity : ComponentActivity() {
Main()
}
}
handleIncomingIntent(intent)
}
override fun onDestroy() {
@@ -174,6 +178,73 @@ class MainActivity : ComponentActivity() {
}
super.onStop()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIncomingIntent(intent)
}
private fun handleIncomingIntent(intent: Intent) {
val data: Uri? = intent.data
if (data != null && data.scheme == "librepods") {
when (data.host) {
"add-magic-keys" -> {
// Extract query parameters
val queryParams = data.queryParameterNames
queryParams.forEach { param ->
val value = data.getQueryParameter(param)
// Handle your parameters here
Log.d("LibrePods", "Parameter: $param = $value")
}
// Process the magic keys addition
handleAddMagicKeys(data)
}
}
}
}
private fun handleAddMagicKeys(uri: Uri) {
val context = this
val sharedPreferences = getSharedPreferences("settings", Context.MODE_PRIVATE)
val irkHex = uri.getQueryParameter("irk")
val encKeyHex = uri.getQueryParameter("enc_key")
try {
if (irkHex != null && validateHexInput(irkHex)) {
val irkBytes = hexStringToByteArray(irkHex)
val irkBase64 = Base64.encode(irkBytes)
sharedPreferences.edit().putString("IRK", irkBase64).apply()
}
if (encKeyHex != null && validateHexInput(encKeyHex)) {
val encKeyBytes = hexStringToByteArray(encKeyHex)
val encKeyBase64 = Base64.encode(encKeyBytes)
sharedPreferences.edit().putString("ENC_KEY", encKeyBase64).apply()
}
Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun validateHexInput(input: String): Boolean {
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
return hexPattern.matches(input)
}
private fun hexStringToByteArray(hex: String): ByteArray {
val result = ByteArray(16)
for (i in 0 until 16) {
val hexByte = hex.substring(i * 2, i * 2 + 2)
result[i] = hexByte.toInt(16).toByte()
}
return result
}
}
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
@@ -661,4 +732,3 @@ fun PermissionCard(
}
}
}

View File

@@ -62,22 +62,20 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
import me.kavishdevar.librepods.composables.IconAreaSize
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
import me.kavishdevar.librepods.composables.IconAreaSize
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AirPodsNotifications
import me.kavishdevar.librepods.utils.NoiseControlMode
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs

View File

@@ -47,11 +47,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.AirPodsNotifications
import me.kavishdevar.librepods.utils.Battery
import me.kavishdevar.librepods.utils.BatteryComponent
import me.kavishdevar.librepods.utils.BatteryStatus
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable

View File

@@ -56,7 +56,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.NoiseControlMode
import me.kavishdevar.librepods.constants.NoiseControlMode
private val ContainerColor = Color(0x593C3C3E)
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)

View File

@@ -127,4 +127,4 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
@Composable
fun IndependentTogglePreview() {
IndependentToggle("Test", AirPodsService(), "test", LocalContext.current.getSharedPreferences("preview", 0), true)
}
}

View File

@@ -73,10 +73,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AirPodsNotifications
import me.kavishdevar.librepods.utils.NoiseControlMode
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt

View File

@@ -18,6 +18,7 @@
package me.kavishdevar.librepods.composables
import android.content.Context
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -57,6 +58,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.StemAction
@Composable
fun PressAndHoldSettings(navController: NavController) {
@@ -70,6 +72,24 @@ fun PressAndHoldSettings(navController: NavController) {
val animatedLeftBackgroundColor by animateColorAsState(targetValue = leftBackgroundColor, animationSpec = animationSpec)
val animatedRightBackgroundColor by animateColorAsState(targetValue = rightBackgroundColor, animationSpec = animationSpec)
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!"
}
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!"
}
Text(
text = stringResource(R.string.press_and_hold_airpods).uppercase(),
style = TextStyle(
@@ -122,7 +142,7 @@ fun PressAndHoldSettings(navController: NavController) {
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.noise_control),
text = leftActionText,
style = TextStyle(
fontSize = 18.sp,
color = textColor.copy(alpha = 0.6f),
@@ -182,7 +202,7 @@ fun PressAndHoldSettings(navController: NavController) {
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = stringResource(R.string.noise_control),
text = rightActionText,
style = TextStyle(
fontSize = 18.sp,
color = textColor.copy(alpha = 0.6f),

View File

@@ -16,10 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("unused")
package me.kavishdevar.librepods.utils
package me.kavishdevar.librepods.constants
import android.os.Parcelable
import android.util.Log
@@ -27,27 +24,10 @@ import kotlinx.parcelize.Parcelize
enum class Enums(val value: ByteArray) {
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
CONVERSATION_AWARENESS(Capabilities.CONVERSATION_AWARENESS),
CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY(Capabilities.CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY),
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
SETTINGS(byteArrayOf(0x09, 0x00)),
SUFFIX(byteArrayOf(0x00, 0x00, 0x00)),
NOTIFICATION_FILTER(byteArrayOf(0x0f)),
HANDSHAKE(byteArrayOf(0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
SPECIFIC_FEATURES(byteArrayOf(0x4d)),
SET_SPECIFIC_FEATURES(PREFIX.value + SPECIFIC_FEATURES.value + byteArrayOf(0x00,
0xff.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
REQUEST_NOTIFICATIONS(PREFIX.value + NOTIFICATION_FILTER.value + byteArrayOf(0x00, 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte())),
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
NOISE_CANCELLATION_OFF(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.OFF.value + SUFFIX.value),
NOISE_CANCELLATION_ON(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.ON.value + SUFFIX.value),
NOISE_CANCELLATION_TRANSPARENCY(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.TRANSPARENCY.value + SUFFIX.value),
NOISE_CANCELLATION_ADAPTIVE(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.ADAPTIVE.value + SUFFIX.value),
SET_CONVERSATION_AWARENESS_OFF(PREFIX.value + SETTINGS.value + CONVERSATION_AWARENESS.value + Capabilities.ConversationAwareness.OFF.value + SUFFIX.value),
SET_CONVERSATION_AWARENESS_ON(PREFIX.value + SETTINGS.value + CONVERSATION_AWARENESS.value + Capabilities.ConversationAwareness.ON.value + SUFFIX.value),
CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00)),
START_HEAD_TRACKING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00)),
STOP_HEAD_TRACKING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E.toByte(), 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E.toByte(), 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00));
}
object BatteryComponent {
@@ -156,7 +136,7 @@ class AirPodsNotifications {
}
val name: String =
when (status) {
when (status) {
1 -> "OFF"
2 -> "ON"
3 -> "TRANSPARENCY"
@@ -251,103 +231,10 @@ class AirPodsNotifications {
class Capabilities {
companion object {
val NOISE_CANCELLATION = byteArrayOf(0x0d)
val CONVERSATION_AWARENESS = byteArrayOf(0x28)
val CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY = byteArrayOf(0x01, 0x02)
val EAR_DETECTION = byteArrayOf(0x06)
}
enum class NoiseCancellation(val value: ByteArray) {
OFF(byteArrayOf(0x01)),
ON(byteArrayOf(0x02)),
TRANSPARENCY(byteArrayOf(0x03)),
ADAPTIVE(byteArrayOf(0x04));
}
enum class ConversationAwareness(val value: ByteArray) {
OFF(byteArrayOf(0x02)),
ON(byteArrayOf(0x01));
}
}
enum class LongPressPackets(val value: ByteArray) {
ENABLE_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0F, 0x00, 0x00, 0x00)),
DISABLE_OFF_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
DISABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0c, 0x00, 0x00, 0x00)),
DISABLE_OFF_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
DISABLE_OFF_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0a, 0x00, 0x00, 0x00)),
ENABLE_OFF_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
ENABLE_OFF_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
ENABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x03, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0a, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x09, 0x00, 0x00, 0x00)),
ENABLE_TRANSPARENCY_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
ENABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
ENABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
DISABLE_ANC_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0D, 0x00, 0x00, 0x00)),
DISABLE_ANC_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x05, 0x00, 0x00, 0x00)),
DISABLE_ANC_FROM_ADAPTIVE_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0c, 0x00, 0x00, 0x00)),
DISABLE_ANC_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x09, 0x00, 0x00, 0x00)),
ENABLE_ANC_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
ENABLE_ANC_FROM_ADAPTIVE_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
ENABLE_ANC_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x05, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x03, 0x00, 0x00, 0x00)),
ENABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
ENABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
ENABLE_ADAPTIVE_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
ENABLE_EVERYTHING_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0E, 0x00, 0x00, 0x00)),
DISABLE_TRANSPARENCY_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0A, 0x00, 0x00, 0x00)),
DISABLE_ADAPTIVE_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
DISABLE_ANC_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0C, 0x00, 0x00, 0x00)),
}
//enum class LongPressMode {
// OFF, TRANSPARENCY, ADAPTIVE, ANC
//}
//
//data class LongPressPacket(val modes: Set<LongPressMode>) {
// val value: ByteArray
// get() {
// val baseArray = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A)
// val modeByte = calculateModeByte()
// return baseArray + byteArrayOf(modeByte, 0x00, 0x00, 0x00)
// }
//
// private fun calculateModeByte(): Byte {
// var modeByte: Byte = 0x00
// modes.forEach { mode ->
// modeByte = when (mode) {
// LongPressMode.OFF -> (modeByte + 0x01).toByte()
// LongPressMode.TRANSPARENCY -> (modeByte + 0x02).toByte()
// LongPressMode.ADAPTIVE -> (modeByte + 0x04).toByte()
// LongPressMode.ANC -> (modeByte + 0x08).toByte()
// }
// }
// return modeByte
// }
//}
//
//fun determinePacket(changedIndex: Int, newEnabled: Boolean, oldModes: Set<LongPressMode>, newModes: Set<LongPressMode>): ByteArray? {
// return if (newEnabled) {
// LongPressPacket(oldModes + newModes.elementAt(changedIndex)).value
// } else {
// LongPressPacket(oldModes - newModes.elementAt(changedIndex)).value
// }
//}
fun isHeadTrackingData(data: ByteArray): Boolean {
if (data.size <= 60) return false

View File

@@ -0,0 +1,42 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.constants
import me.kavishdevar.librepods.constants.StemAction.entries
import me.kavishdevar.librepods.utils.AACPManager
enum class StemAction {
PLAY_PAUSE,
PREVIOUS_TRACK,
NEXT_TRACK,
CAMERA_SHUTTER,
DIGITAL_ASSISTANT,
CYCLE_NOISE_CONTROL_MODES;
companion object {
fun fromString(action: String): StemAction? {
return entries.find { it.name == action }
}
val defaultActions: Map<AACPManager.Companion.StemPressType, StemAction> = mapOf(
AACPManager.Companion.StemPressType.SINGLE_PRESS to PLAY_PAUSE,
AACPManager.Companion.StemPressType.DOUBLE_PRESS to NEXT_TRACK,
AACPManager.Companion.StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
AACPManager.Companion.StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
)
}
}

View File

@@ -99,10 +99,10 @@ import me.kavishdevar.librepods.composables.NameField
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.NoiseControlSettings
import me.kavishdevar.librepods.composables.PressAndHoldSettings
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AirPodsNotifications
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -113,6 +113,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
var isLocallyConnected by remember { mutableStateOf(isConnected) }
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val bleOnlyMode = sharedPreferences.getBoolean("ble_only_mode", false)
var device by remember { mutableStateOf(dev) }
var deviceName by remember {
mutableStateOf(
@@ -329,35 +330,67 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
Spacer(modifier = Modifier.height(32.dp))
NameField(
name = stringResource(R.string.name),
value = deviceName.text,
navController = navController
)
// Show BLE-only mode indicator
if (bleOnlyMode) {
Text(
text = "BLE-only mode - advanced features disabled",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 16.dp)
)
}
Spacer(modifier = Modifier.height(32.dp))
NoiseControlSettings(service = service)
// Only show name field when not in BLE-only mode
if (!bleOnlyMode) {
NameField(
name = stringResource(R.string.name),
value = deviceName.text,
navController = navController
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.head_gestures).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
// Only show L2CAP-dependent features when not in BLE-only mode
if (!bleOnlyMode) {
Spacer(modifier = Modifier.height(32.dp))
NoiseControlSettings(service = service)
Spacer(modifier = Modifier.height(2.dp))
NavigationButton(to = "head_tracking", "Head Tracking", navController)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.head_gestures).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
Spacer(modifier = Modifier.height(16.dp))
PressAndHoldSettings(navController = navController)
Spacer(modifier = Modifier.height(2.dp))
NavigationButton(to = "head_tracking", "Head Tracking", navController)
Spacer(modifier = Modifier.height(16.dp))
AudioSettings()
Spacer(modifier = Modifier.height(16.dp))
PressAndHoldSettings(navController = navController)
Spacer(modifier = Modifier.height(16.dp))
AudioSettings()
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
name = "Off Listening Mode",
service = service,
sharedPreferences = sharedPreferences,
default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
)
Spacer(modifier = Modifier.height(16.dp))
AccessibilitySettings()
}
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
@@ -365,23 +398,15 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
service = service,
functionName = "setEarDetection",
sharedPreferences = sharedPreferences,
default = true
default = true,
)
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
name = "Off Listening Mode",
service = service,
sharedPreferences = sharedPreferences,
default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
)
// Only show debug when not in BLE-only mode
if (!bleOnlyMode) {
Spacer(modifier = Modifier.height(16.dp))
NavigationButton("debug", "Debug", navController)
}
Spacer(modifier = Modifier.height(16.dp))
AccessibilitySettings()
Spacer(modifier = Modifier.height(16.dp))
NavigationButton("debug", "Debug", navController)
Spacer(Modifier.height(24.dp))
}
}

View File

@@ -179,6 +179,21 @@ fun AppSettingsScreen(navController: NavController) {
mutableStateOf(sharedPreferences.getBoolean("takeover_when_media_start", true))
}
var useAlternateHeadTrackingPackets by remember {
mutableStateOf(sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false))
}
var bleOnlyMode by remember {
mutableStateOf(sharedPreferences.getBoolean("ble_only_mode", false))
}
// Ensure the default value is properly set if not exists
LaunchedEffect(Unit) {
if (!sharedPreferences.contains("ble_only_mode")) {
sharedPreferences.edit().putBoolean("ble_only_mode", false).apply()
}
}
var mDensity by remember { mutableFloatStateOf(0f) }
fun validateHexInput(input: String): Boolean {
@@ -331,6 +346,69 @@ fun AppSettingsScreen(navController: NavController) {
}
}
Text(
text = "Connection Mode".uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(2.dp))
Column (
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(14.dp)
)
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
bleOnlyMode = !bleOnlyMode
sharedPreferences.edit().putBoolean("ble_only_mode", bleOnlyMode).apply()
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = "BLE Only Mode",
fontSize = 16.sp,
color = textColor
)
Text(
text = "Only use Bluetooth Low Energy for battery data and ear detection. Disables advanced features requiring L2CAP connection.",
fontSize = 13.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = bleOnlyMode,
onCheckedChange = {
bleOnlyMode = it
sharedPreferences.edit().putBoolean("ble_only_mode", it).apply()
}
)
}
}
Text(
text = "Conversational Awareness".uppercase(),
style = TextStyle(
@@ -1040,6 +1118,47 @@ fun AppSettingsScreen(navController: NavController) {
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
useAlternateHeadTrackingPackets = !useAlternateHeadTrackingPackets
sharedPreferences.edit().putBoolean("use_alternate_head_tracking_packets", useAlternateHeadTrackingPackets).apply()
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = "Use alternate head tracking packets",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Enable this if head tracking doesn't work for you. This sends different data to AirPods for requesting/stopping head tracking data.",
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = useAlternateHeadTrackingPackets,
onCheckedChange = {
useAlternateHeadTrackingPackets = it
sharedPreferences.edit().putBoolean("use_alternate_head_tracking_packets", it).apply()
}
)
}
Row(
modifier = Modifier
.fillMaxWidth()

View File

@@ -100,9 +100,9 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.constants.isHeadTrackingData
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.BatteryStatus
import me.kavishdevar.librepods.utils.isHeadTrackingData
import kotlin.io.encoding.ExperimentalEncodingApi
data class PacketInfo(

View File

@@ -57,10 +57,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -69,6 +68,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.experimental.and
@@ -84,6 +84,16 @@ fun RightDivider() {
)
}
@Composable()
fun RightDividerNoIcon() {
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 20.dp)
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongPress(navController: NavController, name: String) {
@@ -104,6 +114,10 @@ fun LongPress(navController: NavController, name: String) {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val deviceName = sharedPreferences.getString("name", "AirPods Pro")
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
val longPressActionPref = sharedPreferences.getString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name)
Log.d("PressAndHoldSettingsScreen", "Long press action preference ($prefKey): $longPressActionPref")
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
Scaffold(
topBar = {
CenterAlignedTopAppBar(
@@ -153,56 +167,88 @@ fun LongPress(navController: NavController, name: String) {
.padding(horizontal = 16.dp)
.padding(top = 8.dp)
) {
Text(
text = "NOISE CONTROL",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
),
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier
.padding(8.dp, bottom = 4.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
val offListeningMode = offListeningModeValue == 1.toByte()
LongPressElement(
name = "Off",
enabled = offListeningMode,
resourceId = R.drawable.noise_cancellation,
isFirst = true)
if (offListeningMode) RightDivider()
LongPressElement(
name = "Transparency",
resourceId = R.drawable.transparency,
isFirst = !offListeningMode)
RightDivider()
LongPressElement(
name = "Adaptive",
resourceId = R.drawable.adaptive)
RightDivider()
LongPressElement(
name = "Noise Cancellation",
resourceId = R.drawable.noise_cancellation,
isLast = true)
LongPressActionElement(
name = "Noise Control",
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
onClick = {
longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES
sharedPreferences.edit().putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name).apply()
},
isFirst = true,
isLast = false
)
RightDividerNoIcon()
LongPressActionElement(
name = "Digital Assistant",
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
onClick = {
longPressAction = StemAction.DIGITAL_ASSISTANT
sharedPreferences.edit().putString(prefKey, StemAction.DIGITAL_ASSISTANT.name).apply()
},
isFirst = false,
isLast = true
)
}
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
Text(
text = "NOISE CONTROL",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
),
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier
.padding(top = 32.dp, bottom = 4.dp)
.padding(horizontal = 8.dp)
)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
val offListeningMode = offListeningModeValue == 1.toByte()
LongPressElement(
name = "Off",
enabled = offListeningMode,
resourceId = R.drawable.noise_cancellation,
isFirst = true)
if (offListeningMode) RightDivider()
LongPressElement(
name = "Transparency",
resourceId = R.drawable.transparency,
isFirst = !offListeningMode)
RightDivider()
LongPressElement(
name = "Adaptive",
resourceId = R.drawable.adaptive)
RightDivider()
LongPressElement(
name = "Noise Cancellation",
resourceId = R.drawable.noise_cancellation,
isLast = true)
}
Text(
"Press and hold the stem to cycle between the selected noise control modes.",
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f),
modifier = Modifier
.padding(start = 16.dp, top = 4.dp)
)
}
Text(
"Press and hold the stem to cycle between the selected noise control modes.",
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f),
modifier = Modifier
.padding(start = 16.dp, top = 4.dp)
)
}
}
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
@@ -336,7 +382,7 @@ fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isF
horizontalArrangement = Arrangement.SpaceBetween
) {
Icon(
bitmap = ImageBitmap.imageResource(resourceId),
painter = painterResource(resourceId),
contentDescription = "Icon",
tint = Color(0xFF007AFF),
modifier = Modifier
@@ -384,3 +430,67 @@ fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isF
}
}
}
@Composable
fun LongPressActionElement(
name: String,
selected: Boolean,
onClick: () -> Unit,
isFirst: Boolean = false,
isLast: Boolean = false
) {
val darkMode = isSystemInDarkTheme()
val shape = when {
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
else -> RoundedCornerShape(0.dp)
}
var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
Row(
modifier = Modifier
.height(48.dp)
.background(animatedBackgroundColor, shape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
onClick()
}
)
}
.padding(horizontal = 16.dp, vertical = 0.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
name,
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier
.weight(1f)
.padding(start = 4.dp)
)
Checkbox(
checked = selected,
onCheckedChange = { onClick() },
colors = CheckboxDefaults.colors().copy(
checkedCheckmarkColor = Color(0xFF007AFF),
uncheckedCheckmarkColor = Color.Transparent,
checkedBoxColor = Color.Transparent,
uncheckedBoxColor = Color.Transparent,
checkedBorderColor = Color.Transparent,
uncheckedBorderColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
disabledCheckedBoxColor = Color.Transparent,
disabledUncheckedBoxColor = Color.Transparent,
disabledUncheckedBorderColor = Color.Transparent
),
modifier = Modifier
.height(24.dp)
.scale(1.5f),
)
}
}

View File

@@ -35,9 +35,9 @@ import android.util.Log
import androidx.annotation.RequiresApi
import me.kavishdevar.librepods.QuickSettingsDialogActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AirPodsNotifications
import me.kavishdevar.librepods.utils.NoiseControlMode
import kotlin.io.encoding.ExperimentalEncodingApi
@RequiresApi(Build.VERSION_CODES.Q)

View File

@@ -77,12 +77,15 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.constants.isHeadTrackingData
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AirPodsNotifications
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.utils.BLEManager
import me.kavishdevar.librepods.utils.Battery
import me.kavishdevar.librepods.utils.BatteryComponent
import me.kavishdevar.librepods.utils.BatteryStatus
import me.kavishdevar.librepods.utils.BluetoothConnectionManager
import me.kavishdevar.librepods.utils.CrossDevice
import me.kavishdevar.librepods.utils.CrossDevicePackets
@@ -111,7 +114,6 @@ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_CHARGING
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ICON
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
import me.kavishdevar.librepods.utils.isHeadTrackingData
import me.kavishdevar.librepods.widgets.BatteryWidget
import me.kavishdevar.librepods.widgets.NoiseControlWidget
import org.lsposed.hiddenapibypass.HiddenApiBypass
@@ -142,7 +144,7 @@ object ServiceManager {
class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener {
var macAddress = ""
lateinit var aacpManager: AACPManager
var cameraActive = false
data class ServiceConfig(
var deviceName: String = "AirPods",
var earDetectionEnabled: Boolean = true,
@@ -154,6 +156,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var conversationalAwarenessVolume: Int = 43,
var textColor: Long = -1L,
var qsClickBehavior: String = "cycle",
var bleOnlyMode: Boolean = false,
// AirPods state-based takeover
var takeoverWhenDisconnected: Boolean = true,
@@ -163,7 +166,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// Phone state-based takeover
var takeoverWhenRingingCall: Boolean = true,
var takeoverWhenMediaStart: Boolean = true
var takeoverWhenMediaStart: Boolean = true,
var leftSinglePressAction: StemAction = StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!,
var rightSinglePressAction: StemAction = StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!,
var leftDoublePressAction: StemAction = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!,
var rightDoublePressAction: StemAction = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!,
var leftTriplePressAction: StemAction = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!,
var rightTriplePressAction: StemAction = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!,
var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
)
private lateinit var config: ServiceConfig
@@ -192,7 +207,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
device: BLEManager.AirPodsStatus,
previousStatus: BLEManager.AirPodsStatus?
) {
if (device.connectionState == "Disconnected") {
// Store MAC address for BLE-only mode if not already stored
if (config.bleOnlyMode && macAddress.isEmpty()) {
macAddress = device.address
sharedPreferences.edit {
putString("mac_address", macAddress)
}
Log.d("AirPodsBLEService", "BLE-only mode: stored MAC address ${device.address}")
}
if (device.connectionState == "Disconnected" && !config.bleOnlyMode) {
Log.d("AirPodsBLEService", "Seems no device has taken over, we will.")
val bluetoothManager = getSystemService(BluetoothManager::class.java)
val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(sharedPreferences.getString(
@@ -259,7 +283,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
leftInEar: Boolean,
rightInEar: Boolean
) {
Log.d("AirPodsBLEService", "Ear state changed")
Log.d("AirPodsBLEService", "Ear state changed - Left: $leftInEar, Right: $rightInEar")
// In BLE-only mode, ear detection is purely based on BLE data
if (config.bleOnlyMode) {
Log.d("AirPodsBLEService", "BLE-only mode: ear detection from BLE data")
}
}
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
@@ -300,6 +329,67 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
}
fun cameraOpened() {
Log.d("AirPodsService", "Camera opened, gonna handle stem presses and take action if enabled")
val isCameraShutterUsed = listOf(
config.leftSinglePressAction,
config.rightSinglePressAction,
config.leftDoublePressAction,
config.rightDoublePressAction,
config.leftTriplePressAction,
config.rightTriplePressAction,
config.leftLongPressAction,
config.rightLongPressAction
).any { it == StemAction.CAMERA_SHUTTER }
if (isCameraShutterUsed) {
Log.d("AirPodsService", "Camera opened, setting up stem actions")
cameraActive = true
setupStemActions(isCameraActive = true)
}
}
fun cameraClosed() {
cameraActive = false
setupStemActions()
}
fun isCustomAction(
action: StemAction?,
default: StemAction?,
isCameraActive: Boolean = false
): Boolean {
Log.d("AirPodsService", "Checking if action $action is custom against default $default, camera active: $isCameraActive")
return action != default && (action != StemAction.CAMERA_SHUTTER || isCameraActive)
}
fun setupStemActions(isCameraActive: Boolean = false) {
val singlePressDefault = StemAction.defaultActions[StemPressType.SINGLE_PRESS]
val doublePressDefault = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]
val triplePressDefault = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]
val longPressDefault = StemAction.defaultActions[StemPressType.LONG_PRESS]
val singlePressCustomized = isCustomAction(config.leftSinglePressAction, singlePressDefault, isCameraActive) ||
isCustomAction(config.rightSinglePressAction, singlePressDefault, isCameraActive)
val doublePressCustomized = isCustomAction(config.leftDoublePressAction, doublePressDefault, isCameraActive) ||
isCustomAction(config.rightDoublePressAction, doublePressDefault, isCameraActive)
val triplePressCustomized = isCustomAction(config.leftTriplePressAction, triplePressDefault, isCameraActive) ||
isCustomAction(config.rightTriplePressAction, triplePressDefault, isCameraActive)
val longPressCustomized = isCustomAction(config.leftLongPressAction, longPressDefault, isCameraActive) ||
isCustomAction(config.rightLongPressAction, longPressDefault, isCameraActive)
Log.d("AirPodsService", "Setting up stem actions: " +
"Single Press Customized: $singlePressCustomized, " +
"Double Press Customized: $doublePressCustomized, " +
"Triple Press Customized: $triplePressCustomized, " +
"Long Press Customized: $longPressCustomized")
aacpManager.sendStemConfigPacket(
singlePressCustomized,
doublePressCustomized,
triplePressCustomized,
longPressCustomized,
)
}
@ExperimentalEncodingApi
private fun initializeAACPManagerCallback() {
aacpManager.setPacketCallback(object : AACPManager.PacketCallback {
@@ -398,12 +488,58 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
override fun onStemPressReceived(stemPress: ByteArray) {
val (stemPressType, bud) = aacpManager.parseStemPressResponse(stemPress)
Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud")
val action = getActionFor(bud, stemPressType)
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
action?.let { executeStemAction(it) }
}
override fun onUnknownPacketReceived(packet: ByteArray) {
Log.d("AACPManager", "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}")
}
})
}
private fun getActionFor(bud: AACPManager.Companion.StemPressBudType, type: StemPressType): StemAction? {
return when (type) {
StemPressType.SINGLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftSinglePressAction else config.rightSinglePressAction
StemPressType.DOUBLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftDoublePressAction else config.rightDoublePressAction
StemPressType.TRIPLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftTriplePressAction else config.rightTriplePressAction
StemPressType.LONG_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftLongPressAction else config.rightLongPressAction
}
}
private fun executeStemAction(action: StemAction) {
when (action) {
StemAction.defaultActions[StemPressType.SINGLE_PRESS] -> {
Log.d("AirPodsParser", "Default single press action: Play/Pause, not taking action.")
}
StemAction.PLAY_PAUSE -> MediaController.sendPlayPause()
StemAction.PREVIOUS_TRACK -> MediaController.sendPreviousTrack()
StemAction.NEXT_TRACK -> MediaController.sendNextTrack()
StemAction.CAMERA_SHUTTER -> Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
StemAction.DIGITAL_ASSISTANT -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val intent = Intent(Intent.ACTION_VOICE_COMMAND).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(intent)
} else {
Log.w("AirPodsParser", "Digital Assistant action is not supported on this Android version.")
}
}
StemAction.CYCLE_NOISE_CONTROL_MODES -> {
Log.d("AirPodsParser", "Cycling noise control modes")
sendBroadcast(Intent("me.kavishdevar.librepods.SET_ANC_MODE"))
}
}
}
private fun processEarDetectionChange(earDetection: ByteArray) {
var inEar = false
var inEarData = listOf(earDetectionNotification.status[0] == 0x00.toByte(), earDetectionNotification.status[1] == 0x00.toByte())
@@ -513,6 +649,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43),
textColor = sharedPreferences.getLong("textColor", -1L),
qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle",
bleOnlyMode = sharedPreferences.getBoolean("ble_only_mode", false),
// AirPods state-based takeover
takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", true),
@@ -522,7 +659,20 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// Phone state-based takeover
takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", true),
takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", true)
takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", true),
// Stem actions
leftSinglePressAction = StemAction.fromString(sharedPreferences.getString("left_single_press_action", "PLAY_PAUSE") ?: "PLAY_PAUSE")!!,
rightSinglePressAction = StemAction.fromString(sharedPreferences.getString("right_single_press_action", "PLAY_PAUSE") ?: "PLAY_PAUSE")!!,
leftDoublePressAction = StemAction.fromString(sharedPreferences.getString("left_double_press_action", "PREVIOUS_TRACK") ?: "NEXT_TRACK")!!,
rightDoublePressAction = StemAction.fromString(sharedPreferences.getString("right_double_press_action", "NEXT_TRACK") ?: "NEXT_TRACK")!!,
leftTriplePressAction = StemAction.fromString(sharedPreferences.getString("left_triple_press_action", "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK")!!,
rightTriplePressAction = StemAction.fromString(sharedPreferences.getString("right_triple_press_action", "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK")!!,
leftLongPressAction = StemAction.fromString(sharedPreferences.getString("left_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")!!,
rightLongPressAction = StemAction.fromString(sharedPreferences.getString("right_long_press_action", "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT")!!
)
}
@@ -544,6 +694,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
"conversational_awareness_volume" -> config.conversationalAwarenessVolume = preferences.getInt(key, 43)
"textColor" -> config.textColor = preferences.getLong(key, -1L)
"qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle"
"ble_only_mode" -> config.bleOnlyMode = preferences.getBoolean(key, false)
// AirPods state-based takeover
"takeover_when_disconnected" -> config.takeoverWhenDisconnected = preferences.getBoolean(key, true)
@@ -554,6 +705,55 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// Phone state-based takeover
"takeover_when_ringing_call" -> config.takeoverWhenRingingCall = preferences.getBoolean(key, true)
"takeover_when_media_start" -> config.takeoverWhenMediaStart = preferences.getBoolean(key, true)
"left_single_press_action" -> {
config.leftSinglePressAction = StemAction.fromString(
preferences.getString(key, "PLAY_PAUSE") ?: "PLAY_PAUSE"
)!!
setupStemActions()
}
"right_single_press_action" -> {
config.rightSinglePressAction = StemAction.fromString(
preferences.getString(key, "PLAY_PAUSE") ?: "PLAY_PAUSE"
)!!
setupStemActions()
}
"left_double_press_action" -> {
config.leftDoublePressAction = StemAction.fromString(
preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK"
)!!
setupStemActions()
}
"right_double_press_action" -> {
config.rightDoublePressAction = StemAction.fromString(
preferences.getString(key, "NEXT_TRACK") ?: "NEXT_TRACK"
)!!
setupStemActions()
}
"left_triple_press_action" -> {
config.leftTriplePressAction = StemAction.fromString(
preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK"
)!!
setupStemActions()
}
"right_triple_press_action" -> {
config.rightTriplePressAction = StemAction.fromString(
preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK"
)!!
setupStemActions()
}
"left_long_press_action" -> {
config.leftLongPressAction = StemAction.fromString(
preferences.getString(key, "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES"
)!!
setupStemActions()
}
"right_long_press_action" -> {
config.rightLongPressAction = StemAction.fromString(
preferences.getString(key, "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT"
)!!
setupStemActions()
}
}
if (key == "mac_address") {
@@ -1002,10 +1202,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
if (!::socket.isInitialized) {
if (!::socket.isInitialized && !config.bleOnlyMode) {
return
}
if (connected && socket.isConnected) {
if (connected && (config.bleOnlyMode || socket.isConnected)) {
updatedNotification = NotificationCompat.Builder(this, "airpods_connection_status")
.setSmallIcon(R.drawable.airpods)
.setContentTitle(airpodsName ?: config.deviceName)
@@ -1057,7 +1257,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.notify(1, updatedNotification)
notificationManager.cancel(2)
} else if (!socket.isConnected && isConnectedLocally) {
} else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) {
Log.d("AirPodsService", "<LogCollector:Complete:Failed> Socket not connected")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
}
@@ -1068,7 +1268,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (isInCall) return
if (config.headGestures) {
initGestureDetector()
aacpManager.sendStartHeadTracking()
startHeadTracking()
gestureDetector?.startDetection { accepted ->
if (accepted) {
answerCall()
@@ -1374,8 +1574,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val ancModeFilter = IntentFilter("me.kavishdevar.librepods.SET_ANC_MODE")
var ancModeReceiver: BroadcastReceiver? = null
@SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("AirPodsService", "Service started")
@@ -1426,6 +1624,24 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (!contains("qs_click_behavior")) editor.putString("qs_click_behavior", "cycle")
if (!contains("name")) editor.putString("name", "AirPods")
if (!contains("ble_only_mode")) editor.putBoolean("ble_only_mode", false)
if (!contains("left_single_press_action")) editor.putString("left_single_press_action",
StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name)
if (!contains("right_single_press_action")) editor.putString("right_single_press_action",
StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name)
if (!contains("left_double_press_action")) editor.putString("left_double_press_action",
StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name)
if (!contains("right_double_press_action")) editor.putString("right_double_press_action",
StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name)
if (!contains("left_triple_press_action")) editor.putString("left_triple_press_action",
StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name)
if (!contains("right_triple_press_action")) editor.putString("right_triple_press_action",
StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name)
if (!contains("left_long_press_action")) editor.putString("left_long_press_action",
StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name)
if (!contains("right_long_press_action")) editor.putString("right_long_press_action",
StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name)
editor.apply()
}
@@ -1575,7 +1791,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
if (!CrossDevice.isAvailable) {
if (!CrossDevice.isAvailable && !config.bleOnlyMode) {
Log.d("AirPodsService", "${config.deviceName} connected")
CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device!!)
@@ -1587,6 +1803,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.edit {
putString("mac_address", macAddress)
}
} else if (config.bleOnlyMode) {
Log.d("AirPodsService", "BLE-only mode: skipping L2CAP connection")
macAddress = device!!.address
sharedPreferences.edit {
putString("mac_address", macAddress)
}
}
} else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
device = null
@@ -1647,7 +1869,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (profile == BluetoothProfile.A2DP) {
val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
if (!CrossDevice.isAvailable) {
if (!CrossDevice.isAvailable && !config.bleOnlyMode) {
CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device)
}
@@ -1656,6 +1878,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.edit {
putString("mac_address", macAddress)
}
} else if (config.bleOnlyMode) {
Log.d("AirPodsService", "BLE-only mode: skipping L2CAP connection")
macAddress = device.address
sharedPreferences.edit {
putString("mac_address", macAddress)
}
}
this@AirPodsService.sendBroadcast(
Intent(AirPodsNotifications.AIRPODS_CONNECTED)
@@ -1760,14 +1988,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
if (device != null) {
connectToSocket(device!!)
connectAudio(this, device)
if (config.bleOnlyMode) {
// In BLE-only mode, just show connecting status without actual L2CAP connection
Log.d("AirPodsService", "BLE-only mode: showing connecting status without L2CAP connection")
updateNotificationContent(
true,
config.deviceName,
batteryNotification.getBattery()
)
// Set a temporary connecting state
isConnectedLocally = false // Keep as false since we're not actually connecting to L2CAP
} else {
connectToSocket(device!!)
connectAudio(this, device)
isConnectedLocally = true
}
}
showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!),
IslandType.TAKING_OVER)
isConnectedLocally = true
CrossDevice.isAvailable = false
}
@@ -1879,6 +2119,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
.putExtra("device", device)
)
setupStemActions()
while (socket.isConnected == true) {
socket.let {
val buffer = ByteArray(1024)
@@ -2131,12 +2373,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun startHeadTracking() {
isHeadTrackingActive = true
aacpManager.sendStartHeadTracking()
val useAlternatePackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false)
if (useAlternatePackets) {
aacpManager.sendDataPacket(aacpManager.createAlternateStartHeadTrackingPacket())
} else {
aacpManager.sendStartHeadTracking()
}
HeadTracking.reset()
}
fun stopHeadTracking() {
aacpManager.sendStopHeadTracking()
val useAlternatePackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false)
if (useAlternatePackets) {
aacpManager.sendDataPacket(aacpManager.createAlternateStopHeadTrackingPacket())
} else {
aacpManager.sendStopHeadTracking()
}
isHeadTrackingActive = false
}

View File

@@ -15,18 +15,21 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
import android.util.Log
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers.entries
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressBudType.entries
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType.entries
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Manager class for Apple Accessory Communication Protocol (AACP)
* This class is responsible for handling the L2CAP socket management,
* constructing and parsing packets for communication with Apple accessories.
* constructing and parsing packets for communication with AirPods.
*/
class AACPManager {
companion object {
@@ -44,6 +47,7 @@ class AACPManager {
const val HEADTRACKING: Byte = 0x17
const val PROXIMITY_KEYS_REQ: Byte = 0x30
const val PROXIMITY_KEYS_RSP: Byte = 0x31
const val STEM_PRESS: Byte = 0x19
}
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
@@ -101,8 +105,8 @@ class AACPManager {
IN_CASE_TONE_CONFIG(0x31),
SIRI_MULTITONE_CONFIG(0x32),
HEARING_ASSIST_CONFIG(0x33),
ALLOW_OFF_OPTION(0x34);
ALLOW_OFF_OPTION(0x34),
STEM_CONFIG(0x39);
companion object {
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
entries.find { it.value == byte }
@@ -118,6 +122,28 @@ class AACPManager {
ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
}
}
enum class StemPressType(val value: Byte) {
SINGLE_PRESS(0x05),
DOUBLE_PRESS(0x06),
TRIPLE_PRESS(0x07),
LONG_PRESS(0x08);
companion object {
fun fromByte(byte: Byte): StemPressType? =
entries.find { it.value == byte }
}
}
enum class StemPressBudType(val value: Byte) {
LEFT(0x01),
RIGHT(0x02);
companion object {
fun fromByte(byte: Byte): StemPressBudType? =
entries.find { it.value == byte }
}
}
}
var controlCommandStatusList: MutableList<ControlCommandStatus> = mutableListOf<ControlCommandStatus>()
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf()
@@ -149,6 +175,20 @@ class AACPManager {
fun onHeadTrackingReceived(headTracking: ByteArray)
fun onUnknownPacketReceived(packet: ByteArray)
fun onProximityKeysReceived(proximityKeys: ByteArray)
fun onStemPressReceived(stemPress: ByteArray)
}
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
Log.d(TAG, "Parsing Stem Press Response: ${data.joinToString(" ") { "%02X".format(it) }}")
if (data.size != 8) {
throw IllegalArgumentException("Data array too short to parse Stem Press Response")
}
if (data[4] != Opcodes.STEM_PRESS) {
throw IllegalArgumentException("Data array does not start with STEM_PRESS opcode")
}
val type = StemPressType.fromByte(data[6]) ?: throw IllegalArgumentException("Unknown Stem Press Type: ${data[5]}")
val bud = StemPressBudType.fromByte(data[7]) ?: throw IllegalArgumentException("Unknown Stem Press Bud Type: ${data[6]}")
return Pair(type, bud)
}
interface ControlCommandListener {
@@ -195,6 +235,7 @@ class AACPManager {
return sendDataPacket(controlPacket)
}
@OptIn(ExperimentalStdlibApi::class)
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
setControlCommandStatusValue(
@@ -323,6 +364,9 @@ class AACPManager {
Opcodes.PROXIMITY_KEYS_RSP -> {
callback?.onProximityKeysReceived(packet)
}
Opcodes.STEM_PRESS -> {
callback?.onStemPressReceived(packet)
}
else -> {
callback?.onUnknownPacketReceived(packet)
}
@@ -345,7 +389,7 @@ class AACPManager {
fun createSetFeatureFlagsPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00)
val data = byteArrayOf(0xFF.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
val data = byteArrayOf(0xD7.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
return opcode + data
}
@@ -370,6 +414,14 @@ class AACPManager {
return opcode + data
}
fun createAlternateStartHeadTrackingPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
val data = byteArrayOf(
0x00, 0x00, 0x10, 0x00, 0x0F, 0x00, 0x08, 0x73, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00
)
return opcode + data
}
fun sendStopHeadTracking(): Boolean {
return sendDataPacket(createStopHeadTrackingPacket())
}
@@ -382,6 +434,14 @@ class AACPManager {
return opcode + data
}
fun createAlternateStopHeadTrackingPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
val data = byteArrayOf(
0x00, 0x00, 0x10, 0x00, 0x0F, 0x00, 0x08, 0x75, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
)
return opcode + data
}
fun sendRename(name: String): Boolean {
return sendDataPacket(createRenamePacket(name))
}
@@ -440,13 +500,29 @@ class AACPManager {
val value = ByteArray(4)
System.arraycopy(data, 3, value, 0, 4)
// drop trailing zeroes in the array, and return the bytearray of the reduced array
val trimmedValue = value.takeWhile { it != 0x00.toByte() }.toByteArray()
return ControlCommand(identifier, trimmedValue)
}
}
}
@OptIn(ExperimentalStdlibApi::class)
fun sendStemConfigPacket(
singlePressCustomized: Boolean = false,
doublePressCustomized: Boolean = false,
triplePressCustomized: Boolean = false,
longPressCustomized: Boolean = false
): Boolean {
val value = ((if (singlePressCustomized) 0x01 else 0) or
(if (doublePressCustomized) 0x02 else 0) or
(if (triplePressCustomized) 0x04 else 0) or
(if (longPressCustomized) 0x08 else 0)).toByte()
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
return sendControlCommand(
ControlCommandIdentifiers.STEM_CONFIG.value, value
)
}
@OptIn(ExperimentalStdlibApi::class)
fun sendPacket(packet: ByteArray): Boolean {
try {

View File

@@ -58,6 +58,10 @@ import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
@@ -118,6 +122,7 @@ class IslandWindow(private val context: Context) {
val isVisible: Boolean
get() = containerView.parent != null && containerView.visibility == View.VISIBLE
@SuppressLint("SetTextI18n")
private fun updateBatteryDisplay(batteryList: ArrayList<Battery>?) {
if (batteryList == null || batteryList.isEmpty()) return
@@ -150,7 +155,7 @@ class IslandWindow(private val context: Context) {
}
}
@SuppressLint("SetTextI18s", "ClickableViewAccessibility")
@SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag")
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
if (ServiceManager.getService()?.islandOpen == true) return
else ServiceManager.getService()?.islandOpen = true
@@ -162,13 +167,13 @@ class IslandWindow(private val context: Context) {
val batteryList = ServiceManager.getService()?.getBattery()
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
val displayBatteryLevel = if (batteryList != null) {
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
when {
leftBattery?.level ?: 0 > 0 && rightBattery?.level ?: 0 > 0 ->
leftBattery?.level ?: 0 > 0 && rightBattery?.level ?: 0 > 0 ->
minOf(leftBattery!!.level, rightBattery!!.level)
leftBattery?.level ?: 0 > 0 -> leftBattery!!.level
rightBattery?.level ?: 0 > 0 -> rightBattery!!.level
@@ -180,7 +185,7 @@ class IslandWindow(private val context: Context) {
} else {
null
}
if (displayBatteryLevel != null) {
batteryText.text = "$displayBatteryLevel%"
batteryProgressBar.progress = displayBatteryLevel
@@ -188,7 +193,7 @@ class IslandWindow(private val context: Context) {
batteryText.text = "?"
batteryProgressBar.progress = 0
}
batteryProgressBar.isIndeterminate = false
islandView.findViewById<TextView>(R.id.island_device_name).text = name
@@ -403,11 +408,11 @@ class IslandWindow(private val context: Context) {
if (params != null) {
params!!.height = screenHeight
val containerParams = containerView.layoutParams
containerParams.height = screenHeight
containerView.layoutParams = containerParams
try {
windowManager.updateViewLayout(containerView, params)
} catch (e: Exception) {
@@ -552,7 +557,7 @@ class IslandWindow(private val context: Context) {
normalizeAnimator.addUpdateListener { animation ->
val progress = animation.animatedValue as Float
containerView.alpha = progress
if (progress < 0.7f) {
islandView.findViewById<VideoView>(R.id.island_video_view).visibility = View.GONE
}
@@ -620,7 +625,7 @@ class IslandWindow(private val context: Context) {
} catch (e: Exception) {
e.printStackTrace()
}
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, containerView.scaleX, 0.5f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, containerView.scaleY, 0.5f)
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, containerView.translationY, -200f)
@@ -640,7 +645,7 @@ class IslandWindow(private val context: Context) {
cleanupAndRemoveView()
}
}
private fun cleanupAndRemoveView() {
containerView.visibility = View.GONE
try {
@@ -655,25 +660,25 @@ class IslandWindow(private val context: Context) {
springAnimation.cancel()
flingAnimator.cancel()
}
fun forceClose() {
try {
if (isClosing) return
isClosing = true
try {
context.unregisterReceiver(batteryReceiver)
} catch (e: Exception) {
// Silent catch - receiver might already be unregistered
}
ServiceManager.getService()?.islandOpen = false
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
// Cancel all ongoing animations
springAnimation.cancel()
flingAnimator.cancel()
// Immediately remove the view without animations
cleanupAndRemoveView()
} catch (e: Exception) {

View File

@@ -109,67 +109,6 @@ class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModul
Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e)
}
}
if (param.packageName == "com.android.systemui") {
Log.i(TAG, "SystemUI detected, hooking volume panel")
try {
val volumePanelViewClass = param.classLoader.loadClass("com.android.systemui.volume.VolumeDialogImpl")
try {
val initDialogMethod = volumePanelViewClass.getDeclaredMethod("initDialog", Int::class.java)
hook(initDialogMethod, VolumeDialogInitHooker::class.java)
Log.i(TAG, "Hooked initDialog method successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to hook initDialog method: ${e.message}")
}
try {
val showHMethod = volumePanelViewClass.getDeclaredMethod("showH", Int::class.java, Boolean::class.java, Int::class.java)
hook(showHMethod, VolumeDialogShowHooker::class.java)
Log.i(TAG, "Hooked showH method successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to hook showH method: ${e.message}")
}
Log.i(TAG, "Volume panel hook setup attempted on multiple methods")
} catch (e: Exception) {
Log.e(TAG, "Failed to hook volume panel: ${e.message}", e)
}
}
}
@XposedHooker
class VolumeDialogInitHooker : XposedInterface.Hooker {
companion object {
@JvmStatic
@AfterInvocation
fun afterInitDialog(callback: AfterHookCallback) {
try {
val volumeDialog = callback.thisObject
Log.i(TAG, "Volume dialog initialized, adding AirPods controls")
addAirPodsControlsToDialog(volumeDialog!!)
} catch (e: Exception) {
Log.e(TAG, "Error in initDialog hook: ${e.message}", e)
}
}
}
}
@XposedHooker
class VolumeDialogShowHooker : XposedInterface.Hooker {
companion object {
@JvmStatic
@AfterInvocation
fun afterShowH(callback: AfterHookCallback) {
try {
val volumeDialog = callback.thisObject
Log.i(TAG, "Volume dialog shown, ensuring AirPods controls are added")
addAirPodsControlsToDialog(volumeDialog!!)
} catch (e: Exception) {
Log.e(TAG, "Error in showH hook: ${e.message}", e)
}
}
}
}
@XposedHooker

View File

@@ -100,6 +100,51 @@ object MediaController {
return audioManager.isMusicActive
}
@Synchronized
fun sendPlayPause() {
if (audioManager.isMusicActive) {
Log.d("MediaController", "Sending pause because music is active")
sendPause()
} else {
Log.d("MediaController", "Sending play because music is not active")
sendPlay()
}
}
@Synchronized
fun sendPreviousTrack() {
Log.d("MediaController", "Sending previous track")
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_DOWN,
KeyEvent.KEYCODE_MEDIA_PREVIOUS
)
)
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_UP,
KeyEvent.KEYCODE_MEDIA_PREVIOUS
)
)
}
@Synchronized
fun sendNextTrack() {
Log.d("MediaController", "Sending next track")
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_DOWN,
KeyEvent.KEYCODE_MEDIA_NEXT
)
)
audioManager.dispatchMediaKeyEvent(
KeyEvent(
KeyEvent.ACTION_UP,
KeyEvent.KEYCODE_MEDIA_NEXT
)
)
}
@Synchronized
fun sendPause(force: Boolean = false) {
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force")

View File

@@ -45,6 +45,11 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.VideoView
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import kotlin.collections.find
@SuppressLint("InflateParams", "ClickableViewAccessibility")
class PopupWindow(
@@ -124,9 +129,9 @@ class PopupWindow(
try {
if (mView.windowToken == null && mView.parent == null && !isClosing) {
mView.findViewById<TextView>(R.id.name).text = name
updateBatteryStatus(batteryNotification)
val vid = mView.findViewById<VideoView>(R.id.video)
vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
vid.resolveAdjustedSize(vid.width, vid.height)
@@ -134,7 +139,7 @@ class PopupWindow(
vid.setOnCompletionListener {
vid.start()
}
mWindowManager.addView(mView, mParams)
val displayMetrics = mView.context.resources.displayMetrics
@@ -144,13 +149,13 @@ class PopupWindow(
mView.alpha = 1f
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, screenHeight.toFloat(), 0f)
ObjectAnimator.ofPropertyValuesHolder(mView, translationY).apply {
duration = 500
interpolator = DecelerateInterpolator()
start()
}
registerBatteryUpdateReceiver()
autoCloseRunnable = Runnable { close() }
@@ -162,6 +167,7 @@ class PopupWindow(
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
private fun registerBatteryUpdateReceiver() {
batteryUpdateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
@@ -173,7 +179,7 @@ class PopupWindow(
}
}
}
val filter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(batteryUpdateReceiver, filter, Context.RECEIVER_EXPORTED)
@@ -192,7 +198,7 @@ class PopupWindow(
}
}
}
private fun updateBatteryStatusFromList(batteryList: List<Battery>) {
val batteryLeftText = mView.findViewById<TextView>(R.id.left_battery)
val batteryRightText = mView.findViewById<TextView>(R.id.right_battery)
@@ -205,7 +211,7 @@ class PopupWindow(
""
}
} ?: ""
batteryRightText.text = batteryList.find { it.component == BatteryComponent.RIGHT }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"\uDBC3\uDC8D ${it.level}%"
@@ -213,7 +219,7 @@ class PopupWindow(
""
}
} ?: ""
batteryCaseText.text = batteryList.find { it.component == BatteryComponent.CASE }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"\uDBC3\uDE6C ${it.level}%"
@@ -233,13 +239,13 @@ class PopupWindow(
try {
if (isClosing) return
isClosing = true
autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) }
unregisterBatteryUpdateReceiver()
val vid = mView.findViewById<VideoView>(R.id.video)
vid.stopPlayback()
ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
duration = 500
interpolator = AccelerateInterpolator()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M320,960Q303,960 291.5,948.5Q280,937 280,920Q280,903 291.5,891.5Q303,880 320,880Q337,880 348.5,891.5Q360,903 360,920Q360,937 348.5,948.5Q337,960 320,960ZM480,960Q463,960 451.5,948.5Q440,937 440,920Q440,903 451.5,891.5Q463,880 480,880Q497,880 508.5,891.5Q520,903 520,920Q520,937 508.5,948.5Q497,960 480,960ZM640,960Q623,960 611.5,948.5Q600,937 600,920Q600,903 611.5,891.5Q623,880 640,880Q657,880 668.5,891.5Q680,903 680,920Q680,937 668.5,948.5Q657,960 640,960ZM480,560Q430,560 395,525Q360,490 360,440L360,200Q360,150 395,115Q430,80 480,80Q530,80 565,115Q600,150 600,200L600,440Q600,490 565,525Q530,560 480,560ZM440,840L440,716Q336,702 268,623.5Q200,545 200,440L280,440Q280,523 338.5,581.5Q397,640 480,640Q563,640 621.5,581.5Q680,523 680,440L760,440Q760,545 692,623.5Q624,702 520,716L520,840L440,840Z"/>
</vector>

View File

@@ -0,0 +1,82 @@
<resources>
<string name="app_name" translatable="false">LibrePods</string>
<string name="app_description" translatable="false">让你的 AirPods 摆脱苹果的生态系统。</string>
<string name="title_activity_custom_device" translatable="false">GATT 测试</string>
<string name="app_widget_description">在主屏幕上即可查看 AirPods 的电池状态!</string>
<string name="accessibility">辅助功能</string>
<string name="tone_volume">提示音音量</string>
<string name="audio">音频</string>
<string name="adaptive_audio">自适应音频</string>
<string name="adaptive_audio_description">自适应音频会根据环境动态调整,消除或允许外部噪音。你可以自定义允许的噪音多少。</string>
<string name="buds">耳机</string>
<string name="case_alt">充电盒</string>
<string name="test">测试</string>
<string name="name">名称</string>
<string name="noise_control">噪音控制</string>
<string name="off">关闭</string>
<string name="transparency">通透模式</string>
<string name="adaptive">自适应</string>
<string name="noise_cancellation">主动降噪</string>
<string name="press_and_hold_airpods">按住 AirPods</string>
<string name="head_gestures">头部手势</string>
<string name="left">左耳</string>
<string name="right">右耳</string>
<string name="adjusts_volume">根据环境调整媒体音量</string>
<string name="conversational_awareness">对话感知</string>
<string name="conversational_awareness_description">当你开始与他人交谈时,会降低媒体音量并减少背景噪音。</string>
<string name="personalized_volume">个性化音量</string>
<string name="personalized_volume_description">根据环境自动调整媒体音量。</string>
<string name="less_noise">减少噪音</string>
<string name="more_noise">增加噪音</string>
<string name="noise_cancellation_single_airpod">单只 AirPod 主动降噪</string>
<string name="noise_cancellation_single_airpod_description">仅佩戴一只 AirPod 时也能开启主动降噪。</string>
<string name="volume_control">音量控制</string>
<string name="volume_control_description">通过在 AirPods Pro 柄部传感器上下滑动调节音量。</string>
<string name="airpods_not_connected">AirPods 未连接</string>
<string name="airpods_not_connected_description">请连接 AirPods 以访问设置。如果卡在此处,请先关闭应用再重新打开。\n不要强制结束应用</string>
<string name="back">返回</string>
<string name="app_settings">自定义</string>
<string name="relative_conversational_awareness_volume">相对音量</string>
<string name="relative_conversational_awareness_volume_description">降低到当前音量的百分比,而不是最大音量。</string>
<string name="conversational_awareness_pause_music">暂停音乐</string>
<string name="conversational_awareness_pause_music_description">当你开始说话时,音乐会自动暂停。</string>
<string name="appwidget_text">示例</string>
<string name="add_widget">添加小组件</string>
<string name="noise_control_widget_description">在主屏幕直接控制噪音模式。</string>
<string name="island_connected_text">已连接</string>
<string name="island_connected_remote_text">已连接到 Linux</string>
<string name="island_taking_over_text">已切换到手机</string>
<string name="island_moved_to_remote_text">已切换到 Linux</string>
<string name="head_tracking">头部追踪</string>
<string name="head_gestures_details">点头接听电话,摇头拒接。</string>
<string name="general_settings_header">通用</string>
<string name="qs_click_behavior_title">快捷设置磁贴操作</string>
<string name="qs_click_behavior_dialog_desc">点击时显示噪音控制对话框。</string>
<string name="qs_click_behavior_cycle_desc">点击时循环切换模式。</string>
<string name="developer_options_header">开发者</string>
<string name="more_settings_title">打开 AirPods 设置</string>
<string name="more_settings_subtitle">管理 AirPods 功能与偏好</string>
<string name="ear_detection">自动入耳检测</string>
<string name="auto_play">自动播放</string>
<string name="auto_pause">自动暂停</string>
<string name="troubleshooting">故障排查</string>
<string name="troubleshooting_description">收集日志以诊断 AirPods 连接问题</string>
<string name="collect_logs">收集日志</string>
<string name="saved_logs">已保存的日志</string>
<string name="no_logs_found">未找到保存的日志</string>
<string name="takeover_header">自动连接偏好</string>
<string name="takeover_airpods_state">当 AirPods 状态为以下情况时连接:</string>
<string name="takeover_disconnected">未连接</string>
<string name="takeover_disconnected_desc">AirPods 未连接到任何设备</string>
<string name="takeover_idle">空闲</string>
<string name="takeover_idle_desc">某设备已连接 AirPods但未播放媒体或通话</string>
<string name="takeover_music">正在播放媒体</string>
<string name="takeover_music_desc">某设备正在 AirPods 上播放媒体</string>
<string name="takeover_call">正在通话</string>
<string name="takeover_call_desc">某设备正在使用 AirPods 通话</string>
<string name="takeover_phone_state">当手机处于以下状态时连接 AirPods</string>
<string name="takeover_ringing_call">来电中</string>
<string name="takeover_ringing_call_desc">手机开始响铃时</string>
<string name="takeover_media_start">开始播放媒体</string>
<string name="takeover_media_start_desc">手机开始播放媒体时</string>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 132 KiB

View File

@@ -27,7 +27,7 @@ These commands
| 0x16 | ClickHoldMode | Two values (2 bytes; First byte = right bud Second byte = for left): `0x01` = Noise control `0x05` = Siri |
| 0x17 | DoubleClickInterval | Single value (1 byte): 0x00 = Default, `0x01` = Slower, `0x02` = Slowest|
| 0x18 | ClickHoldInterval | Single value (1 byte): 0x00 = Default, `0x01` = Slower, `0x02` = Slowest|
| 0x1A | ListeningModeConfigs | Single value (1 byte): bitwise OR of the selected modes. Off mode = `0x01`, ANC=`0x02`, Transparency = 0x04, Adaptive = `0x08` |
| 0x1A | ListeningModeConfigs | Single value (1 byte): bitmask, Off mode = `0x01`, ANC=`0x02`, Transparency = 0x04, Adaptive = `0x08` |
| 0x1B | OneBudANCMode | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
| 0x1C | CrownRotationDirection | Single value (1 byte): `0x01` = reversed, `0x02` = default |
| 0x0D | ListeningMode | Single value (1 byte): 1 = Off, 2 = noise cancellation, 3 = transparency, 4 = adaptive |
@@ -48,6 +48,15 @@ These commands
| 0x32 | Siri Multitone config | Single value (1 byte) |
| 0x33 | Hearing Assist config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
| 0x34 | Allow Off Option for Listening Mode config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
| 0x35 | Sleep Detection config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
| 0x36 | Allow Auto Connect | Single value (1 byte): `0x01` = allow, `0x02` = disallow |
| 0x39 | Raw Gestures config | Single value (1 byte): bitmask, single press = `0x01`, double press = `0x02`, triple press = `0x04`, long press = `0x08` |
| 0x3C | System Siri message config | Single value (1 byte) |
| 0x3E | Uplink EQ Bud config | Single value (1 byte) |
| 0x3F | Uplink EQ Source config | Single value (1 byte) |
| 0x40 | In Case Tone Volume | Single value (1 byte): 0 to 100 |
| 0x41 | Disable Button Input config | Single value (1 byte) |
> [!NOTE]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 199 KiB

View File

@@ -5,15 +5,15 @@ project(linux VERSION 0.1 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Qt6 6.4 REQUIRED COMPONENTS Quick Widgets Bluetooth DBus)
find_package(OpenSSL REQUIRED)
qt_standard_project_setup(REQUIRES 6.4)
qt_add_executable(applinux
qt_add_executable(librepods
main.cpp
main.h
logger.h
mediacontroller.cpp
mediacontroller.h
media/mediacontroller.cpp
media/mediacontroller.h
airpods_packets.h
trayiconmanager.cpp
trayiconmanager.h
@@ -24,9 +24,20 @@ qt_add_executable(applinux
autostartmanager.hpp
BasicControlCommand.hpp
deviceinfo.hpp
ble/bleutils.cpp
ble/bleutils.h
ble/blemanager.cpp
ble/blemanager.h
thirdparty/QR-Code-generator/qrcodegen.cpp
thirdparty/QR-Code-generator/qrcodegen.hpp
QRCodeImageProvider.hpp
eardetection.hpp
media/playerstatuswatcher.cpp
media/playerstatuswatcher.h
systemsleepmonitor.hpp
)
qt_add_qml_module(applinux
qt_add_qml_module(librepods
URI linux
VERSION 1.0
QML_FILES
@@ -35,10 +46,11 @@ qt_add_qml_module(applinux
SegmentedControl.qml
PodColumn.qml
Icon.qml
KeysQRDialog.qml
)
# Add the resource file
qt_add_resources(applinux "resources"
qt_add_resources(librepods "resources"
PREFIX "/icons"
FILES
assets/airpods.png
@@ -53,12 +65,12 @@ qt_add_resources(applinux "resources"
assets/fonts/SF-Symbols-6.ttf
)
target_link_libraries(applinux
PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus
target_link_libraries(librepods
PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus OpenSSL::SSL OpenSSL::Crypto
)
include(GNUInstallDirs)
install(TARGETS applinux
install(TARGETS librepods
BUNDLE DESTINATION .
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}

69
linux/KeysQRDialog.qml Normal file
View File

@@ -0,0 +1,69 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
import QtQuick.Window 2.15
Window {
id: root
title: "Magic Cloud Keys QR Code"
flags: Qt.Dialog
modality: Qt.WindowModal
// Use system palette for dynamic theming
SystemPalette { id: systemPalette }
color: systemPalette.window // Background adapts to theme
width: Math.min(Screen.width * 0.8, 300)
height: Math.min(Screen.height * 0.7, 350)
property string irk: ""
property string encKey: ""
ColumnLayout {
anchors.fill: parent
anchors.margins: 20
spacing: 20
// QR Code Container
Rectangle {
id: qrContainer
Layout.fillWidth: true
Layout.fillHeight: true
Layout.minimumHeight: width
radius: 4
color: systemPalette.base
border.color: systemPalette.mid
Image {
id: qrCodeImage
anchors.centerIn: parent
width: Math.min(parent.width * 0.9, parent.height * 0.9)
height: width
fillMode: Image.PreserveAspectFit
source: "image://qrcode/" + root.encKey + ";" + root.irk
BusyIndicator {
anchors.centerIn: parent
running: qrCodeImage.status === Image.Loading
}
Label {
anchors.centerIn: parent
visible: qrCodeImage.status === Image.Error
text: "Failed to generate QR code"
color: systemPalette.text // Dynamic text color
}
}
}
// Instruction text
Label {
Layout.fillWidth: true
text: "Scan this QR code to transfer\nthe Magic Cloud Keys to another device"
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
color: systemPalette.text // Adapts to dark/light mode
font.pixelSize: 14
}
}
}

View File

@@ -8,7 +8,7 @@ ApplicationWindow {
visible: !airPodsTrayApp.hideOnStart
width: 400
height: 300
title: "AirPods Settings"
title: "Librepods"
objectName: "mainWindowObject"
onClosing: mainWindow.visible = false
@@ -94,7 +94,7 @@ ApplicationWindow {
spacing: 8
PodColumn {
isVisible: airPodsTrayApp.deviceInfo.battery.leftPodAvailable
visible: airPodsTrayApp.deviceInfo.battery.leftPodAvailable
inEar: airPodsTrayApp.deviceInfo.leftPodInEar
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
batteryLevel: airPodsTrayApp.deviceInfo.battery.leftPodLevel
@@ -103,7 +103,7 @@ ApplicationWindow {
}
PodColumn {
isVisible: airPodsTrayApp.deviceInfo.battery.rightPodAvailable
visible: airPodsTrayApp.deviceInfo.battery.rightPodAvailable
inEar: airPodsTrayApp.deviceInfo.rightPodInEar
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
batteryLevel: airPodsTrayApp.deviceInfo.battery.rightPodLevel
@@ -112,7 +112,7 @@ ApplicationWindow {
}
PodColumn {
isVisible: airPodsTrayApp.deviceInfo.battery.caseAvailable
visible: airPodsTrayApp.deviceInfo.battery.caseAvailable
inEar: true
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.caseIcon
batteryLevel: airPodsTrayApp.deviceInfo.battery.caseLevel
@@ -164,7 +164,7 @@ ApplicationWindow {
anchors.margins: 10
font.family: iconFont.name
font.pixelSize: 18
text: "\uf958" // U+F958
text: "\uf958"
onClicked: stackView.push(settingsPage)
}
}
@@ -265,9 +265,37 @@ ApplicationWindow {
Button {
text: "Rename"
onClicked: airPodsTrayApp.deviceInfo.renameAirPods(newNameField.text)
onClicked: airPodsTrayApp.renameAirPods(newNameField.text)
}
}
Row {
spacing: 10
visible: airPodsTrayApp.airpodsConnected
TextField {
id: newPhoneMacField
placeholderText: (PHONE_MAC_ADDRESS !== "" ? PHONE_MAC_ADDRESS : "00:00:00:00:00:00")
maximumLength: 32
}
Button {
text: "Change Phone MAC"
onClicked: airPodsTrayApp.setPhoneMac(newPhoneMacField.text)
}
}
Button {
text: "Show Magic Cloud Keys QR"
onClicked: keysQrDialog.show()
}
KeysQRDialog {
id: keysQrDialog
encKey: airPodsTrayApp.deviceInfo.magicAccEncKey
irk: airPodsTrayApp.deviceInfo.magicAccIRK
}
}
}

View File

@@ -1,16 +1,25 @@
import QtQuick 2.15
Column {
property bool isVisible: true
id: root
property bool inEar: true
property string iconSource
property int batteryLevel: 0
property bool isCharging: false
property string indicator: ""
property real targetOpacity: inEar ? 1 : 0.5
Timer {
id: opacityTimer
interval: 50
onTriggered: root.opacity = root.targetOpacity
}
onInEarChanged: {
opacityTimer.restart()
}
spacing: 5
opacity: inEar ? 1 : 0.5
visible: isVisible
Image {
source: parent.iconSource

View File

@@ -0,0 +1,46 @@
#include <QQuickImageProvider>
#include <QPainter>
#include "thirdparty/QR-Code-generator/qrcodegen.hpp"
class QRCodeImageProvider : public QQuickImageProvider
{
public:
QRCodeImageProvider() : QQuickImageProvider(QQuickImageProvider::Image) {}
QImage requestImage(const QString &id, QSize *size, const QSize &requestedSize) override
{
// Parse the keys from id (format: "encKey;irk")
QStringList keys = id.split(';');
if (keys.size() != 2)
return QImage();
// Create URL format: librepods://add-magic-keys?enc_key=...&irk=...
QString data = QString("librepods://add-magic-keys?enc_key=%1&irk=%2").arg(keys[0], keys[1]);
// Generate QR code using the existing qrcodegen library
qrcodegen::QrCode qr = qrcodegen::QrCode::encodeText(data.toUtf8().constData(), qrcodegen::QrCode::Ecc::MEDIUM);
int scale = 8;
QImage image(qr.getSize() * scale, qr.getSize() * scale, QImage::Format_RGB32);
image.fill(Qt::white);
QPainter painter(&image);
painter.setPen(Qt::NoPen);
painter.setBrush(Qt::black);
for (int y = 0; y < qr.getSize(); y++)
{
for (int x = 0; x < qr.getSize(); x++)
{
if (qr.getModule(x, y))
{
painter.drawRect(x * scale, y * scale, scale, scale);
}
}
}
if (size)
*size = image.size();
return image;
}
};

View File

@@ -21,14 +21,29 @@ A native Linux application to control your AirPods, with support for:
sudo apt-get install qt6-base-dev qt6-declarative-dev qt6-connectivity-dev qt6-multimedia-dev \
qml6-module-qtquick-controls qml6-module-qtqml-workerscript qml6-module-qtquick-templates \
qml6-module-qtquick-window qml6-module-qtquick-layouts
```
# For Fedora
sudo dnf install qt6-qtbase-devel qt6-qtconnectivity-devel \
qt6-qtmultimedia-devel qt6-qtdeclarative-devel
```
3. OpenSSL development headers
```bash
# On Arch Linux / EndevaourOS, these are included in the OpenSSL package, so you might already have them installed.
sudo pacman -S openssl
# For Debian / Ubuntu
sudo apt-get install libssl-dev
# For Fedora
sudo dnf install openssl-devel
```
## Setup
1. Edit `main.h` and update `PHONE_MAC_ADDRESS` with your phone's Bluetooth MAC address:
1. Set the `PHONE_MAC_ADDRESS` environment variable to your phone's Bluetooth MAC address by running the following:
```cpp
#define PHONE_MAC_ADDRESS "XX:XX:XX:XX:XX:XX" // Replace with your phone's MAC
```bash
export PHONE_MAC_ADDRESS="XX:XX:XX:XX:XX:XX" # Replace with your phone's MAC
```
2. Build the application:
@@ -43,7 +58,7 @@ A native Linux application to control your AirPods, with support for:
3. Run the application:
```bash
./applinux
./librepods
```
## Usage

View File

@@ -4,6 +4,7 @@
#include <QByteArray>
#include <optional>
#include <climits>
#include "enums.h"
#include "BasicControlCommand.hpp"
@@ -39,13 +40,13 @@ namespace AirPodsPackets
inline std::optional<NoiseControlMode> parseMode(const QByteArray &data)
{
char mode = ControlCommand::parseActive(data).value_or(CHAR_MAX);
char mode = ControlCommand::parseActive(data).value_or(CHAR_MAX) - 1;
if (mode < static_cast<quint8>(NoiseControlMode::MinValue) ||
mode > static_cast<quint8>(NoiseControlMode::MaxValue))
{
return std::nullopt;
}
return static_cast<NoiseControlMode>(mode - 1);
return static_cast<NoiseControlMode>(mode);
}
}
@@ -120,7 +121,7 @@ namespace AirPodsPackets
namespace Connection
{
static const QByteArray HANDSHAKE = QByteArray::fromHex("00000400010002000000000000000000");
static const QByteArray SET_SPECIFIC_FEATURES = QByteArray::fromHex("040004004d00ff00000000000000");
static const QByteArray SET_SPECIFIC_FEATURES = QByteArray::fromHex("040004004d00d700000000000000");
static const QByteArray REQUEST_NOTIFICATIONS = QByteArray::fromHex("040004000f00ffffffffff");
static const QByteArray AIRPODS_DISCONNECTED = QByteArray::fromHex("00010000");
}
@@ -220,4 +221,4 @@ namespace AirPodsPackets
}
}
#endif // AIRPODS_PACKETS_H
#endif // AIRPODS_PACKETS_H

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 808 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -66,7 +66,7 @@ private:
"[Desktop Entry]\n"
"Type=Application\n"
"Name=%1\n"
"Exec=%2\n"
"Exec=%2 --hide\n"
"Icon=%3\n"
"Comment=%4\n"
"X-GNOME-Autostart-enabled=true\n"

View File

@@ -4,8 +4,10 @@
#include <QMap>
#include <QString>
#include <QObject>
#include <climits>
#include "airpods_packets.h"
#include "logger.h"
class Battery : public QObject
{
@@ -97,7 +99,10 @@ public:
auto level = static_cast<quint8>(packet[offset + 2]);
auto status = static_cast<BatteryStatus>(packet[offset + 3]);
newStates[comp] = {level, status};
if (status != BatteryStatus::Disconnected)
{
newStates[comp] = {level, status};
}
// If this is a pod (Left or Right), add it to the list
if (comp == Component::Left || comp == Component::Right)
@@ -127,6 +132,61 @@ public:
// Emit signal to notify about battery status change
emit batteryStatusChanged();
// Log which is left and right pod
LOG_INFO("Primary Pod:" << primaryPod);
LOG_INFO("Secondary Pod:" << secondaryPod);
return true;
}
bool parseEncryptedPacket(const QByteArray &packet, bool isLeftPodPrimary, bool podInCase)
{
// Validate packet size (expect 16 bytes based on provided payloads)
if (packet.size() != 16)
{
return false;
}
// Determine byte indices based on isFlipped
int leftByteIndex = isLeftPodPrimary ? 1 : 2;
int rightByteIndex = isLeftPodPrimary ? 2 : 1;
// Extract raw battery bytes
unsigned char rawLeftBatteryByte = static_cast<unsigned char>(packet.at(leftByteIndex));
unsigned char rawRightBatteryByte = static_cast<unsigned char>(packet.at(rightByteIndex));
unsigned char rawCaseBatteryByte = static_cast<unsigned char>(packet.at(3));
// Extract battery data (charging status and raw level 0-127)
auto [isLeftCharging, rawLeftBattery] = formatBattery(rawLeftBatteryByte);
auto [isRightCharging, rawRightBattery] = formatBattery(rawRightBatteryByte);
auto [isCaseCharging, rawCaseBattery] = formatBattery(rawCaseBatteryByte);
if (rawLeftBattery == CHAR_MAX) {
rawLeftBattery = states.value(Component::Left).level; // Use last valid level
isLeftCharging = states.value(Component::Left).status == BatteryStatus::Charging;
}
if (rawRightBattery == CHAR_MAX) {
rawRightBattery = states.value(Component::Right).level; // Use last valid level
isRightCharging = states.value(Component::Right).status == BatteryStatus::Charging;
}
if (rawCaseBattery == CHAR_MAX) {
rawCaseBattery = states.value(Component::Case).level; // Use last valid level
isCaseCharging = states.value(Component::Case).status == BatteryStatus::Charging;
}
// Update states
states[Component::Left] = {static_cast<quint8>(rawLeftBattery), isLeftCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
states[Component::Right] = {static_cast<quint8>(rawRightBattery), isRightCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
if (podInCase) {
states[Component::Case] = {static_cast<quint8>(rawCaseBattery), isCaseCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
}
primaryPod = isLeftPodPrimary ? Component::Left : Component::Right;
secondaryPod = isLeftPodPrimary ? Component::Right : Component::Left;
emit batteryStatusChanged();
emit primaryChanged();
return true;
}
@@ -187,7 +247,14 @@ private:
return states.value(component).status == status;
}
std::pair<bool, int> formatBattery(unsigned char byteVal)
{
bool charging = (byteVal & 0x80) != 0;
int level = byteVal & 0x7F;
return std::make_pair(charging, level);
}
QMap<Component, BatteryState> states;
Component primaryPod;
Component secondaryPod;
};
};

View File

@@ -1,29 +0,0 @@
cmake_minimum_required(VERSION 3.16)
project(ble_monitor VERSION 0.1 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
find_package(Qt6 6.4 REQUIRED COMPONENTS Core Bluetooth Widgets)
qt_add_executable(ble_monitor
main.cpp
blemanager.h
blemanager.cpp
blescanner.h
blescanner.cpp
)
target_link_libraries(ble_monitor
PRIVATE Qt6::Core Qt6::Bluetooth Qt6::Widgets
)
install(TARGETS ble_monitor
BUNDLE DESTINATION .
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)

View File

@@ -1,6 +1,86 @@
#include "blemanager.h"
#include "enums.h"
#include <QDebug>
#include <QTimer>
#include "logger.h"
#include <QMap>
AirpodsTrayApp::Enums::AirPodsModel getModelName(quint16 modelId)
{
using namespace AirpodsTrayApp::Enums;
static const QMap<quint16, AirPodsModel> modelMap = {
{0x0220, AirPodsModel::AirPods1},
{0x0F20, AirPodsModel::AirPods2},
{0x1320, AirPodsModel::AirPods3},
{0x1920, AirPodsModel::AirPods4},
{0x1B20, AirPodsModel::AirPods4ANC},
{0x0A20, AirPodsModel::AirPodsMaxLightning},
{0x1F20, AirPodsModel::AirPodsMaxUSBC},
{0x0E20, AirPodsModel::AirPodsPro},
{0x1420, AirPodsModel::AirPodsPro2Lightning},
{0x2420, AirPodsModel::AirPodsPro2USBC}
};
return modelMap.value(modelId, AirPodsModel::Unknown);
}
QString getColorName(quint8 colorId)
{
switch (colorId)
{
case 0x00:
return "White";
case 0x01:
return "Black";
case 0x02:
return "Red";
case 0x03:
return "Blue";
case 0x04:
return "Pink";
case 0x05:
return "Gray";
case 0x06:
return "Silver";
case 0x07:
return "Gold";
case 0x08:
return "Rose Gold";
case 0x09:
return "Space Gray";
case 0x0A:
return "Dark Blue";
case 0x0B:
return "Light Blue";
case 0x0C:
return "Yellow";
default:
return "Unknown";
}
}
QString getConnectionStateName(BleInfo::ConnectionState state)
{
using ConnectionState = BleInfo::ConnectionState;
switch (state)
{
case ConnectionState::DISCONNECTED:
return QString("Disconnected");
case ConnectionState::IDLE:
return QString("Idle");
case ConnectionState::MUSIC:
return QString("Playing Music");
case ConnectionState::CALL:
return QString("On Call");
case ConnectionState::RINGING:
return QString("Ringing");
case ConnectionState::HANGING_UP:
return QString("Hanging Up");
case ConnectionState::UNKNOWN:
default:
return QString("Unknown");
}
}
BleManager::BleManager(QObject *parent) : QObject(parent)
{
@@ -13,36 +93,28 @@ BleManager::BleManager(QObject *parent) : QObject(parent)
this, &BleManager::onScanFinished);
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::errorOccurred,
this, &BleManager::onErrorOccurred);
// Set up pruning timer
pruneTimer = new QTimer(this);
connect(pruneTimer, &QTimer::timeout, this, &BleManager::pruneOldDevices);
pruneTimer->start(PRUNE_INTERVAL_MS); // Start timer (runs every 5 seconds)
}
BleManager::~BleManager()
{
delete discoveryAgent;
delete pruneTimer;
}
void BleManager::startScan()
{
qDebug() << "Starting BLE scan...";
devices.clear();
LOG_DEBUG("Starting BLE scan...");
discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod);
pruneTimer->start(PRUNE_INTERVAL_MS); // Ensure timer is running
}
void BleManager::stopScan()
{
qDebug() << "Stopping BLE scan...";
LOG_DEBUG("Stopping BLE scan...");
discoveryAgent->stop();
}
const QMap<QString, DeviceInfo> &BleManager::getDevices() const
bool BleManager::isScanning() const
{
return devices;
return discoveryAgent->isActive();
}
void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
@@ -55,10 +127,11 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
if (data.size() >= 10 && data[0] == 0x07)
{
QString address = info.address().toString();
DeviceInfo deviceInfo;
BleInfo deviceInfo;
deviceInfo.name = info.name().isEmpty() ? "AirPods" : info.name();
deviceInfo.address = address;
deviceInfo.rawData = data;
deviceInfo.rawData = data.left(data.size() - 16);
deviceInfo.encryptedPayload = data.mid(data.size() - 16);
// data[1] is the length of the data, so we can skip it
@@ -68,8 +141,9 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
return; // Skip pairing mode devices (the values are differently structured)
}
// Parse device model (big-endian: high byte at data[3], low byte at data[4])
deviceInfo.deviceModel = static_cast<quint16>(data[4]) | (static_cast<quint8>(data[3]) << 8);
deviceInfo.modelName = getModelName(static_cast<quint16>(data[4]) | (static_cast<quint8>(data[3]) << 8));
// Status byte for primary pod and other flags
quint8 status = static_cast<quint8>(data[5]);
@@ -83,9 +157,9 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
// Lid open counter and device color
quint8 lidIndicator = static_cast<quint8>(data[8]);
deviceInfo.deviceColor = static_cast<quint8>(data[9]);
deviceInfo.color = getColorName((quint8)(data[9]));
deviceInfo.connectionState = static_cast<DeviceInfo::ConnectionState>(data[10]);
deviceInfo.connectionState = static_cast<BleInfo::ConnectionState>(data[10]);
// Next: Encrypted Payload: 16 bytes
@@ -93,6 +167,8 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
bool primaryLeft = (status & 0x20) != 0; // Bit 5: 1 = left primary, 0 = right primary
bool areValuesFlipped = !primaryLeft; // Flipped when right pod is primary
deviceInfo.primaryLeft = primaryLeft; // Store primary pod information
// Parse battery levels
int leftNibble = areValuesFlipped ? (podsBatteryByte >> 4) & 0x0F : podsBatteryByte & 0x0F;
int rightNibble = areValuesFlipped ? podsBatteryByte & 0x0F : (podsBatteryByte >> 4) & 0x0F;
@@ -117,6 +193,10 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
deviceInfo.isLeftPodInEar = xorFactor ? (status & 0x08) != 0 : (status & 0x02) != 0; // Bit 3 or 1
deviceInfo.isRightPodInEar = xorFactor ? (status & 0x02) != 0 : (status & 0x08) != 0; // Bit 1 or 3
// Determine primary and secondary in-ear status
deviceInfo.isPrimaryInEar = primaryLeft ? deviceInfo.isLeftPodInEar : deviceInfo.isRightPodInEar;
deviceInfo.isSecondaryInEar = primaryLeft ? deviceInfo.isRightPodInEar : deviceInfo.isLeftPodInEar;
// Microphone status
deviceInfo.isLeftPodMicrophone = primaryLeft ^ deviceInfo.isThisPodInTheCase;
deviceInfo.isRightPodMicrophone = !primaryLeft ^ deviceInfo.isThisPodInTheCase;
@@ -124,27 +204,19 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
deviceInfo.lidOpenCounter = lidIndicator & 0x07; // Extract bits 0-2 (count)
quint8 lidState = static_cast<quint8>((lidIndicator >> 3) & 0x01); // Extract bit 3 (lid state)
if (deviceInfo.isThisPodInTheCase) {
deviceInfo.lidState = static_cast<DeviceInfo::LidState>(lidState);
deviceInfo.lidState = static_cast<BleInfo::LidState>(lidState);
}
// Update timestamp
deviceInfo.lastSeen = QDateTime::currentDateTime();
// Store device info in the map
devices[address] = deviceInfo;
// Debug output
qDebug() << "Found device:" << deviceInfo.name
<< "Left:" << (deviceInfo.leftPodBattery >= 0 ? QString("%1%").arg(deviceInfo.leftPodBattery) : "N/A")
<< "Right:" << (deviceInfo.rightPodBattery >= 0 ? QString("%1%").arg(deviceInfo.rightPodBattery) : "N/A")
<< "Case:" << (deviceInfo.caseBattery >= 0 ? QString("%1%").arg(deviceInfo.caseBattery) : "N/A");
emit deviceFound(deviceInfo); // Emit signal for device found
}
}
}
void BleManager::onScanFinished()
{
qDebug() << "Scan finished.";
if (discoveryAgent->isActive())
{
discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod);
@@ -153,24 +225,6 @@ void BleManager::onScanFinished()
void BleManager::onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error)
{
qDebug() << "Error occurred:" << error;
LOG_ERROR("BLE scan error occurred:" << error);
stopScan();
}
void BleManager::pruneOldDevices()
{
QDateTime now = QDateTime::currentDateTime();
auto it = devices.begin();
while (it != devices.end())
{
if (it.value().lastSeen.msecsTo(now) > DEVICE_TIMEOUT_MS)
{
qDebug() << "Removing old device:" << it.value().name << "at" << it.key();
it = devices.erase(it); // Remove device if not seen recently
}
else
{
++it;
}
}
}

View File

@@ -6,10 +6,11 @@
#include <QMap>
#include <QString>
#include <QDateTime>
#include "enums.h"
class QTimer;
class DeviceInfo
class BleInfo
{
public:
QString name;
@@ -20,20 +21,24 @@ public:
bool leftCharging = false;
bool rightCharging = false;
bool caseCharging = false;
quint16 deviceModel = 0;
AirpodsTrayApp::Enums::AirPodsModel modelName = AirpodsTrayApp::Enums::AirPodsModel::Unknown;
quint8 lidOpenCounter = 0;
quint8 deviceColor = 0;
QString color = "Unknown"; // Default color
quint8 status = 0;
QByteArray rawData;
QByteArray encryptedPayload; // 16 bytes of encrypted payload
// Additional status flags from Kotlin version
bool isLeftPodInEar = false;
bool isRightPodInEar = false;
bool isPrimaryInEar = false;
bool isSecondaryInEar = false;
bool isLeftPodMicrophone = false;
bool isRightPodMicrophone = false;
bool isThisPodInTheCase = false;
bool isOnePodInCase = false;
bool areBothPodsInCase = false;
bool primaryLeft = true; // True if left pod is primary, false if right pod is primary
// Lid state enumeration
enum class LidState
@@ -41,8 +46,7 @@ public:
OPEN = 0x0,
CLOSED = 0x1,
UNKNOWN,
};
LidState lidState = LidState::UNKNOWN;
} lidState = LidState::UNKNOWN;
// Connection state enumeration
enum class ConnectionState : uint8_t
@@ -54,8 +58,7 @@ public:
RINGING = 0x07,
HANGING_UP = 0x09,
UNKNOWN = 0xFF // Using 0xFF for representing null in the original
};
ConnectionState connectionState = ConnectionState::UNKNOWN;
} connectionState = ConnectionState::UNKNOWN;
QDateTime lastSeen; // Timestamp of last detection
};
@@ -69,21 +72,18 @@ public:
void startScan();
void stopScan();
const QMap<QString, DeviceInfo> &getDevices() const;
bool isScanning() const;
private slots:
void onDeviceDiscovered(const QBluetoothDeviceInfo &info);
void onScanFinished();
void onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error);
void pruneOldDevices();
signals:
void deviceFound(const BleInfo &device);
private:
QBluetoothDeviceDiscoveryAgent *discoveryAgent;
QMap<QString, DeviceInfo> devices;
QTimer *pruneTimer; // Timer for periodic pruning
static const int PRUNE_INTERVAL_MS = 5000; // Check every 5 seconds
static const int DEVICE_TIMEOUT_MS = 10000; // Remove after 10 seconds
};
#endif // BLEMANAGER_H

View File

@@ -1,398 +0,0 @@
#include "blescanner.h"
#include <QApplication>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QGridLayout>
#include <QLabel>
#include <QPushButton>
#include <QTableWidget>
#include <QHeaderView>
#include <QProgressBar>
#include <QGroupBox>
#include <QMenu>
BleScanner::BleScanner(QWidget *parent) : QMainWindow(parent)
{
setWindowTitle("AirPods Battery Monitor");
resize(600, 400);
QWidget *centralWidget = new QWidget(this);
QVBoxLayout *mainLayout = new QVBoxLayout(centralWidget);
setCentralWidget(centralWidget);
QHBoxLayout *buttonLayout = new QHBoxLayout();
scanButton = new QPushButton("Start Scan", this);
stopButton = new QPushButton("Stop Scan", this);
stopButton->setEnabled(false);
buttonLayout->addWidget(scanButton);
buttonLayout->addWidget(stopButton);
buttonLayout->addStretch();
mainLayout->addLayout(buttonLayout);
deviceTable = new QTableWidget(0, 5, this);
deviceTable->setHorizontalHeaderLabels({"Device", "Left Pod", "Right Pod", "Case", "Address"});
deviceTable->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
deviceTable->setSelectionBehavior(QTableWidget::SelectRows);
deviceTable->setEditTriggers(QTableWidget::NoEditTriggers);
mainLayout->addWidget(deviceTable);
detailsGroup = new QGroupBox("Device Details", this);
QGridLayout *detailsLayout = new QGridLayout(detailsGroup);
// Row 0: Left Pod
detailsLayout->addWidget(new QLabel("Left Pod:"), 0, 0);
leftBatteryBar = new QProgressBar(this);
leftBatteryBar->setRange(0, 100);
leftBatteryBar->setTextVisible(true);
detailsLayout->addWidget(leftBatteryBar, 0, 1);
leftChargingLabel = new QLabel(this);
detailsLayout->addWidget(leftChargingLabel, 0, 2);
// Row 1: Right Pod
detailsLayout->addWidget(new QLabel("Right Pod:"), 1, 0);
rightBatteryBar = new QProgressBar(this);
rightBatteryBar->setRange(0, 100);
rightBatteryBar->setTextVisible(true);
detailsLayout->addWidget(rightBatteryBar, 1, 1);
rightChargingLabel = new QLabel(this);
detailsLayout->addWidget(rightChargingLabel, 1, 2);
// Row 2: Case
detailsLayout->addWidget(new QLabel("Case:"), 2, 0);
caseBatteryBar = new QProgressBar(this);
caseBatteryBar->setRange(0, 100);
caseBatteryBar->setTextVisible(true);
detailsLayout->addWidget(caseBatteryBar, 2, 1);
caseChargingLabel = new QLabel(this);
detailsLayout->addWidget(caseChargingLabel, 2, 2);
// Row 3: Model
detailsLayout->addWidget(new QLabel("Model:"), 3, 0);
modelLabel = new QLabel(this);
detailsLayout->addWidget(modelLabel, 3, 1);
// Row 4: Status
detailsLayout->addWidget(new QLabel("Status:"), 4, 0);
statusLabel = new QLabel(this);
detailsLayout->addWidget(statusLabel, 4, 1);
// Row 5: Lid State (replaces Lid Opens)
detailsLayout->addWidget(new QLabel("Lid State:"), 5, 0);
lidStateLabel = new QLabel(this);
detailsLayout->addWidget(lidStateLabel, 5, 1);
// Row 6: Color
detailsLayout->addWidget(new QLabel("Color:"), 6, 0);
colorLabel = new QLabel(this);
detailsLayout->addWidget(colorLabel, 6, 1);
// Row 7: Raw Data
detailsLayout->addWidget(new QLabel("Raw Data:"), 7, 0);
rawDataLabel = new QLabel(this);
rawDataLabel->setWordWrap(true);
rawDataLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
detailsLayout->addWidget(rawDataLabel, 7, 1, 1, 2);
// New Rows for Additional Info
// Row 8: Left Pod In Ear
detailsLayout->addWidget(new QLabel("Left Pod In Ear:"), 8, 0);
leftInEarLabel = new QLabel(this);
detailsLayout->addWidget(leftInEarLabel, 8, 1);
// Row 9: Right Pod In Ear
detailsLayout->addWidget(new QLabel("Right Pod In Ear:"), 9, 0);
rightInEarLabel = new QLabel(this);
detailsLayout->addWidget(rightInEarLabel, 9, 1);
// Row 10: Left Pod Microphone
detailsLayout->addWidget(new QLabel("Left Pod Microphone:"), 10, 0);
leftMicLabel = new QLabel(this);
detailsLayout->addWidget(leftMicLabel, 10, 1);
// Row 11: Right Pod Microphone
detailsLayout->addWidget(new QLabel("Right Pod Microphone:"), 11, 0);
rightMicLabel = new QLabel(this);
detailsLayout->addWidget(rightMicLabel, 11, 1);
// Row 12: This Pod In Case
detailsLayout->addWidget(new QLabel("This Pod In Case:"), 12, 0);
thisPodInCaseLabel = new QLabel(this);
detailsLayout->addWidget(thisPodInCaseLabel, 12, 1);
// Row 13: One Pod In Case
detailsLayout->addWidget(new QLabel("One Pod In Case:"), 13, 0);
onePodInCaseLabel = new QLabel(this);
detailsLayout->addWidget(onePodInCaseLabel, 13, 1);
// Row 14: Both Pods In Case
detailsLayout->addWidget(new QLabel("Both Pods In Case:"), 14, 0);
bothPodsInCaseLabel = new QLabel(this);
detailsLayout->addWidget(bothPodsInCaseLabel, 14, 1);
// Row 15: Connection State
detailsLayout->addWidget(new QLabel("Connection State:"), 15, 0);
connectionStateLabel = new QLabel(this);
detailsLayout->addWidget(connectionStateLabel, 15, 1);
mainLayout->addWidget(detailsGroup);
detailsGroup->setVisible(false);
bleManager = new BleManager(this);
refreshTimer = new QTimer(this);
connect(scanButton, &QPushButton::clicked, this, &BleScanner::startScan);
connect(stopButton, &QPushButton::clicked, this, &BleScanner::stopScan);
connect(deviceTable, &QTableWidget::itemSelectionChanged, this, &BleScanner::onDeviceSelected);
connect(refreshTimer, &QTimer::timeout, this, &BleScanner::updateDeviceList);
}
void BleScanner::startScan()
{
scanButton->setEnabled(false);
stopButton->setEnabled(true);
deviceTable->setRowCount(0);
detailsGroup->setVisible(false);
bleManager->startScan();
refreshTimer->start(500);
}
void BleScanner::stopScan()
{
bleManager->stopScan();
refreshTimer->stop();
scanButton->setEnabled(true);
stopButton->setEnabled(false);
}
void BleScanner::updateDeviceList()
{
const QMap<QString, DeviceInfo> &devices = bleManager->getDevices();
QString selectedAddress;
if (deviceTable->selectionModel()->hasSelection())
{
int row = deviceTable->selectionModel()->selectedRows().first().row();
selectedAddress = deviceTable->item(row, 4)->text();
}
deviceTable->setRowCount(0);
deviceTable->setRowCount(devices.size());
int row = 0;
for (auto it = devices.begin(); it != devices.end(); ++it, ++row)
{
const DeviceInfo &device = it.value();
deviceTable->setItem(row, 0, new QTableWidgetItem(device.name));
QString leftStatus = (device.leftPodBattery >= 0 ? QString::number(device.leftPodBattery) + "%" : "N/A") +
(device.leftCharging ? "" : "");
deviceTable->setItem(row, 1, new QTableWidgetItem(leftStatus));
QString rightStatus = (device.rightPodBattery >= 0 ? QString::number(device.rightPodBattery) + "%" : "N/A") +
(device.rightCharging ? "" : "");
deviceTable->setItem(row, 2, new QTableWidgetItem(rightStatus));
QString caseStatus = (device.caseBattery >= 0 ? QString::number(device.caseBattery) + "%" : "N/A") +
(device.caseCharging ? "" : "");
deviceTable->setItem(row, 3, new QTableWidgetItem(caseStatus));
deviceTable->setItem(row, 4, new QTableWidgetItem(device.address));
if (device.address == selectedAddress)
{
deviceTable->selectRow(row);
}
}
if (deviceTable->selectedItems().isEmpty()) {
deviceTable->selectRow(0);
}
}
void BleScanner::onDeviceSelected()
{
if (!deviceTable->selectionModel()->hasSelection())
{
detailsGroup->setVisible(false);
return;
}
int row = deviceTable->selectionModel()->selectedRows().first().row();
QString address = deviceTable->item(row, 4)->text();
const QMap<QString, DeviceInfo> &devices = bleManager->getDevices();
if (!devices.contains(address))
{
detailsGroup->setVisible(false);
return;
}
const DeviceInfo &device = devices[address];
// Battery bars with N/A handling
if (device.leftPodBattery >= 0)
{
leftBatteryBar->setValue(device.leftPodBattery);
leftBatteryBar->setFormat("%p%");
}
else
{
leftBatteryBar->setValue(0);
leftBatteryBar->setFormat("N/A");
}
if (device.rightPodBattery >= 0)
{
rightBatteryBar->setValue(device.rightPodBattery);
rightBatteryBar->setFormat("%p%");
}
else
{
rightBatteryBar->setValue(0);
rightBatteryBar->setFormat("N/A");
}
if (device.caseBattery >= 0)
{
caseBatteryBar->setValue(device.caseBattery);
caseBatteryBar->setFormat("%p%");
}
else
{
caseBatteryBar->setValue(0);
caseBatteryBar->setFormat("N/A");
}
leftChargingLabel->setText(device.leftCharging ? "Charging" : "Not Charging");
rightChargingLabel->setText(device.rightCharging ? "Charging" : "Not Charging");
caseChargingLabel->setText(device.caseCharging ? "Charging" : "Not Charging");
QString modelName = getModelName(device.deviceModel);
modelLabel->setText(modelName + " (0x" + QString::number(device.deviceModel, 16).toUpper() + ")");
QString statusBinary = QString("%1").arg(device.status, 8, 2, QChar('0'));
statusLabel->setText(QString("0x%1 (%2) - Binary: %3")
.arg(device.status, 2, 16, QChar('0'))
.toUpper()
.arg(device.status)
.arg(statusBinary));
// Lid State enum handling
QString lidStateStr;
switch (device.lidState)
{
case DeviceInfo::LidState::OPEN:
lidStateStr.append("Open");
break;
case DeviceInfo::LidState::CLOSED:
lidStateStr.append("Closed");
break;
case DeviceInfo::LidState::UNKNOWN:
lidStateStr.append("Unknown");
break;
}
lidStateStr.append(" (0x" + QString::number(device.lidOpenCounter, 16).toUpper() + " = " + QString::number(device.lidOpenCounter) + ")");
lidStateLabel->setText(lidStateStr);
QString colorName = getColorName(device.deviceColor);
colorLabel->setText(colorName + " (" + QString::number(device.deviceColor) + ")");
QString rawDataStr = "Bytes: ";
for (int i = 0; i < device.rawData.size(); ++i)
{
rawDataStr += QString("0x%1 ").arg(static_cast<quint8>(device.rawData[i]), 2, 16, QChar('0')).toUpper();
}
rawDataLabel->setText(rawDataStr);
// Set new status labels
leftInEarLabel->setText(device.isLeftPodInEar ? "Yes" : "No");
rightInEarLabel->setText(device.isRightPodInEar ? "Yes" : "No");
leftMicLabel->setText(device.isLeftPodMicrophone ? "Yes" : "No");
rightMicLabel->setText(device.isRightPodMicrophone ? "Yes" : "No");
thisPodInCaseLabel->setText(device.isThisPodInTheCase ? "Yes" : "No");
onePodInCaseLabel->setText(device.isOnePodInCase ? "Yes" : "No");
bothPodsInCaseLabel->setText(device.areBothPodsInCase ? "Yes" : "No");
connectionStateLabel->setText(getConnectionStateName(device.connectionState));
detailsGroup->setVisible(true);
}
QString BleScanner::getModelName(quint16 modelId)
{
switch (modelId)
{
case 0x0220:
return "AirPods 1st Gen";
case 0x0F20:
return "AirPods 2nd Gen";
case 0x1320:
return "AirPods 3rd Gen";
case 0x1920:
return "AirPods 4th Gen";
case 0x1B20:
return "AirPods 4th Gen (ANC)";
case 0x0A20:
return "AirPods Max";
case 0x1F20:
return "AirPods Max (USB-C)";
case 0x0E20:
return "AirPods Pro";
case 0x1420:
return "AirPods Pro 2nd Gen";
case 0x2420:
return "AirPods Pro 2nd Gen (USB-C)";
default:
return "Unknown Apple Device";
}
}
QString BleScanner::getColorName(quint8 colorId)
{
switch (colorId)
{
case 0x00:
return "White";
case 0x01:
return "Black";
case 0x02:
return "Red";
case 0x03:
return "Blue";
case 0x04:
return "Pink";
case 0x05:
return "Gray";
case 0x06:
return "Silver";
case 0x07:
return "Gold";
case 0x08:
return "Rose Gold";
case 0x09:
return "Space Gray";
case 0x0A:
return "Dark Blue";
case 0x0B:
return "Light Blue";
case 0x0C:
return "Yellow";
default:
return "Unknown";
}
}
QString BleScanner::getConnectionStateName(DeviceInfo::ConnectionState state)
{
using ConnectionState = DeviceInfo::ConnectionState;
switch (state)
{
case ConnectionState::DISCONNECTED:
return QString("Disconnected");
case ConnectionState::IDLE:
return QString("Idle");
case ConnectionState::MUSIC:
return QString("Playing Music");
case ConnectionState::CALL:
return QString("On Call");
case ConnectionState::RINGING:
return QString("Ringing");
case ConnectionState::HANGING_UP:
return QString("Hanging Up");
case ConnectionState::UNKNOWN:
default:
return QString("Unknown");
}
}

View File

@@ -1,61 +0,0 @@
#ifndef BLESCANNER_H
#define BLESCANNER_H
#include <QMainWindow>
#include "blemanager.h"
#include <QTimer>
#include <QSystemTrayIcon>
class QTableWidget;
class QGroupBox;
class QProgressBar;
class QLabel;
class QPushButton;
class BleScanner : public QMainWindow
{
Q_OBJECT
public:
explicit BleScanner(QWidget *parent = nullptr);
private slots:
void startScan();
void stopScan();
void updateDeviceList();
void onDeviceSelected();
private:
QString getModelName(quint16 modelId);
QString getColorName(quint8 colorId);
QString getConnectionStateName(DeviceInfo::ConnectionState state);
BleManager *bleManager;
QTimer *refreshTimer;
QPushButton *scanButton;
QPushButton *stopButton;
QTableWidget *deviceTable;
QGroupBox *detailsGroup;
QProgressBar *leftBatteryBar;
QProgressBar *rightBatteryBar;
QProgressBar *caseBatteryBar;
QLabel *leftChargingLabel;
QLabel *rightChargingLabel;
QLabel *caseChargingLabel;
QLabel *modelLabel;
QLabel *statusLabel;
QLabel *lidStateLabel; // Renamed from lidOpenLabel
QLabel *colorLabel;
QLabel *rawDataLabel;
// New labels for additional DeviceInfo fields
QLabel *leftInEarLabel;
QLabel *rightInEarLabel;
QLabel *leftMicLabel;
QLabel *rightMicLabel;
QLabel *thisPodInCaseLabel;
QLabel *onePodInCaseLabel;
QLabel *bothPodsInCaseLabel;
QLabel *connectionStateLabel;
};
#endif // BLESCANNER_H

138
linux/ble/bleutils.cpp Normal file
View File

@@ -0,0 +1,138 @@
#include <openssl/aes.h>
#include "deviceinfo.hpp"
#include "bleutils.h"
#include <QDebug>
#include <QByteArray>
#include <QtEndian>
#include <QCryptographicHash>
#include <cstring> // For memset
BLEUtils::BLEUtils(QObject *parent) : QObject(parent)
{
}
bool BLEUtils::verifyRPA(const QString &address, const QByteArray &irk)
{
if (address.isEmpty() || irk.isEmpty() || irk.size() != 16)
{
return false;
}
// Split address into bytes and reverse order
QStringList parts = address.split(':');
if (parts.size() != 6)
{
return false;
}
QByteArray rpa;
bool ok;
for (int i = parts.size() - 1; i >= 0; --i)
{
rpa.append(static_cast<char>(parts[i].toInt(&ok, 16)));
if (!ok)
{
return false;
}
}
if (rpa.size() != 6)
{
return false;
}
QByteArray prand = rpa.mid(3, 3);
QByteArray hash = rpa.left(3);
QByteArray computedHash = ah(irk, prand);
return hash == computedHash;
}
bool BLEUtils::isValidIrkRpa(const QByteArray &irk, const QString &rpa)
{
return verifyRPA(rpa, irk);
}
QByteArray BLEUtils::e(const QByteArray &key, const QByteArray &data)
{
if (key.size() != 16 || data.size() != 16)
{
return QByteArray();
}
// Prepare key and data (needs to be reversed)
QByteArray reversedKey(key);
std::reverse(reversedKey.begin(), reversedKey.end());
QByteArray reversedData(data);
std::reverse(reversedData.begin(), reversedData.end());
// Set up AES encryption
AES_KEY aesKey;
if (AES_set_encrypt_key(reinterpret_cast<const unsigned char *>(reversedKey.constData()), 128, &aesKey) != 0)
{
return QByteArray();
}
unsigned char out[16];
AES_encrypt(reinterpret_cast<const unsigned char *>(reversedData.constData()), out, &aesKey);
// Convert output to QByteArray and reverse it
QByteArray result(reinterpret_cast<char *>(out), 16);
std::reverse(result.begin(), result.end());
return result;
}
QByteArray BLEUtils::ah(const QByteArray &k, const QByteArray &r)
{
if (r.size() < 3)
{
return QByteArray();
}
// Pad the random part to 16 bytes
QByteArray rPadded(16, 0);
rPadded.replace(0, 3, r.left(3));
QByteArray encrypted = e(k, rPadded);
if (encrypted.isEmpty())
{
return QByteArray();
}
return encrypted.left(3);
}
QByteArray BLEUtils::decryptLastBytes(const QByteArray &data, const QByteArray &key)
{
if (data.size() < 16 || key.size() != 16)
{
qDebug() << "Invalid input: data size < 16 or key size != 16";
return QByteArray();
}
// Extract the last 16 bytes
QByteArray block = data.right(16);
// Set up AES decryption key (use key directly, no reversal)
AES_KEY aesKey;
if (AES_set_decrypt_key(reinterpret_cast<const unsigned char *>(key.constData()), 128, &aesKey) != 0)
{
qDebug() << "Failed to set AES decryption key";
return QByteArray();
}
unsigned char out[16];
unsigned char iv[16];
memset(iv, 0, 16); // Zero IV for CBC mode
// Perform AES decryption using CBC mode with zero IV
// AES_cbc_encrypt is used for both encryption and decryption depending on the key schedule
AES_cbc_encrypt(reinterpret_cast<const unsigned char *>(block.constData()), out, 16, &aesKey, iv, AES_DECRYPT);
// Convert output to QByteArray (no reversal)
QByteArray result(reinterpret_cast<char *>(out), 16);
return result;
}

52
linux/ble/bleutils.h Normal file
View File

@@ -0,0 +1,52 @@
#pragma once
#include <QObject>
#include <QByteArray>
class BLEUtils : public QObject
{
Q_OBJECT
public:
explicit BLEUtils(QObject *parent = nullptr);
/**
* @brief Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK)
* @param address The Bluetooth address to verify
* @param irk The Identity Resolving Key to use for verification
* @return true if the address is verified as an RPA matching the IRK
*/
static bool verifyRPA(const QString &address, const QByteArray &irk);
/**
* @brief Checks if the given IRK and RPA are valid
* @param irk The Identity Resolving Key
* @param rpa The Resolvable Private Address
* @return true if the RPA is valid for the given IRK
*/
Q_INVOKABLE static bool isValidIrkRpa(const QByteArray &irk, const QString &rpa);
/**
* @brief Decrypts the last 16 bytes of the input data using the provided key with AES-128 ECB
* @param data The input data containing at least 16 bytes
* @param key The 16-byte key for decryption
* @return The decrypted 16 bytes, or an empty QByteArray on failure
*/
static QByteArray decryptLastBytes(const QByteArray &data, const QByteArray &key);
private:
/**
* @brief Performs E function (AES-128) as specified in Bluetooth Core Specification
* @param key The key for encryption
* @param data The data to encrypt
* @return The encrypted data
*/
static QByteArray e(const QByteArray &key, const QByteArray &data);
/**
* @brief Performs the ah function as specified in Bluetooth Core Specification
* @param k The IRK key
* @param r The random part of the address
* @return The hash part of the address
*/
static QByteArray ah(const QByteArray &k, const QByteArray &r);
};

View File

@@ -1,10 +0,0 @@
#include "blescanner.h"
#include <QApplication>
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
BleScanner scanner;
scanner.show();
return app.exec();
}

View File

@@ -5,6 +5,7 @@
#include <QSettings>
#include "battery.hpp"
#include "enums.h"
#include "eardetection.hpp"
using namespace AirpodsTrayApp::Enums;
@@ -12,14 +13,11 @@ class DeviceInfo : public QObject
{
Q_OBJECT
Q_PROPERTY(QString batteryStatus READ batteryStatus WRITE setBatteryStatus NOTIFY batteryStatusChanged)
Q_PROPERTY(QString earDetectionStatus READ earDetectionStatus WRITE setEarDetectionStatus NOTIFY earDetectionStatusChanged)
Q_PROPERTY(int noiseControlMode READ noiseControlModeInt WRITE setNoiseControlModeInt NOTIFY noiseControlModeChangedInt)
Q_PROPERTY(bool conversationalAwareness READ conversationalAwareness WRITE setConversationalAwareness NOTIFY conversationalAwarenessChanged)
Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged)
Q_PROPERTY(QString deviceName READ deviceName WRITE setDeviceName NOTIFY deviceNameChanged)
Q_PROPERTY(Battery *battery READ getBattery CONSTANT)
Q_PROPERTY(bool primaryInEar READ isPrimaryInEar WRITE setPrimaryInEar NOTIFY primaryChanged)
Q_PROPERTY(bool secondaryInEar READ isSecondaryInEar WRITE setSecondaryInEar NOTIFY primaryChanged)
Q_PROPERTY(bool oneBudANCMode READ oneBudANCMode WRITE setOneBudANCMode NOTIFY oneBudANCModeChanged)
Q_PROPERTY(AirPodsModel model READ model WRITE setModel NOTIFY modelChanged)
Q_PROPERTY(bool adaptiveModeActive READ adaptiveModeActive NOTIFY noiseControlModeChangedInt)
@@ -28,9 +26,13 @@ class DeviceInfo : public QObject
Q_PROPERTY(bool leftPodInEar READ isLeftPodInEar NOTIFY primaryChanged)
Q_PROPERTY(bool rightPodInEar READ isRightPodInEar NOTIFY primaryChanged)
Q_PROPERTY(QString bluetoothAddress READ bluetoothAddress WRITE setBluetoothAddress NOTIFY bluetoothAddressChanged)
Q_PROPERTY(QString magicAccIRK READ magicAccIRKHex CONSTANT)
Q_PROPERTY(QString magicAccEncKey READ magicAccEncKeyHex CONSTANT)
public:
explicit DeviceInfo(QObject *parent = nullptr) : QObject(parent), m_battery(new Battery(this)) {}
explicit DeviceInfo(QObject *parent = nullptr) : QObject(parent), m_battery(new Battery(this)), m_earDetection(new EarDetection(this)) {
connect(getEarDetection(), &EarDetection::statusChanged, this, &DeviceInfo::primaryChanged);
}
QString batteryStatus() const { return m_batteryStatus; }
void setBatteryStatus(const QString &status)
@@ -42,16 +44,6 @@ public:
}
}
QString earDetectionStatus() const { return m_earDetectionStatus; }
void setEarDetectionStatus(const QString &status)
{
if (m_earDetectionStatus != status)
{
m_earDetectionStatus = status;
emit earDetectionStatusChanged(status);
}
}
NoiseControlMode noiseControlMode() const { return m_noiseControlMode; }
void setNoiseControlMode(NoiseControlMode mode)
{
@@ -97,26 +89,6 @@ public:
Battery *getBattery() const { return m_battery; }
bool isPrimaryInEar() const { return m_primaryInEar; }
void setPrimaryInEar(bool inEar)
{
if (m_primaryInEar != inEar)
{
m_primaryInEar = inEar;
emit primaryChanged();
}
}
bool isSecondaryInEar() const { return m_secoundaryInEar; }
void setSecondaryInEar(bool inEar)
{
if (m_secoundaryInEar != inEar)
{
m_secoundaryInEar = inEar;
emit primaryChanged();
}
}
bool oneBudANCMode() const { return m_oneBudANCMode; }
void setOneBudANCMode(bool enabled)
{
@@ -139,9 +111,11 @@ public:
QByteArray magicAccIRK() const { return m_magicAccIRK; }
void setMagicAccIRK(const QByteArray &irk) { m_magicAccIRK = irk; }
QString magicAccIRKHex() const { return QString::fromUtf8(m_magicAccIRK.toHex()); }
QByteArray magicAccEncKey() const { return m_magicAccEncKey; }
void setMagicAccEncKey(const QByteArray &key) { m_magicAccEncKey = key; }
QString magicAccEncKeyHex() const { return QString::fromUtf8(m_magicAccEncKey.toHex()); }
QString modelNumber() const { return m_modelNumber; }
void setModelNumber(const QString &modelNumber) { m_modelNumber = modelNumber; }
@@ -163,18 +137,18 @@ public:
QString caseIcon() const { return getModelIcon(model()).second; }
bool isLeftPodInEar() const
{
if (getBattery()->getPrimaryPod() == Battery::Component::Left) return isPrimaryInEar();
else return isSecondaryInEar();
if (getBattery()->getPrimaryPod() == Battery::Component::Left) return getEarDetection()->isPrimaryInEar();
else return getEarDetection()->isSecondaryInEar();
}
bool isRightPodInEar() const
{
if (getBattery()->getPrimaryPod() == Battery::Component::Right) return isPrimaryInEar();
else return isSecondaryInEar();
if (getBattery()->getPrimaryPod() == Battery::Component::Right) return getEarDetection()->isPrimaryInEar();
else return getEarDetection()->isSecondaryInEar();
}
bool adaptiveModeActive() const { return noiseControlMode() == NoiseControlMode::Adaptive; }
bool oneOrMorePodsInCase() const { return earDetectionStatus().contains("In case"); }
bool oneOrMorePodsInEar() const { return isPrimaryInEar() || isSecondaryInEar(); }
EarDetection *getEarDetection() const { return m_earDetection; }
void reset()
{
@@ -182,38 +156,38 @@ public:
setModel(AirPodsModel::Unknown);
m_battery->reset();
setBatteryStatus("");
setEarDetectionStatus("");
setPrimaryInEar(false);
setSecondaryInEar(false);
setNoiseControlMode(NoiseControlMode::Off);
setBluetoothAddress("");
getEarDetection()->reset();
}
void save() const
void saveToSettings(QSettings &settings)
{
QSettings settings("AirpodsTrayApp", "DeviceInfo");
settings.beginGroup("DeviceInfo");
settings.setValue("deviceName", m_deviceName);
settings.setValue("bluetoothAddress", m_bluetoothAddress);
settings.setValue("magicAccIRK", m_magicAccIRK.toBase64());
settings.setValue("magicAccEncKey", m_magicAccEncKey.toBase64());
settings.setValue("deviceName", deviceName());
settings.setValue("model", static_cast<int>(model()));
settings.setValue("magicAccIRK", magicAccIRK());
settings.setValue("magicAccEncKey", magicAccEncKey());
settings.endGroup();
}
void load()
void loadFromSettings(const QSettings &settings)
{
QSettings settings("AirpodsTrayApp", "DeviceInfo");
settings.beginGroup("DeviceInfo");
setDeviceName(settings.value("deviceName", "").toString());
setBluetoothAddress(settings.value("bluetoothAddress", "").toString());
setMagicAccIRK(QByteArray::fromBase64(settings.value("magicAccIRK", "").toByteArray()));
setMagicAccEncKey(QByteArray::fromBase64(settings.value("magicAccEncKey", "").toByteArray()));
settings.endGroup();
setDeviceName(settings.value("DeviceInfo/deviceName", "").toString());
setModel(static_cast<AirPodsModel>(settings.value("DeviceInfo/model", (int)(AirPodsModel::Unknown)).toInt()));
setMagicAccIRK(settings.value("DeviceInfo/magicAccIRK", QByteArray()).toByteArray());
setMagicAccEncKey(settings.value("DeviceInfo/magicAccEncKey", QByteArray()).toByteArray());
}
void updateBatteryStatus()
{
int leftLevel = getBattery()->getState(Battery::Component::Left).level;
int rightLevel = getBattery()->getState(Battery::Component::Right).level;
int caseLevel = getBattery()->getState(Battery::Component::Case).level;
setBatteryStatus(QString("Left: %1%, Right: %2%, Case: %3%").arg(leftLevel).arg(rightLevel).arg(caseLevel));
}
signals:
void batteryStatusChanged(const QString &status);
void earDetectionStatusChanged(const QString &status);
void noiseControlModeChanged(NoiseControlMode mode);
void noiseControlModeChangedInt(int mode);
void conversationalAwarenessChanged(bool enabled);
@@ -226,14 +200,11 @@ signals:
private:
QString m_batteryStatus;
QString m_earDetectionStatus;
NoiseControlMode m_noiseControlMode = NoiseControlMode::Off;
NoiseControlMode m_noiseControlMode = NoiseControlMode::Transparency;
bool m_conversationalAwareness = false;
int m_adaptiveNoiseLevel = 50;
QString m_deviceName;
Battery *m_battery;
bool m_primaryInEar = false;
bool m_secoundaryInEar = false;
QByteArray m_magicAccIRK;
QByteArray m_magicAccEncKey;
bool m_oneBudANCMode = false;
@@ -241,4 +212,5 @@ private:
QString m_modelNumber;
QString m_manufacturer;
QString m_bluetoothAddress;
EarDetection *m_earDetection;
};

94
linux/eardetection.hpp Normal file
View File

@@ -0,0 +1,94 @@
#pragma once
#include <QObject>
#include <QByteArray>
#include <QPair>
#include "logger.h"
class EarDetection : public QObject
{
Q_OBJECT
public:
enum class EarDetectionStatus
{
InEar,
NotInEar,
InCase,
Disconnected,
};
Q_ENUM(EarDetectionStatus)
explicit EarDetection(QObject *parent = nullptr) : QObject(parent)
{
reset();
}
void reset()
{
primaryStatus = EarDetectionStatus::Disconnected;
secondaryStatus = EarDetectionStatus::Disconnected;
emit statusChanged();
}
bool parseData(const QByteArray &data)
{
if (data.size() < 2)
{
return false;
}
auto [newprimaryStatus, newsecondaryStatus] = parseStatusBytes(data);
primaryStatus = newprimaryStatus;
secondaryStatus = newsecondaryStatus;
LOG_DEBUG("Parsed Ear Detection Status: Primary - " << primaryStatus
<< ", Secondary - " << secondaryStatus);
emit statusChanged();
return true;
}
void overrideEarDetectionStatus(bool primaryInEar, bool secondaryInEar)
{
primaryStatus = primaryInEar ? EarDetectionStatus::InEar : EarDetectionStatus::NotInEar;
secondaryStatus = secondaryInEar ? EarDetectionStatus::InEar : EarDetectionStatus::NotInEar;
emit statusChanged();
}
bool isPrimaryInEar() const { return primaryStatus == EarDetectionStatus::InEar; }
bool isSecondaryInEar() const { return secondaryStatus == EarDetectionStatus::InEar; }
bool oneOrMorePodsInCase() const { return primaryStatus == EarDetectionStatus::InCase || secondaryStatus == EarDetectionStatus::InCase; }
bool oneOrMorePodsInEar() const { return isPrimaryInEar() || isSecondaryInEar(); }
EarDetectionStatus getprimaryStatus() const { return primaryStatus; }
EarDetectionStatus getsecondaryStatus() const { return secondaryStatus; }
signals:
void statusChanged();
private:
QPair<EarDetectionStatus, EarDetectionStatus> parseStatusBytes(const QByteArray &data) const
{
quint8 primaryByte = static_cast<quint8>(data[6]);
quint8 secondaryByte = static_cast<quint8>(data[7]);
auto primaryStatus = parseStatusByte(primaryByte);
auto secondaryStatus = parseStatusByte(secondaryByte);
return qMakePair(primaryStatus, secondaryStatus);
}
EarDetectionStatus parseStatusByte(quint8 byte) const
{
if (byte == 0x00)
return EarDetectionStatus::InEar;
if (byte == 0x01)
return EarDetectionStatus::NotInEar;
if (byte == 0x02)
return EarDetectionStatus::InCase;
return EarDetectionStatus::Disconnected;
}
EarDetectionStatus primaryStatus = EarDetectionStatus::Disconnected;
EarDetectionStatus secondaryStatus = EarDetectionStatus::Disconnected;
};

View File

@@ -3,9 +3,9 @@
#include <QDebug>
#include <QLoggingCategory>
Q_DECLARE_LOGGING_CATEGORY(airpodsApp)
Q_DECLARE_LOGGING_CATEGORY(Librepods)
#define LOG_INFO(msg) qCInfo(airpodsApp) << "\033[32m" << msg << "\033[0m"
#define LOG_WARN(msg) qCWarning(airpodsApp) << "\033[33m" << msg << "\033[0m"
#define LOG_ERROR(msg) qCCritical(airpodsApp) << "\033[31m" << msg << "\033[0m"
#define LOG_DEBUG(msg) qCDebug(airpodsApp) << "\033[34m" << msg << "\033[0m"
#define LOG_INFO(msg) qCInfo(Librepods) << "\033[32m" << msg << "\033[0m"
#define LOG_WARN(msg) qCWarning(Librepods) << "\033[33m" << msg << "\033[0m"
#define LOG_ERROR(msg) qCCritical(Librepods) << "\033[31m" << msg << "\033[0m"
#define LOG_DEBUG(msg) qCDebug(Librepods) << "\033[34m" << msg << "\033[0m"

View File

@@ -1,20 +1,35 @@
#include <QSettings>
#include <QLocalServer>
#include <QLocalSocket>
#include "main.h"
#include <QApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QBluetoothLocalDevice>
#include <QBluetoothSocket>
#include <QQuickWindow>
#include <QLoggingCategory>
#include <QThread>
#include <QTimer>
#include <QProcess>
#include <QRegularExpression>
#include "airpods_packets.h"
#include "logger.h"
#include "mediacontroller.h"
#include "media/mediacontroller.h"
#include "trayiconmanager.h"
#include "enums.h"
#include "battery.hpp"
#include "BluetoothMonitor.h"
#include "autostartmanager.hpp"
#include "deviceinfo.hpp"
#include "ble/blemanager.h"
#include "ble/bleutils.h"
#include "QRCodeImageProvider.hpp"
#include "systemsleepmonitor.hpp"
using namespace AirpodsTrayApp::Enums;
Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
Q_LOGGING_CATEGORY(Librepods, "Librepods")
class AirPodsTrayApp : public QObject {
Q_OBJECT
@@ -26,12 +41,16 @@ class AirPodsTrayApp : public QObject {
Q_PROPERTY(int retryAttempts READ retryAttempts WRITE setRetryAttempts NOTIFY retryAttemptsChanged)
Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT)
Q_PROPERTY(DeviceInfo *deviceInfo READ deviceInfo CONSTANT)
Q_PROPERTY(QString phoneMacStatus READ phoneMacStatus NOTIFY phoneMacStatusChanged)
public:
AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr)
: QObject(parent), debugMode(debugMode), m_settings(new QSettings("AirPodsTrayApp", "AirPodsTrayApp")), m_autoStartManager(new AutoStartManager(this)), m_hideOnStart(hideOnStart), parent(parent), m_deviceInfo(new DeviceInfo(this))
: QObject(parent), debugMode(debugMode), m_settings(new QSettings("AirPodsTrayApp", "AirPodsTrayApp"))
, m_autoStartManager(new AutoStartManager(this)), m_hideOnStart(hideOnStart), parent(parent)
, m_deviceInfo(new DeviceInfo(this)), m_bleManager(new BleManager(this))
, m_systemSleepMonitor(new SystemSleepMonitor(this))
{
QLoggingCategory::setFilterRules(QString("airpodsApp.debug=%1").arg(debugMode ? "true" : "false"));
QLoggingCategory::setFilterRules(QString("Librepods.debug=%1").arg(debugMode ? "true" : "false"));
LOG_INFO("Initializing AirPodsTrayApp");
// Initialize tray icon and connect signals
@@ -50,16 +69,17 @@ public:
// Initialize MediaController and connect signals
mediaController = new MediaController(this);
connect(m_deviceInfo, &DeviceInfo::earDetectionStatusChanged, mediaController, &MediaController::handleEarDetection);
connect(mediaController, &MediaController::mediaStateChanged, this, &AirPodsTrayApp::handleMediaStateChange);
mediaController->initializeMprisInterface();
mediaController->followMediaChanges();
monitor = new BluetoothMonitor(this);
connect(monitor, &BluetoothMonitor::deviceConnected, this, &AirPodsTrayApp::bluezDeviceConnected);
connect(monitor, &BluetoothMonitor::deviceDisconnected, this, &AirPodsTrayApp::bluezDeviceDisconnected);
connect(m_bleManager, &BleManager::deviceFound, this, &AirPodsTrayApp::bleDeviceFound);
connect(m_deviceInfo->getBattery(), &Battery::primaryChanged, this, &AirPodsTrayApp::primaryChanged);
connect(m_systemSleepMonitor, &SystemSleepMonitor::systemGoingToSleep, this, &AirPodsTrayApp::onSystemGoingToSleep);
connect(m_systemSleepMonitor, &SystemSleepMonitor::systemWakingUp, this, &AirPodsTrayApp::onSystemWakingUp);
// Load settings
CrossDevice.isEnabled = loadCrossDeviceEnabled();
@@ -101,6 +121,7 @@ public:
int retryAttempts() const { return m_retryAttempts; }
bool hideOnStart() const { return m_hideOnStart; }
DeviceInfo *deviceInfo() const { return m_deviceInfo; }
QString phoneMacStatus() const { return m_phoneMacStatus; }
private:
bool debugMode;
@@ -151,6 +172,11 @@ public slots:
void setNoiseControlMode(NoiseControlMode mode)
{
if (m_deviceInfo->noiseControlMode() == mode)
{
LOG_INFO("Noise control mode is already set to: " << static_cast<int>(mode));
return;
}
LOG_INFO("Setting noise control mode to: " << mode);
QByteArray packet = AirPodsPackets::NoiseControl::getPacketForMode(mode);
writePacketToSocket(packet, "Noise control mode packet written: ");
@@ -287,6 +313,51 @@ public slots:
emit crossDeviceEnabledChanged(enabled);
}
void setPhoneMac(const QString &mac)
{
if (mac.isEmpty()) {
LOG_WARN("Empty MAC provided, ignoring");
m_phoneMacStatus = QStringLiteral("No MAC provided (ignoring)");
emit phoneMacStatusChanged();
return;
}
// Basic MAC address validation (accepts formats like AA:BB:CC:DD:EE:FF, AABBCCDDEEFF, AA-BB-CC-DD-EE-FF)
QRegularExpression re("^([0-9A-Fa-f]{2}([-:]?)){5}[0-9A-Fa-f]{2}$");
if (!re.match(mac).hasMatch()) {
LOG_ERROR("Invalid MAC address format: " << mac);
m_phoneMacStatus = QStringLiteral("Invalid MAC: ") + mac;
emit phoneMacStatusChanged();
return;
}
// Set environment variable for the running process
qputenv("PHONE_MAC_ADDRESS", mac.toUtf8());
LOG_INFO("PHONE_MAC_ADDRESS environment variable set to: " << mac);
m_phoneMacStatus = QStringLiteral("Updated MAC: ") + mac;
emit phoneMacStatusChanged();
// Update QML context property so UI placeholders reflect the new value
if (parent) {
parent->rootContext()->setContextProperty("PHONE_MAC_ADDRESS", mac);
}
// If a phone socket exists, restart connection using the new MAC
if (phoneSocket && phoneSocket->isOpen()) {
phoneSocket->close();
phoneSocket->deleteLater();
phoneSocket = nullptr;
}
connectToPhone();
}
void updatePhoneMacStatus(const QString &status)
{
m_phoneMacStatus = status;
emit phoneMacStatusChanged();
}
bool writePacketToSocket(const QByteArray &packet, const QString &logMessage)
{
if (socket && socket->isOpen())
@@ -314,6 +385,20 @@ public slots:
int loadRetryAttempts() const { return m_settings->value("bluetooth/retryAttempts", 3).toInt(); }
void saveRetryAttempts(int attempts) { m_settings->setValue("bluetooth/retryAttempts", attempts); }
void onSystemGoingToSleep()
{
if (m_bleManager->isScanning())
{
LOG_INFO("Stopping BLE scan before going to sleep");
m_bleManager->stopScan();
}
}
void onSystemWakingUp()
{
LOG_INFO("System is waking up, starting ble scan");
m_bleManager->startScan();
}
private slots:
void onTrayIconActivated()
{
@@ -379,6 +464,8 @@ private slots:
// Clear the device name and model
m_deviceInfo->reset();
m_bleManager->startScan();
emit airPodsStatusChanged();
// Show system notification
trayManager->showNotification(
@@ -545,6 +632,7 @@ private slots:
// Store the keys
m_deviceInfo->setMagicAccIRK(keys.magicAccIRK);
m_deviceInfo->setMagicAccEncKey(keys.magicAccEncKey);
m_deviceInfo->saveToSettings(*m_settings);
}
// Get CA state
else if (data.startsWith(AirPodsPackets::ConversationalAwareness::HEADER)) {
@@ -559,7 +647,6 @@ private slots:
{
if (auto value = AirPodsPackets::NoiseControl::parseMode(data))
{
LOG_INFO("Received noise control mode: " << value.value());
m_deviceInfo->setNoiseControlMode(value.value());
LOG_INFO("Noise control mode received: " << m_deviceInfo->noiseControlMode());
}
@@ -567,26 +654,14 @@ private slots:
// Ear Detection
else if (data.size() == 8 && data.startsWith(AirPodsPackets::Parse::EAR_DETECTION))
{
char primary = data[6];
char secondary = data[7];
m_deviceInfo->setPrimaryInEar(data[6] == 0x00);
m_deviceInfo->setSecondaryInEar(data[7] == 0x00);
m_deviceInfo->setEarDetectionStatus(QString("Primary: %1, Secondary: %2")
.arg(getEarStatus(primary), getEarStatus(secondary)));
LOG_INFO("Ear detection status: " << m_deviceInfo->earDetectionStatus());
m_deviceInfo->getEarDetection()->parseData(data);
mediaController->handleEarDetection(m_deviceInfo->getEarDetection());
}
// Battery Status
else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
{
m_deviceInfo->getBattery()->parsePacket(data);
int leftLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Left).level;
int rightLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Right).level;
int caseLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Case).level;
m_deviceInfo->setBatteryStatus(QString("Left: %1%, Right: %2%, Case: %3%")
.arg(leftLevel)
.arg(rightLevel)
.arg(caseLevel));
m_deviceInfo->updateBatteryStatus();
LOG_INFO("Battery status: " << m_deviceInfo->batteryStatus());
}
// Conversational Awareness Data
@@ -600,10 +675,11 @@ private slots:
parseMetadata(data);
initiateMagicPairing();
mediaController->setConnectedDeviceMacAddress(m_deviceInfo->bluetoothAddress().replace(":", "_"));
if (m_deviceInfo->oneOrMorePodsInEar()) // AirPods get added as output device only after this
if (m_deviceInfo->getEarDetection()->oneOrMorePodsInEar()) // AirPods get added as output device only after this
{
mediaController->activateA2dpProfile();
}
m_bleManager->stopScan();
emit airPodsStatusChanged();
}
else if (data.startsWith(AirPodsPackets::OneBudANCMode::HEADER)) {
@@ -628,11 +704,12 @@ private slots:
LOG_INFO("Already connected to the phone");
return;
}
QBluetoothAddress phoneAddress(PHONE_MAC_ADDRESS);
QBluetoothAddress phoneAddress("00:00:00:00:00:00"); // Default address, will be overwritten if PHONE_MAC_ADDRESS is set
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
if (!env.value("PHONE_MAC_ADDRESS").isEmpty())
{
QBluetoothAddress phoneAddress = QBluetoothAddress(env.value("PHONE_MAC_ADDRESS"));
phoneAddress = QBluetoothAddress(env.value("PHONE_MAC_ADDRESS"));
}
phoneSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol);
connect(phoneSocket, &QBluetoothSocket::connected, this, [this]() {
@@ -733,6 +810,16 @@ private slots:
QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data));
}
void bleDeviceFound(const BleInfo &device)
{
if (BLEUtils::isValidIrkRpa(m_deviceInfo->magicAccIRK(), device.address)) {
m_deviceInfo->setModel(device.modelName);
auto decryptet = BLEUtils::decryptLastBytes(device.encryptedPayload, m_deviceInfo->magicAccEncKey());
m_deviceInfo->getBattery()->parseEncryptedPacket(decryptet, device.primaryLeft, device.isThisPodInTheCase);
m_deviceInfo->getEarDetection()->overrideEarDetectionStatus(device.isPrimaryInEar, device.isSecondaryInEar);
}
}
public:
void handleMediaStateChange(MediaController::MediaState state) {
if (state == MediaController::MediaState::Playing) {
@@ -774,13 +861,6 @@ public:
process.waitForFinished();
QString output = process.readAllStandardOutput().trimmed();
LOG_INFO("Bluetoothctl output: " << output);
if (output.contains("Connection successful")) {
LOG_INFO("Connection successful, proceeding with L2CAP connection");
QBluetoothAddress btAddress(m_deviceInfo->bluetoothAddress());
forceL2capConnection(btAddress);
} else {
LOG_ERROR("Connection failed, cannot proceed with L2CAP connection");
}
}
QBluetoothLocalDevice localDevice;
const QList<QBluetoothAddress> connectedDevices = localDevice.connectedDevices();
@@ -795,33 +875,13 @@ public:
LOG_WARN("AirPods not found among connected devices");
}
void forceL2capConnection(const QBluetoothAddress &address) {
LOG_INFO("Retrying L2CAP connection for up to 10 seconds...");
QBluetoothDeviceInfo device(address, "", 0);
QElapsedTimer timer;
timer.start();
while (timer.elapsed() < 10000) {
QProcess bcProcess;
bcProcess.start("bluetoothctl", QStringList() << "connect" << address.toString());
bcProcess.waitForFinished();
QString output = bcProcess.readAllStandardOutput().trimmed();
LOG_INFO("Bluetoothctl output: " << output);
if (output.contains("Connection successful")) {
connectToDevice(device);
QThread::sleep(1);
if (socket && socket->isOpen()) {
LOG_INFO("Successfully connected to device: " << address.toString());
return;
}
} else {
LOG_WARN("Connection attempt failed, retrying...");
}
}
LOG_ERROR("Failed to connect to device within 10 seconds: " << address.toString());
}
void initializeBluetooth() {
connectToPhone();
m_deviceInfo->loadFromSettings(*m_settings);
if (!areAirpodsConnected()) {
m_bleManager->startScan();
}
}
void loadMainModule() {
@@ -843,6 +903,7 @@ signals:
void notificationsEnabledChanged(bool enabled);
void retryAttemptsChanged(int attempts);
void oneBudANCModeChanged(bool enabled);
void phoneMacStatusChanged();
private:
QBluetoothSocket *socket = nullptr;
@@ -857,6 +918,9 @@ private:
int m_retryAttempts = 3;
bool m_hideOnStart = false;
DeviceInfo *m_deviceInfo;
BleManager *m_bleManager;
SystemSleepMonitor *m_systemSleepMonitor = nullptr;
QString m_phoneMacStatus;
};
int main(int argc, char *argv[]) {
@@ -904,6 +968,17 @@ int main(int argc, char *argv[]) {
qmlRegisterType<DeviceInfo>("me.kavishdevar.DeviceInfo", 1, 0, "DeviceInfo");
AirPodsTrayApp *trayApp = new AirPodsTrayApp(debugMode, hideOnStart, &engine);
engine.rootContext()->setContextProperty("airPodsTrayApp", trayApp);
// Expose PHONE_MAC_ADDRESS environment variable to QML for placeholder in settings
{
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
QString phoneMacEnv = env.value("PHONE_MAC_ADDRESS", "");
engine.rootContext()->setContextProperty("PHONE_MAC_ADDRESS", phoneMacEnv);
// Initialize the visible status in the GUI
trayApp->updatePhoneMacStatus(phoneMacEnv.isEmpty() ? QStringLiteral("No phone MAC set") : phoneMacEnv);
}
engine.addImageProvider("qrcode", new QRCodeImageProvider());
trayApp->loadMainModule();
QLocalServer server;

View File

@@ -1,36 +0,0 @@
#ifndef MAIN_H
#define MAIN_H
#include <QApplication>
#include <QQmlApplicationEngine>
#include <QSystemTrayIcon>
#include <QMenu>
#include <QAction>
#include <QActionGroup>
#include <QBluetoothDeviceDiscoveryAgent>
#include <QBluetoothLocalDevice>
#include <QBluetoothSocket>
#include <QQuickWindow>
#include <QDebug>
#include <QInputDialog>
#include <QQmlContext>
#include <QLoggingCategory>
#include <QThread>
#include <QTimer>
#include <QPainter>
#include <QPalette>
#include <QDBusInterface>
#include <QDBusReply>
#include <QDBusConnectionInterface>
#include <QProcess>
#include <QRegularExpression>
#include <QFile>
#include <QTextStream>
#include <QStandardPaths>
#define PHONE_MAC_ADDRESS "22:22:F5:BB:1C:A0"
#define MANUFACTURER_ID 0x1234
#define MANUFACTURER_DATA "ALN_AirPods"
#endif

View File

@@ -1,5 +1,7 @@
#include "mediacontroller.h"
#include "logger.h"
#include "eardetection.hpp"
#include "playerstatuswatcher.h"
#include <QDebug>
#include <QProcess>
@@ -8,37 +10,9 @@
#include <QDBusConnectionInterface>
MediaController::MediaController(QObject *parent) : QObject(parent) {
// No additional initialization required here
}
void MediaController::initializeMprisInterface() {
QStringList services =
QDBusConnection::sessionBus().interface()->registeredServiceNames();
QString mprisService;
for (const QString &service : services) {
if (service.startsWith("org.mpris.MediaPlayer2.") &&
service != "org.mpris.MediaPlayer2") {
mprisService = service;
break;
}
}
if (!mprisService.isEmpty()) {
mprisInterface = new QDBusInterface(mprisService, "/org/mpris/MediaPlayer2",
"org.mpris.MediaPlayer2.Player",
QDBusConnection::sessionBus(), this);
if (!mprisInterface->isValid()) {
LOG_ERROR("Failed to initialize MPRIS interface for service: ") << mprisService;
} else {
LOG_INFO("Connected to MPRIS service: " << mprisService);
}
} else {
LOG_WARN("No active MPRIS media players found");
}
}
void MediaController::handleEarDetection(const QString &status)
void MediaController::handleEarDetection(EarDetection *earDetection)
{
if (earDetectionBehavior == Disabled)
{
@@ -46,15 +20,8 @@ void MediaController::handleEarDetection(const QString &status)
return;
}
bool primaryInEar = false;
bool secondaryInEar = false;
QStringList parts = status.split(", ");
if (parts.size() == 2)
{
primaryInEar = parts[0].contains("In Ear");
secondaryInEar = parts[1].contains("In Ear");
}
bool primaryInEar = earDetection->isPrimaryInEar();
bool secondaryInEar = earDetection->isSecondaryInEar();
LOG_DEBUG("Ear detection status: primaryInEar="
<< primaryInEar << ", secondaryInEar=" << secondaryInEar
@@ -77,12 +44,7 @@ void MediaController::handleEarDetection(const QString &status)
if (shouldPause && isActiveOutputDeviceAirPods())
{
QProcess process;
process.start("playerctl", QStringList() << "status");
process.waitForFinished();
QString playbackStatus = process.readAllStandardOutput().trimmed();
LOG_DEBUG("Playback status: " << playbackStatus);
if (playbackStatus == "Playing")
if (getCurrentMediaState() == Playing)
{
pause();
}
@@ -97,17 +59,7 @@ void MediaController::handleEarDetection(const QString &status)
// Resume if conditions are met and we previously paused
if (shouldResume && wasPausedByApp && isActiveOutputDeviceAirPods())
{
int result = QProcess::execute("playerctl", QStringList() << "play");
LOG_DEBUG("Executed 'playerctl play' with result: " << result);
if (result == 0)
{
LOG_INFO("Resumed playback via Playerctl");
wasPausedByApp = false;
}
else
{
LOG_ERROR("Failed to resume playback via Playerctl");
}
play();
}
}
else
@@ -124,16 +76,14 @@ void MediaController::setEarDetectionBehavior(EarDetectionBehavior behavior)
}
void MediaController::followMediaChanges() {
playerctlProcess = new QProcess(this);
connect(playerctlProcess, &QProcess::readyReadStandardOutput, this,
[this]() {
QString output =
playerctlProcess->readAllStandardOutput().trimmed();
LOG_DEBUG("Playerctl output: " << output);
MediaState state = mediaStateFromPlayerctlOutput(output);
playerStatusWatcher = new PlayerStatusWatcher("", this);
connect(playerStatusWatcher, &PlayerStatusWatcher::playbackStatusChanged,
this, [this](const QString &status)
{
LOG_DEBUG("Playback status changed: " << status);
MediaState state = mediaStateFromPlayerctlOutput(status);
emit mediaStateChanged(state);
});
playerctlProcess->start("playerctl", QStringList() << "--follow" << "status");
}
bool MediaController::isActiveOutputDeviceAirPods() {
@@ -222,7 +172,7 @@ void MediaController::setConnectedDeviceMacAddress(const QString &macAddress) {
}
MediaController::MediaState MediaController::mediaStateFromPlayerctlOutput(
const QString &output) {
const QString &output) const {
if (output == "Playing") {
return MediaState::Playing;
} else if (output == "Paused") {
@@ -232,28 +182,106 @@ MediaController::MediaState MediaController::mediaStateFromPlayerctlOutput(
}
}
void MediaController::pause() {
int result = QProcess::execute("playerctl", QStringList() << "pause");
LOG_DEBUG("Executed 'playerctl pause' with result: " << result);
if (result == 0)
MediaController::MediaState MediaController::getCurrentMediaState() const
{
return mediaStateFromPlayerctlOutput(PlayerStatusWatcher::getCurrentPlaybackStatus(""));
}
bool MediaController::sendMediaPlayerCommand(const QString &method)
{
// Connect to the session bus
QDBusConnection bus = QDBusConnection::sessionBus();
// Find available MPRIS-compatible media players
QStringList services = bus.interface()->registeredServiceNames().value();
QStringList mprisServices;
for (const QString &service : services)
{
LOG_INFO("Paused playback via Playerctl");
if (service.startsWith("org.mpris.MediaPlayer2."))
{
mprisServices << service;
}
}
if (mprisServices.isEmpty())
{
LOG_ERROR("No MPRIS-compatible media players found on DBus");
return false;
}
bool success = false;
// Try each MPRIS service until one succeeds
for (const QString &service : mprisServices)
{
QDBusInterface playerInterface(
service,
"/org/mpris/MediaPlayer2",
"org.mpris.MediaPlayer2.Player",
bus);
if (!playerInterface.isValid())
{
LOG_ERROR("Invalid DBus interface for service: " << service);
continue;
}
// Send the Play or Pause command
if (method == "Play" || method == "Pause")
{
QDBusReply<void> reply = playerInterface.call(method);
if (reply.isValid())
{
LOG_INFO("Successfully sent " << method << " to " << service);
success = true;
break; // Exit after the first successful command
}
else
{
LOG_ERROR("Failed to send " << method << " to " << service
<< ": " << reply.error().message());
}
}
else
{
LOG_ERROR("Unsupported method: " << method);
return false;
}
}
if (!success)
{
LOG_ERROR("No media player responded successfully to " << method);
}
return success;
}
void MediaController::play()
{
if (sendMediaPlayerCommand("Play"))
{
LOG_INFO("Resumed playback via DBus");
wasPausedByApp = false;
}
else
{
LOG_ERROR("Failed to resume playback via DBus");
}
}
void MediaController::pause()
{
if (sendMediaPlayerCommand("Pause"))
{
LOG_INFO("Paused playback via DBus");
wasPausedByApp = true;
}
else
{
LOG_ERROR("Failed to pause playback via Playerctl");
LOG_ERROR("Failed to pause playback via DBus");
}
}
MediaController::~MediaController() {
if (playerctlProcess) {
playerctlProcess->terminate();
if (!playerctlProcess->waitForFinished()) {
playerctlProcess->kill();
playerctlProcess->waitForFinished(1000);
}
}
}
QString MediaController::getAudioDeviceName()

View File

@@ -1,10 +1,12 @@
#ifndef MEDIACONTROLLER_H
#define MEDIACONTROLLER_H
#include <QDBusInterface>
#include <QObject>
class QProcess;
class EarDetection;
class PlayerStatusWatcher;
class QDBusInterface;
class MediaController : public QObject
{
@@ -28,8 +30,7 @@ public:
explicit MediaController(QObject *parent = nullptr);
~MediaController();
void initializeMprisInterface();
void handleEarDetection(const QString &status);
void handleEarDetection(EarDetection*);
void followMediaChanges();
bool isActiveOutputDeviceAirPods();
void handleConversationalAwareness(const QByteArray &data);
@@ -40,22 +41,24 @@ public:
void setEarDetectionBehavior(EarDetectionBehavior behavior);
inline EarDetectionBehavior getEarDetectionBehavior() const { return earDetectionBehavior; }
void play();
void pause();
MediaState getCurrentMediaState() const;
Q_SIGNALS:
void mediaStateChanged(MediaState state);
private:
MediaState mediaStateFromPlayerctlOutput(const QString &output);
MediaState mediaStateFromPlayerctlOutput(const QString &output) const;
QString getAudioDeviceName();
bool sendMediaPlayerCommand(const QString &method);
QDBusInterface *mprisInterface = nullptr;
QProcess *playerctlProcess = nullptr;
bool wasPausedByApp = false;
int initialVolume = -1;
QString connectedDeviceMacAddress;
EarDetectionBehavior earDetectionBehavior = PauseWhenOneRemoved;
QString m_deviceOutputName;
PlayerStatusWatcher *playerStatusWatcher = nullptr;
};
#endif // MEDIACONTROLLER_H

View File

@@ -0,0 +1,70 @@
#include "playerstatuswatcher.h"
#include <QDBusConnection>
#include <QDBusPendingReply>
#include <QVariantMap>
#include <QDBusReply>
#include <QDBusConnectionInterface>
PlayerStatusWatcher::PlayerStatusWatcher(const QString &playerService, QObject *parent)
: QObject(parent),
m_playerService(playerService),
m_iface(new QDBusInterface(playerService, "/org/mpris/MediaPlayer2",
"org.mpris.MediaPlayer2.Player", QDBusConnection::sessionBus(), this)),
m_serviceWatcher(new QDBusServiceWatcher(playerService, QDBusConnection::sessionBus(),
QDBusServiceWatcher::WatchForOwnerChange, this))
{
QDBusConnection::sessionBus().connect(
playerService, "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties",
"PropertiesChanged", this, SLOT(onPropertiesChanged(QString,QVariantMap,QStringList))
);
connect(m_serviceWatcher, &QDBusServiceWatcher::serviceOwnerChanged,
this, &PlayerStatusWatcher::onServiceOwnerChanged);
updateStatus();
}
void PlayerStatusWatcher::onPropertiesChanged(const QString &interface,
const QVariantMap &changed,
const QStringList &)
{
if (interface == "org.mpris.MediaPlayer2.Player" && changed.contains("PlaybackStatus")) {
emit playbackStatusChanged(changed.value("PlaybackStatus").toString());
}
}
void PlayerStatusWatcher::updateStatus() {
QVariant reply = m_iface->property("PlaybackStatus");
if (reply.isValid()) {
emit playbackStatusChanged(reply.toString());
}
}
void PlayerStatusWatcher::onServiceOwnerChanged(const QString &name, const QString &, const QString &newOwner)
{
if (name == m_playerService && newOwner.isEmpty()) {
emit playbackStatusChanged(""); // player disappeared
} else if (name == m_playerService && !newOwner.isEmpty()) {
updateStatus(); // player appeared/reappeared
}
}
QString PlayerStatusWatcher::getCurrentPlaybackStatus(const QString &playerService)
{
QDBusConnection bus = QDBusConnection::sessionBus();
QStringList services = bus.interface()->registeredServiceNames().value();
for (const QString &service : services) {
if (service.startsWith("org.mpris.MediaPlayer2.")) {
QDBusInterface iface(service, "/org/mpris/MediaPlayer2",
"org.mpris.MediaPlayer2.Player", bus);
if (iface.isValid()) {
QVariant status = iface.property("PlaybackStatus");
if (status.isValid() && status.toString() == "Playing") {
return status.toString();
}
}
}
}
return QString();
}

View File

@@ -0,0 +1,25 @@
#pragma once
#include <QObject>
#include <QDBusInterface>
#include <QDBusServiceWatcher>
class PlayerStatusWatcher : public QObject {
Q_OBJECT
public:
explicit PlayerStatusWatcher(const QString &playerService, QObject *parent = nullptr);
static QString getCurrentPlaybackStatus(const QString &playerService);
signals:
void playbackStatusChanged(const QString &status);
private slots:
void onPropertiesChanged(const QString &interface, const QVariantMap &changed, const QStringList &);
void onServiceOwnerChanged(const QString &name, const QString &oldOwner, const QString &newOwner);
private:
void updateStatus();
QString m_playerService;
QDBusInterface *m_iface;
QDBusServiceWatcher *m_serviceWatcher;
};

View File

@@ -0,0 +1,77 @@
#include "media/playerstatuswatcher.h"
#include <QDBusConnection>
#include <QDBusPendingReply>
#include <QVariantMap>
#include <QDBusReply>
PlayerStatusWatcher::PlayerStatusWatcher(const QString &playerService, QObject *parent)
: QObject(parent),
m_playerService(playerService),
m_iface(new QDBusInterface(playerService, "/org/mpris/MediaPlayer2",
"org.mpris.MediaPlayer2.Player", QDBusConnection::sessionBus(), this)),
m_serviceWatcher(new QDBusServiceWatcher(playerService, QDBusConnection::sessionBus(),
QDBusServiceWatcher::WatchForOwnerChange, this))
{
// Register this object on the session bus to receive D-Bus messages
QDBusConnection::sessionBus().registerObject("/PlayerStatusWatcher", this,
QDBusConnection::ExportAllSlots);
QDBusConnection::sessionBus().connect(
playerService, "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties",
"PropertiesChanged", this, SLOT(onPropertiesChanged(QString,QVariantMap,QStringList))
);
connect(m_serviceWatcher, &QDBusServiceWatcher::serviceOwnerChanged,
this, &PlayerStatusWatcher::onServiceOwnerChanged);
updateStatus();
}
void PlayerStatusWatcher::onPropertiesChanged(const QString &interface,
const QVariantMap &changed,
const QStringList &)
{
// Get the service name of the sender
QString sender = message().service();
// Skip if it's a KDE Connect player
if (sender.contains("kdeconnect", Qt::CaseInsensitive)) {
return;
}
if (interface == "org.mpris.MediaPlayer2.Player" && changed.contains("PlaybackStatus")) {
emit playbackStatusChanged(changed.value("PlaybackStatus").toString());
}
}
void PlayerStatusWatcher::updateStatus() {
QVariant reply = m_iface->property("PlaybackStatus");
if (reply.isValid()) {
emit playbackStatusChanged(reply.toString());
}
}
void PlayerStatusWatcher::onServiceOwnerChanged(const QString &name, const QString &, const QString &newOwner)
{
if (name == m_playerService && newOwner.isEmpty()) {
emit playbackStatusChanged(""); // player disappeared
} else if (name == m_playerService && !newOwner.isEmpty()) {
updateStatus(); // player appeared/reappeared
}
}
QString PlayerStatusWatcher::getCurrentPlaybackStatus(const QString &playerService)
{
QDBusInterface iface(
playerService,
"/org/mpris/MediaPlayer2",
"org.mpris.MediaPlayer2.Player",
QDBusConnection::sessionBus());
QVariant reply = iface.property("PlaybackStatus");
if (reply.isValid())
{
return reply.toString(); // "Playing", "Paused", "Stopped"
}
else
{
return QString(); // or handle error as needed
}
}

View File

@@ -0,0 +1,49 @@
#ifndef SYSTEMSLEEPMONITOR_HPP
#define SYSTEMSLEEPMONITOR_HPP
#include <QObject>
#include <QDBusConnection>
#include <QDBusInterface>
#include <QDBusMessage>
#include <QDebug>
class SystemSleepMonitor : public QObject {
Q_OBJECT
public:
explicit SystemSleepMonitor(QObject *parent = nullptr) : QObject(parent) {
// Connect to the system D-Bus
QDBusConnection systemBus = QDBusConnection::systemBus();
if (!systemBus.isConnected()) {
qWarning() << "Cannot connect to system D-Bus";
return;
}
// Subscribe to PrepareForSleep signal from logind
systemBus.connect(
"org.freedesktop.login1",
"/org/freedesktop/login1",
"org.freedesktop.login1.Manager",
"PrepareForSleep",
this,
SLOT(handlePrepareForSleep(bool))
);
}
~SystemSleepMonitor() override = default;
signals:
void systemGoingToSleep();
void systemWakingUp();
private slots:
void handlePrepareForSleep(bool sleeping) {
if (sleeping) {
emit systemGoingToSleep();
} else {
emit systemWakingUp();
}
}
};
#endif // SYSTEMSLEEPMONITOR_HPP

View File

@@ -0,0 +1,829 @@
/*
* QR Code generator library (C++)
*
* Copyright (c) Project Nayuki. (MIT License)
* https://www.nayuki.io/page/qr-code-generator-library
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
* - The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
* - The Software is provided "as is", without warranty of any kind, express or
* implied, including but not limited to the warranties of merchantability,
* fitness for a particular purpose and noninfringement. In no event shall the
* authors or copyright holders be liable for any claim, damages or other
* liability, whether in an action of contract, tort or otherwise, arising from,
* out of or in connection with the Software or the use or other dealings in the
* Software.
*/
#include <algorithm>
#include <cassert>
#include <climits>
#include <cstddef>
#include <cstdlib>
#include <cstring>
#include <sstream>
#include <utility>
#include "qrcodegen.hpp"
using std::int8_t;
using std::uint8_t;
using std::size_t;
using std::vector;
namespace qrcodegen {
/*---- Class QrSegment ----*/
QrSegment::Mode::Mode(int mode, int cc0, int cc1, int cc2) :
modeBits(mode) {
numBitsCharCount[0] = cc0;
numBitsCharCount[1] = cc1;
numBitsCharCount[2] = cc2;
}
int QrSegment::Mode::getModeBits() const {
return modeBits;
}
int QrSegment::Mode::numCharCountBits(int ver) const {
return numBitsCharCount[(ver + 7) / 17];
}
const QrSegment::Mode QrSegment::Mode::NUMERIC (0x1, 10, 12, 14);
const QrSegment::Mode QrSegment::Mode::ALPHANUMERIC(0x2, 9, 11, 13);
const QrSegment::Mode QrSegment::Mode::BYTE (0x4, 8, 16, 16);
const QrSegment::Mode QrSegment::Mode::KANJI (0x8, 8, 10, 12);
const QrSegment::Mode QrSegment::Mode::ECI (0x7, 0, 0, 0);
QrSegment QrSegment::makeBytes(const vector<uint8_t> &data) {
if (data.size() > static_cast<unsigned int>(INT_MAX))
throw std::length_error("Data too long");
BitBuffer bb;
for (uint8_t b : data)
bb.appendBits(b, 8);
return QrSegment(Mode::BYTE, static_cast<int>(data.size()), std::move(bb));
}
QrSegment QrSegment::makeNumeric(const char *digits) {
BitBuffer bb;
int accumData = 0;
int accumCount = 0;
int charCount = 0;
for (; *digits != '\0'; digits++, charCount++) {
char c = *digits;
if (c < '0' || c > '9')
throw std::domain_error("String contains non-numeric characters");
accumData = accumData * 10 + (c - '0');
accumCount++;
if (accumCount == 3) {
bb.appendBits(static_cast<uint32_t>(accumData), 10);
accumData = 0;
accumCount = 0;
}
}
if (accumCount > 0) // 1 or 2 digits remaining
bb.appendBits(static_cast<uint32_t>(accumData), accumCount * 3 + 1);
return QrSegment(Mode::NUMERIC, charCount, std::move(bb));
}
QrSegment QrSegment::makeAlphanumeric(const char *text) {
BitBuffer bb;
int accumData = 0;
int accumCount = 0;
int charCount = 0;
for (; *text != '\0'; text++, charCount++) {
const char *temp = std::strchr(ALPHANUMERIC_CHARSET, *text);
if (temp == nullptr)
throw std::domain_error("String contains unencodable characters in alphanumeric mode");
accumData = accumData * 45 + static_cast<int>(temp - ALPHANUMERIC_CHARSET);
accumCount++;
if (accumCount == 2) {
bb.appendBits(static_cast<uint32_t>(accumData), 11);
accumData = 0;
accumCount = 0;
}
}
if (accumCount > 0) // 1 character remaining
bb.appendBits(static_cast<uint32_t>(accumData), 6);
return QrSegment(Mode::ALPHANUMERIC, charCount, std::move(bb));
}
vector<QrSegment> QrSegment::makeSegments(const char *text) {
// Select the most efficient segment encoding automatically
vector<QrSegment> result;
if (*text == '\0'); // Leave result empty
else if (isNumeric(text))
result.push_back(makeNumeric(text));
else if (isAlphanumeric(text))
result.push_back(makeAlphanumeric(text));
else {
vector<uint8_t> bytes;
for (; *text != '\0'; text++)
bytes.push_back(static_cast<uint8_t>(*text));
result.push_back(makeBytes(bytes));
}
return result;
}
QrSegment QrSegment::makeEci(long assignVal) {
BitBuffer bb;
if (assignVal < 0)
throw std::domain_error("ECI assignment value out of range");
else if (assignVal < (1 << 7))
bb.appendBits(static_cast<uint32_t>(assignVal), 8);
else if (assignVal < (1 << 14)) {
bb.appendBits(2, 2);
bb.appendBits(static_cast<uint32_t>(assignVal), 14);
} else if (assignVal < 1000000L) {
bb.appendBits(6, 3);
bb.appendBits(static_cast<uint32_t>(assignVal), 21);
} else
throw std::domain_error("ECI assignment value out of range");
return QrSegment(Mode::ECI, 0, std::move(bb));
}
QrSegment::QrSegment(const Mode &md, int numCh, const std::vector<bool> &dt) :
mode(&md),
numChars(numCh),
data(dt) {
if (numCh < 0)
throw std::domain_error("Invalid value");
}
QrSegment::QrSegment(const Mode &md, int numCh, std::vector<bool> &&dt) :
mode(&md),
numChars(numCh),
data(std::move(dt)) {
if (numCh < 0)
throw std::domain_error("Invalid value");
}
int QrSegment::getTotalBits(const vector<QrSegment> &segs, int version) {
int result = 0;
for (const QrSegment &seg : segs) {
int ccbits = seg.mode->numCharCountBits(version);
if (seg.numChars >= (1L << ccbits))
return -1; // The segment's length doesn't fit the field's bit width
if (4 + ccbits > INT_MAX - result)
return -1; // The sum will overflow an int type
result += 4 + ccbits;
if (seg.data.size() > static_cast<unsigned int>(INT_MAX - result))
return -1; // The sum will overflow an int type
result += static_cast<int>(seg.data.size());
}
return result;
}
bool QrSegment::isNumeric(const char *text) {
for (; *text != '\0'; text++) {
char c = *text;
if (c < '0' || c > '9')
return false;
}
return true;
}
bool QrSegment::isAlphanumeric(const char *text) {
for (; *text != '\0'; text++) {
if (std::strchr(ALPHANUMERIC_CHARSET, *text) == nullptr)
return false;
}
return true;
}
const QrSegment::Mode &QrSegment::getMode() const {
return *mode;
}
int QrSegment::getNumChars() const {
return numChars;
}
const std::vector<bool> &QrSegment::getData() const {
return data;
}
const char *QrSegment::ALPHANUMERIC_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
/*---- Class QrCode ----*/
int QrCode::getFormatBits(Ecc ecl) {
switch (ecl) {
case Ecc::LOW : return 1;
case Ecc::MEDIUM : return 0;
case Ecc::QUARTILE: return 3;
case Ecc::HIGH : return 2;
default: throw std::logic_error("Unreachable");
}
}
QrCode QrCode::encodeText(const char *text, Ecc ecl) {
vector<QrSegment> segs = QrSegment::makeSegments(text);
return encodeSegments(segs, ecl);
}
QrCode QrCode::encodeBinary(const vector<uint8_t> &data, Ecc ecl) {
vector<QrSegment> segs{QrSegment::makeBytes(data)};
return encodeSegments(segs, ecl);
}
QrCode QrCode::encodeSegments(const vector<QrSegment> &segs, Ecc ecl,
int minVersion, int maxVersion, int mask, bool boostEcl) {
if (!(MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= MAX_VERSION) || mask < -1 || mask > 7)
throw std::invalid_argument("Invalid value");
// Find the minimal version number to use
int version, dataUsedBits;
for (version = minVersion; ; version++) {
int dataCapacityBits = getNumDataCodewords(version, ecl) * 8; // Number of data bits available
dataUsedBits = QrSegment::getTotalBits(segs, version);
if (dataUsedBits != -1 && dataUsedBits <= dataCapacityBits)
break; // This version number is found to be suitable
if (version >= maxVersion) { // All versions in the range could not fit the given data
std::ostringstream sb;
if (dataUsedBits == -1)
sb << "Segment too long";
else {
sb << "Data length = " << dataUsedBits << " bits, ";
sb << "Max capacity = " << dataCapacityBits << " bits";
}
throw data_too_long(sb.str());
}
}
assert(dataUsedBits != -1);
// Increase the error correction level while the data still fits in the current version number
for (Ecc newEcl : {Ecc::MEDIUM, Ecc::QUARTILE, Ecc::HIGH}) { // From low to high
if (boostEcl && dataUsedBits <= getNumDataCodewords(version, newEcl) * 8)
ecl = newEcl;
}
// Concatenate all segments to create the data bit string
BitBuffer bb;
for (const QrSegment &seg : segs) {
bb.appendBits(static_cast<uint32_t>(seg.getMode().getModeBits()), 4);
bb.appendBits(static_cast<uint32_t>(seg.getNumChars()), seg.getMode().numCharCountBits(version));
bb.insert(bb.end(), seg.getData().begin(), seg.getData().end());
}
assert(bb.size() == static_cast<unsigned int>(dataUsedBits));
// Add terminator and pad up to a byte if applicable
size_t dataCapacityBits = static_cast<size_t>(getNumDataCodewords(version, ecl)) * 8;
assert(bb.size() <= dataCapacityBits);
bb.appendBits(0, std::min(4, static_cast<int>(dataCapacityBits - bb.size())));
bb.appendBits(0, (8 - static_cast<int>(bb.size() % 8)) % 8);
assert(bb.size() % 8 == 0);
// Pad with alternating bytes until data capacity is reached
for (uint8_t padByte = 0xEC; bb.size() < dataCapacityBits; padByte ^= 0xEC ^ 0x11)
bb.appendBits(padByte, 8);
// Pack bits into bytes in big endian
vector<uint8_t> dataCodewords(bb.size() / 8);
for (size_t i = 0; i < bb.size(); i++)
dataCodewords.at(i >> 3) |= (bb.at(i) ? 1 : 0) << (7 - (i & 7));
// Create the QR Code object
return QrCode(version, ecl, dataCodewords, mask);
}
QrCode::QrCode(int ver, Ecc ecl, const vector<uint8_t> &dataCodewords, int msk) :
// Initialize fields and check arguments
version(ver),
errorCorrectionLevel(ecl) {
if (ver < MIN_VERSION || ver > MAX_VERSION)
throw std::domain_error("Version value out of range");
if (msk < -1 || msk > 7)
throw std::domain_error("Mask value out of range");
size = ver * 4 + 17;
size_t sz = static_cast<size_t>(size);
modules = vector<vector<bool> >(sz, vector<bool>(sz)); // Initially all light
isFunction = vector<vector<bool> >(sz, vector<bool>(sz));
// Compute ECC, draw modules
drawFunctionPatterns();
const vector<uint8_t> allCodewords = addEccAndInterleave(dataCodewords);
drawCodewords(allCodewords);
// Do masking
if (msk == -1) { // Automatically choose best mask
long minPenalty = LONG_MAX;
for (int i = 0; i < 8; i++) {
applyMask(i);
drawFormatBits(i);
long penalty = getPenaltyScore();
if (penalty < minPenalty) {
msk = i;
minPenalty = penalty;
}
applyMask(i); // Undoes the mask due to XOR
}
}
assert(0 <= msk && msk <= 7);
mask = msk;
applyMask(msk); // Apply the final choice of mask
drawFormatBits(msk); // Overwrite old format bits
isFunction.clear();
isFunction.shrink_to_fit();
}
int QrCode::getVersion() const {
return version;
}
int QrCode::getSize() const {
return size;
}
QrCode::Ecc QrCode::getErrorCorrectionLevel() const {
return errorCorrectionLevel;
}
int QrCode::getMask() const {
return mask;
}
bool QrCode::getModule(int x, int y) const {
return 0 <= x && x < size && 0 <= y && y < size && module(x, y);
}
void QrCode::drawFunctionPatterns() {
// Draw horizontal and vertical timing patterns
for (int i = 0; i < size; i++) {
setFunctionModule(6, i, i % 2 == 0);
setFunctionModule(i, 6, i % 2 == 0);
}
// Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules)
drawFinderPattern(3, 3);
drawFinderPattern(size - 4, 3);
drawFinderPattern(3, size - 4);
// Draw numerous alignment patterns
const vector<int> alignPatPos = getAlignmentPatternPositions();
size_t numAlign = alignPatPos.size();
for (size_t i = 0; i < numAlign; i++) {
for (size_t j = 0; j < numAlign; j++) {
// Don't draw on the three finder corners
if (!((i == 0 && j == 0) || (i == 0 && j == numAlign - 1) || (i == numAlign - 1 && j == 0)))
drawAlignmentPattern(alignPatPos.at(i), alignPatPos.at(j));
}
}
// Draw configuration data
drawFormatBits(0); // Dummy mask value; overwritten later in the constructor
drawVersion();
}
void QrCode::drawFormatBits(int msk) {
// Calculate error correction code and pack bits
int data = getFormatBits(errorCorrectionLevel) << 3 | msk; // errCorrLvl is uint2, msk is uint3
int rem = data;
for (int i = 0; i < 10; i++)
rem = (rem << 1) ^ ((rem >> 9) * 0x537);
int bits = (data << 10 | rem) ^ 0x5412; // uint15
assert(bits >> 15 == 0);
// Draw first copy
for (int i = 0; i <= 5; i++)
setFunctionModule(8, i, getBit(bits, i));
setFunctionModule(8, 7, getBit(bits, 6));
setFunctionModule(8, 8, getBit(bits, 7));
setFunctionModule(7, 8, getBit(bits, 8));
for (int i = 9; i < 15; i++)
setFunctionModule(14 - i, 8, getBit(bits, i));
// Draw second copy
for (int i = 0; i < 8; i++)
setFunctionModule(size - 1 - i, 8, getBit(bits, i));
for (int i = 8; i < 15; i++)
setFunctionModule(8, size - 15 + i, getBit(bits, i));
setFunctionModule(8, size - 8, true); // Always dark
}
void QrCode::drawVersion() {
if (version < 7)
return;
// Calculate error correction code and pack bits
int rem = version; // version is uint6, in the range [7, 40]
for (int i = 0; i < 12; i++)
rem = (rem << 1) ^ ((rem >> 11) * 0x1F25);
long bits = static_cast<long>(version) << 12 | rem; // uint18
assert(bits >> 18 == 0);
// Draw two copies
for (int i = 0; i < 18; i++) {
bool bit = getBit(bits, i);
int a = size - 11 + i % 3;
int b = i / 3;
setFunctionModule(a, b, bit);
setFunctionModule(b, a, bit);
}
}
void QrCode::drawFinderPattern(int x, int y) {
for (int dy = -4; dy <= 4; dy++) {
for (int dx = -4; dx <= 4; dx++) {
int dist = std::max(std::abs(dx), std::abs(dy)); // Chebyshev/infinity norm
int xx = x + dx, yy = y + dy;
if (0 <= xx && xx < size && 0 <= yy && yy < size)
setFunctionModule(xx, yy, dist != 2 && dist != 4);
}
}
}
void QrCode::drawAlignmentPattern(int x, int y) {
for (int dy = -2; dy <= 2; dy++) {
for (int dx = -2; dx <= 2; dx++)
setFunctionModule(x + dx, y + dy, std::max(std::abs(dx), std::abs(dy)) != 1);
}
}
void QrCode::setFunctionModule(int x, int y, bool isDark) {
size_t ux = static_cast<size_t>(x);
size_t uy = static_cast<size_t>(y);
modules .at(uy).at(ux) = isDark;
isFunction.at(uy).at(ux) = true;
}
bool QrCode::module(int x, int y) const {
return modules.at(static_cast<size_t>(y)).at(static_cast<size_t>(x));
}
vector<uint8_t> QrCode::addEccAndInterleave(const vector<uint8_t> &data) const {
if (data.size() != static_cast<unsigned int>(getNumDataCodewords(version, errorCorrectionLevel)))
throw std::invalid_argument("Invalid argument");
// Calculate parameter numbers
int numBlocks = NUM_ERROR_CORRECTION_BLOCKS[static_cast<int>(errorCorrectionLevel)][version];
int blockEccLen = ECC_CODEWORDS_PER_BLOCK [static_cast<int>(errorCorrectionLevel)][version];
int rawCodewords = getNumRawDataModules(version) / 8;
int numShortBlocks = numBlocks - rawCodewords % numBlocks;
int shortBlockLen = rawCodewords / numBlocks;
// Split data into blocks and append ECC to each block
vector<vector<uint8_t> > blocks;
const vector<uint8_t> rsDiv = reedSolomonComputeDivisor(blockEccLen);
for (int i = 0, k = 0; i < numBlocks; i++) {
vector<uint8_t> dat(data.cbegin() + k, data.cbegin() + (k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1)));
k += static_cast<int>(dat.size());
const vector<uint8_t> ecc = reedSolomonComputeRemainder(dat, rsDiv);
if (i < numShortBlocks)
dat.push_back(0);
dat.insert(dat.end(), ecc.cbegin(), ecc.cend());
blocks.push_back(std::move(dat));
}
// Interleave (not concatenate) the bytes from every block into a single sequence
vector<uint8_t> result;
for (size_t i = 0; i < blocks.at(0).size(); i++) {
for (size_t j = 0; j < blocks.size(); j++) {
// Skip the padding byte in short blocks
if (i != static_cast<unsigned int>(shortBlockLen - blockEccLen) || j >= static_cast<unsigned int>(numShortBlocks))
result.push_back(blocks.at(j).at(i));
}
}
assert(result.size() == static_cast<unsigned int>(rawCodewords));
return result;
}
void QrCode::drawCodewords(const vector<uint8_t> &data) {
if (data.size() != static_cast<unsigned int>(getNumRawDataModules(version) / 8))
throw std::invalid_argument("Invalid argument");
size_t i = 0; // Bit index into the data
// Do the funny zigzag scan
for (int right = size - 1; right >= 1; right -= 2) { // Index of right column in each column pair
if (right == 6)
right = 5;
for (int vert = 0; vert < size; vert++) { // Vertical counter
for (int j = 0; j < 2; j++) {
size_t x = static_cast<size_t>(right - j); // Actual x coordinate
bool upward = ((right + 1) & 2) == 0;
size_t y = static_cast<size_t>(upward ? size - 1 - vert : vert); // Actual y coordinate
if (!isFunction.at(y).at(x) && i < data.size() * 8) {
modules.at(y).at(x) = getBit(data.at(i >> 3), 7 - static_cast<int>(i & 7));
i++;
}
// If this QR Code has any remainder bits (0 to 7), they were assigned as
// 0/false/light by the constructor and are left unchanged by this method
}
}
}
assert(i == data.size() * 8);
}
void QrCode::applyMask(int msk) {
if (msk < 0 || msk > 7)
throw std::domain_error("Mask value out of range");
size_t sz = static_cast<size_t>(size);
for (size_t y = 0; y < sz; y++) {
for (size_t x = 0; x < sz; x++) {
bool invert;
switch (msk) {
case 0: invert = (x + y) % 2 == 0; break;
case 1: invert = y % 2 == 0; break;
case 2: invert = x % 3 == 0; break;
case 3: invert = (x + y) % 3 == 0; break;
case 4: invert = (x / 3 + y / 2) % 2 == 0; break;
case 5: invert = x * y % 2 + x * y % 3 == 0; break;
case 6: invert = (x * y % 2 + x * y % 3) % 2 == 0; break;
case 7: invert = ((x + y) % 2 + x * y % 3) % 2 == 0; break;
default: throw std::logic_error("Unreachable");
}
modules.at(y).at(x) = modules.at(y).at(x) ^ (invert & !isFunction.at(y).at(x));
}
}
}
long QrCode::getPenaltyScore() const {
long result = 0;
// Adjacent modules in row having same color, and finder-like patterns
for (int y = 0; y < size; y++) {
bool runColor = false;
int runX = 0;
std::array<int,7> runHistory = {};
for (int x = 0; x < size; x++) {
if (module(x, y) == runColor) {
runX++;
if (runX == 5)
result += PENALTY_N1;
else if (runX > 5)
result++;
} else {
finderPenaltyAddHistory(runX, runHistory);
if (!runColor)
result += finderPenaltyCountPatterns(runHistory) * PENALTY_N3;
runColor = module(x, y);
runX = 1;
}
}
result += finderPenaltyTerminateAndCount(runColor, runX, runHistory) * PENALTY_N3;
}
// Adjacent modules in column having same color, and finder-like patterns
for (int x = 0; x < size; x++) {
bool runColor = false;
int runY = 0;
std::array<int,7> runHistory = {};
for (int y = 0; y < size; y++) {
if (module(x, y) == runColor) {
runY++;
if (runY == 5)
result += PENALTY_N1;
else if (runY > 5)
result++;
} else {
finderPenaltyAddHistory(runY, runHistory);
if (!runColor)
result += finderPenaltyCountPatterns(runHistory) * PENALTY_N3;
runColor = module(x, y);
runY = 1;
}
}
result += finderPenaltyTerminateAndCount(runColor, runY, runHistory) * PENALTY_N3;
}
// 2*2 blocks of modules having same color
for (int y = 0; y < size - 1; y++) {
for (int x = 0; x < size - 1; x++) {
bool color = module(x, y);
if ( color == module(x + 1, y) &&
color == module(x, y + 1) &&
color == module(x + 1, y + 1))
result += PENALTY_N2;
}
}
// Balance of dark and light modules
int dark = 0;
for (const vector<bool> &row : modules) {
for (bool color : row) {
if (color)
dark++;
}
}
int total = size * size; // Note that size is odd, so dark/total != 1/2
// Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)%
int k = static_cast<int>((std::abs(dark * 20L - total * 10L) + total - 1) / total) - 1;
assert(0 <= k && k <= 9);
result += k * PENALTY_N4;
assert(0 <= result && result <= 2568888L); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4
return result;
}
vector<int> QrCode::getAlignmentPatternPositions() const {
if (version == 1)
return vector<int>();
else {
int numAlign = version / 7 + 2;
int step = (version * 8 + numAlign * 3 + 5) / (numAlign * 4 - 4) * 2;
vector<int> result;
for (int i = 0, pos = size - 7; i < numAlign - 1; i++, pos -= step)
result.insert(result.begin(), pos);
result.insert(result.begin(), 6);
return result;
}
}
int QrCode::getNumRawDataModules(int ver) {
if (ver < MIN_VERSION || ver > MAX_VERSION)
throw std::domain_error("Version number out of range");
int result = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
int numAlign = ver / 7 + 2;
result -= (25 * numAlign - 10) * numAlign - 55;
if (ver >= 7)
result -= 36;
}
assert(208 <= result && result <= 29648);
return result;
}
int QrCode::getNumDataCodewords(int ver, Ecc ecl) {
return getNumRawDataModules(ver) / 8
- ECC_CODEWORDS_PER_BLOCK [static_cast<int>(ecl)][ver]
* NUM_ERROR_CORRECTION_BLOCKS[static_cast<int>(ecl)][ver];
}
vector<uint8_t> QrCode::reedSolomonComputeDivisor(int degree) {
if (degree < 1 || degree > 255)
throw std::domain_error("Degree out of range");
// Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
// For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array {255, 8, 93}.
vector<uint8_t> result(static_cast<size_t>(degree));
result.at(result.size() - 1) = 1; // Start off with the monomial x^0
// Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}),
// and drop the highest monomial term which is always 1x^degree.
// Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D).
uint8_t root = 1;
for (int i = 0; i < degree; i++) {
// Multiply the current product by (x - r^i)
for (size_t j = 0; j < result.size(); j++) {
result.at(j) = reedSolomonMultiply(result.at(j), root);
if (j + 1 < result.size())
result.at(j) ^= result.at(j + 1);
}
root = reedSolomonMultiply(root, 0x02);
}
return result;
}
vector<uint8_t> QrCode::reedSolomonComputeRemainder(const vector<uint8_t> &data, const vector<uint8_t> &divisor) {
vector<uint8_t> result(divisor.size());
for (uint8_t b : data) { // Polynomial division
uint8_t factor = b ^ result.at(0);
result.erase(result.begin());
result.push_back(0);
for (size_t i = 0; i < result.size(); i++)
result.at(i) ^= reedSolomonMultiply(divisor.at(i), factor);
}
return result;
}
uint8_t QrCode::reedSolomonMultiply(uint8_t x, uint8_t y) {
// Russian peasant multiplication
int z = 0;
for (int i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >> 7) * 0x11D);
z ^= ((y >> i) & 1) * x;
}
assert(z >> 8 == 0);
return static_cast<uint8_t>(z);
}
int QrCode::finderPenaltyCountPatterns(const std::array<int,7> &runHistory) const {
int n = runHistory.at(1);
assert(n <= size * 3);
bool core = n > 0 && runHistory.at(2) == n && runHistory.at(3) == n * 3 && runHistory.at(4) == n && runHistory.at(5) == n;
return (core && runHistory.at(0) >= n * 4 && runHistory.at(6) >= n ? 1 : 0)
+ (core && runHistory.at(6) >= n * 4 && runHistory.at(0) >= n ? 1 : 0);
}
int QrCode::finderPenaltyTerminateAndCount(bool currentRunColor, int currentRunLength, std::array<int,7> &runHistory) const {
if (currentRunColor) { // Terminate dark run
finderPenaltyAddHistory(currentRunLength, runHistory);
currentRunLength = 0;
}
currentRunLength += size; // Add light border to final run
finderPenaltyAddHistory(currentRunLength, runHistory);
return finderPenaltyCountPatterns(runHistory);
}
void QrCode::finderPenaltyAddHistory(int currentRunLength, std::array<int,7> &runHistory) const {
if (runHistory.at(0) == 0)
currentRunLength += size; // Add light border to initial run
std::copy_backward(runHistory.cbegin(), runHistory.cend() - 1, runHistory.end());
runHistory.at(0) = currentRunLength;
}
bool QrCode::getBit(long x, int i) {
return ((x >> i) & 1) != 0;
}
/*---- Tables of constants ----*/
const int QrCode::PENALTY_N1 = 3;
const int QrCode::PENALTY_N2 = 3;
const int QrCode::PENALTY_N3 = 40;
const int QrCode::PENALTY_N4 = 10;
const int8_t QrCode::ECC_CODEWORDS_PER_BLOCK[4][41] = {
// Version: (note that index 0 is for padding, and is set to an illegal value)
//0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
{-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // Low
{-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28}, // Medium
{-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // Quartile
{-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30}, // High
};
const int8_t QrCode::NUM_ERROR_CORRECTION_BLOCKS[4][41] = {
// Version: (note that index 0 is for padding, and is set to an illegal value)
//0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
{-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25}, // Low
{-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49}, // Medium
{-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68}, // Quartile
{-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81}, // High
};
data_too_long::data_too_long(const std::string &msg) :
std::length_error(msg) {}
/*---- Class BitBuffer ----*/
BitBuffer::BitBuffer()
: std::vector<bool>() {}
void BitBuffer::appendBits(std::uint32_t val, int len) {
if (len < 0 || len > 31 || val >> len != 0)
throw std::domain_error("Value out of range");
for (int i = len - 1; i >= 0; i--) // Append bit by bit
this->push_back(((val >> i) & 1) != 0);
}
}

View File

@@ -0,0 +1,549 @@
/*
* QR Code generator library (C++)
*
* Copyright (c) Project Nayuki. (MIT License)
* https://www.nayuki.io/page/qr-code-generator-library
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
* - The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
* - The Software is provided "as is", without warranty of any kind, express or
* implied, including but not limited to the warranties of merchantability,
* fitness for a particular purpose and noninfringement. In no event shall the
* authors or copyright holders be liable for any claim, damages or other
* liability, whether in an action of contract, tort or otherwise, arising from,
* out of or in connection with the Software or the use or other dealings in the
* Software.
*/
#pragma once
#include <array>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <vector>
namespace qrcodegen {
/*
* A segment of character/binary/control data in a QR Code symbol.
* Instances of this class are immutable.
* The mid-level way to create a segment is to take the payload data
* and call a static factory function such as QrSegment::makeNumeric().
* The low-level way to create a segment is to custom-make the bit buffer
* and call the QrSegment() constructor with appropriate values.
* This segment class imposes no length restrictions, but QR Codes have restrictions.
* Even in the most favorable conditions, a QR Code can only hold 7089 characters of data.
* Any segment longer than this is meaningless for the purpose of generating QR Codes.
*/
class QrSegment final {
/*---- Public helper enumeration ----*/
/*
* Describes how a segment's data bits are interpreted. Immutable.
*/
public: class Mode final {
/*-- Constants --*/
public: static const Mode NUMERIC;
public: static const Mode ALPHANUMERIC;
public: static const Mode BYTE;
public: static const Mode KANJI;
public: static const Mode ECI;
/*-- Fields --*/
// The mode indicator bits, which is a uint4 value (range 0 to 15).
private: int modeBits;
// Number of character count bits for three different version ranges.
private: int numBitsCharCount[3];
/*-- Constructor --*/
private: Mode(int mode, int cc0, int cc1, int cc2);
/*-- Methods --*/
/*
* (Package-private) Returns the mode indicator bits, which is an unsigned 4-bit value (range 0 to 15).
*/
public: int getModeBits() const;
/*
* (Package-private) Returns the bit width of the character count field for a segment in
* this mode in a QR Code at the given version number. The result is in the range [0, 16].
*/
public: int numCharCountBits(int ver) const;
};
/*---- Static factory functions (mid level) ----*/
/*
* Returns a segment representing the given binary data encoded in
* byte mode. All input byte vectors are acceptable. Any text string
* can be converted to UTF-8 bytes and encoded as a byte mode segment.
*/
public: static QrSegment makeBytes(const std::vector<std::uint8_t> &data);
/*
* Returns a segment representing the given string of decimal digits encoded in numeric mode.
*/
public: static QrSegment makeNumeric(const char *digits);
/*
* Returns a segment representing the given text string encoded in alphanumeric mode.
* The characters allowed are: 0 to 9, A to Z (uppercase only), space,
* dollar, percent, asterisk, plus, hyphen, period, slash, colon.
*/
public: static QrSegment makeAlphanumeric(const char *text);
/*
* Returns a list of zero or more segments to represent the given text string. The result
* may use various segment modes and switch modes to optimize the length of the bit stream.
*/
public: static std::vector<QrSegment> makeSegments(const char *text);
/*
* Returns a segment representing an Extended Channel Interpretation
* (ECI) designator with the given assignment value.
*/
public: static QrSegment makeEci(long assignVal);
/*---- Public static helper functions ----*/
/*
* Tests whether the given string can be encoded as a segment in numeric mode.
* A string is encodable iff each character is in the range 0 to 9.
*/
public: static bool isNumeric(const char *text);
/*
* Tests whether the given string can be encoded as a segment in alphanumeric mode.
* A string is encodable iff each character is in the following set: 0 to 9, A to Z
* (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon.
*/
public: static bool isAlphanumeric(const char *text);
/*---- Instance fields ----*/
/* The mode indicator of this segment. Accessed through getMode(). */
private: const Mode *mode;
/* The length of this segment's unencoded data. Measured in characters for
* numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode.
* Always zero or positive. Not the same as the data's bit length.
* Accessed through getNumChars(). */
private: int numChars;
/* The data bits of this segment. Accessed through getData(). */
private: std::vector<bool> data;
/*---- Constructors (low level) ----*/
/*
* Creates a new QR Code segment with the given attributes and data.
* The character count (numCh) must agree with the mode and the bit buffer length,
* but the constraint isn't checked. The given bit buffer is copied and stored.
*/
public: QrSegment(const Mode &md, int numCh, const std::vector<bool> &dt);
/*
* Creates a new QR Code segment with the given parameters and data.
* The character count (numCh) must agree with the mode and the bit buffer length,
* but the constraint isn't checked. The given bit buffer is moved and stored.
*/
public: QrSegment(const Mode &md, int numCh, std::vector<bool> &&dt);
/*---- Methods ----*/
/*
* Returns the mode field of this segment.
*/
public: const Mode &getMode() const;
/*
* Returns the character count field of this segment.
*/
public: int getNumChars() const;
/*
* Returns the data bits of this segment.
*/
public: const std::vector<bool> &getData() const;
// (Package-private) Calculates the number of bits needed to encode the given segments at
// the given version. Returns a non-negative number if successful. Otherwise returns -1 if a
// segment has too many characters to fit its length field, or the total bits exceeds INT_MAX.
public: static int getTotalBits(const std::vector<QrSegment> &segs, int version);
/*---- Private constant ----*/
/* The set of all legal characters in alphanumeric mode, where
* each character value maps to the index in the string. */
private: static const char *ALPHANUMERIC_CHARSET;
};
/*
* A QR Code symbol, which is a type of two-dimension barcode.
* Invented by Denso Wave and described in the ISO/IEC 18004 standard.
* Instances of this class represent an immutable square grid of dark and light cells.
* The class provides static factory functions to create a QR Code from text or binary data.
* The class covers the QR Code Model 2 specification, supporting all versions (sizes)
* from 1 to 40, all 4 error correction levels, and 4 character encoding modes.
*
* Ways to create a QR Code object:
* - High level: Take the payload data and call QrCode::encodeText() or QrCode::encodeBinary().
* - Mid level: Custom-make the list of segments and call QrCode::encodeSegments().
* - Low level: Custom-make the array of data codeword bytes (including
* segment headers and final padding, excluding error correction codewords),
* supply the appropriate version number, and call the QrCode() constructor.
* (Note that all ways require supplying the desired error correction level.)
*/
class QrCode final {
/*---- Public helper enumeration ----*/
/*
* The error correction level in a QR Code symbol.
*/
public: enum class Ecc {
LOW = 0 , // The QR Code can tolerate about 7% erroneous codewords
MEDIUM , // The QR Code can tolerate about 15% erroneous codewords
QUARTILE, // The QR Code can tolerate about 25% erroneous codewords
HIGH , // The QR Code can tolerate about 30% erroneous codewords
};
// Returns a value in the range 0 to 3 (unsigned 2-bit integer).
private: static int getFormatBits(Ecc ecl);
/*---- Static factory functions (high level) ----*/
/*
* Returns a QR Code representing the given Unicode text string at the given error correction level.
* As a conservative upper bound, this function is guaranteed to succeed for strings that have 2953 or fewer
* UTF-8 code units (not Unicode code points) if the low error correction level is used. The smallest possible
* QR Code version is automatically chosen for the output. The ECC level of the result may be higher than
* the ecl argument if it can be done without increasing the version.
*/
public: static QrCode encodeText(const char *text, Ecc ecl);
/*
* Returns a QR Code representing the given binary data at the given error correction level.
* This function always encodes using the binary segment mode, not any text mode. The maximum number of
* bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output.
* The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version.
*/
public: static QrCode encodeBinary(const std::vector<std::uint8_t> &data, Ecc ecl);
/*---- Static factory functions (mid level) ----*/
/*
* Returns a QR Code representing the given segments with the given encoding parameters.
* The smallest possible QR Code version within the given range is automatically
* chosen for the output. Iff boostEcl is true, then the ECC level of the result
* may be higher than the ecl argument if it can be done without increasing the
* version. The mask number is either between 0 to 7 (inclusive) to force that
* mask, or -1 to automatically choose an appropriate mask (which may be slow).
* This function allows the user to create a custom sequence of segments that switches
* between modes (such as alphanumeric and byte) to encode text in less space.
* This is a mid-level API; the high-level API is encodeText() and encodeBinary().
*/
public: static QrCode encodeSegments(const std::vector<QrSegment> &segs, Ecc ecl,
int minVersion=1, int maxVersion=40, int mask=-1, bool boostEcl=true); // All optional parameters
/*---- Instance fields ----*/
// Immutable scalar parameters:
/* The version number of this QR Code, which is between 1 and 40 (inclusive).
* This determines the size of this barcode. */
private: int version;
/* The width and height of this QR Code, measured in modules, between
* 21 and 177 (inclusive). This is equal to version * 4 + 17. */
private: int size;
/* The error correction level used in this QR Code. */
private: Ecc errorCorrectionLevel;
/* The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive).
* Even if a QR Code is created with automatic masking requested (mask = -1),
* the resulting object still has a mask value between 0 and 7. */
private: int mask;
// Private grids of modules/pixels, with dimensions of size*size:
// The modules of this QR Code (false = light, true = dark).
// Immutable after constructor finishes. Accessed through getModule().
private: std::vector<std::vector<bool> > modules;
// Indicates function modules that are not subjected to masking. Discarded when constructor finishes.
private: std::vector<std::vector<bool> > isFunction;
/*---- Constructor (low level) ----*/
/*
* Creates a new QR Code with the given version number,
* error correction level, data codeword bytes, and mask number.
* This is a low-level API that most users should not use directly.
* A mid-level API is the encodeSegments() function.
*/
public: QrCode(int ver, Ecc ecl, const std::vector<std::uint8_t> &dataCodewords, int msk);
/*---- Public instance methods ----*/
/*
* Returns this QR Code's version, in the range [1, 40].
*/
public: int getVersion() const;
/*
* Returns this QR Code's size, in the range [21, 177].
*/
public: int getSize() const;
/*
* Returns this QR Code's error correction level.
*/
public: Ecc getErrorCorrectionLevel() const;
/*
* Returns this QR Code's mask, in the range [0, 7].
*/
public: int getMask() const;
/*
* Returns the color of the module (pixel) at the given coordinates, which is false
* for light or true for dark. The top left corner has the coordinates (x=0, y=0).
* If the given coordinates are out of bounds, then false (light) is returned.
*/
public: bool getModule(int x, int y) const;
/*---- Private helper methods for constructor: Drawing function modules ----*/
// Reads this object's version field, and draws and marks all function modules.
private: void drawFunctionPatterns();
// Draws two copies of the format bits (with its own error correction code)
// based on the given mask and this object's error correction level field.
private: void drawFormatBits(int msk);
// Draws two copies of the version bits (with its own error correction code),
// based on this object's version field, iff 7 <= version <= 40.
private: void drawVersion();
// Draws a 9*9 finder pattern including the border separator,
// with the center module at (x, y). Modules can be out of bounds.
private: void drawFinderPattern(int x, int y);
// Draws a 5*5 alignment pattern, with the center module
// at (x, y). All modules must be in bounds.
private: void drawAlignmentPattern(int x, int y);
// Sets the color of a module and marks it as a function module.
// Only used by the constructor. Coordinates must be in bounds.
private: void setFunctionModule(int x, int y, bool isDark);
// Returns the color of the module at the given coordinates, which must be in range.
private: bool module(int x, int y) const;
/*---- Private helper methods for constructor: Codewords and masking ----*/
// Returns a new byte string representing the given data with the appropriate error correction
// codewords appended to it, based on this object's version and error correction level.
private: std::vector<std::uint8_t> addEccAndInterleave(const std::vector<std::uint8_t> &data) const;
// Draws the given sequence of 8-bit codewords (data and error correction) onto the entire
// data area of this QR Code. Function modules need to be marked off before this is called.
private: void drawCodewords(const std::vector<std::uint8_t> &data);
// XORs the codeword modules in this QR Code with the given mask pattern.
// The function modules must be marked and the codeword bits must be drawn
// before masking. Due to the arithmetic of XOR, calling applyMask() with
// the same mask value a second time will undo the mask. A final well-formed
// QR Code needs exactly one (not zero, two, etc.) mask applied.
private: void applyMask(int msk);
// Calculates and returns the penalty score based on state of this QR Code's current modules.
// This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score.
private: long getPenaltyScore() const;
/*---- Private helper functions ----*/
// Returns an ascending list of positions of alignment patterns for this version number.
// Each position is in the range [0,177), and are used on both the x and y axes.
// This could be implemented as lookup table of 40 variable-length lists of unsigned bytes.
private: std::vector<int> getAlignmentPatternPositions() const;
// Returns the number of data bits that can be stored in a QR Code of the given version number, after
// all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
// The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table.
private: static int getNumRawDataModules(int ver);
// Returns the number of 8-bit data (i.e. not error correction) codewords contained in any
// QR Code of the given version number and error correction level, with remainder bits discarded.
// This stateless pure function could be implemented as a (40*4)-cell lookup table.
private: static int getNumDataCodewords(int ver, Ecc ecl);
// Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be
// implemented as a lookup table over all possible parameter values, instead of as an algorithm.
private: static std::vector<std::uint8_t> reedSolomonComputeDivisor(int degree);
// Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials.
private: static std::vector<std::uint8_t> reedSolomonComputeRemainder(const std::vector<std::uint8_t> &data, const std::vector<std::uint8_t> &divisor);
// Returns the product of the two given field elements modulo GF(2^8/0x11D).
// All inputs are valid. This could be implemented as a 256*256 lookup table.
private: static std::uint8_t reedSolomonMultiply(std::uint8_t x, std::uint8_t y);
// Can only be called immediately after a light run is added, and
// returns either 0, 1, or 2. A helper function for getPenaltyScore().
private: int finderPenaltyCountPatterns(const std::array<int,7> &runHistory) const;
// Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore().
private: int finderPenaltyTerminateAndCount(bool currentRunColor, int currentRunLength, std::array<int,7> &runHistory) const;
// Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore().
private: void finderPenaltyAddHistory(int currentRunLength, std::array<int,7> &runHistory) const;
// Returns true iff the i'th bit of x is set to 1.
private: static bool getBit(long x, int i);
/*---- Constants and tables ----*/
// The minimum version number supported in the QR Code Model 2 standard.
public: static constexpr int MIN_VERSION = 1;
// The maximum version number supported in the QR Code Model 2 standard.
public: static constexpr int MAX_VERSION = 40;
// For use in getPenaltyScore(), when evaluating which mask is best.
private: static const int PENALTY_N1;
private: static const int PENALTY_N2;
private: static const int PENALTY_N3;
private: static const int PENALTY_N4;
private: static const std::int8_t ECC_CODEWORDS_PER_BLOCK[4][41];
private: static const std::int8_t NUM_ERROR_CORRECTION_BLOCKS[4][41];
};
/*---- Public exception class ----*/
/*
* Thrown when the supplied data does not fit any QR Code version. Ways to handle this exception include:
* - Decrease the error correction level if it was greater than Ecc::LOW.
* - If the encodeSegments() function was called with a maxVersion argument, then increase
* it if it was less than QrCode::MAX_VERSION. (This advice does not apply to the other
* factory functions because they search all versions up to QrCode::MAX_VERSION.)
* - Split the text data into better or optimal segments in order to reduce the number of bits required.
* - Change the text or binary data to be shorter.
* - Change the text to fit the character set of a particular segment mode (e.g. alphanumeric).
* - Propagate the error upward to the caller/user.
*/
class data_too_long : public std::length_error {
public: explicit data_too_long(const std::string &msg);
};
/*
* An appendable sequence of bits (0s and 1s). Mainly used by QrSegment.
*/
class BitBuffer final : public std::vector<bool> {
/*---- Constructor ----*/
// Creates an empty bit buffer (length 0).
public: BitBuffer();
/*---- Method ----*/
// Appends the given number of low-order bits of the given value
// to this buffer. Requires 0 <= len <= 31 and val < 2^len.
public: void appendBits(std::uint32_t val, int len);
};
}

214
proximity_keys.py Normal file
View File

@@ -0,0 +1,214 @@
#!/usr/bin/env python3
# Needs https://github.com/google/bumble on Windows
# See https://github.com/google/bumble/blob/main/docs/mkdocs/src/platforms/windows.md for usage.
# You need to associate WinUSB with your Bluetooth interface. Once done, you can roll back to the original driver from Device Manager.
import sys
import asyncio
import argparse
import logging
import platform
from typing import Any, Optional
from colorama import Fore, Style, init as colorama_init
colorama_init(autoreset=True)
handler = logging.StreamHandler()
class ColorFormatter(logging.Formatter):
COLORS = {
logging.DEBUG: Fore.BLUE,
logging.INFO: Fore.GREEN,
logging.WARNING: Fore.YELLOW,
logging.ERROR: Fore.RED,
logging.CRITICAL: Fore.MAGENTA,
}
def format(self, record):
color = self.COLORS.get(record.levelno, "")
prefix = f"{color}[{record.levelname}:{record.name}]{Style.RESET_ALL}"
return f"{prefix} {record.getMessage()}"
handler.setFormatter(ColorFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
logger = logging.getLogger("proximitykeys")
PROXIMITY_KEY_TYPES = {0x01: "IRK", 0x04: "ENC_KEY"}
def parse_proximity_keys_response(data: bytes):
if len(data) < 7 or data[4] != 0x31:
return None
key_count = data[6]
keys = []
offset = 7
for _ in range(key_count):
if offset + 3 >= len(data):
break
key_type = data[offset]
key_length = data[offset + 2]
offset += 4
if offset + key_length > len(data):
break
key_bytes = data[offset:offset + key_length]
keys.append((PROXIMITY_KEY_TYPES.get(key_type, f"TYPE_{key_type:02X}"), key_bytes))
offset += key_length
return keys
def hexdump(data: bytes) -> str:
return " ".join(f"{b:02X}" for b in data)
async def run_bumble(bdaddr: str):
try:
from bumble.l2cap import ClassicChannelSpec
from bumble.transport import open_transport
from bumble.device import Device
from bumble.host import Host
from bumble.core import PhysicalTransport
from bumble.pairing import PairingConfig, PairingDelegate
from bumble.hci import HCI_Error
except ImportError:
logger.error("Bumble not installed")
return 1
PSM_PROXIMITY = 0x1001
HANDSHAKE = bytes.fromhex("00 00 04 00 01 00 02 00 00 00 00 00 00 00 00 00")
KEY_REQ = bytes.fromhex("04 00 04 00 30 00 05 00")
class KeyStore:
async def delete(self, name: str): pass
async def update(self, name: str, keys: Any): pass
async def get(self, _name: str) -> Optional[Any]: return None
async def get_all(self): return []
async def get_resolving_keys(self) -> list[tuple[bytes, Any]]:
all_keys = await self.get_all()
resolving_keys = []
for name, keys in all_keys:
if getattr(keys, "irk", None) is not None:
resolving_keys.append((
keys.irk.value,
getattr(keys, "address", "UNKNOWN")
))
return resolving_keys
async def exchange_keys(channel, timeout=5.0):
recv_q: asyncio.Queue = asyncio.Queue()
channel.sink = lambda sdu: recv_q.put_nowait(sdu)
logger.info("Sending handshake packet...")
channel.send_pdu(HANDSHAKE)
await asyncio.sleep(0.5)
logger.info("Sending key request packet...")
channel.send_pdu(KEY_REQ)
while True:
try:
pkt = await asyncio.wait_for(recv_q.get(), timeout)
except asyncio.TimeoutError:
logger.error("Timed out waiting for SDU response")
return None
logger.debug("Received SDU (%d bytes): %s", len(pkt), hexdump(pkt))
keys = parse_proximity_keys_response(pkt)
if keys:
return keys
async def get_device():
logger.info("Opening transport...")
transport = await open_transport("usb:0")
device = Device(host=Host(controller_source=transport.source, controller_sink=transport.sink))
device.classic_enabled = True
device.le_enabled = False
device.keystore = KeyStore()
device.pairing_config_factory = lambda conn: PairingConfig(
sc=True, mitm=False, bonding=True,
delegate=PairingDelegate(io_capability=PairingDelegate.NO_OUTPUT_NO_INPUT)
)
await device.power_on()
logger.info("Device powered on")
return transport, device
async def create_channel_and_exchange(conn):
spec = ClassicChannelSpec(psm=PSM_PROXIMITY, mtu=2048)
logger.info("Requesting L2CAP channel on PSM = 0x%04X", spec.psm)
if not conn.is_encrypted:
logger.info("Enabling link encryption...")
await conn.encrypt()
await asyncio.sleep(0.05)
channel = await conn.create_l2cap_channel(spec=spec)
keys = await exchange_keys(channel, timeout=8.0)
if not keys:
logger.warning("No proximity keys found")
return
logger.info("Keys successfully retrieved")
print(f"{Fore.CYAN}{Style.BRIGHT}Proximity Keys:{Style.RESET_ALL}")
for name, key_bytes in keys:
print(f" {Fore.MAGENTA}{name}{Style.RESET_ALL}: {hexdump(key_bytes)}")
transport, device = await get_device()
logger.info("Connecting to %s (BR/EDR)...", bdaddr)
try:
connection = await device.connect(bdaddr, PhysicalTransport.BR_EDR)
logger.info("Connected to %s (handle %s)", connection.peer_address, connection.handle)
logger.info("Authenticating...")
await connection.authenticate()
if not connection.is_encrypted:
logger.info("Encrypting link...")
await connection.encrypt()
await create_channel_and_exchange(connection)
except HCI_Error as e:
if "PAIRING_NOT_ALLOWED_ERROR" in str(e):
logger.error("Put your device into pairing mode and run the script again")
else:
logger.error("HCI error: %s", e)
except Exception as e:
logger.error("Unexpected error: %s", e)
finally:
if hasattr(transport, "close"):
logger.info("Closing transport...")
await transport.close()
logger.info("Transport closed")
return 0
def run_linux(bdaddr: str):
import socket
PSM = 0x1001
handshake = bytes.fromhex("00 00 04 00 01 00 02 00 00 00 00 00 00 00 00 00")
key_req = bytes.fromhex("04 00 04 00 30 00 05 00")
logger.info("Connecting to %s (L2CAP)...", bdaddr)
sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_SEQPACKET, socket.BTPROTO_L2CAP)
try:
sock.connect((bdaddr, PSM))
logger.info("Connected, sending handshake and key request...")
sock.send(handshake)
sock.send(key_req)
while True:
pkt = sock.recv(1024)
logger.debug("Received packet (%d bytes): %s", len(pkt), hexdump(pkt))
keys = parse_proximity_keys_response(pkt)
if keys:
logger.info("Keys successfully retrieved")
print(f"{Fore.CYAN}{Style.BRIGHT}Proximity Keys:{Style.RESET_ALL}")
for name, key_bytes in keys:
print(f" {Fore.MAGENTA}{name}{Style.RESET_ALL}: {hexdump(key_bytes)}")
break
else:
logger.warning("Received packet did not contain keys, waiting...")
except Exception as e:
logger.error("Error during L2CAP exchange: %s", e)
finally:
sock.close()
logger.info("Connection closed")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("bdaddr")
parser.add_argument("--debug", action="store_true")
parser.add_argument("--bumble", action="store_true")
args = parser.parse_args()
logging.getLogger().setLevel(logging.DEBUG if args.debug else logging.INFO)
if args.bumble or platform.system() == "Windows":
asyncio.run(run_bumble(args.bdaddr))
else:
run_linux(args.bdaddr)
if __name__ == "__main__":
main()