26 Commits

Author SHA1 Message Date
Kavish Devar
b8e9765aff merge linux changes with local 2025-06-03 20:12:47 +05:30
Kavish Devar
62aabe80c1 android: add support for settings hook on A16 2025-06-03 20:12:25 +05:30
Kavish Devar
dc0b06a369 android: remove volume panel hook
I've moved away from AOSP and I can't maintain the hook for pixel/AOSP ROMs
2025-06-03 20:11:08 +05:30
Kavish Devar
96baebee28 Update name in linux README 2025-06-03 14:22:57 +05:30
Tim Gromeyer
c05a37bcca [Linux] Fix UI not working (#137)
* Move mac adress to deviceinfo

* Missing changes
2025-06-03 10:31:19 +02:00
Tim Gromeyer
8a69dbe173 [Linux] Move all device related properties to new class (#135)
* Clean up code

* Move all device releated properties to new class
2025-06-03 09:07:30 +02:00
Kavish Devar
b783b86b7a android: add update config for root module 2025-05-30 17:33:47 +05:30
Kavish Devar
445c999208 android: start head gestures after auto-connect 2025-05-30 17:30:30 +05:30
Kavish Devar
96e63cf35e android: fix head gestures not working 2025-05-21 21:52:41 +05:30
Kavish Devar
5472e09293 android: fix island not closing 2025-05-20 22:31:53 +05:30
Kavish Devar
e852182b48 android: use encrypted data from BLE broadcast for accurate battery levels when not connected over AACP 2025-05-20 14:52:05 +05:30
Kavish Devar
5eb13ace0c android: improve ble-based autoconnection 2025-05-20 09:54:18 +05:30
Kavish Devar
2b1fb5b71e android: use broadcasted battery data if not connected via l2cap for popup 2025-05-19 18:26:44 +05:30
Kavish Devar
c95a619465 android: bump version 2025-05-19 17:39:55 +05:30
Kavish Devar
c4bc47c48a merge the a11 fix with local 2025-05-19 17:28:30 +05:30
Kavish Devar
6a026ebab0 android: refactor AACP and add autoconnect based on BLE broadcasts 2025-05-19 17:24:41 +05:30
Kavish Devar
f3ed3bbc70 [Linux] Add One Bud ANC Mode setting (#128) 2025-05-16 18:10:20 +05:30
Tim Gromeyer
5fe123f544 [Linux] Add One Bud ANC Mode setting 2025-05-16 14:08:42 +02:00
Tim Gromeyer
09e1aa1530 [Linux] Reset tray icon when airpods disconnect 2025-05-16 14:08:20 +02:00
Kavish Devar
fd917d3fd0 [Linux] Add more control commands (#127) 2025-05-16 16:51:09 +05:30
Tim Gromeyer
84891a0bdf Remove VoiceTrigger and InCaseTone 2025-05-16 12:10:40 +02:00
Tim Gromeyer
4b3cc92e56 Make the copilot reviewer happy 2025-05-16 08:41:29 +02:00
Kavish Devar
b89d6d9dc2 android: fix support for A11 and lower 2025-05-16 04:46:25 +00:00
Kavish Devar
6985aa4a7b fix typo in AAP docs 2025-05-15 22:54:12 +05:30
Tim Gromeyer
9161f8b294 [Linux] Add more control commands (4c0381968f) 2025-05-15 12:00:01 +02:00
Kavish Devar
4c0381968f docs: create control_commands.md 2025-05-15 02:16:02 +05:30
58 changed files with 4049 additions and 2003 deletions

View File

@@ -184,7 +184,7 @@ Example packet:
040004001d0002d5000400416972506f64732050726f004133303438004170706c6520496e632e0051584e524848595850360036312e313836383034303030323030303030302e323731330036312e313836383034303030323030303030302e3237313300312e302e3000636f6d2e6170706c652e6163636573736f72792e757064617465722e6170702e3731004859394c5432454632364a59004833504c5748444a32364b3000363335373533360089312a6567a5400f84a3ca234947efd40b90d78436ae5946748d70273e66066a2589300035333935303630363400```
The packet contains device identification and version information followed by some encrypted data whose format is not known.
```
# Writing to the AirPods
@@ -442,4 +442,4 @@ 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/>.
along with this program. If not, see <https://www.gnu.org/licenses/>.

View File

@@ -13,8 +13,8 @@ android {
applicationId = "me.kavishdevar.librepods"
minSdk = 28
targetSdk = 35
versionCode = 6
versionName = "0.1.0-rc.3"
versionCode = 7
versionName = "0.1.0-rc.4"
}
buildTypes {
@@ -61,5 +61,6 @@ dependencies {
implementation(libs.androidx.constraintlayout)
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(libs.androidx.dynamicanimation)
compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
}

View File

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

View File

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

View File

@@ -1,5 +1,8 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
@@ -72,16 +75,12 @@ import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedBu
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
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
data class DismissAnimationValues(
val offsetY: Dp = 0.dp,
val scale: Float = 1f,
val alpha: Float = 1f
)
class QuickSettingsDialogActivity : ComponentActivity() {
private var airPodsService: AirPodsService? = null
@@ -114,7 +113,6 @@ class QuickSettingsDialogActivity : ComponentActivity() {
isNoiseControlExpanded = isNoiseControlExpandedState,
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
)
} else {
}
}
}
@@ -159,7 +157,6 @@ class QuickSettingsDialogActivity : ComponentActivity() {
isNoiseControlExpanded = isNoiseControlExpandedState,
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
)
} else {
}
}
}
@@ -182,7 +179,6 @@ fun DraggableDismissBox(
content: @Composable () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val density = LocalDensity.current
var dragOffset by remember { mutableFloatStateOf(0f) }
var isDragging by remember { mutableStateOf(false) }
@@ -218,7 +214,6 @@ fun DraggableDismissBox(
LaunchedEffect(dragOffset, isDragging) {
if (isDragging) {
val dragDirection = if (dragOffset > 0) 1f else -1f
val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f)
animatedOffset.snapTo(dragOffset)
@@ -285,6 +280,7 @@ fun DraggableDismissBox(
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
@Composable
fun NewControlCenterDialogContent(
service: AirPodsService?,
@@ -353,7 +349,7 @@ fun NewControlCenterDialogContent(
}
service?.let {
val initialModeOrdinal = it.getANC().minus(1) ?: NoiseControlMode.TRANSPARENCY.ordinal
val initialModeOrdinal = it.getANC().minus(1)
var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY }
if (!availableModes.contains(initialMode)) {
initialMode = NoiseControlMode.TRANSPARENCY
@@ -482,7 +478,10 @@ fun NewControlCenterDialogContent(
availableModes = availableModes,
selectedMode = currentAncMode,
onModeSelected = { newMode ->
service.setANCMode(newMode.ordinal + 1)
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
value = newMode.ordinal + 1
)
currentAncMode = newMode
},
modifier = Modifier.fillMaxWidth(0.8f)
@@ -560,7 +559,10 @@ fun NewControlCenterDialogContent(
.clickable(
onClick = {
val newState = !isConvAwarenessEnabled
service.setCAEnabled(newState)
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
value = newState
)
isConvAwarenessEnabled = newState
},
indication = null,

View File

@@ -16,10 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
@@ -38,7 +38,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -46,14 +45,16 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
fun AccessibilitySettings() {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val service = ServiceManager.getService()!!
Text(
text = stringResource(R.string.accessibility).uppercase(),
style = TextStyle(
@@ -87,51 +88,75 @@ fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPref
)
)
ToneVolumeSlider(service = service, sharedPreferences = sharedPreferences)
ToneVolumeSlider()
}
val pressSpeedOptions = listOf("Default", "Slower", "Slowest")
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[0]) }
val pressSpeedOptions = mapOf(
0.toByte() to "Default",
1.toByte() to "Slower",
2.toByte() to "Slowest"
)
val selectedPressSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]) }
DropdownMenuComponent(
label = "Press Speed",
options = pressSpeedOptions,
selectedOption = selectedPressSpeed,
onOptionSelected = {
selectedPressSpeed = it
service.setPressSpeed(pressSpeedOptions.indexOf(it))
options = pressSpeedOptions.values.toList(),
selectedOption = selectedPressSpeed.toString(),
onOptionSelected = { newValue ->
selectedPressSpeed = newValue
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
)
},
textColor = textColor
)
val pressAndHoldDurationOptions = listOf("Default", "Slower", "Slowest")
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[0]) }
val pressAndHoldDurationOptions = mapOf(
0.toByte() to "Default",
1.toByte() to "Slower",
2.toByte() to "Slowest"
)
val selectedPressAndHoldDurationValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] ?: pressAndHoldDurationOptions[0]) }
DropdownMenuComponent(
label = "Press and Hold Duration",
options = pressAndHoldDurationOptions,
selectedOption = selectedPressAndHoldDuration,
onOptionSelected = {
selectedPressAndHoldDuration = it
service.setPressAndHoldDuration(pressAndHoldDurationOptions.indexOf(it))
options = pressAndHoldDurationOptions.values.toList(),
selectedOption = selectedPressAndHoldDuration.toString(),
onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
)
},
textColor = textColor
)
val volumeSwipeSpeedOptions = listOf("Default", "Longer", "Longest")
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[0]) }
val volumeSwipeSpeedOptions = mapOf<Byte, String>(
1.toByte() to "Default",
2.toByte() to "Longer",
3.toByte() to "Longest"
)
val selectedVolumeSwipeSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] ?: volumeSwipeSpeedOptions[1]) }
DropdownMenuComponent(
label = "Volume Swipe Speed",
options = volumeSwipeSpeedOptions,
selectedOption = selectedVolumeSwipeSpeed,
onOptionSelected = {
selectedVolumeSwipeSpeed = it
service.setVolumeSwipeSpeed(volumeSwipeSpeedOptions.indexOf(it))
options = volumeSwipeSpeedOptions.values.toList(),
selectedOption = selectedVolumeSwipeSpeed.toString(),
onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 1.toByte()
)
},
textColor = textColor
)
SinglePodANCSwitch(service = service, sharedPreferences = sharedPreferences)
VolumeControlSwitch(service = service, sharedPreferences = sharedPreferences)
// TransparencySettings(service = service, sharedPreferences = sharedPreferences)
SinglePodANCSwitch()
VolumeControlSwitch()
}
}
@@ -192,5 +217,5 @@ fun DropdownMenuComponent(
@Preview
@Composable
fun AccessibilitySettingsPreview() {
AccessibilitySettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
AccessibilitySettings()
}

View File

@@ -1,25 +1,25 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
*
* Copyright (C) 2025 LibrePods contributors
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -44,26 +44,26 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
fun AdaptiveStrengthSlider() {
val sliderValue = remember { mutableFloatStateOf(0f) }
val service = ServiceManager.getService()!!
LaunchedEffect(sliderValue) {
if (sharedPreferences.contains("adaptive_strength")) {
sliderValue.floatValue = sharedPreferences.getInt("adaptive_strength", 0).toFloat()
}
}
LaunchedEffect(sliderValue.floatValue) {
sharedPreferences.edit().putInt("adaptive_strength", sliderValue.floatValue.toInt()).apply()
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
}
val isDarkTheme = isSystemInDarkTheme()
@@ -86,7 +86,10 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
valueRange = 0f..100f,
onValueChangeFinished = {
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
service.setAdaptiveStrength(100 - sliderValue.floatValue.toInt())
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
value = (100 - sliderValue.floatValue).toInt()
)
},
modifier = Modifier
.fillMaxWidth()
@@ -151,5 +154,5 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
@Preview
@Composable
fun AdaptiveStrengthSliderPreview() {
AdaptiveStrengthSlider(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
}
AdaptiveStrengthSlider()
}

View File

@@ -1,25 +1,25 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
*
* Copyright (C) 2025 LibrePods contributors
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
@@ -30,7 +30,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -38,10 +37,10 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.AirPodsService
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
fun AudioSettings() {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -64,9 +63,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
.padding(top = 2.dp)
) {
PersonalizedVolumeSwitch(service = service, sharedPreferences = sharedPreferences)
ConversationalAwarenessSwitch(service = service, sharedPreferences = sharedPreferences)
LoudSoundReductionSwitch(service = service, sharedPreferences = sharedPreferences)
ConversationalAwarenessSwitch()
Column(
modifier = Modifier
@@ -95,7 +92,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
)
)
AdaptiveStrengthSlider(service = service, sharedPreferences = sharedPreferences)
AdaptiveStrengthSlider()
}
}
}
@@ -103,5 +100,5 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
@Preview
@Composable
fun AudioSettingsPreview() {
AudioSettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
AudioSettings()
}

View File

@@ -1,21 +1,23 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
*
* Copyright (C) 2025 LibrePods contributors
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.BroadcastReceiver
@@ -50,6 +52,7 @@ 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
fun BatteryView(service: AirPodsService, preview: Boolean = false) {

View File

@@ -39,7 +39,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -88,7 +88,7 @@ fun ControlCenterNoiseControlSegmentedButton(
) {
val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0)
val density = LocalDensity.current
var iconRowWidthPx by remember { mutableStateOf(0f) }
var iconRowWidthPx by remember { mutableFloatStateOf(0f) }
val itemCount = availableModes.size
val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) {

View File

@@ -16,9 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -41,24 +42,31 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
fun ConversationalAwarenessSwitch() {
val service = ServiceManager.getService()!!
val conversationEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
var conversationalAwarenessEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("conversational_awareness", true)
conversationEnabledValue == 1.toByte()
)
}
fun updateConversationalAwareness(enabled: Boolean) {
conversationalAwarenessEnabled = enabled
sharedPreferences.edit().putBoolean("conversational_awareness", enabled).apply()
service.setCAEnabled(enabled)
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
enabled
)
}
val isDarkTheme = isSystemInDarkTheme()
@@ -121,5 +129,5 @@ fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: Sh
@Preview
@Composable
fun ConversationalAwarenessSwitchPreview() {
ConversationalAwarenessSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
ConversationalAwarenessSwitch()
}

View File

@@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
@@ -46,17 +48,40 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false) {
fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false, controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers? = null) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val snakeCasedName = name.replace(Regex("[\\W\\s]+"), "_").lowercase()
val snakeCasedName =
controlCommandIdentifier?.name ?: name.replace(Regex("[\\W\\s]+"), "_").lowercase()
var checked by remember { mutableStateOf(default) }
if (controlCommandIdentifier != null) {
checked = service!!.aacpManager.controlCommandStatusList.find {
it.identifier == controlCommandIdentifier
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
}
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
fun cb() {
if (controlCommandIdentifier == null) {
sharedPreferences.edit().putBoolean(snakeCasedName, checked).apply()
}
if (functionName != null && service != null) {
val method =
service::class.java.getMethod(functionName, Boolean::class.java)
method.invoke(service, checked)
}
if (controlCommandIdentifier != null) {
service?.aacpManager?.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
}
}
LaunchedEffect(sharedPreferences) {
checked = sharedPreferences.getBoolean(snakeCasedName, true)
}
@@ -73,14 +98,7 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
},
onTap = {
checked = !checked
sharedPreferences
.edit()
.putBoolean(snakeCasedName, checked)
.apply()
if (functionName != null && service != null) {
val method = service::class.java.getMethod(functionName, Boolean::class.java)
method.invoke(service, checked)
}
cb()
}
)
},
@@ -98,12 +116,7 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
checked = checked,
onCheckedChange = {
checked = it
sharedPreferences.edit().putBoolean(snakeCasedName, it).apply()
if (functionName != null && service != null) {
val method =
service::class.java.getMethod(functionName, Boolean::class.java)
method.invoke(service, it)
}
cb()
},
)
}

View File

@@ -1,126 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.services.AirPodsService
@Composable
fun LoudSoundReductionSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
var loudSoundReductionEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("loud_sound_reduction", true)
)
}
fun updateLoudSoundReduction(enabled: Boolean) {
loudSoundReductionEnabled = enabled
sharedPreferences.edit().putBoolean("loud_sound_reduction", enabled).apply()
service.setLoudSoundReduction(enabled)
}
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(14.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(horizontal = 12.dp, vertical = 12.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
updateLoudSoundReduction(!loudSoundReductionEnabled)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = "Loud Sound Reduction",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Reduces loud sounds you are exposed to.",
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
StyledSwitch(
checked = loudSoundReductionEnabled,
onCheckedChange = {
updateLoudSoundReduction(it)
},
)
}
}
@Preview
@Composable
fun LoudSoundReductionSwitchPreview() {
LoudSoundReductionSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0))
}

View File

