diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 114ab39..269374c 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -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"))))
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index ced71e7..f3f1867 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -31,7 +31,8 @@
-
+
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt
index c6d1214..c865f9b 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt
@@ -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,
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt
index 929d45d..75cbd2a 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt
@@ -16,10 +16,10 @@
* along with this program. If not, see .
*/
+@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(
+ 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()
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt
index 56e5295..ad6dc8d 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt
@@ -1,25 +1,25 @@
/*
* 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 .
*/
+@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))
-}
\ No newline at end of file
+ AdaptiveStrengthSlider()
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
index 157e597..5e7cf63 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
@@ -1,25 +1,25 @@
/*
* 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 .
*/
+@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()
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt
index dd2cfa5..bab51b1 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt
@@ -1,21 +1,23 @@
/*
* 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 .
*/
+@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) {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt
index a25277b..7a1f335 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt
@@ -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) {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt
index de2f8ec..8c2aa7d 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt
@@ -16,9 +16,10 @@
* along with this program. If not, see .
*/
+@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()
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt
index f310f29..421c920 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt
@@ -16,6 +16,8 @@
* along with this program. If not, see .
*/
+@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()
},
)
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt
deleted file mode 100644
index 2f971f9..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt
+++ /dev/null
@@ -1,126 +0,0 @@
-/*
- * 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 .
- */
-
-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))
-}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt
index 4b7b111..7720c08 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt
@@ -1,21 +1,23 @@
/*
* 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 .
*/
+@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()) {}
-}
\ No newline at end of file
+ NoiseControlSettings(AirPodsService())
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt
deleted file mode 100644
index 31379dc..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt
+++ /dev/null
@@ -1,126 +0,0 @@
-/*
- * 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 .
- */
-
-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))
-}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt
index 14410c7..370be0d 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt
@@ -1,24 +1,25 @@
/*
* 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 .
*/
+@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))
-}
\ No newline at end of file
+ SinglePodANCSwitch()
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt
index 69d5edb..38e190e 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt
@@ -16,9 +16,11 @@
* along with this program. If not, see .
*/
+@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()
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/TransparencySettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/TransparencySettings.kt
deleted file mode 100644
index 3eece59..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/TransparencySettings.kt
+++ /dev/null
@@ -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)
- )
- }
-}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt
index 1acbef4..41bc9cc 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt
@@ -1,24 +1,25 @@
/*
* 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 .
*/
+@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))
-}
\ No newline at end of file
+ VolumeControlSwitch()
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt b/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt
index d7a1a23..7a240c6 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt
@@ -16,11 +16,14 @@
* along with this program. If not, see .
*/
+@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() {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
index 149e987..ce325d5 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
@@ -16,6 +16,8 @@
* along with this program. If not, see .
*/
+@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)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt
index ad0f8a6..30120be 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt
@@ -119,7 +119,29 @@ 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) }
+
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -607,6 +629,299 @@ 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)
+ )
+
+ // Disconnected
+ 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()
+ }
+ )
+ }
+
+ // Idle
+ 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()
+ }
+ )
+ }
+
+ // Music
+ 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()
+ }
+ )
+ }
+
+ // Call
+ 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)
+ )
+
+ // Ringing Call
+ 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()
+ }
+ )
+ }
+
+ // Media Start
+ 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(
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt
index aba8ba6..76e0d88 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt
@@ -16,7 +16,7 @@
* along with this program. If not, see .
*/
-@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()
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt
index 810acef..e7039de 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt
@@ -16,6 +16,8 @@
* along with this program. If not, see .
*/
+@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
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt
index 78f92f1..bb8668e 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt
@@ -16,6 +16,8 @@
* along with this program. If not, see .
*/
+@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, 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, 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, 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),
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt
index a5adb02..9601e93 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt
@@ -16,6 +16,8 @@
* along with this program. If not, see .
*/
+@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))
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt
index b44ac3e..a30a5ef 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt
@@ -15,7 +15,9 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
-
+
+@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")
- }
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
index e7fb971..7dd8ab3 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
@@ -16,6 +16,8 @@
* along with this program. If not, see .
*/
+@file:OptIn(ExperimentalEncodingApi::class)
+
package me.kavishdevar.librepods.services
import android.Manifest
@@ -75,18 +77,19 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AirPodsNotifications
+import me.kavishdevar.librepods.utils.BLEManager
import me.kavishdevar.librepods.utils.Battery
import me.kavishdevar.librepods.utils.BatteryComponent
import me.kavishdevar.librepods.utils.BatteryStatus
+import me.kavishdevar.librepods.utils.BluetoothConnectionManager
import me.kavishdevar.librepods.utils.CrossDevice
import me.kavishdevar.librepods.utils.CrossDevicePackets
-import me.kavishdevar.librepods.utils.Enums
import me.kavishdevar.librepods.utils.GestureDetector
import me.kavishdevar.librepods.utils.HeadTracking
import me.kavishdevar.librepods.utils.IslandType
import me.kavishdevar.librepods.utils.IslandWindow
-import me.kavishdevar.librepods.utils.LongPressPackets
import me.kavishdevar.librepods.utils.MediaController
import me.kavishdevar.librepods.utils.PopupWindow
import me.kavishdevar.librepods.utils.SystemApisUtils
@@ -114,63 +117,53 @@ import me.kavishdevar.librepods.widgets.NoiseControlWidget
import org.lsposed.hiddenapibypass.HiddenApiBypass
import java.nio.ByteBuffer
import java.nio.ByteOrder
+import kotlin.io.encoding.Base64
+import kotlin.io.encoding.ExperimentalEncodingApi
object ServiceManager {
+ @ExperimentalEncodingApi
private var service: AirPodsService? = null
+ @ExperimentalEncodingApi
@Synchronized
fun getService(): AirPodsService? {
return service
}
+ @ExperimentalEncodingApi
@Synchronized
fun setService(service: AirPodsService?) {
this.service = service
}
-
- @OptIn(ExperimentalMaterial3Api::class)
- @Synchronized
- fun restartService(context: Context) {
- service?.stopSelf()
- Log.d("ServiceManager", "Restarting service, service is null: ${service == null}")
- val intent = Intent(context, AirPodsService::class.java)
- context.stopService(intent)
- CoroutineScope(Dispatchers.IO).launch {
- delay(1000)
- context.startService(intent)
- context.startActivity(Intent(context, MainActivity::class.java))
- service?.clearLogs()
- }
- }
}
// @Suppress("unused")
+@ExperimentalEncodingApi
class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener {
var macAddress = ""
+ lateinit var aacpManager: AACPManager
data class ServiceConfig(
var deviceName: String = "AirPods",
var earDetectionEnabled: Boolean = true,
var conversationalAwarenessPauseMusic: Boolean = false,
- var personalizedVolume: Boolean = false,
- var longPressNC: Boolean = true,
- var offListeningMode: Boolean = false,
var showPhoneBatteryInWidget: Boolean = true,
- var singleANC: Boolean = true,
- var longPressTransparency: Boolean = true,
- var conversationalAwareness: Boolean = true,
var relativeConversationalAwarenessVolume: Boolean = true,
- var longPressAdaptive: Boolean = true,
- var loudSoundReduction: Boolean = true,
- var longPressOff: Boolean = false,
- var volumeControl: Boolean = true,
var headGestures: Boolean = true,
var disconnectWhenNotWearing: Boolean = false,
- var adaptiveStrength: Int = 51,
- var toneVolume: Int = 75,
var conversationalAwarenessVolume: Int = 43,
var textColor: Long = -1L,
- var qsClickBehavior: String = "cycle"
+ var qsClickBehavior: String = "cycle",
+
+ // AirPods state-based takeover
+ var takeoverWhenDisconnected: Boolean = true,
+ var takeoverWhenIdle: Boolean = true,
+ var takeoverWhenMusic: Boolean = false,
+ var takeoverWhenCall: Boolean = true,
+
+ // Phone state-based takeover
+ var takeoverWhenRingingCall: Boolean = true,
+ var takeoverWhenMediaStart: Boolean = true
)
private lateinit var config: ServiceConfig
@@ -190,6 +183,54 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private val maxLogEntries = 1000
private val inMemoryLogs = mutableSetOf()
+ lateinit var bleManager: BLEManager
+ private val bleStatusListener = object : BLEManager.AirPodsStatusListener {
+ @SuppressLint("NewApi")
+ override fun onDeviceStatusChanged(
+ device: BLEManager.AirPodsStatus,
+ previousStatus: BLEManager.AirPodsStatus?
+ ) {
+ if (device.connectionState == "Disconnected") {
+ Log.d("AirPodsBLEService", "Seems no device has taken over, we will.")
+ val bluetoothManager = getSystemService(BluetoothManager::class.java)
+ val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(sharedPreferences.getString(
+ "mac_address", "") ?: "")
+ connectToSocket(bluetoothDevice)
+ }
+ Log.d("AirPodsBLEService", "Device status changed, inEar: ${device.isLeftInEar}, ${device.isRightInEar}")
+ }
+
+ override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) {
+ Log.d("AirPodsService", "New address detected")
+ }
+
+ override fun onLidStateChanged(
+ lidOpen: Boolean,
+ ) {
+ if (lidOpen) {
+ Log.d("AirPodsBLEService", "Lid opened")
+ showPopup(
+ this@AirPodsService,
+ getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro") ?: "AirPods"
+ )
+ } else {
+ Log.d("AirPodsBLEService", "Lid closed")
+ }
+ }
+
+ override fun onEarStateChanged(
+ device: BLEManager.AirPodsStatus,
+ leftInEar: Boolean,
+ rightInEar: Boolean
+ ) {
+ Log.d("AirPodsBLEService", "Ear state changed")
+ }
+
+ override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
+ Log.d("AirPodsBLEService", "Battery changed")
+ }
+
+ }
override fun onCreate() {
super.onCreate()
sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE)
@@ -200,33 +241,233 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
initializeConfig()
+ aacpManager = AACPManager()
+ initializeAACPManagerCallback()
+
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
}
+ @ExperimentalEncodingApi
+ private fun initializeAACPManagerCallback() {
+ aacpManager.setPacketCallback(object : AACPManager.PacketCallback {
+ @SuppressLint("MissingPermission")
+ override fun onBatteryInfoReceived(batteryInfo: ByteArray) {
+ batteryNotification.setBattery(batteryInfo)
+ sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
+ putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery()))
+ })
+ updateBattery()
+ updateNotificationContent(
+ true,
+ this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE)
+ .getString("name", device?.name),
+ batteryNotification.getBattery()
+ )
+ CrossDevice.sendRemotePacket(batteryInfo)
+ CrossDevice.batteryBytes = batteryInfo
+
+ for (battery in batteryNotification.getBattery()) {
+ Log.d(
+ "AirPodsParser",
+ "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% "
+ )
+ }
+
+ if (batteryNotification.getBattery()[0].status == BatteryStatus.CHARGING && batteryNotification.getBattery()[1].status == BatteryStatus.CHARGING) {
+ disconnectAudio(this@AirPodsService, device)
+ } else {
+ connectAudio(this@AirPodsService, device)
+ }
+ }
+
+ override fun onEarDetectionReceived(earDetection: ByteArray) {
+ sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply {
+ val list = earDetectionNotification.status
+ val bytes = ByteArray(2)
+ bytes[0] = list[0]
+ bytes[1] = list[1]
+ putExtra("data", bytes)
+ })
+ Log.d(
+ "AirPodsParser",
+ "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}"
+ )
+ processEarDetectionChange(earDetection)
+ }
+
+ override fun onConversationAwarenessReceived(conversationAwareness: ByteArray) {
+ conversationAwarenessNotification.setData(conversationAwareness)
+ sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply {
+ putExtra("data", conversationAwarenessNotification.status)
+ })
+
+ if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) {
+ MediaController.startSpeaking()
+ } else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) {
+ MediaController.stopSpeaking()
+ }
+
+ Log.d(
+ "AirPodsParser",
+ "Conversation Awareness: ${conversationAwarenessNotification.status}"
+ )
+ }
+
+ override fun onControlCommandReceived(controlCommand: ByteArray) {
+ val command = AACPManager.ControlCommand.fromByteArray(controlCommand)
+ if (command.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value) {
+ ancNotification.setStatus(byteArrayOf(command.value.takeIf { it.isNotEmpty() }?.get(0) ?: 0x00.toByte()))
+ sendANCBroadcast()
+ updateNoiseControlWidget()
+ }
+ }
+
+ override fun onDeviceMetadataReceived(deviceMetadata: ByteArray) {
+
+ }
+
+ @SuppressLint("NewApi")
+ override fun onHeadTrackingReceived(headTracking: ByteArray) {
+ if (isHeadTrackingActive) {
+ HeadTracking.processPacket(headTracking)
+ processHeadTrackingData(headTracking)
+ }
+ }
+
+ override fun onProximityKeysReceived(proximityKeys: ByteArray) {
+ val keys = aacpManager.parseProximityKeysResponse(proximityKeys)
+ Log.d("AirPodsParser", "Proximity keys: $keys")
+ sharedPreferences.edit {
+ for (key in keys) {
+ Log.d("AirPodsParser", "Proximity key: ${key.key.name} = ${key.value}")
+ putString(key.key.name, Base64.encode(key.value))
+ }
+ }
+ }
+
+ override fun onUnknownPacketReceived(packet: ByteArray) {
+ Log.d("AACPManager", "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}")
+ }
+ })
+ }
+
+ private fun processEarDetectionChange(earDetection: ByteArray) {
+ var inEar = false
+ var inEarData = listOf(earDetectionNotification.status[0] == 0x00.toByte(), earDetectionNotification.status[1] == 0x00.toByte())
+ var justEnabledA2dp = false
+ earDetectionNotification.setStatus(earDetection)
+ if (config.earDetectionEnabled) {
+ val data = earDetection.copyOfRange(earDetection.size - 2, earDetection.size)
+ inEar = data[0] == 0x00.toByte() && data[1] == 0x00.toByte()
+
+ val newInEarData = listOf(
+ data[0] == 0x00.toByte(),
+ data[1] == 0x00.toByte()
+ )
+
+ if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf(false, false) && islandWindow?.isVisible != true) {
+ showIsland(
+ this@AirPodsService,
+ (batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0))
+ }
+
+ if (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) {
+ islandWindow?.close()
+ }
+
+ if (newInEarData.contains(true) && inEarData == listOf(false, false)) {
+ connectAudio(this@AirPodsService, device)
+ justEnabledA2dp = true
+ registerA2dpConnectionReceiver()
+ } else if (newInEarData == listOf(false, false)) {
+ MediaController.sendPause(force = true)
+ if (config.disconnectWhenNotWearing) {
+ disconnectAudio(this@AirPodsService, device)
+ }
+ }
+
+ if (inEarData.contains(false) && newInEarData == listOf(true, true)) {
+ Log.d("AirPodsParser", "User put in both AirPods from just one.")
+ MediaController.userPlayedTheMedia = false
+ }
+
+ if (newInEarData.contains(false) && inEarData == listOf(true, true)) {
+ Log.d("AirPodsParser", "User took one of two out.")
+ MediaController.userPlayedTheMedia = false
+ }
+
+ Log.d("AirPodsParser", "inEarData: ${inEarData.sorted()}, newInEarData: ${newInEarData.sorted()}")
+
+ if (newInEarData.sorted() != inEarData.sorted()) {
+ inEarData = newInEarData
+
+ if (inEar == true) {
+ if (!justEnabledA2dp) {
+ justEnabledA2dp = false
+ MediaController.sendPlay()
+ MediaController.iPausedTheMedia = false
+ }
+ } else {
+ MediaController.sendPause()
+ }
+ }
+ }
+ }
+
+ private fun registerA2dpConnectionReceiver() {
+ val a2dpConnectionStateReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == "android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") {
+ val state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED)
+ val previousState = intent.getIntExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, BluetoothProfile.STATE_DISCONNECTED)
+ val device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
+
+ Log.d("MediaController", "A2DP state changed: $previousState -> $state for device: ${device?.address}")
+
+ if (state == BluetoothProfile.STATE_CONNECTED &&
+ previousState != BluetoothProfile.STATE_CONNECTED &&
+ device?.address == this@AirPodsService.device?.address) {
+
+ Log.d("MediaController", "A2DP connected, sending play command")
+ MediaController.sendPlay()
+ MediaController.iPausedTheMedia = false
+
+ context.unregisterReceiver(this)
+ }
+ }
+ }
+ }
+
+ val a2dpIntentFilter = IntentFilter("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter, RECEIVER_EXPORTED)
+ } else {
+ registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter)
+ }
+ }
+
private fun initializeConfig() {
config = ServiceConfig(
deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods",
earDetectionEnabled = sharedPreferences.getBoolean("automatic_ear_detection", true),
conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false),
- personalizedVolume = sharedPreferences.getBoolean("personalized_volume", false),
- longPressNC = sharedPreferences.getBoolean("long_press_nc", true),
- offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false),
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", true),
- singleANC = sharedPreferences.getBoolean("single_anc", true),
- longPressTransparency = sharedPreferences.getBoolean("long_press_transparency", true),
- conversationalAwareness = sharedPreferences.getBoolean("conversational_awareness", true),
relativeConversationalAwarenessVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", true),
- longPressAdaptive = sharedPreferences.getBoolean("long_press_adaptive", true),
- loudSoundReduction = sharedPreferences.getBoolean("loud_sound_reduction", true),
- longPressOff = sharedPreferences.getBoolean("long_press_off", false),
- volumeControl = sharedPreferences.getBoolean("volume_control", true),
headGestures = sharedPreferences.getBoolean("head_gestures", true),
disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false),
- adaptiveStrength = sharedPreferences.getInt("adaptive_strength", 51),
- toneVolume = sharedPreferences.getInt("tone_volume", 75),
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43),
textColor = sharedPreferences.getLong("textColor", -1L),
- qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle"
+ qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle",
+
+ // AirPods state-based takeover
+ takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", true),
+ takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", true),
+ takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false),
+ takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", true),
+
+ // Phone state-based takeover
+ takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", true),
+ takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", true)
)
}
@@ -237,32 +478,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
"name" -> config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods"
"automatic_ear_detection" -> config.earDetectionEnabled = preferences.getBoolean(key, true)
"conversational_awareness_pause_music" -> config.conversationalAwarenessPauseMusic = preferences.getBoolean(key, false)
- "personalized_volume" -> config.personalizedVolume = preferences.getBoolean(key, false)
- "long_press_nc" -> config.longPressNC = preferences.getBoolean(key, true)
- "off_listening_mode" -> {
- config.offListeningMode = preferences.getBoolean(key, false)
- updateNoiseControlWidget()
- }
"show_phone_battery_in_widget" -> {
config.showPhoneBatteryInWidget = preferences.getBoolean(key, true)
widgetMobileBatteryEnabled = config.showPhoneBatteryInWidget
updateBattery()
- }
- "single_anc" -> config.singleANC = preferences.getBoolean(key, true)
- "long_press_transparency" -> config.longPressTransparency = preferences.getBoolean(key, true)
- "conversational_awareness" -> config.conversationalAwareness = preferences.getBoolean(key, true)
- "relative_conversational_awareness_volume" -> config.relativeConversationalAwarenessVolume = preferences.getBoolean(key, true)
- "long_press_adaptive" -> config.longPressAdaptive = preferences.getBoolean(key, true)
- "loud_sound_reduction" -> config.loudSoundReduction = preferences.getBoolean(key, true)
- "long_press_off" -> config.longPressOff = preferences.getBoolean(key, false)
- "volume_control" -> config.volumeControl = preferences.getBoolean(key, true)
+ } "relative_conversational_awareness_volume" -> config.relativeConversationalAwarenessVolume = preferences.getBoolean(key, true)
"head_gestures" -> config.headGestures = preferences.getBoolean(key, true)
"disconnect_when_not_wearing" -> config.disconnectWhenNotWearing = preferences.getBoolean(key, false)
- "adaptive_strength" -> config.adaptiveStrength = preferences.getInt(key, 51)
- "tone_volume" -> config.toneVolume = preferences.getInt(key, 75)
"conversational_awareness_volume" -> config.conversationalAwarenessVolume = preferences.getInt(key, 43)
"textColor" -> config.textColor = preferences.getLong(key, -1L)
"qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle"
+
+ // AirPods state-based takeover
+ "takeover_when_disconnected" -> config.takeoverWhenDisconnected = preferences.getBoolean(key, true)
+ "takeover_when_idle" -> config.takeoverWhenIdle = preferences.getBoolean(key, true)
+ "takeover_when_music" -> config.takeoverWhenMusic = preferences.getBoolean(key, false)
+ "takeover_when_call" -> config.takeoverWhenCall = preferences.getBoolean(key, true)
+
+ // Phone state-based takeover
+ "takeover_when_ringing_call" -> config.takeoverWhenRingingCall = preferences.getBoolean(key, true)
+ "takeover_when_media_start" -> config.takeoverWhenMediaStart = preferences.getBoolean(key, true)
}
if (key == "mac_address") {
@@ -277,7 +512,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
synchronized(inMemoryLogs) {
inMemoryLogs.add(logEntry)
if (inMemoryLogs.size > maxLogEntries) {
- inMemoryLogs.iterator().next()?.let {
+ inMemoryLogs.iterator().next().let {
inMemoryLogs.remove(it)
}
}
@@ -333,7 +568,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var popupShown = false
-
fun showPopup(service: Service, name: String) {
if (!Settings.canDrawOverlays(service)) {
Log.d("AirPodsService", "No permission for SYSTEM_ALERT_WINDOW")
@@ -346,6 +580,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
popupWindow.open(name, batteryNotification)
popupShown = true
}
+
var islandOpen = false
var islandWindow: IslandWindow? = null
@SuppressLint("MissingPermission")
@@ -368,46 +603,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
startActivity(intent)
}
- @Suppress("ClassName")
- private object bluetoothReceiver : BroadcastReceiver() {
- @SuppressLint("MissingPermission")
- override fun onReceive(context: Context?, intent: Intent) {
- val bluetoothDevice =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- intent.getParcelableExtra(
- "android.bluetooth.device.extra.DEVICE",
- BluetoothDevice::class.java
- )
- } else {
- intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE") as BluetoothDevice?
- }
- val action = intent.action
- val context = context?.applicationContext
- val name = context?.getSharedPreferences("settings", MODE_PRIVATE)
- ?.getString("name", bluetoothDevice?.name)
- if (bluetoothDevice != null && action != null && !action.isEmpty()) {
- Log.d("AirPodsService", "Received bluetooth connection broadcast")
- if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
- if (ServiceManager.getService()?.isConnectedLocally == true) {
- ServiceManager.getService()?.manuallyCheckForAudioSource()
- return
- }
- val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
- bluetoothDevice.fetchUuidsWithSdp()
- if (bluetoothDevice.uuids != null) {
- if (bluetoothDevice.uuids.contains(uuid)) {
- val intent =
- Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
- intent.putExtra("name", name)
- intent.putExtra("device", bluetoothDevice)
- context?.sendBroadcast(intent)
- }
- }
- }
- }
- }
- }
-
var isConnectedLocally = false
var device: BluetoothDevice? = null
@@ -463,14 +658,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.createNotificationChannel(connectedNotificationChannel)
notificationManager.createNotificationChannel(socketFailureChannel)
- val notificationIntent = Intent(this, MainActivity::class.java)
- val pendingIntent = PendingIntent.getActivity(
- this,
- 0,
- notificationIntent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
- )
-
val notificationSettingsIntent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, "background_service_status")
@@ -678,9 +865,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
(if (batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.status == BatteryStatus.CHARGING) "1".toByteArray() else "0".toByteArray())
)
}
-
- // broadcast
-// broadcastBatteryInformation()
}
fun updateNoiseControlWidget() {
@@ -689,6 +873,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val widgetIds = appWidgetManager.getAppWidgetIds(componentName)
val remoteViews = RemoteViews(packageName, R.layout.noise_control_widget).also {
val ancStatus = ancNotification.status
+ val allowOffModeValue = aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION }
+ val allowOffMode = allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte()
it.setInt(
R.id.widget_off_button,
"setBackgroundResource",
@@ -697,7 +883,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
it.setInt(
R.id.widget_transparency_button,
"setBackgroundResource",
- if (ancStatus == 3) (if (config.offListeningMode) R.drawable.widget_button_checked_shape_middle else R.drawable.widget_button_checked_shape_start) else (if (config.offListeningMode) R.drawable.widget_button_shape_middle else R.drawable.widget_button_shape_start)
+ if (ancStatus == 3) (if (allowOffMode) R.drawable.widget_button_checked_shape_middle else R.drawable.widget_button_checked_shape_start) else (if (allowOffMode) R.drawable.widget_button_shape_middle else R.drawable.widget_button_shape_start)
)
it.setInt(
R.id.widget_adaptive_button,
@@ -711,19 +897,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
it.setViewVisibility(
R.id.widget_off_button,
- if (config.offListeningMode) View.VISIBLE else View.GONE
+ if (allowOffMode) View.VISIBLE else View.GONE
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
it.setViewLayoutMargin(
R.id.widget_transparency_button,
RemoteViews.MARGIN_START,
- if (config.offListeningMode) 2f else 12f,
+ if (allowOffMode) 2f else 12f,
TypedValue.COMPLEX_UNIT_DIP
)
} else {
it.setViewPadding(
R.id.widget_transparency_button,
- if (config.offListeningMode) 2.dpToPx() else 12.dpToPx(),
+ if (allowOffMode) 2.dpToPx() else 12.dpToPx(),
12.dpToPx(),
2.dpToPx(),
12.dpToPx()
@@ -865,12 +1051,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private fun rejectCall() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- val telecomManager = getSystemService(Context.TELECOM_SERVICE) as TelecomManager
+ val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) {
telecomManager.endCall()
}
} else {
- val telephonyService = getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
+ val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
val telephonyClass = Class.forName(telephonyService.javaClass.name)
val method = telephonyClass.getDeclaredMethod("getITelephony")
method.isAccessible = true
@@ -917,12 +1103,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
+ @Suppress("PrivatePropertyName")
private val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV = "+IPHONEACCEV"
+ @Suppress("PrivatePropertyName")
private val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL = 1
+ @Suppress("PrivatePropertyName")
private val APPLE = 0x004C
+ @Suppress("PrivatePropertyName")
private val ACTION_BATTERY_LEVEL_CHANGED = "android.bluetooth.device.action.BATTERY_LEVEL_CHANGED"
+ @Suppress("PrivatePropertyName")
private val EXTRA_BATTERY_LEVEL = "android.bluetooth.device.extra.BATTERY_LEVEL"
+ @Suppress("PrivatePropertyName")
private val PACKAGE_ASI = "com.google.android.settings.intelligence"
+ @Suppress("PrivatePropertyName")
private val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data"
@Suppress("MissingPermission")
@@ -942,7 +1135,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// Check charging status
val isLeftCharging = leftBattery?.status == BatteryStatus.CHARGING
val isRightCharging = rightBattery?.status == BatteryStatus.CHARGING
- val isChargingMain = isLeftCharging && isRightCharging
+ isLeftCharging && isRightCharging
// Create arguments for vendor-specific event
val arguments = arrayOf(
@@ -1065,8 +1258,51 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
Log.d("AirPodsService", "Metadata set: $metadataSet")
}
}
+
+ @Suppress("ClassName")
+ private object bluetoothReceiver : BroadcastReceiver() {
+ @SuppressLint("MissingPermission")
+ override fun onReceive(context: Context?, intent: Intent) {
+ val bluetoothDevice =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(
+ "android.bluetooth.device.extra.DEVICE",
+ BluetoothDevice::class.java
+ )
+ } else {
+ intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE") as BluetoothDevice?
+ }
+ val action = intent.action
+ val context = context?.applicationContext
+ val name = context?.getSharedPreferences("settings", MODE_PRIVATE)
+ ?.getString("name", bluetoothDevice?.name)
+ if (bluetoothDevice != null && action != null && !action.isEmpty()) {
+ Log.d("AirPodsService", "Received bluetooth connection broadcast")
+ if (ServiceManager.getService()?.isConnectedLocally == true) {
+ Log.d("AirPodsService", "Checking if audio should be connected")
+ ServiceManager.getService()?.manuallyCheckForAudioSource()
+ return
+ }
+ if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
+ val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
+ bluetoothDevice.fetchUuidsWithSdp()
+ if (bluetoothDevice.uuids != null) {
+ if (bluetoothDevice.uuids.contains(uuid)) {
+ val intent =
+ Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
+ intent.putExtra("name", name)
+ intent.putExtra("device", bluetoothDevice)
+ context?.sendBroadcast(intent)
+ }
+ }
+ }
+ }
+ }
+ }
+
val ancModeFilter = IntentFilter("me.kavishdevar.librepods.SET_ANC_MODE")
var ancModeReceiver: BroadcastReceiver? = null
+
@SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("AirPodsService", "Service started")
@@ -1074,6 +1310,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
startForegroundNotification()
initGestureDetector()
+ bleManager = BLEManager(this)
+ bleManager.setAirPodsStatusListener(bleStatusListener)
+
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
with(sharedPreferences) {
@@ -1096,6 +1335,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (!contains("head_gestures")) editor.putBoolean("head_gestures", true)
if (!contains("disconnect_when_not_wearing")) editor.putBoolean("disconnect_when_not_wearing", false)
+ // AirPods state-based takeover
+ if (!contains("takeover_when_disconnected")) editor.putBoolean("takeover_when_disconnected", true)
+ if (!contains("takeover_when_idle")) editor.putBoolean("takeover_when_idle", true)
+ if (!contains("takeover_when_music")) editor.putBoolean("takeover_when_music", false)
+ if (!contains("takeover_when_call")) editor.putBoolean("takeover_when_call", true)
+
+ // Phone state-based takeover
+ if (!contains("takeover_when_ringing_call")) editor.putBoolean("takeover_when_ringing_call", true)
+ if (!contains("takeover_when_media_start")) editor.putBoolean("takeover_when_media_start", true)
+
if (!contains("adaptive_strength")) editor.putInt("adaptive_strength", 51)
if (!contains("tone_volume")) editor.putInt("tone_volume", 75)
if (!contains("conversational_awareness_volume")) editor.putInt("conversational_awareness_volume", 43)
@@ -1116,13 +1365,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (intent.hasExtra("mode")) {
val mode = intent.getIntExtra("mode", -1)
if (mode in 1..4) {
- setANCMode(mode)
+ aacpManager.sendControlCommand(
+ AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
+ mode
+ )
}
} else {
val currentMode = ancNotification.status
- val offListeningMode = config.offListeningMode
+ val allowOffModeValue = aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION }
+ val allowOffMode = allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte()
- val nextMode = if (offListeningMode) {
+ val nextMode = if (allowOffMode) {
when (currentMode) {
1 -> 2
2 -> 3
@@ -1140,8 +1393,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
- setANCMode(nextMode)
- Log.d("AirPodsService", "Cycling ANC mode from $currentMode to $nextMode (offListeningMode: $offListeningMode)")
+ aacpManager.sendControlCommand(
+ AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
+ nextMode
+ )
+ Log.d("AirPodsService", "Cycling ANC mode from $currentMode to $nextMode (offListeningMode: $allowOffMode)")
}
}
}
@@ -1177,8 +1433,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
super.onCallStateChanged(state, phoneNumber)
when (state) {
TelephonyManager.CALL_STATE_RINGING -> {
- if (CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) CoroutineScope(Dispatchers.IO).launch {
- takeOver()
+ val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true
+ if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(Dispatchers.IO).launch {
+ takeOver("call")
}
if (config.headGestures) {
callNumber = phoneNumber
@@ -1186,9 +1443,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
TelephonyManager.CALL_STATE_OFFHOOK -> {
- if (CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) CoroutineScope(
+ val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true
+ if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(
Dispatchers.IO).launch {
- takeOver()
+ takeOver("call")
}
isInCall = true
}
@@ -1246,7 +1504,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString())
if (!CrossDevice.isAvailable) {
Log.d("AirPodsService", "${config.deviceName} connected")
- showPopup(this@AirPodsService, config.deviceName)
CoroutineScope(Dispatchers.IO).launch {
connectToSocket(device!!)
}
@@ -1347,36 +1604,73 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
clearPacketLogs()
}
+ CoroutineScope(Dispatchers.IO).launch {
+ bleManager.startScanning()
+ }
+
return START_STICKY
}
private lateinit var socket: BluetoothSocket
fun manuallyCheckForAudioSource() {
+ val shouldResume = MediaController.getMusicActive()
if (earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) {
Log.d(
"AirPodsService",
"For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again!"
)
- disconnectAudio(this, device)
+ disconnectAudio(this, device, shouldResume = shouldResume)
}
}
@RequiresApi(Build.VERSION_CODES.R)
@SuppressLint("MissingPermission")
- fun takeOver() {
+ fun takeOver(takingOverFor: String) {
+ if (isConnectedLocally || !CrossDevice.isAvailable || bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true) {
+ Log.d("AirPodsService", "Already connected or not available for takeover")
+ return
+ }
+
+ val shouldTakeOverPState = when (takingOverFor) {
+ "music" -> config.takeoverWhenMediaStart
+ "call" -> config.takeoverWhenRingingCall
+ else -> false
+ }
+ if (!shouldTakeOverPState) {
+ Log.d("AirPodsService", "Not taking over audio, phone state takeover disabled")
+ return
+ }
+
+ val shouldTakeOver = when (bleManager.getMostRecentStatus()?.connectionState) {
+ "Disconnected" -> config.takeoverWhenDisconnected
+ "Idle" -> config.takeoverWhenIdle
+ "Music" -> config.takeoverWhenMusic
+ "Call" -> config.takeoverWhenCall
+ "Ringing" -> config.takeoverWhenCall
+ "Hanging Up" -> config.takeoverWhenCall
+ else -> false
+ }
+
+ if (!shouldTakeOver) {
+ Log.d("AirPodsService", "Not taking over audio, airpods state takeover disabled")
+ return
+ }
+
Log.d("AirPodsService", "Taking over audio")
CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet)
Log.d("AirPodsService", macAddress)
- CrossDevice.isAvailable = false
+
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false) }
device = getSystemService(BluetoothManager::class.java).adapter.bondedDevices.find {
it.address == macAddress
}
+
if (device != null) {
connectToSocket(device!!)
connectAudio(this, device)
}
+
showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!),
IslandType.TAKING_OVER)
@@ -1443,6 +1737,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
socket.connect()
isConnectedLocally = true
this@AirPodsService.device = device
+
+ BluetoothConnectionManager.setCurrentConnection(socket, device)
+
updateNotificationContent(
true,
config.deviceName,
@@ -1462,38 +1759,33 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
this@AirPodsService.device = device
socket.let { it ->
- it.outputStream.write(Enums.HANDSHAKE.value)
- it.outputStream.flush()
- it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value)
- it.outputStream.flush()
- it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value)
- it.outputStream.flush()
+ aacpManager.sendPacket(aacpManager.createHandshakePacket())
+ aacpManager.sendSetFeatureFlagsPacket()
+ aacpManager.sendNotificationRequest()
+ Log.d("AirPodsService", "Requesting proximity keys")
+ aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value)
CoroutineScope(Dispatchers.IO).launch {
- it.outputStream.write(Enums.HANDSHAKE.value)
- it.outputStream.flush()
+ aacpManager.sendPacket(aacpManager.createHandshakePacket())
delay(200)
- it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value)
- it.outputStream.flush()
+ aacpManager.sendSetFeatureFlagsPacket()
delay(200)
- it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value)
- it.outputStream.flush()
+ aacpManager.sendNotificationRequest()
delay(200)
- it.outputStream.write(Enums.START_HEAD_TRACKING.value)
- it.outputStream.flush()
+ aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value)
+ startHeadTracking()
Handler(Looper.getMainLooper()).postDelayed({
- it.outputStream.write(Enums.HANDSHAKE.value)
- it.outputStream.flush()
- it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value)
- it.outputStream.flush()
- it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value)
- it.outputStream.flush()
- it.outputStream.write(Enums.STOP_HEAD_TRACKING.value)
- it.outputStream.flush()
+ aacpManager.sendPacket(aacpManager.createHandshakePacket())
+ aacpManager.sendSetFeatureFlagsPacket()
+ aacpManager.sendNotificationRequest()
+ aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value)
+ stopHeadTracking()
}, 5000)
+
sendBroadcast(
Intent(AirPodsNotifications.AIRPODS_CONNECTED)
.putExtra("device", device)
)
+
while (socket.isConnected == true) {
socket.let {
val buffer = ByteArray(1024)
@@ -1512,219 +1804,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferences.getString("name", device.name),
batteryNotification.getBattery()
)
+
+ aacpManager.receivePacket(data)
+
if (!isHeadTrackingData(data)) {
- Log.d("AirPods Data", "Data received: $formattedHex")
+ Log.d("AirPodsData", "Data received: $formattedHex")
logPacket(data, "AirPods")
}
+
} else if (bytesRead == -1) {
Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
return@launch
}
- var inEar = false
- var inEarData = listOf()
- processData(data)
- if (earDetectionNotification.isEarDetectionData(data)) {
- earDetectionNotification.setStatus(data)
- sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply {
- val list = earDetectionNotification.status
- val bytes = ByteArray(2)
- bytes[0] = list[0]
- bytes[1] = list[1]
- putExtra("data", bytes)
- })
- Log.d(
- "AirPods Parser",
- "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}"
- )
- var justEnabledA2dp = false
- earReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- val data = intent.getByteArrayExtra("data")
- if (data != null && config.earDetectionEnabled) {
- inEar =
- if (data.find { it == 0x02.toByte() } != null || data.find { it == 0x03.toByte() } != null) {
- data[0] == 0x00.toByte() || data[1] == 0x00.toByte()
- } else {
- data[0] == 0x00.toByte() && data[1] == 0x00.toByte()
- }
- val newInEarData = listOf(
- data[0] == 0x00.toByte(),
- data[1] == 0x00.toByte()
- )
- if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf(false, false) && islandWindow?.isVisible != true) {
- showIsland(this@AirPodsService, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!))
- }
- if (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) {
- islandWindow?.close()
- }
- if (newInEarData.contains(true) && inEarData == listOf(
- false,
- false
- )
- ) {
- connectAudio(this@AirPodsService, device)
- justEnabledA2dp = true
- val a2dpConnectionStateReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- if (intent.action == "android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") {
- val state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED)
- val previousState = intent.getIntExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, BluetoothProfile.STATE_DISCONNECTED)
- val device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
-
- Log.d("MediaController", "A2DP state changed: $previousState -> $state for device: ${device?.address}")
-
- if (state == BluetoothProfile.STATE_CONNECTED &&
- previousState != BluetoothProfile.STATE_CONNECTED &&
- device?.address == this@AirPodsService.device?.address) {
-
- Log.d("MediaController", "A2DP connected, sending play command")
- MediaController.sendPlay()
- MediaController.iPausedTheMedia = false
-
- context.unregisterReceiver(this)
- }
- }
- }
- }
- val a2dpIntentFilter = IntentFilter("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter, RECEIVER_EXPORTED)
- } else {
- registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter)
- }
- } else if (newInEarData == listOf(false, false)) {
- MediaController.sendPause(force = true)
- if (config.disconnectWhenNotWearing) {
- disconnectAudio(this@AirPodsService, device)
- }
- }
-
- if (inEarData.contains(false) && newInEarData == listOf(
- true,
- true
- )
- ) {
- Log.d(
- "AirPods Parser",
- "User put in both AirPods from just one."
- )
- MediaController.userPlayedTheMedia = false
- }
- if (newInEarData.contains(false) && inEarData == listOf(
- true,
- true
- )
- ) {
- Log.d(
- "AirPods Parser",
- "User took one of two out."
- )
- MediaController.userPlayedTheMedia = false
- }
-
- Log.d(
- "AirPods Parser",
- "inEarData: ${inEarData.sorted()}, newInEarData: ${newInEarData.sorted()}"
- )
- if (newInEarData.sorted() == inEarData.sorted()) {
- Log.d("AirPods Parser", "hi")
- return
- }
- Log.d(
- "AirPods Parser",
- "this shouldn't be run if the last log was 'hi'."
- )
-
- inEarData = newInEarData
-
- if (inEar == true) {
- if (!justEnabledA2dp) {
- justEnabledA2dp = false
- MediaController.sendPlay()
- MediaController.iPausedTheMedia = false
- }
- } else {
- MediaController.sendPause()
- }
- }
- }
- }
-
- val earIntentFilter =
- IntentFilter(AirPodsNotifications.EAR_DETECTION_DATA)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- this@AirPodsService.registerReceiver(
- earReceiver, earIntentFilter,
- RECEIVER_EXPORTED
- )
- } else {
- this@AirPodsService.registerReceiver(
- earReceiver,
- earIntentFilter
- )
- }
- } else if (ancNotification.isANCData(data)) {
- CrossDevice.sendRemotePacket(data)
- CrossDevice.ancBytes = data
- ancNotification.setStatus(data)
- sendANCBroadcast()
- updateNoiseControlWidget()
- Log.d("AirPods Parser", "ANC: ${ancNotification.status}")
- } else if (batteryNotification.isBatteryData(data)) {
- CrossDevice.sendRemotePacket(data)
- CrossDevice.batteryBytes = data
- batteryNotification.setBattery(data)
- sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
- putParcelableArrayListExtra(
- "data",
- ArrayList(batteryNotification.getBattery())
- )
- })
- updateBattery()
- updateNotificationContent(
- true,
- this@AirPodsService.getSharedPreferences(
- "settings",
- MODE_PRIVATE
- ).getString("name", device.name),
- batteryNotification.getBattery()
- )
- for (battery in batteryNotification.getBattery()) {
- Log.d(
- "AirPods Parser",
- "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% "
- )
- }
- if (batteryNotification.getBattery()[0].status == 1 && batteryNotification.getBattery()[1].status == 1) {
- disconnectAudio(this@AirPodsService, device)
- } else {
- connectAudio(this@AirPodsService, device)
- }
- } else if (conversationAwarenessNotification.isConversationalAwarenessData(
- data
- )
- ) {
- conversationAwarenessNotification.setData(data)
- sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply {
- putExtra("data", conversationAwarenessNotification.status)
- })
-
-
- if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) {
- MediaController.startSpeaking()
- } else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) {
- MediaController.stopSpeaking()
- }
-
- Log.d(
- "AirPods Parser",
- "Conversation Awareness: ${conversationAwarenessNotification.status}"
- )
- }
- else if (isHeadTrackingData(data)) {
- processHeadTrackingData(data)
- }
}
}
Log.d("AirPods Service", "Socket closed")
@@ -1769,199 +1861,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
CrossDevice.isAvailable = true
}
- fun sendPacket(packet: String) {
- val fromHex = packet.split(" ").map { it.toInt(16).toByte() }
- try {
- logPacket(fromHex.toByteArray(), "Sent")
-
- if (!isConnectedLocally && CrossDevice.isAvailable) {
- CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + fromHex.toByteArray())
- return
- }
- if (this::socket.isInitialized && socket.isConnected && socket.outputStream != null) {
- val byteArray = fromHex.toByteArray()
- socket.outputStream?.write(byteArray)
- socket.outputStream?.flush()
- } else {
- Log.d("AirPodsService", "Can't send packet: Socket not initialized or connected")
- }
- } catch (e: Exception) {
- Log.e("AirPodsService", "Error sending packet: ${e.message}")
- }
- }
-
- fun sendPacket(packet: ByteArray) {
- try {
- logPacket(packet, "Sent")
-
- if (!isConnectedLocally && CrossDevice.isAvailable) {
- CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
- return
- }
- if (this::socket.isInitialized && socket.isConnected && socket.outputStream != null && isConnectedLocally) {
- socket.outputStream?.write(packet)
- socket.outputStream?.flush()
- } else {
- Log.d("AirPodsService", "Can't send packet: Socket not initialized or connected")
- }
- } catch (e: Exception) {
- Log.e("AirPodsService", "Error sending packet: ${e.message}")
- }
- }
-
- fun setANCMode(mode: Int) {
- Log.d("AirPodsService", "setANCMode: $mode")
- when (mode) {
- 1 -> {
- sendPacket(Enums.NOISE_CANCELLATION_OFF.value)
- }
-
- 2 -> {
- sendPacket(Enums.NOISE_CANCELLATION_ON.value)
- }
-
- 3 -> {
- sendPacket(Enums.NOISE_CANCELLATION_TRANSPARENCY.value)
- }
-
- 4 -> {
- sendPacket(Enums.NOISE_CANCELLATION_ADAPTIVE.value)
- }
- }
- }
-
- fun setCAEnabled(enabled: Boolean) {
- sendPacket(if (enabled) Enums.SET_CONVERSATION_AWARENESS_ON.value else Enums.SET_CONVERSATION_AWARENESS_OFF.value)
- }
-
- fun setOffListeningMode(enabled: Boolean) {
- sendPacket(
- byteArrayOf(
- 0x04,
- 0x00,
- 0x04,
- 0x00,
- 0x09,
- 0x00,
- 0x34,
- if (enabled) 0x01 else 0x02,
- 0x00,
- 0x00,
- 0x00
- )
- )
-
- if (config.offListeningMode != enabled) {
- config.offListeningMode = enabled
- sharedPreferences.edit { putBoolean("off_listening_mode", enabled) }
- }
- updateNoiseControlWidget()
- }
-
- fun setAdaptiveStrength(strength: Int) {
- val bytes =
- byteArrayOf(
- 0x04,
- 0x00,
- 0x04,
- 0x00,
- 0x09,
- 0x00,
- 0x2E,
- strength.toByte(),
- 0x00,
- 0x00,
- 0x00
- )
- sendPacket(bytes)
-
- if (config.adaptiveStrength != strength) {
- config.adaptiveStrength = strength
- sharedPreferences.edit { putInt("adaptive_strength", strength) }
- }
- }
-
- fun setPressSpeed(speed: Int) {
- // 0x00 = default, 0x01 = slower, 0x02 = slowest
- val bytes =
- byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x17, speed.toByte(), 0x00, 0x00, 0x00)
- sendPacket(bytes)
- }
-
- fun setPressAndHoldDuration(speed: Int) {
- // 0 - default, 1 - slower, 2 - slowest
- val bytes =
- byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x18, speed.toByte(), 0x00, 0x00, 0x00)
- sendPacket(bytes)
- }
-
- fun setVolumeSwipeSpeed(speed: Int) {
- // 0 - default, 1 - longer, 2 - longest
- val bytes =
- byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x23, speed.toByte(), 0x00, 0x00, 0x00)
- Log.d(
- "AirPodsService",
- "Setting volume swipe speed to $speed by packet ${
- bytes.joinToString(" ") {
- "%02X".format(
- it
- )
- }
- }"
- )
- sendPacket(bytes)
- }
-
- fun setNoiseCancellationWithOnePod(enabled: Boolean) {
- val bytes = byteArrayOf(
- 0x04,
- 0x00,
- 0x04,
- 0x00,
- 0x09,
- 0x00,
- 0x1B,
- if (enabled) 0x01 else 0x02,
- 0x00,
- 0x00,
- 0x00
- )
- sendPacket(bytes)
- }
-
- fun setVolumeControl(enabled: Boolean) {
- val bytes = byteArrayOf(
- 0x04,
- 0x00,
- 0x04,
- 0x00,
- 0x09,
- 0x00,
- 0x25,
- if (enabled) 0x01 else 0x02,
- 0x00,
- 0x00,
- 0x00
- )
- sendPacket(bytes)
-
- if (config.volumeControl != enabled) {
- config.volumeControl = enabled
- sharedPreferences.edit { putBoolean("volume_control", enabled) }
- }
- }
-
- fun setToneVolume(volume: Int) {
- val bytes =
- byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1F, volume.toByte(), 0x50, 0x00, 0x00)
- sendPacket(bytes)
-
- if (config.toneVolume != volume) {
- config.toneVolume = volume
- sharedPreferences.edit { putInt("tone_volume", volume) }
- }
- }
-
val earDetectionNotification = AirPodsNotifications.EarDetection()
val ancNotification = AirPodsNotifications.ANC()
val batteryNotification = AirPodsNotifications.BatteryNotification()
@@ -1989,15 +1888,24 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return ancNotification.status
}
- fun disconnectAudio(context: Context, device: BluetoothDevice?) {
+ fun disconnectAudio(context: Context, device: BluetoothDevice?, shouldResume: Boolean = false) {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) {
try {
+ if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) {
+ Log.d("AirPodsService", "Already disconnected from A2DP")
+ return
+ }
val method =
proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java)
method.invoke(proxy, device)
+ if (shouldResume) {
+ Handler(Looper.getMainLooper()).postDelayed({
+ MediaController.sendPlay()
+ }, 150)
+ }
} catch (e: Exception) {
e.printStackTrace()
} finally {
@@ -2072,13 +1980,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
fun setName(name: String) {
- val nameBytes = name.toByteArray()
- val bytes = byteArrayOf(
- 0x04, 0x00, 0x04, 0x00, 0x1a, 0x00, 0x01,
- nameBytes.size.toByte(), 0x00
- ) + nameBytes
- sendPacket(bytes)
- val hex = bytes.joinToString(" ") { "%02X".format(it) }
+ aacpManager.sendRename(name)
if (config.deviceName != name) {
config.deviceName = name
@@ -2086,183 +1988,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
updateNotificationContent(true, name, batteryNotification.getBattery())
- Log.d("AirPodsService", "setName: $name, sent packet: $hex")
- }
-
- fun setPVEnabled(enabled: Boolean) {
- var hex = "04 00 04 00 09 00 26 ${if (enabled) "01" else "02"} 00 00 00"
- var bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray()
- sendPacket(bytes)
- hex =
- "04 00 04 00 17 00 00 00 10 00 12 00 08 E${if (enabled) "6" else "5"} 05 10 02 42 0B 08 50 10 02 1A 05 02 ${if (enabled) "32" else "00"} 00 00 00"
- bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray()
- sendPacket(bytes)
-
- if (config.personalizedVolume != enabled) {
- config.personalizedVolume = enabled
- sharedPreferences.edit { putBoolean("personalized_volume", enabled) }
- }
- }
-
- fun setLoudSoundReduction(enabled: Boolean) {
- val hex = "52 1B 00 0${if (enabled) "1" else "0"}"
- val bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray()
- sendPacket(bytes)
-
- if (config.loudSoundReduction != enabled) {
- config.loudSoundReduction = enabled
- sharedPreferences.edit { putBoolean("loud_sound_reduction", enabled) }
- }
- }
-
- fun findChangedIndex(oldArray: BooleanArray, newArray: BooleanArray): Int {
- for (i in oldArray.indices) {
- if (oldArray[i] != newArray[i]) {
- return i
- }
- }
- throw IllegalArgumentException("No element has changed")
- }
-
- fun updateLongPress(
- oldLongPressArray: BooleanArray,
- newLongPressArray: BooleanArray,
- offListeningMode: Boolean
- ) {
- if (oldLongPressArray.contentEquals(newLongPressArray)) {
- return
- }
- val oldOffEnabled = oldLongPressArray[0]
- val oldAncEnabled = oldLongPressArray[1]
- val oldTransparencyEnabled = oldLongPressArray[2]
- val oldAdaptiveEnabled = oldLongPressArray[3]
-
- val newOffEnabled = newLongPressArray[0]
- val newAncEnabled = newLongPressArray[1]
- val newTransparencyEnabled = newLongPressArray[2]
- val newAdaptiveEnabled = newLongPressArray[3]
-
- val changedIndex = findChangedIndex(oldLongPressArray, newLongPressArray)
- Log.d("AirPodsService", "changedIndex: $changedIndex")
- var packet: ByteArray? = null
- if (offListeningMode) {
- packet = when (changedIndex) {
- 0 -> {
- if (newOffEnabled) {
- when {
- oldAncEnabled && oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_EVERYTHING.value
- oldAncEnabled && oldTransparencyEnabled -> LongPressPackets.ENABLE_OFF_FROM_TRANSPARENCY_AND_ANC.value
- oldAncEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_OFF_FROM_ADAPTIVE_AND_ANC.value
- oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE.value
- else -> null
- }
- } else {
- when {
- oldAncEnabled && oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_OFF_FROM_EVERYTHING.value
- oldAncEnabled && oldTransparencyEnabled -> LongPressPackets.DISABLE_OFF_FROM_TRANSPARENCY_AND_ANC.value
- oldAncEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_OFF_FROM_ADAPTIVE_AND_ANC.value
- oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE.value
- else -> null
- }
- }
- }
-
- 1 -> {
- if (newAncEnabled) {
- when {
- oldOffEnabled && oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_EVERYTHING.value
- oldOffEnabled && oldTransparencyEnabled -> LongPressPackets.ENABLE_ANC_FROM_OFF_AND_TRANSPARENCY.value
- oldOffEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_ANC_FROM_OFF_AND_ADAPTIVE.value
- oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE.value
- else -> null
- }
- } else {
- when {
- oldOffEnabled && oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_ANC_FROM_EVERYTHING.value
- oldOffEnabled && oldTransparencyEnabled -> LongPressPackets.DISABLE_ANC_FROM_OFF_AND_TRANSPARENCY.value
- oldOffEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_ANC_FROM_OFF_AND_ADAPTIVE.value
- oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE.value
- else -> null
- }
- }
- }
-
- 2 -> {
- if (newTransparencyEnabled) {
- when {
- oldOffEnabled && oldAncEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_EVERYTHING.value
- oldOffEnabled && oldAncEnabled -> LongPressPackets.ENABLE_TRANSPARENCY_FROM_OFF_AND_ANC.value
- oldOffEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE.value
- oldAncEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC.value
- else -> null
- }
- } else {
- when {
- oldOffEnabled && oldAncEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_TRANSPARENCY_FROM_EVERYTHING.value
- oldOffEnabled && oldAncEnabled -> LongPressPackets.DISABLE_TRANSPARENCY_FROM_OFF_AND_ANC.value
- oldOffEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE.value
- oldAncEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC.value
- else -> null
- }
- }
- }
-
- 3 -> {
- if (newAdaptiveEnabled) {
- when {
- oldOffEnabled && oldAncEnabled && oldTransparencyEnabled -> LongPressPackets.ENABLE_EVERYTHING.value
- oldOffEnabled && oldAncEnabled -> LongPressPackets.ENABLE_ADAPTIVE_FROM_OFF_AND_ANC.value
- oldOffEnabled && oldTransparencyEnabled -> LongPressPackets.ENABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY.value
- oldAncEnabled && oldTransparencyEnabled -> LongPressPackets.ENABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC.value
- else -> null
- }
- } else {
- when {
- oldOffEnabled && oldAncEnabled && oldTransparencyEnabled -> LongPressPackets.DISABLE_ADAPTIVE_FROM_EVERYTHING.value
- oldOffEnabled && oldAncEnabled -> LongPressPackets.DISABLE_ADAPTIVE_FROM_OFF_AND_ANC.value
- oldOffEnabled && oldTransparencyEnabled -> LongPressPackets.DISABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY.value
- oldAncEnabled && oldTransparencyEnabled -> LongPressPackets.DISABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC.value
- else -> null
- }
- }
- }
-
- else -> null
- }
- } else {
- when (changedIndex) {
- 1 -> {
- packet = if (newLongPressArray[1]) {
- LongPressPackets.ENABLE_EVERYTHING_OFF_DISABLED.value
- } else {
- LongPressPackets.DISABLE_ANC_OFF_DISABLED.value
- }
- }
-
- 2 -> {
- packet = if (newLongPressArray[2]) {
- LongPressPackets.ENABLE_EVERYTHING_OFF_DISABLED.value
- } else {
- LongPressPackets.DISABLE_TRANSPARENCY_OFF_DISABLED.value
- }
- }
-
- 3 -> {
- packet = if (newLongPressArray[3]) {
- LongPressPackets.ENABLE_EVERYTHING_OFF_DISABLED.value
- } else {
- LongPressPackets.DISABLE_ADAPTIVE_OFF_DISABLED.value
- }
- }
- }
-
- }
- packet?.let {
- Log.d("AirPodsService", "Sending packet: ${it.joinToString(" ") { "%02X".format(it) }}")
- sendPacket(it)
- }
+ Log.d("AirPodsService", "setName: $name")
}
+ @SuppressLint("MissingPermission")
override fun onDestroy() {
clearPacketLogs()
Log.d("AirPodsService", "Service stopped is being destroyed for some reason!")
@@ -2294,6 +2023,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} catch (e: Exception) {
e.printStackTrace()
}
+ try {
+ bleManager.stopScanning()
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
isConnectedLocally = false
CrossDevice.isAvailable = true
@@ -2304,18 +2038,24 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun startHeadTracking() {
isHeadTrackingActive = true
- socket.outputStream.write(Enums.START_HEAD_TRACKING.value)
+ aacpManager.sendStartHeadTracking()
HeadTracking.reset()
}
fun stopHeadTracking() {
- socket.outputStream.write(Enums.STOP_HEAD_TRACKING.value)
+ aacpManager.sendStopHeadTracking()
isHeadTrackingActive = false
}
- fun processData(data: ByteArray) {
- if (isHeadTrackingActive && isHeadTrackingData(data)) {
- HeadTracking.processPacket(data)
+ fun shouldTakeOverBasedOnAirPodsState(connectionState: String): Boolean {
+ if (CrossDevice.isAvailable) return true
+
+ return when (connectionState) {
+ "Disconnected" -> config.takeoverWhenDisconnected
+ "Idle" -> config.takeoverWhenIdle
+ "Music" -> config.takeoverWhenMusic
+ "Call", "Ringing", "Hanging Up" -> config.takeoverWhenCall
+ else -> false
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
new file mode 100644
index 0000000..4ec3baf
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
@@ -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 .
+ */
+@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 = mutableListOf()
+ var controlCommandListeners: MutableMap> = 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 {
+ 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()
+ 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
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt
new file mode 100644
index 0000000..0e3f9ab
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt
@@ -0,0 +1,429 @@
+/*
+ * 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 .
+ */
+
+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 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()
+ private val verifiedAddresses = mutableSetOf()
+ private val sharedPreferences: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
+ private var currentGlobalLidState: Boolean? = null
+ private var lastBroadcastTime: Long = 0
+ private val processedAddresses = mutableSetOf()
+ 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) {
+ 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)
+ }
+ }
+
+ 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 parsedStatus = 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 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 leftBatteryNibble = if (xorFactor) (podsBattery shr 4) and 0x0F else podsBattery and 0x0F
+ val rightBatteryNibble = if (xorFactor) podsBattery and 0x0F else (podsBattery shr 4) and 0x0F
+
+ val caseBattery = (flagsCase shr 4) and 0x0F
+ val flags = flagsCase and 0x0F
+
+ val isRightCharging = if (xorFactor) (flags and 0x02) != 0 else (flags and 0x01) != 0
+ val isLeftCharging = if (xorFactor) (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
+ )
+ }
+
+ private val bleStatusListener = object : BLEManager.AirPodsStatusListener {
+ @SuppressLint("NewApi")
+ override fun onDeviceStatusChanged(
+ device: BLEManager.AirPodsStatus,
+ previousStatus: BLEManager.AirPodsStatus?
+ ) {
+ if (ServiceManager.getService()?.isConnectedLocally == true) {
+ Log.d("AirPodsBLEService", "Checking if audio should be connected")
+ ServiceManager.getService()?.manuallyCheckForAudioSource()
+ return
+ }
+
+ Log.d("AirPodsBLEService", "Device status changed, inEar: ${device.isLeftInEar}, ${device.isRightInEar}")
+
+ if (previousStatus != null && device.connectionState != previousStatus.connectionState) {
+ Log.d("AirPodsBLEService", "Connection state changed from ${previousStatus.connectionState} to ${device.connectionState}")
+
+ if (ServiceManager.getService()?.shouldTakeOverBasedOnAirPodsState(device.connectionState) == true) {
+ Log.d("AirPodsBLEService", "Taking over based on AirPods state: ${device.connectionState}")
+
+ val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
+ val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(context.getSharedPreferences(
+ "settings", Context.MODE_PRIVATE).getString("mac_address", "") ?: "")
+
+ ServiceManager.getService()?.connectToSocket(bluetoothDevice)
+ }
+ }
+ }
+
+ override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) {
+ // Implement this method if needed
+ }
+
+ override fun onLidStateChanged(lidOpen: Boolean) {
+ // Implement this method if needed
+ }
+
+ override fun onEarStateChanged(device: BLEManager.AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean) {
+ // Implement this method if needed
+ }
+
+ override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
+ // Implement this method if needed
+ }
+ }
+
+ 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
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothConnectionManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothConnectionManager.kt
new file mode 100644
index 0000000..5655793
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothConnectionManager.kt
@@ -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 .
+ */
+
+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
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt
new file mode 100644
index 0000000..145c89f
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt
@@ -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 .
+ */
+
+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)
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt
index deb1f29..f5130ea 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt
@@ -16,6 +16,7 @@
* along with this program. If not, see .
*/
+@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)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt
index d0b5dc4..b7b14bd 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt
@@ -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
}
@@ -92,7 +92,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
prevHorizontal = 0.0
prevVertical = 0.0
- airPodsService.sendPacket(START_CMD)
+ airPodsService.aacpManager.sendStartHeadTracking()
detectionJob = CoroutineScope(Dispatchers.Default).launch {
while (isRunning) {
@@ -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.aacpManager.sendStopHeadTracking()
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
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt
index a6f39ba..711bcbe 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt
@@ -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)
- }
- }
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt
index d2a9e87..859f49b 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt
@@ -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
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt
index 78b1272..fa66d52 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt
@@ -16,57 +16,200 @@
* along with this program. If not, see .
*/
+@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("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?) {
+ 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(R.id.island_battery_text)
+ val batteryProgressBar = islandView.findViewById(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(R.id.island_battery_text)
+ val batteryProgressBar = islandView.findViewById(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(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,97 @@ class IslandWindow(context: Context) {
}
islandView.visibility = View.VISIBLE
- islandView.findViewById(R.id.island_battery_text).text = "$batteryPercentage%"
- islandView.findViewById(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
+
+ 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 +323,8 @@ class IslandWindow(context: Context) {
IslandType.MOVED_TO_REMOTE -> {
islandView.findViewById(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text)
}
-// IslandType.CALL_GESTURE -> {
-// islandView.findViewById(R.id.island_connected_text).text = "Incoming Call from $name"
-// islandView.findViewById(R.id.island_device_name).text = "Use Head Gestures to answer."
-// }
}
- val batteryProgressBar = islandView.findViewById(R.id.island_battery_progress)
- batteryProgressBar.progress = batteryPercentage
- batteryProgressBar.isIndeterminate = false
-
val videoView = islandView.findViewById(R.id.island_video_view)
val videoUri = Uri.parse("android.resource://me.kavishdevar.librepods/${R.raw.island}")
videoView.setVideoURI(videoUri)
@@ -113,19 +333,265 @@ 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(R.id.island_connected_text)
+ val deviceText = islandView.findViewById(R.id.island_device_name)
+ val batteryView = islandView.findViewById(R.id.island_battery_container)
+ val videoView = islandView.findViewById(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(R.id.island_window_layout)
+ val connectedText = islandView.findViewById(R.id.island_connected_text)
+ val deviceText = islandView.findViewById(R.id.island_device_name)
+ val batteryView = islandView.findViewById(R.id.island_battery_container)
+ val videoView = islandView.findViewById(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 = 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(R.id.island_window_layout)
+ val deviceText = islandView.findViewById(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) {
+ close()
+ }
+ })
+
+ 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(R.id.island_video_view).visibility = View.GONE
+ }
+ }
+ normalizeAnimator.addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ ServiceManager.getService()?.startMainActivity()
+ close()
+ }
+ })
+
+ containerAnimator.start()
+ stretchAnimator.start()
+ normalizeAnimator.start()
+ }
+
+ private fun animateCustomStretch(progress: Float, duration: Long) {
+ try {
+ val mainLayout = islandView.findViewById(R.id.island_window_layout)
+ val connectedText = islandView.findViewById(R.id.island_connected_text)
+ val deviceText = islandView.findViewById(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,21 +599,30 @@ 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(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 {
+ 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
+ containerView.visibility = View.GONE
try {
- windowManager.removeView(islandView)
+ windowManager.removeView(containerView)
} catch (e: Exception) {
e("IslandWindow", "Error removing view: $e")
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt
index 40ca6be..edee981 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt
@@ -16,6 +16,8 @@
* along with this program. If not, see .
*/
+@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
@@ -86,15 +89,20 @@ object MediaController {
}, 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) {
+ if (!pausedForCrossDevice && audioManager.isMusicActive) {
Log.d("MediaController", "Pausing for cross device and taking over.")
sendPause(true)
pausedForCrossDevice = true
- ServiceManager.getService()?.takeOver()
+ 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")
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/Packets.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/Packets.kt
index 2f1a41e..752d00e 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/Packets.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/Packets.kt
@@ -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 =
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt
index 5e6381a..e6a28e8 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt
@@ -16,6 +16,8 @@
* along with this program. If not, see .
*/
+@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) {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/widgets/BatteryWidget.kt b/android/app/src/main/java/me/kavishdevar/librepods/widgets/BatteryWidget.kt
index ae4c33c..f67588b 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/widgets/BatteryWidget.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/widgets/BatteryWidget.kt
@@ -16,6 +16,7 @@
* along with this program. If not, see .
*/
+@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(
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/widgets/NoiseControlWidget.kt b/android/app/src/main/java/me/kavishdevar/librepods/widgets/NoiseControlWidget.kt
index 710257f..3f5af9d 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/widgets/NoiseControlWidget.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/widgets/NoiseControlWidget.kt
@@ -16,6 +16,7 @@
* along with this program. If not, see .
*/
+@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()
+ )
}
}
}
diff --git a/android/app/src/main/res/layout/island_window.xml b/android/app/src/main/res/layout/island_window.xml
index 6341dea..fd8b8ef 100644
--- a/android/app/src/main/res/layout/island_window.xml
+++ b/android/app/src/main/res/layout/island_window.xml
@@ -2,10 +2,9 @@
-
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index e46b8b2..7f5fdb0 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -64,4 +64,19 @@
Collect Logs
Saved Logs
No saved logs found
+ Auto-Connect preferences
+ Connect to your AirPods when its status is:
+ Disconnected
+ AirPods are not connected to a device
+ Idle
+ A device is connected to your AirPods, but not playing media or on a call
+ Playing media
+ A device is playing media on your AirPods
+ On call
+ A device is on a call with your AirPods
+ Connect to AirPods when your phone is:
+ Receiving a call
+ Your phone starts ringing
+ Starting media playback
+ Your phone starts playing media
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 2a01f29..415d5ce 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -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" }