android: refactor AACP and add autoconnect based on BLE broadcasts

This commit is contained in:
Kavish Devar
2025-05-19 17:24:41 +05:30
parent 6985aa4a7b
commit 6a026ebab0
43 changed files with 2826 additions and 1648 deletions

View File

@@ -61,5 +61,6 @@ dependencies {
implementation(libs.androidx.constraintlayout)
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(libs.androidx.dynamicanimation)
compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
}

View File

@@ -31,7 +31,8 @@
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<protected-broadcast android:name="batterywidget.impl.action.update_bluetooth_data" />

View File

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

View File

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

View File

@@ -16,10 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -44,26 +44,26 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPreferences) {
fun AdaptiveStrengthSlider() {
val sliderValue = remember { mutableFloatStateOf(0f) }
val service = ServiceManager.getService()!!
LaunchedEffect(sliderValue) {
if (sharedPreferences.contains("adaptive_strength")) {
sliderValue.floatValue = sharedPreferences.getInt("adaptive_strength", 0).toFloat()
}
}
LaunchedEffect(sliderValue.floatValue) {
sharedPreferences.edit().putInt("adaptive_strength", sliderValue.floatValue.toInt()).apply()
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
}
val isDarkTheme = isSystemInDarkTheme()
@@ -86,7 +86,10 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
valueRange = 0f..100f,
onValueChangeFinished = {
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
service.setAdaptiveStrength(100 - sliderValue.floatValue.toInt())
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
value = (100 - sliderValue.floatValue).toInt()
)
},
modifier = Modifier
.fillMaxWidth()
@@ -151,5 +154,5 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre
@Preview
@Composable
fun AdaptiveStrengthSliderPreview() {
AdaptiveStrengthSlider(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
AdaptiveStrengthSlider()
}

View File

@@ -16,10 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
@@ -30,7 +30,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
@@ -38,10 +37,10 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.AirPodsService
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) {
fun AudioSettings() {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -64,9 +63,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
.padding(top = 2.dp)
) {
PersonalizedVolumeSwitch(service = service, sharedPreferences = sharedPreferences)
ConversationalAwarenessSwitch(service = service, sharedPreferences = sharedPreferences)
LoudSoundReductionSwitch(service = service, sharedPreferences = sharedPreferences)
ConversationalAwarenessSwitch()
Column(
modifier = Modifier
@@ -95,7 +92,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
)
)
AdaptiveStrengthSlider(service = service, sharedPreferences = sharedPreferences)
AdaptiveStrengthSlider()
}
}
}
@@ -103,5 +100,5 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences)
@Preview
@Composable
fun AudioSettingsPreview() {
AudioSettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE))
AudioSettings()
}

View File