@@ -1,21 +1,23 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
*
* Copyright (C) 2025 LibrePods contributors
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.annotation.SuppressLint
@@ -23,7 +25,6 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.os.Build
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
@@ -50,7 +51,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
@@ -74,35 +74,34 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R
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
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
@Composable
fun NoiseControlSettings(
service: AirPodsService,
onModeSelectedCallback: () -> Unit = {} // Callback parameter remains, but won't finish activity
) {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val offListeningMode = remember { mutableStateOf(sharedPreferences.getBoolean("off_listening_mode", true)) }
val preferenceChangeListener = remember {
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "off_listening_mode") {
offListeningMode.value = sharedPreferences.getBoolean("off_listening_mode", true)
}
}
}
DisposableEffect(Unit) {
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
onDispose {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
val offListeningModeListener = object: AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
offListeningMode.value = controlCommand.value[0] == 1.toByte()
}
}
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
offListeningModeListener
)
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -116,27 +115,21 @@ fun NoiseControlSettings(
val d3a = remember { mutableFloatStateOf(0f) }
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
val previousMode = noiseControlMode.value // Store previous mode
val previousMode = noiseControlMode.value
// Ensure the mode is valid if 'Off' is disabled
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
// If trying to select OFF but it's disabled, default to Transparency or Adaptive
NoiseControlMode.TRANSPARENCY // Or ADAPTIVE, based on preference
NoiseControlMode.TRANSPARENCY
} else {
mode
}
noiseControlMode.value = targetMode // Update internal state immediately
noiseControlMode.value = targetMode
// Only call service if the mode was manually selected (!received)
// and the target mode is actually different from the previous mode
if (!received && targetMode != previousMode) {
service.setANCMode(targetMode.ordinal + 1)
// onModeSelectedCallback() // REMOVE this call to keep dialog open
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
}
// Update divider alphas based on the *new* mode
when (noiseControlMode.value) { // Use the updated noiseControlMode.value
when (noiseControlMode.value) {
NoiseControlMode.NOISE_CANCELLATION -> {
d1a.floatValue = 1f
d2a.floatValue = 1f
@@ -447,5 +440,5 @@ fun NoiseControlSettings(
@Preview()
@Composable
fun NoiseControlSettingsPreview() {
NoiseControlSettings(AirPodsService()) {}
}
NoiseControlSettings(AirPodsService())
}

View File

@@ -1,126 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.services.AirPodsService
@Composable
fun PersonalizedVolumeSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
var personalizedVolumeEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("personalized_volume", true)
)
}
fun updatePersonalizedVolume(enabled: Boolean) {
personalizedVolumeEnabled = enabled
sharedPreferences.edit().putBoolean("personalized_volume", enabled).apply()
service.setPVEnabled(enabled)
}
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(14.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(horizontal = 12.dp, vertical = 12.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
updatePersonalizedVolume(!personalizedVolumeEnabled)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = "Personalized Volume",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Adjusts the volume of media in response to your environment.",
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
StyledSwitch(
checked = personalizedVolumeEnabled,
onCheckedChange = {
updatePersonalizedVolume(it)
},
)
}
}
@Preview
@Composable
fun PersonalizedVolumeSwitchPreview() {
PersonalizedVolumeSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
}

View File

@@ -1,24 +1,25 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
*
* Copyright (C) 2025 LibrePods contributors
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -41,24 +42,31 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
fun SinglePodANCSwitch() {
val service = ServiceManager.getService()!!
val singleANCEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
var singleANCEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("single_anc", true)
singleANCEnabledValue == 1.toByte()
)
}
fun updateSingleEnabled(enabled: Boolean) {
singleANCEnabled = enabled
sharedPreferences.edit().putBoolean("single_anc", enabled).apply()
service.setNoiseCancellationWithOnePod(enabled)
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE.value,
enabled
)
}
val isDarkTheme = isSystemInDarkTheme()
@@ -121,5 +129,5 @@ fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPrefere
@Preview
@Composable
fun SinglePodANCSwitchPreview() {
SinglePodANCSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
}
SinglePodANCSwitch()
}

View File

@@ -16,9 +16,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -35,14 +37,12 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -51,21 +51,22 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
val sliderValue = remember { mutableFloatStateOf(0f) }
LaunchedEffect(sliderValue) {
if (sharedPreferences.contains("tone_volume")) {
sliderValue.floatValue = sharedPreferences.getInt("tone_volume", 0).toFloat()
}
}
LaunchedEffect(sliderValue.floatValue) {
sharedPreferences.edit().putInt("tone_volume", sliderValue.floatValue.toInt()).apply()
}
fun ToneVolumeSlider() {
val service = ServiceManager.getService()!!
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
val sliderValue = remember { mutableFloatStateOf(
sliderValueFromAACP?.toFloat() ?: -1f
) }
Log.d("ToneVolumeSlider", "Slider value: ${sliderValue.floatValue}")
val isDarkTheme = isSystemInDarkTheme()
@@ -74,7 +75,6 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
Row(
modifier = Modifier
.fillMaxWidth(),
@@ -99,7 +99,12 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
valueRange = 0f..100f,
onValueChangeFinished = {
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
service.setToneVolume(volume = sliderValue.floatValue.toInt())
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
value = byteArrayOf(sliderValue.floatValue.toInt().toByte(),
0x50.toByte()
)
)
},
modifier = Modifier
.weight(1f)
@@ -156,5 +161,5 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc
@Preview
@Composable
fun ToneVolumeSliderPreview() {
ToneVolumeSlider(AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
ToneVolumeSlider()
}

View File

@@ -1,270 +0,0 @@
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.AirPodsService
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransparencySettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
var transparencyModeCustomizationEnabled by remember { mutableStateOf(sharedPreferences.getBoolean("transparency_mode_customization", false)) }
var amplification by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_amplification", 0)) }
var balance by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_balance", 0)) }
var tone by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_tone", 0)) }
var ambientNoise by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_ambient_noise", 0)) }
var conversationBoostEnabled by remember { mutableStateOf(sharedPreferences.getBoolean("transparency_conversation_boost", false)) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
transparencyModeCustomizationEnabled = !transparencyModeCustomizationEnabled
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = "Transparency Mode",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "You can customize Transparency mode for your AirPods Pro.",
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
StyledSwitch(
checked = transparencyModeCustomizationEnabled,
onCheckedChange = {
transparencyModeCustomizationEnabled = it
},
)
}
if (transparencyModeCustomizationEnabled) {
Spacer(modifier = Modifier.height(8.dp))
SliderRow(
label = "Amplification",
value = amplification,
onValueChange = {
amplification = it
sharedPreferences.edit().putInt("transparency_amplification", it).apply()
},
isDarkTheme = isDarkTheme
)
Spacer(modifier = Modifier.height(8.dp))
SliderRow(
label = "Balance",
value = balance,
onValueChange = {
balance = it
sharedPreferences.edit().putInt("transparency_balance", it).apply()
},
isDarkTheme = isDarkTheme
)
Spacer(modifier = Modifier.height(8.dp))
SliderRow(
label = "Tone",
value = tone,
onValueChange = {
tone = it
sharedPreferences.edit().putInt("transparency_tone", it).apply()
},
isDarkTheme = isDarkTheme
)
Spacer(modifier = Modifier.height(8.dp))
SliderRow(
label = "Ambient Noise",
value = ambientNoise,
onValueChange = {
ambientNoise = it
sharedPreferences.edit().putInt("transparency_ambient_noise", it).apply()
},
isDarkTheme = isDarkTheme
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
conversationBoostEnabled = !conversationBoostEnabled
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = "Conversation Boost",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Conversation Boost focuses your AirPods on the person in front of you, making it easier to hear in a face-to-face conversation.",
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
StyledSwitch(
checked = conversationBoostEnabled,
onCheckedChange = {
conversationBoostEnabled = it
},
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SliderRow(
label: String,
value: Int,
onValueChange: (Int) -> Unit,
isDarkTheme: Boolean
) {
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = Color(0xFFFFFFFF)
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = "\uDBC0\uDEA1",
style = TextStyle(
fontSize = 16.sp,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(start = 4.dp)
)
Slider(
value = value.toFloat(),
onValueChange = {
onValueChange(it.toInt())
},
valueRange = 0f..100f,
onValueChangeFinished = {
onValueChange(value)
},
modifier = Modifier
.weight(1f)
.height(36.dp),
colors = SliderDefaults.colors(
thumbColor = thumbColor,
activeTrackColor = activeTrackColor,
inactiveTrackColor = trackColor
),
thumb = {
Box(
modifier = Modifier
.size(24.dp)
.shadow(4.dp, CircleShape)
.background(thumbColor, CircleShape)
)
},
track = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(12.dp),
contentAlignment = Alignment.CenterStart
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.background(trackColor, RoundedCornerShape(4.dp))
)
Box(
modifier = Modifier
.fillMaxWidth(value.toFloat() / 100)
.height(4.dp)
.background(activeTrackColor, RoundedCornerShape(4.dp))
)
}
}
)
Text(
text = "\uDBC0\uDEA9",
style = TextStyle(
fontSize = 16.sp,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(end = 4.dp)
)
}
}

View File

@@ -1,24 +1,25 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
*
* Copyright (C) 2025 LibrePods contributors
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -41,23 +42,30 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) {
fun VolumeControlSwitch() {
val service = ServiceManager.getService()!!
val volumeControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
var volumeControlEnabled by remember {
mutableStateOf(
sharedPreferences.getBoolean("volume_control", true)
volumeControlEnabledValue == 1.toByte()
)
}
fun updateVolumeControlEnabled(enabled: Boolean) {
volumeControlEnabled = enabled
sharedPreferences.edit().putBoolean("volume_control", enabled).apply()
service.setVolumeControl(enabled)
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE.value,
enabled
)
}
val isDarkTheme = isSystemInDarkTheme()
@@ -120,5 +128,5 @@ fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPrefer
@Preview
@Composable
fun VolumeControlSwitchPreview() {
VolumeControlSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0))
}
VolumeControlSwitch()
}

View File

@@ -16,11 +16,14 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import kotlin.io.encoding.ExperimentalEncodingApi
import me.kavishdevar.librepods.services.AirPodsService
class BootReceiver: BroadcastReceiver() {

View File

@@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
@@ -36,7 +38,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -84,7 +85,6 @@ import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
@@ -101,7 +101,9 @@ import me.kavishdevar.librepods.composables.NoiseControlSettings
import me.kavishdevar.librepods.composables.PressAndHoldSettings
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)
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
@@ -355,7 +357,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
PressAndHoldSettings(navController = navController)
Spacer(modifier = Modifier.height(16.dp))
AudioSettings(service = service, sharedPreferences = sharedPreferences)
AudioSettings()
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
@@ -363,20 +365,20 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
service = service,
functionName = "setEarDetection",
sharedPreferences = sharedPreferences,
true
default = true
)
Spacer(modifier = Modifier.height(16.dp))
IndependentToggle(
name = "Off Listening Mode",
service = service,
functionName = "setOffListeningMode",
sharedPreferences = sharedPreferences,
false
default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
)
Spacer(modifier = Modifier.height(16.dp))
AccessibilitySettings(service = service, sharedPreferences = sharedPreferences)
AccessibilitySettings()
Spacer(modifier = Modifier.height(16.dp))
NavigationButton("debug", "Debug", navController)

View File

@@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
@@ -49,11 +50,15 @@ import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -76,6 +81,8 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -88,10 +95,13 @@ import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledSwitch
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
@Composable
fun AppSettingsScreen(navController: NavController) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
@@ -103,6 +113,35 @@ fun AppSettingsScreen(navController: NavController) {
val hazeState = remember { HazeState() }
var showResetDialog by remember { mutableStateOf(false) }
var showIrkDialog by remember { mutableStateOf(false) }
var showEncKeyDialog by remember { mutableStateOf(false) }
var irkValue by remember { mutableStateOf("") }
var encKeyValue by remember { mutableStateOf("") }
var irkError by remember { mutableStateOf<String?>(null) }
var encKeyError by remember { mutableStateOf<String?>(null) }
LaunchedEffect(Unit) {
val savedIrk = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
val savedEncKey = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
if (savedIrk != null) {
try {
val decoded = Base64.decode(savedIrk)
irkValue = decoded.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
irkValue = ""
}
}
if (savedEncKey != null) {
try {
val decoded = Base64.decode(savedEncKey)
encKeyValue = decoded.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
encKeyValue = ""
}
}
}
var showPhoneBatteryInWidget by remember {
mutableStateOf(sharedPreferences.getBoolean("show_phone_battery_in_widget", true))
@@ -119,7 +158,34 @@ fun AppSettingsScreen(navController: NavController) {
var disconnectWhenNotWearing by remember {
mutableStateOf(sharedPreferences.getBoolean("disconnect_when_not_wearing", false))
}
var takeoverWhenDisconnected by remember {
mutableStateOf(sharedPreferences.getBoolean("takeover_when_disconnected", true))
}
var takeoverWhenIdle by remember {
mutableStateOf(sharedPreferences.getBoolean("takeover_when_idle", true))
}
var takeoverWhenMusic by remember {
mutableStateOf(sharedPreferences.getBoolean("takeover_when_music", false))
}
var takeoverWhenCall by remember {
mutableStateOf(sharedPreferences.getBoolean("takeover_when_call", true))
}
var takeoverWhenRingingCall by remember {
mutableStateOf(sharedPreferences.getBoolean("takeover_when_ringing_call", true))
}
var takeoverWhenMediaStart by remember {
mutableStateOf(sharedPreferences.getBoolean("takeover_when_media_start", true))
}
var mDensity by remember { mutableFloatStateOf(0f) }
fun validateHexInput(input: String): Boolean {
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
return hexPattern.matches(input)
}
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -607,6 +673,293 @@ fun AppSettingsScreen(navController: NavController) {
}
}
Text(
text = stringResource(R.string.takeover_header).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)
) {
Text(
text = stringResource(R.string.takeover_airpods_state),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
takeoverWhenDisconnected = !takeoverWhenDisconnected
sharedPreferences.edit().putBoolean("takeover_when_disconnected", takeoverWhenDisconnected).apply()
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = stringResource(R.string.takeover_disconnected),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.takeover_disconnected_desc),
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = takeoverWhenDisconnected,
onCheckedChange = {
takeoverWhenDisconnected = it
sharedPreferences.edit().putBoolean("takeover_when_disconnected", it).apply()
}
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
takeoverWhenIdle = !takeoverWhenIdle
sharedPreferences.edit().putBoolean("takeover_when_idle", takeoverWhenIdle).apply()
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = stringResource(R.string.takeover_idle),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.takeover_idle_desc),
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = takeoverWhenIdle,
onCheckedChange = {
takeoverWhenIdle = it
sharedPreferences.edit().putBoolean("takeover_when_idle", it).apply()
}
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
takeoverWhenMusic = !takeoverWhenMusic
sharedPreferences.edit().putBoolean("takeover_when_music", takeoverWhenMusic).apply()
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = stringResource(R.string.takeover_music),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.takeover_music_desc),
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = takeoverWhenMusic,
onCheckedChange = {
takeoverWhenMusic = it
sharedPreferences.edit().putBoolean("takeover_when_music", it).apply()
}
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
takeoverWhenCall = !takeoverWhenCall
sharedPreferences.edit().putBoolean("takeover_when_call", takeoverWhenCall).apply()
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = stringResource(R.string.takeover_call),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.takeover_call_desc),
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = takeoverWhenCall,
onCheckedChange = {
takeoverWhenCall = it
sharedPreferences.edit().putBoolean("takeover_when_call", it).apply()
}
)
}
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.takeover_phone_state),
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
takeoverWhenRingingCall = !takeoverWhenRingingCall
sharedPreferences.edit().putBoolean("takeover_when_ringing_call", takeoverWhenRingingCall).apply()
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = stringResource(R.string.takeover_ringing_call),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.takeover_ringing_call_desc),
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = takeoverWhenRingingCall,
onCheckedChange = {
takeoverWhenRingingCall = it
sharedPreferences.edit().putBoolean("takeover_when_ringing_call", it).apply()
}
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
takeoverWhenMediaStart = !takeoverWhenMediaStart
sharedPreferences.edit().putBoolean("takeover_when_media_start", takeoverWhenMediaStart).apply()
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = stringResource(R.string.takeover_media_start),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.takeover_media_start_desc),
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
StyledSwitch(
checked = takeoverWhenMediaStart,
onCheckedChange = {
takeoverWhenMediaStart = it
sharedPreferences.edit().putBoolean("takeover_when_media_start", it).apply()
}
)
}
}
Text(
text = "Advanced Options".uppercase(),
style = TextStyle(
@@ -629,6 +982,64 @@ fun AppSettingsScreen(navController: NavController) {
)
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
showIrkDialog = true
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = "Set Identity Resolving Key (IRK)",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Manually set the IRK value used for resolving BLE random addresses",
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
showEncKeyDialog = true
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp)
.padding(end = 4.dp)
) {
Text(
text = "Set Encryption Key",
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Manually set the ENC_KEY value used for decrypting BLE advertisements",
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
@@ -758,6 +1169,184 @@ fun AppSettingsScreen(navController: NavController) {
}
)
}
if (showIrkDialog) {
AlertDialog(
onDismissRequest = { showIrkDialog = false },
title = {
Text(
"Set Identity Resolving Key (IRK)",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
},
text = {
Column {
Text(
"Enter 16-byte IRK as hex string (32 characters):",
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.padding(bottom = 8.dp)
)
OutlinedTextField(
value = irkValue,
onValueChange = {
irkValue = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' }
irkError = null
},
modifier = Modifier.fillMaxWidth(),
isError = irkError != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
capitalization = KeyboardCapitalization.None
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
),
supportingText = {
if (irkError != null) {
Text(irkError!!, color = MaterialTheme.colorScheme.error)
}
},
label = { Text("IRK Hex Value") }
)
}
},
confirmButton = {
TextButton(
onClick = {
if (!validateHexInput(irkValue)) {
irkError = "Must be exactly 32 hex characters"
return@TextButton
}
try {
val hexBytes = ByteArray(16)
for (i in 0 until 16) {
val hexByte = irkValue.substring(i * 2, i * 2 + 2)
hexBytes[i] = hexByte.toInt(16).toByte()
}
val base64Value = Base64.encode(hexBytes)
sharedPreferences.edit().putString(AACPManager.Companion.ProximityKeyType.IRK.name, base64Value).apply()
Toast.makeText(context, "IRK has been set successfully", Toast.LENGTH_SHORT).show()
showIrkDialog = false
} catch (e: Exception) {
irkError = "Error converting hex: ${e.message}"
}
}
) {
Text(
"Save",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}
},
dismissButton = {
TextButton(
onClick = { showIrkDialog = false }
) {
Text(
"Cancel",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}
}
)
}
if (showEncKeyDialog) {
AlertDialog(
onDismissRequest = { showEncKeyDialog = false },
title = {
Text(
"Set Encryption Key",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
},
text = {
Column {
Text(
"Enter 16-byte ENC_KEY as hex string (32 characters):",
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.padding(bottom = 8.dp)
)
OutlinedTextField(
value = encKeyValue,
onValueChange = {
encKeyValue = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' }
encKeyError = null
},
modifier = Modifier.fillMaxWidth(),
isError = encKeyError != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
capitalization = KeyboardCapitalization.None
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
),
supportingText = {
if (encKeyError != null) {
Text(encKeyError!!, color = MaterialTheme.colorScheme.error)
}
},
label = { Text("ENC_KEY Hex Value") }
)
}
},
confirmButton = {
TextButton(
onClick = {
if (!validateHexInput(encKeyValue)) {
encKeyError = "Must be exactly 32 hex characters"
return@TextButton
}
try {
val hexBytes = ByteArray(16)
for (i in 0 until 16) {
val hexByte = encKeyValue.substring(i * 2, i * 2 + 2)
hexBytes[i] = hexByte.toInt(16).toByte()
}
val base64Value = Base64.encode(hexBytes)
sharedPreferences.edit().putString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, base64Value).apply()
Toast.makeText(context, "Encryption key has been set successfully", Toast.LENGTH_SHORT).show()
showEncKeyDialog = false
} catch (e: Exception) {
encKeyError = "Error converting hex: ${e.message}"
}
}
) {
Text(
"Save",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}
},
dismissButton = {
TextButton(
onClick = { showEncKeyDialog = false }
) {
Text(
"Cancel",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
}
}
)
}
}
}
}

