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 ## LibrePods root module changelog
- ([#34](https://github.com/kavishdevar/librepods/pull/34)) @devnoname120 Add on-device libbluetooth patcher using a Magisk/KernelSU module (arm64-only) _[See here](https://github.com/kavishdevar/librepods/releases)_
_[See more 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 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) [![GitHub contributors](https://img.shields.io/github/contributors/kavishdevar/librepods)](https://github.com/kavishdevar/librepods/graphs/contributors)
## What is LibrePods? ## 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. 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) | | ![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) | | ![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) | | ![Long Press Configuration](/android/imgs/long-press.png) | ![Widget](/android/imgs/widget.png) | ![Customizations 1](/android/imgs/customizations-1.png) |
| ![audio-popup](/android/imgs/audio-connected-island.png) | | | | ![Customizations 2](/android/imgs/customizations-2.png) | ![audio-popup](/android/imgs/audio-connected-island.png) | |
#### Root Requirement #### 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 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/>. 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" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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>
<activity <activity

View File

@@ -303,15 +303,15 @@ uintptr_t getModuleBase(const char *module_name) {
return base_addr; return base_addr;
} }
bool findAndHookFunction([[maybe_unused]] const char *library_path) { bool findAndHookFunction(const char *library_name) {
if (!hook_func) { if (!hook_func) {
LOGE("Hook function not initialized"); LOGE("Hook function not initialized");
return false; return false;
} }
uintptr_t base_addr = getModuleBase("libbluetooth_jni.so"); uintptr_t base_addr = getModuleBase(library_name);
if (!base_addr) { 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; 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) { void on_library_loaded(const char *name, [[maybe_unused]] void *handle) {
if (strstr(name, "libbluetooth_jni.so")) { 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) { 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; hook_func = entries->hook_func;
return on_library_loaded; return on_library_loaded;
} }

View File

@@ -34,6 +34,7 @@ import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.MultiplePermissionsState
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.AppSettingsScreen import me.kavishdevar.librepods.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.DebugScreen 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.screens.TroubleshootingScreen
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AirPodsNotifications
import me.kavishdevar.librepods.utils.CrossDevice import me.kavishdevar.librepods.utils.CrossDevice
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection lateinit var serviceConnection: ServiceConnection
@@ -140,6 +142,8 @@ class MainActivity : ComponentActivity() {
Main() Main()
} }
} }
handleIncomingIntent(intent)
} }
override fun onDestroy() { override fun onDestroy() {
@@ -174,6 +178,73 @@ class MainActivity : ComponentActivity() {
} }
super.onStop() 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") @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.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
import me.kavishdevar.librepods.composables.IconAreaSize
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
import me.kavishdevar.librepods.composables.IconAreaSize
import me.kavishdevar.librepods.composables.VerticalVolumeSlider 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.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager 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.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R 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.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 import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable

View File

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

View File

@@ -127,4 +127,4 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
@Composable @Composable
fun IndependentTogglePreview() { fun IndependentTogglePreview() {
IndependentToggle("Test", AirPodsService(), "test", LocalContext.current.getSharedPreferences("preview", 0), true) 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.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R 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.services.AirPodsService
import me.kavishdevar.librepods.utils.AACPManager 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.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt import kotlin.math.roundToInt

View File

@@ -18,6 +18,7 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.content.Context
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -57,6 +58,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.StemAction
@Composable @Composable
fun PressAndHoldSettings(navController: NavController) { fun PressAndHoldSettings(navController: NavController) {
@@ -70,6 +72,24 @@ fun PressAndHoldSettings(navController: NavController) {
val animatedLeftBackgroundColor by animateColorAsState(targetValue = leftBackgroundColor, animationSpec = animationSpec) val animatedLeftBackgroundColor by animateColorAsState(targetValue = leftBackgroundColor, animationSpec = animationSpec)
val animatedRightBackgroundColor by animateColorAsState(targetValue = rightBackgroundColor, 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(
text = stringResource(R.string.press_and_hold_airpods).uppercase(), text = stringResource(R.string.press_and_hold_airpods).uppercase(),
style = TextStyle( style = TextStyle(
@@ -122,7 +142,7 @@ fun PressAndHoldSettings(navController: NavController) {
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Text( Text(
text = stringResource(R.string.noise_control), text = leftActionText,
style = TextStyle( style = TextStyle(
fontSize = 18.sp, fontSize = 18.sp,
color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),
@@ -182,7 +202,7 @@ fun PressAndHoldSettings(navController: NavController) {
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Text( Text(
text = stringResource(R.string.noise_control), text = rightActionText,
style = TextStyle( style = TextStyle(
fontSize = 18.sp, fontSize = 18.sp,
color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),

View File

@@ -16,10 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.constants
@file:Suppress("unused")
package me.kavishdevar.librepods.utils
import android.os.Parcelable import android.os.Parcelable
import android.util.Log import android.util.Log
@@ -27,27 +24,10 @@ import kotlinx.parcelize.Parcelize
enum class Enums(val value: ByteArray) { enum class Enums(val value: ByteArray) {
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION), NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
CONVERSATION_AWARENESS(Capabilities.CONVERSATION_AWARENESS),
CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY(Capabilities.CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY),
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)), PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
SETTINGS(byteArrayOf(0x09, 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_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)), 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 { object BatteryComponent {
@@ -156,7 +136,7 @@ class AirPodsNotifications {
} }
val name: String = val name: String =
when (status) { when (status) {
1 -> "OFF" 1 -> "OFF"
2 -> "ON" 2 -> "ON"
3 -> "TRANSPARENCY" 3 -> "TRANSPARENCY"
@@ -251,103 +231,10 @@ class AirPodsNotifications {
class Capabilities { class Capabilities {
companion object { companion object {
val NOISE_CANCELLATION = byteArrayOf(0x0d) val NOISE_CANCELLATION = byteArrayOf(0x0d)
val CONVERSATION_AWARENESS = byteArrayOf(0x28)
val CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY = byteArrayOf(0x01, 0x02)
val EAR_DETECTION = byteArrayOf(0x06) 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 { fun isHeadTrackingData(data: ByteArray): Boolean {
if (data.size <= 60) return false 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.NavigationButton
import me.kavishdevar.librepods.composables.NoiseControlSettings import me.kavishdevar.librepods.composables.NoiseControlSettings
import me.kavishdevar.librepods.composables.PressAndHoldSettings import me.kavishdevar.librepods.composables.PressAndHoldSettings
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AirPodsNotifications
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -113,6 +113,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
var isLocallyConnected by remember { mutableStateOf(isConnected) } var isLocallyConnected by remember { mutableStateOf(isConnected) }
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) } var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val bleOnlyMode = sharedPreferences.getBoolean("ble_only_mode", false)
var device by remember { mutableStateOf(dev) } var device by remember { mutableStateOf(dev) }
var deviceName by remember { var deviceName by remember {
mutableStateOf( mutableStateOf(
@@ -329,35 +330,67 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
NameField( // Show BLE-only mode indicator
name = stringResource(R.string.name), if (bleOnlyMode) {
value = deviceName.text, Text(
navController = navController 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)) // Only show name field when not in BLE-only mode
NoiseControlSettings(service = service) if (!bleOnlyMode) {
NameField(
name = stringResource(R.string.name),
value = deviceName.text,
navController = navController
)
}
Spacer(modifier = Modifier.height(16.dp)) // Only show L2CAP-dependent features when not in BLE-only mode
Text( if (!bleOnlyMode) {
text = stringResource(R.string.head_gestures).uppercase(), Spacer(modifier = Modifier.height(32.dp))
style = TextStyle( NoiseControlSettings(service = service)
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(2.dp)) Spacer(modifier = Modifier.height(16.dp))
NavigationButton(to = "head_tracking", "Head Tracking", navController) 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)) Spacer(modifier = Modifier.height(2.dp))
PressAndHoldSettings(navController = navController) NavigationButton(to = "head_tracking", "Head Tracking", navController)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
AudioSettings() 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)) Spacer(modifier = Modifier.height(16.dp))
IndependentToggle( IndependentToggle(
@@ -365,23 +398,15 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
service = service, service = service,
functionName = "setEarDetection", functionName = "setEarDetection",
sharedPreferences = sharedPreferences, sharedPreferences = sharedPreferences,
default = true default = true,
) )
Spacer(modifier = Modifier.height(16.dp)) // Only show debug when not in BLE-only mode
IndependentToggle( if (!bleOnlyMode) {
name = "Off Listening Mode", Spacer(modifier = Modifier.height(16.dp))
service = service, NavigationButton("debug", "Debug", navController)
sharedPreferences = sharedPreferences, }
default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
)
Spacer(modifier = Modifier.height(16.dp))
AccessibilitySettings()
Spacer(modifier = Modifier.height(16.dp))
NavigationButton("debug", "Debug", navController)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
} }
} }

View File

@@ -179,6 +179,21 @@ fun AppSettingsScreen(navController: NavController) {
mutableStateOf(sharedPreferences.getBoolean("takeover_when_media_start", true)) 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) } var mDensity by remember { mutableFloatStateOf(0f) }
fun validateHexInput(input: String): Boolean { 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(
text = "Conversational Awareness".uppercase(), text = "Conversational Awareness".uppercase(),
style = TextStyle( 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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()

View File

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

View File

@@ -57,10 +57,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext 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.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily 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.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import kotlin.experimental.and 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LongPress(navController: NavController, name: String) { fun LongPress(navController: NavController, name: String) {
@@ -104,6 +114,10 @@ fun LongPress(navController: NavController, name: String) {
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val deviceName = sharedPreferences.getString("name", "AirPods Pro") 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( Scaffold(
topBar = { topBar = {
CenterAlignedTopAppBar( CenterAlignedTopAppBar(
@@ -153,56 +167,88 @@ fun LongPress(navController: NavController, name: String) {
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(top = 8.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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp)), .background(backgroundColor, RoundedCornerShape(14.dp)),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { LongPressActionElement(
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION name = "Noise Control",
}?.value?.takeIf { it.isNotEmpty() }?.get(0) selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
val offListeningMode = offListeningModeValue == 1.toByte() onClick = {
LongPressElement( longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES
name = "Off", sharedPreferences.edit().putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name).apply()
enabled = offListeningMode, },
resourceId = R.drawable.noise_cancellation, isFirst = true,
isFirst = true) isLast = false
if (offListeningMode) RightDivider() )
LongPressElement( RightDividerNoIcon()
name = "Transparency", LongPressActionElement(
resourceId = R.drawable.transparency, name = "Digital Assistant",
isFirst = !offListeningMode) selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
RightDivider() onClick = {
LongPressElement( longPressAction = StemAction.DIGITAL_ASSISTANT
name = "Adaptive", sharedPreferences.edit().putString(prefKey, StemAction.DIGITAL_ASSISTANT.name).apply()
resourceId = R.drawable.adaptive) },
RightDivider() isFirst = false,
LongPressElement( isLast = true
name = "Noise Cancellation", )
resourceId = R.drawable.noise_cancellation, }
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 { 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 horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Icon( Icon(
bitmap = ImageBitmap.imageResource(resourceId), painter = painterResource(resourceId),
contentDescription = "Icon", contentDescription = "Icon",
tint = Color(0xFF007AFF), tint = Color(0xFF007AFF),
modifier = Modifier 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 androidx.annotation.RequiresApi
import me.kavishdevar.librepods.QuickSettingsDialogActivity import me.kavishdevar.librepods.QuickSettingsDialogActivity
import me.kavishdevar.librepods.R 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.AACPManager
import me.kavishdevar.librepods.utils.AirPodsNotifications
import me.kavishdevar.librepods.utils.NoiseControlMode
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)

View File

@@ -77,12 +77,15 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import me.kavishdevar.librepods.MainActivity import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.R 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.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.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.BluetoothConnectionManager
import me.kavishdevar.librepods.utils.CrossDevice import me.kavishdevar.librepods.utils.CrossDevice
import me.kavishdevar.librepods.utils.CrossDevicePackets 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_CHARGING
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ICON 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.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
import me.kavishdevar.librepods.utils.isHeadTrackingData
import me.kavishdevar.librepods.widgets.BatteryWidget import me.kavishdevar.librepods.widgets.BatteryWidget
import me.kavishdevar.librepods.widgets.NoiseControlWidget import me.kavishdevar.librepods.widgets.NoiseControlWidget
import org.lsposed.hiddenapibypass.HiddenApiBypass import org.lsposed.hiddenapibypass.HiddenApiBypass
@@ -142,7 +144,7 @@ object ServiceManager {
class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener { class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener {
var macAddress = "" var macAddress = ""
lateinit var aacpManager: AACPManager lateinit var aacpManager: AACPManager
var cameraActive = false
data class ServiceConfig( data class ServiceConfig(
var deviceName: String = "AirPods", var deviceName: String = "AirPods",
var earDetectionEnabled: Boolean = true, var earDetectionEnabled: Boolean = true,
@@ -154,6 +156,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var conversationalAwarenessVolume: Int = 43, var conversationalAwarenessVolume: Int = 43,
var textColor: Long = -1L, var textColor: Long = -1L,
var qsClickBehavior: String = "cycle", var qsClickBehavior: String = "cycle",
var bleOnlyMode: Boolean = false,
// AirPods state-based takeover // AirPods state-based takeover
var takeoverWhenDisconnected: Boolean = true, var takeoverWhenDisconnected: Boolean = true,
@@ -163,7 +166,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// Phone state-based takeover // Phone state-based takeover
var takeoverWhenRingingCall: Boolean = true, 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 private lateinit var config: ServiceConfig
@@ -192,7 +207,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
device: BLEManager.AirPodsStatus, device: BLEManager.AirPodsStatus,
previousStatus: 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.") Log.d("AirPodsBLEService", "Seems no device has taken over, we will.")
val bluetoothManager = getSystemService(BluetoothManager::class.java) val bluetoothManager = getSystemService(BluetoothManager::class.java)
val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(sharedPreferences.getString( val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(sharedPreferences.getString(
@@ -259,7 +283,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
leftInEar: Boolean, leftInEar: Boolean,
rightInEar: 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) { override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
@@ -300,6 +329,67 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.registerOnSharedPreferenceChangeListener(this) 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 @ExperimentalEncodingApi
private fun initializeAACPManagerCallback() { private fun initializeAACPManagerCallback() {
aacpManager.setPacketCallback(object : AACPManager.PacketCallback { 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) { override fun onUnknownPacketReceived(packet: ByteArray) {
Log.d("AACPManager", "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}") 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) { private fun processEarDetectionChange(earDetection: ByteArray) {
var inEar = false var inEar = false
var inEarData = listOf(earDetectionNotification.status[0] == 0x00.toByte(), earDetectionNotification.status[1] == 0x00.toByte()) 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), conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43),
textColor = sharedPreferences.getLong("textColor", -1L), textColor = sharedPreferences.getLong("textColor", -1L),
qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle", qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle",
bleOnlyMode = sharedPreferences.getBoolean("ble_only_mode", false),
// AirPods state-based takeover // AirPods state-based takeover
takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", true), takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", true),
@@ -522,7 +659,20 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// Phone state-based takeover // Phone state-based takeover
takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", true), 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) "conversational_awareness_volume" -> config.conversationalAwarenessVolume = preferences.getInt(key, 43)
"textColor" -> config.textColor = preferences.getLong(key, -1L) "textColor" -> config.textColor = preferences.getLong(key, -1L)
"qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle" "qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle"
"ble_only_mode" -> config.bleOnlyMode = preferences.getBoolean(key, false)
// AirPods state-based takeover // AirPods state-based takeover
"takeover_when_disconnected" -> config.takeoverWhenDisconnected = preferences.getBoolean(key, true) "takeover_when_disconnected" -> config.takeoverWhenDisconnected = preferences.getBoolean(key, true)
@@ -554,6 +705,55 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// Phone state-based takeover // Phone state-based takeover
"takeover_when_ringing_call" -> config.takeoverWhenRingingCall = preferences.getBoolean(key, true) "takeover_when_ringing_call" -> config.takeoverWhenRingingCall = preferences.getBoolean(key, true)
"takeover_when_media_start" -> config.takeoverWhenMediaStart = 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") { if (key == "mac_address") {
@@ -1002,10 +1202,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
if (!::socket.isInitialized) { if (!::socket.isInitialized && !config.bleOnlyMode) {
return return
} }
if (connected && socket.isConnected) { if (connected && (config.bleOnlyMode || socket.isConnected)) {
updatedNotification = NotificationCompat.Builder(this, "airpods_connection_status") updatedNotification = NotificationCompat.Builder(this, "airpods_connection_status")
.setSmallIcon(R.drawable.airpods) .setSmallIcon(R.drawable.airpods)
.setContentTitle(airpodsName ?: config.deviceName) .setContentTitle(airpodsName ?: config.deviceName)
@@ -1057,7 +1257,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.notify(1, updatedNotification) notificationManager.notify(1, updatedNotification)
notificationManager.cancel(2) notificationManager.cancel(2)
} else if (!socket.isConnected && isConnectedLocally) { } else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) {
Log.d("AirPodsService", "<LogCollector:Complete:Failed> Socket not connected") Log.d("AirPodsService", "<LogCollector:Complete:Failed> Socket not connected")
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
} }
@@ -1068,7 +1268,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (isInCall) return if (isInCall) return
if (config.headGestures) { if (config.headGestures) {
initGestureDetector() initGestureDetector()
aacpManager.sendStartHeadTracking() startHeadTracking()
gestureDetector?.startDetection { accepted -> gestureDetector?.startDetection { accepted ->
if (accepted) { if (accepted) {
answerCall() answerCall()
@@ -1374,8 +1574,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val ancModeFilter = IntentFilter("me.kavishdevar.librepods.SET_ANC_MODE") val ancModeFilter = IntentFilter("me.kavishdevar.librepods.SET_ANC_MODE")
var ancModeReceiver: BroadcastReceiver? = null var ancModeReceiver: BroadcastReceiver? = null
@SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag") @SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("AirPodsService", "Service started") 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("qs_click_behavior")) editor.putString("qs_click_behavior", "cycle")
if (!contains("name")) editor.putString("name", "AirPods") 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() editor.apply()
} }
@@ -1575,7 +1791,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString()) Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
if (!CrossDevice.isAvailable) { if (!CrossDevice.isAvailable && !config.bleOnlyMode) {
Log.d("AirPodsService", "${config.deviceName} connected") Log.d("AirPodsService", "${config.deviceName} connected")
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device!!) connectToSocket(device!!)
@@ -1587,6 +1803,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.edit { sharedPreferences.edit {
putString("mac_address", macAddress) 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) { } else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
device = null device = null
@@ -1647,7 +1869,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (profile == BluetoothProfile.A2DP) { if (profile == BluetoothProfile.A2DP) {
val connectedDevices = proxy.connectedDevices val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) { if (connectedDevices.isNotEmpty()) {
if (!CrossDevice.isAvailable) { if (!CrossDevice.isAvailable && !config.bleOnlyMode) {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device) connectToSocket(device)
} }
@@ -1656,6 +1878,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.edit { sharedPreferences.edit {
putString("mac_address", macAddress) 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( this@AirPodsService.sendBroadcast(
Intent(AirPodsNotifications.AIRPODS_CONNECTED) Intent(AirPodsNotifications.AIRPODS_CONNECTED)
@@ -1760,14 +1988,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
if (device != null) { if (device != null) {
connectToSocket(device!!) if (config.bleOnlyMode) {
connectAudio(this, device) // 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!!), showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!),
IslandType.TAKING_OVER) IslandType.TAKING_OVER)
isConnectedLocally = true
CrossDevice.isAvailable = false CrossDevice.isAvailable = false
} }
@@ -1879,6 +2119,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
.putExtra("device", device) .putExtra("device", device)
) )
setupStemActions()
while (socket.isConnected == true) { while (socket.isConnected == true) {
socket.let { socket.let {
val buffer = ByteArray(1024) val buffer = ByteArray(1024)
@@ -2131,12 +2373,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun startHeadTracking() { fun startHeadTracking() {
isHeadTrackingActive = true 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() HeadTracking.reset()
} }
fun stopHeadTracking() { 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 isHeadTrackingActive = false
} }

View File

@@ -15,18 +15,21 @@
* You should have received a copy of the GNU Affero General Public License * 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/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
import android.util.Log import android.util.Log
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers.entries 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 import kotlin.io.encoding.ExperimentalEncodingApi
/** /**
* Manager class for Apple Accessory Communication Protocol (AACP) * Manager class for Apple Accessory Communication Protocol (AACP)
* This class is responsible for handling the L2CAP socket management, * 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 { class AACPManager {
companion object { companion object {
@@ -44,6 +47,7 @@ class AACPManager {
const val HEADTRACKING: Byte = 0x17 const val HEADTRACKING: Byte = 0x17
const val PROXIMITY_KEYS_REQ: Byte = 0x30 const val PROXIMITY_KEYS_REQ: Byte = 0x30
const val PROXIMITY_KEYS_RSP: Byte = 0x31 const val PROXIMITY_KEYS_RSP: Byte = 0x31
const val STEM_PRESS: Byte = 0x19
} }
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
@@ -101,8 +105,8 @@ class AACPManager {
IN_CASE_TONE_CONFIG(0x31), IN_CASE_TONE_CONFIG(0x31),
SIRI_MULTITONE_CONFIG(0x32), SIRI_MULTITONE_CONFIG(0x32),
HEARING_ASSIST_CONFIG(0x33), HEARING_ASSIST_CONFIG(0x33),
ALLOW_OFF_OPTION(0x34); ALLOW_OFF_OPTION(0x34),
STEM_CONFIG(0x39);
companion object { companion object {
fun fromByte(byte: Byte): ControlCommandIdentifiers? = fun fromByte(byte: Byte): ControlCommandIdentifiers? =
entries.find { it.value == byte } entries.find { it.value == byte }
@@ -118,6 +122,28 @@ class AACPManager {
ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte") 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 controlCommandStatusList: MutableList<ControlCommandStatus> = mutableListOf<ControlCommandStatus>()
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf() var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf()
@@ -149,6 +175,20 @@ class AACPManager {
fun onHeadTrackingReceived(headTracking: ByteArray) fun onHeadTrackingReceived(headTracking: ByteArray)
fun onUnknownPacketReceived(packet: ByteArray) fun onUnknownPacketReceived(packet: ByteArray)
fun onProximityKeysReceived(proximityKeys: 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 { interface ControlCommandListener {
@@ -195,6 +235,7 @@ class AACPManager {
return sendDataPacket(controlPacket) return sendDataPacket(controlPacket)
} }
@OptIn(ExperimentalStdlibApi::class)
fun sendControlCommand(identifier: Byte, value: Byte): Boolean { fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value)) val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
setControlCommandStatusValue( setControlCommandStatusValue(
@@ -323,6 +364,9 @@ class AACPManager {
Opcodes.PROXIMITY_KEYS_RSP -> { Opcodes.PROXIMITY_KEYS_RSP -> {
callback?.onProximityKeysReceived(packet) callback?.onProximityKeysReceived(packet)
} }
Opcodes.STEM_PRESS -> {
callback?.onStemPressReceived(packet)
}
else -> { else -> {
callback?.onUnknownPacketReceived(packet) callback?.onUnknownPacketReceived(packet)
} }
@@ -345,7 +389,7 @@ class AACPManager {
fun createSetFeatureFlagsPacket(): ByteArray { fun createSetFeatureFlagsPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00) 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 return opcode + data
} }
@@ -370,6 +414,14 @@ class AACPManager {
return opcode + data 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 { fun sendStopHeadTracking(): Boolean {
return sendDataPacket(createStopHeadTrackingPacket()) return sendDataPacket(createStopHeadTrackingPacket())
} }
@@ -382,6 +434,14 @@ class AACPManager {
return opcode + data 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 { fun sendRename(name: String): Boolean {
return sendDataPacket(createRenamePacket(name)) return sendDataPacket(createRenamePacket(name))
} }
@@ -440,13 +500,29 @@ class AACPManager {
val value = ByteArray(4) val value = ByteArray(4)
System.arraycopy(data, 3, value, 0, 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() val trimmedValue = value.takeWhile { it != 0x00.toByte() }.toByteArray()
return ControlCommand(identifier, trimmedValue) 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) @OptIn(ExperimentalStdlibApi::class)
fun sendPacket(packet: ByteArray): Boolean { fun sendPacket(packet: ByteArray): Boolean {
try { try {

View File

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

View File

@@ -100,6 +100,51 @@ object MediaController {
return audioManager.isMusicActive 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 @Synchronized
fun sendPause(force: Boolean = false) { fun sendPause(force: Boolean = false) {
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force") 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.TextView
import android.widget.VideoView import android.widget.VideoView
import me.kavishdevar.librepods.R 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") @SuppressLint("InflateParams", "ClickableViewAccessibility")
class PopupWindow( class PopupWindow(
@@ -124,9 +129,9 @@ class PopupWindow(
try { try {
if (mView.windowToken == null && mView.parent == null && !isClosing) { if (mView.windowToken == null && mView.parent == null && !isClosing) {
mView.findViewById<TextView>(R.id.name).text = name mView.findViewById<TextView>(R.id.name).text = name
updateBatteryStatus(batteryNotification) updateBatteryStatus(batteryNotification)
val vid = mView.findViewById<VideoView>(R.id.video) val vid = mView.findViewById<VideoView>(R.id.video)
vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected) vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
vid.resolveAdjustedSize(vid.width, vid.height) vid.resolveAdjustedSize(vid.width, vid.height)
@@ -134,7 +139,7 @@ class PopupWindow(
vid.setOnCompletionListener { vid.setOnCompletionListener {
vid.start() vid.start()
} }
mWindowManager.addView(mView, mParams) mWindowManager.addView(mView, mParams)
val displayMetrics = mView.context.resources.displayMetrics val displayMetrics = mView.context.resources.displayMetrics
@@ -144,13 +149,13 @@ class PopupWindow(
mView.alpha = 1f mView.alpha = 1f
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, screenHeight.toFloat(), 0f) val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, screenHeight.toFloat(), 0f)
ObjectAnimator.ofPropertyValuesHolder(mView, translationY).apply { ObjectAnimator.ofPropertyValuesHolder(mView, translationY).apply {
duration = 500 duration = 500
interpolator = DecelerateInterpolator() interpolator = DecelerateInterpolator()
start() start()
} }
registerBatteryUpdateReceiver() registerBatteryUpdateReceiver()
autoCloseRunnable = Runnable { close() } autoCloseRunnable = Runnable { close() }
@@ -162,6 +167,7 @@ class PopupWindow(
} }
} }
@SuppressLint("UnspecifiedRegisterReceiverFlag")
private fun registerBatteryUpdateReceiver() { private fun registerBatteryUpdateReceiver() {
batteryUpdateReceiver = object : BroadcastReceiver() { batteryUpdateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
@@ -173,7 +179,7 @@ class PopupWindow(
} }
} }
} }
val filter = IntentFilter(AirPodsNotifications.BATTERY_DATA) val filter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(batteryUpdateReceiver, filter, Context.RECEIVER_EXPORTED) context.registerReceiver(batteryUpdateReceiver, filter, Context.RECEIVER_EXPORTED)
@@ -192,7 +198,7 @@ class PopupWindow(
} }
} }
} }
private fun updateBatteryStatusFromList(batteryList: List<Battery>) { private fun updateBatteryStatusFromList(batteryList: List<Battery>) {
val batteryLeftText = mView.findViewById<TextView>(R.id.left_battery) val batteryLeftText = mView.findViewById<TextView>(R.id.left_battery)
val batteryRightText = mView.findViewById<TextView>(R.id.right_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 { batteryRightText.text = batteryList.find { it.component == BatteryComponent.RIGHT }?.let {
if (it.status != BatteryStatus.DISCONNECTED) { if (it.status != BatteryStatus.DISCONNECTED) {
"\uDBC3\uDC8D ${it.level}%" "\uDBC3\uDC8D ${it.level}%"
@@ -213,7 +219,7 @@ class PopupWindow(
"" ""
} }
} ?: "" } ?: ""
batteryCaseText.text = batteryList.find { it.component == BatteryComponent.CASE }?.let { batteryCaseText.text = batteryList.find { it.component == BatteryComponent.CASE }?.let {
if (it.status != BatteryStatus.DISCONNECTED) { if (it.status != BatteryStatus.DISCONNECTED) {
"\uDBC3\uDE6C ${it.level}%" "\uDBC3\uDE6C ${it.level}%"
@@ -233,13 +239,13 @@ class PopupWindow(
try { try {
if (isClosing) return if (isClosing) return
isClosing = true isClosing = true
autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) } autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) }
unregisterBatteryUpdateReceiver() unregisterBatteryUpdateReceiver()
val vid = mView.findViewById<VideoView>(R.id.video) val vid = mView.findViewById<VideoView>(R.id.video)
vid.stopPlayback() vid.stopPlayback()
ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply { ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
duration = 500 duration = 500
interpolator = AccelerateInterpolator() 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 | | 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| | 0x17 | DoubleClickInterval | Single value (1 byte): 0x00 = Default, `0x01` = Slower, `0x02` = Slowest|
| 0x18 | ClickHoldInterval | 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 | | 0x1B | OneBudANCMode | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
| 0x1C | CrownRotationDirection | Single value (1 byte): `0x01` = reversed, `0x02` = default | | 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 | | 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) | | 0x32 | Siri Multitone config | Single value (1 byte) |
| 0x33 | Hearing Assist config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled | | 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 | | 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] > [!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) set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(Qt6 6.4 REQUIRED COMPONENTS Quick Widgets Bluetooth DBus) find_package(Qt6 6.4 REQUIRED COMPONENTS Quick Widgets Bluetooth DBus)
find_package(OpenSSL REQUIRED)
qt_standard_project_setup(REQUIRES 6.4) qt_standard_project_setup(REQUIRES 6.4)
qt_add_executable(applinux qt_add_executable(librepods
main.cpp main.cpp
main.h
logger.h logger.h
mediacontroller.cpp media/mediacontroller.cpp
mediacontroller.h media/mediacontroller.h
airpods_packets.h airpods_packets.h
trayiconmanager.cpp trayiconmanager.cpp
trayiconmanager.h trayiconmanager.h
@@ -24,9 +24,20 @@ qt_add_executable(applinux
autostartmanager.hpp autostartmanager.hpp
BasicControlCommand.hpp BasicControlCommand.hpp
deviceinfo.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 URI linux
VERSION 1.0 VERSION 1.0
QML_FILES QML_FILES
@@ -35,10 +46,11 @@ qt_add_qml_module(applinux
SegmentedControl.qml SegmentedControl.qml
PodColumn.qml PodColumn.qml
Icon.qml Icon.qml
KeysQRDialog.qml
) )
# Add the resource file # Add the resource file
qt_add_resources(applinux "resources" qt_add_resources(librepods "resources"
PREFIX "/icons" PREFIX "/icons"
FILES FILES
assets/airpods.png assets/airpods.png
@@ -53,12 +65,12 @@ qt_add_resources(applinux "resources"
assets/fonts/SF-Symbols-6.ttf assets/fonts/SF-Symbols-6.ttf
) )
target_link_libraries(applinux target_link_libraries(librepods
PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus OpenSSL::SSL OpenSSL::Crypto
) )
include(GNUInstallDirs) include(GNUInstallDirs)
install(TARGETS applinux install(TARGETS librepods
BUNDLE DESTINATION . BUNDLE DESTINATION .
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} 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 visible: !airPodsTrayApp.hideOnStart
width: 400 width: 400
height: 300 height: 300
title: "AirPods Settings" title: "Librepods"
objectName: "mainWindowObject" objectName: "mainWindowObject"
onClosing: mainWindow.visible = false onClosing: mainWindow.visible = false
@@ -94,7 +94,7 @@ ApplicationWindow {
spacing: 8 spacing: 8
PodColumn { PodColumn {
isVisible: airPodsTrayApp.deviceInfo.battery.leftPodAvailable visible: airPodsTrayApp.deviceInfo.battery.leftPodAvailable
inEar: airPodsTrayApp.deviceInfo.leftPodInEar inEar: airPodsTrayApp.deviceInfo.leftPodInEar
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
batteryLevel: airPodsTrayApp.deviceInfo.battery.leftPodLevel batteryLevel: airPodsTrayApp.deviceInfo.battery.leftPodLevel
@@ -103,7 +103,7 @@ ApplicationWindow {
} }
PodColumn { PodColumn {
isVisible: airPodsTrayApp.deviceInfo.battery.rightPodAvailable visible: airPodsTrayApp.deviceInfo.battery.rightPodAvailable
inEar: airPodsTrayApp.deviceInfo.rightPodInEar inEar: airPodsTrayApp.deviceInfo.rightPodInEar
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
batteryLevel: airPodsTrayApp.deviceInfo.battery.rightPodLevel batteryLevel: airPodsTrayApp.deviceInfo.battery.rightPodLevel
@@ -112,7 +112,7 @@ ApplicationWindow {
} }
PodColumn { PodColumn {
isVisible: airPodsTrayApp.deviceInfo.battery.caseAvailable visible: airPodsTrayApp.deviceInfo.battery.caseAvailable
inEar: true inEar: true
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.caseIcon iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.caseIcon
batteryLevel: airPodsTrayApp.deviceInfo.battery.caseLevel batteryLevel: airPodsTrayApp.deviceInfo.battery.caseLevel
@@ -164,7 +164,7 @@ ApplicationWindow {
anchors.margins: 10 anchors.margins: 10
font.family: iconFont.name font.family: iconFont.name
font.pixelSize: 18 font.pixelSize: 18
text: "\uf958" // U+F958 text: "\uf958"
onClicked: stackView.push(settingsPage) onClicked: stackView.push(settingsPage)
} }
} }
@@ -265,9 +265,37 @@ ApplicationWindow {
Button { Button {
text: "Rename" 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 import QtQuick 2.15
Column { Column {
property bool isVisible: true id: root
property bool inEar: true property bool inEar: true
property string iconSource property string iconSource
property int batteryLevel: 0 property int batteryLevel: 0
property bool isCharging: false property bool isCharging: false
property string indicator: "" 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 spacing: 5
opacity: inEar ? 1 : 0.5
visible: isVisible
Image { Image {
source: parent.iconSource 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 \ 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-controls qml6-module-qtqml-workerscript qml6-module-qtquick-templates \
qml6-module-qtquick-window qml6-module-qtquick-layouts 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 ## 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 ```bash
#define PHONE_MAC_ADDRESS "XX:XX:XX:XX:XX:XX" // Replace with your phone's MAC export PHONE_MAC_ADDRESS="XX:XX:XX:XX:XX:XX" # Replace with your phone's MAC
``` ```
2. Build the application: 2. Build the application:
@@ -43,7 +58,7 @@ A native Linux application to control your AirPods, with support for:
3. Run the application: 3. Run the application:
```bash ```bash
./applinux ./librepods
``` ```
## Usage ## Usage

View File

@@ -4,6 +4,7 @@
#include <QByteArray> #include <QByteArray>
#include <optional> #include <optional>
#include <climits>
#include "enums.h" #include "enums.h"
#include "BasicControlCommand.hpp" #include "BasicControlCommand.hpp"
@@ -39,13 +40,13 @@ namespace AirPodsPackets
inline std::optional<NoiseControlMode> parseMode(const QByteArray &data) 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) || if (mode < static_cast<quint8>(NoiseControlMode::MinValue) ||
mode > static_cast<quint8>(NoiseControlMode::MaxValue)) mode > static_cast<quint8>(NoiseControlMode::MaxValue))
{ {
return std::nullopt; return std::nullopt;
} }
return static_cast<NoiseControlMode>(mode - 1); return static_cast<NoiseControlMode>(mode);
} }
} }
@@ -120,7 +121,7 @@ namespace AirPodsPackets
namespace Connection namespace Connection
{ {
static const QByteArray HANDSHAKE = QByteArray::fromHex("00000400010002000000000000000000"); 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 REQUEST_NOTIFICATIONS = QByteArray::fromHex("040004000f00ffffffffff");
static const QByteArray AIRPODS_DISCONNECTED = QByteArray::fromHex("00010000"); 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" "[Desktop Entry]\n"
"Type=Application\n" "Type=Application\n"
"Name=%1\n" "Name=%1\n"
"Exec=%2\n" "Exec=%2 --hide\n"
"Icon=%3\n" "Icon=%3\n"
"Comment=%4\n" "Comment=%4\n"
"X-GNOME-Autostart-enabled=true\n" "X-GNOME-Autostart-enabled=true\n"

View File

@@ -4,8 +4,10 @@
#include <QMap> #include <QMap>
#include <QString> #include <QString>
#include <QObject> #include <QObject>
#include <climits>
#include "airpods_packets.h" #include "airpods_packets.h"
#include "logger.h"
class Battery : public QObject class Battery : public QObject
{ {
@@ -97,7 +99,10 @@ public:
auto level = static_cast<quint8>(packet[offset + 2]); auto level = static_cast<quint8>(packet[offset + 2]);
auto status = static_cast<BatteryStatus>(packet[offset + 3]); 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 this is a pod (Left or Right), add it to the list
if (comp == Component::Left || comp == Component::Right) if (comp == Component::Left || comp == Component::Right)
@@ -127,6 +132,61 @@ public:
// Emit signal to notify about battery status change // Emit signal to notify about battery status change
emit batteryStatusChanged(); 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; return true;
} }
@@ -187,7 +247,14 @@ private:
return states.value(component).status == status; 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; QMap<Component, BatteryState> states;
Component primaryPod; Component primaryPod;
Component secondaryPod; 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 "blemanager.h"
#include "enums.h"
#include <QDebug> #include <QDebug>
#include <QTimer> #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) BleManager::BleManager(QObject *parent) : QObject(parent)
{ {
@@ -13,36 +93,28 @@ BleManager::BleManager(QObject *parent) : QObject(parent)
this, &BleManager::onScanFinished); this, &BleManager::onScanFinished);
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::errorOccurred, connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::errorOccurred,
this, &BleManager::onErrorOccurred); 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() BleManager::~BleManager()
{ {
delete discoveryAgent; delete discoveryAgent;
delete pruneTimer;
} }
void BleManager::startScan() void BleManager::startScan()
{ {
qDebug() << "Starting BLE scan..."; LOG_DEBUG("Starting BLE scan...");
devices.clear();
discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod); discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod);
pruneTimer->start(PRUNE_INTERVAL_MS); // Ensure timer is running
} }
void BleManager::stopScan() void BleManager::stopScan()
{ {
qDebug() << "Stopping BLE scan..."; LOG_DEBUG("Stopping BLE scan...");
discoveryAgent->stop(); discoveryAgent->stop();
} }
const QMap<QString, DeviceInfo> &BleManager::getDevices() const bool BleManager::isScanning() const
{ {
return devices; return discoveryAgent->isActive();
} }
void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info) void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
@@ -55,10 +127,11 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
if (data.size() >= 10 && data[0] == 0x07) if (data.size() >= 10 && data[0] == 0x07)
{ {
QString address = info.address().toString(); QString address = info.address().toString();
DeviceInfo deviceInfo; BleInfo deviceInfo;
deviceInfo.name = info.name().isEmpty() ? "AirPods" : info.name(); deviceInfo.name = info.name().isEmpty() ? "AirPods" : info.name();
deviceInfo.address = address; 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 // 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) 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]) // 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 // Status byte for primary pod and other flags
quint8 status = static_cast<quint8>(data[5]); quint8 status = static_cast<quint8>(data[5]);
@@ -83,9 +157,9 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
// Lid open counter and device color // Lid open counter and device color
quint8 lidIndicator = static_cast<quint8>(data[8]); 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 // 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 primaryLeft = (status & 0x20) != 0; // Bit 5: 1 = left primary, 0 = right primary
bool areValuesFlipped = !primaryLeft; // Flipped when right pod is primary bool areValuesFlipped = !primaryLeft; // Flipped when right pod is primary
deviceInfo.primaryLeft = primaryLeft; // Store primary pod information
// Parse battery levels // Parse battery levels
int leftNibble = areValuesFlipped ? (podsBatteryByte >> 4) & 0x0F : podsBatteryByte & 0x0F; int leftNibble = areValuesFlipped ? (podsBatteryByte >> 4) & 0x0F : podsBatteryByte & 0x0F;
int rightNibble = areValuesFlipped ? podsBatteryByte & 0x0F : (podsBatteryByte >> 4) & 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.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 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 // Microphone status
deviceInfo.isLeftPodMicrophone = primaryLeft ^ deviceInfo.isThisPodInTheCase; deviceInfo.isLeftPodMicrophone = primaryLeft ^ deviceInfo.isThisPodInTheCase;
deviceInfo.isRightPodMicrophone = !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) deviceInfo.lidOpenCounter = lidIndicator & 0x07; // Extract bits 0-2 (count)
quint8 lidState = static_cast<quint8>((lidIndicator >> 3) & 0x01); // Extract bit 3 (lid state) quint8 lidState = static_cast<quint8>((lidIndicator >> 3) & 0x01); // Extract bit 3 (lid state)
if (deviceInfo.isThisPodInTheCase) { if (deviceInfo.isThisPodInTheCase) {
deviceInfo.lidState = static_cast<DeviceInfo::LidState>(lidState); deviceInfo.lidState = static_cast<BleInfo::LidState>(lidState);
} }
// Update timestamp // Update timestamp
deviceInfo.lastSeen = QDateTime::currentDateTime(); deviceInfo.lastSeen = QDateTime::currentDateTime();
// Store device info in the map emit deviceFound(deviceInfo); // Emit signal for device found
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");
} }
} }
} }
void BleManager::onScanFinished() void BleManager::onScanFinished()
{ {
qDebug() << "Scan finished.";
if (discoveryAgent->isActive()) if (discoveryAgent->isActive())
{ {
discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod); discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod);
@@ -153,24 +225,6 @@ void BleManager::onScanFinished()
void BleManager::onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error) void BleManager::onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error)
{ {
qDebug() << "Error occurred:" << error; LOG_ERROR("BLE scan error occurred:" << error);
stopScan(); 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 <QMap>
#include <QString> #include <QString>
#include <QDateTime> #include <QDateTime>
#include "enums.h"
class QTimer; class QTimer;
class DeviceInfo class BleInfo
{ {
public: public:
QString name; QString name;
@@ -20,20 +21,24 @@ public:
bool leftCharging = false; bool leftCharging = false;
bool rightCharging = false; bool rightCharging = false;
bool caseCharging = false; bool caseCharging = false;
quint16 deviceModel = 0; AirpodsTrayApp::Enums::AirPodsModel modelName = AirpodsTrayApp::Enums::AirPodsModel::Unknown;
quint8 lidOpenCounter = 0; quint8 lidOpenCounter = 0;
quint8 deviceColor = 0; QString color = "Unknown"; // Default color
quint8 status = 0; quint8 status = 0;
QByteArray rawData; QByteArray rawData;
QByteArray encryptedPayload; // 16 bytes of encrypted payload
// Additional status flags from Kotlin version // Additional status flags from Kotlin version
bool isLeftPodInEar = false; bool isLeftPodInEar = false;
bool isRightPodInEar = false; bool isRightPodInEar = false;
bool isPrimaryInEar = false;
bool isSecondaryInEar = false;
bool isLeftPodMicrophone = false; bool isLeftPodMicrophone = false;
bool isRightPodMicrophone = false; bool isRightPodMicrophone = false;
bool isThisPodInTheCase = false; bool isThisPodInTheCase = false;
bool isOnePodInCase = false; bool isOnePodInCase = false;
bool areBothPodsInCase = false; bool areBothPodsInCase = false;
bool primaryLeft = true; // True if left pod is primary, false if right pod is primary
// Lid state enumeration // Lid state enumeration
enum class LidState enum class LidState
@@ -41,8 +46,7 @@ public:
OPEN = 0x0, OPEN = 0x0,
CLOSED = 0x1, CLOSED = 0x1,
UNKNOWN, UNKNOWN,
}; } lidState = LidState::UNKNOWN;
LidState lidState = LidState::UNKNOWN;
// Connection state enumeration // Connection state enumeration
enum class ConnectionState : uint8_t enum class ConnectionState : uint8_t
@@ -54,8 +58,7 @@ public:
RINGING = 0x07, RINGING = 0x07,
HANGING_UP = 0x09, HANGING_UP = 0x09,
UNKNOWN = 0xFF // Using 0xFF for representing null in the original UNKNOWN = 0xFF // Using 0xFF for representing null in the original
}; } connectionState = ConnectionState::UNKNOWN;
ConnectionState connectionState = ConnectionState::UNKNOWN;
QDateTime lastSeen; // Timestamp of last detection QDateTime lastSeen; // Timestamp of last detection
}; };
@@ -69,21 +72,18 @@ public:
void startScan(); void startScan();
void stopScan(); void stopScan();
const QMap<QString, DeviceInfo> &getDevices() const; bool isScanning() const;
private slots: private slots:
void onDeviceDiscovered(const QBluetoothDeviceInfo &info); void onDeviceDiscovered(const QBluetoothDeviceInfo &info);
void onScanFinished(); void onScanFinished();
void onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error); void onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error);
void pruneOldDevices();
signals:
void deviceFound(const BleInfo &device);
private: private:
QBluetoothDeviceDiscoveryAgent *discoveryAgent; 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 #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 <QSettings>
#include "battery.hpp" #include "battery.hpp"
#include "enums.h" #include "enums.h"
#include "eardetection.hpp"
using namespace AirpodsTrayApp::Enums; using namespace AirpodsTrayApp::Enums;
@@ -12,14 +13,11 @@ class DeviceInfo : public QObject
{ {
Q_OBJECT Q_OBJECT
Q_PROPERTY(QString batteryStatus READ batteryStatus WRITE setBatteryStatus NOTIFY batteryStatusChanged) 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(int noiseControlMode READ noiseControlModeInt WRITE setNoiseControlModeInt NOTIFY noiseControlModeChangedInt)
Q_PROPERTY(bool conversationalAwareness READ conversationalAwareness WRITE setConversationalAwareness NOTIFY conversationalAwarenessChanged) Q_PROPERTY(bool conversationalAwareness READ conversationalAwareness WRITE setConversationalAwareness NOTIFY conversationalAwarenessChanged)
Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged) Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged)
Q_PROPERTY(QString deviceName READ deviceName WRITE setDeviceName NOTIFY deviceNameChanged) Q_PROPERTY(QString deviceName READ deviceName WRITE setDeviceName NOTIFY deviceNameChanged)
Q_PROPERTY(Battery *battery READ getBattery CONSTANT) 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(bool oneBudANCMode READ oneBudANCMode WRITE setOneBudANCMode NOTIFY oneBudANCModeChanged)
Q_PROPERTY(AirPodsModel model READ model WRITE setModel NOTIFY modelChanged) Q_PROPERTY(AirPodsModel model READ model WRITE setModel NOTIFY modelChanged)
Q_PROPERTY(bool adaptiveModeActive READ adaptiveModeActive NOTIFY noiseControlModeChangedInt) 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 leftPodInEar READ isLeftPodInEar NOTIFY primaryChanged)
Q_PROPERTY(bool rightPodInEar READ isRightPodInEar NOTIFY primaryChanged) Q_PROPERTY(bool rightPodInEar READ isRightPodInEar NOTIFY primaryChanged)
Q_PROPERTY(QString bluetoothAddress READ bluetoothAddress WRITE setBluetoothAddress NOTIFY bluetoothAddressChanged) 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: 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; } QString batteryStatus() const { return m_batteryStatus; }
void setBatteryStatus(const QString &status) 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; } NoiseControlMode noiseControlMode() const { return m_noiseControlMode; }
void setNoiseControlMode(NoiseControlMode mode) void setNoiseControlMode(NoiseControlMode mode)
{ {
@@ -97,26 +89,6 @@ public:
Battery *getBattery() const { return m_battery; } 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; } bool oneBudANCMode() const { return m_oneBudANCMode; }
void setOneBudANCMode(bool enabled) void setOneBudANCMode(bool enabled)
{ {
@@ -139,9 +111,11 @@ public:
QByteArray magicAccIRK() const { return m_magicAccIRK; } QByteArray magicAccIRK() const { return m_magicAccIRK; }
void setMagicAccIRK(const QByteArray &irk) { m_magicAccIRK = irk; } void setMagicAccIRK(const QByteArray &irk) { m_magicAccIRK = irk; }
QString magicAccIRKHex() const { return QString::fromUtf8(m_magicAccIRK.toHex()); }
QByteArray magicAccEncKey() const { return m_magicAccEncKey; } QByteArray magicAccEncKey() const { return m_magicAccEncKey; }
void setMagicAccEncKey(const QByteArray &key) { m_magicAccEncKey = key; } void setMagicAccEncKey(const QByteArray &key) { m_magicAccEncKey = key; }
QString magicAccEncKeyHex() const { return QString::fromUtf8(m_magicAccEncKey.toHex()); }
QString modelNumber() const { return m_modelNumber; } QString modelNumber() const { return m_modelNumber; }
void setModelNumber(const QString &modelNumber) { m_modelNumber = modelNumber; } void setModelNumber(const QString &modelNumber) { m_modelNumber = modelNumber; }
@@ -163,18 +137,18 @@ public:
QString caseIcon() const { return getModelIcon(model()).second; } QString caseIcon() const { return getModelIcon(model()).second; }
bool isLeftPodInEar() const bool isLeftPodInEar() const
{ {
if (getBattery()->getPrimaryPod() == Battery::Component::Left) return isPrimaryInEar(); if (getBattery()->getPrimaryPod() == Battery::Component::Left) return getEarDetection()->isPrimaryInEar();
else return isSecondaryInEar(); else return getEarDetection()->isSecondaryInEar();
} }
bool isRightPodInEar() const bool isRightPodInEar() const
{ {
if (getBattery()->getPrimaryPod() == Battery::Component::Right) return isPrimaryInEar(); if (getBattery()->getPrimaryPod() == Battery::Component::Right) return getEarDetection()->isPrimaryInEar();
else return isSecondaryInEar(); else return getEarDetection()->isSecondaryInEar();
} }
bool adaptiveModeActive() const { return noiseControlMode() == NoiseControlMode::Adaptive; } 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() void reset()
{ {
@@ -182,38 +156,38 @@ public:
setModel(AirPodsModel::Unknown); setModel(AirPodsModel::Unknown);
m_battery->reset(); m_battery->reset();
setBatteryStatus(""); setBatteryStatus("");
setEarDetectionStatus("");
setPrimaryInEar(false);
setSecondaryInEar(false);
setNoiseControlMode(NoiseControlMode::Off); setNoiseControlMode(NoiseControlMode::Off);
setBluetoothAddress(""); setBluetoothAddress("");
getEarDetection()->reset();
} }
void save() const void saveToSettings(QSettings &settings)
{ {
QSettings settings("AirpodsTrayApp", "DeviceInfo");
settings.beginGroup("DeviceInfo"); settings.beginGroup("DeviceInfo");
settings.setValue("deviceName", m_deviceName); settings.setValue("deviceName", deviceName());
settings.setValue("bluetoothAddress", m_bluetoothAddress); settings.setValue("model", static_cast<int>(model()));
settings.setValue("magicAccIRK", m_magicAccIRK.toBase64()); settings.setValue("magicAccIRK", magicAccIRK());
settings.setValue("magicAccEncKey", m_magicAccEncKey.toBase64()); settings.setValue("magicAccEncKey", magicAccEncKey());
settings.endGroup(); settings.endGroup();
} }
void loadFromSettings(const QSettings &settings)
void load()
{ {
QSettings settings("AirpodsTrayApp", "DeviceInfo"); setDeviceName(settings.value("DeviceInfo/deviceName", "").toString());
settings.beginGroup("DeviceInfo"); setModel(static_cast<AirPodsModel>(settings.value("DeviceInfo/model", (int)(AirPodsModel::Unknown)).toInt()));
setDeviceName(settings.value("deviceName", "").toString()); setMagicAccIRK(settings.value("DeviceInfo/magicAccIRK", QByteArray()).toByteArray());
setBluetoothAddress(settings.value("bluetoothAddress", "").toString()); setMagicAccEncKey(settings.value("DeviceInfo/magicAccEncKey", QByteArray()).toByteArray());
setMagicAccIRK(QByteArray::fromBase64(settings.value("magicAccIRK", "").toByteArray())); }
setMagicAccEncKey(QByteArray::fromBase64(settings.value("magicAccEncKey", "").toByteArray()));
settings.endGroup(); 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: signals:
void batteryStatusChanged(const QString &status); void batteryStatusChanged(const QString &status);
void earDetectionStatusChanged(const QString &status);
void noiseControlModeChanged(NoiseControlMode mode); void noiseControlModeChanged(NoiseControlMode mode);
void noiseControlModeChangedInt(int mode); void noiseControlModeChangedInt(int mode);
void conversationalAwarenessChanged(bool enabled); void conversationalAwarenessChanged(bool enabled);
@@ -226,14 +200,11 @@ signals:
private: private:
QString m_batteryStatus; QString m_batteryStatus;
QString m_earDetectionStatus; NoiseControlMode m_noiseControlMode = NoiseControlMode::Transparency;
NoiseControlMode m_noiseControlMode = NoiseControlMode::Off;
bool m_conversationalAwareness = false; bool m_conversationalAwareness = false;
int m_adaptiveNoiseLevel = 50; int m_adaptiveNoiseLevel = 50;
QString m_deviceName; QString m_deviceName;
Battery *m_battery; Battery *m_battery;
bool m_primaryInEar = false;
bool m_secoundaryInEar = false;
QByteArray m_magicAccIRK; QByteArray m_magicAccIRK;
QByteArray m_magicAccEncKey; QByteArray m_magicAccEncKey;
bool m_oneBudANCMode = false; bool m_oneBudANCMode = false;
@@ -241,4 +212,5 @@ private:
QString m_modelNumber; QString m_modelNumber;
QString m_manufacturer; QString m_manufacturer;
QString m_bluetoothAddress; 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 <QDebug>
#include <QLoggingCategory> #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_INFO(msg) qCInfo(Librepods) << "\033[32m" << msg << "\033[0m"
#define LOG_WARN(msg) qCWarning(airpodsApp) << "\033[33m" << msg << "\033[0m" #define LOG_WARN(msg) qCWarning(Librepods) << "\033[33m" << msg << "\033[0m"
#define LOG_ERROR(msg) qCCritical(airpodsApp) << "\033[31m" << msg << "\033[0m" #define LOG_ERROR(msg) qCCritical(Librepods) << "\033[31m" << msg << "\033[0m"
#define LOG_DEBUG(msg) qCDebug(airpodsApp) << "\033[34m" << msg << "\033[0m" #define LOG_DEBUG(msg) qCDebug(Librepods) << "\033[34m" << msg << "\033[0m"

View File

@@ -1,20 +1,35 @@
#include <QSettings> #include <QSettings>
#include <QLocalServer> #include <QLocalServer>
#include <QLocalSocket> #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 "airpods_packets.h"
#include "logger.h" #include "logger.h"
#include "mediacontroller.h" #include "media/mediacontroller.h"
#include "trayiconmanager.h" #include "trayiconmanager.h"
#include "enums.h" #include "enums.h"
#include "battery.hpp" #include "battery.hpp"
#include "BluetoothMonitor.h" #include "BluetoothMonitor.h"
#include "autostartmanager.hpp" #include "autostartmanager.hpp"
#include "deviceinfo.hpp" #include "deviceinfo.hpp"
#include "ble/blemanager.h"
#include "ble/bleutils.h"
#include "QRCodeImageProvider.hpp"
#include "systemsleepmonitor.hpp"
using namespace AirpodsTrayApp::Enums; using namespace AirpodsTrayApp::Enums;
Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp") Q_LOGGING_CATEGORY(Librepods, "Librepods")
class AirPodsTrayApp : public QObject { class AirPodsTrayApp : public QObject {
Q_OBJECT Q_OBJECT
@@ -26,12 +41,16 @@ class AirPodsTrayApp : public QObject {
Q_PROPERTY(int retryAttempts READ retryAttempts WRITE setRetryAttempts NOTIFY retryAttemptsChanged) Q_PROPERTY(int retryAttempts READ retryAttempts WRITE setRetryAttempts NOTIFY retryAttemptsChanged)
Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT) Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT)
Q_PROPERTY(DeviceInfo *deviceInfo READ deviceInfo CONSTANT) Q_PROPERTY(DeviceInfo *deviceInfo READ deviceInfo CONSTANT)
Q_PROPERTY(QString phoneMacStatus READ phoneMacStatus NOTIFY phoneMacStatusChanged)
public: public:
AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr) 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"); LOG_INFO("Initializing AirPodsTrayApp");
// Initialize tray icon and connect signals // Initialize tray icon and connect signals
@@ -50,16 +69,17 @@ public:
// Initialize MediaController and connect signals // Initialize MediaController and connect signals
mediaController = new MediaController(this); mediaController = new MediaController(this);
connect(m_deviceInfo, &DeviceInfo::earDetectionStatusChanged, mediaController, &MediaController::handleEarDetection);
connect(mediaController, &MediaController::mediaStateChanged, this, &AirPodsTrayApp::handleMediaStateChange); connect(mediaController, &MediaController::mediaStateChanged, this, &AirPodsTrayApp::handleMediaStateChange);
mediaController->initializeMprisInterface();
mediaController->followMediaChanges(); mediaController->followMediaChanges();
monitor = new BluetoothMonitor(this); monitor = new BluetoothMonitor(this);
connect(monitor, &BluetoothMonitor::deviceConnected, this, &AirPodsTrayApp::bluezDeviceConnected); connect(monitor, &BluetoothMonitor::deviceConnected, this, &AirPodsTrayApp::bluezDeviceConnected);
connect(monitor, &BluetoothMonitor::deviceDisconnected, this, &AirPodsTrayApp::bluezDeviceDisconnected); 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_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 // Load settings
CrossDevice.isEnabled = loadCrossDeviceEnabled(); CrossDevice.isEnabled = loadCrossDeviceEnabled();
@@ -101,6 +121,7 @@ public:
int retryAttempts() const { return m_retryAttempts; } int retryAttempts() const { return m_retryAttempts; }
bool hideOnStart() const { return m_hideOnStart; } bool hideOnStart() const { return m_hideOnStart; }
DeviceInfo *deviceInfo() const { return m_deviceInfo; } DeviceInfo *deviceInfo() const { return m_deviceInfo; }
QString phoneMacStatus() const { return m_phoneMacStatus; }
private: private:
bool debugMode; bool debugMode;
@@ -151,6 +172,11 @@ public slots:
void setNoiseControlMode(NoiseControlMode mode) 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); LOG_INFO("Setting noise control mode to: " << mode);
QByteArray packet = AirPodsPackets::NoiseControl::getPacketForMode(mode); QByteArray packet = AirPodsPackets::NoiseControl::getPacketForMode(mode);
writePacketToSocket(packet, "Noise control mode packet written: "); writePacketToSocket(packet, "Noise control mode packet written: ");
@@ -287,6 +313,51 @@ public slots:
emit crossDeviceEnabledChanged(enabled); 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) bool writePacketToSocket(const QByteArray &packet, const QString &logMessage)
{ {
if (socket && socket->isOpen()) if (socket && socket->isOpen())
@@ -314,6 +385,20 @@ public slots:
int loadRetryAttempts() const { return m_settings->value("bluetooth/retryAttempts", 3).toInt(); } int loadRetryAttempts() const { return m_settings->value("bluetooth/retryAttempts", 3).toInt(); }
void saveRetryAttempts(int attempts) { m_settings->setValue("bluetooth/retryAttempts", attempts); } 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: private slots:
void onTrayIconActivated() void onTrayIconActivated()
{ {
@@ -379,6 +464,8 @@ private slots:
// Clear the device name and model // Clear the device name and model
m_deviceInfo->reset(); m_deviceInfo->reset();
m_bleManager->startScan();
emit airPodsStatusChanged();
// Show system notification // Show system notification
trayManager->showNotification( trayManager->showNotification(
@@ -545,6 +632,7 @@ private slots:
// Store the keys // Store the keys
m_deviceInfo->setMagicAccIRK(keys.magicAccIRK); m_deviceInfo->setMagicAccIRK(keys.magicAccIRK);
m_deviceInfo->setMagicAccEncKey(keys.magicAccEncKey); m_deviceInfo->setMagicAccEncKey(keys.magicAccEncKey);
m_deviceInfo->saveToSettings(*m_settings);
} }
// Get CA state // Get CA state
else if (data.startsWith(AirPodsPackets::ConversationalAwareness::HEADER)) { else if (data.startsWith(AirPodsPackets::ConversationalAwareness::HEADER)) {
@@ -559,7 +647,6 @@ private slots:
{ {
if (auto value = AirPodsPackets::NoiseControl::parseMode(data)) if (auto value = AirPodsPackets::NoiseControl::parseMode(data))
{ {
LOG_INFO("Received noise control mode: " << value.value());
m_deviceInfo->setNoiseControlMode(value.value()); m_deviceInfo->setNoiseControlMode(value.value());
LOG_INFO("Noise control mode received: " << m_deviceInfo->noiseControlMode()); LOG_INFO("Noise control mode received: " << m_deviceInfo->noiseControlMode());
} }
@@ -567,26 +654,14 @@ private slots:
// Ear Detection // Ear Detection
else if (data.size() == 8 && data.startsWith(AirPodsPackets::Parse::EAR_DETECTION)) else if (data.size() == 8 && data.startsWith(AirPodsPackets::Parse::EAR_DETECTION))
{ {
char primary = data[6]; m_deviceInfo->getEarDetection()->parseData(data);
char secondary = data[7]; mediaController->handleEarDetection(m_deviceInfo->getEarDetection());
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());
} }
// Battery Status // Battery Status
else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS)) else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
{ {
m_deviceInfo->getBattery()->parsePacket(data); m_deviceInfo->getBattery()->parsePacket(data);
m_deviceInfo->updateBatteryStatus();
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));
LOG_INFO("Battery status: " << m_deviceInfo->batteryStatus()); LOG_INFO("Battery status: " << m_deviceInfo->batteryStatus());
} }
// Conversational Awareness Data // Conversational Awareness Data
@@ -600,10 +675,11 @@ private slots:
parseMetadata(data); parseMetadata(data);
initiateMagicPairing(); initiateMagicPairing();
mediaController->setConnectedDeviceMacAddress(m_deviceInfo->bluetoothAddress().replace(":", "_")); 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(); mediaController->activateA2dpProfile();
} }
m_bleManager->stopScan();
emit airPodsStatusChanged(); emit airPodsStatusChanged();
} }
else if (data.startsWith(AirPodsPackets::OneBudANCMode::HEADER)) { else if (data.startsWith(AirPodsPackets::OneBudANCMode::HEADER)) {
@@ -628,11 +704,12 @@ private slots:
LOG_INFO("Already connected to the phone"); LOG_INFO("Already connected to the phone");
return; 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(); QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
if (!env.value("PHONE_MAC_ADDRESS").isEmpty()) 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); phoneSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol);
connect(phoneSocket, &QBluetoothSocket::connected, this, [this]() { connect(phoneSocket, &QBluetoothSocket::connected, this, [this]() {
@@ -733,6 +810,16 @@ private slots:
QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data)); 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: public:
void handleMediaStateChange(MediaController::MediaState state) { void handleMediaStateChange(MediaController::MediaState state) {
if (state == MediaController::MediaState::Playing) { if (state == MediaController::MediaState::Playing) {
@@ -774,13 +861,6 @@ public:
process.waitForFinished(); process.waitForFinished();
QString output = process.readAllStandardOutput().trimmed(); QString output = process.readAllStandardOutput().trimmed();
LOG_INFO("Bluetoothctl output: " << output); 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; QBluetoothLocalDevice localDevice;
const QList<QBluetoothAddress> connectedDevices = localDevice.connectedDevices(); const QList<QBluetoothAddress> connectedDevices = localDevice.connectedDevices();
@@ -795,33 +875,13 @@ public:
LOG_WARN("AirPods not found among connected devices"); 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() { void initializeBluetooth() {
connectToPhone(); connectToPhone();
m_deviceInfo->loadFromSettings(*m_settings);
if (!areAirpodsConnected()) {
m_bleManager->startScan();
}
} }
void loadMainModule() { void loadMainModule() {
@@ -843,6 +903,7 @@ signals:
void notificationsEnabledChanged(bool enabled); void notificationsEnabledChanged(bool enabled);
void retryAttemptsChanged(int attempts); void retryAttemptsChanged(int attempts);
void oneBudANCModeChanged(bool enabled); void oneBudANCModeChanged(bool enabled);
void phoneMacStatusChanged();
private: private:
QBluetoothSocket *socket = nullptr; QBluetoothSocket *socket = nullptr;
@@ -857,6 +918,9 @@ private:
int m_retryAttempts = 3; int m_retryAttempts = 3;
bool m_hideOnStart = false; bool m_hideOnStart = false;
DeviceInfo *m_deviceInfo; DeviceInfo *m_deviceInfo;
BleManager *m_bleManager;
SystemSleepMonitor *m_systemSleepMonitor = nullptr;
QString m_phoneMacStatus;
}; };
int main(int argc, char *argv[]) { int main(int argc, char *argv[]) {
@@ -904,6 +968,17 @@ int main(int argc, char *argv[]) {
qmlRegisterType<DeviceInfo>("me.kavishdevar.DeviceInfo", 1, 0, "DeviceInfo"); qmlRegisterType<DeviceInfo>("me.kavishdevar.DeviceInfo", 1, 0, "DeviceInfo");
AirPodsTrayApp *trayApp = new AirPodsTrayApp(debugMode, hideOnStart, &engine); AirPodsTrayApp *trayApp = new AirPodsTrayApp(debugMode, hideOnStart, &engine);
engine.rootContext()->setContextProperty("airPodsTrayApp", trayApp); 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(); trayApp->loadMainModule();
QLocalServer server; 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 "mediacontroller.h"
#include "logger.h" #include "logger.h"
#include "eardetection.hpp"
#include "playerstatuswatcher.h"
#include <QDebug> #include <QDebug>
#include <QProcess> #include <QProcess>
@@ -8,37 +10,9 @@
#include <QDBusConnectionInterface> #include <QDBusConnectionInterface>
MediaController::MediaController(QObject *parent) : QObject(parent) { MediaController::MediaController(QObject *parent) : QObject(parent) {
// No additional initialization required here
} }
void MediaController::initializeMprisInterface() { void MediaController::handleEarDetection(EarDetection *earDetection)
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)
{ {
if (earDetectionBehavior == Disabled) if (earDetectionBehavior == Disabled)
{ {
@@ -46,15 +20,8 @@ void MediaController::handleEarDetection(const QString &status)
return; return;
} }
bool primaryInEar = false; bool primaryInEar = earDetection->isPrimaryInEar();
bool secondaryInEar = false; bool secondaryInEar = earDetection->isSecondaryInEar();
QStringList parts = status.split(", ");
if (parts.size() == 2)
{
primaryInEar = parts[0].contains("In Ear");
secondaryInEar = parts[1].contains("In Ear");
}
LOG_DEBUG("Ear detection status: primaryInEar=" LOG_DEBUG("Ear detection status: primaryInEar="
<< primaryInEar << ", secondaryInEar=" << secondaryInEar << primaryInEar << ", secondaryInEar=" << secondaryInEar
@@ -77,12 +44,7 @@ void MediaController::handleEarDetection(const QString &status)
if (shouldPause && isActiveOutputDeviceAirPods()) if (shouldPause && isActiveOutputDeviceAirPods())
{ {
QProcess process; if (getCurrentMediaState() == Playing)
process.start("playerctl", QStringList() << "status");
process.waitForFinished();
QString playbackStatus = process.readAllStandardOutput().trimmed();
LOG_DEBUG("Playback status: " << playbackStatus);
if (playbackStatus == "Playing")
{ {
pause(); pause();
} }
@@ -97,17 +59,7 @@ void MediaController::handleEarDetection(const QString &status)
// Resume if conditions are met and we previously paused // Resume if conditions are met and we previously paused
if (shouldResume && wasPausedByApp && isActiveOutputDeviceAirPods()) if (shouldResume && wasPausedByApp && isActiveOutputDeviceAirPods())
{ {
int result = QProcess::execute("playerctl", QStringList() << "play"); 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");
}
} }
} }
else else
@@ -124,16 +76,14 @@ void MediaController::setEarDetectionBehavior(EarDetectionBehavior behavior)
} }
void MediaController::followMediaChanges() { void MediaController::followMediaChanges() {
playerctlProcess = new QProcess(this); playerStatusWatcher = new PlayerStatusWatcher("", this);
connect(playerctlProcess, &QProcess::readyReadStandardOutput, this, connect(playerStatusWatcher, &PlayerStatusWatcher::playbackStatusChanged,
[this]() { this, [this](const QString &status)
QString output = {
playerctlProcess->readAllStandardOutput().trimmed(); LOG_DEBUG("Playback status changed: " << status);
LOG_DEBUG("Playerctl output: " << output); MediaState state = mediaStateFromPlayerctlOutput(status);
MediaState state = mediaStateFromPlayerctlOutput(output);
emit mediaStateChanged(state); emit mediaStateChanged(state);
}); });
playerctlProcess->start("playerctl", QStringList() << "--follow" << "status");
} }
bool MediaController::isActiveOutputDeviceAirPods() { bool MediaController::isActiveOutputDeviceAirPods() {
@@ -222,7 +172,7 @@ void MediaController::setConnectedDeviceMacAddress(const QString &macAddress) {
} }
MediaController::MediaState MediaController::mediaStateFromPlayerctlOutput( MediaController::MediaState MediaController::mediaStateFromPlayerctlOutput(
const QString &output) { const QString &output) const {
if (output == "Playing") { if (output == "Playing") {
return MediaState::Playing; return MediaState::Playing;
} else if (output == "Paused") { } else if (output == "Paused") {
@@ -232,28 +182,106 @@ MediaController::MediaState MediaController::mediaStateFromPlayerctlOutput(
} }
} }
void MediaController::pause() { MediaController::MediaState MediaController::getCurrentMediaState() const
int result = QProcess::execute("playerctl", QStringList() << "pause"); {
LOG_DEBUG("Executed 'playerctl pause' with result: " << result); return mediaStateFromPlayerctlOutput(PlayerStatusWatcher::getCurrentPlaybackStatus(""));
if (result == 0) }
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; wasPausedByApp = true;
} }
else else
{ {
LOG_ERROR("Failed to pause playback via Playerctl"); LOG_ERROR("Failed to pause playback via DBus");
} }
} }
MediaController::~MediaController() { MediaController::~MediaController() {
if (playerctlProcess) {
playerctlProcess->terminate();
if (!playerctlProcess->waitForFinished()) {
playerctlProcess->kill();
playerctlProcess->waitForFinished(1000);
}
}
} }
QString MediaController::getAudioDeviceName() QString MediaController::getAudioDeviceName()

View File

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