@@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.BroadcastReceiver
@@ -50,6 +52,7 @@ import me.kavishdevar.librepods.utils.AirPodsNotifications
import me.kavishdevar.librepods.utils.Battery
import me.kavishdevar.librepods.utils.BatteryComponent
import me.kavishdevar.librepods.utils.BatteryStatus
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun BatteryView(service: AirPodsService, preview: Boolean = false) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.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,34 +74,33 @@ 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 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 preferenceChangeListener = remember {
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "off_listening_mode") {
offListeningMode.value = sharedPreferences.getBoolean("off_listening_mode", true)
}
val offListeningModeListener = object: AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
offListeningMode.value = controlCommand.value[0] == 1.toByte()
}
}
DisposableEffect(Unit) {
sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
onDispose {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
}
}
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
offListeningModeListener
)
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
@@ -116,27 +115,21 @@ fun NoiseControlSettings(
val d3a = remember { mutableFloatStateOf(0f) }
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
val previousMode = noiseControlMode.value // Store previous mode
val previousMode = noiseControlMode.value
// Ensure the mode is valid if 'Off' is disabled
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
// If trying to select OFF but it's disabled, default to Transparency or Adaptive
NoiseControlMode.TRANSPARENCY // Or ADAPTIVE, based on preference
NoiseControlMode.TRANSPARENCY
} else {
mode
}
noiseControlMode.value = targetMode // Update internal state immediately
noiseControlMode.value = targetMode
// Only call service if the mode was manually selected (!received)
// and the target mode is actually different from the previous mode
if (!received && targetMode != previousMode) {
service.setANCMode(targetMode.ordinal + 1)
// onModeSelectedCallback() // REMOVE this call to keep dialog open
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
}
// Update divider alphas based on the *new* mode
when (noiseControlMode.value) { // Use the updated noiseControlMode.value
when (noiseControlMode.value) {
NoiseControlMode.NOISE_CANCELLATION -> {
d1a.floatValue = 1f
d2a.floatValue = 1f
@@ -447,5 +440,5 @@ fun NoiseControlSettings(
@Preview()
@Composable
fun NoiseControlSettingsPreview() {
NoiseControlSettings(AirPodsService()) {}
NoiseControlSettings(AirPodsService())
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.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,7 +194,10 @@ 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() {
@@ -263,41 +269,8 @@ class AirPodsQSService : TileService() {
}
}
@ExperimentalMaterial3Api
override fun onTileAdded() {
super.onTileAdded()
Log.d("AirPodsQSService", "Tile added")
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
@ExperimentalMaterial3Api
fun openMainActivity() {
Log.d("AirPodsQSService", "Opening MainActivity")
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION")
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivityAndCollapse(intent)
}
Log.d("AirPodsQSService", "Called startActivityAndCollapse for MainActivity")
} catch (e: Exception) {
Log.e("AirPodsQSService", "Error launching MainActivity: $e")
}
}
}

View File

@@ -0,0 +1,478 @@
/*
* LibrePods - AirPods liberated from Apple's ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
import android.util.Log
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers.entries
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Manager class for Apple Accessory Communication Protocol (AACP)
* This class is responsible for handling the L2CAP socket management,
* constructing and parsing packets for communication with Apple accessories.
*/
class AACPManager {
companion object {
private const val TAG = "AACPManager"
object Opcodes {
const val SET_FEATURE_FLAGS: Byte = 0x4d
const val REQUEST_NOTIFICATIONS: Byte = 0x0f
const val BATTERY_INFO: Byte = 0x04
const val CONTROL_COMMAND: Byte = 0x09
const val EAR_DETECTION: Byte = 0x06
const val CONVERSATION_AWARENESS: Byte = 0x4b
const val DEVICE_METADATA: Byte = 0x1d
const val RENAME: Byte = 0x1E
const val HEADTRACKING: Byte = 0x17
const val PROXIMITY_KEYS_REQ: Byte = 0x30
const val PROXIMITY_KEYS_RSP: Byte = 0x31
}
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
data class ControlCommandStatus(
val identifier: ControlCommandIdentifiers,
val value: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ControlCommandStatus
if (identifier != other.identifier) return false
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
var result: Int = identifier.hashCode()
result = 31 * result + value.contentHashCode()
return result
}
}
// @Suppress("unused")
enum class ControlCommandIdentifiers(val value: Byte) {
MIC_MODE(0x01),
BUTTON_SEND_MODE(0x05),
VOICE_TRIGGER(0x12),
SINGLE_CLICK_MODE(0x14),
DOUBLE_CLICK_MODE(0x15),
CLICK_HOLD_MODE(0x16),
DOUBLE_CLICK_INTERVAL(0x17),
CLICK_HOLD_INTERVAL(0x18),
LISTENING_MODE_CONFIGS(0x1A),
ONE_BUD_ANC_MODE(0x1B),
CROWN_ROTATION_DIRECTION(0x1C),
LISTENING_MODE(0x0D),
AUTO_ANSWER_MODE(0x1E),
CHIME_VOLUME(0x1F),
VOLUME_SWIPE_INTERVAL(0x23),
CALL_MANAGEMENT_CONFIG(0x24),
VOLUME_SWIPE_MODE(0x25),
ADAPTIVE_VOLUME_CONFIG(0x26),
SOFTWARE_MUTE_CONFIG(0x27),
CONVERSATION_DETECT_CONFIG(0x28),
SSL(0x29),
HEARING_AID(0x2C),
AUTO_ANC_STRENGTH(0x2E),
HPS_GAIN_SWIPE(0x2F),
HRM_STATE(0x30),
IN_CASE_TONE_CONFIG(0x31),
SIRI_MULTITONE_CONFIG(0x32),
HEARING_ASSIST_CONFIG(0x33),
ALLOW_OFF_OPTION(0x34);
companion object {
fun fromByte(byte: Byte): ControlCommandIdentifiers? =
entries.find { it.value == byte }
}
}
enum class ProximityKeyType(val value: Byte) {
IRK(0x01),
ENC_KEY(0x04);
companion object {
fun fromByte(byte: Byte): ProximityKeyType =
ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
}
}
}
var controlCommandStatusList: MutableList<ControlCommandStatus> = mutableListOf<ControlCommandStatus>()
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> = mutableMapOf()
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
return controlCommandStatusList.find { it.identifier == identifier }
}
private fun setControlCommandStatusValue(identifier: ControlCommandIdentifiers, value: ByteArray) {
val existingStatus = getControlCommandStatus(identifier)
if (existingStatus == value) {
controlCommandStatusList.remove(existingStatus)
}
if (existingStatus != null) {
controlCommandStatusList.remove(existingStatus)
}
controlCommandListeners[identifier]?.forEach { listener ->
listener.onControlCommandReceived(ControlCommand(identifier.value, value))
}
controlCommandStatusList.add(ControlCommandStatus(identifier, value))
}
interface PacketCallback {
fun onBatteryInfoReceived(batteryInfo: ByteArray)
fun onEarDetectionReceived(earDetection: ByteArray)
fun onConversationAwarenessReceived(conversationAwareness: ByteArray)
fun onControlCommandReceived(controlCommand: ByteArray)
fun onDeviceMetadataReceived(deviceMetadata: ByteArray)
fun onHeadTrackingReceived(headTracking: ByteArray)
fun onUnknownPacketReceived(packet: ByteArray)
fun onProximityKeysReceived(proximityKeys: ByteArray)
}
interface ControlCommandListener {
fun onControlCommandReceived(controlCommand: ControlCommand)
}
fun registerControlCommandListener(identifier: ControlCommandIdentifiers, callback: ControlCommandListener) {
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
}
private var callback: PacketCallback? = null
fun setPacketCallback(callback: PacketCallback) {
this.callback = callback
}
fun createDataPacket(data: ByteArray): ByteArray {
return HEADER_BYTES + data
}
fun createControlCommandPacket(identifier: Byte, data: ByteArray): ByteArray {
val opcode = byteArrayOf(Opcodes.CONTROL_COMMAND, 0x00)
val payload = ByteArray(7)
System.arraycopy(opcode, 0, payload, 0, 2)
payload[2] = identifier
val dataLength = minOf(data.size, 4)
System.arraycopy(data, 0, payload, 3, dataLength)
return payload
}
fun sendDataPacket(data: ByteArray): Boolean {
return sendPacket(createDataPacket(data))
}
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
val controlPacket = createControlCommandPacket(identifier, value)
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
value
)
return sendDataPacket(controlPacket)
}
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
byteArrayOf(value)
)
return sendDataPacket(controlPacket)
}
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
val controlPacket = createControlCommandPacket(identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02))
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
)
return sendDataPacket(controlPacket)
}
fun sendControlCommand(identifier: Byte, value: Int): Boolean {
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value.toByte()))
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
byteArrayOf(value.toByte())
)
return sendDataPacket(controlPacket)
}
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
Log.d(TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}")
if (data.size < 4) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
}
if (data[4] != Opcodes.PROXIMITY_KEYS_RSP) {
throw IllegalArgumentException("Data array does not start with PROXIMITY_KEYS_RSP opcode")
}
val keyCount = data[6].toInt()
val keys = mutableMapOf<ProximityKeyType, ByteArray>()
var offset = 7
for (i in 0 until keyCount) {
Log.d(TAG, "Parsing Proximity Key $i")
if (offset + 3 >= data.size) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
}
val keyType = data[offset]
val keyLength = data[offset + 2].toInt()
Log.d(TAG, "Key Type: ${keyType.toString(16)}, Key Length: $keyLength")
offset += 4
if (offset + keyLength > data.size) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
}
val key = ByteArray(keyLength)
System.arraycopy(data, offset, key, 0, keyLength)
keys[ProximityKeyType.fromByte(keyType)] = key
offset += keyLength
Log.d(TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${key.joinToString(" ") { "%02X".format(it) }}")
}
return keys
}
fun sendRequestProximityKeys(type: Byte): Boolean {
Log.d(TAG, "Requesting proximity keys of type: ${type.toString(16)}")
return sendDataPacket(createRequestProximityKeysPacket(type))
}
fun createRequestProximityKeysPacket(type: Byte): ByteArray {
val opcode = byteArrayOf(Opcodes.PROXIMITY_KEYS_REQ, 0x00)
val data = byteArrayOf(type, 0x00)
return opcode + data
}
@OptIn(ExperimentalStdlibApi::class)
fun receivePacket(packet: ByteArray) {
if (!packet.toHexString().startsWith("04000400")) {
Log.w(TAG, "Received packet does not start with expected header: ${packet.joinToString(" ") { "%02X".format(it) }}")
return
}
if (packet.size < 6) {
Log.w(TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
return
}
val opcode = packet[4]
when (opcode) {
Opcodes.BATTERY_INFO -> {
callback?.onBatteryInfoReceived(packet)
}
Opcodes.CONTROL_COMMAND -> {
val controlCommand = ControlCommand.fromByteArray(packet)
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return,
controlCommand.value
)
Log.d(TAG, "Control command received: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
Log.d(TAG, "Control command list is now: ${
controlCommandStatusList.joinToString(", ") { "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${it.value.joinToString(" ") { "%02X".format(it) }}" }
}")
val controlCommandIdentifier = ControlCommandIdentifiers.fromByte(controlCommand.identifier)
if (controlCommandIdentifier != null) {
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
listener.onControlCommandReceived(controlCommand)
}
} else {
Log.w(TAG, "Unknown control command identifier: ${controlCommand.identifier.toHexString()}")
}
callback?.onControlCommandReceived(packet)
}
Opcodes.EAR_DETECTION -> {
callback?.onEarDetectionReceived(packet)
}
Opcodes.CONVERSATION_AWARENESS -> {
callback?.onConversationAwarenessReceived(packet)
}
Opcodes.DEVICE_METADATA -> {
callback?.onDeviceMetadataReceived(packet)
}
Opcodes.HEADTRACKING -> {
if (packet.size < 70) {
Log.w(TAG, "Received HEADTRACKING packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}")
return
}
callback?.onHeadTrackingReceived(packet)
}
Opcodes.PROXIMITY_KEYS_RSP -> {
callback?.onProximityKeysReceived(packet)
}
else -> {
callback?.onUnknownPacketReceived(packet)
}
}
}
fun sendNotificationRequest(): Boolean {
return sendDataPacket(createRequestNotificationPacket())
}
fun createRequestNotificationPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.REQUEST_NOTIFICATIONS, 0x00)
val data = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())
return opcode + data
}
fun sendSetFeatureFlagsPacket(): Boolean {
return sendDataPacket(createSetFeatureFlagsPacket())
}
fun createSetFeatureFlagsPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00)
val data = byteArrayOf(0xFF.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00)
return opcode + data
}
fun createHandshakePacket(): ByteArray {
return byteArrayOf(
0x00, 0x00, 0x04, 0x00,
0x01, 0x00, 0x02, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
)
}
fun sendStartHeadTracking(): Boolean {
return sendDataPacket(createStartHeadTrackingPacket())
}
fun createStartHeadTrackingPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
val data = byteArrayOf(
0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00,
)
return opcode + data
}
fun sendStopHeadTracking(): Boolean {
return sendDataPacket(createStopHeadTrackingPacket())
}
fun createStopHeadTrackingPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00)
val data = byteArrayOf(
0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E, 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
)
return opcode + data
}
fun sendRename(name: String): Boolean {
return sendDataPacket(createRenamePacket(name))
}
fun createRenamePacket(name: String): ByteArray {
val nameBytes = name.toByteArray()
val size = nameBytes.size
val packet = ByteArray(5 + size)
packet[0] = Opcodes.RENAME
packet[1] = 0x00
packet[2] = size.toByte()
packet[3] = 0x00
System.arraycopy(nameBytes, 0, packet, 4, size)
return packet
}
data class ControlCommand(
val identifier: Byte,
val value: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ControlCommand
if (identifier != other.identifier) return false
if (!value.contentEquals(other.value)) return false
return true
}
override fun hashCode(): Int {
var result: Int = identifier.toInt()
result = 31 * result + value.contentHashCode()
return result
}
companion object {
fun fromByteArray(data: ByteArray): ControlCommand {
if (data.size < 4) {
throw IllegalArgumentException("Data array too short to parse ControlCommand")
}
if (data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() && data[3] == 0x00.toByte()) {
val newData = ByteArray(data.size - 4)
System.arraycopy(data, 4, newData, 0, data.size - 4)
return fromByteArray(newData)
}
if (data[0] != Opcodes.CONTROL_COMMAND) {
throw IllegalArgumentException("Data array does not start with CONTROL_COMMAND opcode")
}
val identifier = data[2]
val value = ByteArray(4)
System.arraycopy(data, 3, value, 0, 4)
// drop trailing zeroes in the array, and return the bytearray of the reduced array
val trimmedValue = value.takeWhile { it != 0x00.toByte() }.toByteArray()
return ControlCommand(identifier, trimmedValue)
}
}
}
@OptIn(ExperimentalStdlibApi::class)
fun sendPacket(packet: ByteArray): Boolean {
try {
Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}")
if (packet[4] == Opcodes.CONTROL_COMMAND) {
val controlCommand = ControlCommand.fromByteArray(packet)
Log.d(TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}")
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
controlCommand.value
)
}
val socket = BluetoothConnectionManager.getCurrentSocket()
if (socket?.isConnected == true) {
socket.outputStream?.write(packet)
socket.outputStream?.flush()
return true
} else {
Log.d(TAG, "Can't send packet: Socket not initialized or connected")
return false
}
} catch (e: Exception) {
Log.e(TAG, "Error sending packet: ${e.message}")
return false
}
}
}