View File

@@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalHazeMaterialsApi::class)
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens
@@ -103,6 +103,7 @@ import me.kavishdevar.librepods.R
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(
val type: String,
@@ -616,7 +617,12 @@ fun DebugScreen(navController: NavController) {
IconButton(
onClick = {
if (packet.value.text.isNotBlank()) {
airPodsService?.value?.sendPacket(packet.value.text)
airPodsService?.value?.aacpManager?.sendPacket(
packet.value.text
.split(" ")
.map { it.toInt(16).toByte() }
.toByteArray()
)
packet.value = TextFieldValue("")
focusManager.clearFocus()

View File

@@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens
import android.content.Context
@@ -115,6 +117,7 @@ import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.IndependentToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin

View File

@@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens
import android.content.Context
@@ -47,7 +49,6 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -69,6 +70,9 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable()
fun RightDivider() {
@@ -83,15 +87,23 @@ fun RightDivider() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongPress(navController: NavController, name: String) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val offChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_off", false)) }
val ncChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_nc", false)) }
val transparencyChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_transparency", false)) }
val adaptiveChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_adaptive", false)) }
Log.d("LongPress", "offChecked: ${offChecked.value}, ncChecked: ${ncChecked.value}, transparencyChecked: ${transparencyChecked.value}, adaptiveChecked: ${adaptiveChecked.value}")
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
if (modesByte != null) {
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x02) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x04) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
}
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val deviceName = sharedPreferences.getString("name", "AirPods Pro")
Scaffold(
topBar = {
CenterAlignedTopAppBar(
@@ -115,7 +127,7 @@ fun LongPress(navController: NavController, name: String) {
modifier = Modifier.scale(1.5f)
)
Text(
sharedPreferences.getString("name", "AirPods")!!,
deviceName?: "AirPods Pro",
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
@@ -159,14 +171,29 @@ fun LongPress(navController: NavController, name: String) {
.background(backgroundColor, RoundedCornerShape(14.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false)
LongPressElement("Off", offChecked, "long_press_off", offListeningMode, R.drawable.noise_cancellation, isFirst = true)
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("Transparency", transparencyChecked, "long_press_transparency", resourceId = R.drawable.transparency, isFirst = !offListeningMode)
LongPressElement(
name = "Transparency",
resourceId = R.drawable.transparency,
isFirst = !offListeningMode)
RightDivider()
LongPressElement("Adaptive", adaptiveChecked, "long_press_adaptive", resourceId = R.drawable.adaptive)
LongPressElement(
name = "Adaptive",
resourceId = R.drawable.adaptive)
RightDivider()
LongPressElement("Noise Cancellation", ncChecked, "long_press_nc", resourceId = R.drawable.noise_cancellation, isLast = true)
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.",
@@ -178,13 +205,33 @@ fun LongPress(navController: NavController, name: String) {
)
}
}
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
}
@Composable
fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) {
val sharedPreferences =
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false)
fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) {
val bit = when (name) {
"Off" -> 0x01
"Transparency" -> 0x02
"Noise Cancellation" -> 0x04
"Adaptive" -> 0x08
else -> -1
}
val context = LocalContext.current
val currentByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
val savedByte = context.getSharedPreferences("settings", Context.MODE_PRIVATE).getInt("long_press_byte", 0b0101.toInt())
val byteValue = currentByteValue ?: (savedByte and 0xFF).toByte()
val isChecked = (byteValue.toInt() and bit) != 0
val checked = remember { mutableStateOf(isChecked) }
Log.d("PressAndHoldSettingsScreen", "LongPressElement: $name, checked: ${checked.value}, byteValue: ${byteValue.toInt()}, in bits: ${byteValue.toInt().toString(2)}")
val darkMode = isSystemInDarkTheme()
val textColor = if (darkMode) Color.White else Color.Black
val desc = when (name) {
@@ -194,30 +241,72 @@ fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, e
"Adaptive" -> "Dynamically adjust external noise"
else -> ""
}
fun valueChanged(value: Boolean = !checked.value) {
val originalLongPressArray = booleanArrayOf(
sharedPreferences.getBoolean("long_press_off", false),
sharedPreferences.getBoolean("long_press_nc", false),
sharedPreferences.getBoolean("long_press_transparency", false),
sharedPreferences.getBoolean("long_press_adaptive", false)
)
if (!value && originalLongPressArray.count { it } <= 2) {
return
}
checked.value = value
with(sharedPreferences.edit()) {
putBoolean(id, checked.value)
apply()
}
val newLongPressArray = booleanArrayOf(
sharedPreferences.getBoolean("long_press_off", false),
sharedPreferences.getBoolean("long_press_nc", false),
sharedPreferences.getBoolean("long_press_transparency", false),
sharedPreferences.getBoolean("long_press_adaptive", false)
)
ServiceManager.getService()
?.updateLongPress(originalLongPressArray, newLongPressArray, offListeningMode)
fun countEnabledModes(byteValue: Int): Int {
var count = 0
if ((byteValue and 0x01) != 0) count++
if ((byteValue and 0x02) != 0) count++
if ((byteValue and 0x04) != 0) count++
if ((byteValue and 0x08) != 0) count++
Log.d("PressAndHoldSettingsScreen", "Byte: ${byteValue.toString(2)} Enabled modes: $count")
return count
}
fun valueChanged(value: Boolean = !checked.value) {
val latestByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
val currentValue = (latestByteValue?.toInt() ?: byteValue.toInt()) and 0xFF
Log.d("PressAndHoldSettingsScreen", "Current value: $currentValue (binary: ${Integer.toBinaryString(currentValue)}), bit: $bit, value: $value")
if (!value) {
val newValue = currentValue and bit.inv()
Log.d("PressAndHoldSettingsScreen", "Bit to disable: $bit, inverted: ${bit.inv()}, after AND: ${Integer.toBinaryString(newValue)}")
val modeCount = countEnabledModes(newValue)
Log.d("PressAndHoldSettingsScreen", "After disabling, enabled modes count: $modeCount")
if (modeCount < 2) {
Log.d("PressAndHoldSettingsScreen", "Cannot disable $name mode - need at least 2 modes enabled")
return
}
val updatedByte = newValue.toByte()
Log.d("PressAndHoldSettingsScreen", "Sending updated byte: ${updatedByte.toInt() and 0xFF} (binary: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)})")
ServiceManager.getService()!!.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
updatedByte
)
context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit()
.putInt("long_press_byte", newValue).apply()
checked.value = false
Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: false, byte: ${updatedByte.toInt() and 0xFF}, bits: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)}")
} else {
val newValue = currentValue or bit
val updatedByte = newValue.toByte()
ServiceManager.getService()!!.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
updatedByte
)
context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit()
.putInt("long_press_byte", newValue).apply()
checked.value = true
Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: true, byte: ${updatedByte.toInt() and 0xFF}, bits: ${newValue.toString(2)}")
}
}
val shape = when {
isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp)
isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)
@@ -238,8 +327,8 @@ fun LongPressElement(name: String, checked: MutableState<Boolean>, id: String, e
backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
valueChanged()
},
onTap = { valueChanged() }
)
}
.padding(horizontal = 16.dp, vertical = 0.dp),

View File

@@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens
import android.content.Context
@@ -66,6 +68,7 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class)
@@ -198,4 +201,4 @@ fun RenameScreen(navController: NavController) {
@Composable
fun RenameScreenPreview() {
RenameScreen(navController = NavController(LocalContext.current))
}
}

View File

@@ -15,7 +15,9 @@
* 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.services
import android.annotation.SuppressLint
@@ -31,12 +33,12 @@ import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.compose.material3.ExperimentalMaterial3Api
import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.QuickSettingsDialogActivity
import me.kavishdevar.librepods.R
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)
class AirPodsQSService : TileService() {
@@ -171,10 +173,11 @@ class AirPodsQSService : TileService() {
)
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION")
val intent = Intent(this, QuickSettingsDialogActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
}
@Suppress("DEPRECATION")
@SuppressLint("StartActivityAndCollapseDeprecated")
startActivityAndCollapse(intent)
}
Log.d("AirPodsQSService", "Called startActivityAndCollapse for QuickSettingsDialogActivity")
@@ -191,14 +194,17 @@ class AirPodsQSService : TileService() {
}
val nextMode = getNextAncMode()
Log.d("AirPodsQSService", "Cycling ANC mode to: $nextMode")
service.setANCMode(nextMode)
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
nextMode
)
}
private fun updateTile() {
val tile = qsTile ?: return
Log.d("AirPodsQSService", "updateTile - Connected: $isAirPodsConnected, Mode: $currentAncMode")
val deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods"
val deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods"
if (isAirPodsConnected) {
tile.state = Tile.STATE_ACTIVE
@@ -262,42 +268,9 @@ class AirPodsQSService : TileService() {
else -> R.drawable.airpods
}
}
@ExperimentalMaterial3Api
override fun onTileAdded() {
super.onTileAdded()
Log.d("AirPodsQSService", "Tile added")
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
@ExperimentalMaterial3Api
fun openMainActivity() {
Log.d("AirPodsQSService", "Opening MainActivity")
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION")
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivityAndCollapse(intent)
}
Log.d("AirPodsQSService", "Called startActivityAndCollapse for MainActivity")
} catch (e: Exception) {
Log.e("AirPodsQSService", "Error launching MainActivity: $e")
}
}
}

View File

