Compare commits
37 Commits
v0.1.0-rc.
...
nightly
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f547cc13c0 | ||
|
|
11fa9180e2 | ||
|
|
73e55a02d6 | ||
|
|
325ef1e953 | ||
|
|
5e30531514 | ||
|
|
75fa80c17e | ||
|
|
eb1b633aff | ||
|
|
dde5d1e808 | ||
|
|
598bd3d7d8 | ||
|
|
46071f17d7 | ||
|
|
13ab2d1feb | ||
|
|
72a7637863 | ||
|
|
24686da1f3 | ||
|
|
d9359cd81a | ||
|
|
db563fa75f | ||
|
|
fb3c8c73a4 | ||
|
|
05c0a7c88b | ||
|
|
96ee2410e8 | ||
|
|
c0d915666b | ||
|
|
91ffaaa972 | ||
|
|
48ae249405 | ||
|
|
aaf82c9738 | ||
|
|
38d6f8ceae | ||
|
|
5754dbfb16 | ||
|
|
3b20540c34 | ||
|
|
595797c703 | ||
|
|
2e782ba051 | ||
|
|
3023c706bf | ||
|
|
0d582d890b | ||
|
|
9b907fdec4 | ||
|
|
43d703423a | ||
|
|
dcb25e2e52 | ||
|
|
31397f055e | ||
|
|
070713540a | ||
|
|
6574e52195 | ||
|
|
c4633d6871 | ||
|
|
5dc7e512ae |
36
.github/workflows/ci-linux.yml
vendored
Normal 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
|
||||
@@ -1,4 +1,2 @@
|
||||
## btl2capfix v0.0.3
|
||||
- ([#34](https://github.com/kavishdevar/librepods/pull/34)) @devnoname120 Add on-device libbluetooth patcher using a Magisk/KernelSU module (arm64-only)
|
||||
|
||||
_[See more here](https://github.com/kavishdevar/librepods/releases)_
|
||||
## LibrePods root module changelog
|
||||
_[See here](https://github.com/kavishdevar/librepods/releases)_
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
[](https://github.com/kavishdevar/librepods/blob/main/LICENSE)
|
||||
[](https://github.com/kavishdevar/librepods/graphs/contributors)
|
||||
|
||||
|
||||
## What is LibrePods?
|
||||
|
||||
LibrePods unlocks Apple's exclusive AirPods features on non-Apple devices. Get access to noise control modes, adaptive transparency, ear detection, battery status, and more - all the premium features you paid for but Apple locked to their ecosystem.
|
||||
@@ -61,8 +62,8 @@ For installation and detailed info, see the [Linux README](/linux/README.md).
|
||||
|-------------------|-------------------|-------------------|
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  |  |  |
|
||||
|  | | |
|
||||
|  |  |  |
|
||||
|  |  | |
|
||||
|
||||
#### Root Requirement
|
||||
|
||||
@@ -149,3 +150,5 @@ GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program over [here](/LICENSE). If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
All trademarks, logos, and brand names are the property of their respective owners. Use of them does not imply any affiliation with or endorsement by them. All AirPods images, symbols, and the SF Pro font are the property of Apple Inc.
|
||||
|
||||
@@ -90,6 +90,13 @@
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="librepods"
|
||||
android:host="add-magic-keys" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
|
||||
@@ -303,15 +303,15 @@ uintptr_t getModuleBase(const char *module_name) {
|
||||
return base_addr;
|
||||
}
|
||||
|
||||
bool findAndHookFunction([[maybe_unused]] const char *library_path) {
|
||||
bool findAndHookFunction(const char *library_name) {
|
||||
if (!hook_func) {
|
||||
LOGE("Hook function not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
uintptr_t base_addr = getModuleBase("libbluetooth_jni.so");
|
||||
uintptr_t base_addr = getModuleBase(library_name);
|
||||
if (!base_addr) {
|
||||
LOGE("Failed to get base address of libbluetooth_jni.so");
|
||||
LOGE("Failed to get base address of %s", library_name);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -397,11 +397,18 @@ bool findAndHookFunction([[maybe_unused]] const char *library_path) {
|
||||
|
||||
void on_library_loaded(const char *name, [[maybe_unused]] void *handle) {
|
||||
if (strstr(name, "libbluetooth_jni.so")) {
|
||||
LOGI("Detected Bluetooth library: %s", name);
|
||||
LOGI("Detected Bluetooth JNI library: %s", name);
|
||||
|
||||
bool hooked = findAndHookFunction(name);
|
||||
bool hooked = findAndHookFunction("libbluetooth_jni.so");
|
||||
if (!hooked) {
|
||||
LOGE("Failed to hook Bluetooth library function");
|
||||
LOGE("Failed to hook Bluetooth JNI library function");
|
||||
}
|
||||
} else if (strstr(name, "libbluetooth_qti.so")) {
|
||||
LOGI("Detected Bluetooth QTI library: %s", name);
|
||||
|
||||
bool hooked = findAndHookFunction("libbluetooth_qti.so");
|
||||
if (!hooked) {
|
||||
LOGE("Failed to hook Bluetooth QTI library function");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,5 +420,4 @@ NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
|
||||
hook_func = entries->hook_func;
|
||||
|
||||
return on_library_loaded;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -103,6 +104,7 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.MultiplePermissionsState
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
|
||||
import me.kavishdevar.librepods.screens.AppSettingsScreen
|
||||
import me.kavishdevar.librepods.screens.DebugScreen
|
||||
@@ -113,9 +115,9 @@ import me.kavishdevar.librepods.screens.RenameScreen
|
||||
import me.kavishdevar.librepods.screens.TroubleshootingScreen
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.utils.CrossDevice
|
||||
import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
lateinit var serviceConnection: ServiceConnection
|
||||
@@ -140,6 +142,8 @@ class MainActivity : ComponentActivity() {
|
||||
Main()
|
||||
}
|
||||
}
|
||||
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -174,6 +178,73 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIncomingIntent(intent: Intent) {
|
||||
val data: Uri? = intent.data
|
||||
|
||||
if (data != null && data.scheme == "librepods") {
|
||||
when (data.host) {
|
||||
"add-magic-keys" -> {
|
||||
// Extract query parameters
|
||||
val queryParams = data.queryParameterNames
|
||||
queryParams.forEach { param ->
|
||||
val value = data.getQueryParameter(param)
|
||||
// Handle your parameters here
|
||||
Log.d("LibrePods", "Parameter: $param = $value")
|
||||
}
|
||||
|
||||
// Process the magic keys addition
|
||||
handleAddMagicKeys(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAddMagicKeys(uri: Uri) {
|
||||
val context = this
|
||||
val sharedPreferences = getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
val irkHex = uri.getQueryParameter("irk")
|
||||
val encKeyHex = uri.getQueryParameter("enc_key")
|
||||
|
||||
try {
|
||||
if (irkHex != null && validateHexInput(irkHex)) {
|
||||
val irkBytes = hexStringToByteArray(irkHex)
|
||||
val irkBase64 = Base64.encode(irkBytes)
|
||||
sharedPreferences.edit().putString("IRK", irkBase64).apply()
|
||||
}
|
||||
|
||||
if (encKeyHex != null && validateHexInput(encKeyHex)) {
|
||||
val encKeyBytes = hexStringToByteArray(encKeyHex)
|
||||
val encKeyBase64 = Base64.encode(encKeyBytes)
|
||||
sharedPreferences.edit().putString("ENC_KEY", encKeyBase64).apply()
|
||||
}
|
||||
|
||||
Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateHexInput(input: String): Boolean {
|
||||
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
|
||||
return hexPattern.matches(input)
|
||||
}
|
||||
|
||||
private fun hexStringToByteArray(hex: String): ByteArray {
|
||||
val result = ByteArray(16)
|
||||
for (i in 0 until 16) {
|
||||
val hexByte = hex.substring(i * 2, i * 2 + 2)
|
||||
result[i] = hexByte.toInt(16).toByte()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
||||
@@ -661,4 +732,3 @@ fun PermissionCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,22 +62,20 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
|
||||
import me.kavishdevar.librepods.composables.IconAreaSize
|
||||
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
|
||||
import me.kavishdevar.librepods.composables.IconAreaSize
|
||||
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
|
||||
|
||||
@@ -47,11 +47,11 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.Battery
|
||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.utils.Battery
|
||||
import me.kavishdevar.librepods.utils.BatteryComponent
|
||||
import me.kavishdevar.librepods.utils.BatteryStatus
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -56,7 +56,7 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
|
||||
private val ContainerColor = Color(0x593C3C3E)
|
||||
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
|
||||
|
||||
@@ -127,4 +127,4 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
|
||||
@Composable
|
||||
fun IndependentTogglePreview() {
|
||||
IndependentToggle("Test", AirPodsService(), "test", LocalContext.current.getSharedPreferences("preview", 0), true)
|
||||
}
|
||||
}
|
||||
@@ -73,10 +73,10 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
package me.kavishdevar.librepods.composables
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
@@ -57,6 +58,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.StemAction
|
||||
|
||||
@Composable
|
||||
fun PressAndHoldSettings(navController: NavController) {
|
||||
@@ -70,6 +72,24 @@ fun PressAndHoldSettings(navController: NavController) {
|
||||
val animatedLeftBackgroundColor by animateColorAsState(targetValue = leftBackgroundColor, animationSpec = animationSpec)
|
||||
val animatedRightBackgroundColor by animateColorAsState(targetValue = rightBackgroundColor, animationSpec = animationSpec)
|
||||
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
|
||||
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||
else -> "INVALID!!"
|
||||
}
|
||||
|
||||
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
|
||||
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
|
||||
else -> "INVALID!!"
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.press_and_hold_airpods).uppercase(),
|
||||
style = TextStyle(
|
||||
@@ -122,7 +142,7 @@ fun PressAndHoldSettings(navController: NavController) {
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
text = leftActionText,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
@@ -182,7 +202,7 @@ fun PressAndHoldSettings(navController: NavController) {
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
text = rightActionText,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
|
||||
@@ -16,10 +16,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
|
||||
@file:Suppress("unused")
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
package me.kavishdevar.librepods.constants
|
||||
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
@@ -27,27 +24,10 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
enum class Enums(val value: ByteArray) {
|
||||
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
|
||||
CONVERSATION_AWARENESS(Capabilities.CONVERSATION_AWARENESS),
|
||||
CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY(Capabilities.CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY),
|
||||
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
||||
SETTINGS(byteArrayOf(0x09, 0x00)),
|
||||
SUFFIX(byteArrayOf(0x00, 0x00, 0x00)),
|
||||
NOTIFICATION_FILTER(byteArrayOf(0x0f)),
|
||||
HANDSHAKE(byteArrayOf(0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||
SPECIFIC_FEATURES(byteArrayOf(0x4d)),
|
||||
SET_SPECIFIC_FEATURES(PREFIX.value + SPECIFIC_FEATURES.value + byteArrayOf(0x00,
|
||||
0xff.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)),
|
||||
REQUEST_NOTIFICATIONS(PREFIX.value + NOTIFICATION_FILTER.value + byteArrayOf(0x00, 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte())),
|
||||
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
|
||||
NOISE_CANCELLATION_OFF(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.OFF.value + SUFFIX.value),
|
||||
NOISE_CANCELLATION_ON(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.ON.value + SUFFIX.value),
|
||||
NOISE_CANCELLATION_TRANSPARENCY(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.TRANSPARENCY.value + SUFFIX.value),
|
||||
NOISE_CANCELLATION_ADAPTIVE(NOISE_CANCELLATION_PREFIX.value + Capabilities.NoiseCancellation.ADAPTIVE.value + SUFFIX.value),
|
||||
SET_CONVERSATION_AWARENESS_OFF(PREFIX.value + SETTINGS.value + CONVERSATION_AWARENESS.value + Capabilities.ConversationAwareness.OFF.value + SUFFIX.value),
|
||||
SET_CONVERSATION_AWARENESS_ON(PREFIX.value + SETTINGS.value + CONVERSATION_AWARENESS.value + Capabilities.ConversationAwareness.ON.value + SUFFIX.value),
|
||||
CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00)),
|
||||
START_HEAD_TRACKING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00)),
|
||||
STOP_HEAD_TRACKING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E.toByte(), 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E.toByte(), 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00));
|
||||
}
|
||||
|
||||
object BatteryComponent {
|
||||
@@ -156,7 +136,7 @@ class AirPodsNotifications {
|
||||
}
|
||||
|
||||
val name: String =
|
||||
when (status) {
|
||||
when (status) {
|
||||
1 -> "OFF"
|
||||
2 -> "ON"
|
||||
3 -> "TRANSPARENCY"
|
||||
@@ -251,103 +231,10 @@ class AirPodsNotifications {
|
||||
class Capabilities {
|
||||
companion object {
|
||||
val NOISE_CANCELLATION = byteArrayOf(0x0d)
|
||||
val CONVERSATION_AWARENESS = byteArrayOf(0x28)
|
||||
val CUSTOMIZABLE_ADAPTIVE_TRANSPARENCY = byteArrayOf(0x01, 0x02)
|
||||
val EAR_DETECTION = byteArrayOf(0x06)
|
||||
}
|
||||
|
||||
enum class NoiseCancellation(val value: ByteArray) {
|
||||
OFF(byteArrayOf(0x01)),
|
||||
ON(byteArrayOf(0x02)),
|
||||
TRANSPARENCY(byteArrayOf(0x03)),
|
||||
ADAPTIVE(byteArrayOf(0x04));
|
||||
}
|
||||
|
||||
enum class ConversationAwareness(val value: ByteArray) {
|
||||
OFF(byteArrayOf(0x02)),
|
||||
ON(byteArrayOf(0x01));
|
||||
}
|
||||
}
|
||||
|
||||
enum class LongPressPackets(val value: ByteArray) {
|
||||
ENABLE_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0F, 0x00, 0x00, 0x00)),
|
||||
|
||||
DISABLE_OFF_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
|
||||
DISABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0c, 0x00, 0x00, 0x00)),
|
||||
DISABLE_OFF_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
|
||||
DISABLE_OFF_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0a, 0x00, 0x00, 0x00)),
|
||||
|
||||
ENABLE_OFF_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
|
||||
ENABLE_OFF_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
|
||||
ENABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
|
||||
|
||||
DISABLE_TRANSPARENCY_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
|
||||
DISABLE_TRANSPARENCY_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x03, 0x00, 0x00, 0x00)),
|
||||
DISABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0a, 0x00, 0x00, 0x00)),
|
||||
DISABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x09, 0x00, 0x00, 0x00)),
|
||||
|
||||
ENABLE_TRANSPARENCY_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
|
||||
ENABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
|
||||
ENABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
|
||||
|
||||
DISABLE_ANC_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0D, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ANC_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x05, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ANC_FROM_ADAPTIVE_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0c, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ANC_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x09, 0x00, 0x00, 0x00)),
|
||||
|
||||
ENABLE_ANC_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
|
||||
ENABLE_ANC_FROM_ADAPTIVE_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
|
||||
ENABLE_ANC_FROM_OFF_AND_ADAPTIVE(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
|
||||
|
||||
DISABLE_ADAPTIVE_FROM_EVERYTHING(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x07, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x05, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ADAPTIVE_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x03, 0x00, 0x00, 0x00)),
|
||||
|
||||
ENABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0d, 0x00, 0x00, 0x00)),
|
||||
ENABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0e, 0x00, 0x00, 0x00)),
|
||||
ENABLE_ADAPTIVE_FROM_OFF_AND_ANC(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0b, 0x00, 0x00, 0x00)),
|
||||
|
||||
ENABLE_EVERYTHING_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0E, 0x00, 0x00, 0x00)),
|
||||
DISABLE_TRANSPARENCY_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0A, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ADAPTIVE_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x06, 0x00, 0x00, 0x00)),
|
||||
DISABLE_ANC_OFF_DISABLED(byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A, 0x0C, 0x00, 0x00, 0x00)),
|
||||
}
|
||||
|
||||
//enum class LongPressMode {
|
||||
// OFF, TRANSPARENCY, ADAPTIVE, ANC
|
||||
//}
|
||||
//
|
||||
//data class LongPressPacket(val modes: Set<LongPressMode>) {
|
||||
// val value: ByteArray
|
||||
// get() {
|
||||
// val baseArray = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1A)
|
||||
// val modeByte = calculateModeByte()
|
||||
// return baseArray + byteArrayOf(modeByte, 0x00, 0x00, 0x00)
|
||||
// }
|
||||
//
|
||||
// private fun calculateModeByte(): Byte {
|
||||
// var modeByte: Byte = 0x00
|
||||
// modes.forEach { mode ->
|
||||
// modeByte = when (mode) {
|
||||
// LongPressMode.OFF -> (modeByte + 0x01).toByte()
|
||||
// LongPressMode.TRANSPARENCY -> (modeByte + 0x02).toByte()
|
||||
// LongPressMode.ADAPTIVE -> (modeByte + 0x04).toByte()
|
||||
// LongPressMode.ANC -> (modeByte + 0x08).toByte()
|
||||
// }
|
||||
// }
|
||||
// return modeByte
|
||||
// }
|
||||
//}
|
||||
//
|
||||
//fun determinePacket(changedIndex: Int, newEnabled: Boolean, oldModes: Set<LongPressMode>, newModes: Set<LongPressMode>): ByteArray? {
|
||||
// return if (newEnabled) {
|
||||
// LongPressPacket(oldModes + newModes.elementAt(changedIndex)).value
|
||||
// } else {
|
||||
// LongPressPacket(oldModes - newModes.elementAt(changedIndex)).value
|
||||
// }
|
||||
//}
|
||||
|
||||
fun isHeadTrackingData(data: ByteArray): Boolean {
|
||||
if (data.size <= 60) return false
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -99,10 +99,10 @@ import me.kavishdevar.librepods.composables.NameField
|
||||
import me.kavishdevar.librepods.composables.NavigationButton
|
||||
import me.kavishdevar.librepods.composables.NoiseControlSettings
|
||||
import me.kavishdevar.librepods.composables.PressAndHoldSettings
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@@ -113,6 +113,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
var isLocallyConnected by remember { mutableStateOf(isConnected) }
|
||||
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
val bleOnlyMode = sharedPreferences.getBoolean("ble_only_mode", false)
|
||||
var device by remember { mutableStateOf(dev) }
|
||||
var deviceName by remember {
|
||||
mutableStateOf(
|
||||
@@ -329,35 +330,67 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
NameField(
|
||||
name = stringResource(R.string.name),
|
||||
value = deviceName.text,
|
||||
navController = navController
|
||||
)
|
||||
// Show BLE-only mode indicator
|
||||
if (bleOnlyMode) {
|
||||
Text(
|
||||
text = "BLE-only mode - advanced features disabled",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
NoiseControlSettings(service = service)
|
||||
// Only show name field when not in BLE-only mode
|
||||
if (!bleOnlyMode) {
|
||||
NameField(
|
||||
name = stringResource(R.string.name),
|
||||
value = deviceName.text,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.head_gestures).uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp)
|
||||
)
|
||||
// Only show L2CAP-dependent features when not in BLE-only mode
|
||||
if (!bleOnlyMode) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
NoiseControlSettings(service = service)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
NavigationButton(to = "head_tracking", "Head Tracking", navController)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.head_gestures).uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
PressAndHoldSettings(navController = navController)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
NavigationButton(to = "head_tracking", "Head Tracking", navController)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AudioSettings()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
PressAndHoldSettings(navController = navController)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AudioSettings()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IndependentToggle(
|
||||
name = "Off Listening Mode",
|
||||
service = service,
|
||||
sharedPreferences = sharedPreferences,
|
||||
default = false,
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AccessibilitySettings()
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IndependentToggle(
|
||||
@@ -365,23 +398,15 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
|
||||
service = service,
|
||||
functionName = "setEarDetection",
|
||||
sharedPreferences = sharedPreferences,
|
||||
default = true
|
||||
default = true,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IndependentToggle(
|
||||
name = "Off Listening Mode",
|
||||
service = service,
|
||||
sharedPreferences = sharedPreferences,
|
||||
default = false,
|
||||
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||
)
|
||||
// Only show debug when not in BLE-only mode
|
||||
if (!bleOnlyMode) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
NavigationButton("debug", "Debug", navController)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AccessibilitySettings()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
NavigationButton("debug", "Debug", navController)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,21 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
mutableStateOf(sharedPreferences.getBoolean("takeover_when_media_start", true))
|
||||
}
|
||||
|
||||
var useAlternateHeadTrackingPackets by remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false))
|
||||
}
|
||||
|
||||
var bleOnlyMode by remember {
|
||||
mutableStateOf(sharedPreferences.getBoolean("ble_only_mode", false))
|
||||
}
|
||||
|
||||
// Ensure the default value is properly set if not exists
|
||||
LaunchedEffect(Unit) {
|
||||
if (!sharedPreferences.contains("ble_only_mode")) {
|
||||
sharedPreferences.edit().putBoolean("ble_only_mode", false).apply()
|
||||
}
|
||||
}
|
||||
|
||||
var mDensity by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
fun validateHexInput(input: String): Boolean {
|
||||
@@ -331,6 +346,69 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Connection Mode".uppercase(),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(14.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
bleOnlyMode = !bleOnlyMode
|
||||
sharedPreferences.edit().putBoolean("ble_only_mode", bleOnlyMode).apply()
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "BLE Only Mode",
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Text(
|
||||
text = "Only use Bluetooth Low Energy for battery data and ear detection. Disables advanced features requiring L2CAP connection.",
|
||||
fontSize = 13.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 16.sp,
|
||||
)
|
||||
}
|
||||
|
||||
StyledSwitch(
|
||||
checked = bleOnlyMode,
|
||||
onCheckedChange = {
|
||||
bleOnlyMode = it
|
||||
sharedPreferences.edit().putBoolean("ble_only_mode", it).apply()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Conversational Awareness".uppercase(),
|
||||
style = TextStyle(
|
||||
@@ -1040,6 +1118,47 @@ fun AppSettingsScreen(navController: NavController) {
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
useAlternateHeadTrackingPackets = !useAlternateHeadTrackingPackets
|
||||
sharedPreferences.edit().putBoolean("use_alternate_head_tracking_packets", useAlternateHeadTrackingPackets).apply()
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Use alternate head tracking packets",
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Enable this if head tracking doesn't work for you. This sends different data to AirPods for requesting/stopping head tracking data.",
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(0.6f),
|
||||
lineHeight = 16.sp,
|
||||
)
|
||||
}
|
||||
|
||||
StyledSwitch(
|
||||
checked = useAlternateHeadTrackingPackets,
|
||||
onCheckedChange = {
|
||||
useAlternateHeadTrackingPackets = it
|
||||
sharedPreferences.edit().putBoolean("use_alternate_head_tracking_packets", it).apply()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
|
||||
@@ -100,9 +100,9 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
||||
import me.kavishdevar.librepods.constants.isHeadTrackingData
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.BatteryStatus
|
||||
import me.kavishdevar.librepods.utils.isHeadTrackingData
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
data class PacketInfo(
|
||||
|
||||
@@ -57,10 +57,9 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
@@ -69,6 +68,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.StemAction
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import kotlin.experimental.and
|
||||
@@ -84,6 +84,16 @@ fun RightDivider() {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable()
|
||||
fun RightDividerNoIcon() {
|
||||
HorizontalDivider(
|
||||
thickness = 1.5.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier
|
||||
.padding(start = 20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LongPress(navController: NavController, name: String) {
|
||||
@@ -104,6 +114,10 @@ fun LongPress(navController: NavController, name: String) {
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val deviceName = sharedPreferences.getString("name", "AirPods Pro")
|
||||
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
|
||||
val longPressActionPref = sharedPreferences.getString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name)
|
||||
Log.d("PressAndHoldSettingsScreen", "Long press action preference ($prefKey): $longPressActionPref")
|
||||
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
|
||||
Scaffold(
|
||||
topBar = {
|
||||
CenterAlignedTopAppBar(
|
||||
@@ -153,56 +167,88 @@ fun LongPress(navController: NavController, name: String) {
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "NOISE CONTROL",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier
|
||||
.padding(8.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(14.dp)),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
val offListeningMode = offListeningModeValue == 1.toByte()
|
||||
LongPressElement(
|
||||
name = "Off",
|
||||
enabled = offListeningMode,
|
||||
resourceId = R.drawable.noise_cancellation,
|
||||
isFirst = true)
|
||||
if (offListeningMode) RightDivider()
|
||||
LongPressElement(
|
||||
name = "Transparency",
|
||||
resourceId = R.drawable.transparency,
|
||||
isFirst = !offListeningMode)
|
||||
RightDivider()
|
||||
LongPressElement(
|
||||
name = "Adaptive",
|
||||
resourceId = R.drawable.adaptive)
|
||||
RightDivider()
|
||||
LongPressElement(
|
||||
name = "Noise Cancellation",
|
||||
resourceId = R.drawable.noise_cancellation,
|
||||
isLast = true)
|
||||
LongPressActionElement(
|
||||
name = "Noise Control",
|
||||
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||
onClick = {
|
||||
longPressAction = StemAction.CYCLE_NOISE_CONTROL_MODES
|
||||
sharedPreferences.edit().putString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name).apply()
|
||||
},
|
||||
isFirst = true,
|
||||
isLast = false
|
||||
)
|
||||
RightDividerNoIcon()
|
||||
LongPressActionElement(
|
||||
name = "Digital Assistant",
|
||||
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
|
||||
onClick = {
|
||||
longPressAction = StemAction.DIGITAL_ASSISTANT
|
||||
sharedPreferences.edit().putString(prefKey, StemAction.DIGITAL_ASSISTANT.name).apply()
|
||||
},
|
||||
isFirst = false,
|
||||
isLast = true
|
||||
)
|
||||
}
|
||||
|
||||
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
|
||||
Text(
|
||||
text = "NOISE CONTROL",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier
|
||||
.padding(top = 32.dp, bottom = 4.dp)
|
||||
.padding(horizontal = 8.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(14.dp)),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
|
||||
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
|
||||
val offListeningMode = offListeningModeValue == 1.toByte()
|
||||
LongPressElement(
|
||||
name = "Off",
|
||||
enabled = offListeningMode,
|
||||
resourceId = R.drawable.noise_cancellation,
|
||||
isFirst = true)
|
||||
if (offListeningMode) RightDivider()
|
||||
LongPressElement(
|
||||
name = "Transparency",
|
||||
resourceId = R.drawable.transparency,
|
||||
isFirst = !offListeningMode)
|
||||
RightDivider()
|
||||
LongPressElement(
|
||||
name = "Adaptive",
|
||||
resourceId = R.drawable.adaptive)
|
||||
RightDivider()
|
||||
LongPressElement(
|
||||
name = "Noise Cancellation",
|
||||
resourceId = R.drawable.noise_cancellation,
|
||||
isLast = true)
|
||||
}
|
||||
Text(
|
||||
"Press and hold the stem to cycle between the selected noise control modes.",
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, top = 4.dp)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Press and hold the stem to cycle between the selected noise control modes.",
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
modifier = Modifier
|
||||
.padding(start = 16.dp, top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
|
||||
@@ -336,7 +382,7 @@ fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isF
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Icon(
|
||||
bitmap = ImageBitmap.imageResource(resourceId),
|
||||
painter = painterResource(resourceId),
|
||||
contentDescription = "Icon",
|
||||
tint = Color(0xFF007AFF),
|
||||
modifier = Modifier
|
||||
@@ -384,3 +430,67 @@ fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LongPressActionElement(
|
||||
name: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
isFirst: Boolean = false,
|
||||
isLast: Boolean = false
|
||||
) {
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val shape = when {
|
||||
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
|
||||
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
|
||||
else -> RoundedCornerShape(0.dp)
|
||||
}
|
||||
var backgroundColor by remember { mutableStateOf(if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
|
||||
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(48.dp)
|
||||
.background(animatedBackgroundColor, shape)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
tryAwaitRelease()
|
||||
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
onClick()
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 16.dp, vertical = 0.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
name,
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 4.dp)
|
||||
)
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = { onClick() },
|
||||
colors = CheckboxDefaults.colors().copy(
|
||||
checkedCheckmarkColor = Color(0xFF007AFF),
|
||||
uncheckedCheckmarkColor = Color.Transparent,
|
||||
checkedBoxColor = Color.Transparent,
|
||||
uncheckedBoxColor = Color.Transparent,
|
||||
checkedBorderColor = Color.Transparent,
|
||||
uncheckedBorderColor = Color.Transparent,
|
||||
disabledBorderColor = Color.Transparent,
|
||||
disabledCheckedBoxColor = Color.Transparent,
|
||||
disabledUncheckedBoxColor = Color.Transparent,
|
||||
disabledUncheckedBorderColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier
|
||||
.height(24.dp)
|
||||
.scale(1.5f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,9 @@ import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.NoiseControlMode
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.utils.NoiseControlMode
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
|
||||
@@ -77,12 +77,15 @@ import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import me.kavishdevar.librepods.MainActivity
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.Battery
|
||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
||||
import me.kavishdevar.librepods.constants.StemAction
|
||||
import me.kavishdevar.librepods.constants.isHeadTrackingData
|
||||
import me.kavishdevar.librepods.utils.AACPManager
|
||||
import me.kavishdevar.librepods.utils.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
|
||||
import me.kavishdevar.librepods.utils.BLEManager
|
||||
import me.kavishdevar.librepods.utils.Battery
|
||||
import me.kavishdevar.librepods.utils.BatteryComponent
|
||||
import me.kavishdevar.librepods.utils.BatteryStatus
|
||||
import me.kavishdevar.librepods.utils.BluetoothConnectionManager
|
||||
import me.kavishdevar.librepods.utils.CrossDevice
|
||||
import me.kavishdevar.librepods.utils.CrossDevicePackets
|
||||
@@ -111,7 +114,6 @@ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_
|
||||
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_CHARGING
|
||||
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ICON
|
||||
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
|
||||
import me.kavishdevar.librepods.utils.isHeadTrackingData
|
||||
import me.kavishdevar.librepods.widgets.BatteryWidget
|
||||
import me.kavishdevar.librepods.widgets.NoiseControlWidget
|
||||
import org.lsposed.hiddenapibypass.HiddenApiBypass
|
||||
@@ -142,7 +144,7 @@ object ServiceManager {
|
||||
class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
var macAddress = ""
|
||||
lateinit var aacpManager: AACPManager
|
||||
|
||||
var cameraActive = false
|
||||
data class ServiceConfig(
|
||||
var deviceName: String = "AirPods",
|
||||
var earDetectionEnabled: Boolean = true,
|
||||
@@ -154,6 +156,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
var conversationalAwarenessVolume: Int = 43,
|
||||
var textColor: Long = -1L,
|
||||
var qsClickBehavior: String = "cycle",
|
||||
var bleOnlyMode: Boolean = false,
|
||||
|
||||
// AirPods state-based takeover
|
||||
var takeoverWhenDisconnected: Boolean = true,
|
||||
@@ -163,7 +166,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
// Phone state-based takeover
|
||||
var takeoverWhenRingingCall: Boolean = true,
|
||||
var takeoverWhenMediaStart: Boolean = true
|
||||
var takeoverWhenMediaStart: Boolean = true,
|
||||
|
||||
var leftSinglePressAction: StemAction = StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!,
|
||||
var rightSinglePressAction: StemAction = StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!,
|
||||
|
||||
var leftDoublePressAction: StemAction = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!,
|
||||
var rightDoublePressAction: StemAction = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!,
|
||||
|
||||
var leftTriplePressAction: StemAction = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!,
|
||||
var rightTriplePressAction: StemAction = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!,
|
||||
|
||||
var leftLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
|
||||
var rightLongPressAction: StemAction = StemAction.defaultActions[StemPressType.LONG_PRESS]!!,
|
||||
)
|
||||
|
||||
private lateinit var config: ServiceConfig
|
||||
@@ -192,7 +207,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
device: BLEManager.AirPodsStatus,
|
||||
previousStatus: BLEManager.AirPodsStatus?
|
||||
) {
|
||||
if (device.connectionState == "Disconnected") {
|
||||
// Store MAC address for BLE-only mode if not already stored
|
||||
if (config.bleOnlyMode && macAddress.isEmpty()) {
|
||||
macAddress = device.address
|
||||
sharedPreferences.edit {
|
||||
putString("mac_address", macAddress)
|
||||
}
|
||||
Log.d("AirPodsBLEService", "BLE-only mode: stored MAC address ${device.address}")
|
||||
}
|
||||
|
||||
if (device.connectionState == "Disconnected" && !config.bleOnlyMode) {
|
||||
Log.d("AirPodsBLEService", "Seems no device has taken over, we will.")
|
||||
val bluetoothManager = getSystemService(BluetoothManager::class.java)
|
||||
val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(sharedPreferences.getString(
|
||||
@@ -259,7 +283,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
leftInEar: Boolean,
|
||||
rightInEar: Boolean
|
||||
) {
|
||||
Log.d("AirPodsBLEService", "Ear state changed")
|
||||
Log.d("AirPodsBLEService", "Ear state changed - Left: $leftInEar, Right: $rightInEar")
|
||||
|
||||
// In BLE-only mode, ear detection is purely based on BLE data
|
||||
if (config.bleOnlyMode) {
|
||||
Log.d("AirPodsBLEService", "BLE-only mode: ear detection from BLE data")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
|
||||
@@ -300,6 +329,67 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
||||
}
|
||||
|
||||
fun cameraOpened() {
|
||||
Log.d("AirPodsService", "Camera opened, gonna handle stem presses and take action if enabled")
|
||||
val isCameraShutterUsed = listOf(
|
||||
config.leftSinglePressAction,
|
||||
config.rightSinglePressAction,
|
||||
config.leftDoublePressAction,
|
||||
config.rightDoublePressAction,
|
||||
config.leftTriplePressAction,
|
||||
config.rightTriplePressAction,
|
||||
config.leftLongPressAction,
|
||||
config.rightLongPressAction
|
||||
).any { it == StemAction.CAMERA_SHUTTER }
|
||||
|
||||
if (isCameraShutterUsed) {
|
||||
Log.d("AirPodsService", "Camera opened, setting up stem actions")
|
||||
cameraActive = true
|
||||
setupStemActions(isCameraActive = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun cameraClosed() {
|
||||
cameraActive = false
|
||||
setupStemActions()
|
||||
}
|
||||
|
||||
fun isCustomAction(
|
||||
action: StemAction?,
|
||||
default: StemAction?,
|
||||
isCameraActive: Boolean = false
|
||||
): Boolean {
|
||||
Log.d("AirPodsService", "Checking if action $action is custom against default $default, camera active: $isCameraActive")
|
||||
return action != default && (action != StemAction.CAMERA_SHUTTER || isCameraActive)
|
||||
}
|
||||
|
||||
fun setupStemActions(isCameraActive: Boolean = false) {
|
||||
val singlePressDefault = StemAction.defaultActions[StemPressType.SINGLE_PRESS]
|
||||
val doublePressDefault = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]
|
||||
val triplePressDefault = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]
|
||||
val longPressDefault = StemAction.defaultActions[StemPressType.LONG_PRESS]
|
||||
|
||||
val singlePressCustomized = isCustomAction(config.leftSinglePressAction, singlePressDefault, isCameraActive) ||
|
||||
isCustomAction(config.rightSinglePressAction, singlePressDefault, isCameraActive)
|
||||
val doublePressCustomized = isCustomAction(config.leftDoublePressAction, doublePressDefault, isCameraActive) ||
|
||||
isCustomAction(config.rightDoublePressAction, doublePressDefault, isCameraActive)
|
||||
val triplePressCustomized = isCustomAction(config.leftTriplePressAction, triplePressDefault, isCameraActive) ||
|
||||
isCustomAction(config.rightTriplePressAction, triplePressDefault, isCameraActive)
|
||||
val longPressCustomized = isCustomAction(config.leftLongPressAction, longPressDefault, isCameraActive) ||
|
||||
isCustomAction(config.rightLongPressAction, longPressDefault, isCameraActive)
|
||||
Log.d("AirPodsService", "Setting up stem actions: " +
|
||||
"Single Press Customized: $singlePressCustomized, " +
|
||||
"Double Press Customized: $doublePressCustomized, " +
|
||||
"Triple Press Customized: $triplePressCustomized, " +
|
||||
"Long Press Customized: $longPressCustomized")
|
||||
aacpManager.sendStemConfigPacket(
|
||||
singlePressCustomized,
|
||||
doublePressCustomized,
|
||||
triplePressCustomized,
|
||||
longPressCustomized,
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalEncodingApi
|
||||
private fun initializeAACPManagerCallback() {
|
||||
aacpManager.setPacketCallback(object : AACPManager.PacketCallback {
|
||||
@@ -398,12 +488,58 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStemPressReceived(stemPress: ByteArray) {
|
||||
val (stemPressType, bud) = aacpManager.parseStemPressResponse(stemPress)
|
||||
|
||||
Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud")
|
||||
|
||||
val action = getActionFor(bud, stemPressType)
|
||||
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
|
||||
|
||||
action?.let { executeStemAction(it) }
|
||||
}
|
||||
|
||||
override fun onUnknownPacketReceived(packet: ByteArray) {
|
||||
Log.d("AACPManager", "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun getActionFor(bud: AACPManager.Companion.StemPressBudType, type: StemPressType): StemAction? {
|
||||
return when (type) {
|
||||
StemPressType.SINGLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftSinglePressAction else config.rightSinglePressAction
|
||||
StemPressType.DOUBLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftDoublePressAction else config.rightDoublePressAction
|
||||
StemPressType.TRIPLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftTriplePressAction else config.rightTriplePressAction
|
||||
StemPressType.LONG_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftLongPressAction else config.rightLongPressAction
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeStemAction(action: StemAction) {
|
||||
when (action) {
|
||||
StemAction.defaultActions[StemPressType.SINGLE_PRESS] -> {
|
||||
Log.d("AirPodsParser", "Default single press action: Play/Pause, not taking action.")
|
||||
}
|
||||
StemAction.PLAY_PAUSE -> MediaController.sendPlayPause()
|
||||
StemAction.PREVIOUS_TRACK -> MediaController.sendPreviousTrack()
|
||||
StemAction.NEXT_TRACK -> MediaController.sendNextTrack()
|
||||
StemAction.CAMERA_SHUTTER -> Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
|
||||
StemAction.DIGITAL_ASSISTANT -> {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
val intent = Intent(Intent.ACTION_VOICE_COMMAND).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
startActivity(intent)
|
||||
} else {
|
||||
Log.w("AirPodsParser", "Digital Assistant action is not supported on this Android version.")
|
||||
}
|
||||
}
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES -> {
|
||||
Log.d("AirPodsParser", "Cycling noise control modes")
|
||||
sendBroadcast(Intent("me.kavishdevar.librepods.SET_ANC_MODE"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun processEarDetectionChange(earDetection: ByteArray) {
|
||||
var inEar = false
|
||||
var inEarData = listOf(earDetectionNotification.status[0] == 0x00.toByte(), earDetectionNotification.status[1] == 0x00.toByte())
|
||||
@@ -513,6 +649,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43),
|
||||
textColor = sharedPreferences.getLong("textColor", -1L),
|
||||
qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle",
|
||||
bleOnlyMode = sharedPreferences.getBoolean("ble_only_mode", false),
|
||||
|
||||
// AirPods state-based takeover
|
||||
takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", true),
|
||||
@@ -522,7 +659,20 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
// Phone state-based takeover
|
||||
takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", true),
|
||||
takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", true)
|
||||
takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", true),
|
||||
|
||||
// Stem actions
|
||||
leftSinglePressAction = StemAction.fromString(sharedPreferences.getString("left_single_press_action", "PLAY_PAUSE") ?: "PLAY_PAUSE")!!,
|
||||
rightSinglePressAction = StemAction.fromString(sharedPreferences.getString("right_single_press_action", "PLAY_PAUSE") ?: "PLAY_PAUSE")!!,
|
||||
|
||||
leftDoublePressAction = StemAction.fromString(sharedPreferences.getString("left_double_press_action", "PREVIOUS_TRACK") ?: "NEXT_TRACK")!!,
|
||||
rightDoublePressAction = StemAction.fromString(sharedPreferences.getString("right_double_press_action", "NEXT_TRACK") ?: "NEXT_TRACK")!!,
|
||||
|
||||
leftTriplePressAction = StemAction.fromString(sharedPreferences.getString("left_triple_press_action", "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK")!!,
|
||||
rightTriplePressAction = StemAction.fromString(sharedPreferences.getString("right_triple_press_action", "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK")!!,
|
||||
|
||||
leftLongPressAction = StemAction.fromString(sharedPreferences.getString("left_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")!!,
|
||||
rightLongPressAction = StemAction.fromString(sharedPreferences.getString("right_long_press_action", "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT")!!
|
||||
)
|
||||
}
|
||||
|
||||
@@ -544,6 +694,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
"conversational_awareness_volume" -> config.conversationalAwarenessVolume = preferences.getInt(key, 43)
|
||||
"textColor" -> config.textColor = preferences.getLong(key, -1L)
|
||||
"qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle"
|
||||
"ble_only_mode" -> config.bleOnlyMode = preferences.getBoolean(key, false)
|
||||
|
||||
// AirPods state-based takeover
|
||||
"takeover_when_disconnected" -> config.takeoverWhenDisconnected = preferences.getBoolean(key, true)
|
||||
@@ -554,6 +705,55 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
// Phone state-based takeover
|
||||
"takeover_when_ringing_call" -> config.takeoverWhenRingingCall = preferences.getBoolean(key, true)
|
||||
"takeover_when_media_start" -> config.takeoverWhenMediaStart = preferences.getBoolean(key, true)
|
||||
|
||||
"left_single_press_action" -> {
|
||||
config.leftSinglePressAction = StemAction.fromString(
|
||||
preferences.getString(key, "PLAY_PAUSE") ?: "PLAY_PAUSE"
|
||||
)!!
|
||||
setupStemActions()
|
||||
}
|
||||
"right_single_press_action" -> {
|
||||
config.rightSinglePressAction = StemAction.fromString(
|
||||
preferences.getString(key, "PLAY_PAUSE") ?: "PLAY_PAUSE"
|
||||
)!!
|
||||
setupStemActions()
|
||||
}
|
||||
"left_double_press_action" -> {
|
||||
config.leftDoublePressAction = StemAction.fromString(
|
||||
preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK"
|
||||
)!!
|
||||
setupStemActions()
|
||||
}
|
||||
"right_double_press_action" -> {
|
||||
config.rightDoublePressAction = StemAction.fromString(
|
||||
preferences.getString(key, "NEXT_TRACK") ?: "NEXT_TRACK"
|
||||
)!!
|
||||
setupStemActions()
|
||||
}
|
||||
"left_triple_press_action" -> {
|
||||
config.leftTriplePressAction = StemAction.fromString(
|
||||
preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK"
|
||||
)!!
|
||||
setupStemActions()
|
||||
}
|
||||
"right_triple_press_action" -> {
|
||||
config.rightTriplePressAction = StemAction.fromString(
|
||||
preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK"
|
||||
)!!
|
||||
setupStemActions()
|
||||
}
|
||||
"left_long_press_action" -> {
|
||||
config.leftLongPressAction = StemAction.fromString(
|
||||
preferences.getString(key, "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES"
|
||||
)!!
|
||||
setupStemActions()
|
||||
}
|
||||
"right_long_press_action" -> {
|
||||
config.rightLongPressAction = StemAction.fromString(
|
||||
preferences.getString(key, "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT"
|
||||
)!!
|
||||
setupStemActions()
|
||||
}
|
||||
}
|
||||
|
||||
if (key == "mac_address") {
|
||||
@@ -1002,10 +1202,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
if (!::socket.isInitialized) {
|
||||
if (!::socket.isInitialized && !config.bleOnlyMode) {
|
||||
return
|
||||
}
|
||||
if (connected && socket.isConnected) {
|
||||
if (connected && (config.bleOnlyMode || socket.isConnected)) {
|
||||
updatedNotification = NotificationCompat.Builder(this, "airpods_connection_status")
|
||||
.setSmallIcon(R.drawable.airpods)
|
||||
.setContentTitle(airpodsName ?: config.deviceName)
|
||||
@@ -1057,7 +1257,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
notificationManager.notify(1, updatedNotification)
|
||||
notificationManager.cancel(2)
|
||||
} else if (!socket.isConnected && isConnectedLocally) {
|
||||
} else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) {
|
||||
Log.d("AirPodsService", "<LogCollector:Complete:Failed> Socket not connected")
|
||||
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
|
||||
}
|
||||
@@ -1068,7 +1268,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
if (isInCall) return
|
||||
if (config.headGestures) {
|
||||
initGestureDetector()
|
||||
aacpManager.sendStartHeadTracking()
|
||||
startHeadTracking()
|
||||
gestureDetector?.startDetection { accepted ->
|
||||
if (accepted) {
|
||||
answerCall()
|
||||
@@ -1374,8 +1574,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
val ancModeFilter = IntentFilter("me.kavishdevar.librepods.SET_ANC_MODE")
|
||||
var ancModeReceiver: BroadcastReceiver? = null
|
||||
|
||||
|
||||
|
||||
@SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d("AirPodsService", "Service started")
|
||||
@@ -1426,6 +1624,24 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
if (!contains("qs_click_behavior")) editor.putString("qs_click_behavior", "cycle")
|
||||
if (!contains("name")) editor.putString("name", "AirPods")
|
||||
if (!contains("ble_only_mode")) editor.putBoolean("ble_only_mode", false)
|
||||
|
||||
if (!contains("left_single_press_action")) editor.putString("left_single_press_action",
|
||||
StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name)
|
||||
if (!contains("right_single_press_action")) editor.putString("right_single_press_action",
|
||||
StemAction.defaultActions[StemPressType.SINGLE_PRESS]!!.name)
|
||||
if (!contains("left_double_press_action")) editor.putString("left_double_press_action",
|
||||
StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name)
|
||||
if (!contains("right_double_press_action")) editor.putString("right_double_press_action",
|
||||
StemAction.defaultActions[StemPressType.DOUBLE_PRESS]!!.name)
|
||||
if (!contains("left_triple_press_action")) editor.putString("left_triple_press_action",
|
||||
StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name)
|
||||
if (!contains("right_triple_press_action")) editor.putString("right_triple_press_action",
|
||||
StemAction.defaultActions[StemPressType.TRIPLE_PRESS]!!.name)
|
||||
if (!contains("left_long_press_action")) editor.putString("left_long_press_action",
|
||||
StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name)
|
||||
if (!contains("right_long_press_action")) editor.putString("right_long_press_action",
|
||||
StemAction.defaultActions[StemPressType.LONG_PRESS]!!.name)
|
||||
|
||||
editor.apply()
|
||||
}
|
||||
@@ -1575,7 +1791,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
|
||||
Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
|
||||
if (!CrossDevice.isAvailable) {
|
||||
if (!CrossDevice.isAvailable && !config.bleOnlyMode) {
|
||||
Log.d("AirPodsService", "${config.deviceName} connected")
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
connectToSocket(device!!)
|
||||
@@ -1587,6 +1803,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
sharedPreferences.edit {
|
||||
putString("mac_address", macAddress)
|
||||
}
|
||||
} else if (config.bleOnlyMode) {
|
||||
Log.d("AirPodsService", "BLE-only mode: skipping L2CAP connection")
|
||||
macAddress = device!!.address
|
||||
sharedPreferences.edit {
|
||||
putString("mac_address", macAddress)
|
||||
}
|
||||
}
|
||||
} else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
|
||||
device = null
|
||||
@@ -1647,7 +1869,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
if (profile == BluetoothProfile.A2DP) {
|
||||
val connectedDevices = proxy.connectedDevices
|
||||
if (connectedDevices.isNotEmpty()) {
|
||||
if (!CrossDevice.isAvailable) {
|
||||
if (!CrossDevice.isAvailable && !config.bleOnlyMode) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
connectToSocket(device)
|
||||
}
|
||||
@@ -1656,6 +1878,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
sharedPreferences.edit {
|
||||
putString("mac_address", macAddress)
|
||||
}
|
||||
} else if (config.bleOnlyMode) {
|
||||
Log.d("AirPodsService", "BLE-only mode: skipping L2CAP connection")
|
||||
macAddress = device.address
|
||||
sharedPreferences.edit {
|
||||
putString("mac_address", macAddress)
|
||||
}
|
||||
}
|
||||
this@AirPodsService.sendBroadcast(
|
||||
Intent(AirPodsNotifications.AIRPODS_CONNECTED)
|
||||
@@ -1760,14 +1988,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
|
||||
if (device != null) {
|
||||
connectToSocket(device!!)
|
||||
connectAudio(this, device)
|
||||
if (config.bleOnlyMode) {
|
||||
// In BLE-only mode, just show connecting status without actual L2CAP connection
|
||||
Log.d("AirPodsService", "BLE-only mode: showing connecting status without L2CAP connection")
|
||||
updateNotificationContent(
|
||||
true,
|
||||
config.deviceName,
|
||||
batteryNotification.getBattery()
|
||||
)
|
||||
// Set a temporary connecting state
|
||||
isConnectedLocally = false // Keep as false since we're not actually connecting to L2CAP
|
||||
} else {
|
||||
connectToSocket(device!!)
|
||||
connectAudio(this, device)
|
||||
isConnectedLocally = true
|
||||
}
|
||||
}
|
||||
|
||||
showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!),
|
||||
IslandType.TAKING_OVER)
|
||||
|
||||
isConnectedLocally = true
|
||||
CrossDevice.isAvailable = false
|
||||
}
|
||||
|
||||
@@ -1879,6 +2119,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
.putExtra("device", device)
|
||||
)
|
||||
|
||||
setupStemActions()
|
||||
|
||||
while (socket.isConnected == true) {
|
||||
socket.let {
|
||||
val buffer = ByteArray(1024)
|
||||
@@ -2131,12 +2373,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
fun startHeadTracking() {
|
||||
isHeadTrackingActive = true
|
||||
aacpManager.sendStartHeadTracking()
|
||||
val useAlternatePackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false)
|
||||
if (useAlternatePackets) {
|
||||
aacpManager.sendDataPacket(aacpManager.createAlternateStartHeadTrackingPacket())
|
||||
} else {
|
||||
aacpManager.sendStartHeadTracking()
|
||||
}
|
||||
HeadTracking.reset()
|
||||
}
|
||||
|
||||
fun stopHeadTracking() {
|
||||
aacpManager.sendStopHeadTracking()
|
||||
val useAlternatePackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false)
|
||||
if (useAlternatePackets) {
|
||||
aacpManager.sendDataPacket(aacpManager.createAlternateStopHeadTrackingPacket())
|
||||
} else {
|
||||
aacpManager.sendStopHeadTracking()
|
||||
}
|
||||
isHeadTrackingActive = false
|
||||
}
|
||||
|
||||
|
||||
@@ -15,18 +15,21 @@
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.util.Log
|
||||
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers.entries
|
||||
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressBudType.entries
|
||||
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType.entries
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
/**
|
||||
* Manager class for Apple Accessory Communication Protocol (AACP)
|
||||
* This class is responsible for handling the L2CAP socket management,
|
||||
* constructing and parsing packets for communication with Apple accessories.
|
||||
* constructing and parsing packets for communication with AirPods.
|
||||
*/
|
||||
class AACPManager {
|
||||
companion object {
|
||||
@@ -44,6 +47,7 @@ class AACPManager {
|
||||
const val HEADTRACKING: Byte = 0x17
|
||||
const val PROXIMITY_KEYS_REQ: Byte = 0x30
|
||||
const val PROXIMITY_KEYS_RSP: Byte = 0x31
|
||||
const val STEM_PRESS: Byte = 0x19
|
||||
}
|
||||
|
||||
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
||||
@@ -101,8 +105,8 @@ class AACPManager {
|
||||
IN_CASE_TONE_CONFIG(0x31),
|
||||
SIRI_MULTITONE_CONFIG(0x32),
|
||||
HEARING_ASSIST_CONFIG(0x33),
|
||||
ALLOW_OFF_OPTION(0x34);
|
||||
|
||||
ALLOW_OFF_OPTION(0x34),
|
||||
STEM_CONFIG(0x39);
|
||||
companion object {
|
||||
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
|
||||
entries.find { it.value == byte }
|
||||
@@ -118,6 +122,28 @@ class AACPManager {
|
||||
ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
|
||||
}
|
||||
}
|
||||
|
||||
enum class StemPressType(val value: Byte) {
|
||||
SINGLE_PRESS(0x05),
|
||||
DOUBLE_PRESS(0x06),
|
||||
TRIPLE_PRESS(0x07),
|
||||
LONG_PRESS(0x08);
|
||||
|
||||
companion object {
|
||||
fun fromByte(byte: Byte): StemPressType? =
|
||||
entries.find { it.value == byte }
|
||||
}
|
||||
}
|
||||
|
||||
enum class StemPressBudType(val value: Byte) {
|
||||
LEFT(0x01),
|
||||
RIGHT(0x02);
|
||||
|
||||
companion object {
|
||||
fun fromByte(byte: Byte): StemPressBudType? =
|
||||
entries.find { it.value == byte }
|
||||
}
|
||||
}
|
||||
}
|
||||
var controlCommandStatusList: MutableList<ControlCommandStatus> = mutableListOf<ControlCommandStatus>()
|
||||
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf()
|
||||
@@ -149,6 +175,20 @@ class AACPManager {
|
||||
fun onHeadTrackingReceived(headTracking: ByteArray)
|
||||
fun onUnknownPacketReceived(packet: ByteArray)
|
||||
fun onProximityKeysReceived(proximityKeys: ByteArray)
|
||||
fun onStemPressReceived(stemPress: ByteArray)
|
||||
}
|
||||
|
||||
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
||||
Log.d(TAG, "Parsing Stem Press Response: ${data.joinToString(" ") { "%02X".format(it) }}")
|
||||
if (data.size != 8) {
|
||||
throw IllegalArgumentException("Data array too short to parse Stem Press Response")
|
||||
}
|
||||
if (data[4] != Opcodes.STEM_PRESS) {
|
||||
throw IllegalArgumentException("Data array does not start with STEM_PRESS opcode")
|
||||
}
|
||||
val type = StemPressType.fromByte(data[6]) ?: throw IllegalArgumentException("Unknown Stem Press Type: ${data[5]}")
|
||||
val bud = StemPressBudType.fromByte(data[7]) ?: throw IllegalArgumentException("Unknown Stem Press Bud Type: ${data[6]}")
|
||||
return Pair(type, bud)
|
||||
}
|
||||
|
||||
interface ControlCommandListener {
|
||||
@@ -195,6 +235,7 @@ class AACPManager {
|
||||
return sendDataPacket(controlPacket)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
|
||||
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
|
||||
setControlCommandStatusValue(
|
||||
@@ -323,6 +364,9 @@ class AACPManager {
|
||||
Opcodes.PROXIMITY_KEYS_RSP -> {
|
||||
callback?.onProximityKeysReceived(packet)
|
||||
}
|
||||
Opcodes.STEM_PRESS -> {
|
||||
callback?.onStemPressReceived(packet)
|
||||
}
|
||||
else -> {
|
||||
callback?.onUnknownPacketReceived(packet)
|
||||
}
|
||||
@@ -345,7 +389,7 @@ class AACPManager {
|
||||
|
||||
fun createSetFeatureFlagsPacket(): ByteArray {
|
||||
val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00)
|
||||
val data = byteArrayOf(0xFF.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
|
||||
val data = byteArrayOf(0xD7.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
|
||||
return opcode + data
|
||||
}
|
||||
|
||||
@@ -370,6 +414,14 @@ class AACPManager {
|
||||
return opcode + data
|
||||
}
|
||||
|
||||
fun createAlternateStartHeadTrackingPacket(): ByteArray {
|
||||
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
|
||||
val data = byteArrayOf(
|
||||
0x00, 0x00, 0x10, 0x00, 0x0F, 0x00, 0x08, 0x73, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00
|
||||
)
|
||||
return opcode + data
|
||||
}
|
||||
|
||||
fun sendStopHeadTracking(): Boolean {
|
||||
return sendDataPacket(createStopHeadTrackingPacket())
|
||||
}
|
||||
@@ -382,6 +434,14 @@ class AACPManager {
|
||||
return opcode + data
|
||||
}
|
||||
|
||||
fun createAlternateStopHeadTrackingPacket(): ByteArray {
|
||||
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
|
||||
val data = byteArrayOf(
|
||||
0x00, 0x00, 0x10, 0x00, 0x0F, 0x00, 0x08, 0x75, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
|
||||
)
|
||||
return opcode + data
|
||||
}
|
||||
|
||||
fun sendRename(name: String): Boolean {
|
||||
return sendDataPacket(createRenamePacket(name))
|
||||
}
|
||||
@@ -440,13 +500,29 @@ class AACPManager {
|
||||
val value = ByteArray(4)
|
||||
System.arraycopy(data, 3, value, 0, 4)
|
||||
|
||||
// drop trailing zeroes in the array, and return the bytearray of the reduced array
|
||||
val trimmedValue = value.takeWhile { it != 0x00.toByte() }.toByteArray()
|
||||
return ControlCommand(identifier, trimmedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun sendStemConfigPacket(
|
||||
singlePressCustomized: Boolean = false,
|
||||
doublePressCustomized: Boolean = false,
|
||||
triplePressCustomized: Boolean = false,
|
||||
longPressCustomized: Boolean = false
|
||||
): Boolean {
|
||||
val value = ((if (singlePressCustomized) 0x01 else 0) or
|
||||
(if (doublePressCustomized) 0x02 else 0) or
|
||||
(if (triplePressCustomized) 0x04 else 0) or
|
||||
(if (longPressCustomized) 0x08 else 0)).toByte()
|
||||
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
|
||||
return sendControlCommand(
|
||||
ControlCommandIdentifiers.STEM_CONFIG.value, value
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalStdlibApi::class)
|
||||
fun sendPacket(packet: ByteArray): Boolean {
|
||||
try {
|
||||
|
||||
@@ -58,6 +58,10 @@ import androidx.dynamicanimation.animation.DynamicAnimation
|
||||
import androidx.dynamicanimation.animation.SpringAnimation
|
||||
import androidx.dynamicanimation.animation.SpringForce
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.Battery
|
||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
@@ -118,6 +122,7 @@ class IslandWindow(private val context: Context) {
|
||||
val isVisible: Boolean
|
||||
get() = containerView.parent != null && containerView.visibility == View.VISIBLE
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
private fun updateBatteryDisplay(batteryList: ArrayList<Battery>?) {
|
||||
if (batteryList == null || batteryList.isEmpty()) return
|
||||
|
||||
@@ -150,7 +155,7 @@ class IslandWindow(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18s", "ClickableViewAccessibility")
|
||||
@SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag")
|
||||
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
|
||||
if (ServiceManager.getService()?.islandOpen == true) return
|
||||
else ServiceManager.getService()?.islandOpen = true
|
||||
@@ -162,13 +167,13 @@ class IslandWindow(private val context: Context) {
|
||||
val batteryList = ServiceManager.getService()?.getBattery()
|
||||
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
|
||||
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
||||
|
||||
|
||||
val displayBatteryLevel = if (batteryList != null) {
|
||||
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||
|
||||
|
||||
when {
|
||||
leftBattery?.level ?: 0 > 0 && rightBattery?.level ?: 0 > 0 ->
|
||||
leftBattery?.level ?: 0 > 0 && rightBattery?.level ?: 0 > 0 ->
|
||||
minOf(leftBattery!!.level, rightBattery!!.level)
|
||||
leftBattery?.level ?: 0 > 0 -> leftBattery!!.level
|
||||
rightBattery?.level ?: 0 > 0 -> rightBattery!!.level
|
||||
@@ -180,7 +185,7 @@ class IslandWindow(private val context: Context) {
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
|
||||
if (displayBatteryLevel != null) {
|
||||
batteryText.text = "$displayBatteryLevel%"
|
||||
batteryProgressBar.progress = displayBatteryLevel
|
||||
@@ -188,7 +193,7 @@ class IslandWindow(private val context: Context) {
|
||||
batteryText.text = "?"
|
||||
batteryProgressBar.progress = 0
|
||||
}
|
||||
|
||||
|
||||
batteryProgressBar.isIndeterminate = false
|
||||
islandView.findViewById<TextView>(R.id.island_device_name).text = name
|
||||
|
||||
@@ -403,11 +408,11 @@ class IslandWindow(private val context: Context) {
|
||||
|
||||
if (params != null) {
|
||||
params!!.height = screenHeight
|
||||
|
||||
|
||||
val containerParams = containerView.layoutParams
|
||||
containerParams.height = screenHeight
|
||||
containerView.layoutParams = containerParams
|
||||
|
||||
|
||||
try {
|
||||
windowManager.updateViewLayout(containerView, params)
|
||||
} catch (e: Exception) {
|
||||
@@ -552,7 +557,7 @@ class IslandWindow(private val context: Context) {
|
||||
normalizeAnimator.addUpdateListener { animation ->
|
||||
val progress = animation.animatedValue as Float
|
||||
containerView.alpha = progress
|
||||
|
||||
|
||||
if (progress < 0.7f) {
|
||||
islandView.findViewById<VideoView>(R.id.island_video_view).visibility = View.GONE
|
||||
}
|
||||
@@ -620,7 +625,7 @@ class IslandWindow(private val context: Context) {
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
|
||||
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, containerView.scaleX, 0.5f)
|
||||
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, containerView.scaleY, 0.5f)
|
||||
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, containerView.translationY, -200f)
|
||||
@@ -640,7 +645,7 @@ class IslandWindow(private val context: Context) {
|
||||
cleanupAndRemoveView()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun cleanupAndRemoveView() {
|
||||
containerView.visibility = View.GONE
|
||||
try {
|
||||
@@ -655,25 +660,25 @@ class IslandWindow(private val context: Context) {
|
||||
springAnimation.cancel()
|
||||
flingAnimator.cancel()
|
||||
}
|
||||
|
||||
|
||||
fun forceClose() {
|
||||
try {
|
||||
if (isClosing) return
|
||||
isClosing = true
|
||||
|
||||
|
||||
try {
|
||||
context.unregisterReceiver(batteryReceiver)
|
||||
} catch (e: Exception) {
|
||||
// Silent catch - receiver might already be unregistered
|
||||
}
|
||||
|
||||
|
||||
ServiceManager.getService()?.islandOpen = false
|
||||
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
|
||||
|
||||
|
||||
// Cancel all ongoing animations
|
||||
springAnimation.cancel()
|
||||
flingAnimator.cancel()
|
||||
|
||||
|
||||
// Immediately remove the view without animations
|
||||
cleanupAndRemoveView()
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -109,67 +109,6 @@ class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModul
|
||||
Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (param.packageName == "com.android.systemui") {
|
||||
Log.i(TAG, "SystemUI detected, hooking volume panel")
|
||||
try {
|
||||
val volumePanelViewClass = param.classLoader.loadClass("com.android.systemui.volume.VolumeDialogImpl")
|
||||
|
||||
try {
|
||||
val initDialogMethod = volumePanelViewClass.getDeclaredMethod("initDialog", Int::class.java)
|
||||
hook(initDialogMethod, VolumeDialogInitHooker::class.java)
|
||||
Log.i(TAG, "Hooked initDialog method successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to hook initDialog method: ${e.message}")
|
||||
}
|
||||
|
||||
try {
|
||||
val showHMethod = volumePanelViewClass.getDeclaredMethod("showH", Int::class.java, Boolean::class.java, Int::class.java)
|
||||
hook(showHMethod, VolumeDialogShowHooker::class.java)
|
||||
Log.i(TAG, "Hooked showH method successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to hook showH method: ${e.message}")
|
||||
}
|
||||
|
||||
Log.i(TAG, "Volume panel hook setup attempted on multiple methods")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to hook volume panel: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@XposedHooker
|
||||
class VolumeDialogInitHooker : XposedInterface.Hooker {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@AfterInvocation
|
||||
fun afterInitDialog(callback: AfterHookCallback) {
|
||||
try {
|
||||
val volumeDialog = callback.thisObject
|
||||
Log.i(TAG, "Volume dialog initialized, adding AirPods controls")
|
||||
addAirPodsControlsToDialog(volumeDialog!!)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in initDialog hook: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@XposedHooker
|
||||
class VolumeDialogShowHooker : XposedInterface.Hooker {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@AfterInvocation
|
||||
fun afterShowH(callback: AfterHookCallback) {
|
||||
try {
|
||||
val volumeDialog = callback.thisObject
|
||||
Log.i(TAG, "Volume dialog shown, ensuring AirPods controls are added")
|
||||
addAirPodsControlsToDialog(volumeDialog!!)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error in showH hook: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@XposedHooker
|
||||
|
||||
@@ -100,6 +100,51 @@ object MediaController {
|
||||
return audioManager.isMusicActive
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sendPlayPause() {
|
||||
if (audioManager.isMusicActive) {
|
||||
Log.d("MediaController", "Sending pause because music is active")
|
||||
sendPause()
|
||||
} else {
|
||||
Log.d("MediaController", "Sending play because music is not active")
|
||||
sendPlay()
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sendPreviousTrack() {
|
||||
Log.d("MediaController", "Sending previous track")
|
||||
audioManager.dispatchMediaKeyEvent(
|
||||
KeyEvent(
|
||||
KeyEvent.ACTION_DOWN,
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS
|
||||
)
|
||||
)
|
||||
audioManager.dispatchMediaKeyEvent(
|
||||
KeyEvent(
|
||||
KeyEvent.ACTION_UP,
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sendNextTrack() {
|
||||
Log.d("MediaController", "Sending next track")
|
||||
audioManager.dispatchMediaKeyEvent(
|
||||
KeyEvent(
|
||||
KeyEvent.ACTION_DOWN,
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT
|
||||
)
|
||||
)
|
||||
audioManager.dispatchMediaKeyEvent(
|
||||
KeyEvent(
|
||||
KeyEvent.ACTION_UP,
|
||||
KeyEvent.KEYCODE_MEDIA_NEXT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sendPause(force: Boolean = false) {
|
||||
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force")
|
||||
|
||||
@@ -45,6 +45,11 @@ import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.VideoView
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.constants.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.constants.Battery
|
||||
import me.kavishdevar.librepods.constants.BatteryComponent
|
||||
import me.kavishdevar.librepods.constants.BatteryStatus
|
||||
import kotlin.collections.find
|
||||
|
||||
@SuppressLint("InflateParams", "ClickableViewAccessibility")
|
||||
class PopupWindow(
|
||||
@@ -124,9 +129,9 @@ class PopupWindow(
|
||||
try {
|
||||
if (mView.windowToken == null && mView.parent == null && !isClosing) {
|
||||
mView.findViewById<TextView>(R.id.name).text = name
|
||||
|
||||
|
||||
updateBatteryStatus(batteryNotification)
|
||||
|
||||
|
||||
val vid = mView.findViewById<VideoView>(R.id.video)
|
||||
vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
|
||||
vid.resolveAdjustedSize(vid.width, vid.height)
|
||||
@@ -134,7 +139,7 @@ class PopupWindow(
|
||||
vid.setOnCompletionListener {
|
||||
vid.start()
|
||||
}
|
||||
|
||||
|
||||
mWindowManager.addView(mView, mParams)
|
||||
|
||||
val displayMetrics = mView.context.resources.displayMetrics
|
||||
@@ -144,13 +149,13 @@ class PopupWindow(
|
||||
mView.alpha = 1f
|
||||
|
||||
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, screenHeight.toFloat(), 0f)
|
||||
|
||||
|
||||
ObjectAnimator.ofPropertyValuesHolder(mView, translationY).apply {
|
||||
duration = 500
|
||||
interpolator = DecelerateInterpolator()
|
||||
start()
|
||||
}
|
||||
|
||||
|
||||
registerBatteryUpdateReceiver()
|
||||
|
||||
autoCloseRunnable = Runnable { close() }
|
||||
@@ -162,6 +167,7 @@ class PopupWindow(
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
private fun registerBatteryUpdateReceiver() {
|
||||
batteryUpdateReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
@@ -173,7 +179,7 @@ class PopupWindow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val filter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(batteryUpdateReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||
@@ -192,7 +198,7 @@ class PopupWindow(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun updateBatteryStatusFromList(batteryList: List<Battery>) {
|
||||
val batteryLeftText = mView.findViewById<TextView>(R.id.left_battery)
|
||||
val batteryRightText = mView.findViewById<TextView>(R.id.right_battery)
|
||||
@@ -205,7 +211,7 @@ class PopupWindow(
|
||||
""
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
|
||||
batteryRightText.text = batteryList.find { it.component == BatteryComponent.RIGHT }?.let {
|
||||
if (it.status != BatteryStatus.DISCONNECTED) {
|
||||
"\uDBC3\uDC8D ${it.level}%"
|
||||
@@ -213,7 +219,7 @@ class PopupWindow(
|
||||
""
|
||||
}
|
||||
} ?: ""
|
||||
|
||||
|
||||
batteryCaseText.text = batteryList.find { it.component == BatteryComponent.CASE }?.let {
|
||||
if (it.status != BatteryStatus.DISCONNECTED) {
|
||||
"\uDBC3\uDE6C ${it.level}%"
|
||||
@@ -233,13 +239,13 @@ class PopupWindow(
|
||||
try {
|
||||
if (isClosing) return
|
||||
isClosing = true
|
||||
|
||||
|
||||
autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) }
|
||||
unregisterBatteryUpdateReceiver()
|
||||
|
||||
|
||||
val vid = mView.findViewById<VideoView>(R.id.video)
|
||||
vid.stopPlayback()
|
||||
|
||||
|
||||
ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
|
||||
duration = 500
|
||||
interpolator = AccelerateInterpolator()
|
||||
|
||||
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 35 KiB |
10
android/app/src/main/res/drawable/settings_voice.xml
Normal 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>
|
||||
82
android/app/src/main/res/values-zh-rCN/strings.xml
Normal 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>
|
||||
BIN
android/imgs/customizations-1.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
android/imgs/customizations-2.png
Normal file
|
After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 132 KiB |
@@ -27,7 +27,7 @@ These commands
|
||||
| 0x16 | ClickHoldMode | Two values (2 bytes; First byte = right bud Second byte = for left): `0x01` = Noise control `0x05` = Siri |
|
||||
| 0x17 | DoubleClickInterval | Single value (1 byte): 0x00 = Default, `0x01` = Slower, `0x02` = Slowest|
|
||||
| 0x18 | ClickHoldInterval | Single value (1 byte): 0x00 = Default, `0x01` = Slower, `0x02` = Slowest|
|
||||
| 0x1A | ListeningModeConfigs | Single value (1 byte): bitwise OR of the selected modes. Off mode = `0x01`, ANC=`0x02`, Transparency = 0x04, Adaptive = `0x08` |
|
||||
| 0x1A | ListeningModeConfigs | Single value (1 byte): bitmask, Off mode = `0x01`, ANC=`0x02`, Transparency = 0x04, Adaptive = `0x08` |
|
||||
| 0x1B | OneBudANCMode | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||
| 0x1C | CrownRotationDirection | Single value (1 byte): `0x01` = reversed, `0x02` = default |
|
||||
| 0x0D | ListeningMode | Single value (1 byte): 1 = Off, 2 = noise cancellation, 3 = transparency, 4 = adaptive |
|
||||
@@ -48,6 +48,15 @@ These commands
|
||||
| 0x32 | Siri Multitone config | Single value (1 byte) |
|
||||
| 0x33 | Hearing Assist config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||
| 0x34 | Allow Off Option for Listening Mode config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||
| 0x35 | Sleep Detection config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
|
||||
| 0x36 | Allow Auto Connect | Single value (1 byte): `0x01` = allow, `0x02` = disallow |
|
||||
| 0x39 | Raw Gestures config | Single value (1 byte): bitmask, single press = `0x01`, double press = `0x02`, triple press = `0x04`, long press = `0x08` |
|
||||
| 0x3C | System Siri message config | Single value (1 byte) |
|
||||
| 0x3E | Uplink EQ Bud config | Single value (1 byte) |
|
||||
| 0x3F | Uplink EQ Source config | Single value (1 byte) |
|
||||
| 0x40 | In Case Tone Volume | Single value (1 byte): 0 to 100 |
|
||||
| 0x41 | Disable Button Input config | Single value (1 byte) |
|
||||
|
||||
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
BIN
imgs/banner.png
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 199 KiB |
@@ -5,15 +5,15 @@ project(linux VERSION 0.1 LANGUAGES CXX)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
find_package(Qt6 6.4 REQUIRED COMPONENTS Quick Widgets Bluetooth DBus)
|
||||
find_package(OpenSSL REQUIRED)
|
||||
|
||||
qt_standard_project_setup(REQUIRES 6.4)
|
||||
|
||||
qt_add_executable(applinux
|
||||
qt_add_executable(librepods
|
||||
main.cpp
|
||||
main.h
|
||||
logger.h
|
||||
mediacontroller.cpp
|
||||
mediacontroller.h
|
||||
media/mediacontroller.cpp
|
||||
media/mediacontroller.h
|
||||
airpods_packets.h
|
||||
trayiconmanager.cpp
|
||||
trayiconmanager.h
|
||||
@@ -24,9 +24,20 @@ qt_add_executable(applinux
|
||||
autostartmanager.hpp
|
||||
BasicControlCommand.hpp
|
||||
deviceinfo.hpp
|
||||
ble/bleutils.cpp
|
||||
ble/bleutils.h
|
||||
ble/blemanager.cpp
|
||||
ble/blemanager.h
|
||||
thirdparty/QR-Code-generator/qrcodegen.cpp
|
||||
thirdparty/QR-Code-generator/qrcodegen.hpp
|
||||
QRCodeImageProvider.hpp
|
||||
eardetection.hpp
|
||||
media/playerstatuswatcher.cpp
|
||||
media/playerstatuswatcher.h
|
||||
systemsleepmonitor.hpp
|
||||
)
|
||||
|
||||
qt_add_qml_module(applinux
|
||||
qt_add_qml_module(librepods
|
||||
URI linux
|
||||
VERSION 1.0
|
||||
QML_FILES
|
||||
@@ -35,10 +46,11 @@ qt_add_qml_module(applinux
|
||||
SegmentedControl.qml
|
||||
PodColumn.qml
|
||||
Icon.qml
|
||||
KeysQRDialog.qml
|
||||
)
|
||||
|
||||
# Add the resource file
|
||||
qt_add_resources(applinux "resources"
|
||||
qt_add_resources(librepods "resources"
|
||||
PREFIX "/icons"
|
||||
FILES
|
||||
assets/airpods.png
|
||||
@@ -53,12 +65,12 @@ qt_add_resources(applinux "resources"
|
||||
assets/fonts/SF-Symbols-6.ttf
|
||||
)
|
||||
|
||||
target_link_libraries(applinux
|
||||
PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus
|
||||
target_link_libraries(librepods
|
||||
PRIVATE Qt6::Quick Qt6::Widgets Qt6::Bluetooth Qt6::DBus OpenSSL::SSL OpenSSL::Crypto
|
||||
)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
install(TARGETS applinux
|
||||
install(TARGETS librepods
|
||||
BUNDLE DESTINATION .
|
||||
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||
|
||||
69
linux/KeysQRDialog.qml
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ ApplicationWindow {
|
||||
visible: !airPodsTrayApp.hideOnStart
|
||||
width: 400
|
||||
height: 300
|
||||
title: "AirPods Settings"
|
||||
title: "Librepods"
|
||||
objectName: "mainWindowObject"
|
||||
|
||||
onClosing: mainWindow.visible = false
|
||||
@@ -94,7 +94,7 @@ ApplicationWindow {
|
||||
spacing: 8
|
||||
|
||||
PodColumn {
|
||||
isVisible: airPodsTrayApp.deviceInfo.battery.leftPodAvailable
|
||||
visible: airPodsTrayApp.deviceInfo.battery.leftPodAvailable
|
||||
inEar: airPodsTrayApp.deviceInfo.leftPodInEar
|
||||
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
|
||||
batteryLevel: airPodsTrayApp.deviceInfo.battery.leftPodLevel
|
||||
@@ -103,7 +103,7 @@ ApplicationWindow {
|
||||
}
|
||||
|
||||
PodColumn {
|
||||
isVisible: airPodsTrayApp.deviceInfo.battery.rightPodAvailable
|
||||
visible: airPodsTrayApp.deviceInfo.battery.rightPodAvailable
|
||||
inEar: airPodsTrayApp.deviceInfo.rightPodInEar
|
||||
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
|
||||
batteryLevel: airPodsTrayApp.deviceInfo.battery.rightPodLevel
|
||||
@@ -112,7 +112,7 @@ ApplicationWindow {
|
||||
}
|
||||
|
||||
PodColumn {
|
||||
isVisible: airPodsTrayApp.deviceInfo.battery.caseAvailable
|
||||
visible: airPodsTrayApp.deviceInfo.battery.caseAvailable
|
||||
inEar: true
|
||||
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.caseIcon
|
||||
batteryLevel: airPodsTrayApp.deviceInfo.battery.caseLevel
|
||||
@@ -164,7 +164,7 @@ ApplicationWindow {
|
||||
anchors.margins: 10
|
||||
font.family: iconFont.name
|
||||
font.pixelSize: 18
|
||||
text: "\uf958" // U+F958
|
||||
text: "\uf958"
|
||||
onClicked: stackView.push(settingsPage)
|
||||
}
|
||||
}
|
||||
@@ -265,9 +265,37 @@ ApplicationWindow {
|
||||
|
||||
Button {
|
||||
text: "Rename"
|
||||
onClicked: airPodsTrayApp.deviceInfo.renameAirPods(newNameField.text)
|
||||
onClicked: airPodsTrayApp.renameAirPods(newNameField.text)
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: 10
|
||||
visible: airPodsTrayApp.airpodsConnected
|
||||
|
||||
TextField {
|
||||
id: newPhoneMacField
|
||||
placeholderText: (PHONE_MAC_ADDRESS !== "" ? PHONE_MAC_ADDRESS : "00:00:00:00:00:00")
|
||||
maximumLength: 32
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Change Phone MAC"
|
||||
onClicked: airPodsTrayApp.setPhoneMac(newPhoneMacField.text)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Button {
|
||||
text: "Show Magic Cloud Keys QR"
|
||||
onClicked: keysQrDialog.show()
|
||||
}
|
||||
|
||||
KeysQRDialog {
|
||||
id: keysQrDialog
|
||||
encKey: airPodsTrayApp.deviceInfo.magicAccEncKey
|
||||
irk: airPodsTrayApp.deviceInfo.magicAccIRK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import QtQuick 2.15
|
||||
|
||||
Column {
|
||||
property bool isVisible: true
|
||||
id: root
|
||||
property bool inEar: true
|
||||
property string iconSource
|
||||
property int batteryLevel: 0
|
||||
property bool isCharging: false
|
||||
property string indicator: ""
|
||||
property real targetOpacity: inEar ? 1 : 0.5
|
||||
|
||||
Timer {
|
||||
id: opacityTimer
|
||||
interval: 50
|
||||
onTriggered: root.opacity = root.targetOpacity
|
||||
}
|
||||
|
||||
onInEarChanged: {
|
||||
opacityTimer.restart()
|
||||
}
|
||||
|
||||
spacing: 5
|
||||
opacity: inEar ? 1 : 0.5
|
||||
visible: isVisible
|
||||
|
||||
Image {
|
||||
source: parent.iconSource
|
||||
|
||||
46
linux/QRCodeImageProvider.hpp
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -21,14 +21,29 @@ A native Linux application to control your AirPods, with support for:
|
||||
sudo apt-get install qt6-base-dev qt6-declarative-dev qt6-connectivity-dev qt6-multimedia-dev \
|
||||
qml6-module-qtquick-controls qml6-module-qtqml-workerscript qml6-module-qtquick-templates \
|
||||
qml6-module-qtquick-window qml6-module-qtquick-layouts
|
||||
```
|
||||
|
||||
# For Fedora
|
||||
sudo dnf install qt6-qtbase-devel qt6-qtconnectivity-devel \
|
||||
qt6-qtmultimedia-devel qt6-qtdeclarative-devel
|
||||
```
|
||||
3. OpenSSL development headers
|
||||
|
||||
```bash
|
||||
# On Arch Linux / EndevaourOS, these are included in the OpenSSL package, so you might already have them installed.
|
||||
sudo pacman -S openssl
|
||||
|
||||
# For Debian / Ubuntu
|
||||
sudo apt-get install libssl-dev
|
||||
|
||||
# For Fedora
|
||||
sudo dnf install openssl-devel
|
||||
```
|
||||
## Setup
|
||||
|
||||
1. Edit `main.h` and update `PHONE_MAC_ADDRESS` with your phone's Bluetooth MAC address:
|
||||
1. Set the `PHONE_MAC_ADDRESS` environment variable to your phone's Bluetooth MAC address by running the following:
|
||||
|
||||
```cpp
|
||||
#define PHONE_MAC_ADDRESS "XX:XX:XX:XX:XX:XX" // Replace with your phone's MAC
|
||||
```bash
|
||||
export PHONE_MAC_ADDRESS="XX:XX:XX:XX:XX:XX" # Replace with your phone's MAC
|
||||
```
|
||||
|
||||
2. Build the application:
|
||||
@@ -43,7 +58,7 @@ A native Linux application to control your AirPods, with support for:
|
||||
3. Run the application:
|
||||
|
||||
```bash
|
||||
./applinux
|
||||
./librepods
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include <QByteArray>
|
||||
#include <optional>
|
||||
#include <climits>
|
||||
|
||||
#include "enums.h"
|
||||
#include "BasicControlCommand.hpp"
|
||||
@@ -39,13 +40,13 @@ namespace AirPodsPackets
|
||||
|
||||
inline std::optional<NoiseControlMode> parseMode(const QByteArray &data)
|
||||
{
|
||||
char mode = ControlCommand::parseActive(data).value_or(CHAR_MAX);
|
||||
char mode = ControlCommand::parseActive(data).value_or(CHAR_MAX) - 1;
|
||||
if (mode < static_cast<quint8>(NoiseControlMode::MinValue) ||
|
||||
mode > static_cast<quint8>(NoiseControlMode::MaxValue))
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
return static_cast<NoiseControlMode>(mode - 1);
|
||||
return static_cast<NoiseControlMode>(mode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +121,7 @@ namespace AirPodsPackets
|
||||
namespace Connection
|
||||
{
|
||||
static const QByteArray HANDSHAKE = QByteArray::fromHex("00000400010002000000000000000000");
|
||||
static const QByteArray SET_SPECIFIC_FEATURES = QByteArray::fromHex("040004004d00ff00000000000000");
|
||||
static const QByteArray SET_SPECIFIC_FEATURES = QByteArray::fromHex("040004004d00d700000000000000");
|
||||
static const QByteArray REQUEST_NOTIFICATIONS = QByteArray::fromHex("040004000f00ffffffffff");
|
||||
static const QByteArray AIRPODS_DISCONNECTED = QByteArray::fromHex("00010000");
|
||||
}
|
||||
@@ -220,4 +221,4 @@ namespace AirPodsPackets
|
||||
}
|
||||
}
|
||||
|
||||
#endif // AIRPODS_PACKETS_H
|
||||
#endif // AIRPODS_PACKETS_H
|
||||
|
||||
|
Before Width: | Height: | Size: 605 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 808 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 484 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 5.4 KiB |
@@ -66,7 +66,7 @@ private:
|
||||
"[Desktop Entry]\n"
|
||||
"Type=Application\n"
|
||||
"Name=%1\n"
|
||||
"Exec=%2\n"
|
||||
"Exec=%2 --hide\n"
|
||||
"Icon=%3\n"
|
||||
"Comment=%4\n"
|
||||
"X-GNOME-Autostart-enabled=true\n"
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
#include <QMap>
|
||||
#include <QString>
|
||||
#include <QObject>
|
||||
#include <climits>
|
||||
|
||||
#include "airpods_packets.h"
|
||||
#include "logger.h"
|
||||
|
||||
class Battery : public QObject
|
||||
{
|
||||
@@ -97,7 +99,10 @@ public:
|
||||
auto level = static_cast<quint8>(packet[offset + 2]);
|
||||
auto status = static_cast<BatteryStatus>(packet[offset + 3]);
|
||||
|
||||
newStates[comp] = {level, status};
|
||||
if (status != BatteryStatus::Disconnected)
|
||||
{
|
||||
newStates[comp] = {level, status};
|
||||
}
|
||||
|
||||
// If this is a pod (Left or Right), add it to the list
|
||||
if (comp == Component::Left || comp == Component::Right)
|
||||
@@ -127,6 +132,61 @@ public:
|
||||
// Emit signal to notify about battery status change
|
||||
emit batteryStatusChanged();
|
||||
|
||||
// Log which is left and right pod
|
||||
LOG_INFO("Primary Pod:" << primaryPod);
|
||||
LOG_INFO("Secondary Pod:" << secondaryPod);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool parseEncryptedPacket(const QByteArray &packet, bool isLeftPodPrimary, bool podInCase)
|
||||
{
|
||||
// Validate packet size (expect 16 bytes based on provided payloads)
|
||||
if (packet.size() != 16)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine byte indices based on isFlipped
|
||||
int leftByteIndex = isLeftPodPrimary ? 1 : 2;
|
||||
int rightByteIndex = isLeftPodPrimary ? 2 : 1;
|
||||
|
||||
// Extract raw battery bytes
|
||||
unsigned char rawLeftBatteryByte = static_cast<unsigned char>(packet.at(leftByteIndex));
|
||||
unsigned char rawRightBatteryByte = static_cast<unsigned char>(packet.at(rightByteIndex));
|
||||
unsigned char rawCaseBatteryByte = static_cast<unsigned char>(packet.at(3));
|
||||
|
||||
// Extract battery data (charging status and raw level 0-127)
|
||||
auto [isLeftCharging, rawLeftBattery] = formatBattery(rawLeftBatteryByte);
|
||||
auto [isRightCharging, rawRightBattery] = formatBattery(rawRightBatteryByte);
|
||||
auto [isCaseCharging, rawCaseBattery] = formatBattery(rawCaseBatteryByte);
|
||||
|
||||
if (rawLeftBattery == CHAR_MAX) {
|
||||
rawLeftBattery = states.value(Component::Left).level; // Use last valid level
|
||||
isLeftCharging = states.value(Component::Left).status == BatteryStatus::Charging;
|
||||
}
|
||||
|
||||
if (rawRightBattery == CHAR_MAX) {
|
||||
rawRightBattery = states.value(Component::Right).level; // Use last valid level
|
||||
isRightCharging = states.value(Component::Right).status == BatteryStatus::Charging;
|
||||
}
|
||||
|
||||
if (rawCaseBattery == CHAR_MAX) {
|
||||
rawCaseBattery = states.value(Component::Case).level; // Use last valid level
|
||||
isCaseCharging = states.value(Component::Case).status == BatteryStatus::Charging;
|
||||
}
|
||||
|
||||
// Update states
|
||||
states[Component::Left] = {static_cast<quint8>(rawLeftBattery), isLeftCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
|
||||
states[Component::Right] = {static_cast<quint8>(rawRightBattery), isRightCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
|
||||
if (podInCase) {
|
||||
states[Component::Case] = {static_cast<quint8>(rawCaseBattery), isCaseCharging ? BatteryStatus::Charging : BatteryStatus::Discharging};
|
||||
}
|
||||
primaryPod = isLeftPodPrimary ? Component::Left : Component::Right;
|
||||
secondaryPod = isLeftPodPrimary ? Component::Right : Component::Left;
|
||||
emit batteryStatusChanged();
|
||||
emit primaryChanged();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -187,7 +247,14 @@ private:
|
||||
return states.value(component).status == status;
|
||||
}
|
||||
|
||||
std::pair<bool, int> formatBattery(unsigned char byteVal)
|
||||
{
|
||||
bool charging = (byteVal & 0x80) != 0;
|
||||
int level = byteVal & 0x7F;
|
||||
return std::make_pair(charging, level);
|
||||
}
|
||||
|
||||
QMap<Component, BatteryState> states;
|
||||
Component primaryPod;
|
||||
Component secondaryPod;
|
||||
};
|
||||
};
|
||||
@@ -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}
|
||||
)
|
||||
@@ -1,6 +1,86 @@
|
||||
#include "blemanager.h"
|
||||
#include "enums.h"
|
||||
#include <QDebug>
|
||||
#include <QTimer>
|
||||
#include "logger.h"
|
||||
#include <QMap>
|
||||
|
||||
AirpodsTrayApp::Enums::AirPodsModel getModelName(quint16 modelId)
|
||||
{
|
||||
using namespace AirpodsTrayApp::Enums;
|
||||
static const QMap<quint16, AirPodsModel> modelMap = {
|
||||
{0x0220, AirPodsModel::AirPods1},
|
||||
{0x0F20, AirPodsModel::AirPods2},
|
||||
{0x1320, AirPodsModel::AirPods3},
|
||||
{0x1920, AirPodsModel::AirPods4},
|
||||
{0x1B20, AirPodsModel::AirPods4ANC},
|
||||
{0x0A20, AirPodsModel::AirPodsMaxLightning},
|
||||
{0x1F20, AirPodsModel::AirPodsMaxUSBC},
|
||||
{0x0E20, AirPodsModel::AirPodsPro},
|
||||
{0x1420, AirPodsModel::AirPodsPro2Lightning},
|
||||
{0x2420, AirPodsModel::AirPodsPro2USBC}
|
||||
};
|
||||
|
||||
return modelMap.value(modelId, AirPodsModel::Unknown);
|
||||
}
|
||||
|
||||
QString getColorName(quint8 colorId)
|
||||
{
|
||||
switch (colorId)
|
||||
{
|
||||
case 0x00:
|
||||
return "White";
|
||||
case 0x01:
|
||||
return "Black";
|
||||
case 0x02:
|
||||
return "Red";
|
||||
case 0x03:
|
||||
return "Blue";
|
||||
case 0x04:
|
||||
return "Pink";
|
||||
case 0x05:
|
||||
return "Gray";
|
||||
case 0x06:
|
||||
return "Silver";
|
||||
case 0x07:
|
||||
return "Gold";
|
||||
case 0x08:
|
||||
return "Rose Gold";
|
||||
case 0x09:
|
||||
return "Space Gray";
|
||||
case 0x0A:
|
||||
return "Dark Blue";
|
||||
case 0x0B:
|
||||
return "Light Blue";
|
||||
case 0x0C:
|
||||
return "Yellow";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
QString getConnectionStateName(BleInfo::ConnectionState state)
|
||||
{
|
||||
using ConnectionState = BleInfo::ConnectionState;
|
||||
switch (state)
|
||||
{
|
||||
case ConnectionState::DISCONNECTED:
|
||||
return QString("Disconnected");
|
||||
case ConnectionState::IDLE:
|
||||
return QString("Idle");
|
||||
case ConnectionState::MUSIC:
|
||||
return QString("Playing Music");
|
||||
case ConnectionState::CALL:
|
||||
return QString("On Call");
|
||||
case ConnectionState::RINGING:
|
||||
return QString("Ringing");
|
||||
case ConnectionState::HANGING_UP:
|
||||
return QString("Hanging Up");
|
||||
case ConnectionState::UNKNOWN:
|
||||
default:
|
||||
return QString("Unknown");
|
||||
}
|
||||
}
|
||||
|
||||
BleManager::BleManager(QObject *parent) : QObject(parent)
|
||||
{
|
||||
@@ -13,36 +93,28 @@ BleManager::BleManager(QObject *parent) : QObject(parent)
|
||||
this, &BleManager::onScanFinished);
|
||||
connect(discoveryAgent, &QBluetoothDeviceDiscoveryAgent::errorOccurred,
|
||||
this, &BleManager::onErrorOccurred);
|
||||
|
||||
// Set up pruning timer
|
||||
pruneTimer = new QTimer(this);
|
||||
connect(pruneTimer, &QTimer::timeout, this, &BleManager::pruneOldDevices);
|
||||
pruneTimer->start(PRUNE_INTERVAL_MS); // Start timer (runs every 5 seconds)
|
||||
}
|
||||
|
||||
BleManager::~BleManager()
|
||||
{
|
||||
delete discoveryAgent;
|
||||
delete pruneTimer;
|
||||
}
|
||||
|
||||
void BleManager::startScan()
|
||||
{
|
||||
qDebug() << "Starting BLE scan...";
|
||||
devices.clear();
|
||||
LOG_DEBUG("Starting BLE scan...");
|
||||
discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod);
|
||||
pruneTimer->start(PRUNE_INTERVAL_MS); // Ensure timer is running
|
||||
}
|
||||
|
||||
void BleManager::stopScan()
|
||||
{
|
||||
qDebug() << "Stopping BLE scan...";
|
||||
LOG_DEBUG("Stopping BLE scan...");
|
||||
discoveryAgent->stop();
|
||||
}
|
||||
|
||||
const QMap<QString, DeviceInfo> &BleManager::getDevices() const
|
||||
bool BleManager::isScanning() const
|
||||
{
|
||||
return devices;
|
||||
return discoveryAgent->isActive();
|
||||
}
|
||||
|
||||
void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
|
||||
@@ -55,10 +127,11 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
|
||||
if (data.size() >= 10 && data[0] == 0x07)
|
||||
{
|
||||
QString address = info.address().toString();
|
||||
DeviceInfo deviceInfo;
|
||||
BleInfo deviceInfo;
|
||||
deviceInfo.name = info.name().isEmpty() ? "AirPods" : info.name();
|
||||
deviceInfo.address = address;
|
||||
deviceInfo.rawData = data;
|
||||
deviceInfo.rawData = data.left(data.size() - 16);
|
||||
deviceInfo.encryptedPayload = data.mid(data.size() - 16);
|
||||
|
||||
// data[1] is the length of the data, so we can skip it
|
||||
|
||||
@@ -68,8 +141,9 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
|
||||
return; // Skip pairing mode devices (the values are differently structured)
|
||||
}
|
||||
|
||||
|
||||
// Parse device model (big-endian: high byte at data[3], low byte at data[4])
|
||||
deviceInfo.deviceModel = static_cast<quint16>(data[4]) | (static_cast<quint8>(data[3]) << 8);
|
||||
deviceInfo.modelName = getModelName(static_cast<quint16>(data[4]) | (static_cast<quint8>(data[3]) << 8));
|
||||
|
||||
// Status byte for primary pod and other flags
|
||||
quint8 status = static_cast<quint8>(data[5]);
|
||||
@@ -83,9 +157,9 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
|
||||
|
||||
// Lid open counter and device color
|
||||
quint8 lidIndicator = static_cast<quint8>(data[8]);
|
||||
deviceInfo.deviceColor = static_cast<quint8>(data[9]);
|
||||
deviceInfo.color = getColorName((quint8)(data[9]));
|
||||
|
||||
deviceInfo.connectionState = static_cast<DeviceInfo::ConnectionState>(data[10]);
|
||||
deviceInfo.connectionState = static_cast<BleInfo::ConnectionState>(data[10]);
|
||||
|
||||
// Next: Encrypted Payload: 16 bytes
|
||||
|
||||
@@ -93,6 +167,8 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
|
||||
bool primaryLeft = (status & 0x20) != 0; // Bit 5: 1 = left primary, 0 = right primary
|
||||
bool areValuesFlipped = !primaryLeft; // Flipped when right pod is primary
|
||||
|
||||
deviceInfo.primaryLeft = primaryLeft; // Store primary pod information
|
||||
|
||||
// Parse battery levels
|
||||
int leftNibble = areValuesFlipped ? (podsBatteryByte >> 4) & 0x0F : podsBatteryByte & 0x0F;
|
||||
int rightNibble = areValuesFlipped ? podsBatteryByte & 0x0F : (podsBatteryByte >> 4) & 0x0F;
|
||||
@@ -117,6 +193,10 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
|
||||
deviceInfo.isLeftPodInEar = xorFactor ? (status & 0x08) != 0 : (status & 0x02) != 0; // Bit 3 or 1
|
||||
deviceInfo.isRightPodInEar = xorFactor ? (status & 0x02) != 0 : (status & 0x08) != 0; // Bit 1 or 3
|
||||
|
||||
// Determine primary and secondary in-ear status
|
||||
deviceInfo.isPrimaryInEar = primaryLeft ? deviceInfo.isLeftPodInEar : deviceInfo.isRightPodInEar;
|
||||
deviceInfo.isSecondaryInEar = primaryLeft ? deviceInfo.isRightPodInEar : deviceInfo.isLeftPodInEar;
|
||||
|
||||
// Microphone status
|
||||
deviceInfo.isLeftPodMicrophone = primaryLeft ^ deviceInfo.isThisPodInTheCase;
|
||||
deviceInfo.isRightPodMicrophone = !primaryLeft ^ deviceInfo.isThisPodInTheCase;
|
||||
@@ -124,27 +204,19 @@ void BleManager::onDeviceDiscovered(const QBluetoothDeviceInfo &info)
|
||||
deviceInfo.lidOpenCounter = lidIndicator & 0x07; // Extract bits 0-2 (count)
|
||||
quint8 lidState = static_cast<quint8>((lidIndicator >> 3) & 0x01); // Extract bit 3 (lid state)
|
||||
if (deviceInfo.isThisPodInTheCase) {
|
||||
deviceInfo.lidState = static_cast<DeviceInfo::LidState>(lidState);
|
||||
deviceInfo.lidState = static_cast<BleInfo::LidState>(lidState);
|
||||
}
|
||||
|
||||
// Update timestamp
|
||||
deviceInfo.lastSeen = QDateTime::currentDateTime();
|
||||
|
||||
// Store device info in the map
|
||||
devices[address] = deviceInfo;
|
||||
|
||||
// Debug output
|
||||
qDebug() << "Found device:" << deviceInfo.name
|
||||
<< "Left:" << (deviceInfo.leftPodBattery >= 0 ? QString("%1%").arg(deviceInfo.leftPodBattery) : "N/A")
|
||||
<< "Right:" << (deviceInfo.rightPodBattery >= 0 ? QString("%1%").arg(deviceInfo.rightPodBattery) : "N/A")
|
||||
<< "Case:" << (deviceInfo.caseBattery >= 0 ? QString("%1%").arg(deviceInfo.caseBattery) : "N/A");
|
||||
emit deviceFound(deviceInfo); // Emit signal for device found
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void BleManager::onScanFinished()
|
||||
{
|
||||
qDebug() << "Scan finished.";
|
||||
if (discoveryAgent->isActive())
|
||||
{
|
||||
discoveryAgent->start(QBluetoothDeviceDiscoveryAgent::LowEnergyMethod);
|
||||
@@ -153,24 +225,6 @@ void BleManager::onScanFinished()
|
||||
|
||||
void BleManager::onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error)
|
||||
{
|
||||
qDebug() << "Error occurred:" << error;
|
||||
LOG_ERROR("BLE scan error occurred:" << error);
|
||||
stopScan();
|
||||
}
|
||||
|
||||
void BleManager::pruneOldDevices()
|
||||
{
|
||||
QDateTime now = QDateTime::currentDateTime();
|
||||
auto it = devices.begin();
|
||||
while (it != devices.end())
|
||||
{
|
||||
if (it.value().lastSeen.msecsTo(now) > DEVICE_TIMEOUT_MS)
|
||||
{
|
||||
qDebug() << "Removing old device:" << it.value().name << "at" << it.key();
|
||||
it = devices.erase(it); // Remove device if not seen recently
|
||||
}
|
||||
else
|
||||
{
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,11 @@
|
||||
#include <QMap>
|
||||
#include <QString>
|
||||
#include <QDateTime>
|
||||
#include "enums.h"
|
||||
|
||||
class QTimer;
|
||||
|
||||
class DeviceInfo
|
||||
class BleInfo
|
||||
{
|
||||
public:
|
||||
QString name;
|
||||
@@ -20,20 +21,24 @@ public:
|
||||
bool leftCharging = false;
|
||||
bool rightCharging = false;
|
||||
bool caseCharging = false;
|
||||
quint16 deviceModel = 0;
|
||||
AirpodsTrayApp::Enums::AirPodsModel modelName = AirpodsTrayApp::Enums::AirPodsModel::Unknown;
|
||||
quint8 lidOpenCounter = 0;
|
||||
quint8 deviceColor = 0;
|
||||
QString color = "Unknown"; // Default color
|
||||
quint8 status = 0;
|
||||
QByteArray rawData;
|
||||
QByteArray encryptedPayload; // 16 bytes of encrypted payload
|
||||
|
||||
// Additional status flags from Kotlin version
|
||||
bool isLeftPodInEar = false;
|
||||
bool isRightPodInEar = false;
|
||||
bool isPrimaryInEar = false;
|
||||
bool isSecondaryInEar = false;
|
||||
bool isLeftPodMicrophone = false;
|
||||
bool isRightPodMicrophone = false;
|
||||
bool isThisPodInTheCase = false;
|
||||
bool isOnePodInCase = false;
|
||||
bool areBothPodsInCase = false;
|
||||
bool primaryLeft = true; // True if left pod is primary, false if right pod is primary
|
||||
|
||||
// Lid state enumeration
|
||||
enum class LidState
|
||||
@@ -41,8 +46,7 @@ public:
|
||||
OPEN = 0x0,
|
||||
CLOSED = 0x1,
|
||||
UNKNOWN,
|
||||
};
|
||||
LidState lidState = LidState::UNKNOWN;
|
||||
} lidState = LidState::UNKNOWN;
|
||||
|
||||
// Connection state enumeration
|
||||
enum class ConnectionState : uint8_t
|
||||
@@ -54,8 +58,7 @@ public:
|
||||
RINGING = 0x07,
|
||||
HANGING_UP = 0x09,
|
||||
UNKNOWN = 0xFF // Using 0xFF for representing null in the original
|
||||
};
|
||||
ConnectionState connectionState = ConnectionState::UNKNOWN;
|
||||
} connectionState = ConnectionState::UNKNOWN;
|
||||
|
||||
QDateTime lastSeen; // Timestamp of last detection
|
||||
};
|
||||
@@ -69,21 +72,18 @@ public:
|
||||
|
||||
void startScan();
|
||||
void stopScan();
|
||||
const QMap<QString, DeviceInfo> &getDevices() const;
|
||||
bool isScanning() const;
|
||||
|
||||
private slots:
|
||||
void onDeviceDiscovered(const QBluetoothDeviceInfo &info);
|
||||
void onScanFinished();
|
||||
void onErrorOccurred(QBluetoothDeviceDiscoveryAgent::Error error);
|
||||
void pruneOldDevices();
|
||||
|
||||
signals:
|
||||
void deviceFound(const BleInfo &device);
|
||||
|
||||
private:
|
||||
QBluetoothDeviceDiscoveryAgent *discoveryAgent;
|
||||
QMap<QString, DeviceInfo> devices;
|
||||
|
||||
QTimer *pruneTimer; // Timer for periodic pruning
|
||||
static const int PRUNE_INTERVAL_MS = 5000; // Check every 5 seconds
|
||||
static const int DEVICE_TIMEOUT_MS = 10000; // Remove after 10 seconds
|
||||
};
|
||||
|
||||
#endif // BLEMANAGER_H
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <QSettings>
|
||||
#include "battery.hpp"
|
||||
#include "enums.h"
|
||||
#include "eardetection.hpp"
|
||||
|
||||
using namespace AirpodsTrayApp::Enums;
|
||||
|
||||
@@ -12,14 +13,11 @@ class DeviceInfo : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
Q_PROPERTY(QString batteryStatus READ batteryStatus WRITE setBatteryStatus NOTIFY batteryStatusChanged)
|
||||
Q_PROPERTY(QString earDetectionStatus READ earDetectionStatus WRITE setEarDetectionStatus NOTIFY earDetectionStatusChanged)
|
||||
Q_PROPERTY(int noiseControlMode READ noiseControlModeInt WRITE setNoiseControlModeInt NOTIFY noiseControlModeChangedInt)
|
||||
Q_PROPERTY(bool conversationalAwareness READ conversationalAwareness WRITE setConversationalAwareness NOTIFY conversationalAwarenessChanged)
|
||||
Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged)
|
||||
Q_PROPERTY(QString deviceName READ deviceName WRITE setDeviceName NOTIFY deviceNameChanged)
|
||||
Q_PROPERTY(Battery *battery READ getBattery CONSTANT)
|
||||
Q_PROPERTY(bool primaryInEar READ isPrimaryInEar WRITE setPrimaryInEar NOTIFY primaryChanged)
|
||||
Q_PROPERTY(bool secondaryInEar READ isSecondaryInEar WRITE setSecondaryInEar NOTIFY primaryChanged)
|
||||
Q_PROPERTY(bool oneBudANCMode READ oneBudANCMode WRITE setOneBudANCMode NOTIFY oneBudANCModeChanged)
|
||||
Q_PROPERTY(AirPodsModel model READ model WRITE setModel NOTIFY modelChanged)
|
||||
Q_PROPERTY(bool adaptiveModeActive READ adaptiveModeActive NOTIFY noiseControlModeChangedInt)
|
||||
@@ -28,9 +26,13 @@ class DeviceInfo : public QObject
|
||||
Q_PROPERTY(bool leftPodInEar READ isLeftPodInEar NOTIFY primaryChanged)
|
||||
Q_PROPERTY(bool rightPodInEar READ isRightPodInEar NOTIFY primaryChanged)
|
||||
Q_PROPERTY(QString bluetoothAddress READ bluetoothAddress WRITE setBluetoothAddress NOTIFY bluetoothAddressChanged)
|
||||
Q_PROPERTY(QString magicAccIRK READ magicAccIRKHex CONSTANT)
|
||||
Q_PROPERTY(QString magicAccEncKey READ magicAccEncKeyHex CONSTANT)
|
||||
|
||||
public:
|
||||
explicit DeviceInfo(QObject *parent = nullptr) : QObject(parent), m_battery(new Battery(this)) {}
|
||||
explicit DeviceInfo(QObject *parent = nullptr) : QObject(parent), m_battery(new Battery(this)), m_earDetection(new EarDetection(this)) {
|
||||
connect(getEarDetection(), &EarDetection::statusChanged, this, &DeviceInfo::primaryChanged);
|
||||
}
|
||||
|
||||
QString batteryStatus() const { return m_batteryStatus; }
|
||||
void setBatteryStatus(const QString &status)
|
||||
@@ -42,16 +44,6 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
QString earDetectionStatus() const { return m_earDetectionStatus; }
|
||||
void setEarDetectionStatus(const QString &status)
|
||||
{
|
||||
if (m_earDetectionStatus != status)
|
||||
{
|
||||
m_earDetectionStatus = status;
|
||||
emit earDetectionStatusChanged(status);
|
||||
}
|
||||
}
|
||||
|
||||
NoiseControlMode noiseControlMode() const { return m_noiseControlMode; }
|
||||
void setNoiseControlMode(NoiseControlMode mode)
|
||||
{
|
||||
@@ -97,26 +89,6 @@ public:
|
||||
|
||||
Battery *getBattery() const { return m_battery; }
|
||||
|
||||
bool isPrimaryInEar() const { return m_primaryInEar; }
|
||||
void setPrimaryInEar(bool inEar)
|
||||
{
|
||||
if (m_primaryInEar != inEar)
|
||||
{
|
||||
m_primaryInEar = inEar;
|
||||
emit primaryChanged();
|
||||
}
|
||||
}
|
||||
|
||||
bool isSecondaryInEar() const { return m_secoundaryInEar; }
|
||||
void setSecondaryInEar(bool inEar)
|
||||
{
|
||||
if (m_secoundaryInEar != inEar)
|
||||
{
|
||||
m_secoundaryInEar = inEar;
|
||||
emit primaryChanged();
|
||||
}
|
||||
}
|
||||
|
||||
bool oneBudANCMode() const { return m_oneBudANCMode; }
|
||||
void setOneBudANCMode(bool enabled)
|
||||
{
|
||||
@@ -139,9 +111,11 @@ public:
|
||||
|
||||
QByteArray magicAccIRK() const { return m_magicAccIRK; }
|
||||
void setMagicAccIRK(const QByteArray &irk) { m_magicAccIRK = irk; }
|
||||
QString magicAccIRKHex() const { return QString::fromUtf8(m_magicAccIRK.toHex()); }
|
||||
|
||||
QByteArray magicAccEncKey() const { return m_magicAccEncKey; }
|
||||
void setMagicAccEncKey(const QByteArray &key) { m_magicAccEncKey = key; }
|
||||
QString magicAccEncKeyHex() const { return QString::fromUtf8(m_magicAccEncKey.toHex()); }
|
||||
|
||||
QString modelNumber() const { return m_modelNumber; }
|
||||
void setModelNumber(const QString &modelNumber) { m_modelNumber = modelNumber; }
|
||||
@@ -163,18 +137,18 @@ public:
|
||||
QString caseIcon() const { return getModelIcon(model()).second; }
|
||||
bool isLeftPodInEar() const
|
||||
{
|
||||
if (getBattery()->getPrimaryPod() == Battery::Component::Left) return isPrimaryInEar();
|
||||
else return isSecondaryInEar();
|
||||
if (getBattery()->getPrimaryPod() == Battery::Component::Left) return getEarDetection()->isPrimaryInEar();
|
||||
else return getEarDetection()->isSecondaryInEar();
|
||||
}
|
||||
bool isRightPodInEar() const
|
||||
{
|
||||
if (getBattery()->getPrimaryPod() == Battery::Component::Right) return isPrimaryInEar();
|
||||
else return isSecondaryInEar();
|
||||
if (getBattery()->getPrimaryPod() == Battery::Component::Right) return getEarDetection()->isPrimaryInEar();
|
||||
else return getEarDetection()->isSecondaryInEar();
|
||||
}
|
||||
|
||||
bool adaptiveModeActive() const { return noiseControlMode() == NoiseControlMode::Adaptive; }
|
||||
bool oneOrMorePodsInCase() const { return earDetectionStatus().contains("In case"); }
|
||||
bool oneOrMorePodsInEar() const { return isPrimaryInEar() || isSecondaryInEar(); }
|
||||
|
||||
EarDetection *getEarDetection() const { return m_earDetection; }
|
||||
|
||||
void reset()
|
||||
{
|
||||
@@ -182,38 +156,38 @@ public:
|
||||
setModel(AirPodsModel::Unknown);
|
||||
m_battery->reset();
|
||||
setBatteryStatus("");
|
||||
setEarDetectionStatus("");
|
||||
setPrimaryInEar(false);
|
||||
setSecondaryInEar(false);
|
||||
setNoiseControlMode(NoiseControlMode::Off);
|
||||
setBluetoothAddress("");
|
||||
getEarDetection()->reset();
|
||||
}
|
||||
|
||||
void save() const
|
||||
void saveToSettings(QSettings &settings)
|
||||
{
|
||||
QSettings settings("AirpodsTrayApp", "DeviceInfo");
|
||||
settings.beginGroup("DeviceInfo");
|
||||
settings.setValue("deviceName", m_deviceName);
|
||||
settings.setValue("bluetoothAddress", m_bluetoothAddress);
|
||||
settings.setValue("magicAccIRK", m_magicAccIRK.toBase64());
|
||||
settings.setValue("magicAccEncKey", m_magicAccEncKey.toBase64());
|
||||
settings.setValue("deviceName", deviceName());
|
||||
settings.setValue("model", static_cast<int>(model()));
|
||||
settings.setValue("magicAccIRK", magicAccIRK());
|
||||
settings.setValue("magicAccEncKey", magicAccEncKey());
|
||||
settings.endGroup();
|
||||
}
|
||||
|
||||
void load()
|
||||
void loadFromSettings(const QSettings &settings)
|
||||
{
|
||||
QSettings settings("AirpodsTrayApp", "DeviceInfo");
|
||||
settings.beginGroup("DeviceInfo");
|
||||
setDeviceName(settings.value("deviceName", "").toString());
|
||||
setBluetoothAddress(settings.value("bluetoothAddress", "").toString());
|
||||
setMagicAccIRK(QByteArray::fromBase64(settings.value("magicAccIRK", "").toByteArray()));
|
||||
setMagicAccEncKey(QByteArray::fromBase64(settings.value("magicAccEncKey", "").toByteArray()));
|
||||
settings.endGroup();
|
||||
setDeviceName(settings.value("DeviceInfo/deviceName", "").toString());
|
||||
setModel(static_cast<AirPodsModel>(settings.value("DeviceInfo/model", (int)(AirPodsModel::Unknown)).toInt()));
|
||||
setMagicAccIRK(settings.value("DeviceInfo/magicAccIRK", QByteArray()).toByteArray());
|
||||
setMagicAccEncKey(settings.value("DeviceInfo/magicAccEncKey", QByteArray()).toByteArray());
|
||||
}
|
||||
|
||||
void updateBatteryStatus()
|
||||
{
|
||||
int leftLevel = getBattery()->getState(Battery::Component::Left).level;
|
||||
int rightLevel = getBattery()->getState(Battery::Component::Right).level;
|
||||
int caseLevel = getBattery()->getState(Battery::Component::Case).level;
|
||||
setBatteryStatus(QString("Left: %1%, Right: %2%, Case: %3%").arg(leftLevel).arg(rightLevel).arg(caseLevel));
|
||||
}
|
||||
|
||||
signals:
|
||||
void batteryStatusChanged(const QString &status);
|
||||
void earDetectionStatusChanged(const QString &status);
|
||||
void noiseControlModeChanged(NoiseControlMode mode);
|
||||
void noiseControlModeChangedInt(int mode);
|
||||
void conversationalAwarenessChanged(bool enabled);
|
||||
@@ -226,14 +200,11 @@ signals:
|
||||
|
||||
private:
|
||||
QString m_batteryStatus;
|
||||
QString m_earDetectionStatus;
|
||||
NoiseControlMode m_noiseControlMode = NoiseControlMode::Off;
|
||||
NoiseControlMode m_noiseControlMode = NoiseControlMode::Transparency;
|
||||
bool m_conversationalAwareness = false;
|
||||
int m_adaptiveNoiseLevel = 50;
|
||||
QString m_deviceName;
|
||||
Battery *m_battery;
|
||||
bool m_primaryInEar = false;
|
||||
bool m_secoundaryInEar = false;
|
||||
QByteArray m_magicAccIRK;
|
||||
QByteArray m_magicAccEncKey;
|
||||
bool m_oneBudANCMode = false;
|
||||
@@ -241,4 +212,5 @@ private:
|
||||
QString m_modelNumber;
|
||||
QString m_manufacturer;
|
||||
QString m_bluetoothAddress;
|
||||
EarDetection *m_earDetection;
|
||||
};
|
||||
94
linux/eardetection.hpp
Normal file
@@ -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;
|
||||
};
|
||||
@@ -3,9 +3,9 @@
|
||||
#include <QDebug>
|
||||
#include <QLoggingCategory>
|
||||
|
||||
Q_DECLARE_LOGGING_CATEGORY(airpodsApp)
|
||||
Q_DECLARE_LOGGING_CATEGORY(Librepods)
|
||||
|
||||
#define LOG_INFO(msg) qCInfo(airpodsApp) << "\033[32m" << msg << "\033[0m"
|
||||
#define LOG_WARN(msg) qCWarning(airpodsApp) << "\033[33m" << msg << "\033[0m"
|
||||
#define LOG_ERROR(msg) qCCritical(airpodsApp) << "\033[31m" << msg << "\033[0m"
|
||||
#define LOG_DEBUG(msg) qCDebug(airpodsApp) << "\033[34m" << msg << "\033[0m"
|
||||
#define LOG_INFO(msg) qCInfo(Librepods) << "\033[32m" << msg << "\033[0m"
|
||||
#define LOG_WARN(msg) qCWarning(Librepods) << "\033[33m" << msg << "\033[0m"
|
||||
#define LOG_ERROR(msg) qCCritical(Librepods) << "\033[31m" << msg << "\033[0m"
|
||||
#define LOG_DEBUG(msg) qCDebug(Librepods) << "\033[34m" << msg << "\033[0m"
|
||||
|
||||
191
linux/main.cpp
@@ -1,20 +1,35 @@
|
||||
#include <QSettings>
|
||||
#include <QLocalServer>
|
||||
#include <QLocalSocket>
|
||||
#include "main.h"
|
||||
#include <QApplication>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QQmlContext>
|
||||
#include <QBluetoothLocalDevice>
|
||||
#include <QBluetoothSocket>
|
||||
#include <QQuickWindow>
|
||||
#include <QLoggingCategory>
|
||||
#include <QThread>
|
||||
#include <QTimer>
|
||||
#include <QProcess>
|
||||
#include <QRegularExpression>
|
||||
|
||||
#include "airpods_packets.h"
|
||||
#include "logger.h"
|
||||
#include "mediacontroller.h"
|
||||
#include "media/mediacontroller.h"
|
||||
#include "trayiconmanager.h"
|
||||
#include "enums.h"
|
||||
#include "battery.hpp"
|
||||
#include "BluetoothMonitor.h"
|
||||
#include "autostartmanager.hpp"
|
||||
#include "deviceinfo.hpp"
|
||||
#include "ble/blemanager.h"
|
||||
#include "ble/bleutils.h"
|
||||
#include "QRCodeImageProvider.hpp"
|
||||
#include "systemsleepmonitor.hpp"
|
||||
|
||||
using namespace AirpodsTrayApp::Enums;
|
||||
|
||||
Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
|
||||
Q_LOGGING_CATEGORY(Librepods, "Librepods")
|
||||
|
||||
class AirPodsTrayApp : public QObject {
|
||||
Q_OBJECT
|
||||
@@ -26,12 +41,16 @@ class AirPodsTrayApp : public QObject {
|
||||
Q_PROPERTY(int retryAttempts READ retryAttempts WRITE setRetryAttempts NOTIFY retryAttemptsChanged)
|
||||
Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT)
|
||||
Q_PROPERTY(DeviceInfo *deviceInfo READ deviceInfo CONSTANT)
|
||||
Q_PROPERTY(QString phoneMacStatus READ phoneMacStatus NOTIFY phoneMacStatusChanged)
|
||||
|
||||
public:
|
||||
AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr)
|
||||
: QObject(parent), debugMode(debugMode), m_settings(new QSettings("AirPodsTrayApp", "AirPodsTrayApp")), m_autoStartManager(new AutoStartManager(this)), m_hideOnStart(hideOnStart), parent(parent), m_deviceInfo(new DeviceInfo(this))
|
||||
: QObject(parent), debugMode(debugMode), m_settings(new QSettings("AirPodsTrayApp", "AirPodsTrayApp"))
|
||||
, m_autoStartManager(new AutoStartManager(this)), m_hideOnStart(hideOnStart), parent(parent)
|
||||
, m_deviceInfo(new DeviceInfo(this)), m_bleManager(new BleManager(this))
|
||||
, m_systemSleepMonitor(new SystemSleepMonitor(this))
|
||||
{
|
||||
QLoggingCategory::setFilterRules(QString("airpodsApp.debug=%1").arg(debugMode ? "true" : "false"));
|
||||
QLoggingCategory::setFilterRules(QString("Librepods.debug=%1").arg(debugMode ? "true" : "false"));
|
||||
LOG_INFO("Initializing AirPodsTrayApp");
|
||||
|
||||
// Initialize tray icon and connect signals
|
||||
@@ -50,16 +69,17 @@ public:
|
||||
|
||||
// Initialize MediaController and connect signals
|
||||
mediaController = new MediaController(this);
|
||||
connect(m_deviceInfo, &DeviceInfo::earDetectionStatusChanged, mediaController, &MediaController::handleEarDetection);
|
||||
connect(mediaController, &MediaController::mediaStateChanged, this, &AirPodsTrayApp::handleMediaStateChange);
|
||||
mediaController->initializeMprisInterface();
|
||||
mediaController->followMediaChanges();
|
||||
|
||||
monitor = new BluetoothMonitor(this);
|
||||
connect(monitor, &BluetoothMonitor::deviceConnected, this, &AirPodsTrayApp::bluezDeviceConnected);
|
||||
connect(monitor, &BluetoothMonitor::deviceDisconnected, this, &AirPodsTrayApp::bluezDeviceDisconnected);
|
||||
|
||||
connect(m_bleManager, &BleManager::deviceFound, this, &AirPodsTrayApp::bleDeviceFound);
|
||||
connect(m_deviceInfo->getBattery(), &Battery::primaryChanged, this, &AirPodsTrayApp::primaryChanged);
|
||||
connect(m_systemSleepMonitor, &SystemSleepMonitor::systemGoingToSleep, this, &AirPodsTrayApp::onSystemGoingToSleep);
|
||||
connect(m_systemSleepMonitor, &SystemSleepMonitor::systemWakingUp, this, &AirPodsTrayApp::onSystemWakingUp);
|
||||
|
||||
// Load settings
|
||||
CrossDevice.isEnabled = loadCrossDeviceEnabled();
|
||||
@@ -101,6 +121,7 @@ public:
|
||||
int retryAttempts() const { return m_retryAttempts; }
|
||||
bool hideOnStart() const { return m_hideOnStart; }
|
||||
DeviceInfo *deviceInfo() const { return m_deviceInfo; }
|
||||
QString phoneMacStatus() const { return m_phoneMacStatus; }
|
||||
|
||||
private:
|
||||
bool debugMode;
|
||||
@@ -151,6 +172,11 @@ public slots:
|
||||
|
||||
void setNoiseControlMode(NoiseControlMode mode)
|
||||
{
|
||||
if (m_deviceInfo->noiseControlMode() == mode)
|
||||
{
|
||||
LOG_INFO("Noise control mode is already set to: " << static_cast<int>(mode));
|
||||
return;
|
||||
}
|
||||
LOG_INFO("Setting noise control mode to: " << mode);
|
||||
QByteArray packet = AirPodsPackets::NoiseControl::getPacketForMode(mode);
|
||||
writePacketToSocket(packet, "Noise control mode packet written: ");
|
||||
@@ -287,6 +313,51 @@ public slots:
|
||||
emit crossDeviceEnabledChanged(enabled);
|
||||
}
|
||||
|
||||
void setPhoneMac(const QString &mac)
|
||||
{
|
||||
if (mac.isEmpty()) {
|
||||
LOG_WARN("Empty MAC provided, ignoring");
|
||||
m_phoneMacStatus = QStringLiteral("No MAC provided (ignoring)");
|
||||
emit phoneMacStatusChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic MAC address validation (accepts formats like AA:BB:CC:DD:EE:FF, AABBCCDDEEFF, AA-BB-CC-DD-EE-FF)
|
||||
QRegularExpression re("^([0-9A-Fa-f]{2}([-:]?)){5}[0-9A-Fa-f]{2}$");
|
||||
if (!re.match(mac).hasMatch()) {
|
||||
LOG_ERROR("Invalid MAC address format: " << mac);
|
||||
m_phoneMacStatus = QStringLiteral("Invalid MAC: ") + mac;
|
||||
emit phoneMacStatusChanged();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set environment variable for the running process
|
||||
qputenv("PHONE_MAC_ADDRESS", mac.toUtf8());
|
||||
LOG_INFO("PHONE_MAC_ADDRESS environment variable set to: " << mac);
|
||||
|
||||
m_phoneMacStatus = QStringLiteral("Updated MAC: ") + mac;
|
||||
emit phoneMacStatusChanged();
|
||||
|
||||
// Update QML context property so UI placeholders reflect the new value
|
||||
if (parent) {
|
||||
parent->rootContext()->setContextProperty("PHONE_MAC_ADDRESS", mac);
|
||||
}
|
||||
|
||||
// If a phone socket exists, restart connection using the new MAC
|
||||
if (phoneSocket && phoneSocket->isOpen()) {
|
||||
phoneSocket->close();
|
||||
phoneSocket->deleteLater();
|
||||
phoneSocket = nullptr;
|
||||
}
|
||||
connectToPhone();
|
||||
}
|
||||
|
||||
void updatePhoneMacStatus(const QString &status)
|
||||
{
|
||||
m_phoneMacStatus = status;
|
||||
emit phoneMacStatusChanged();
|
||||
}
|
||||
|
||||
bool writePacketToSocket(const QByteArray &packet, const QString &logMessage)
|
||||
{
|
||||
if (socket && socket->isOpen())
|
||||
@@ -314,6 +385,20 @@ public slots:
|
||||
int loadRetryAttempts() const { return m_settings->value("bluetooth/retryAttempts", 3).toInt(); }
|
||||
void saveRetryAttempts(int attempts) { m_settings->setValue("bluetooth/retryAttempts", attempts); }
|
||||
|
||||
void onSystemGoingToSleep()
|
||||
{
|
||||
if (m_bleManager->isScanning())
|
||||
{
|
||||
LOG_INFO("Stopping BLE scan before going to sleep");
|
||||
m_bleManager->stopScan();
|
||||
}
|
||||
}
|
||||
void onSystemWakingUp()
|
||||
{
|
||||
LOG_INFO("System is waking up, starting ble scan");
|
||||
m_bleManager->startScan();
|
||||
}
|
||||
|
||||
private slots:
|
||||
void onTrayIconActivated()
|
||||
{
|
||||
@@ -379,6 +464,8 @@ private slots:
|
||||
|
||||
// Clear the device name and model
|
||||
m_deviceInfo->reset();
|
||||
m_bleManager->startScan();
|
||||
emit airPodsStatusChanged();
|
||||
|
||||
// Show system notification
|
||||
trayManager->showNotification(
|
||||
@@ -545,6 +632,7 @@ private slots:
|
||||
// Store the keys
|
||||
m_deviceInfo->setMagicAccIRK(keys.magicAccIRK);
|
||||
m_deviceInfo->setMagicAccEncKey(keys.magicAccEncKey);
|
||||
m_deviceInfo->saveToSettings(*m_settings);
|
||||
}
|
||||
// Get CA state
|
||||
else if (data.startsWith(AirPodsPackets::ConversationalAwareness::HEADER)) {
|
||||
@@ -559,7 +647,6 @@ private slots:
|
||||
{
|
||||
if (auto value = AirPodsPackets::NoiseControl::parseMode(data))
|
||||
{
|
||||
LOG_INFO("Received noise control mode: " << value.value());
|
||||
m_deviceInfo->setNoiseControlMode(value.value());
|
||||
LOG_INFO("Noise control mode received: " << m_deviceInfo->noiseControlMode());
|
||||
}
|
||||
@@ -567,26 +654,14 @@ private slots:
|
||||
// Ear Detection
|
||||
else if (data.size() == 8 && data.startsWith(AirPodsPackets::Parse::EAR_DETECTION))
|
||||
{
|
||||
char primary = data[6];
|
||||
char secondary = data[7];
|
||||
m_deviceInfo->setPrimaryInEar(data[6] == 0x00);
|
||||
m_deviceInfo->setSecondaryInEar(data[7] == 0x00);
|
||||
m_deviceInfo->setEarDetectionStatus(QString("Primary: %1, Secondary: %2")
|
||||
.arg(getEarStatus(primary), getEarStatus(secondary)));
|
||||
LOG_INFO("Ear detection status: " << m_deviceInfo->earDetectionStatus());
|
||||
m_deviceInfo->getEarDetection()->parseData(data);
|
||||
mediaController->handleEarDetection(m_deviceInfo->getEarDetection());
|
||||
}
|
||||
// Battery Status
|
||||
else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
|
||||
{
|
||||
m_deviceInfo->getBattery()->parsePacket(data);
|
||||
|
||||
int leftLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Left).level;
|
||||
int rightLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Right).level;
|
||||
int caseLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Case).level;
|
||||
m_deviceInfo->setBatteryStatus(QString("Left: %1%, Right: %2%, Case: %3%")
|
||||
.arg(leftLevel)
|
||||
.arg(rightLevel)
|
||||
.arg(caseLevel));
|
||||
m_deviceInfo->updateBatteryStatus();
|
||||
LOG_INFO("Battery status: " << m_deviceInfo->batteryStatus());
|
||||
}
|
||||
// Conversational Awareness Data
|
||||
@@ -600,10 +675,11 @@ private slots:
|
||||
parseMetadata(data);
|
||||
initiateMagicPairing();
|
||||
mediaController->setConnectedDeviceMacAddress(m_deviceInfo->bluetoothAddress().replace(":", "_"));
|
||||
if (m_deviceInfo->oneOrMorePodsInEar()) // AirPods get added as output device only after this
|
||||
if (m_deviceInfo->getEarDetection()->oneOrMorePodsInEar()) // AirPods get added as output device only after this
|
||||
{
|
||||
mediaController->activateA2dpProfile();
|
||||
}
|
||||
m_bleManager->stopScan();
|
||||
emit airPodsStatusChanged();
|
||||
}
|
||||
else if (data.startsWith(AirPodsPackets::OneBudANCMode::HEADER)) {
|
||||
@@ -628,11 +704,12 @@ private slots:
|
||||
LOG_INFO("Already connected to the phone");
|
||||
return;
|
||||
}
|
||||
QBluetoothAddress phoneAddress(PHONE_MAC_ADDRESS);
|
||||
QBluetoothAddress phoneAddress("00:00:00:00:00:00"); // Default address, will be overwritten if PHONE_MAC_ADDRESS is set
|
||||
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
||||
|
||||
if (!env.value("PHONE_MAC_ADDRESS").isEmpty())
|
||||
{
|
||||
QBluetoothAddress phoneAddress = QBluetoothAddress(env.value("PHONE_MAC_ADDRESS"));
|
||||
phoneAddress = QBluetoothAddress(env.value("PHONE_MAC_ADDRESS"));
|
||||
}
|
||||
phoneSocket = new QBluetoothSocket(QBluetoothServiceInfo::L2capProtocol);
|
||||
connect(phoneSocket, &QBluetoothSocket::connected, this, [this]() {
|
||||
@@ -733,6 +810,16 @@ private slots:
|
||||
QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data));
|
||||
}
|
||||
|
||||
void bleDeviceFound(const BleInfo &device)
|
||||
{
|
||||
if (BLEUtils::isValidIrkRpa(m_deviceInfo->magicAccIRK(), device.address)) {
|
||||
m_deviceInfo->setModel(device.modelName);
|
||||
auto decryptet = BLEUtils::decryptLastBytes(device.encryptedPayload, m_deviceInfo->magicAccEncKey());
|
||||
m_deviceInfo->getBattery()->parseEncryptedPacket(decryptet, device.primaryLeft, device.isThisPodInTheCase);
|
||||
m_deviceInfo->getEarDetection()->overrideEarDetectionStatus(device.isPrimaryInEar, device.isSecondaryInEar);
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
void handleMediaStateChange(MediaController::MediaState state) {
|
||||
if (state == MediaController::MediaState::Playing) {
|
||||
@@ -774,13 +861,6 @@ public:
|
||||
process.waitForFinished();
|
||||
QString output = process.readAllStandardOutput().trimmed();
|
||||
LOG_INFO("Bluetoothctl output: " << output);
|
||||
if (output.contains("Connection successful")) {
|
||||
LOG_INFO("Connection successful, proceeding with L2CAP connection");
|
||||
QBluetoothAddress btAddress(m_deviceInfo->bluetoothAddress());
|
||||
forceL2capConnection(btAddress);
|
||||
} else {
|
||||
LOG_ERROR("Connection failed, cannot proceed with L2CAP connection");
|
||||
}
|
||||
}
|
||||
QBluetoothLocalDevice localDevice;
|
||||
const QList<QBluetoothAddress> connectedDevices = localDevice.connectedDevices();
|
||||
@@ -795,33 +875,13 @@ public:
|
||||
LOG_WARN("AirPods not found among connected devices");
|
||||
}
|
||||
|
||||
void forceL2capConnection(const QBluetoothAddress &address) {
|
||||
LOG_INFO("Retrying L2CAP connection for up to 10 seconds...");
|
||||
QBluetoothDeviceInfo device(address, "", 0);
|
||||
QElapsedTimer timer;
|
||||
timer.start();
|
||||
while (timer.elapsed() < 10000) {
|
||||
QProcess bcProcess;
|
||||
bcProcess.start("bluetoothctl", QStringList() << "connect" << address.toString());
|
||||
bcProcess.waitForFinished();
|
||||
QString output = bcProcess.readAllStandardOutput().trimmed();
|
||||
LOG_INFO("Bluetoothctl output: " << output);
|
||||
if (output.contains("Connection successful")) {
|
||||
connectToDevice(device);
|
||||
QThread::sleep(1);
|
||||
if (socket && socket->isOpen()) {
|
||||
LOG_INFO("Successfully connected to device: " << address.toString());
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
LOG_WARN("Connection attempt failed, retrying...");
|
||||
}
|
||||
}
|
||||
LOG_ERROR("Failed to connect to device within 10 seconds: " << address.toString());
|
||||
}
|
||||
|
||||
void initializeBluetooth() {
|
||||
connectToPhone();
|
||||
|
||||
m_deviceInfo->loadFromSettings(*m_settings);
|
||||
if (!areAirpodsConnected()) {
|
||||
m_bleManager->startScan();
|
||||
}
|
||||
}
|
||||
|
||||
void loadMainModule() {
|
||||
@@ -843,6 +903,7 @@ signals:
|
||||
void notificationsEnabledChanged(bool enabled);
|
||||
void retryAttemptsChanged(int attempts);
|
||||
void oneBudANCModeChanged(bool enabled);
|
||||
void phoneMacStatusChanged();
|
||||
|
||||
private:
|
||||
QBluetoothSocket *socket = nullptr;
|
||||
@@ -857,6 +918,9 @@ private:
|
||||
int m_retryAttempts = 3;
|
||||
bool m_hideOnStart = false;
|
||||
DeviceInfo *m_deviceInfo;
|
||||
BleManager *m_bleManager;
|
||||
SystemSleepMonitor *m_systemSleepMonitor = nullptr;
|
||||
QString m_phoneMacStatus;
|
||||
};
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
@@ -904,6 +968,17 @@ int main(int argc, char *argv[]) {
|
||||
qmlRegisterType<DeviceInfo>("me.kavishdevar.DeviceInfo", 1, 0, "DeviceInfo");
|
||||
AirPodsTrayApp *trayApp = new AirPodsTrayApp(debugMode, hideOnStart, &engine);
|
||||
engine.rootContext()->setContextProperty("airPodsTrayApp", trayApp);
|
||||
|
||||
// Expose PHONE_MAC_ADDRESS environment variable to QML for placeholder in settings
|
||||
{
|
||||
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
||||
QString phoneMacEnv = env.value("PHONE_MAC_ADDRESS", "");
|
||||
engine.rootContext()->setContextProperty("PHONE_MAC_ADDRESS", phoneMacEnv);
|
||||
// Initialize the visible status in the GUI
|
||||
trayApp->updatePhoneMacStatus(phoneMacEnv.isEmpty() ? QStringLiteral("No phone MAC set") : phoneMacEnv);
|
||||
}
|
||||
|
||||
engine.addImageProvider("qrcode", new QRCodeImageProvider());
|
||||
trayApp->loadMainModule();
|
||||
|
||||
QLocalServer server;
|
||||
|
||||
36
linux/main.h
@@ -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
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "mediacontroller.h"
|
||||
#include "logger.h"
|
||||
#include "eardetection.hpp"
|
||||
#include "playerstatuswatcher.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QProcess>
|
||||
@@ -8,37 +10,9 @@
|
||||
#include <QDBusConnectionInterface>
|
||||
|
||||
MediaController::MediaController(QObject *parent) : QObject(parent) {
|
||||
// No additional initialization required here
|
||||
}
|
||||
|
||||
void MediaController::initializeMprisInterface() {
|
||||
QStringList services =
|
||||
QDBusConnection::sessionBus().interface()->registeredServiceNames();
|
||||
QString mprisService;
|
||||
|
||||
for (const QString &service : services) {
|
||||
if (service.startsWith("org.mpris.MediaPlayer2.") &&
|
||||
service != "org.mpris.MediaPlayer2") {
|
||||
mprisService = service;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mprisService.isEmpty()) {
|
||||
mprisInterface = new QDBusInterface(mprisService, "/org/mpris/MediaPlayer2",
|
||||
"org.mpris.MediaPlayer2.Player",
|
||||
QDBusConnection::sessionBus(), this);
|
||||
if (!mprisInterface->isValid()) {
|
||||
LOG_ERROR("Failed to initialize MPRIS interface for service: ") << mprisService;
|
||||
} else {
|
||||
LOG_INFO("Connected to MPRIS service: " << mprisService);
|
||||
}
|
||||
} else {
|
||||
LOG_WARN("No active MPRIS media players found");
|
||||
}
|
||||
}
|
||||
|
||||
void MediaController::handleEarDetection(const QString &status)
|
||||
void MediaController::handleEarDetection(EarDetection *earDetection)
|
||||
{
|
||||
if (earDetectionBehavior == Disabled)
|
||||
{
|
||||
@@ -46,15 +20,8 @@ void MediaController::handleEarDetection(const QString &status)
|
||||
return;
|
||||
}
|
||||
|
||||
bool primaryInEar = false;
|
||||
bool secondaryInEar = false;
|
||||
|
||||
QStringList parts = status.split(", ");
|
||||
if (parts.size() == 2)
|
||||
{
|
||||
primaryInEar = parts[0].contains("In Ear");
|
||||
secondaryInEar = parts[1].contains("In Ear");
|
||||
}
|
||||
bool primaryInEar = earDetection->isPrimaryInEar();
|
||||
bool secondaryInEar = earDetection->isSecondaryInEar();
|
||||
|
||||
LOG_DEBUG("Ear detection status: primaryInEar="
|
||||
<< primaryInEar << ", secondaryInEar=" << secondaryInEar
|
||||
@@ -77,12 +44,7 @@ void MediaController::handleEarDetection(const QString &status)
|
||||
|
||||
if (shouldPause && isActiveOutputDeviceAirPods())
|
||||
{
|
||||
QProcess process;
|
||||
process.start("playerctl", QStringList() << "status");
|
||||
process.waitForFinished();
|
||||
QString playbackStatus = process.readAllStandardOutput().trimmed();
|
||||
LOG_DEBUG("Playback status: " << playbackStatus);
|
||||
if (playbackStatus == "Playing")
|
||||
if (getCurrentMediaState() == Playing)
|
||||
{
|
||||
pause();
|
||||
}
|
||||
@@ -97,17 +59,7 @@ void MediaController::handleEarDetection(const QString &status)
|
||||
// Resume if conditions are met and we previously paused
|
||||
if (shouldResume && wasPausedByApp && isActiveOutputDeviceAirPods())
|
||||
{
|
||||
int result = QProcess::execute("playerctl", QStringList() << "play");
|
||||
LOG_DEBUG("Executed 'playerctl play' with result: " << result);
|
||||
if (result == 0)
|
||||
{
|
||||
LOG_INFO("Resumed playback via Playerctl");
|
||||
wasPausedByApp = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG_ERROR("Failed to resume playback via Playerctl");
|
||||
}
|
||||
play();
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -124,16 +76,14 @@ void MediaController::setEarDetectionBehavior(EarDetectionBehavior behavior)
|
||||
}
|
||||
|
||||
void MediaController::followMediaChanges() {
|
||||
playerctlProcess = new QProcess(this);
|
||||
connect(playerctlProcess, &QProcess::readyReadStandardOutput, this,
|
||||
[this]() {
|
||||
QString output =
|
||||
playerctlProcess->readAllStandardOutput().trimmed();
|
||||
LOG_DEBUG("Playerctl output: " << output);
|
||||
MediaState state = mediaStateFromPlayerctlOutput(output);
|
||||
playerStatusWatcher = new PlayerStatusWatcher("", this);
|
||||
connect(playerStatusWatcher, &PlayerStatusWatcher::playbackStatusChanged,
|
||||
this, [this](const QString &status)
|
||||
{
|
||||
LOG_DEBUG("Playback status changed: " << status);
|
||||
MediaState state = mediaStateFromPlayerctlOutput(status);
|
||||
emit mediaStateChanged(state);
|
||||
});
|
||||
playerctlProcess->start("playerctl", QStringList() << "--follow" << "status");
|
||||
}
|
||||
|
||||
bool MediaController::isActiveOutputDeviceAirPods() {
|
||||
@@ -222,7 +172,7 @@ void MediaController::setConnectedDeviceMacAddress(const QString &macAddress) {
|
||||
}
|
||||
|
||||
MediaController::MediaState MediaController::mediaStateFromPlayerctlOutput(
|
||||
const QString &output) {
|
||||
const QString &output) const {
|
||||
if (output == "Playing") {
|
||||
return MediaState::Playing;
|
||||
} else if (output == "Paused") {
|
||||
@@ -232,28 +182,106 @@ MediaController::MediaState MediaController::mediaStateFromPlayerctlOutput(
|
||||
}
|
||||
}
|
||||
|
||||
void MediaController::pause() {
|
||||
int result = QProcess::execute("playerctl", QStringList() << "pause");
|
||||
LOG_DEBUG("Executed 'playerctl pause' with result: " << result);
|
||||
if (result == 0)
|
||||
MediaController::MediaState MediaController::getCurrentMediaState() const
|
||||
{
|
||||
return mediaStateFromPlayerctlOutput(PlayerStatusWatcher::getCurrentPlaybackStatus(""));
|
||||
}
|
||||
|
||||
bool MediaController::sendMediaPlayerCommand(const QString &method)
|
||||
{
|
||||
// Connect to the session bus
|
||||
QDBusConnection bus = QDBusConnection::sessionBus();
|
||||
|
||||
// Find available MPRIS-compatible media players
|
||||
QStringList services = bus.interface()->registeredServiceNames().value();
|
||||
QStringList mprisServices;
|
||||
for (const QString &service : services)
|
||||
{
|
||||
LOG_INFO("Paused playback via Playerctl");
|
||||
if (service.startsWith("org.mpris.MediaPlayer2."))
|
||||
{
|
||||
mprisServices << service;
|
||||
}
|
||||
}
|
||||
|
||||
if (mprisServices.isEmpty())
|
||||
{
|
||||
LOG_ERROR("No MPRIS-compatible media players found on DBus");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool success = false;
|
||||
// Try each MPRIS service until one succeeds
|
||||
for (const QString &service : mprisServices)
|
||||
{
|
||||
QDBusInterface playerInterface(
|
||||
service,
|
||||
"/org/mpris/MediaPlayer2",
|
||||
"org.mpris.MediaPlayer2.Player",
|
||||
bus);
|
||||
|
||||
if (!playerInterface.isValid())
|
||||
{
|
||||
LOG_ERROR("Invalid DBus interface for service: " << service);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send the Play or Pause command
|
||||
if (method == "Play" || method == "Pause")
|
||||
{
|
||||
QDBusReply<void> reply = playerInterface.call(method);
|
||||
if (reply.isValid())
|
||||
{
|
||||
LOG_INFO("Successfully sent " << method << " to " << service);
|
||||
success = true;
|
||||
break; // Exit after the first successful command
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG_ERROR("Failed to send " << method << " to " << service
|
||||
<< ": " << reply.error().message());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG_ERROR("Unsupported method: " << method);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!success)
|
||||
{
|
||||
LOG_ERROR("No media player responded successfully to " << method);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
void MediaController::play()
|
||||
{
|
||||
if (sendMediaPlayerCommand("Play"))
|
||||
{
|
||||
LOG_INFO("Resumed playback via DBus");
|
||||
wasPausedByApp = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG_ERROR("Failed to resume playback via DBus");
|
||||
}
|
||||
}
|
||||
|
||||
void MediaController::pause()
|
||||
{
|
||||
if (sendMediaPlayerCommand("Pause"))
|
||||
{
|
||||
LOG_INFO("Paused playback via DBus");
|
||||
wasPausedByApp = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
LOG_ERROR("Failed to pause playback via Playerctl");
|
||||
LOG_ERROR("Failed to pause playback via DBus");
|
||||
}
|
||||
}
|
||||
|
||||
MediaController::~MediaController() {
|
||||
if (playerctlProcess) {
|
||||
playerctlProcess->terminate();
|
||||
if (!playerctlProcess->waitForFinished()) {
|
||||
playerctlProcess->kill();
|
||||
playerctlProcess->waitForFinished(1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QString MediaController::getAudioDeviceName()
|
||||
@@ -1,10 +1,12 @@
|
||||
#ifndef MEDIACONTROLLER_H
|
||||
#define MEDIACONTROLLER_H
|
||||
|
||||
#include <QDBusInterface>
|
||||
#include <QObject>
|
||||
|
||||
class QProcess;
|
||||
class EarDetection;
|
||||
class PlayerStatusWatcher;
|
||||
class QDBusInterface;
|
||||
|
||||
class MediaController : public QObject
|
||||
{
|
||||
@@ -28,8 +30,7 @@ public:
|
||||
explicit MediaController(QObject *parent = nullptr);
|
||||
~MediaController();
|
||||
|
||||
void initializeMprisInterface();
|
||||
void handleEarDetection(const QString &status);
|
||||
void handleEarDetection(EarDetection*);
|
||||
void followMediaChanges();
|
||||
bool isActiveOutputDeviceAirPods();
|
||||
void handleConversationalAwareness(const QByteArray &data);
|
||||
@@ -40,22 +41,24 @@ public:
|
||||
void setEarDetectionBehavior(EarDetectionBehavior behavior);
|
||||
inline EarDetectionBehavior getEarDetectionBehavior() const { return earDetectionBehavior; }
|
||||
|
||||
void play();
|
||||
void pause();
|
||||
MediaState getCurrentMediaState() const;
|
||||
|
||||
Q_SIGNALS:
|
||||
void mediaStateChanged(MediaState state);
|
||||
|
||||
private:
|
||||
MediaState mediaStateFromPlayerctlOutput(const QString &output);
|
||||
MediaState mediaStateFromPlayerctlOutput(const QString &output) const;
|
||||
QString getAudioDeviceName();
|
||||
bool sendMediaPlayerCommand(const QString &method);
|
||||
|
||||
QDBusInterface *mprisInterface = nullptr;
|
||||
QProcess *playerctlProcess = nullptr;
|
||||
bool wasPausedByApp = false;
|
||||
int initialVolume = -1;
|
||||
QString connectedDeviceMacAddress;
|
||||
EarDetectionBehavior earDetectionBehavior = PauseWhenOneRemoved;
|
||||
QString m_deviceOutputName;
|
||||
PlayerStatusWatcher *playerStatusWatcher = nullptr;
|
||||
};
|
||||
|
||||
#endif // MEDIACONTROLLER_H
|
||||
70
linux/media/playerstatuswatcher.cpp
Normal 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();
|
||||
}
|
||||
25
linux/media/playerstatuswatcher.h
Normal 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;
|
||||
};
|
||||
77
linux/playerstatuswatcher.cpp
Normal 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
|
||||
}
|
||||
}
|
||||
49
linux/systemsleepmonitor.hpp
Normal 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
|
||||
829
linux/thirdparty/QR-Code-generator/qrcodegen.cpp
vendored
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
549
linux/thirdparty/QR-Code-generator/qrcodegen.hpp
vendored
Normal 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
@@ -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()
|
||||