View File

@@ -0,0 +1,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 <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
import android.util.Log
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Manager for Bluetooth Low Energy scanning operations specifically for AirPods
*/
@OptIn(ExperimentalEncodingApi::class)
class BLEManager(private val context: Context) {
data class AirPodsStatus(
val address: String,
val lastSeen: Long = System.currentTimeMillis(),
val paired: Boolean = false,
val model: String = "Unknown",
val leftBattery: Int? = null,
val rightBattery: Int? = null,
val caseBattery: Int? = null,
val isLeftInEar: Boolean = false,
val isRightInEar: Boolean = false,
val isLeftCharging: Boolean = false,
val isRightCharging: Boolean = false,
val isCaseCharging: Boolean = false,
val lidOpen: Boolean = false,
val color: String = "Unknown",
val connectionState: String = "Unknown"
)
fun getMostRecentStatus(): AirPodsStatus? {
return deviceStatusMap.values.maxByOrNull { it.lastSeen }
}
interface AirPodsStatusListener {
fun onDeviceStatusChanged(device: AirPodsStatus, previousStatus: AirPodsStatus?)
fun onBroadcastFromNewAddress(device: AirPodsStatus)
fun onLidStateChanged(lidOpen: Boolean)
fun onEarStateChanged(device: AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean)
fun onBatteryChanged(device: AirPodsStatus)
}
private var mBluetoothLeScanner: BluetoothLeScanner? = null
private var mScanCallback: ScanCallback? = null
private var airPodsStatusListener: AirPodsStatusListener? = null
private val deviceStatusMap = mutableMapOf<String, AirPodsStatus>()
private val verifiedAddresses = mutableSetOf<String>()
private val sharedPreferences: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
private var currentGlobalLidState: Boolean? = null
private var lastBroadcastTime: Long = 0
private val processedAddresses = mutableSetOf<String>()
private val modelNames = mapOf(
0x0E20 to "AirPods Pro",
0x1420 to "AirPods Pro 2",
0x2420 to "AirPods Pro 2 (USB-C)",
0x0220 to "AirPods 1",
0x0F20 to "AirPods 2",
0x1320 to "AirPods 3",
0x1920 to "AirPods 4",
0x1B20 to "AirPods 4 (ANC)",
0x0A20 to "AirPods Max",
0x1F20 to "AirPods Max (USB-C)"
)
val colorNames = mapOf(
0x00 to "White", 0x01 to "Black", 0x02 to "Red", 0x03 to "Blue",
0x04 to "Pink", 0x05 to "Gray", 0x06 to "Silver", 0x07 to "Gold",
0x08 to "Rose Gold", 0x09 to "Space Gray", 0x0A to "Dark Blue",
0x0B to "Light Blue", 0x0C to "Yellow"
)
val connStates = mapOf(
0x00 to "Disconnected", 0x04 to "Idle", 0x05 to "Music",
0x06 to "Call", 0x07 to "Ringing", 0x09 to "Hanging Up", 0xFF to "Unknown"
)
private val cleanupHandler = Handler(Looper.getMainLooper())
private val cleanupRunnable = object : Runnable {
override fun run() {
cleanupStaleDevices()
checkLidStateTimeout()
cleanupHandler.postDelayed(this, CLEANUP_INTERVAL_MS)
}
}
fun setAirPodsStatusListener(listener: AirPodsStatusListener) {
airPodsStatusListener = listener
}
@SuppressLint("MissingPermission")
fun startScanning() {
try {
Log.d(TAG, "Starting BLE scanner")
val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
val btAdapter = btManager.adapter
if (btAdapter == null) {
Log.d(TAG, "No Bluetooth adapter available")
return
}
if (mBluetoothLeScanner != null && mScanCallback != null) {
mBluetoothLeScanner?.stopScan(mScanCallback)
mScanCallback = null
}
if (!btAdapter.isEnabled) {
Log.d(TAG, "Bluetooth is disabled")
return
}
mBluetoothLeScanner = btAdapter.bluetoothLeScanner
val scanSettings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT)
.setReportDelay(500L)
.build()
val manufacturerData = ByteArray(27)
val manufacturerDataMask = ByteArray(27)
manufacturerData[0] = 7
manufacturerData[1] = 25
manufacturerDataMask[0] = -1
manufacturerDataMask[1] = -1
val scanFilter = ScanFilter.Builder()
.setManufacturerData(76, manufacturerData, manufacturerDataMask)
.build()
mScanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
processScanResult(result)
}
override fun onBatchScanResults(results: List<ScanResult>) {
processedAddresses.clear()
for (result in results) {
processScanResult(result)
}
}
override fun onScanFailed(errorCode: Int) {
Log.e(TAG, "BLE scan failed with error code: $errorCode")
}
}
mBluetoothLeScanner?.startScan(listOf(scanFilter), scanSettings, mScanCallback)
Log.d(TAG, "BLE scanner started successfully")
cleanupHandler.postDelayed(cleanupRunnable, CLEANUP_INTERVAL_MS)
} catch (t: Throwable) {
Log.e(TAG, "Error starting BLE scanner", t)
}
}
@SuppressLint("MissingPermission")
fun stopScanning() {
try {
if (mBluetoothLeScanner != null && mScanCallback != null) {
Log.d(TAG, "Stopping BLE scanner")
mBluetoothLeScanner?.stopScan(mScanCallback)
mScanCallback = null
}
cleanupHandler.removeCallbacks(cleanupRunnable)
} catch (t: Throwable) {
Log.e(TAG, "Error stopping BLE scanner", t)
}
}
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
}
}