@@ -0,0 +1,478 @@
/*
* 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/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
import android.util.Log
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers.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.
*/
class AACPManager {
companion object {
private const val TAG = "AACPManager"
object Opcodes {
const val SET_FEATURE_FLAGS: Byte = 0x4d
const val REQUEST_NOTIFICATIONS: Byte = 0x0f
const val BATTERY_INFO: Byte = 0x04
const val CONTROL_COMMAND: Byte = 0x09
const val EAR_DETECTION: Byte = 0x06
const val CONVERSATION_AWARENESS: Byte = 0x4b
const val DEVICE_METADATA: Byte = 0x1d
const val RENAME: Byte = 0x1E
const val HEADTRACKING: Byte = 0x17
const val PROXIMITY_KEYS_REQ: Byte = 0x30
const val PROXIMITY_KEYS_RSP: Byte = 0x31
}
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
data class ControlCommandStatus(
val identifier: ControlCommandIdentifiers,
val value: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ControlCommandStatus
if (identifier != other.identifier) return false
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
var result: Int = identifier.hashCode()
result = 31 * result + value.contentHashCode()
return result
}
}
// @Suppress("unused")
enum class ControlCommandIdentifiers(val value: Byte) {
MIC_MODE(0x01),
BUTTON_SEND_MODE(0x05),
VOICE_TRIGGER(0x12),
SINGLE_CLICK_MODE(0x14),
DOUBLE_CLICK_MODE(0x15),
CLICK_HOLD_MODE(0x16),
DOUBLE_CLICK_INTERVAL(0x17),
CLICK_HOLD_INTERVAL(0x18),
LISTENING_MODE_CONFIGS(0x1A),
ONE_BUD_ANC_MODE(0x1B),
CROWN_ROTATION_DIRECTION(0x1C),
LISTENING_MODE(0x0D),
AUTO_ANSWER_MODE(0x1E),
CHIME_VOLUME(0x1F),
VOLUME_SWIPE_INTERVAL(0x23),
CALL_MANAGEMENT_CONFIG(0x24),
VOLUME_SWIPE_MODE(0x25),
ADAPTIVE_VOLUME_CONFIG(0x26),
SOFTWARE_MUTE_CONFIG(0x27),
CONVERSATION_DETECT_CONFIG(0x28),
SSL(0x29),
HEARING_AID(0x2C),
AUTO_ANC_STRENGTH(0x2E),
HPS_GAIN_SWIPE(0x2F),
HRM_STATE(0x30),
IN_CASE_TONE_CONFIG(0x31),
SIRI_MULTITONE_CONFIG(0x32),
HEARING_ASSIST_CONFIG(0x33),
ALLOW_OFF_OPTION(0x34);
companion object {
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
entries.find { it.value == byte }
}
}
enum class ProximityKeyType(val value: Byte) {
IRK(0x01),
ENC_KEY(0x04);
companion object {
fun fromByte(byte: Byte): ProximityKeyType =
ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
}
}
}
var controlCommandStatusList: MutableList<ControlCommandStatus> = mutableListOf<ControlCommandStatus>()
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf()
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
return controlCommandStatusList.find { it.identifier == identifier }
}
private fun setControlCommandStatusValue(identifier: ControlCommandIdentifiers, value: ByteArray) {
val existingStatus = getControlCommandStatus(identifier)
if (existingStatus == value) {
controlCommandStatusList.remove(existingStatus)
}
if (existingStatus != null) {
controlCommandStatusList.remove(existingStatus)
}
controlCommandListeners[identifier]?.forEach { listener ->
listener.onControlCommandReceived(ControlCommand(identifier.value, value))
}
controlCommandStatusList.add(ControlCommandStatus(identifier, value))
}
interface PacketCallback {
fun onBatteryInfoReceived(batteryInfo: ByteArray)
fun onEarDetectionReceived(earDetection: ByteArray)
fun onConversationAwarenessReceived(conversationAwareness: ByteArray)
fun onControlCommandReceived(controlCommand: ByteArray)
fun onDeviceMetadataReceived(deviceMetadata: ByteArray)
fun onHeadTrackingReceived(headTracking: ByteArray)
fun onUnknownPacketReceived(packet: ByteArray)
fun onProximityKeysReceived(proximityKeys: ByteArray)
}
interface ControlCommandListener {
fun onControlCommandReceived(controlCommand: ControlCommand)
}
fun registerControlCommandListener(identifier: ControlCommandIdentifiers, callback: ControlCommandListener) {
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
}
private var callback: PacketCallback? = null
fun setPacketCallback(callback: PacketCallback) {
this.callback = callback
}
fun createDataPacket(data: ByteArray): ByteArray {
return HEADER_BYTES + data
}
fun createControlCommandPacket(identifier: Byte, data: ByteArray): ByteArray {
val opcode = byteArrayOf(Opcodes.CONTROL_COMMAND, 0x00)
val payload = ByteArray(7)
System.arraycopy(opcode, 0, payload, 0, 2)
payload[2] = identifier
val dataLength = minOf(data.size, 4)
System.arraycopy(data, 0, payload, 3, dataLength)
return payload
}
fun sendDataPacket(data: ByteArray): Boolean {
return sendPacket(createDataPacket(data))
}
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
val controlPacket = createControlCommandPacket(identifier, value)
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
value
)
return sendDataPacket(controlPacket)
}
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
byteArrayOf(value)
)
return sendDataPacket(controlPacket)
}
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
val controlPacket = createControlCommandPacket(identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02))
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
)
return sendDataPacket(controlPacket)
}
fun sendControlCommand(identifier: Byte, value: Int): Boolean {
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value.toByte()))
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
byteArrayOf(value.toByte())
)
return sendDataPacket(controlPacket)
}
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
Log.d(TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}")
if (data.size < 4) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
}
if (data[4] != Opcodes.PROXIMITY_KEYS_RSP) {
throw IllegalArgumentException("Data array does not start with PROXIMITY_KEYS_RSP opcode")
}
val keyCount = data[6].toInt()
val keys = mutableMapOf<ProximityKeyType, ByteArray>()
var offset = 7
for (i in 0 until keyCount) {
Log.d(TAG, "Parsing Proximity Key $i")
if (offset + 3 >= data.size) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
}
val keyType = data[offset]
val keyLength = data[offset + 2].toInt()
Log.d(TAG, "Key Type: ${keyType.toString(16)}, Key Length: $keyLength")
offset += 4
if (offset + keyLength > data.size) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
}
val key = ByteArray(keyLength)
System.arraycopy(data, offset, key, 0, keyLength)
keys[ProximityKeyType.fromByte(keyType)] = key
offset += keyLength
Log.d(TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${key.joinToString(" ") { "%02X".format(it) }}")
}
return keys
}
fun sendRequestProximityKeys(type: Byte): Boolean {
Log.d(TAG, "Requesting proximity keys of type: ${type.toString(16)}")
return sendDataPacket(createRequestProximityKeysPacket(type))
}
fun createRequestProximityKeysPacket(type: Byte): ByteArray {
val opcode = byteArrayOf(Opcodes.PROXIMITY_KEYS_REQ, 0x00)
val data = byteArrayOf(type, 0x00)
return opcode + data
}
@OptIn(ExperimentalStdlibApi::class)
fun receivePacket(packet: ByteArray) {
if (!packet.toHexString().startsWith("04000400")) {
Log.w(TAG, "Received packet does not start with expected header: ${packet.joinToString(" ") { "%02X".format(it) }}")
return
}
if (packet.size < 6) {
Log.w(TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
return
}
val opcode = packet[4]
when (opcode) {
Opcodes.BATTERY_INFO -> {
callback?.onBatteryInfoReceived(packet)
}
Opcodes.CONTROL_COMMAND -> {
val controlCommand = ControlCommand.fromByteArray(packet)
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return,
controlCommand.value
)
Log.d(TAG, "Control command received: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
Log.d(TAG, "Control command list is now: ${
controlCommandStatusList.joinToString(", ") { "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${it.value.joinToString(" ") { "%02X".format(it) }}" }
}")
val controlCommandIdentifier = ControlCommandIdentifiers.fromByte(controlCommand.identifier)
if (controlCommandIdentifier != null) {
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
listener.onControlCommandReceived(controlCommand)
}
} else {
Log.w(TAG, "Unknown control command identifier: ${controlCommand.identifier.toHexString()}")
}
callback?.onControlCommandReceived(packet)
}
Opcodes.EAR_DETECTION -> {
callback?.onEarDetectionReceived(packet)
}
Opcodes.CONVERSATION_AWARENESS -> {
callback?.onConversationAwarenessReceived(packet)
}
Opcodes.DEVICE_METADATA -> {
callback?.onDeviceMetadataReceived(packet)
}
Opcodes.HEADTRACKING -> {
if (packet.size < 70) {
Log.w(TAG, "Received HEADTRACKING packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
return
}
callback?.onHeadTrackingReceived(packet)
}
Opcodes.PROXIMITY_KEYS_RSP -> {
callback?.onProximityKeysReceived(packet)
}
else -> {
callback?.onUnknownPacketReceived(packet)
}
}
}
fun sendNotificationRequest(): Boolean {
return sendDataPacket(createRequestNotificationPacket())
}
fun createRequestNotificationPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.REQUEST_NOTIFICATIONS, 0x00)
val data = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())
return opcode + data
}
fun sendSetFeatureFlagsPacket(): Boolean {
return sendDataPacket(createSetFeatureFlagsPacket())
}
fun createSetFeatureFlagsPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00)
val data = byteArrayOf(0xFF.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
return opcode + data
}
fun createHandshakePacket(): ByteArray {
return byteArrayOf(
0x00, 0x00, 0x04, 0x00,
0x01, 0x00, 0x02, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
)
}
fun sendStartHeadTracking(): Boolean {
return sendDataPacket(createStartHeadTrackingPacket())
}
fun createStartHeadTrackingPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
val data = byteArrayOf(
0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00,
)
return opcode + data
}
fun sendStopHeadTracking(): Boolean {
return sendDataPacket(createStopHeadTrackingPacket())
}
fun createStopHeadTrackingPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
val data = byteArrayOf(
0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E, 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
)
return opcode + data
}
fun sendRename(name: String): Boolean {
return sendDataPacket(createRenamePacket(name))
}
fun createRenamePacket(name: String): ByteArray {
val nameBytes = name.toByteArray()
val size = nameBytes.size
val packet = ByteArray(5 + size)
packet[0] = Opcodes.RENAME
packet[1] = 0x00
packet[2] = size.toByte()
packet[3] = 0x00
System.arraycopy(nameBytes, 0, packet, 4, size)
return packet
}
data class ControlCommand(
val identifier: Byte,
val value: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ControlCommand
if (identifier != other.identifier) return false
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
var result: Int = identifier.toInt()
result = 31 * result + value.contentHashCode()
return result
}
companion object {
fun fromByteArray(data: ByteArray): ControlCommand {
if (data.size < 4) {
throw IllegalArgumentException("Data array too short to parse ControlCommand")
}
if (data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() && data[3] == 0x00.toByte()) {
val newData = ByteArray(data.size - 4)
System.arraycopy(data, 4, newData, 0, data.size - 4)
return fromByteArray(newData)
}
if (data[0] != Opcodes.CONTROL_COMMAND) {
throw IllegalArgumentException("Data array does not start with CONTROL_COMMAND opcode")
}
val identifier = data[2]
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 sendPacket(packet: ByteArray): Boolean {
try {
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
if (packet[4] == Opcodes.CONTROL_COMMAND) {
val controlCommand = ControlCommand.fromByteArray(packet)
Log.d(TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
controlCommand.value
)
}
val socket = BluetoothConnectionManager.getCurrentSocket()
if (socket?.isConnected == true) {
socket.outputStream?.write(packet)
socket.outputStream?.flush()
return true
} else {
Log.d(TAG, "Can't send packet: Socket not initialized or connected")
return false
}
} catch (e: Exception) {
Log.e(TAG, "Error sending packet: ${e.message}")
return false
}
}
}

View File

@@ -0,0 +1,490 @@
/*
* 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.utils
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
import android.util.Log
import me.kavishdevar.librepods.services.ServiceManager
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Manager for Bluetooth Low Energy scanning operations specifically for AirPods
*/
@OptIn(ExperimentalEncodingApi::class)
class BLEManager(private val context: Context) {
data class AirPodsStatus(
val address: String,
val lastSeen: Long = System.currentTimeMillis(),
val paired: Boolean = false,
val model: String = "Unknown",
val leftBattery: Int? = null,
val rightBattery: Int? = null,
val caseBattery: Int? = null,
val isLeftInEar: Boolean = false,
val isRightInEar: Boolean = false,
val isLeftCharging: Boolean = false,
val isRightCharging: Boolean = false,
val isCaseCharging: Boolean = false,
val lidOpen: Boolean = false,
val color: String = "Unknown",
val connectionState: String = "Unknown"
)
fun getMostRecentStatus(): AirPodsStatus? {
return deviceStatusMap.values.maxByOrNull { it.lastSeen }
}
interface AirPodsStatusListener {
fun onDeviceStatusChanged(device: AirPodsStatus, previousStatus: AirPodsStatus?)
fun onBroadcastFromNewAddress(device: AirPodsStatus)
fun onLidStateChanged(lidOpen: Boolean)
fun onEarStateChanged(device: AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean)
fun onBatteryChanged(device: AirPodsStatus)
}
private var mBluetoothLeScanner: BluetoothLeScanner? = null
private var mScanCallback: ScanCallback? = null
private var airPodsStatusListener: AirPodsStatusListener? = null
private val deviceStatusMap = mutableMapOf<String, AirPodsStatus>()
private val verifiedAddresses = mutableSetOf<String>()
private val sharedPreferences: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
private var currentGlobalLidState: Boolean? = null
private var lastBroadcastTime: Long = 0
private val processedAddresses = mutableSetOf<String>()
private val lastValidCaseBatteryMap = mutableMapOf<String, Int>()
private val modelNames = mapOf(
0x0E20 to "AirPods Pro",
0x1420 to "AirPods Pro 2",
0x2420 to "AirPods Pro 2 (USB-C)",
0x0220 to "AirPods 1",
0x0F20 to "AirPods 2",
0x1320 to "AirPods 3",
0x1920 to "AirPods 4",
0x1B20 to "AirPods 4 (ANC)",
0x0A20 to "AirPods Max",
0x1F20 to "AirPods Max (USB-C)"
)
val colorNames = mapOf(
0x00 to "White", 0x01 to "Black", 0x02 to "Red", 0x03 to "Blue",
0x04 to "Pink", 0x05 to "Gray", 0x06 to "Silver", 0x07 to "Gold",
0x08 to "Rose Gold", 0x09 to "Space Gray", 0x0A to "Dark Blue",
0x0B to "Light Blue", 0x0C to "Yellow"
)
val connStates = mapOf(
0x00 to "Disconnected", 0x04 to "Idle", 0x05 to "Music",
0x06 to "Call", 0x07 to "Ringing", 0x09 to "Hanging Up", 0xFF to "Unknown"
)
private val cleanupHandler = Handler(Looper.getMainLooper())
private val cleanupRunnable = object : Runnable {
override fun run() {
cleanupStaleDevices()
checkLidStateTimeout()
cleanupHandler.postDelayed(this, CLEANUP_INTERVAL_MS)
}
}
fun setAirPodsStatusListener(listener: AirPodsStatusListener) {
airPodsStatusListener = listener
}
@SuppressLint("MissingPermission")
fun startScanning() {
try {
Log.d(TAG, "Starting BLE scanner")
val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val btAdapter = btManager.adapter
if (btAdapter == null) {
Log.d(TAG, "No Bluetooth adapter available")
return
}
if (mBluetoothLeScanner != null && mScanCallback != null) {
mBluetoothLeScanner?.stopScan(mScanCallback)
mScanCallback = null
}
if (!btAdapter.isEnabled) {
Log.d(TAG, "Bluetooth is disabled")
return
}
mBluetoothLeScanner = btAdapter.bluetoothLeScanner
val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
.setReportDelay(500L)
.build()
val manufacturerData = ByteArray(27)
val manufacturerDataMask = ByteArray(27)
manufacturerData[0] = 7
manufacturerData[1] = 25
manufacturerDataMask[0] = -1
manufacturerDataMask[1] = -1
val scanFilter = ScanFilter.Builder()
.setManufacturerData(76, manufacturerData, manufacturerDataMask)
.build()
mScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
processScanResult(result)
}
override fun onBatchScanResults(results: List<ScanResult>) {
processedAddresses.clear()
for (result in results) {
processScanResult(result)
}
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE scan failed with error code: $errorCode")
}
}
mBluetoothLeScanner?.startScan(listOf(scanFilter), scanSettings, mScanCallback)
Log.d(TAG, "BLE scanner started successfully")
cleanupHandler.postDelayed(cleanupRunnable, CLEANUP_INTERVAL_MS)
} catch (t: Throwable) {
Log.e(TAG, "Error starting BLE scanner", t)
}
}
@SuppressLint("MissingPermission")
fun stopScanning() {
try {
if (mBluetoothLeScanner != null && mScanCallback != null) {
Log.d(TAG, "Stopping BLE scanner")
mBluetoothLeScanner?.stopScan(mScanCallback)
mScanCallback = null
}
cleanupHandler.removeCallbacks(cleanupRunnable)
} catch (t: Throwable) {
Log.e(TAG, "Error stopping BLE scanner", t)
}
}
@OptIn(ExperimentalEncodingApi::class)
private fun getEncryptionKeyFromPreferences(): ByteArray? {
val keyBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
return if (keyBase64 != null) {
try {
Base64.decode(keyBase64)
} catch (e: Exception) {
Log.e(TAG, "Failed to decode encryption key", e)
null
}
} else {
null
}
}
private fun decryptLastBytes(data: ByteArray, key: ByteArray): ByteArray? {
return try {
if (data.size < 16) {
return null
}
val block = data.copyOfRange(data.size - 16, data.size)
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
val secretKey = SecretKeySpec(key, "AES")
cipher.init(Cipher.DECRYPT_MODE, secretKey)
cipher.doFinal(block)
} catch (e: Exception) {
Log.e(TAG, "Error decrypting data", e)
null
}
}
private fun formatBattery(byteVal: Int): Pair<Boolean, Int> {
val charging = (byteVal and 0x80) != 0
val level = byteVal and 0x7F
return Pair(charging, level)
}
private fun processScanResult(result: ScanResult) {
try {
val scanRecord = result.scanRecord ?: return
val address = result.device.address
if (processedAddresses.contains(address)) {
return
}
val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
if (manufacturerData.size <= 20) return
if (!verifiedAddresses.contains(address)) {
val irk = getIrkFromPreferences()
if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
return
}
verifiedAddresses.add(address)
Log.d(TAG, "RPA verified and added to trusted list: $address")
}
processedAddresses.add(address)
lastBroadcastTime = System.currentTimeMillis()
val encryptionKey = getEncryptionKeyFromPreferences()
val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null
val parsedStatus = if (decryptedData != null && decryptedData.size == 16) {
parseProximityMessageWithDecryption(address, manufacturerData, decryptedData)
} else {
parseProximityMessage(address, manufacturerData)
}
val previousStatus = deviceStatusMap[address]
deviceStatusMap[address] = parsedStatus
airPodsStatusListener?.let { listener ->
if (previousStatus == null) {
listener.onBroadcastFromNewAddress(parsedStatus)
Log.d(TAG, "New AirPods device detected: $address")
if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
currentGlobalLidState = parsedStatus.lidOpen
listener.onLidStateChanged(parsedStatus.lidOpen)
Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
}
} else {
if (parsedStatus != previousStatus) {
listener.onDeviceStatusChanged(parsedStatus, previousStatus)
}
if (parsedStatus.lidOpen != previousStatus.lidOpen) {
val previousGlobalState = currentGlobalLidState
currentGlobalLidState = parsedStatus.lidOpen
if (previousGlobalState != parsedStatus.lidOpen) {
listener.onLidStateChanged(parsedStatus.lidOpen)
Log.d(TAG, "Lid state changed from ${previousGlobalState} to ${parsedStatus.lidOpen}")
}
}
if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
parsedStatus.isRightInEar != previousStatus.isRightInEar) {
listener.onEarStateChanged(
parsedStatus,
parsedStatus.isLeftInEar,
parsedStatus.isRightInEar
)
Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
}
if (parsedStatus.leftBattery != previousStatus.leftBattery ||
parsedStatus.rightBattery != previousStatus.rightBattery ||
parsedStatus.caseBattery != previousStatus.caseBattery) {
listener.onBatteryChanged(parsedStatus)
Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
}
}
}
} catch (t: Throwable) {
Log.e(TAG, "Error processing scan result", t)
}
}
private fun parseProximityMessageWithDecryption(address: String, data: ByteArray, decrypted: ByteArray): AirPodsStatus {
val paired = data[2].toInt() == 1
val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)
val model = modelNames[modelId] ?: "Unknown ($modelId)"
val status = data[5].toInt() and 0xFF
val flagsCase = data[7].toInt() and 0xFF
val lid = data[8].toInt() and 0xFF
val color = colorNames[data[9].toInt()] ?: "Unknown"
val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})"
val primaryLeft = ((status shr 5) and 0x01) == 1
val thisInCase = ((status shr 6) and 0x01) == 1
val xorFactor = primaryLeft xor thisInCase
val isLeftInEar = if (xorFactor) (status and 0x08) != 0 else (status and 0x02) != 0
val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0
val isFlipped = !primaryLeft
val leftByteIndex = if (isFlipped) 2 else 1
val rightByteIndex = if (isFlipped) 1 else 2
val (isLeftCharging, leftBattery) = formatBattery(decrypted[leftByteIndex].toInt() and 0xFF)
val (isRightCharging, rightBattery) = formatBattery(decrypted[rightByteIndex].toInt() and 0xFF)
val rawCaseBatteryByte = decrypted[3].toInt() and 0xFF
val (isCaseCharging, rawCaseBattery) = formatBattery(rawCaseBatteryByte)
val caseBattery = if (rawCaseBatteryByte == 0xFF || (isCaseCharging && rawCaseBattery == 127)) {
lastValidCaseBatteryMap[address]
} else {
lastValidCaseBatteryMap[address] = rawCaseBattery
rawCaseBattery
}
val lidOpen = ((lid shr 3) and 0x01) == 0
return AirPodsStatus(
address = address,
lastSeen = System.currentTimeMillis(),
paired = paired,
model = model,
leftBattery = leftBattery,
rightBattery = rightBattery,
caseBattery = caseBattery,
isLeftInEar = isLeftInEar,
isRightInEar = isRightInEar,
isLeftCharging = isLeftCharging,
isRightCharging = isRightCharging,
isCaseCharging = isCaseCharging,
lidOpen = lidOpen,
color = color,
connectionState = conn
)
}
private fun cleanupStaleDevices() {
val now = System.currentTimeMillis()
val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS
val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff }
for (device in staleDevices) {
deviceStatusMap.remove(device.key)
Log.d(TAG, "Removed stale device from tracking: ${device.key}")
}
}
private fun checkLidStateTimeout() {
val currentTime = System.currentTimeMillis()
if (currentTime - lastBroadcastTime > LID_CLOSE_TIMEOUT_MS && currentGlobalLidState == true) {
Log.d(TAG, "No broadcasts for ${LID_CLOSE_TIMEOUT_MS}ms, forcing lid state to closed")
currentGlobalLidState = false
airPodsStatusListener?.onLidStateChanged(false)
}
}
@OptIn(ExperimentalEncodingApi::class)
private fun getIrkFromPreferences(): ByteArray? {
val irkBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
return if (irkBase64 != null) {
try {
Base64.decode(irkBase64)
} catch (e: Exception) {
Log.e(TAG, "Failed to decode IRK", e)
null
}
} else {
null
}
}
private fun parseProximityMessage(address: String, data: ByteArray): AirPodsStatus {
val paired = data[2].toInt() == 1
val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF)
val model = modelNames[modelId] ?: "Unknown ($modelId)"
val status = data[5].toInt() and 0xFF
val podsBattery = data[6].toInt() and 0xFF
val flagsCase = data[7].toInt() and 0xFF
val lid = data[8].toInt() and 0xFF
val color = colorNames[data[9].toInt()] ?: "Unknown"
val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})"
val primaryLeft = ((status shr 5) and 0x01) == 1
val thisInCase = ((status shr 6) and 0x01) == 1
val xorFactor = primaryLeft xor thisInCase
val isLeftInEar = if (xorFactor) (status and 0x08) != 0 else (status and 0x02) != 0
val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0
val isFlipped = !primaryLeft
val leftBatteryNibble = if (isFlipped) (podsBattery shr 4) and 0x0F else podsBattery and 0x0F
val rightBatteryNibble = if (isFlipped) podsBattery and 0x0F else (podsBattery shr 4) and 0x0F
val caseBattery = flagsCase and 0x0F
val flags = (flagsCase shr 4) and 0x0F
val isLeftCharging = if (isFlipped) (flags and 0x02) != 0 else (flags and 0x01) != 0
val isRightCharging = if (isFlipped) (flags and 0x01) != 0 else (flags and 0x02) != 0
val isCaseCharging = (flags and 0x04) != 0
val lidOpen = ((lid shr 3) and 0x01) == 0
fun decodeBattery(n: Int): Int? = when (n) {
in 0x0..0x9 -> n * 10
in 0xA..0xE -> 100
0xF -> null
else -> null
}
return AirPodsStatus(
address = address,
lastSeen = System.currentTimeMillis(),
paired = paired,
model = model,
leftBattery = decodeBattery(leftBatteryNibble),
rightBattery = decodeBattery(rightBatteryNibble),
caseBattery = decodeBattery(caseBattery),
isLeftInEar = isLeftInEar,
isRightInEar = isRightInEar,
isLeftCharging = isLeftCharging,
isRightCharging = isRightCharging,
isCaseCharging = isCaseCharging,
lidOpen = lidOpen,
color = color,
connectionState = conn
)
}
companion object {
private const val TAG = "AirPodsBLE"
private const val CLEANUP_INTERVAL_MS = 30000L
private const val STALE_DEVICE_TIMEOUT_MS = 60000L
private const val LID_CLOSE_TIMEOUT_MS = 2000L
}
}

View File

@@ -0,0 +1,40 @@
/*
* 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.utils
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.util.Log
object BluetoothConnectionManager {
private const val TAG = "BluetoothConnectionManager"
private var currentSocket: BluetoothSocket? = null
private var currentDevice: BluetoothDevice? = null
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
currentSocket = socket
currentDevice = device
Log.d(TAG, "Current connection set to device: ${device.address}")
}
fun getCurrentSocket(): BluetoothSocket? {
return currentSocket
}
}

View File

@@ -0,0 +1,74 @@
/*
* 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.utils
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
/**
* Utilities for Bluetooth cryptography operations, particularly for
* verifying Resolvable Private Addresses (RPA) used by AirPods.
*/
object BluetoothCryptography {
/**
* Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK)
*
* @param addr 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
*/
fun verifyRPA(addr: String, irk: ByteArray): Boolean {
val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
val prand = rpa.copyOfRange(3, 6)
val hash = rpa.copyOfRange(0, 3)
val computedHash = ah(irk, prand)
return hash.contentEquals(computedHash)
}
/**
* 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
*/
fun e(key: ByteArray, data: ByteArray): ByteArray {
val swappedKey = key.reversedArray()
val swappedData = data.reversedArray()
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
val secretKey = SecretKeySpec(swappedKey, "AES")
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
return cipher.doFinal(swappedData).reversedArray()
}
/**
* 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
*/
fun ah(k: ByteArray, r: ByteArray): ByteArray {
val rPadded = ByteArray(16)
r.copyInto(rPadded, 0, 0, 3)
val encrypted = e(k, rPadded)
return encrypted.copyOfRange(0, 3)
}
}

View File

@@ -16,6 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
@@ -40,6 +41,7 @@ import kotlinx.coroutines.launch
import me.kavishdevar.librepods.services.ServiceManager
import java.io.IOException
import java.util.UUID
import kotlin.io.encoding.ExperimentalEncodingApi
enum class CrossDevicePackets(val packet: ByteArray) {
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
@@ -87,7 +89,7 @@ object CrossDevice {
private fun startServer() {
CoroutineScope(Dispatchers.IO).launch {
if (!bluetoothAdapter.isEnabled) return@launch
serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
// serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
Log.d("CrossDevice", "Server started")
while (serverSocket != null) {
if (!bluetoothAdapter.isEnabled) {
@@ -233,7 +235,7 @@ object CrossDevice {
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
if (ServiceManager.getService()?.isConnectedLocally == true) {
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
ServiceManager.getService()?.sendPacket(packetInHex)
// ServiceManager.getService()?.sendPacket(packetInHex)
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
batteryBytes = trimmedPacket
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)

View File

@@ -1,3 +1,5 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
import android.os.Build
@@ -13,6 +15,7 @@ import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.services.ServiceManager
import java.util.Collections
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@@ -20,21 +23,18 @@ import kotlin.math.pow
@RequiresApi(Build.VERSION_CODES.Q)
class GestureDetector(
private val airPodsService: AirPodsService,
private val airPodsService: AirPodsService
) {
companion object {
private const val TAG = "GestureDetector"
private const val START_CMD = "04 00 04 00 17 00 00 00 10 00 10 00 08 A1 02 42 0B 08 0E 10 02 1A 05 01 40 9C 00 00"
private const val STOP_CMD = "04 00 04 00 17 00 00 00 10 00 11 00 08 7E 10 02 42 0B 08 4E 10 02 1A 05 01 00 00 00 00"
private const val IMMEDIATE_FEEDBACK_THRESHOLD = 600
private const val DIRECTION_CHANGE_SENSITIVITY = 150
private const val FAST_MOVEMENT_THRESHOLD = 300.0
private const val MIN_REQUIRED_EXTREMES = 3
private const val MAX_REQUIRED_EXTREMES = 4
private const val MAX_VALID_ORIENTATION_VALUE = 6000
}
@@ -87,13 +87,13 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
isRunning = true
gestureDetectedCallback = onGestureDetected
Log.d(TAG, "started: ${airPodsService.startHeadTracking()}")
clearData()
prevHorizontal = 0.0
prevVertical = 0.0
airPodsService.sendPacket(START_CMD)
detectionJob = CoroutineScope(Dispatchers.Default).launch {
while (isRunning) {
delay(50)
@@ -117,7 +117,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
Log.d(TAG, "Stopping gesture detection")
isRunning = false
if (!doNotStop) airPodsService.sendPacket(STOP_CMD)
if (!doNotStop) airPodsService.stopHeadTracking()
detectionJob?.cancel()
detectionJob = null
@@ -187,7 +187,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
}
}
private fun detectPeaksAndTroughs() {
if (horizontalBuffer.size < 4 || verticalBuffer.size < 4) return

View File

@@ -4,9 +4,6 @@ package me.kavishdevar.librepods.utils
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioDeviceInfo
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.SoundPool
import android.os.Build
import android.os.SystemClock
@@ -22,44 +19,6 @@ class GestureFeedback(private val context: Context) {
private val soundsLoaded = AtomicBoolean(false)
private fun forceBluetoothRouting(audioManager: AudioManager) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
val bluetoothDevice = devices.find {
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
}
bluetoothDevice?.let { device ->
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE)
.setAudioAttributes(AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build())
.build()
audioManager.requestAudioFocus(focusRequest)
if (!audioManager.isBluetoothScoOn) {
audioManager.isBluetoothScoOn = true
audioManager.startBluetoothSco()
}
Log.d(TAG, "Forced audio routing to Bluetooth device")
}
} else {
if (!audioManager.isBluetoothScoOn) {
audioManager.isBluetoothScoOn = true
audioManager.startBluetoothSco()
Log.d(TAG, "Started Bluetooth SCO")
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to force Bluetooth routing", e)
}
}
private val soundPool = SoundPool.Builder()
.setMaxStreams(3)
.setAudioAttributes(
@@ -201,12 +160,4 @@ class GestureFeedback(private val context: Context) {
Log.d(TAG, "Playing ${if (isYes) "YES" else "NO"} confirmation - streamID=$streamId")
}
}
fun release() {
try {
soundPool.release()
} catch (e: Exception) {
Log.e(TAG, "Error releasing resources", e)
}
}
}

View File

@@ -60,7 +60,6 @@ object HeadTracking {
private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation {
if (!isCalibrated) return Orientation()
// Add offset before normalizationval
val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral
val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral
val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral

View File

@@ -16,57 +16,200 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.animation.PropertyValuesHolder
import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Resources
import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log.e
import android.view.Gravity
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.View
import android.view.WindowManager
import android.view.animation.AccelerateInterpolator
import android.view.animation.AnticipateOvershootInterpolator
import android.view.animation.DecelerateInterpolator
import android.view.animation.OvershootInterpolator
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.VideoView
import androidx.core.content.ContextCompat.getString
import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
enum class IslandType {
CONNECTED,
TAKING_OVER,
MOVED_TO_REMOTE,
// CALL_GESTURE
}
class IslandWindow(context: Context) {
class IslandWindow(private val context: Context) {
private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
@SuppressLint("InflateParams")
private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
private var isClosing = false
private var params: WindowManager.LayoutParams? = null
private var initialY = 0f
private var initialTouchY = 0f
private var lastTouchY = 0f
private var velocityTracker: VelocityTracker? = null
private var isBeingDragged = false
private var autoCloseHandler: Handler? = null
private var autoCloseRunnable: Runnable? = null
private var initialHeight = 0
private var screenHeight = 0
private var isDraggingDown = false
private var lastMoveTime = 0L
private var yMovement = 0f
private var dragDistance = 0f
private var initialConnectedTextY = 0f
private var initialDeviceTextY = 0f
private var initialBatteryViewY = 0f
private var initialVideoViewY = 0f
private var initialTextSeparation = 0f
private val containerView = FrameLayout(context)
private lateinit var springAnimation: SpringAnimation
private val flingAnimator = ValueAnimator()
private val batteryReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
val batteryList = intent.getParcelableArrayListExtra<Battery>("data")
updateBatteryDisplay(batteryList)
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context?.unregisterReceiver(this)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
val isVisible: Boolean
get() = islandView.parent != null && islandView.visibility == View.VISIBLE
get() = containerView.parent != null && containerView.visibility == View.VISIBLE
@SuppressLint("SetTextI18n")
private fun updateBatteryDisplay(batteryList: ArrayList<Battery>?) {
if (batteryList == null || batteryList.isEmpty()) return
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
val leftLevel = leftBattery?.level ?: 0
val rightLevel = rightBattery?.level ?: 0
val leftStatus = leftBattery?.status ?: BatteryStatus.DISCONNECTED
val rightStatus = rightBattery?.status ?: BatteryStatus.DISCONNECTED
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
val displayBatteryLevel = when {
leftLevel > 0 && rightLevel > 0 -> minOf(leftLevel, rightLevel)
leftLevel > 0 -> leftLevel
rightLevel > 0 -> rightLevel
else -> null
}
if (displayBatteryLevel != null) {
batteryText.text = "$displayBatteryLevel%"
batteryProgressBar.progress = displayBatteryLevel
batteryProgressBar.isIndeterminate = false
} else {
batteryText.text = "?"
batteryProgressBar.progress = 0
batteryProgressBar.isIndeterminate = false
}
}
@SuppressLint("SetTextI18s", "ClickableViewAccessibility")
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) {
if (ServiceManager.getService()?.islandOpen == true) return
else ServiceManager.getService()?.islandOpen = true
val displayMetrics = Resources.getSystem().displayMetrics
val width = (displayMetrics.widthPixels * 0.95).toInt()
screenHeight = displayMetrics.heightPixels
val params = WindowManager.LayoutParams(
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 ->
minOf(leftBattery!!.level, rightBattery!!.level)
leftBattery?.level ?: 0 > 0 -> leftBattery!!.level
rightBattery?.level ?: 0 > 0 -> rightBattery!!.level
batteryPercentage > 0 -> batteryPercentage
else -> null
}
} else if (batteryPercentage > 0) {
batteryPercentage
} else {
null
}
if (displayBatteryLevel != null) {
batteryText.text = "$displayBatteryLevel%"
batteryProgressBar.progress = displayBatteryLevel
} else {
batteryText.text = "?"
batteryProgressBar.progress = 0
}
batteryProgressBar.isIndeterminate = false
islandView.findViewById<TextView>(R.id.island_device_name).text = name
val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
batteryIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(batteryReceiver, batteryIntentFilter)
}
ServiceManager.getService()?.sendBatteryBroadcast()
containerView.removeAllViews()
val containerParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
containerView.addView(islandView, containerParams)
params = WindowManager.LayoutParams(
width,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
@@ -77,12 +220,100 @@ class IslandWindow(context: Context) {
}
islandView.visibility = View.VISIBLE
islandView.findViewById<TextView>(R.id.island_battery_text).text = "$batteryPercentage%"
islandView.findViewById<TextView>(R.id.island_device_name).text = name
containerView.visibility = View.VISIBLE
islandView.setOnClickListener {
ServiceManager.getService()?.startMainActivity()
close()
containerView.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false)
flingAnimator.cancel()
velocityTracker?.recycle()
velocityTracker = VelocityTracker.obtain()
velocityTracker?.addMovement(event)
initialY = containerView.translationY
initialTouchY = event.rawY
lastTouchY = event.rawY
initialHeight = islandView.height
isBeingDragged = false
isDraggingDown = false
lastMoveTime = System.currentTimeMillis()
dragDistance = 0f
captureInitialPositions()
true
}
MotionEvent.ACTION_MOVE -> {
velocityTracker?.addMovement(event)
val deltaY = event.rawY - initialTouchY
val moveDelta = event.rawY - lastTouchY
dragDistance += abs(moveDelta)
isDraggingDown = moveDelta > 0
val currentTime = System.currentTimeMillis()
val timeDelta = currentTime - lastMoveTime
if (timeDelta > 0) {
yMovement = moveDelta / timeDelta * 10
}
lastMoveTime = currentTime
if (abs(deltaY) > 5 || isBeingDragged) {
isBeingDragged = true
// Cancel auto close timer when dragging starts
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false)
val dampedDeltaY = if (deltaY > 0) {
initialY + (deltaY * 0.6f)
} else {
initialY + (deltaY * 0.9f)
}
containerView.translationY = dampedDeltaY
if (isDraggingDown && deltaY > 0) {
val stretchAmount = (deltaY * 0.5f).coerceAtMost(200f)
applyCustomStretchEffect(stretchAmount, deltaY)
}
}
lastTouchY = event.rawY
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
velocityTracker?.addMovement(event)
velocityTracker?.computeCurrentVelocity(1000)
val yVelocity = velocityTracker?.yVelocity ?: 0f
if (isBeingDragged) {
val currentTranslationY = containerView.translationY
val significantVelocity = abs(yVelocity) > 800
val significantDrag = abs(dragDistance) > 80
when {
yVelocity < -1200 || (currentTranslationY < -80 && !isDraggingDown) -> {
animateDismissWithInertia(yVelocity)
}
yVelocity > 1200 || (isDraggingDown && significantDrag) -> {
animateExpandWithStretch(yVelocity)
}
else -> {
springBackWithInertia(yVelocity)
}
}
} else if (dragDistance < 10) {
resetAutoCloseTimer()
}
velocityTracker?.recycle()
velocityTracker = null
isBeingDragged = false
true
}
else -> false
}
}
when (type) {
@@ -95,16 +326,8 @@ class IslandWindow(context: Context) {
IslandType.MOVED_TO_REMOTE -> {
islandView.findViewById<TextView>(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
}
// IslandType.CALL_GESTURE -> {
// islandView.findViewById<TextView>(R.id.island_connected_text).text = "Incoming Call from $name"
// islandView.findViewById<TextView>(R.id.island_device_name).text = "Use Head Gestures to answer."
// }
}
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
batteryProgressBar.progress = batteryPercentage
batteryProgressBar.isIndeterminate = false
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
val videoUri = Uri.parse("android.resource://me.kavishdevar.librepods/${R.raw.island}")
videoView.setVideoURI(videoUri)
@@ -113,19 +336,266 @@ class IslandWindow(context: Context) {
videoView.start()
}
windowManager.addView(islandView, params)
windowManager.addView(containerView, params)
islandView.post {
initialHeight = islandView.height
captureInitialPositions()
}
springAnimation = SpringAnimation(containerView, DynamicAnimation.TRANSLATION_Y, 0f).apply {
spring = SpringForce(0f)
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
.setStiffness(SpringForce.STIFFNESS_MEDIUM)
}
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
duration = 700
interpolator = AnticipateOvershootInterpolator()
start()
}
Handler(Looper.getMainLooper()).postDelayed({
close()
}, 4500)
resetAutoCloseTimer()
}
private fun captureInitialPositions() {
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
val batteryView = islandView.findViewById<FrameLayout>(R.id.island_battery_container)
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
connectedText.post {
initialConnectedTextY = connectedText.y
initialDeviceTextY = deviceText.y
initialTextSeparation = deviceText.y - (connectedText.y + connectedText.height)
if (batteryView != null) initialBatteryViewY = batteryView.y
initialVideoViewY = videoView.y
}
}
private fun applyCustomStretchEffect(stretchAmount: Float, dragY: Float) {
try {
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
val batteryView = islandView.findViewById<FrameLayout>(R.id.island_battery_container)
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
val stretchFactor = 1f + (stretchAmount / 300f).coerceAtMost(4.0f)
val newMinHeight = (initialHeight * stretchFactor).toInt()
mainLayout.minimumHeight = newMinHeight
val textMarginIncrease = (stretchAmount * 0.8f).toInt()
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
deviceTextParams.topMargin = textMarginIncrease
deviceText.layoutParams = deviceTextParams
val background = mainLayout.background
if (background is GradientDrawable) {
val cornerRadius = 56f
background.cornerRadius = cornerRadius
}
if (params != null) {
params!!.height = screenHeight
val containerParams = containerView.layoutParams
containerParams.height = screenHeight
containerView.layoutParams = containerParams
try {
windowManager.updateViewLayout(containerView, params)
} catch (e: Exception) {
e.printStackTrace()
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun resetAutoCloseTimer() {
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
autoCloseHandler = Handler(Looper.getMainLooper())
autoCloseRunnable = Runnable { close() }
autoCloseHandler?.postDelayed(autoCloseRunnable!!, 4500)
}
private fun springBackWithInertia(velocity: Float) {
springAnimation.cancel()
flingAnimator.cancel()
springAnimation.setStartVelocity(velocity)
val baseStiffness = SpringForce.STIFFNESS_MEDIUM
val dynamicStiffness = baseStiffness * (1f + (abs(velocity) / 3000f))
springAnimation.spring = SpringForce(0f)
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
.setStiffness(dynamicStiffness)
resetStretchEffects(velocity)
if (params != null) {
params!!.height = WindowManager.LayoutParams.WRAP_CONTENT
try {
windowManager.updateViewLayout(containerView, params)
} catch (e: Exception) {
e.printStackTrace()
}
}
springAnimation.start()
}
private fun resetStretchEffects(velocity: Float) {
try {
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
val heightAnimator = ValueAnimator.ofInt(mainLayout.minimumHeight, initialHeight)
heightAnimator.duration = 300
heightAnimator.interpolator = OvershootInterpolator(1.5f)
heightAnimator.addUpdateListener { animation ->
mainLayout.minimumHeight = animation.animatedValue as Int
}
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
val textMarginAnimator = ValueAnimator.ofInt(deviceTextParams.topMargin, 0)
textMarginAnimator.duration = 300
textMarginAnimator.interpolator = OvershootInterpolator(1.5f)
textMarginAnimator.addUpdateListener { animation ->
deviceTextParams.topMargin = animation.animatedValue as Int
deviceText.layoutParams = deviceTextParams
}
heightAnimator.start()
textMarginAnimator.start()
} catch (e: Exception) {
e.printStackTrace()
}
}
private fun animateDismissWithInertia(velocity: Float) {
springAnimation.cancel()
flingAnimator.cancel()
val baseDistance = -screenHeight
val velocityFactor = (abs(velocity) / 2000f).coerceIn(0.5f, 2.0f)
val targetDistance = baseDistance * velocityFactor
val baseDuration = 400L
val velocityDurationFactor = (1500f / (abs(velocity) + 1500f))
val duration = (baseDuration * velocityDurationFactor).toLong().coerceIn(200L, 500L)
flingAnimator.setFloatValues(containerView.translationY, targetDistance)
flingAnimator.duration = duration
flingAnimator.addUpdateListener { animation ->
containerView.translationY = animation.animatedValue as Float
val progress = animation.animatedFraction
containerView.scaleX = 1f - (progress * 0.5f)
containerView.scaleY = 1f - (progress * 0.5f)
containerView.alpha = 1f - progress
}
flingAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
forceClose()
}
})
flingAnimator.interpolator = DecelerateInterpolator(1.2f)
flingAnimator.start()
}
private fun animateExpandWithStretch(velocity: Float) {
springAnimation.cancel()
flingAnimator.cancel()
val baseDuration = 600L
val velocityFactor = (1800f / (abs(velocity) + 1800f)).coerceIn(0.5f, 1.5f)
val expandDuration = (baseDuration * velocityFactor).toLong().coerceIn(300L, 700L)
if (params != null) {
params!!.height = screenHeight
try {
windowManager.updateViewLayout(containerView, params)
} catch (e: Exception) {
e.printStackTrace()
}
}
val containerAnimator = ValueAnimator.ofFloat(containerView.translationY, screenHeight * 0.6f)
containerAnimator.duration = expandDuration
containerAnimator.interpolator = DecelerateInterpolator(0.8f)
containerAnimator.addUpdateListener { animation ->
containerView.translationY = animation.animatedValue as Float
}
val stretchAnimator = ValueAnimator.ofFloat(0f, 1f)
stretchAnimator.duration = expandDuration
stretchAnimator.interpolator = OvershootInterpolator(0.5f)
stretchAnimator.addUpdateListener { animation ->
val progress = animation.animatedValue as Float
animateCustomStretch(progress, expandDuration)
}
val normalizeAnimator = ValueAnimator.ofFloat(1.0f, 0.0f)
normalizeAnimator.duration = 300
normalizeAnimator.startDelay = expandDuration - 150
normalizeAnimator.interpolator = AccelerateInterpolator(1.2f)
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
}
}
normalizeAnimator.addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
ServiceManager.getService()?.startMainActivity()
forceClose()
}
})
containerAnimator.start()
stretchAnimator.start()
normalizeAnimator.start()
}
private fun animateCustomStretch(progress: Float, duration: Long) {
try {
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
val targetHeight = (screenHeight * 0.7f).toInt()
val currentHeight = initialHeight + ((targetHeight - initialHeight) * progress)
mainLayout.minimumHeight = currentHeight.toInt()
val mainLayoutParams = mainLayout.layoutParams
mainLayoutParams.height = LinearLayout.LayoutParams.MATCH_PARENT
mainLayout.layoutParams = mainLayoutParams
val targetMargin = (400 * progress).toInt()
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
deviceTextParams.topMargin = targetMargin
deviceText.layoutParams = deviceTextParams
val baseTextSize = 24f
deviceText.textSize = baseTextSize + (progress * 8f)
val baseSubTextSize = 16f
connectedText.textSize = baseSubTextSize + (progress * 4f)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun close() {
@@ -133,31 +603,82 @@ class IslandWindow(context: Context) {
if (isClosing) return
isClosing = true
try {
context.unregisterReceiver(batteryReceiver)
} catch (e: Exception) {
e.printStackTrace()
}
ServiceManager.getService()?.islandOpen = false
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
resetStretchEffects(0f)
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
videoView.stopPlayback()
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 0.5f)
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 0.5f)
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f, -200f)
ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply {
try {
videoView.stopPlayback()
} 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)
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
duration = 700
interpolator = AnticipateOvershootInterpolator()
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
islandView.visibility = View.GONE
try {
windowManager.removeView(islandView)
} catch (e: Exception) {
e("IslandWindow", "Error removing view: $e")
}
isClosing = false
cleanupAndRemoveView()
}
})
start()
}
} catch (e: Exception) {
e.printStackTrace()
// Even if animation fails, ensure we cleanup
cleanupAndRemoveView()
}
}
private fun cleanupAndRemoveView() {
containerView.visibility = View.GONE
try {
if (containerView.parent != null) {
windowManager.removeView(containerView)
}
} catch (e: Exception) {
e("IslandWindow", "Error removing view: $e")
}
isClosing = false
// Make sure all animations are canceled
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) {
e.printStackTrace()
isClosing = false
}
}
}