View File

@@ -0,0 +1,40 @@
/*
* LibrePods - AirPods liberated from Apple's ecosystem
*
* Copyright (C) 2025 LibrePods Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.util.Log
object BluetoothConnectionManager {
private const val TAG = "BluetoothConnectionManager"
private var currentSocket: BluetoothSocket? = null
private var currentDevice: BluetoothDevice? = null
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
currentSocket = socket
currentDevice = device
Log.d(TAG, "Current connection set to device: ${device.address}")
}
fun getCurrentSocket(): BluetoothSocket? {
return currentSocket
}
}

View File

@@ -0,0 +1,74 @@
/*
* LibrePods - AirPods liberated from Apple's ecosystem
*
* Copyright (C) 2025 LibrePods Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
/**
* Utilities for Bluetooth cryptography operations, particularly for
* verifying Resolvable Private Addresses (RPA) used by AirPods.
*/
object BluetoothCryptography {
/**
* Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK)
*
* @param addr The Bluetooth address to verify
* @param irk The Identity Resolving Key to use for verification
* @return true if the address is verified as an RPA matching the IRK
*/
fun verifyRPA(addr: String, irk: ByteArray): Boolean {
val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray()
val prand = rpa.copyOfRange(3, 6)
val hash = rpa.copyOfRange(0, 3)
val computedHash = ah(irk, prand)
return hash.contentEquals(computedHash)
}
/**
* Performs E function (AES-128) as specified in Bluetooth Core Specification
*
* @param key The key for encryption
* @param data The data to encrypt
* @return The encrypted data
*/
fun e(key: ByteArray, data: ByteArray): ByteArray {
val swappedKey = key.reversedArray()
val swappedData = data.reversedArray()
val cipher = Cipher.getInstance("AES/ECB/NoPadding")
val secretKey = SecretKeySpec(swappedKey, "AES")
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
return cipher.doFinal(swappedData).reversedArray()
}
/**
* Performs the ah function as specified in Bluetooth Core Specification
*
* @param k The IRK key
* @param r The random part of the address
* @return The hash part of the address
*/
fun ah(k: ByteArray, r: ByteArray): ByteArray {
val rPadded = ByteArray(16)
r.copyInto(rPadded, 0, 0, 3)
val encrypted = e(k, rPadded)
return encrypted.copyOfRange(0, 3)
}
}

View File

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

View File

@@ -1,3 +1,5 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
import android.os.Build
@@ -13,6 +15,7 @@ import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.services.ServiceManager
import java.util.Collections
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
@@ -20,14 +23,11 @@ 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
@@ -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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
import android.content.SharedPreferences
@@ -28,6 +30,7 @@ import android.util.Log
import android.view.KeyEvent
import androidx.annotation.RequiresApi
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
object MediaController {
private var initialVolume: Int? = null
@@ -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")

View File

@@ -136,10 +136,23 @@ class AirPodsNotifications {
}
fun setStatus(data: ByteArray) {
if (data.size != 11) {
return
when (data.size) {
// if the whole packet is given
11 -> {
status = data[7].toInt()
}
// if only the data is given
1 -> {
status = data[0].toInt()
}
// if the value of control command is given
4 -> {
status = data[0].toInt()
}
else -> {
Log.d("ANC", "Invalid ANC data size: ${data.size}")
}
}
status = data[7].toInt()
}
val name: String =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" }