View File

@@ -52,6 +52,35 @@ class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModul
}
}
if (param.packageName == "com.google.android.settings") {
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
try {
val headerControllerClass = param.classLoader.loadClass(
"com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
val updateIconMethod = headerControllerClass.getDeclaredMethod(
"updateIcon",
android.widget.ImageView::class.java,
String::class.java)
hook(updateIconMethod, BluetoothIconHooker::class.java)
Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings")
try {
val displayPreferenceMethod = headerControllerClass.getDeclaredMethod(
"displayPreference",
param.classLoader.loadClass("androidx.preference.PreferenceScreen"))
hook(displayPreferenceMethod, BluetoothSettingsAirPodsHooker::class.java)
Log.i(TAG, "Successfully hooked displayPreference for AirPods button injection")
} catch (e: Exception) {
Log.e(TAG, "Failed to hook displayPreference: ${e.message}", e)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e)
}
}
if (param.packageName == "com.android.settings") {
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
try {

View File

@@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
import android.content.SharedPreferences
@@ -28,6 +30,7 @@ import android.util.Log
import android.view.KeyEvent
import androidx.annotation.RequiresApi
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
object MediaController {
private var initialVolume: Int? = null
@@ -85,16 +88,18 @@ object MediaController {
userPlayedTheMedia = audioManager.isMusicActive
}, 7) // i have no idea why android sends an event a hundred times after the user does something.
}
Log.d("MediaController", "pausedforcrossdevice: $pausedForCrossDevice Ear detection status: ${ServiceManager.getService()?.earDetectionNotification?.status}, music active: ${audioManager.isMusicActive} and cross device available: ${CrossDevice.isAvailable}")
if (!pausedForCrossDevice && CrossDevice.isAvailable && ServiceManager.getService()?.earDetectionNotification?.status?.contains(0x00) == true && audioManager.isMusicActive) {
Log.d("MediaController", "Pausing for cross device and taking over.")
sendPause(true)
pausedForCrossDevice = true
ServiceManager.getService()?.takeOver()
Log.d("MediaController", "pausedforcrossdevice: $pausedForCrossDevice")
if (!pausedForCrossDevice && audioManager.isMusicActive) {
ServiceManager.getService()?.takeOver("music")
}
}
}
@Synchronized
fun getMusicActive(): Boolean {
return audioManager.isMusicActive
}
@Synchronized
fun sendPause(force: Boolean = false) {
Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force")
@@ -135,6 +140,14 @@ object MediaController {
)
)
}
if (!audioManager.isMusicActive) {
Log.d("MediaController", "Setting iPausedTheMedia to false")
iPausedTheMedia = false
}
if (pausedForCrossDevice) {
Log.d("MediaController", "Setting pausedForCrossDevice to false")
pausedForCrossDevice = false
}
}
@Synchronized

View File

@@ -136,10 +136,23 @@ class AirPodsNotifications {
}
fun setStatus(data: ByteArray) {
if (data.size != 11) {
return
when (data.size) {
// if the whole packet is given
11 -> {
status = data[7].toInt()
}
// if only the data is given
1 -> {
status = data[0].toInt()
}
// if the value of control command is given
4 -> {
status = data[0].toInt()
}
else -> {
Log.d("ANC", "Invalid ANC data size: ${data.size}")
}
}
status = data[7].toInt()
}
val name: String =
@@ -172,6 +185,19 @@ class AirPodsNotifications {
return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")
}
fun setBatteryDirect(
leftLevel: Int,
leftCharging: Boolean,
rightLevel: Int,
rightCharging: Boolean,
caseLevel: Int,
caseCharging: Boolean
) {
first = Battery(BatteryComponent.LEFT, leftLevel, if (leftCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
second = Battery(BatteryComponent.RIGHT, rightLevel, if (rightCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
case = Battery(BatteryComponent.CASE, caseLevel, if (caseCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
}
fun setBattery(data: ByteArray) {
if (data.size != 22) {
return

View File

@@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
import android.content.Context
@@ -32,6 +34,7 @@ import java.io.FileOutputStream
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import kotlin.io.encoding.ExperimentalEncodingApi
@NoLiveLiterals
class RadareOffsetFinder(context: Context) {

View File

@@ -16,6 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.widgets
@@ -23,6 +24,7 @@ import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
class BatteryWidget : AppWidgetProvider() {
override fun onUpdate(

View File

@@ -16,6 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.widgets
@@ -28,6 +29,8 @@ import android.util.Log
import android.widget.RemoteViews
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
class NoiseControlWidget : AppWidgetProvider() {
override fun onUpdate(
@@ -79,7 +82,12 @@ class NoiseControlWidget : AppWidgetProvider() {
if (intent.action == "ACTION_SET_ANC_MODE") {
val mode = intent.getIntExtra("ANC_MODE", 1)
Log.d("NoiseControlWidget", "Setting ANC mode to $mode")
ServiceManager.getService()?.setANCMode(mode)
ServiceManager.getService()!!
.aacpManager
.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
mode.toByte()
)
}
}
}

View File

@@ -2,10 +2,9 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/island_window_layout"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:layout_weight="0.95"
android:background="@drawable/island_background"
android:elevation="4dp"
android:gravity="center"
@@ -24,7 +23,7 @@
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:layout_margin="0dp"
android:layout_weight="1"
android:gravity="bottom"
@@ -38,12 +37,12 @@
android:layout_margin="0dp"
android:fontFamily="@font/sf_pro"
android:gravity="bottom"
android:padding="0dp"
android:text="@string/island_connected_text"
android:textColor="#707072"
android:includeFontPadding="false"
android:lineSpacingExtra="0dp"
android:lineSpacingMultiplier="1"
android:padding="0dp"
android:text="@string/island_connected_text"
android:textColor="#707072"
android:textSize="16sp" />
<TextView
@@ -53,19 +52,20 @@
android:layout_margin="0dp"
android:fontFamily="@font/sf_pro"
android:gravity="bottom"
android:includeFontPadding="false"
android:lineSpacingExtra="0dp"
android:lineSpacingMultiplier="1"
android:padding="0dp"
android:text="AirPods Pro"
android:textColor="@color/white"
android:textSize="24sp"
android:includeFontPadding="false"
android:lineSpacingExtra="0dp"
android:lineSpacingMultiplier="1"
tools:ignore="HardcodedText" />
</LinearLayout>
<FrameLayout
android:id="@+id/island_battery_container"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:gravity="center">
<ProgressBar
@@ -102,4 +102,4 @@
android:textStyle="bold"
tools:ignore="HardcodedText" />
</FrameLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -64,4 +64,19 @@
<string name="collect_logs">Collect Logs</string>
<string name="saved_logs">Saved Logs</string>
<string name="no_logs_found">No saved logs found</string>
<string name="takeover_header">Auto-Connect preferences</string>
<string name="takeover_airpods_state">Connect to your AirPods when its status is:</string>
<string name="takeover_disconnected">Disconnected</string>
<string name="takeover_disconnected_desc">AirPods are not connected to a device</string>
<string name="takeover_idle">Idle</string>
<string name="takeover_idle_desc">A device is connected to your AirPods, but not playing media or on a call</string>
<string name="takeover_music">Playing media</string>
<string name="takeover_music_desc">A device is playing media on your AirPods</string>
<string name="takeover_call">On call</string>
<string name="takeover_call_desc">A device is on a call with your AirPods</string>
<string name="takeover_phone_state">Connect to AirPods when your phone is:</string>
<string name="takeover_ringing_call">Receiving a call</string>
<string name="takeover_ringing_call_desc">Your phone starts ringing</string>
<string name="takeover_media_start">Starting media playback</string>
<string name="takeover_media_start_desc">Your phone starts playing media</string>
</resources>

View File

@@ -1,6 +1,4 @@
com.android.bluetooth
me.kavishdevar.librepods
android
com.android.systemui
com.google.android.settings
com.android.settings
com.google.android.bluetooth

View File

@@ -15,6 +15,7 @@ hazeMaterials = "1.5.3"
sliceBuilders = "1.1.0-alpha02"
sliceCore = "1.1.0-alpha02"
sliceView = "1.1.0-alpha02"
dynamicanimation = "1.1.0"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
@@ -35,6 +36,7 @@ haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", versi
androidx-slice-builders = { group = "androidx.slice", name = "slice-builders", version.ref = "sliceBuilders" }
androidx-slice-core = { group = "androidx.slice", name = "slice-core", version.ref = "sliceCore" }
androidx-slice-view = { group = "androidx.slice", name = "slice-view", version.ref = "sliceView" }
androidx-dynamicanimation = { group = "androidx.dynamicanimation", name = "dynamicanimation", version.ref = "dynamicanimation" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

55
docs/control_commands.md Normal file
View File

@@ -0,0 +1,55 @@
# Control Commands
AACP uses opcode `9` for control commands. opcodes are 16 bit integers that specify the kind of action being done. The length of a control command is fixed to 7 bytes + 4 bytes header (`04 00 04 00`)
An AACP packet is formated as:
`04 00 04 00 [opcode, little endianness] [data]`
So, our control commands becomes
```
04 00 04 00 09 00 [identifier] [data1] [data2] [data3] [data4]
```
Bytes that are not used are set to `0x00`. From what I've observed, the `data3` and `data4` are never used, and hence always zero. And, the `data2` is usually used when the configuration can be different for the two buds: like, to change the long press mode. Or, if there can be two "state" variables for the same feature: like the Hearing Aid feature.
## Control Commands
These commands
| Command identifier | Description | Format |
|--------------|---------------------|--------|
| 0x01 | Mic Mode | Single value (1 byte) |
| 0x05 | Button Send Mode | Single value (1 byte) |
| 0x12 | VoiceTrigger for Siri | Single Value (1 byte): `0x01` = enabled, `0x01` = disabled |
| 0x14 | SingleClickMode | Single value (1 byte) |
| 0x15 | DoubleClickMode | Single value (1 byte) |
| 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` |
| 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 |
| 0x1E | AutoAnswerMode | Single value (1 byte) |
| 0x1F | Chime Volume | Single value (1 byte): 0 to 100|
| 0x23 | VolumeSwipeInterval | Single value (1 byte): 0x00 = Default, `0x01` = Longer, `0x02` = Longest |
| 0x24 | Call Management Config | Single value (1 byte) |
| 0x25 | VolumeSwipeMode | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
| 0x26 | Adaptive Volume Config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
| 0x27 | Software Mute config | Single value (1 byte) |
| 0x28 | Conversation Detect config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
| 0x29 | SSL | Single value (1 byte) |
| 0x2C | Hearing Aid Enrolled and Hearing Aid Enabled | Two values (2 bytes; First byte - enrolled, Second byte = enabled): `0x01` = enabled, `0x02` = disabled |
| 0x2E | AutoANC Strength | Single value (1 byte): 0 to 100|
| 0x2F | HPS Gain Swipe | Single value (1 byte) |
| 0x30 | HRM enable/disable state | Single value (1 byte) |
| 0x31 | In Case Tone config | Single value (1 byte): `0x01` = enabled, `0x02` = disabled |
| 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 |
> [!NOTE]
> - These identifiers have been extracted from the macOS 15.4 Beta (24E5238a)'s bluetooth stack.
> - I have already added the ranges of values a command takes that I know of. Feel free to experiemnt by sending the packets for which the range/values are not given here.

View File

@@ -0,0 +1,72 @@
#include <QByteArray>
// Control Command Header
namespace ControlCommand
{
static const QByteArray HEADER = QByteArray::fromHex("040004000900");
// Helper function to create control command packets
static QByteArray createCommand(quint8 identifier, quint8 data1 = 0x00, quint8 data2 = 0x00,
quint8 data3 = 0x00, quint8 data4 = 0x00)
{
QByteArray packet = HEADER;
packet.append(static_cast<char>(identifier));
packet.append(static_cast<char>(data1));
packet.append(static_cast<char>(data2));
packet.append(static_cast<char>(data3));
packet.append(static_cast<char>(data4));
return packet;
}
inline std::optional<char> parseActive(const QByteArray &data)
{
if (!data.startsWith(ControlCommand::HEADER))
return std::nullopt;
return static_cast<quint8>(data.at(7));
}
}
template <quint8 CommandId>
struct BasicControlCommand
{
static constexpr quint8 ID = CommandId;
static const QByteArray HEADER;
static const QByteArray ENABLED;
static const QByteArray DISABLED;
static QByteArray create(quint8 data1 = 0x00, quint8 data2 = 0x00,
quint8 data3 = 0x00, quint8 data4 = 0x00)
{
return ControlCommand::createCommand(ID, data1, data2, data3, data4);
}
// Basically returns the byte at the index 7
static std::optional<bool> parseState(const QByteArray &data)
{
switch (ControlCommand::parseActive(data).value_or(0x00))
{
case 0x01: // Enabled
return true;
case 0x02: // Disabled
return false;
default:
return std::nullopt;
}
}
static std::optional<char> getValue(const QByteArray &data)
{
return ControlCommand::parseActive(data);
}
};
template <quint8 CommandId>
const QByteArray BasicControlCommand<CommandId>::HEADER = ControlCommand::HEADER + static_cast<char>(CommandId);
template <quint8 CommandId>
const QByteArray BasicControlCommand<CommandId>::ENABLED = create(0x01);
template <quint8 CommandId>
const QByteArray BasicControlCommand<CommandId>::DISABLED = create(0x02);

View File

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

View File

@@ -94,46 +94,46 @@ ApplicationWindow {
spacing: 8
PodColumn {
isVisible: airPodsTrayApp.battery.leftPodAvailable
inEar: airPodsTrayApp.leftPodInEar
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.podIcon
batteryLevel: airPodsTrayApp.battery.leftPodLevel
isCharging: airPodsTrayApp.battery.leftPodCharging
isVisible: airPodsTrayApp.deviceInfo.battery.leftPodAvailable
inEar: airPodsTrayApp.deviceInfo.leftPodInEar
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
batteryLevel: airPodsTrayApp.deviceInfo.battery.leftPodLevel
isCharging: airPodsTrayApp.deviceInfo.battery.leftPodCharging
indicator: "L"
}
PodColumn {
isVisible: airPodsTrayApp.battery.rightPodAvailable
inEar: airPodsTrayApp.rightPodInEar
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.podIcon
batteryLevel: airPodsTrayApp.battery.rightPodLevel
isCharging: airPodsTrayApp.battery.rightPodCharging
isVisible: airPodsTrayApp.deviceInfo.battery.rightPodAvailable
inEar: airPodsTrayApp.deviceInfo.rightPodInEar
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.podIcon
batteryLevel: airPodsTrayApp.deviceInfo.battery.rightPodLevel
isCharging: airPodsTrayApp.deviceInfo.battery.rightPodCharging
indicator: "R"
}
PodColumn {
isVisible: airPodsTrayApp.battery.caseAvailable
isVisible: airPodsTrayApp.deviceInfo.battery.caseAvailable
inEar: true
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.caseIcon
batteryLevel: airPodsTrayApp.battery.caseLevel
isCharging: airPodsTrayApp.battery.caseCharging
iconSource: "qrc:/icons/assets/" + airPodsTrayApp.deviceInfo.caseIcon
batteryLevel: airPodsTrayApp.deviceInfo.battery.caseLevel
isCharging: airPodsTrayApp.deviceInfo.battery.caseCharging
}
}
SegmentedControl {
anchors.horizontalCenter: parent.horizontalCenter
model: ["Off", "Noise Cancellation", "Transparency", "Adaptive"]
currentIndex: airPodsTrayApp.noiseControlMode
onCurrentIndexChanged: airPodsTrayApp.noiseControlMode = currentIndex
currentIndex: airPodsTrayApp.deviceInfo.noiseControlMode
onCurrentIndexChanged: airPodsTrayApp.setNoiseControlModeInt(currentIndex)
visible: airPodsTrayApp.airpodsConnected
}
Slider {
visible: airPodsTrayApp.adaptiveModeActive
visible: airPodsTrayApp.deviceInfo.adaptiveModeActive
from: 0
to: 100
stepSize: 1
value: airPodsTrayApp.adaptiveNoiseLevel
value: airPodsTrayApp.deviceInfo.adaptiveNoiseLevel
Timer {
id: debounceTimer
@@ -153,8 +153,8 @@ ApplicationWindow {
Switch {
visible: airPodsTrayApp.airpodsConnected
text: "Conversational Awareness"
checked: airPodsTrayApp.conversationalAwareness
onCheckedChanged: airPodsTrayApp.conversationalAwareness = checked
checked: airPodsTrayApp.deviceInfo.conversationalAwareness
onCheckedChanged: airPodsTrayApp.setConversationalAwareness(checked)
}
}
@@ -226,6 +226,19 @@ ApplicationWindow {
onCheckedChanged: airPodsTrayApp.notificationsEnabled = checked
}
Switch {
visible: airPodsTrayApp.airpodsConnected
text: "One Bud ANC Mode"
checked: airPodsTrayApp.deviceInfo.oneBudANCMode
onCheckedChanged: airPodsTrayApp.deviceInfo.oneBudANCMode = checked
ToolTip {
visible: parent.hovered
text: "Enable ANC when using one AirPod\n(More noise reduction, but uses more battery)"
delay: 500
}
}
Row {
spacing: 5
Label {
@@ -246,13 +259,13 @@ ApplicationWindow {
TextField {
id: newNameField
placeholderText: airPodsTrayApp.deviceName
placeholderText: airPodsTrayApp.deviceInfo.deviceName
maximumLength: 32
}
Button {
text: "Rename"
onClicked: airPodsTrayApp.renameAirPods(newNameField.text)
onClicked: airPodsTrayApp.deviceInfo.renameAirPods(newNameField.text)
}
}
}

View File

@@ -1,4 +1,4 @@
# ALN Linux app
# LibrePods Linux
A native Linux application to control your AirPods, with support for:

View File

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

View File

@@ -1,3 +1,5 @@
#pragma once
#include <QByteArray>
#include <QMap>
#include <QString>

244
linux/deviceinfo.hpp Normal file
View File

@@ -0,0 +1,244 @@
#pragma once
#include <QObject>
#include <QByteArray>
#include <QSettings>
#include "battery.hpp"
#include "enums.h"
using namespace AirpodsTrayApp::Enums;
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)
Q_PROPERTY(QString podIcon READ podIcon NOTIFY modelChanged)
Q_PROPERTY(QString caseIcon READ caseIcon NOTIFY modelChanged)
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)
public:
explicit DeviceInfo(QObject *parent = nullptr) : QObject(parent), m_battery(new Battery(this)) {}
QString batteryStatus() const { return m_batteryStatus; }
void setBatteryStatus(const QString &status)
{
if (m_batteryStatus != status)
{
m_batteryStatus = status;
emit batteryStatusChanged(status);
}
}
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)
{
if (m_noiseControlMode != mode)
{
m_noiseControlMode = mode;
emit noiseControlModeChanged(mode);
emit noiseControlModeChangedInt(static_cast<int>(mode));
}
}
int noiseControlModeInt() const { return static_cast<int>(noiseControlMode()); }
void setNoiseControlModeInt(int mode) { setNoiseControlMode(static_cast<NoiseControlMode>(mode)); }
bool conversationalAwareness() const { return m_conversationalAwareness; }
void setConversationalAwareness(bool enabled)
{
if (m_conversationalAwareness != enabled)
{
m_conversationalAwareness = enabled;
emit conversationalAwarenessChanged(enabled);
}
}
int adaptiveNoiseLevel() const { return m_adaptiveNoiseLevel; }
void setAdaptiveNoiseLevel(int level)
{
if (m_adaptiveNoiseLevel != level)
{
m_adaptiveNoiseLevel = level;
emit adaptiveNoiseLevelChanged(level);
}
}
QString deviceName() const { return m_deviceName; }
void setDeviceName(const QString &name)
{
if (m_deviceName != name)
{
m_deviceName = name;
emit deviceNameChanged(name);
}
}
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)
{
if (m_oneBudANCMode != enabled)
{
m_oneBudANCMode = enabled;
emit oneBudANCModeChanged(enabled);
}
}
AirPodsModel model() const { return m_model; }
void setModel(AirPodsModel model)
{
if (m_model != model)
{
m_model = model;
emit modelChanged();
}
}
QByteArray magicAccIRK() const { return m_magicAccIRK; }
void setMagicAccIRK(const QByteArray &irk) { m_magicAccIRK = irk; }
QByteArray magicAccEncKey() const { return m_magicAccEncKey; }
void setMagicAccEncKey(const QByteArray &key) { m_magicAccEncKey = key; }
QString modelNumber() const { return m_modelNumber; }
void setModelNumber(const QString &modelNumber) { m_modelNumber = modelNumber; }
QString manufacturer() const { return m_manufacturer; }
void setManufacturer(const QString &manufacturer) { m_manufacturer = manufacturer; }
QString bluetoothAddress() const { return m_bluetoothAddress; }
void setBluetoothAddress(const QString &address)
{
if (m_bluetoothAddress != address)
{
m_bluetoothAddress = address;
emit bluetoothAddressChanged(address);
}
}
QString podIcon() const { return getModelIcon(model()).first; }
QString caseIcon() const { return getModelIcon(model()).second; }
bool isLeftPodInEar() const
{
if (getBattery()->getPrimaryPod() == Battery::Component::Left) return isPrimaryInEar();
else return isSecondaryInEar();
}
bool isRightPodInEar() const
{
if (getBattery()->getPrimaryPod() == Battery::Component::Right) return isPrimaryInEar();
else return isSecondaryInEar();
}
bool adaptiveModeActive() const { return noiseControlMode() == NoiseControlMode::Adaptive; }
bool oneOrMorePodsInCase() const { return earDetectionStatus().contains("In case"); }
bool oneOrMorePodsInEar() const { return isPrimaryInEar() || isSecondaryInEar(); }
void reset()
{
setDeviceName("");
setModel(AirPodsModel::Unknown);
m_battery->reset();
setBatteryStatus("");
setEarDetectionStatus("");
setPrimaryInEar(false);
setSecondaryInEar(false);
setNoiseControlMode(NoiseControlMode::Off);
setBluetoothAddress("");
}
void save() const
{
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.endGroup();
}
void load()
{
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();
}
signals:
void batteryStatusChanged(const QString &status);
void earDetectionStatusChanged(const QString &status);
void noiseControlModeChanged(NoiseControlMode mode);
void noiseControlModeChangedInt(int mode);
void conversationalAwarenessChanged(bool enabled);
void adaptiveNoiseLevelChanged(int level);
void deviceNameChanged(const QString &name);
void primaryChanged();
void oneBudANCModeChanged(bool enabled);
void modelChanged();
void bluetoothAddressChanged(const QString &address);
private:
QString m_batteryStatus;
QString m_earDetectionStatus;
NoiseControlMode m_noiseControlMode = NoiseControlMode::Off;
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;
AirPodsModel m_model = AirPodsModel::Unknown;
QString m_modelNumber;
QString m_manufacturer;
QString m_bluetoothAddress;
};

View File

@@ -10,6 +10,7 @@
#include "battery.hpp"
#include "BluetoothMonitor.h"
#include "autostartmanager.hpp"
#include "deviceinfo.hpp"
using namespace AirpodsTrayApp::Enums;
@@ -17,19 +18,6 @@ Q_LOGGING_CATEGORY(airpodsApp, "airpodsApp")
class AirPodsTrayApp : public QObject {
Q_OBJECT
Q_PROPERTY(QString batteryStatus READ batteryStatus NOTIFY batteryStatusChanged)
Q_PROPERTY(QString earDetectionStatus READ earDetectionStatus NOTIFY earDetectionStatusChanged)
Q_PROPERTY(int noiseControlMode READ noiseControlMode WRITE setNoiseControlMode NOTIFY noiseControlModeChanged)
Q_PROPERTY(bool conversationalAwareness READ conversationalAwareness WRITE setConversationalAwareness NOTIFY conversationalAwarenessChanged)
Q_PROPERTY(int adaptiveNoiseLevel READ adaptiveNoiseLevel WRITE setAdaptiveNoiseLevel NOTIFY adaptiveNoiseLevelChanged)
Q_PROPERTY(bool adaptiveModeActive READ adaptiveModeActive NOTIFY noiseControlModeChanged)
Q_PROPERTY(QString deviceName READ deviceName NOTIFY deviceNameChanged)
Q_PROPERTY(Battery* battery READ getBattery NOTIFY batteryStatusChanged)
Q_PROPERTY(bool oneOrMorePodsInCase READ oneOrMorePodsInCase NOTIFY earDetectionStatusChanged)
Q_PROPERTY(QString podIcon READ podIcon NOTIFY modelChanged)
Q_PROPERTY(QString caseIcon READ caseIcon NOTIFY modelChanged)
Q_PROPERTY(bool leftPodInEar READ isLeftPodInEar NOTIFY primaryChanged)
Q_PROPERTY(bool rightPodInEar READ isRightPodInEar NOTIFY primaryChanged)
Q_PROPERTY(bool airpodsConnected READ areAirpodsConnected NOTIFY airPodsStatusChanged)
Q_PROPERTY(int earDetectionBehavior READ earDetectionBehavior WRITE setEarDetectionBehavior NOTIFY earDetectionBehaviorChanged)
Q_PROPERTY(bool crossDeviceEnabled READ crossDeviceEnabled WRITE setCrossDeviceEnabled NOTIFY crossDeviceEnabledChanged)
@@ -37,23 +25,13 @@ class AirPodsTrayApp : public QObject {
Q_PROPERTY(bool notificationsEnabled READ notificationsEnabled WRITE setNotificationsEnabled NOTIFY notificationsEnabledChanged)
Q_PROPERTY(int retryAttempts READ retryAttempts WRITE setRetryAttempts NOTIFY retryAttemptsChanged)
Q_PROPERTY(bool hideOnStart READ hideOnStart CONSTANT)
Q_PROPERTY(DeviceInfo *deviceInfo READ deviceInfo CONSTANT)
public:
AirPodsTrayApp(bool debugMode, bool hideOnStart, QQmlApplicationEngine *parent = nullptr)
: QObject(parent)
, debugMode(debugMode)
, m_battery(new Battery(this))
, monitor(new BluetoothMonitor(this))
, m_settings(new QSettings("AirPodsTrayApp", "AirPodsTrayApp"))
, m_autoStartManager(new AutoStartManager(this))
, m_hideOnStart(hideOnStart)
, parent(parent)
{
if (debugMode) {
QLoggingCategory::setFilterRules("airpodsApp.debug=true");
} else {
QLoggingCategory::setFilterRules("airpodsApp.debug=false");
}
: 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))
{
QLoggingCategory::setFilterRules(QString("airpodsApp.debug=%1").arg(debugMode ? "true" : "false"));
LOG_INFO("Initializing AirPodsTrayApp");
// Initialize tray icon and connect signals
@@ -62,25 +40,26 @@ public:
connect(trayManager, &TrayIconManager::trayClicked, this, &AirPodsTrayApp::onTrayIconActivated);
connect(trayManager, &TrayIconManager::openApp, this, &AirPodsTrayApp::onOpenApp);
connect(trayManager, &TrayIconManager::openSettings, this, &AirPodsTrayApp::onOpenSettings);
connect(trayManager, &TrayIconManager::noiseControlChanged, this, qOverload<NoiseControlMode>(&AirPodsTrayApp::setNoiseControlMode));
connect(trayManager, &TrayIconManager::noiseControlChanged, this, &AirPodsTrayApp::setNoiseControlMode);
connect(trayManager, &TrayIconManager::conversationalAwarenessToggled, this, &AirPodsTrayApp::setConversationalAwareness);
connect(this, &AirPodsTrayApp::batteryStatusChanged, trayManager, &TrayIconManager::updateBatteryStatus);
connect(this, &AirPodsTrayApp::noiseControlModeChanged, trayManager, &TrayIconManager::updateNoiseControlState);
connect(this, &AirPodsTrayApp::conversationalAwarenessChanged, trayManager, &TrayIconManager::updateConversationalAwareness);
connect(m_deviceInfo, &DeviceInfo::batteryStatusChanged, trayManager, &TrayIconManager::updateBatteryStatus);
connect(m_deviceInfo, &DeviceInfo::noiseControlModeChanged, trayManager, &TrayIconManager::updateNoiseControlState);
connect(m_deviceInfo, &DeviceInfo::conversationalAwarenessChanged, trayManager, &TrayIconManager::updateConversationalAwareness);
connect(trayManager, &TrayIconManager::notificationsEnabledChanged, this, &AirPodsTrayApp::saveNotificationsEnabled);
connect(trayManager, &TrayIconManager::notificationsEnabledChanged, this, &AirPodsTrayApp::notificationsEnabledChanged);
// Initialize MediaController and connect signals
mediaController = new MediaController(this);
connect(this, &AirPodsTrayApp::earDetectionStatusChanged, mediaController, &MediaController::handleEarDetection);
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_battery, &Battery::primaryChanged, this, &AirPodsTrayApp::primaryChanged);
connect(m_deviceInfo->getBattery(), &Battery::primaryChanged, this, &AirPodsTrayApp::primaryChanged);
// Load settings
CrossDevice.isEnabled = loadCrossDeviceEnabled();
@@ -113,31 +92,6 @@ public:
delete phoneSocket;
}
QString batteryStatus() const { return m_batteryStatus; }
QString earDetectionStatus() const { return m_earDetectionStatus; }
int noiseControlMode() const { return static_cast<int>(m_noiseControlMode); }
bool conversationalAwareness() const { return m_conversationalAwareness; }
bool adaptiveModeActive() const { return m_noiseControlMode == NoiseControlMode::Adaptive; }
int adaptiveNoiseLevel() const { return m_adaptiveNoiseLevel; }
QString deviceName() const { return m_deviceName; }
Battery *getBattery() const { return m_battery; }
bool oneOrMorePodsInCase() const { return m_earDetectionStatus.contains("In case"); }
QString podIcon() const { return getModelIcon(m_model).first; }
QString caseIcon() const { return getModelIcon(m_model).second; }
bool isLeftPodInEar() const {
if (m_battery->getPrimaryPod() == Battery::Component::Left) {
return m_primaryInEar;
} else {
return m_secoundaryInEar;
}
}
bool isRightPodInEar() const {
if (m_battery->getPrimaryPod() == Battery::Component::Right) {
return m_primaryInEar;
} else {
return m_secoundaryInEar;
}
}
bool areAirpodsConnected() const { return socket && socket->isOpen() && socket->state() == QBluetoothSocket::SocketState::ConnectedState; }
int earDetectionBehavior() const { return mediaController->getEarDetectionBehavior(); }
bool crossDeviceEnabled() const { return CrossDevice.isEnabled; }
@@ -146,6 +100,7 @@ public:
void setNotificationsEnabled(bool enabled) { trayManager->setNotificationsEnabled(enabled); }
int retryAttempts() const { return m_retryAttempts; }
bool hideOnStart() const { return m_hideOnStart; }
DeviceInfo *deviceInfo() const { return m_deviceInfo; }
private:
bool debugMode;
@@ -197,34 +152,49 @@ public slots:
void setNoiseControlMode(NoiseControlMode mode)
{
LOG_INFO("Setting noise control mode to: " << mode);
if (m_noiseControlMode == mode)
{
LOG_INFO("Noise control mode is already " << mode);
return;
}
QByteArray packet = AirPodsPackets::NoiseControl::getPacketForMode(mode);
writePacketToSocket(packet, "Noise control mode packet written: ");
}
void setNoiseControlMode(int mode)
void setNoiseControlModeInt(int mode)
{
if (mode < 0 || mode > static_cast<int>(NoiseControlMode::Adaptive))
{
LOG_ERROR("Invalid noise control mode: " << mode);
return;
}
setNoiseControlMode(static_cast<NoiseControlMode>(mode));
}
void setConversationalAwareness(bool enabled)
{
if (m_conversationalAwareness == enabled)
{
LOG_INFO("Conversational awareness is already " << (enabled ? "enabled" : "disabled"));
return;
}
LOG_INFO("Setting conversational awareness to: " << (enabled ? "enabled" : "disabled"));
QByteArray packet = enabled ? AirPodsPackets::ConversationalAwareness::ENABLED
: AirPodsPackets::ConversationalAwareness::DISABLED;
writePacketToSocket(packet, "Conversational awareness packet written: ");
m_conversationalAwareness = enabled;
emit conversationalAwarenessChanged(enabled);
m_deviceInfo->setConversationalAwareness(enabled);
}
void setOneBudANCMode(bool enabled)
{
if (m_deviceInfo->oneBudANCMode() == enabled)
{
LOG_INFO("One Bud ANC mode is already " << (enabled ? "enabled" : "disabled"));
return;
}
LOG_INFO("Setting One Bud ANC mode to: " << (enabled ? "enabled" : "disabled"));
QByteArray packet = enabled ? AirPodsPackets::OneBudANCMode::ENABLED
: AirPodsPackets::OneBudANCMode::DISABLED;
if (writePacketToSocket(packet, "One Bud ANC mode packet written: "))
{
m_deviceInfo->setOneBudANCMode(enabled);
}
else
{
LOG_ERROR("Failed to send One Bud ANC mode command: socket not open");
}
}
void setRetryAttempts(int attempts)
@@ -252,12 +222,11 @@ public slots:
void setAdaptiveNoiseLevel(int level)
{
level = qBound(0, level, 100);
if (m_adaptiveNoiseLevel != level && adaptiveModeActive())
if (m_deviceInfo->adaptiveNoiseLevel() != level && m_deviceInfo->adaptiveModeActive())
{
m_adaptiveNoiseLevel = level;
QByteArray packet = AirPodsPackets::AdaptiveNoise::getPacket(level);
writePacketToSocket(packet, "Adaptive noise level packet written: ");
emit adaptiveNoiseLevelChanged(level);
m_deviceInfo->setAdaptiveNoiseLevel(level);
}
}
@@ -273,7 +242,7 @@ public slots:
LOG_WARN("Name is too long, must be 32 characters or less");
return;
}
if (newName == m_deviceName)
if (newName == m_deviceInfo->deviceName())
{
LOG_INFO("Name is already set to: " << newName);
return;
@@ -283,8 +252,7 @@ public slots:
if (writePacketToSocket(packet, "Rename packet written: "))
{
LOG_INFO("Sent rename command for new name: " << newName);
m_deviceName = newName;
emit deviceNameChanged(newName);
m_deviceInfo->setDeviceName(newName);
}
else
{
@@ -388,7 +356,7 @@ private slots:
writePacketToSocket(AirPodsPackets::Connection::HANDSHAKE, "Handshake packet written: ");
}
void bluezDeviceConnected(const QString &address, const QString &name)
void bluezDeviceConnected(const QString &address, const QString &name)
{
QBluetoothDeviceInfo device(QBluetoothAddress(address), name, 0);
connectToDevice(device);
@@ -410,45 +378,22 @@ private slots:
}
// Clear the device name and model
m_deviceName.clear();
connectedDeviceMacAddress.clear();
mediaController->setConnectedDeviceMacAddress(connectedDeviceMacAddress);
m_model = AirPodsModel::Unknown;
emit deviceNameChanged(m_deviceName);
emit modelChanged();
// Reset battery status
m_battery->reset();
m_batteryStatus.clear();
emit batteryStatusChanged(m_batteryStatus);
// Reset ear detection
m_earDetectionStatus.clear();
m_primaryInEar = false;
m_secoundaryInEar = false;
emit earDetectionStatusChanged(m_earDetectionStatus);
emit primaryChanged();
// Reset noise control mode
m_noiseControlMode = NoiseControlMode::Off;
emit noiseControlModeChanged(m_noiseControlMode);
mediaController->pause(); // Since the device is deconnected, we don't know if it was the active output device. Pause to be safe
emit airPodsStatusChanged();
m_deviceInfo->reset();
// Show system notification
trayManager->showNotification(
tr("AirPods Disconnected"),
tr("Your AirPods have been disconnected"));
trayManager->resetTrayIcon();
}
void bluezDeviceDisconnected(const QString &address, const QString &name)
{
if (address == connectedDeviceMacAddress.replace("_", ":"))
if (address == m_deviceInfo->bluetoothAddress())
{
onDeviceDisconnected(QBluetoothAddress(address)); }
else {
LOG_WARN("Disconnected device does not match connected device: " << address << " != " << connectedDeviceMacAddress);
onDeviceDisconnected(QBluetoothAddress(address));
} else {
LOG_WARN("Disconnected device does not match connected device: " << address << " != " << m_deviceInfo->bluetoothAddress());
}
}
@@ -490,43 +435,18 @@ private slots:
return str;
};
m_deviceName = extractString();
QString modelNumber = extractString();
QString manufacturer = extractString();
QString hardwareVersion = extractString();
QString firmwareVersion = extractString();
QString firmwareVersion2 = extractString();
QString softwareVersion = extractString();
QString appIdentifier = extractString();
QString serialNumber1 = extractString();
QString serialNumber2 = extractString();
QString unknownNumeric = extractString();
QString unknownHash = extractString();
QString trailingByte = extractString();
m_model = parseModelNumber(modelNumber);
m_deviceInfo->setDeviceName(extractString());
m_deviceInfo->setModelNumber(extractString());
m_deviceInfo->setManufacturer(extractString());
m_deviceInfo->setModel(parseModelNumber(m_deviceInfo->modelNumber()));
emit modelChanged();
m_model = parseModelNumber(modelNumber);
emit modelChanged();
emit deviceNameChanged(m_deviceName);
// Log extracted metadata
LOG_INFO("Parsed AirPods metadata:");
LOG_INFO("Device Name: " << m_deviceName);
LOG_INFO("Model Number: " << modelNumber);
LOG_INFO("Manufacturer: " << manufacturer);
LOG_INFO("Hardware Version: " << hardwareVersion);
LOG_INFO("Firmware Version: " << firmwareVersion);
LOG_INFO("Firmware Version2: " << firmwareVersion2);
LOG_INFO("Software Version: " << softwareVersion);
LOG_INFO("App Identifier: " << appIdentifier);
LOG_INFO("Serial Number 1: " << serialNumber1);
LOG_INFO("Serial Number 2: " << serialNumber2);
LOG_INFO("Unknown Numeric: " << unknownNumeric);
LOG_INFO("Unknown Hash: " << unknownHash);
LOG_INFO("Trailing Byte: " << trailingByte);
LOG_INFO("Device Name: " << m_deviceInfo->deviceName());
LOG_INFO("Model Number: " << m_deviceInfo->modelNumber());
LOG_INFO("Manufacturer: " << m_deviceInfo->manufacturer());
}
QString getEarStatus(char value)
@@ -580,7 +500,7 @@ private slots:
QTimer::singleShot(1500, this, [this, device]()
{ connectToDevice(device); });
}
else
else
{
LOG_ERROR("Failed to connect after 3 attempts");
retryCount = 0;
@@ -592,7 +512,7 @@ private slots:
this, handleError);
localSocket->connectToService(device.address(), QBluetoothUuid("74ec2172-0bad-4d01-8f77-997b2be0722a"));
connectedDeviceMacAddress = device.address().toString().replace(":", "_");
m_deviceInfo->setBluetoothAddress(device.address().toString());
notifyAndroidDevice();
}
@@ -609,7 +529,7 @@ private slots:
writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: ");
QTimer::singleShot(2000, this, [this]() {
if (m_batteryStatus.isEmpty()) {
if (m_deviceInfo->batteryStatus().isEmpty()) {
writePacketToSocket(AirPodsPackets::Connection::REQUEST_NOTIFICATIONS, "Request notifications packet written: ");
}
});
@@ -622,34 +542,26 @@ private slots:
LOG_INFO("MagicAccIRK: " << keys.magicAccIRK.toHex());
LOG_INFO("MagicAccEncKey: " << keys.magicAccEncKey.toHex());
// Store the keys for later use if needed
m_magicAccIRK = keys.magicAccIRK;
m_magicAccEncKey = keys.magicAccEncKey;
// Store the keys
m_deviceInfo->setMagicAccIRK(keys.magicAccIRK);
m_deviceInfo->setMagicAccEncKey(keys.magicAccEncKey);
}
// Get CA state
else if (data.startsWith(AirPodsPackets::ConversationalAwareness::HEADER)) {
auto result = AirPodsPackets::ConversationalAwareness::parseCAState(data);
if (result.has_value()) {
m_conversationalAwareness = result.value();
LOG_INFO("Conversational awareness state received: " << m_conversationalAwareness);
emit conversationalAwarenessChanged(m_conversationalAwareness);
} else {
LOG_ERROR("Failed to parse conversational awareness state");
if (auto result = AirPodsPackets::ConversationalAwareness::parseState(data))
{
m_deviceInfo->setConversationalAwareness(result.value());
LOG_INFO("Conversational awareness state received: " << m_deviceInfo->conversationalAwareness());
}
}
// Noise Control Mode
else if (data.size() == 11 && data.startsWith(AirPodsPackets::NoiseControl::HEADER))
{
quint8 rawMode = data[7] - 1; // Offset still needed due to protocol
if (rawMode >= (int)NoiseControlMode::MinValue && rawMode <= (int)NoiseControlMode::MaxValue)
if (auto value = AirPodsPackets::NoiseControl::parseMode(data))
{
m_noiseControlMode = static_cast<NoiseControlMode>(rawMode);
LOG_INFO("Noise control mode: " << rawMode);
emit noiseControlModeChanged(m_noiseControlMode);
}
else
{
LOG_ERROR("Invalid noise control mode value received: " << rawMode);
LOG_INFO("Received noise control mode: " << value.value());
m_deviceInfo->setNoiseControlMode(value.value());
LOG_INFO("Noise control mode received: " << m_deviceInfo->noiseControlMode());
}
}
// Ear Detection
@@ -657,28 +569,25 @@ private slots:
{
char primary = data[6];
char secondary = data[7];
m_primaryInEar = data[6] == 0x00;
m_secoundaryInEar = data[7] == 0x00;
m_earDetectionStatus = QString("Primary: %1, Secondary: %2")
.arg(getEarStatus(primary), getEarStatus(secondary));
LOG_INFO("Ear detection status: " << m_earDetectionStatus);
emit earDetectionStatusChanged(m_earDetectionStatus);
emit primaryChanged();
m_deviceInfo->setPrimaryInEar(data[6] == 0x00);
m_deviceInfo->setSecondaryInEar(data[7] == 0x00);
m_deviceInfo->setEarDetectionStatus(QString("Primary: %1, Secondary: %2")
.arg(getEarStatus(primary), getEarStatus(secondary)));
LOG_INFO("Ear detection status: " << m_deviceInfo->earDetectionStatus());
}
// Battery Status
else if (data.size() == 22 && data.startsWith(AirPodsPackets::Parse::BATTERY_STATUS))
{
m_battery->parsePacket(data);
m_deviceInfo->getBattery()->parsePacket(data);
int leftLevel = m_battery->getState(Battery::Component::Left).level;
int rightLevel = m_battery->getState(Battery::Component::Right).level;
int caseLevel = m_battery->getState(Battery::Component::Case).level;
m_batteryStatus = QString("Left: %1%, Right: %2%, Case: %3%")
.arg(leftLevel)
.arg(rightLevel)
.arg(caseLevel);
LOG_INFO("Battery status: " << m_batteryStatus);
emit batteryStatusChanged(m_batteryStatus);
int leftLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Left).level;
int rightLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Right).level;
int caseLevel = m_deviceInfo->getBattery()->getState(Battery::Component::Case).level;
m_deviceInfo->setBatteryStatus(QString("Left: %1%, Right: %2%, Case: %3%")
.arg(leftLevel)
.arg(rightLevel)
.arg(caseLevel));
LOG_INFO("Battery status: " << m_deviceInfo->batteryStatus());
}
// Conversational Awareness Data
else if (data.size() == 10 && data.startsWith(AirPodsPackets::ConversationalAwareness::DATA_HEADER))
@@ -690,13 +599,20 @@ private slots:
{
parseMetadata(data);
initiateMagicPairing();
mediaController->setConnectedDeviceMacAddress(connectedDeviceMacAddress);
if (isLeftPodInEar() || isRightPodInEar()) // AirPods get added as output device only after this
mediaController->setConnectedDeviceMacAddress(m_deviceInfo->bluetoothAddress().replace(":", "_"));
if (m_deviceInfo->oneOrMorePodsInEar()) // AirPods get added as output device only after this
{
mediaController->activateA2dpProfile();
}
emit airPodsStatusChanged();
}
else if (data.startsWith(AirPodsPackets::OneBudANCMode::HEADER)) {
if (auto value = AirPodsPackets::OneBudANCMode::parseState(data))
{
m_deviceInfo->setOneBudANCMode(value.value());
LOG_INFO("One Bud ANC mode received: " << m_deviceInfo->oneBudANCMode());
}
}
else
{
LOG_DEBUG("Unrecognized packet format: " << data.toHex());
@@ -792,7 +708,7 @@ private slots:
socket->close();
LOG_INFO("Disconnected from AirPods");
QProcess process;
process.start("bluetoothctl", QStringList() << "disconnect" << connectedDeviceMacAddress.replace("_", ":"));
process.start("bluetoothctl", QStringList() << "disconnect" << m_deviceInfo->bluetoothAddress());
process.waitForFinished();
QString output = process.readAllStandardOutput().trimmed();
LOG_INFO("Bluetoothctl output: " << output);
@@ -817,14 +733,14 @@ private slots:
QMetaObject::invokeMethod(this, "handlePhonePacket", Qt::QueuedConnection, Q_ARG(QByteArray, data));
}
public:
void handleMediaStateChange(MediaController::MediaState state) {
if (state == MediaController::MediaState::Playing) {
LOG_INFO("Media started playing, sending disconnect request to Android and taking over audio");
sendDisconnectRequestToAndroid();
connectToAirPods(true);
}
public:
void handleMediaStateChange(MediaController::MediaState state) {
if (state == MediaController::MediaState::Playing) {
LOG_INFO("Media started playing, sending disconnect request to Android and taking over audio");
sendDisconnectRequestToAndroid();
connectToAirPods(true);
}
}
void sendDisconnectRequestToAndroid()
{
@@ -854,13 +770,13 @@ private slots:
if (force) {
LOG_INFO("Forcing connection to AirPods");
QProcess process;
process.start("bluetoothctl", QStringList() << "connect" << connectedDeviceMacAddress.replace("_", ":"));
process.start("bluetoothctl", QStringList() << "connect" << m_deviceInfo->bluetoothAddress());
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(connectedDeviceMacAddress.replace("_", ":"));
QBluetoothAddress btAddress(m_deviceInfo->bluetoothAddress());
forceL2capConnection(btAddress);
} else {
LOG_ERROR("Connection failed, cannot proceed with L2CAP connection");
@@ -926,11 +842,11 @@ signals:
void crossDeviceEnabledChanged(bool enabled);
void notificationsEnabledChanged(bool enabled);
void retryAttemptsChanged(int attempts);
void oneBudANCModeChanged(bool enabled);
private:
QBluetoothSocket *socket = nullptr;
QBluetoothSocket *phoneSocket = nullptr;
QString connectedDeviceMacAddress;
QByteArray lastBatteryStatus;
QByteArray lastEarDetectionStatus;
MediaController* mediaController;
@@ -940,19 +856,7 @@ private:
AutoStartManager *m_autoStartManager;
int m_retryAttempts = 3;
bool m_hideOnStart = false;
QString m_batteryStatus;
QString m_earDetectionStatus;
NoiseControlMode m_noiseControlMode = NoiseControlMode::Off;
bool m_conversationalAwareness = false;
int m_adaptiveNoiseLevel = 50;
QString m_deviceName;
Battery *m_battery;
AirPodsModel m_model = AirPodsModel::Unknown;
bool m_primaryInEar = false;
bool m_secoundaryInEar = false;
QByteArray m_magicAccIRK;
QByteArray m_magicAccEncKey;
DeviceInfo *m_deviceInfo;
};
int main(int argc, char *argv[]) {
@@ -997,6 +901,7 @@ int main(int argc, char *argv[]) {
QQmlApplicationEngine engine;
qmlRegisterType<Battery>("me.kavishdevar.Battery", 1, 0, "Battery");
qmlRegisterType<DeviceInfo>("me.kavishdevar.DeviceInfo", 1, 0, "DeviceInfo");
AirPodsTrayApp *trayApp = new AirPodsTrayApp(debugMode, hideOnStart, &engine);
engine.rootContext()->setContextProperty("airPodsTrayApp", trayApp);
trayApp->loadMainModule();

View File

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

6
update_nonpatch.json Normal file
View File

@@ -0,0 +1,6 @@
{
"version": "v0.1.0-rc.4",
"versionCode": 3,
"zipUrl": "https://github.com/kavishdevar/librepods/releases/download/v0.1.0-rc.4/LibrePods-v0.1.0-rc.4.zip",
"changelog": "https://raw.githubusercontent.com/kavishdevar/librepods/main/CHANGELOG.md"
}