From 6a026ebab07ee551b58371d57fb75c1736a90baa Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Mon, 19 May 2025 17:24:41 +0530 Subject: [PATCH] android: refactor AACP and add autoconnect based on BLE broadcasts --- android/app/build.gradle.kts | 1 + android/app/src/main/AndroidManifest.xml | 3 +- .../librepods/QuickSettingsDialogActivity.kt | 28 +- .../composables/AccessibilitySettings.kt | 89 +- .../composables/AdaptiveStrengthSlider.kt | 39 +- .../librepods/composables/AudioSettings.kt | 25 +- .../librepods/composables/BatteryView.kt | 11 +- ...ontrolCenterNoiseControlSegmentedButton.kt | 4 +- .../ConversationalAwarenessSwitch.kt | 24 +- .../composables/IndependentToggle.kt | 47 +- .../composables/LoudSoundReductionSwitch.kt | 126 -- .../composables/NoiseControlSettings.kt | 63 +- .../composables/PersonalizedVolumeSwitch.kt | 126 -- .../composables/SinglePodANCSwitch.kt | 34 +- .../librepods/composables/ToneVolumeSlider.kt | 39 +- .../composables/TransparencySettings.kt | 270 ---- .../composables/VolumeControlSwitch.kt | 34 +- .../librepods/receivers/BootReceiver.kt | 3 + .../screens/AirPodsSettingsScreen.kt | 16 +- .../librepods/screens/AppSettingsScreen.kt | 315 +++++ .../librepods/screens/DebugScreen.kt | 10 +- .../librepods/screens/HeadTrackingScreen.kt | 3 + .../screens/PressAndHoldSettingsScreen.kt | 171 ++- .../librepods/screens/RenameScreen.kt | 5 +- .../librepods/services/AirPodsQSService.kt | 53 +- .../librepods/services/AirPodsService.kt | 1222 +++++++---------- .../librepods/utils/AACPManager.kt | 478 +++++++ .../kavishdevar/librepods/utils/BLEManager.kt | 429 ++++++ .../utils/BluetoothConnectionManager.kt | 40 + .../librepods/utils/BluetoothCryptography.kt | 74 + .../librepods/utils/CrossDevice.kt | 6 +- .../librepods/utils/GestureDetector.kt | 16 +- .../librepods/utils/GestureFeedback.kt | 49 - .../librepods/utils/HeadOrientation.kt | 1 - .../librepods/utils/IslandWindow.kt | 533 ++++++- .../librepods/utils/MediaController.kt | 12 +- .../me/kavishdevar/librepods/utils/Packets.kt | 19 +- .../librepods/utils/RadareOffsetFinder.kt | 3 + .../librepods/widgets/BatteryWidget.kt | 2 + .../librepods/widgets/NoiseControlWidget.kt | 10 +- .../app/src/main/res/layout/island_window.xml | 24 +- android/app/src/main/res/values/strings.xml | 15 + android/gradle/libs.versions.toml | 2 + 43 files changed, 2826 insertions(+), 1648 deletions(-) delete mode 100644 android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt delete mode 100644 android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt delete mode 100644 android/app/src/main/java/me/kavishdevar/librepods/composables/TransparencySettings.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothConnectionManager.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 114ab39..269374c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -61,5 +61,6 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.haze) implementation(libs.haze.materials) + implementation(libs.androidx.dynamicanimation) compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ced71e7..f3f1867 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -31,7 +31,8 @@ - + diff --git a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt index c6d1214..c865f9b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt @@ -1,5 +1,8 @@ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods +import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context @@ -72,16 +75,12 @@ import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedBu import me.kavishdevar.librepods.composables.VerticalVolumeSlider import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.ui.theme.LibrePodsTheme +import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AirPodsNotifications import me.kavishdevar.librepods.utils.NoiseControlMode +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs -data class DismissAnimationValues( - val offsetY: Dp = 0.dp, - val scale: Float = 1f, - val alpha: Float = 1f -) - class QuickSettingsDialogActivity : ComponentActivity() { private var airPodsService: AirPodsService? = null @@ -114,7 +113,6 @@ class QuickSettingsDialogActivity : ComponentActivity() { isNoiseControlExpanded = isNoiseControlExpandedState, onNoiseControlExpandedChange = { isNoiseControlExpandedState = it } ) - } else { } } } @@ -159,7 +157,6 @@ class QuickSettingsDialogActivity : ComponentActivity() { isNoiseControlExpanded = isNoiseControlExpandedState, onNoiseControlExpandedChange = { isNoiseControlExpandedState = it } ) - } else { } } } @@ -182,7 +179,6 @@ fun DraggableDismissBox( content: @Composable () -> Unit ) { val coroutineScope = rememberCoroutineScope() - val density = LocalDensity.current var dragOffset by remember { mutableFloatStateOf(0f) } var isDragging by remember { mutableStateOf(false) } @@ -218,7 +214,6 @@ fun DraggableDismissBox( LaunchedEffect(dragOffset, isDragging) { if (isDragging) { - val dragDirection = if (dragOffset > 0) 1f else -1f val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f) animatedOffset.snapTo(dragOffset) @@ -285,6 +280,7 @@ fun DraggableDismissBox( } } +@SuppressLint("UnspecifiedRegisterReceiverFlag") @Composable fun NewControlCenterDialogContent( service: AirPodsService?, @@ -353,7 +349,7 @@ fun NewControlCenterDialogContent( } service?.let { - val initialModeOrdinal = it.getANC().minus(1) ?: NoiseControlMode.TRANSPARENCY.ordinal + val initialModeOrdinal = it.getANC().minus(1) var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY } if (!availableModes.contains(initialMode)) { initialMode = NoiseControlMode.TRANSPARENCY @@ -482,7 +478,10 @@ fun NewControlCenterDialogContent( availableModes = availableModes, selectedMode = currentAncMode, onModeSelected = { newMode -> - service.setANCMode(newMode.ordinal + 1) + service.aacpManager.sendControlCommand( + identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, + value = newMode.ordinal + 1 + ) currentAncMode = newMode }, modifier = Modifier.fillMaxWidth(0.8f) @@ -560,7 +559,10 @@ fun NewControlCenterDialogContent( .clickable( onClick = { val newState = !isConvAwarenessEnabled - service.setCAEnabled(newState) + service.aacpManager.sendControlCommand( + identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value, + value = newState + ) isConvAwarenessEnabled = newState }, indication = null, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt index 929d45d..75cbd2a 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt @@ -16,10 +16,10 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.composables -import android.content.Context -import android.content.SharedPreferences import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme @@ -38,7 +38,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -46,14 +45,16 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import kotlin.io.encoding.ExperimentalEncodingApi @Composable -fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPreferences) { +fun AccessibilitySettings() { val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - + val service = ServiceManager.getService()!! Text( text = stringResource(R.string.accessibility).uppercase(), style = TextStyle( @@ -87,51 +88,75 @@ fun AccessibilitySettings(service: AirPodsService, sharedPreferences: SharedPref ) ) - ToneVolumeSlider(service = service, sharedPreferences = sharedPreferences) + ToneVolumeSlider() } - val pressSpeedOptions = listOf("Default", "Slower", "Slowest") - var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[0]) } + val pressSpeedOptions = mapOf( + 0.toByte() to "Default", + 1.toByte() to "Slower", + 2.toByte() to "Slowest" + ) + val selectedPressSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0) + var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]) } DropdownMenuComponent( label = "Press Speed", - options = pressSpeedOptions, - selectedOption = selectedPressSpeed, - onOptionSelected = { - selectedPressSpeed = it - service.setPressSpeed(pressSpeedOptions.indexOf(it)) + options = pressSpeedOptions.values.toList(), + selectedOption = selectedPressSpeed.toString(), + onOptionSelected = { newValue -> + selectedPressSpeed = newValue + service.aacpManager.sendControlCommand( + identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value, + value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte() + ) }, textColor = textColor ) - val pressAndHoldDurationOptions = listOf("Default", "Slower", "Slowest") - var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[0]) } + val pressAndHoldDurationOptions = mapOf( + 0.toByte() to "Default", + 1.toByte() to "Slower", + 2.toByte() to "Slowest" + ) + + val selectedPressAndHoldDurationValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0) + var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] ?: pressAndHoldDurationOptions[0]) } DropdownMenuComponent( label = "Press and Hold Duration", - options = pressAndHoldDurationOptions, - selectedOption = selectedPressAndHoldDuration, - onOptionSelected = { - selectedPressAndHoldDuration = it - service.setPressAndHoldDuration(pressAndHoldDurationOptions.indexOf(it)) + options = pressAndHoldDurationOptions.values.toList(), + selectedOption = selectedPressAndHoldDuration.toString(), + onOptionSelected = { newValue -> + selectedPressAndHoldDuration = newValue + service.aacpManager.sendControlCommand( + identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value, + value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte() + ) }, textColor = textColor ) - val volumeSwipeSpeedOptions = listOf("Default", "Longer", "Longest") - var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[0]) } + val volumeSwipeSpeedOptions = mapOf( + 1.toByte() to "Default", + 2.toByte() to "Longer", + 3.toByte() to "Longest" + ) + val selectedVolumeSwipeSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0) + var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] ?: volumeSwipeSpeedOptions[1]) } DropdownMenuComponent( label = "Volume Swipe Speed", - options = volumeSwipeSpeedOptions, - selectedOption = selectedVolumeSwipeSpeed, - onOptionSelected = { - selectedVolumeSwipeSpeed = it - service.setVolumeSwipeSpeed(volumeSwipeSpeedOptions.indexOf(it)) + options = volumeSwipeSpeedOptions.values.toList(), + selectedOption = selectedVolumeSwipeSpeed.toString(), + onOptionSelected = { newValue -> + selectedVolumeSwipeSpeed = newValue + service.aacpManager.sendControlCommand( + identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value, + value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 1.toByte() + ) }, textColor = textColor ) - SinglePodANCSwitch(service = service, sharedPreferences = sharedPreferences) - VolumeControlSwitch(service = service, sharedPreferences = sharedPreferences) -// TransparencySettings(service = service, sharedPreferences = sharedPreferences) + SinglePodANCSwitch() + VolumeControlSwitch() } } @@ -192,5 +217,5 @@ fun DropdownMenuComponent( @Preview @Composable fun AccessibilitySettingsPreview() { - AccessibilitySettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE)) + AccessibilitySettings() } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt index 56e5295..ad6dc8d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt @@ -1,25 +1,25 @@ /* * LibrePods - AirPods liberated from Apple’s ecosystem - * + * * Copyright (C) 2025 LibrePods contributors - * + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.composables -import android.content.Context -import android.content.SharedPreferences import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -44,26 +44,26 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPreferences) { +fun AdaptiveStrengthSlider() { val sliderValue = remember { mutableFloatStateOf(0f) } + val service = ServiceManager.getService()!! LaunchedEffect(sliderValue) { - if (sharedPreferences.contains("adaptive_strength")) { - sliderValue.floatValue = sharedPreferences.getInt("adaptive_strength", 0).toFloat() - } - } - LaunchedEffect(sliderValue.floatValue) { - sharedPreferences.edit().putInt("adaptive_strength", sliderValue.floatValue.toInt()).apply() + val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH + }?.value?.takeIf { it.isNotEmpty() }?.get(0) + sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) } } val isDarkTheme = isSystemInDarkTheme() @@ -86,7 +86,10 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre valueRange = 0f..100f, onValueChangeFinished = { sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat() - service.setAdaptiveStrength(100 - sliderValue.floatValue.toInt()) + service.aacpManager.sendControlCommand( + identifier = AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value, + value = (100 - sliderValue.floatValue).toInt() + ) }, modifier = Modifier .fillMaxWidth() @@ -151,5 +154,5 @@ fun AdaptiveStrengthSlider(service: AirPodsService, sharedPreferences: SharedPre @Preview @Composable fun AdaptiveStrengthSliderPreview() { - AdaptiveStrengthSlider(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE)) -} \ No newline at end of file + AdaptiveStrengthSlider() +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt index 157e597..5e7cf63 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt @@ -1,25 +1,25 @@ /* * LibrePods - AirPods liberated from Apple’s ecosystem - * + * * Copyright (C) 2025 LibrePods contributors - * + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.composables -import android.content.Context -import android.content.SharedPreferences import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Column @@ -30,7 +30,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -38,10 +37,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.services.AirPodsService +import kotlin.io.encoding.ExperimentalEncodingApi @Composable -fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) { +fun AudioSettings() { val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black @@ -64,9 +63,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) .padding(top = 2.dp) ) { - PersonalizedVolumeSwitch(service = service, sharedPreferences = sharedPreferences) - ConversationalAwarenessSwitch(service = service, sharedPreferences = sharedPreferences) - LoudSoundReductionSwitch(service = service, sharedPreferences = sharedPreferences) + ConversationalAwarenessSwitch() Column( modifier = Modifier @@ -95,7 +92,7 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) ) ) - AdaptiveStrengthSlider(service = service, sharedPreferences = sharedPreferences) + AdaptiveStrengthSlider() } } } @@ -103,5 +100,5 @@ fun AudioSettings(service: AirPodsService, sharedPreferences: SharedPreferences) @Preview @Composable fun AudioSettingsPreview() { - AudioSettings(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", Context.MODE_PRIVATE)) + AudioSettings() } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt index dd2cfa5..bab51b1 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt @@ -1,21 +1,23 @@ /* * LibrePods - AirPods liberated from Apple’s ecosystem - * + * * Copyright (C) 2025 LibrePods contributors - * + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.composables import android.content.BroadcastReceiver @@ -50,6 +52,7 @@ import me.kavishdevar.librepods.utils.AirPodsNotifications import me.kavishdevar.librepods.utils.Battery import me.kavishdevar.librepods.utils.BatteryComponent import me.kavishdevar.librepods.utils.BatteryStatus +import kotlin.io.encoding.ExperimentalEncodingApi @Composable fun BatteryView(service: AirPodsService, preview: Boolean = false) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt index a25277b..7a1f335 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt @@ -39,7 +39,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -88,7 +88,7 @@ fun ControlCenterNoiseControlSegmentedButton( ) { val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0) val density = LocalDensity.current - var iconRowWidthPx by remember { mutableStateOf(0f) } + var iconRowWidthPx by remember { mutableFloatStateOf(0f) } val itemCount = availableModes.size val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt index de2f8ec..8c2aa7d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt @@ -16,9 +16,10 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.composables -import android.content.SharedPreferences import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures @@ -41,24 +42,31 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import kotlin.io.encoding.ExperimentalEncodingApi @Composable -fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) { +fun ConversationalAwarenessSwitch() { + val service = ServiceManager.getService()!! + val conversationEnabledValue = service.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG + }?.value?.takeIf { it.isNotEmpty() }?.get(0) var conversationalAwarenessEnabled by remember { mutableStateOf( - sharedPreferences.getBoolean("conversational_awareness", true) + conversationEnabledValue == 1.toByte() ) } fun updateConversationalAwareness(enabled: Boolean) { conversationalAwarenessEnabled = enabled - sharedPreferences.edit().putBoolean("conversational_awareness", enabled).apply() - service.setCAEnabled(enabled) + service.aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value, + enabled + ) } val isDarkTheme = isSystemInDarkTheme() @@ -121,5 +129,5 @@ fun ConversationalAwarenessSwitch(service: AirPodsService, sharedPreferences: Sh @Preview @Composable fun ConversationalAwarenessSwitchPreview() { - ConversationalAwarenessSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0)) + ConversationalAwarenessSwitch() } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt index f310f29..421c920 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.composables import android.content.SharedPreferences @@ -46,17 +48,40 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.utils.AACPManager +import kotlin.io.encoding.ExperimentalEncodingApi @Composable -fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false) { +fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false, controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers? = null) { val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black - - val snakeCasedName = name.replace(Regex("[\\W\\s]+"), "_").lowercase() + val snakeCasedName = + controlCommandIdentifier?.name ?: name.replace(Regex("[\\W\\s]+"), "_").lowercase() var checked by remember { mutableStateOf(default) } + + if (controlCommandIdentifier != null) { + checked = service!!.aacpManager.controlCommandStatusList.find { + it.identifier == controlCommandIdentifier + }?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte() + } + var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) + fun cb() { + if (controlCommandIdentifier == null) { + sharedPreferences.edit().putBoolean(snakeCasedName, checked).apply() + } + if (functionName != null && service != null) { + val method = + service::class.java.getMethod(functionName, Boolean::class.java) + method.invoke(service, checked) + } + if (controlCommandIdentifier != null) { + service?.aacpManager?.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked) + } + } + LaunchedEffect(sharedPreferences) { checked = sharedPreferences.getBoolean(snakeCasedName, true) } @@ -73,14 +98,7 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam }, onTap = { checked = !checked - sharedPreferences - .edit() - .putBoolean(snakeCasedName, checked) - .apply() - if (functionName != null && service != null) { - val method = service::class.java.getMethod(functionName, Boolean::class.java) - method.invoke(service, checked) - } + cb() } ) }, @@ -98,12 +116,7 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam checked = checked, onCheckedChange = { checked = it - sharedPreferences.edit().putBoolean(snakeCasedName, it).apply() - if (functionName != null && service != null) { - val method = - service::class.java.getMethod(functionName, Boolean::class.java) - method.invoke(service, it) - } + cb() }, ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt deleted file mode 100644 index 2f971f9..0000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt +++ /dev/null @@ -1,126 +0,0 @@ -/* - * LibrePods - AirPods liberated from Apple’s ecosystem - * - * Copyright (C) 2025 LibrePods contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package me.kavishdevar.librepods.composables - -import android.content.SharedPreferences -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.services.AirPodsService - -@Composable -fun LoudSoundReductionSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) { - var loudSoundReductionEnabled by remember { - mutableStateOf( - sharedPreferences.getBoolean("loud_sound_reduction", true) - ) - } - - fun updateLoudSoundReduction(enabled: Boolean) { - loudSoundReductionEnabled = enabled - sharedPreferences.edit().putBoolean("loud_sound_reduction", enabled).apply() - service.setLoudSoundReduction(enabled) - } - - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black - - val isPressed = remember { mutableStateOf(false) } - - Row( - modifier = Modifier - .fillMaxWidth() - .background( - shape = RoundedCornerShape(14.dp), - color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent - ) - .padding(horizontal = 12.dp, vertical = 12.dp) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - isPressed.value = true - tryAwaitRelease() - isPressed.value = false - } - ) - } - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - updateLoudSoundReduction(!loudSoundReductionEnabled) - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(end = 4.dp) - ) { - Text( - text = "Loud Sound Reduction", - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Reduces loud sounds you are exposed to.", - fontSize = 12.sp, - color = textColor.copy(0.6f), - lineHeight = 14.sp, - ) - } - - StyledSwitch( - checked = loudSoundReductionEnabled, - onCheckedChange = { - updateLoudSoundReduction(it) - }, - ) - } -} - -@Preview -@Composable -fun LoudSoundReductionSwitchPreview() { - LoudSoundReductionSwitch(AirPodsService(), LocalContext.current.getSharedPreferences("preview", 0)) -} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt index 4b7b111..7720c08 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt @@ -1,21 +1,23 @@ /* * LibrePods - AirPods liberated from Apple’s ecosystem - * + * * Copyright (C) 2025 LibrePods contributors - * + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.composables import android.annotation.SuppressLint @@ -23,7 +25,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.SharedPreferences import android.os.Build import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.Spring @@ -50,7 +51,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -74,35 +74,34 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import me.kavishdevar.librepods.R import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AirPodsNotifications import me.kavishdevar.librepods.utils.NoiseControlMode +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.roundToInt @SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope") @Composable fun NoiseControlSettings( service: AirPodsService, - onModeSelectedCallback: () -> Unit = {} // Callback parameter remains, but won't finish activity ) { val context = LocalContext.current - val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) - val offListeningMode = remember { mutableStateOf(sharedPreferences.getBoolean("off_listening_mode", true)) } - - val preferenceChangeListener = remember { - SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - if (key == "off_listening_mode") { - offListeningMode.value = sharedPreferences.getBoolean("off_listening_mode", true) - } - } - } - - DisposableEffect(Unit) { - sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener) - onDispose { - sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION + }?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte() + val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) } + + val offListeningModeListener = object: AACPManager.ControlCommandListener { + override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { + offListeningMode.value = controlCommand.value[0] == 1.toByte() } } + service.aacpManager.registerControlCommandListener( + AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION, + offListeningModeListener + ) + val isDarkTheme = isSystemInDarkTheme() val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8) val textColor = if (isDarkTheme) Color.White else Color.Black @@ -116,27 +115,21 @@ fun NoiseControlSettings( val d3a = remember { mutableFloatStateOf(0f) } fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) { - val previousMode = noiseControlMode.value // Store previous mode + val previousMode = noiseControlMode.value - // Ensure the mode is valid if 'Off' is disabled val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) { - // If trying to select OFF but it's disabled, default to Transparency or Adaptive - NoiseControlMode.TRANSPARENCY // Or ADAPTIVE, based on preference + NoiseControlMode.TRANSPARENCY } else { mode } - noiseControlMode.value = targetMode // Update internal state immediately + noiseControlMode.value = targetMode - // Only call service if the mode was manually selected (!received) - // and the target mode is actually different from the previous mode if (!received && targetMode != previousMode) { - service.setANCMode(targetMode.ordinal + 1) - // onModeSelectedCallback() // REMOVE this call to keep dialog open + service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1) } - // Update divider alphas based on the *new* mode - when (noiseControlMode.value) { // Use the updated noiseControlMode.value + when (noiseControlMode.value) { NoiseControlMode.NOISE_CANCELLATION -> { d1a.floatValue = 1f d2a.floatValue = 1f @@ -447,5 +440,5 @@ fun NoiseControlSettings( @Preview() @Composable fun NoiseControlSettingsPreview() { - NoiseControlSettings(AirPodsService()) {} -} \ No newline at end of file + NoiseControlSettings(AirPodsService()) +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt deleted file mode 100644 index 31379dc..0000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt +++ /dev/null @@ -1,126 +0,0 @@ -/* - * LibrePods - AirPods liberated from Apple’s ecosystem - * - * Copyright (C) 2025 LibrePods contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package me.kavishdevar.librepods.composables - -import android.content.SharedPreferences -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.services.AirPodsService - -@Composable -fun PersonalizedVolumeSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) { - var personalizedVolumeEnabled by remember { - mutableStateOf( - sharedPreferences.getBoolean("personalized_volume", true) - ) - } - - fun updatePersonalizedVolume(enabled: Boolean) { - personalizedVolumeEnabled = enabled - sharedPreferences.edit().putBoolean("personalized_volume", enabled).apply() - service.setPVEnabled(enabled) - } - - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black - - val isPressed = remember { mutableStateOf(false) } - - Row( - modifier = Modifier - .fillMaxWidth() - .background( - shape = RoundedCornerShape(14.dp), - color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent - ) - .padding(horizontal = 12.dp, vertical = 12.dp) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - isPressed.value = true - tryAwaitRelease() - isPressed.value = false - } - ) - } - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - updatePersonalizedVolume(!personalizedVolumeEnabled) - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(end = 4.dp) - ) { - Text( - text = "Personalized Volume", - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Adjusts the volume of media in response to your environment.", - fontSize = 12.sp, - color = textColor.copy(0.6f), - lineHeight = 14.sp, - ) - } - - StyledSwitch( - checked = personalizedVolumeEnabled, - onCheckedChange = { - updatePersonalizedVolume(it) - }, - ) - } -} - -@Preview -@Composable -fun PersonalizedVolumeSwitchPreview() { - PersonalizedVolumeSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0)) -} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt index 14410c7..370be0d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt @@ -1,24 +1,25 @@ /* * LibrePods - AirPods liberated from Apple’s ecosystem - * + * * Copyright (C) 2025 LibrePods contributors - * + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.composables -import android.content.SharedPreferences import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures @@ -41,24 +42,31 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import kotlin.io.encoding.ExperimentalEncodingApi @Composable -fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) { +fun SinglePodANCSwitch() { + val service = ServiceManager.getService()!! + val singleANCEnabledValue = service.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE + }?.value?.takeIf { it.isNotEmpty() }?.get(0) var singleANCEnabled by remember { mutableStateOf( - sharedPreferences.getBoolean("single_anc", true) + singleANCEnabledValue == 1.toByte() ) } fun updateSingleEnabled(enabled: Boolean) { singleANCEnabled = enabled - sharedPreferences.edit().putBoolean("single_anc", enabled).apply() - service.setNoiseCancellationWithOnePod(enabled) + service.aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE.value, + enabled + ) } val isDarkTheme = isSystemInDarkTheme() @@ -121,5 +129,5 @@ fun SinglePodANCSwitch(service: AirPodsService, sharedPreferences: SharedPrefere @Preview @Composable fun SinglePodANCSwitchPreview() { - SinglePodANCSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0)) -} \ No newline at end of file + SinglePodANCSwitch() +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt index 69d5edb..38e190e 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt @@ -16,9 +16,11 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.composables -import android.content.SharedPreferences +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -35,14 +37,12 @@ import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -51,21 +51,22 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferences) { - val sliderValue = remember { mutableFloatStateOf(0f) } - LaunchedEffect(sliderValue) { - if (sharedPreferences.contains("tone_volume")) { - sliderValue.floatValue = sharedPreferences.getInt("tone_volume", 0).toFloat() - } - } - LaunchedEffect(sliderValue.floatValue) { - sharedPreferences.edit().putInt("tone_volume", sliderValue.floatValue.toInt()).apply() - } +fun ToneVolumeSlider() { + val service = ServiceManager.getService()!! + val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME + }?.value?.takeIf { it.isNotEmpty() }?.get(0) + val sliderValue = remember { mutableFloatStateOf( + sliderValueFromAACP?.toFloat() ?: -1f + ) } + Log.d("ToneVolumeSlider", "Slider value: ${sliderValue.floatValue}") val isDarkTheme = isSystemInDarkTheme() @@ -74,7 +75,6 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) val labelTextColor = if (isDarkTheme) Color.White else Color.Black - Row( modifier = Modifier .fillMaxWidth(), @@ -99,7 +99,12 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc valueRange = 0f..100f, onValueChangeFinished = { sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat() - service.setToneVolume(volume = sliderValue.floatValue.toInt()) + service.aacpManager.sendControlCommand( + identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value, + value = byteArrayOf(sliderValue.floatValue.toInt().toByte(), + 0x50.toByte() + ) + ) }, modifier = Modifier .weight(1f) @@ -156,5 +161,5 @@ fun ToneVolumeSlider(service: AirPodsService, sharedPreferences: SharedPreferenc @Preview @Composable fun ToneVolumeSliderPreview() { - ToneVolumeSlider(AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0)) + ToneVolumeSlider() } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/TransparencySettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/TransparencySettings.kt deleted file mode 100644 index 3eece59..0000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/TransparencySettings.kt +++ /dev/null @@ -1,270 +0,0 @@ -package me.kavishdevar.librepods.composables - -import android.content.SharedPreferences -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Slider -import androidx.compose.material3.SliderDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.Font -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.services.AirPodsService - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TransparencySettings(service: AirPodsService, sharedPreferences: SharedPreferences) { - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black - - var transparencyModeCustomizationEnabled by remember { mutableStateOf(sharedPreferences.getBoolean("transparency_mode_customization", false)) } - var amplification by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_amplification", 0)) } - var balance by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_balance", 0)) } - var tone by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_tone", 0)) } - var ambientNoise by remember { mutableIntStateOf(sharedPreferences.getInt("transparency_ambient_noise", 0)) } - var conversationBoostEnabled by remember { mutableStateOf(sharedPreferences.getBoolean("transparency_conversation_boost", false)) } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 12.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - transparencyModeCustomizationEnabled = !transparencyModeCustomizationEnabled - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(end = 4.dp) - ) { - Text( - text = "Transparency Mode", - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "You can customize Transparency mode for your AirPods Pro.", - fontSize = 12.sp, - color = textColor.copy(0.6f), - lineHeight = 14.sp, - ) - } - StyledSwitch( - checked = transparencyModeCustomizationEnabled, - onCheckedChange = { - transparencyModeCustomizationEnabled = it - }, - ) - } - if (transparencyModeCustomizationEnabled) { - Spacer(modifier = Modifier.height(8.dp)) - SliderRow( - label = "Amplification", - value = amplification, - onValueChange = { - amplification = it - sharedPreferences.edit().putInt("transparency_amplification", it).apply() - }, - isDarkTheme = isDarkTheme - ) - Spacer(modifier = Modifier.height(8.dp)) - SliderRow( - label = "Balance", - value = balance, - onValueChange = { - balance = it - sharedPreferences.edit().putInt("transparency_balance", it).apply() - }, - isDarkTheme = isDarkTheme - ) - Spacer(modifier = Modifier.height(8.dp)) - SliderRow( - label = "Tone", - value = tone, - onValueChange = { - tone = it - sharedPreferences.edit().putInt("transparency_tone", it).apply() - }, - isDarkTheme = isDarkTheme - ) - Spacer(modifier = Modifier.height(8.dp)) - SliderRow( - label = "Ambient Noise", - value = ambientNoise, - onValueChange = { - ambientNoise = it - sharedPreferences.edit().putInt("transparency_ambient_noise", it).apply() - }, - isDarkTheme = isDarkTheme - ) - Spacer(modifier = Modifier.height(8.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - conversationBoostEnabled = !conversationBoostEnabled - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(end = 4.dp) - ) { - Text( - text = "Conversation Boost", - fontSize = 16.sp, - color = textColor - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Conversation Boost focuses your AirPods on the person in front of you, making it easier to hear in a face-to-face conversation.", - fontSize = 12.sp, - color = textColor.copy(0.6f), - lineHeight = 14.sp, - ) - } - StyledSwitch( - checked = conversationBoostEnabled, - onCheckedChange = { - conversationBoostEnabled = it - }, - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SliderRow( - label: String, - value: Int, - onValueChange: (Int) -> Unit, - isDarkTheme: Boolean -) { - val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) - val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) - val thumbColor = Color(0xFFFFFFFF) - val labelTextColor = if (isDarkTheme) Color.White else Color.Black - - Row( - modifier = Modifier - .fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - style = TextStyle( - fontSize = 16.sp, - color = labelTextColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - Text( - text = "\uDBC0\uDEA1", - style = TextStyle( - fontSize = 16.sp, - color = labelTextColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(start = 4.dp) - ) - Slider( - value = value.toFloat(), - onValueChange = { - onValueChange(it.toInt()) - }, - valueRange = 0f..100f, - onValueChangeFinished = { - onValueChange(value) - }, - modifier = Modifier - .weight(1f) - .height(36.dp), - colors = SliderDefaults.colors( - thumbColor = thumbColor, - activeTrackColor = activeTrackColor, - inactiveTrackColor = trackColor - ), - thumb = { - Box( - modifier = Modifier - .size(24.dp) - .shadow(4.dp, CircleShape) - .background(thumbColor, CircleShape) - ) - }, - track = { - Box( - modifier = Modifier - .fillMaxWidth() - .height(12.dp), - contentAlignment = Alignment.CenterStart - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .background(trackColor, RoundedCornerShape(4.dp)) - ) - Box( - modifier = Modifier - .fillMaxWidth(value.toFloat() / 100) - .height(4.dp) - .background(activeTrackColor, RoundedCornerShape(4.dp)) - ) - } - } - ) - Text( - text = "\uDBC0\uDEA9", - style = TextStyle( - fontSize = 16.sp, - color = labelTextColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(end = 4.dp) - ) - } -} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt index 1acbef4..41bc9cc 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt @@ -1,24 +1,25 @@ /* * LibrePods - AirPods liberated from Apple’s ecosystem - * + * * Copyright (C) 2025 LibrePods contributors - * + * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License. - * + * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. - * + * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.composables -import android.content.SharedPreferences import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures @@ -41,23 +42,30 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import me.kavishdevar.librepods.services.AirPodsService +import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import kotlin.io.encoding.ExperimentalEncodingApi @Composable -fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPreferences) { +fun VolumeControlSwitch() { + val service = ServiceManager.getService()!! + val volumeControlEnabledValue = service.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE + }?.value?.takeIf { it.isNotEmpty() }?.get(0) var volumeControlEnabled by remember { mutableStateOf( - sharedPreferences.getBoolean("volume_control", true) + volumeControlEnabledValue == 1.toByte() ) } fun updateVolumeControlEnabled(enabled: Boolean) { volumeControlEnabled = enabled - sharedPreferences.edit().putBoolean("volume_control", enabled).apply() - service.setVolumeControl(enabled) + service.aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE.value, + enabled + ) } val isDarkTheme = isSystemInDarkTheme() @@ -120,5 +128,5 @@ fun VolumeControlSwitch(service: AirPodsService, sharedPreferences: SharedPrefer @Preview @Composable fun VolumeControlSwitchPreview() { - VolumeControlSwitch(service = AirPodsService(), sharedPreferences = LocalContext.current.getSharedPreferences("preview", 0)) -} \ No newline at end of file + VolumeControlSwitch() +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt b/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt index d7a1a23..7a240c6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/receivers/BootReceiver.kt @@ -16,11 +16,14 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.receivers import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import kotlin.io.encoding.ExperimentalEncodingApi import me.kavishdevar.librepods.services.AirPodsService class BootReceiver: BroadcastReceiver() { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt index 149e987..ce325d5 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.screens import android.annotation.SuppressLint @@ -36,7 +38,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -84,7 +85,6 @@ import androidx.navigation.NavController import androidx.navigation.compose.rememberNavController import dev.chrisbanes.haze.HazeEffectScope import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.haze import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.CupertinoMaterials @@ -101,7 +101,9 @@ import me.kavishdevar.librepods.composables.NoiseControlSettings import me.kavishdevar.librepods.composables.PressAndHoldSettings import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.ui.theme.LibrePodsTheme +import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AirPodsNotifications +import kotlin.io.encoding.ExperimentalEncodingApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") @@ -355,7 +357,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, PressAndHoldSettings(navController = navController) Spacer(modifier = Modifier.height(16.dp)) - AudioSettings(service = service, sharedPreferences = sharedPreferences) + AudioSettings() Spacer(modifier = Modifier.height(16.dp)) IndependentToggle( @@ -363,20 +365,20 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, service = service, functionName = "setEarDetection", sharedPreferences = sharedPreferences, - true + default = true ) Spacer(modifier = Modifier.height(16.dp)) IndependentToggle( name = "Off Listening Mode", service = service, - functionName = "setOffListeningMode", sharedPreferences = sharedPreferences, - false + default = false, + controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION ) Spacer(modifier = Modifier.height(16.dp)) - AccessibilitySettings(service = service, sharedPreferences = sharedPreferences) + AccessibilitySettings() Spacer(modifier = Modifier.height(16.dp)) NavigationButton("debug", "Debug", navController) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt index ad0f8a6..30120be 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt @@ -119,7 +119,29 @@ fun AppSettingsScreen(navController: NavController) { var disconnectWhenNotWearing by remember { mutableStateOf(sharedPreferences.getBoolean("disconnect_when_not_wearing", false)) } + + var takeoverWhenDisconnected by remember { + mutableStateOf(sharedPreferences.getBoolean("takeover_when_disconnected", true)) + } + var takeoverWhenIdle by remember { + mutableStateOf(sharedPreferences.getBoolean("takeover_when_idle", true)) + } + var takeoverWhenMusic by remember { + mutableStateOf(sharedPreferences.getBoolean("takeover_when_music", false)) + } + var takeoverWhenCall by remember { + mutableStateOf(sharedPreferences.getBoolean("takeover_when_call", true)) + } + + var takeoverWhenRingingCall by remember { + mutableStateOf(sharedPreferences.getBoolean("takeover_when_ringing_call", true)) + } + var takeoverWhenMediaStart by remember { + mutableStateOf(sharedPreferences.getBoolean("takeover_when_media_start", true)) + } + var mDensity by remember { mutableFloatStateOf(0f) } + Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { @@ -607,6 +629,299 @@ fun AppSettingsScreen(navController: NavController) { } } + Text( + text = stringResource(R.string.takeover_header).uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.padding(8.dp, bottom = 2.dp, top = 24.dp) + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + backgroundColor, + RoundedCornerShape(14.dp) + ) + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + Text( + text = stringResource(R.string.takeover_airpods_state), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = textColor, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp) + ) + + // Disconnected + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + takeoverWhenDisconnected = !takeoverWhenDisconnected + sharedPreferences.edit().putBoolean("takeover_when_disconnected", takeoverWhenDisconnected).apply() + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp) + .padding(end = 4.dp) + ) { + Text( + text = stringResource(R.string.takeover_disconnected), + fontSize = 16.sp, + color = textColor + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.takeover_disconnected_desc), + fontSize = 14.sp, + color = textColor.copy(0.6f), + lineHeight = 16.sp, + ) + } + + StyledSwitch( + checked = takeoverWhenDisconnected, + onCheckedChange = { + takeoverWhenDisconnected = it + sharedPreferences.edit().putBoolean("takeover_when_disconnected", it).apply() + } + ) + } + + // Idle + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + takeoverWhenIdle = !takeoverWhenIdle + sharedPreferences.edit().putBoolean("takeover_when_idle", takeoverWhenIdle).apply() + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp) + .padding(end = 4.dp) + ) { + Text( + text = stringResource(R.string.takeover_idle), + fontSize = 16.sp, + color = textColor + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.takeover_idle_desc), + fontSize = 14.sp, + color = textColor.copy(0.6f), + lineHeight = 16.sp, + ) + } + + StyledSwitch( + checked = takeoverWhenIdle, + onCheckedChange = { + takeoverWhenIdle = it + sharedPreferences.edit().putBoolean("takeover_when_idle", it).apply() + } + ) + } + + // Music + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + takeoverWhenMusic = !takeoverWhenMusic + sharedPreferences.edit().putBoolean("takeover_when_music", takeoverWhenMusic).apply() + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp) + .padding(end = 4.dp) + ) { + Text( + text = stringResource(R.string.takeover_music), + fontSize = 16.sp, + color = textColor + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.takeover_music_desc), + fontSize = 14.sp, + color = textColor.copy(0.6f), + lineHeight = 16.sp, + ) + } + + StyledSwitch( + checked = takeoverWhenMusic, + onCheckedChange = { + takeoverWhenMusic = it + sharedPreferences.edit().putBoolean("takeover_when_music", it).apply() + } + ) + } + + // Call + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + takeoverWhenCall = !takeoverWhenCall + sharedPreferences.edit().putBoolean("takeover_when_call", takeoverWhenCall).apply() + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp) + .padding(end = 4.dp) + ) { + Text( + text = stringResource(R.string.takeover_call), + fontSize = 16.sp, + color = textColor + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.takeover_call_desc), + fontSize = 14.sp, + color = textColor.copy(0.6f), + lineHeight = 16.sp, + ) + } + + StyledSwitch( + checked = takeoverWhenCall, + onCheckedChange = { + takeoverWhenCall = it + sharedPreferences.edit().putBoolean("takeover_when_call", it).apply() + } + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.takeover_phone_state), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = textColor, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp) + ) + + // Ringing Call + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + takeoverWhenRingingCall = !takeoverWhenRingingCall + sharedPreferences.edit().putBoolean("takeover_when_ringing_call", takeoverWhenRingingCall).apply() + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp) + .padding(end = 4.dp) + ) { + Text( + text = stringResource(R.string.takeover_ringing_call), + fontSize = 16.sp, + color = textColor + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.takeover_ringing_call_desc), + fontSize = 14.sp, + color = textColor.copy(0.6f), + lineHeight = 16.sp, + ) + } + + StyledSwitch( + checked = takeoverWhenRingingCall, + onCheckedChange = { + takeoverWhenRingingCall = it + sharedPreferences.edit().putBoolean("takeover_when_ringing_call", it).apply() + } + ) + } + + // Media Start + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + takeoverWhenMediaStart = !takeoverWhenMediaStart + sharedPreferences.edit().putBoolean("takeover_when_media_start", takeoverWhenMediaStart).apply() + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(vertical = 8.dp) + .padding(end = 4.dp) + ) { + Text( + text = stringResource(R.string.takeover_media_start), + fontSize = 16.sp, + color = textColor + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(R.string.takeover_media_start_desc), + fontSize = 14.sp, + color = textColor.copy(0.6f), + lineHeight = 16.sp, + ) + } + + StyledSwitch( + checked = takeoverWhenMediaStart, + onCheckedChange = { + takeoverWhenMediaStart = it + sharedPreferences.edit().putBoolean("takeover_when_media_start", it).apply() + } + ) + } + } + Text( text = "Advanced Options".uppercase(), style = TextStyle( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt index aba8ba6..76e0d88 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -@file:OptIn(ExperimentalHazeMaterialsApi::class) +@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class) package me.kavishdevar.librepods.screens @@ -103,6 +103,7 @@ import me.kavishdevar.librepods.R import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.utils.BatteryStatus import me.kavishdevar.librepods.utils.isHeadTrackingData +import kotlin.io.encoding.ExperimentalEncodingApi data class PacketInfo( val type: String, @@ -616,7 +617,12 @@ fun DebugScreen(navController: NavController) { IconButton( onClick = { if (packet.value.text.isNotBlank()) { - airPodsService?.value?.sendPacket(packet.value.text) + airPodsService?.value?.aacpManager?.sendPacket( + packet.value.text + .split(" ") + .map { it.toInt(16).toByte() } + .toByteArray() + ) packet.value = TextFieldValue("") focusManager.clearFocus() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt index 810acef..e7039de 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.screens import android.content.Context @@ -115,6 +117,7 @@ import me.kavishdevar.librepods.R import me.kavishdevar.librepods.composables.IndependentToggle import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.utils.HeadTracking +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs import kotlin.math.cos import kotlin.math.sin diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt index 78f92f1..bb8668e 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.screens import android.content.Context @@ -47,7 +49,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -69,6 +70,9 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import me.kavishdevar.librepods.R import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import kotlin.experimental.and +import kotlin.io.encoding.ExperimentalEncodingApi @Composable() fun RightDivider() { @@ -83,15 +87,23 @@ fun RightDivider() { @OptIn(ExperimentalMaterial3Api::class) @Composable fun LongPress(navController: NavController, name: String) { - val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) - val offChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_off", false)) } - val ncChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_nc", false)) } - val transparencyChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_transparency", false)) } - val adaptiveChecked = remember { mutableStateOf(sharedPreferences.getBoolean("long_press_adaptive", false)) } - Log.d("LongPress", "offChecked: ${offChecked.value}, ncChecked: ${ncChecked.value}, transparencyChecked: ${transparencyChecked.value}, adaptiveChecked: ${adaptiveChecked.value}") val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black + val modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS + }?.value?.takeIf { it.isNotEmpty() }?.get(0) + + if (modesByte != null) { + Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}") + Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}") + Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x02) != 0.toByte()}") + Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x04) != 0.toByte()}") + Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}") + } + val context = LocalContext.current + val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + val deviceName = sharedPreferences.getString("name", "AirPods Pro") Scaffold( topBar = { CenterAlignedTopAppBar( @@ -115,7 +127,7 @@ fun LongPress(navController: NavController, name: String) { modifier = Modifier.scale(1.5f) ) Text( - sharedPreferences.getString("name", "AirPods")!!, + deviceName?: "AirPods Pro", style = TextStyle( fontSize = 18.sp, fontWeight = FontWeight.Medium, @@ -159,14 +171,29 @@ fun LongPress(navController: NavController, name: String) { .background(backgroundColor, RoundedCornerShape(14.dp)), horizontalAlignment = Alignment.CenterHorizontally ) { - val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false) - LongPressElement("Off", offChecked, "long_press_off", offListeningMode, R.drawable.noise_cancellation, isFirst = true) + val offListeningModeValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION + }?.value?.takeIf { it.isNotEmpty() }?.get(0) + val offListeningMode = offListeningModeValue == 1.toByte() + LongPressElement( + name = "Off", + enabled = offListeningMode, + resourceId = R.drawable.noise_cancellation, + isFirst = true) if (offListeningMode) RightDivider() - LongPressElement("Transparency", transparencyChecked, "long_press_transparency", resourceId = R.drawable.transparency, isFirst = !offListeningMode) + LongPressElement( + name = "Transparency", + resourceId = R.drawable.transparency, + isFirst = !offListeningMode) RightDivider() - LongPressElement("Adaptive", adaptiveChecked, "long_press_adaptive", resourceId = R.drawable.adaptive) + LongPressElement( + name = "Adaptive", + resourceId = R.drawable.adaptive) RightDivider() - LongPressElement("Noise Cancellation", ncChecked, "long_press_nc", resourceId = R.drawable.noise_cancellation, isLast = true) + LongPressElement( + name = "Noise Cancellation", + resourceId = R.drawable.noise_cancellation, + isLast = true) } Text( "Press and hold the stem to cycle between the selected noise control modes.", @@ -178,13 +205,33 @@ fun LongPress(navController: NavController, name: String) { ) } } + Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS + }?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}") } @Composable -fun LongPressElement(name: String, checked: MutableState, id: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) { - val sharedPreferences = - LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) - val offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false) +fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) { + val bit = when (name) { + "Off" -> 0x01 + "Transparency" -> 0x02 + "Noise Cancellation" -> 0x04 + "Adaptive" -> 0x08 + else -> -1 + } + val context = LocalContext.current + + val currentByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS + }?.value?.takeIf { it.isNotEmpty() }?.get(0) + + val savedByte = context.getSharedPreferences("settings", Context.MODE_PRIVATE).getInt("long_press_byte", 0b0101.toInt()) + val byteValue = currentByteValue ?: (savedByte and 0xFF).toByte() + + val isChecked = (byteValue.toInt() and bit) != 0 + val checked = remember { mutableStateOf(isChecked) } + + Log.d("PressAndHoldSettingsScreen", "LongPressElement: $name, checked: ${checked.value}, byteValue: ${byteValue.toInt()}, in bits: ${byteValue.toInt().toString(2)}") val darkMode = isSystemInDarkTheme() val textColor = if (darkMode) Color.White else Color.Black val desc = when (name) { @@ -194,30 +241,72 @@ fun LongPressElement(name: String, checked: MutableState, id: String, e "Adaptive" -> "Dynamically adjust external noise" else -> "" } - fun valueChanged(value: Boolean = !checked.value) { - val originalLongPressArray = booleanArrayOf( - sharedPreferences.getBoolean("long_press_off", false), - sharedPreferences.getBoolean("long_press_nc", false), - sharedPreferences.getBoolean("long_press_transparency", false), - sharedPreferences.getBoolean("long_press_adaptive", false) - ) - if (!value && originalLongPressArray.count { it } <= 2) { - return - } - checked.value = value - with(sharedPreferences.edit()) { - putBoolean(id, checked.value) - apply() - } - val newLongPressArray = booleanArrayOf( - sharedPreferences.getBoolean("long_press_off", false), - sharedPreferences.getBoolean("long_press_nc", false), - sharedPreferences.getBoolean("long_press_transparency", false), - sharedPreferences.getBoolean("long_press_adaptive", false) - ) - ServiceManager.getService() - ?.updateLongPress(originalLongPressArray, newLongPressArray, offListeningMode) + + fun countEnabledModes(byteValue: Int): Int { + var count = 0 + if ((byteValue and 0x01) != 0) count++ + if ((byteValue and 0x02) != 0) count++ + if ((byteValue and 0x04) != 0) count++ + if ((byteValue and 0x08) != 0) count++ + + Log.d("PressAndHoldSettingsScreen", "Byte: ${byteValue.toString(2)} Enabled modes: $count") + return count } + + fun valueChanged(value: Boolean = !checked.value) { + val latestByteValue = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { + it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS + }?.value?.takeIf { it.isNotEmpty() }?.get(0) + + val currentValue = (latestByteValue?.toInt() ?: byteValue.toInt()) and 0xFF + + Log.d("PressAndHoldSettingsScreen", "Current value: $currentValue (binary: ${Integer.toBinaryString(currentValue)}), bit: $bit, value: $value") + + if (!value) { + val newValue = currentValue and bit.inv() + + Log.d("PressAndHoldSettingsScreen", "Bit to disable: $bit, inverted: ${bit.inv()}, after AND: ${Integer.toBinaryString(newValue)}") + + val modeCount = countEnabledModes(newValue) + + Log.d("PressAndHoldSettingsScreen", "After disabling, enabled modes count: $modeCount") + + if (modeCount < 2) { + Log.d("PressAndHoldSettingsScreen", "Cannot disable $name mode - need at least 2 modes enabled") + return + } + + val updatedByte = newValue.toByte() + + Log.d("PressAndHoldSettingsScreen", "Sending updated byte: ${updatedByte.toInt() and 0xFF} (binary: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)})") + + ServiceManager.getService()!!.aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, + updatedByte + ) + + context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit() + .putInt("long_press_byte", newValue).apply() + + checked.value = false + Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: false, byte: ${updatedByte.toInt() and 0xFF}, bits: ${Integer.toBinaryString(updatedByte.toInt() and 0xFF)}") + } else { + val newValue = currentValue or bit + val updatedByte = newValue.toByte() + + ServiceManager.getService()!!.aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, + updatedByte + ) + + context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit() + .putInt("long_press_byte", newValue).apply() + + checked.value = true + Log.d("PressAndHoldSettingsScreen", "Updated: $name, enabled: true, byte: ${updatedByte.toInt() and 0xFF}, bits: ${newValue.toString(2)}") + } + } + val shape = when { isFirst -> RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp) isLast -> RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp) @@ -238,8 +327,8 @@ fun LongPressElement(name: String, checked: MutableState, id: String, e backgroundColor = if (darkMode) Color(0x40888888) else Color(0x40D9D9D9) tryAwaitRelease() backgroundColor = if (darkMode) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + valueChanged() }, - onTap = { valueChanged() } ) } .padding(horizontal = 16.dp, vertical = 0.dp), diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt index a5adb02..9601e93 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.screens import android.content.Context @@ -66,6 +68,7 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import me.kavishdevar.librepods.R import me.kavishdevar.librepods.services.ServiceManager +import kotlin.io.encoding.ExperimentalEncodingApi @OptIn(ExperimentalMaterial3Api::class) @@ -198,4 +201,4 @@ fun RenameScreen(navController: NavController) { @Composable fun RenameScreenPreview() { RenameScreen(navController = NavController(LocalContext.current)) -} \ No newline at end of file +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt index b44ac3e..a30a5ef 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt @@ -15,7 +15,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ - + +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.services import android.annotation.SuppressLint @@ -31,12 +33,12 @@ import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.util.Log import androidx.annotation.RequiresApi -import androidx.compose.material3.ExperimentalMaterial3Api -import me.kavishdevar.librepods.MainActivity import me.kavishdevar.librepods.QuickSettingsDialogActivity import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AirPodsNotifications import me.kavishdevar.librepods.utils.NoiseControlMode +import kotlin.io.encoding.ExperimentalEncodingApi @RequiresApi(Build.VERSION_CODES.Q) class AirPodsQSService : TileService() { @@ -171,10 +173,11 @@ class AirPodsQSService : TileService() { ) startActivityAndCollapse(pendingIntent) } else { - @Suppress("DEPRECATION") val intent = Intent(this, QuickSettingsDialogActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) } + @Suppress("DEPRECATION") + @SuppressLint("StartActivityAndCollapseDeprecated") startActivityAndCollapse(intent) } Log.d("AirPodsQSService", "Called startActivityAndCollapse for QuickSettingsDialogActivity") @@ -191,14 +194,17 @@ class AirPodsQSService : TileService() { } val nextMode = getNextAncMode() Log.d("AirPodsQSService", "Cycling ANC mode to: $nextMode") - service.setANCMode(nextMode) + service.aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, + nextMode + ) } private fun updateTile() { val tile = qsTile ?: return Log.d("AirPodsQSService", "updateTile - Connected: $isAirPodsConnected, Mode: $currentAncMode") - val deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods" + val deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods" if (isAirPodsConnected) { tile.state = Tile.STATE_ACTIVE @@ -262,42 +268,9 @@ class AirPodsQSService : TileService() { else -> R.drawable.airpods } } - - @ExperimentalMaterial3Api + override fun onTileAdded() { super.onTileAdded() Log.d("AirPodsQSService", "Tile added") - - val intent = Intent(this, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - } - - @ExperimentalMaterial3Api - fun openMainActivity() { - Log.d("AirPodsQSService", "Opening MainActivity") - - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - val pendingIntent = PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - }, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - startActivityAndCollapse(pendingIntent) - } else { - @Suppress("DEPRECATION") - val intent = Intent(this, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - startActivityAndCollapse(intent) - } - Log.d("AirPodsQSService", "Called startActivityAndCollapse for MainActivity") - } catch (e: Exception) { - Log.e("AirPodsQSService", "Error launching MainActivity: $e") - } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index e7fb971..7dd8ab3 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.services import android.Manifest @@ -75,18 +77,19 @@ import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeout import me.kavishdevar.librepods.MainActivity import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AirPodsNotifications +import me.kavishdevar.librepods.utils.BLEManager import me.kavishdevar.librepods.utils.Battery import me.kavishdevar.librepods.utils.BatteryComponent import me.kavishdevar.librepods.utils.BatteryStatus +import me.kavishdevar.librepods.utils.BluetoothConnectionManager import me.kavishdevar.librepods.utils.CrossDevice import me.kavishdevar.librepods.utils.CrossDevicePackets -import me.kavishdevar.librepods.utils.Enums import me.kavishdevar.librepods.utils.GestureDetector import me.kavishdevar.librepods.utils.HeadTracking import me.kavishdevar.librepods.utils.IslandType import me.kavishdevar.librepods.utils.IslandWindow -import me.kavishdevar.librepods.utils.LongPressPackets import me.kavishdevar.librepods.utils.MediaController import me.kavishdevar.librepods.utils.PopupWindow import me.kavishdevar.librepods.utils.SystemApisUtils @@ -114,63 +117,53 @@ import me.kavishdevar.librepods.widgets.NoiseControlWidget import org.lsposed.hiddenapibypass.HiddenApiBypass import java.nio.ByteBuffer import java.nio.ByteOrder +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi object ServiceManager { + @ExperimentalEncodingApi private var service: AirPodsService? = null + @ExperimentalEncodingApi @Synchronized fun getService(): AirPodsService? { return service } + @ExperimentalEncodingApi @Synchronized fun setService(service: AirPodsService?) { this.service = service } - - @OptIn(ExperimentalMaterial3Api::class) - @Synchronized - fun restartService(context: Context) { - service?.stopSelf() - Log.d("ServiceManager", "Restarting service, service is null: ${service == null}") - val intent = Intent(context, AirPodsService::class.java) - context.stopService(intent) - CoroutineScope(Dispatchers.IO).launch { - delay(1000) - context.startService(intent) - context.startActivity(Intent(context, MainActivity::class.java)) - service?.clearLogs() - } - } } // @Suppress("unused") +@ExperimentalEncodingApi class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener { var macAddress = "" + lateinit var aacpManager: AACPManager data class ServiceConfig( var deviceName: String = "AirPods", var earDetectionEnabled: Boolean = true, var conversationalAwarenessPauseMusic: Boolean = false, - var personalizedVolume: Boolean = false, - var longPressNC: Boolean = true, - var offListeningMode: Boolean = false, var showPhoneBatteryInWidget: Boolean = true, - var singleANC: Boolean = true, - var longPressTransparency: Boolean = true, - var conversationalAwareness: Boolean = true, var relativeConversationalAwarenessVolume: Boolean = true, - var longPressAdaptive: Boolean = true, - var loudSoundReduction: Boolean = true, - var longPressOff: Boolean = false, - var volumeControl: Boolean = true, var headGestures: Boolean = true, var disconnectWhenNotWearing: Boolean = false, - var adaptiveStrength: Int = 51, - var toneVolume: Int = 75, var conversationalAwarenessVolume: Int = 43, var textColor: Long = -1L, - var qsClickBehavior: String = "cycle" + var qsClickBehavior: String = "cycle", + + // AirPods state-based takeover + var takeoverWhenDisconnected: Boolean = true, + var takeoverWhenIdle: Boolean = true, + var takeoverWhenMusic: Boolean = false, + var takeoverWhenCall: Boolean = true, + + // Phone state-based takeover + var takeoverWhenRingingCall: Boolean = true, + var takeoverWhenMediaStart: Boolean = true ) private lateinit var config: ServiceConfig @@ -190,6 +183,54 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private val maxLogEntries = 1000 private val inMemoryLogs = mutableSetOf() + lateinit var bleManager: BLEManager + private val bleStatusListener = object : BLEManager.AirPodsStatusListener { + @SuppressLint("NewApi") + override fun onDeviceStatusChanged( + device: BLEManager.AirPodsStatus, + previousStatus: BLEManager.AirPodsStatus? + ) { + if (device.connectionState == "Disconnected") { + Log.d("AirPodsBLEService", "Seems no device has taken over, we will.") + val bluetoothManager = getSystemService(BluetoothManager::class.java) + val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(sharedPreferences.getString( + "mac_address", "") ?: "") + connectToSocket(bluetoothDevice) + } + Log.d("AirPodsBLEService", "Device status changed, inEar: ${device.isLeftInEar}, ${device.isRightInEar}") + } + + override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) { + Log.d("AirPodsService", "New address detected") + } + + override fun onLidStateChanged( + lidOpen: Boolean, + ) { + if (lidOpen) { + Log.d("AirPodsBLEService", "Lid opened") + showPopup( + this@AirPodsService, + getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro") ?: "AirPods" + ) + } else { + Log.d("AirPodsBLEService", "Lid closed") + } + } + + override fun onEarStateChanged( + device: BLEManager.AirPodsStatus, + leftInEar: Boolean, + rightInEar: Boolean + ) { + Log.d("AirPodsBLEService", "Ear state changed") + } + + override fun onBatteryChanged(device: BLEManager.AirPodsStatus) { + Log.d("AirPodsBLEService", "Battery changed") + } + + } override fun onCreate() { super.onCreate() sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE) @@ -200,33 +241,233 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) initializeConfig() + aacpManager = AACPManager() + initializeAACPManagerCallback() + sharedPreferences.registerOnSharedPreferenceChangeListener(this) } + @ExperimentalEncodingApi + private fun initializeAACPManagerCallback() { + aacpManager.setPacketCallback(object : AACPManager.PacketCallback { + @SuppressLint("MissingPermission") + override fun onBatteryInfoReceived(batteryInfo: ByteArray) { + batteryNotification.setBattery(batteryInfo) + sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { + putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery())) + }) + updateBattery() + updateNotificationContent( + true, + this@AirPodsService.getSharedPreferences("settings", MODE_PRIVATE) + .getString("name", device?.name), + batteryNotification.getBattery() + ) + CrossDevice.sendRemotePacket(batteryInfo) + CrossDevice.batteryBytes = batteryInfo + + for (battery in batteryNotification.getBattery()) { + Log.d( + "AirPodsParser", + "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% " + ) + } + + if (batteryNotification.getBattery()[0].status == BatteryStatus.CHARGING && batteryNotification.getBattery()[1].status == BatteryStatus.CHARGING) { + disconnectAudio(this@AirPodsService, device) + } else { + connectAudio(this@AirPodsService, device) + } + } + + override fun onEarDetectionReceived(earDetection: ByteArray) { + sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply { + val list = earDetectionNotification.status + val bytes = ByteArray(2) + bytes[0] = list[0] + bytes[1] = list[1] + putExtra("data", bytes) + }) + Log.d( + "AirPodsParser", + "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}" + ) + processEarDetectionChange(earDetection) + } + + override fun onConversationAwarenessReceived(conversationAwareness: ByteArray) { + conversationAwarenessNotification.setData(conversationAwareness) + sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply { + putExtra("data", conversationAwarenessNotification.status) + }) + + if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) { + MediaController.startSpeaking() + } else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) { + MediaController.stopSpeaking() + } + + Log.d( + "AirPodsParser", + "Conversation Awareness: ${conversationAwarenessNotification.status}" + ) + } + + override fun onControlCommandReceived(controlCommand: ByteArray) { + val command = AACPManager.ControlCommand.fromByteArray(controlCommand) + if (command.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value) { + ancNotification.setStatus(byteArrayOf(command.value.takeIf { it.isNotEmpty() }?.get(0) ?: 0x00.toByte())) + sendANCBroadcast() + updateNoiseControlWidget() + } + } + + override fun onDeviceMetadataReceived(deviceMetadata: ByteArray) { + + } + + @SuppressLint("NewApi") + override fun onHeadTrackingReceived(headTracking: ByteArray) { + if (isHeadTrackingActive) { + HeadTracking.processPacket(headTracking) + processHeadTrackingData(headTracking) + } + } + + override fun onProximityKeysReceived(proximityKeys: ByteArray) { + val keys = aacpManager.parseProximityKeysResponse(proximityKeys) + Log.d("AirPodsParser", "Proximity keys: $keys") + sharedPreferences.edit { + for (key in keys) { + Log.d("AirPodsParser", "Proximity key: ${key.key.name} = ${key.value}") + putString(key.key.name, Base64.encode(key.value)) + } + } + } + + override fun onUnknownPacketReceived(packet: ByteArray) { + Log.d("AACPManager", "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}") + } + }) + } + + private fun processEarDetectionChange(earDetection: ByteArray) { + var inEar = false + var inEarData = listOf(earDetectionNotification.status[0] == 0x00.toByte(), earDetectionNotification.status[1] == 0x00.toByte()) + var justEnabledA2dp = false + earDetectionNotification.setStatus(earDetection) + if (config.earDetectionEnabled) { + val data = earDetection.copyOfRange(earDetection.size - 2, earDetection.size) + inEar = data[0] == 0x00.toByte() && data[1] == 0x00.toByte() + + val newInEarData = listOf( + data[0] == 0x00.toByte(), + data[1] == 0x00.toByte() + ) + + if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf(false, false) && islandWindow?.isVisible != true) { + showIsland( + this@AirPodsService, + (batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0)) + } + + if (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) { + islandWindow?.close() + } + + if (newInEarData.contains(true) && inEarData == listOf(false, false)) { + connectAudio(this@AirPodsService, device) + justEnabledA2dp = true + registerA2dpConnectionReceiver() + } else if (newInEarData == listOf(false, false)) { + MediaController.sendPause(force = true) + if (config.disconnectWhenNotWearing) { + disconnectAudio(this@AirPodsService, device) + } + } + + if (inEarData.contains(false) && newInEarData == listOf(true, true)) { + Log.d("AirPodsParser", "User put in both AirPods from just one.") + MediaController.userPlayedTheMedia = false + } + + if (newInEarData.contains(false) && inEarData == listOf(true, true)) { + Log.d("AirPodsParser", "User took one of two out.") + MediaController.userPlayedTheMedia = false + } + + Log.d("AirPodsParser", "inEarData: ${inEarData.sorted()}, newInEarData: ${newInEarData.sorted()}") + + if (newInEarData.sorted() != inEarData.sorted()) { + inEarData = newInEarData + + if (inEar == true) { + if (!justEnabledA2dp) { + justEnabledA2dp = false + MediaController.sendPlay() + MediaController.iPausedTheMedia = false + } + } else { + MediaController.sendPause() + } + } + } + } + + private fun registerA2dpConnectionReceiver() { + val a2dpConnectionStateReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == "android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") { + val state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED) + val previousState = intent.getIntExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, BluetoothProfile.STATE_DISCONNECTED) + val device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) + + Log.d("MediaController", "A2DP state changed: $previousState -> $state for device: ${device?.address}") + + if (state == BluetoothProfile.STATE_CONNECTED && + previousState != BluetoothProfile.STATE_CONNECTED && + device?.address == this@AirPodsService.device?.address) { + + Log.d("MediaController", "A2DP connected, sending play command") + MediaController.sendPlay() + MediaController.iPausedTheMedia = false + + context.unregisterReceiver(this) + } + } + } + } + + val a2dpIntentFilter = IntentFilter("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter, RECEIVER_EXPORTED) + } else { + registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter) + } + } + private fun initializeConfig() { config = ServiceConfig( deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods", earDetectionEnabled = sharedPreferences.getBoolean("automatic_ear_detection", true), conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false), - personalizedVolume = sharedPreferences.getBoolean("personalized_volume", false), - longPressNC = sharedPreferences.getBoolean("long_press_nc", true), - offListeningMode = sharedPreferences.getBoolean("off_listening_mode", false), showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", true), - singleANC = sharedPreferences.getBoolean("single_anc", true), - longPressTransparency = sharedPreferences.getBoolean("long_press_transparency", true), - conversationalAwareness = sharedPreferences.getBoolean("conversational_awareness", true), relativeConversationalAwarenessVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", true), - longPressAdaptive = sharedPreferences.getBoolean("long_press_adaptive", true), - loudSoundReduction = sharedPreferences.getBoolean("loud_sound_reduction", true), - longPressOff = sharedPreferences.getBoolean("long_press_off", false), - volumeControl = sharedPreferences.getBoolean("volume_control", true), headGestures = sharedPreferences.getBoolean("head_gestures", true), disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false), - adaptiveStrength = sharedPreferences.getInt("adaptive_strength", 51), - toneVolume = sharedPreferences.getInt("tone_volume", 75), conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43), textColor = sharedPreferences.getLong("textColor", -1L), - qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle" + qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle", + + // AirPods state-based takeover + takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", true), + takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", true), + takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false), + takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", true), + + // Phone state-based takeover + takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", true), + takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", true) ) } @@ -237,32 +478,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "name" -> config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods" "automatic_ear_detection" -> config.earDetectionEnabled = preferences.getBoolean(key, true) "conversational_awareness_pause_music" -> config.conversationalAwarenessPauseMusic = preferences.getBoolean(key, false) - "personalized_volume" -> config.personalizedVolume = preferences.getBoolean(key, false) - "long_press_nc" -> config.longPressNC = preferences.getBoolean(key, true) - "off_listening_mode" -> { - config.offListeningMode = preferences.getBoolean(key, false) - updateNoiseControlWidget() - } "show_phone_battery_in_widget" -> { config.showPhoneBatteryInWidget = preferences.getBoolean(key, true) widgetMobileBatteryEnabled = config.showPhoneBatteryInWidget updateBattery() - } - "single_anc" -> config.singleANC = preferences.getBoolean(key, true) - "long_press_transparency" -> config.longPressTransparency = preferences.getBoolean(key, true) - "conversational_awareness" -> config.conversationalAwareness = preferences.getBoolean(key, true) - "relative_conversational_awareness_volume" -> config.relativeConversationalAwarenessVolume = preferences.getBoolean(key, true) - "long_press_adaptive" -> config.longPressAdaptive = preferences.getBoolean(key, true) - "loud_sound_reduction" -> config.loudSoundReduction = preferences.getBoolean(key, true) - "long_press_off" -> config.longPressOff = preferences.getBoolean(key, false) - "volume_control" -> config.volumeControl = preferences.getBoolean(key, true) + } "relative_conversational_awareness_volume" -> config.relativeConversationalAwarenessVolume = preferences.getBoolean(key, true) "head_gestures" -> config.headGestures = preferences.getBoolean(key, true) "disconnect_when_not_wearing" -> config.disconnectWhenNotWearing = preferences.getBoolean(key, false) - "adaptive_strength" -> config.adaptiveStrength = preferences.getInt(key, 51) - "tone_volume" -> config.toneVolume = preferences.getInt(key, 75) "conversational_awareness_volume" -> config.conversationalAwarenessVolume = preferences.getInt(key, 43) "textColor" -> config.textColor = preferences.getLong(key, -1L) "qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle" + + // AirPods state-based takeover + "takeover_when_disconnected" -> config.takeoverWhenDisconnected = preferences.getBoolean(key, true) + "takeover_when_idle" -> config.takeoverWhenIdle = preferences.getBoolean(key, true) + "takeover_when_music" -> config.takeoverWhenMusic = preferences.getBoolean(key, false) + "takeover_when_call" -> config.takeoverWhenCall = preferences.getBoolean(key, true) + + // Phone state-based takeover + "takeover_when_ringing_call" -> config.takeoverWhenRingingCall = preferences.getBoolean(key, true) + "takeover_when_media_start" -> config.takeoverWhenMediaStart = preferences.getBoolean(key, true) } if (key == "mac_address") { @@ -277,7 +512,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList synchronized(inMemoryLogs) { inMemoryLogs.add(logEntry) if (inMemoryLogs.size > maxLogEntries) { - inMemoryLogs.iterator().next()?.let { + inMemoryLogs.iterator().next().let { inMemoryLogs.remove(it) } } @@ -333,7 +568,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var popupShown = false - fun showPopup(service: Service, name: String) { if (!Settings.canDrawOverlays(service)) { Log.d("AirPodsService", "No permission for SYSTEM_ALERT_WINDOW") @@ -346,6 +580,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList popupWindow.open(name, batteryNotification) popupShown = true } + var islandOpen = false var islandWindow: IslandWindow? = null @SuppressLint("MissingPermission") @@ -368,46 +603,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList startActivity(intent) } - @Suppress("ClassName") - private object bluetoothReceiver : BroadcastReceiver() { - @SuppressLint("MissingPermission") - override fun onReceive(context: Context?, intent: Intent) { - val bluetoothDevice = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra( - "android.bluetooth.device.extra.DEVICE", - BluetoothDevice::class.java - ) - } else { - intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE") as BluetoothDevice? - } - val action = intent.action - val context = context?.applicationContext - val name = context?.getSharedPreferences("settings", MODE_PRIVATE) - ?.getString("name", bluetoothDevice?.name) - if (bluetoothDevice != null && action != null && !action.isEmpty()) { - Log.d("AirPodsService", "Received bluetooth connection broadcast") - if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { - if (ServiceManager.getService()?.isConnectedLocally == true) { - ServiceManager.getService()?.manuallyCheckForAudioSource() - return - } - val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") - bluetoothDevice.fetchUuidsWithSdp() - if (bluetoothDevice.uuids != null) { - if (bluetoothDevice.uuids.contains(uuid)) { - val intent = - Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) - intent.putExtra("name", name) - intent.putExtra("device", bluetoothDevice) - context?.sendBroadcast(intent) - } - } - } - } - } - } - var isConnectedLocally = false var device: BluetoothDevice? = null @@ -463,14 +658,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList notificationManager.createNotificationChannel(connectedNotificationChannel) notificationManager.createNotificationChannel(socketFailureChannel) - val notificationIntent = Intent(this, MainActivity::class.java) - val pendingIntent = PendingIntent.getActivity( - this, - 0, - notificationIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - val notificationSettingsIntent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { putExtra(Settings.EXTRA_APP_PACKAGE, packageName) putExtra(Settings.EXTRA_CHANNEL_ID, "background_service_status") @@ -678,9 +865,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList (if (batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.status == BatteryStatus.CHARGING) "1".toByteArray() else "0".toByteArray()) ) } - - // broadcast -// broadcastBatteryInformation() } fun updateNoiseControlWidget() { @@ -689,6 +873,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val widgetIds = appWidgetManager.getAppWidgetIds(componentName) val remoteViews = RemoteViews(packageName, R.layout.noise_control_widget).also { val ancStatus = ancNotification.status + val allowOffModeValue = aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION } + val allowOffMode = allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte() it.setInt( R.id.widget_off_button, "setBackgroundResource", @@ -697,7 +883,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList it.setInt( R.id.widget_transparency_button, "setBackgroundResource", - if (ancStatus == 3) (if (config.offListeningMode) R.drawable.widget_button_checked_shape_middle else R.drawable.widget_button_checked_shape_start) else (if (config.offListeningMode) R.drawable.widget_button_shape_middle else R.drawable.widget_button_shape_start) + if (ancStatus == 3) (if (allowOffMode) R.drawable.widget_button_checked_shape_middle else R.drawable.widget_button_checked_shape_start) else (if (allowOffMode) R.drawable.widget_button_shape_middle else R.drawable.widget_button_shape_start) ) it.setInt( R.id.widget_adaptive_button, @@ -711,19 +897,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) it.setViewVisibility( R.id.widget_off_button, - if (config.offListeningMode) View.VISIBLE else View.GONE + if (allowOffMode) View.VISIBLE else View.GONE ) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { it.setViewLayoutMargin( R.id.widget_transparency_button, RemoteViews.MARGIN_START, - if (config.offListeningMode) 2f else 12f, + if (allowOffMode) 2f else 12f, TypedValue.COMPLEX_UNIT_DIP ) } else { it.setViewPadding( R.id.widget_transparency_button, - if (config.offListeningMode) 2.dpToPx() else 12.dpToPx(), + if (allowOffMode) 2.dpToPx() else 12.dpToPx(), 12.dpToPx(), 2.dpToPx(), 12.dpToPx() @@ -865,12 +1051,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList private fun rejectCall() { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val telecomManager = getSystemService(Context.TELECOM_SERVICE) as TelecomManager + val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) { telecomManager.endCall() } } else { - val telephonyService = getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager val telephonyClass = Class.forName(telephonyService.javaClass.name) val method = telephonyClass.getDeclaredMethod("getITelephony") method.isAccessible = true @@ -917,12 +1103,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } + @Suppress("PrivatePropertyName") private val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV = "+IPHONEACCEV" + @Suppress("PrivatePropertyName") private val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL = 1 + @Suppress("PrivatePropertyName") private val APPLE = 0x004C + @Suppress("PrivatePropertyName") private val ACTION_BATTERY_LEVEL_CHANGED = "android.bluetooth.device.action.BATTERY_LEVEL_CHANGED" + @Suppress("PrivatePropertyName") private val EXTRA_BATTERY_LEVEL = "android.bluetooth.device.extra.BATTERY_LEVEL" + @Suppress("PrivatePropertyName") private val PACKAGE_ASI = "com.google.android.settings.intelligence" + @Suppress("PrivatePropertyName") private val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data" @Suppress("MissingPermission") @@ -942,7 +1135,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // Check charging status val isLeftCharging = leftBattery?.status == BatteryStatus.CHARGING val isRightCharging = rightBattery?.status == BatteryStatus.CHARGING - val isChargingMain = isLeftCharging && isRightCharging + isLeftCharging && isRightCharging // Create arguments for vendor-specific event val arguments = arrayOf( @@ -1065,8 +1258,51 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d("AirPodsService", "Metadata set: $metadataSet") } } + + @Suppress("ClassName") + private object bluetoothReceiver : BroadcastReceiver() { + @SuppressLint("MissingPermission") + override fun onReceive(context: Context?, intent: Intent) { + val bluetoothDevice = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra( + "android.bluetooth.device.extra.DEVICE", + BluetoothDevice::class.java + ) + } else { + intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE") as BluetoothDevice? + } + val action = intent.action + val context = context?.applicationContext + val name = context?.getSharedPreferences("settings", MODE_PRIVATE) + ?.getString("name", bluetoothDevice?.name) + if (bluetoothDevice != null && action != null && !action.isEmpty()) { + Log.d("AirPodsService", "Received bluetooth connection broadcast") + if (ServiceManager.getService()?.isConnectedLocally == true) { + Log.d("AirPodsService", "Checking if audio should be connected") + ServiceManager.getService()?.manuallyCheckForAudioSource() + return + } + if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { + val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") + bluetoothDevice.fetchUuidsWithSdp() + if (bluetoothDevice.uuids != null) { + if (bluetoothDevice.uuids.contains(uuid)) { + val intent = + Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) + intent.putExtra("name", name) + intent.putExtra("device", bluetoothDevice) + context?.sendBroadcast(intent) + } + } + } + } + } + } + val ancModeFilter = IntentFilter("me.kavishdevar.librepods.SET_ANC_MODE") var ancModeReceiver: BroadcastReceiver? = null + @SuppressLint("InlinedApi", "MissingPermission", "UnspecifiedRegisterReceiverFlag") override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { Log.d("AirPodsService", "Service started") @@ -1074,6 +1310,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList startForegroundNotification() initGestureDetector() + bleManager = BLEManager(this) + bleManager.setAirPodsStatusListener(bleStatusListener) + sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) with(sharedPreferences) { @@ -1096,6 +1335,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (!contains("head_gestures")) editor.putBoolean("head_gestures", true) if (!contains("disconnect_when_not_wearing")) editor.putBoolean("disconnect_when_not_wearing", false) + // AirPods state-based takeover + if (!contains("takeover_when_disconnected")) editor.putBoolean("takeover_when_disconnected", true) + if (!contains("takeover_when_idle")) editor.putBoolean("takeover_when_idle", true) + if (!contains("takeover_when_music")) editor.putBoolean("takeover_when_music", false) + if (!contains("takeover_when_call")) editor.putBoolean("takeover_when_call", true) + + // Phone state-based takeover + if (!contains("takeover_when_ringing_call")) editor.putBoolean("takeover_when_ringing_call", true) + if (!contains("takeover_when_media_start")) editor.putBoolean("takeover_when_media_start", true) + if (!contains("adaptive_strength")) editor.putInt("adaptive_strength", 51) if (!contains("tone_volume")) editor.putInt("tone_volume", 75) if (!contains("conversational_awareness_volume")) editor.putInt("conversational_awareness_volume", 43) @@ -1116,13 +1365,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (intent.hasExtra("mode")) { val mode = intent.getIntExtra("mode", -1) if (mode in 1..4) { - setANCMode(mode) + aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, + mode + ) } } else { val currentMode = ancNotification.status - val offListeningMode = config.offListeningMode + val allowOffModeValue = aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION } + val allowOffMode = allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte() - val nextMode = if (offListeningMode) { + val nextMode = if (allowOffMode) { when (currentMode) { 1 -> 2 2 -> 3 @@ -1140,8 +1393,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } - setANCMode(nextMode) - Log.d("AirPodsService", "Cycling ANC mode from $currentMode to $nextMode (offListeningMode: $offListeningMode)") + aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, + nextMode + ) + Log.d("AirPodsService", "Cycling ANC mode from $currentMode to $nextMode (offListeningMode: $allowOffMode)") } } } @@ -1177,8 +1433,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList super.onCallStateChanged(state, phoneNumber) when (state) { TelephonyManager.CALL_STATE_RINGING -> { - if (CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) CoroutineScope(Dispatchers.IO).launch { - takeOver() + val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true + if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(Dispatchers.IO).launch { + takeOver("call") } if (config.headGestures) { callNumber = phoneNumber @@ -1186,9 +1443,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } TelephonyManager.CALL_STATE_OFFHOOK -> { - if (CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) CoroutineScope( + val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true + if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope( Dispatchers.IO).launch { - takeOver() + takeOver("call") } isInCall = true } @@ -1246,7 +1504,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString()) if (!CrossDevice.isAvailable) { Log.d("AirPodsService", "${config.deviceName} connected") - showPopup(this@AirPodsService, config.deviceName) CoroutineScope(Dispatchers.IO).launch { connectToSocket(device!!) } @@ -1347,36 +1604,73 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList clearPacketLogs() } + CoroutineScope(Dispatchers.IO).launch { + bleManager.startScanning() + } + return START_STICKY } private lateinit var socket: BluetoothSocket fun manuallyCheckForAudioSource() { + val shouldResume = MediaController.getMusicActive() if (earDetectionNotification.status[0] != 0.toByte() && earDetectionNotification.status[1] != 0.toByte()) { Log.d( "AirPodsService", "For some reason, Android connected to the audio profile itself even after disconnecting. Disconnecting audio profile again!" ) - disconnectAudio(this, device) + disconnectAudio(this, device, shouldResume = shouldResume) } } @RequiresApi(Build.VERSION_CODES.R) @SuppressLint("MissingPermission") - fun takeOver() { + fun takeOver(takingOverFor: String) { + if (isConnectedLocally || !CrossDevice.isAvailable || bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true) { + Log.d("AirPodsService", "Already connected or not available for takeover") + return + } + + val shouldTakeOverPState = when (takingOverFor) { + "music" -> config.takeoverWhenMediaStart + "call" -> config.takeoverWhenRingingCall + else -> false + } + if (!shouldTakeOverPState) { + Log.d("AirPodsService", "Not taking over audio, phone state takeover disabled") + return + } + + val shouldTakeOver = when (bleManager.getMostRecentStatus()?.connectionState) { + "Disconnected" -> config.takeoverWhenDisconnected + "Idle" -> config.takeoverWhenIdle + "Music" -> config.takeoverWhenMusic + "Call" -> config.takeoverWhenCall + "Ringing" -> config.takeoverWhenCall + "Hanging Up" -> config.takeoverWhenCall + else -> false + } + + if (!shouldTakeOver) { + Log.d("AirPodsService", "Not taking over audio, airpods state takeover disabled") + return + } + Log.d("AirPodsService", "Taking over audio") CrossDevice.sendRemotePacket(CrossDevicePackets.REQUEST_DISCONNECT.packet) Log.d("AirPodsService", macAddress) - CrossDevice.isAvailable = false + sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false) } device = getSystemService(BluetoothManager::class.java).adapter.bondedDevices.find { it.address == macAddress } + if (device != null) { connectToSocket(device!!) connectAudio(this, device) } + showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!), IslandType.TAKING_OVER) @@ -1443,6 +1737,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList socket.connect() isConnectedLocally = true this@AirPodsService.device = device + + BluetoothConnectionManager.setCurrentConnection(socket, device) + updateNotificationContent( true, config.deviceName, @@ -1462,38 +1759,33 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } this@AirPodsService.device = device socket.let { it -> - it.outputStream.write(Enums.HANDSHAKE.value) - it.outputStream.flush() - it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value) - it.outputStream.flush() - it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value) - it.outputStream.flush() + aacpManager.sendPacket(aacpManager.createHandshakePacket()) + aacpManager.sendSetFeatureFlagsPacket() + aacpManager.sendNotificationRequest() + Log.d("AirPodsService", "Requesting proximity keys") + aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value) CoroutineScope(Dispatchers.IO).launch { - it.outputStream.write(Enums.HANDSHAKE.value) - it.outputStream.flush() + aacpManager.sendPacket(aacpManager.createHandshakePacket()) delay(200) - it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value) - it.outputStream.flush() + aacpManager.sendSetFeatureFlagsPacket() delay(200) - it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value) - it.outputStream.flush() + aacpManager.sendNotificationRequest() delay(200) - it.outputStream.write(Enums.START_HEAD_TRACKING.value) - it.outputStream.flush() + aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value) + startHeadTracking() Handler(Looper.getMainLooper()).postDelayed({ - it.outputStream.write(Enums.HANDSHAKE.value) - it.outputStream.flush() - it.outputStream.write(Enums.SET_SPECIFIC_FEATURES.value) - it.outputStream.flush() - it.outputStream.write(Enums.REQUEST_NOTIFICATIONS.value) - it.outputStream.flush() - it.outputStream.write(Enums.STOP_HEAD_TRACKING.value) - it.outputStream.flush() + aacpManager.sendPacket(aacpManager.createHandshakePacket()) + aacpManager.sendSetFeatureFlagsPacket() + aacpManager.sendNotificationRequest() + aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value) + stopHeadTracking() }, 5000) + sendBroadcast( Intent(AirPodsNotifications.AIRPODS_CONNECTED) .putExtra("device", device) ) + while (socket.isConnected == true) { socket.let { val buffer = ByteArray(1024) @@ -1512,219 +1804,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList sharedPreferences.getString("name", device.name), batteryNotification.getBattery() ) + + aacpManager.receivePacket(data) + if (!isHeadTrackingData(data)) { - Log.d("AirPods Data", "Data received: $formattedHex") + Log.d("AirPodsData", "Data received: $formattedHex") logPacket(data, "AirPods") } + } else if (bytesRead == -1) { Log.d("AirPods Service", "Socket closed (bytesRead = -1)") sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED)) return@launch } - var inEar = false - var inEarData = listOf() - processData(data) - if (earDetectionNotification.isEarDetectionData(data)) { - earDetectionNotification.setStatus(data) - sendBroadcast(Intent(AirPodsNotifications.EAR_DETECTION_DATA).apply { - val list = earDetectionNotification.status - val bytes = ByteArray(2) - bytes[0] = list[0] - bytes[1] = list[1] - putExtra("data", bytes) - }) - Log.d( - "AirPods Parser", - "Ear Detection: ${earDetectionNotification.status[0]} ${earDetectionNotification.status[1]}" - ) - var justEnabledA2dp = false - earReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val data = intent.getByteArrayExtra("data") - if (data != null && config.earDetectionEnabled) { - inEar = - if (data.find { it == 0x02.toByte() } != null || data.find { it == 0x03.toByte() } != null) { - data[0] == 0x00.toByte() || data[1] == 0x00.toByte() - } else { - data[0] == 0x00.toByte() && data[1] == 0x00.toByte() - } - val newInEarData = listOf( - data[0] == 0x00.toByte(), - data[1] == 0x00.toByte() - ) - if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf(false, false) && islandWindow?.isVisible != true) { - showIsland(this@AirPodsService, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!)) - } - if (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) { - islandWindow?.close() - } - if (newInEarData.contains(true) && inEarData == listOf( - false, - false - ) - ) { - connectAudio(this@AirPodsService, device) - justEnabledA2dp = true - val a2dpConnectionStateReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - if (intent.action == "android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") { - val state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED) - val previousState = intent.getIntExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, BluetoothProfile.STATE_DISCONNECTED) - val device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) - - Log.d("MediaController", "A2DP state changed: $previousState -> $state for device: ${device?.address}") - - if (state == BluetoothProfile.STATE_CONNECTED && - previousState != BluetoothProfile.STATE_CONNECTED && - device?.address == this@AirPodsService.device?.address) { - - Log.d("MediaController", "A2DP connected, sending play command") - MediaController.sendPlay() - MediaController.iPausedTheMedia = false - - context.unregisterReceiver(this) - } - } - } - } - val a2dpIntentFilter = IntentFilter("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter, RECEIVER_EXPORTED) - } else { - registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter) - } - } else if (newInEarData == listOf(false, false)) { - MediaController.sendPause(force = true) - if (config.disconnectWhenNotWearing) { - disconnectAudio(this@AirPodsService, device) - } - } - - if (inEarData.contains(false) && newInEarData == listOf( - true, - true - ) - ) { - Log.d( - "AirPods Parser", - "User put in both AirPods from just one." - ) - MediaController.userPlayedTheMedia = false - } - if (newInEarData.contains(false) && inEarData == listOf( - true, - true - ) - ) { - Log.d( - "AirPods Parser", - "User took one of two out." - ) - MediaController.userPlayedTheMedia = false - } - - Log.d( - "AirPods Parser", - "inEarData: ${inEarData.sorted()}, newInEarData: ${newInEarData.sorted()}" - ) - if (newInEarData.sorted() == inEarData.sorted()) { - Log.d("AirPods Parser", "hi") - return - } - Log.d( - "AirPods Parser", - "this shouldn't be run if the last log was 'hi'." - ) - - inEarData = newInEarData - - if (inEar == true) { - if (!justEnabledA2dp) { - justEnabledA2dp = false - MediaController.sendPlay() - MediaController.iPausedTheMedia = false - } - } else { - MediaController.sendPause() - } - } - } - } - - val earIntentFilter = - IntentFilter(AirPodsNotifications.EAR_DETECTION_DATA) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - this@AirPodsService.registerReceiver( - earReceiver, earIntentFilter, - RECEIVER_EXPORTED - ) - } else { - this@AirPodsService.registerReceiver( - earReceiver, - earIntentFilter - ) - } - } else if (ancNotification.isANCData(data)) { - CrossDevice.sendRemotePacket(data) - CrossDevice.ancBytes = data - ancNotification.setStatus(data) - sendANCBroadcast() - updateNoiseControlWidget() - Log.d("AirPods Parser", "ANC: ${ancNotification.status}") - } else if (batteryNotification.isBatteryData(data)) { - CrossDevice.sendRemotePacket(data) - CrossDevice.batteryBytes = data - batteryNotification.setBattery(data) - sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { - putParcelableArrayListExtra( - "data", - ArrayList(batteryNotification.getBattery()) - ) - }) - updateBattery() - updateNotificationContent( - true, - this@AirPodsService.getSharedPreferences( - "settings", - MODE_PRIVATE - ).getString("name", device.name), - batteryNotification.getBattery() - ) - for (battery in batteryNotification.getBattery()) { - Log.d( - "AirPods Parser", - "${battery.getComponentName()}: ${battery.getStatusName()} at ${battery.level}% " - ) - } - if (batteryNotification.getBattery()[0].status == 1 && batteryNotification.getBattery()[1].status == 1) { - disconnectAudio(this@AirPodsService, device) - } else { - connectAudio(this@AirPodsService, device) - } - } else if (conversationAwarenessNotification.isConversationalAwarenessData( - data - ) - ) { - conversationAwarenessNotification.setData(data) - sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply { - putExtra("data", conversationAwarenessNotification.status) - }) - - - if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) { - MediaController.startSpeaking() - } else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) { - MediaController.stopSpeaking() - } - - Log.d( - "AirPods Parser", - "Conversation Awareness: ${conversationAwarenessNotification.status}" - ) - } - else if (isHeadTrackingData(data)) { - processHeadTrackingData(data) - } } } Log.d("AirPods Service", "Socket closed") @@ -1769,199 +1861,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList CrossDevice.isAvailable = true } - fun sendPacket(packet: String) { - val fromHex = packet.split(" ").map { it.toInt(16).toByte() } - try { - logPacket(fromHex.toByteArray(), "Sent") - - if (!isConnectedLocally && CrossDevice.isAvailable) { - CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + fromHex.toByteArray()) - return - } - if (this::socket.isInitialized && socket.isConnected && socket.outputStream != null) { - val byteArray = fromHex.toByteArray() - socket.outputStream?.write(byteArray) - socket.outputStream?.flush() - } else { - Log.d("AirPodsService", "Can't send packet: Socket not initialized or connected") - } - } catch (e: Exception) { - Log.e("AirPodsService", "Error sending packet: ${e.message}") - } - } - - fun sendPacket(packet: ByteArray) { - try { - logPacket(packet, "Sent") - - if (!isConnectedLocally && CrossDevice.isAvailable) { - CrossDevice.sendRemotePacket(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet) - return - } - if (this::socket.isInitialized && socket.isConnected && socket.outputStream != null && isConnectedLocally) { - socket.outputStream?.write(packet) - socket.outputStream?.flush() - } else { - Log.d("AirPodsService", "Can't send packet: Socket not initialized or connected") - } - } catch (e: Exception) { - Log.e("AirPodsService", "Error sending packet: ${e.message}") - } - } - - fun setANCMode(mode: Int) { - Log.d("AirPodsService", "setANCMode: $mode") - when (mode) { - 1 -> { - sendPacket(Enums.NOISE_CANCELLATION_OFF.value) - } - - 2 -> { - sendPacket(Enums.NOISE_CANCELLATION_ON.value) - } - - 3 -> { - sendPacket(Enums.NOISE_CANCELLATION_TRANSPARENCY.value) - } - - 4 -> { - sendPacket(Enums.NOISE_CANCELLATION_ADAPTIVE.value) - } - } - } - - fun setCAEnabled(enabled: Boolean) { - sendPacket(if (enabled) Enums.SET_CONVERSATION_AWARENESS_ON.value else Enums.SET_CONVERSATION_AWARENESS_OFF.value) - } - - fun setOffListeningMode(enabled: Boolean) { - sendPacket( - byteArrayOf( - 0x04, - 0x00, - 0x04, - 0x00, - 0x09, - 0x00, - 0x34, - if (enabled) 0x01 else 0x02, - 0x00, - 0x00, - 0x00 - ) - ) - - if (config.offListeningMode != enabled) { - config.offListeningMode = enabled - sharedPreferences.edit { putBoolean("off_listening_mode", enabled) } - } - updateNoiseControlWidget() - } - - fun setAdaptiveStrength(strength: Int) { - val bytes = - byteArrayOf( - 0x04, - 0x00, - 0x04, - 0x00, - 0x09, - 0x00, - 0x2E, - strength.toByte(), - 0x00, - 0x00, - 0x00 - ) - sendPacket(bytes) - - if (config.adaptiveStrength != strength) { - config.adaptiveStrength = strength - sharedPreferences.edit { putInt("adaptive_strength", strength) } - } - } - - fun setPressSpeed(speed: Int) { - // 0x00 = default, 0x01 = slower, 0x02 = slowest - val bytes = - byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x17, speed.toByte(), 0x00, 0x00, 0x00) - sendPacket(bytes) - } - - fun setPressAndHoldDuration(speed: Int) { - // 0 - default, 1 - slower, 2 - slowest - val bytes = - byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x18, speed.toByte(), 0x00, 0x00, 0x00) - sendPacket(bytes) - } - - fun setVolumeSwipeSpeed(speed: Int) { - // 0 - default, 1 - longer, 2 - longest - val bytes = - byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x23, speed.toByte(), 0x00, 0x00, 0x00) - Log.d( - "AirPodsService", - "Setting volume swipe speed to $speed by packet ${ - bytes.joinToString(" ") { - "%02X".format( - it - ) - } - }" - ) - sendPacket(bytes) - } - - fun setNoiseCancellationWithOnePod(enabled: Boolean) { - val bytes = byteArrayOf( - 0x04, - 0x00, - 0x04, - 0x00, - 0x09, - 0x00, - 0x1B, - if (enabled) 0x01 else 0x02, - 0x00, - 0x00, - 0x00 - ) - sendPacket(bytes) - } - - fun setVolumeControl(enabled: Boolean) { - val bytes = byteArrayOf( - 0x04, - 0x00, - 0x04, - 0x00, - 0x09, - 0x00, - 0x25, - if (enabled) 0x01 else 0x02, - 0x00, - 0x00, - 0x00 - ) - sendPacket(bytes) - - if (config.volumeControl != enabled) { - config.volumeControl = enabled - sharedPreferences.edit { putBoolean("volume_control", enabled) } - } - } - - fun setToneVolume(volume: Int) { - val bytes = - byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x1F, volume.toByte(), 0x50, 0x00, 0x00) - sendPacket(bytes) - - if (config.toneVolume != volume) { - config.toneVolume = volume - sharedPreferences.edit { putInt("tone_volume", volume) } - } - } - val earDetectionNotification = AirPodsNotifications.EarDetection() val ancNotification = AirPodsNotifications.ANC() val batteryNotification = AirPodsNotifications.BatteryNotification() @@ -1989,15 +1888,24 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList return ancNotification.status } - fun disconnectAudio(context: Context, device: BluetoothDevice?) { + fun disconnectAudio(context: Context, device: BluetoothDevice?, shouldResume: Boolean = false) { val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { if (profile == BluetoothProfile.A2DP) { try { + if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) { + Log.d("AirPodsService", "Already disconnected from A2DP") + return + } val method = proxy.javaClass.getMethod("disconnect", BluetoothDevice::class.java) method.invoke(proxy, device) + if (shouldResume) { + Handler(Looper.getMainLooper()).postDelayed({ + MediaController.sendPlay() + }, 150) + } } catch (e: Exception) { e.printStackTrace() } finally { @@ -2072,13 +1980,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } fun setName(name: String) { - val nameBytes = name.toByteArray() - val bytes = byteArrayOf( - 0x04, 0x00, 0x04, 0x00, 0x1a, 0x00, 0x01, - nameBytes.size.toByte(), 0x00 - ) + nameBytes - sendPacket(bytes) - val hex = bytes.joinToString(" ") { "%02X".format(it) } + aacpManager.sendRename(name) if (config.deviceName != name) { config.deviceName = name @@ -2086,183 +1988,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } updateNotificationContent(true, name, batteryNotification.getBattery()) - Log.d("AirPodsService", "setName: $name, sent packet: $hex") - } - - fun setPVEnabled(enabled: Boolean) { - var hex = "04 00 04 00 09 00 26 ${if (enabled) "01" else "02"} 00 00 00" - var bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray() - sendPacket(bytes) - hex = - "04 00 04 00 17 00 00 00 10 00 12 00 08 E${if (enabled) "6" else "5"} 05 10 02 42 0B 08 50 10 02 1A 05 02 ${if (enabled) "32" else "00"} 00 00 00" - bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray() - sendPacket(bytes) - - if (config.personalizedVolume != enabled) { - config.personalizedVolume = enabled - sharedPreferences.edit { putBoolean("personalized_volume", enabled) } - } - } - - fun setLoudSoundReduction(enabled: Boolean) { - val hex = "52 1B 00 0${if (enabled) "1" else "0"}" - val bytes = hex.split(" ").map { it.toInt(16).toByte() }.toByteArray() - sendPacket(bytes) - - if (config.loudSoundReduction != enabled) { - config.loudSoundReduction = enabled - sharedPreferences.edit { putBoolean("loud_sound_reduction", enabled) } - } - } - - fun findChangedIndex(oldArray: BooleanArray, newArray: BooleanArray): Int { - for (i in oldArray.indices) { - if (oldArray[i] != newArray[i]) { - return i - } - } - throw IllegalArgumentException("No element has changed") - } - - fun updateLongPress( - oldLongPressArray: BooleanArray, - newLongPressArray: BooleanArray, - offListeningMode: Boolean - ) { - if (oldLongPressArray.contentEquals(newLongPressArray)) { - return - } - val oldOffEnabled = oldLongPressArray[0] - val oldAncEnabled = oldLongPressArray[1] - val oldTransparencyEnabled = oldLongPressArray[2] - val oldAdaptiveEnabled = oldLongPressArray[3] - - val newOffEnabled = newLongPressArray[0] - val newAncEnabled = newLongPressArray[1] - val newTransparencyEnabled = newLongPressArray[2] - val newAdaptiveEnabled = newLongPressArray[3] - - val changedIndex = findChangedIndex(oldLongPressArray, newLongPressArray) - Log.d("AirPodsService", "changedIndex: $changedIndex") - var packet: ByteArray? = null - if (offListeningMode) { - packet = when (changedIndex) { - 0 -> { - if (newOffEnabled) { - when { - oldAncEnabled && oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_EVERYTHING.value - oldAncEnabled && oldTransparencyEnabled -> LongPressPackets.ENABLE_OFF_FROM_TRANSPARENCY_AND_ANC.value - oldAncEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_OFF_FROM_ADAPTIVE_AND_ANC.value - oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE.value - else -> null - } - } else { - when { - oldAncEnabled && oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_OFF_FROM_EVERYTHING.value - oldAncEnabled && oldTransparencyEnabled -> LongPressPackets.DISABLE_OFF_FROM_TRANSPARENCY_AND_ANC.value - oldAncEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_OFF_FROM_ADAPTIVE_AND_ANC.value - oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE.value - else -> null - } - } - } - - 1 -> { - if (newAncEnabled) { - when { - oldOffEnabled && oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_EVERYTHING.value - oldOffEnabled && oldTransparencyEnabled -> LongPressPackets.ENABLE_ANC_FROM_OFF_AND_TRANSPARENCY.value - oldOffEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_ANC_FROM_OFF_AND_ADAPTIVE.value - oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE.value - else -> null - } - } else { - when { - oldOffEnabled && oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_ANC_FROM_EVERYTHING.value - oldOffEnabled && oldTransparencyEnabled -> LongPressPackets.DISABLE_ANC_FROM_OFF_AND_TRANSPARENCY.value - oldOffEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_ANC_FROM_OFF_AND_ADAPTIVE.value - oldTransparencyEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_OFF_FROM_TRANSPARENCY_AND_ADAPTIVE.value - else -> null - } - } - } - - 2 -> { - if (newTransparencyEnabled) { - when { - oldOffEnabled && oldAncEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_EVERYTHING.value - oldOffEnabled && oldAncEnabled -> LongPressPackets.ENABLE_TRANSPARENCY_FROM_OFF_AND_ANC.value - oldOffEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE.value - oldAncEnabled && oldAdaptiveEnabled -> LongPressPackets.ENABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC.value - else -> null - } - } else { - when { - oldOffEnabled && oldAncEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_TRANSPARENCY_FROM_EVERYTHING.value - oldOffEnabled && oldAncEnabled -> LongPressPackets.DISABLE_TRANSPARENCY_FROM_OFF_AND_ANC.value - oldOffEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_TRANSPARENCY_FROM_OFF_AND_ADAPTIVE.value - oldAncEnabled && oldAdaptiveEnabled -> LongPressPackets.DISABLE_TRANSPARENCY_FROM_ADAPTIVE_AND_ANC.value - else -> null - } - } - } - - 3 -> { - if (newAdaptiveEnabled) { - when { - oldOffEnabled && oldAncEnabled && oldTransparencyEnabled -> LongPressPackets.ENABLE_EVERYTHING.value - oldOffEnabled && oldAncEnabled -> LongPressPackets.ENABLE_ADAPTIVE_FROM_OFF_AND_ANC.value - oldOffEnabled && oldTransparencyEnabled -> LongPressPackets.ENABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY.value - oldAncEnabled && oldTransparencyEnabled -> LongPressPackets.ENABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC.value - else -> null - } - } else { - when { - oldOffEnabled && oldAncEnabled && oldTransparencyEnabled -> LongPressPackets.DISABLE_ADAPTIVE_FROM_EVERYTHING.value - oldOffEnabled && oldAncEnabled -> LongPressPackets.DISABLE_ADAPTIVE_FROM_OFF_AND_ANC.value - oldOffEnabled && oldTransparencyEnabled -> LongPressPackets.DISABLE_ADAPTIVE_FROM_OFF_AND_TRANSPARENCY.value - oldAncEnabled && oldTransparencyEnabled -> LongPressPackets.DISABLE_ADAPTIVE_FROM_TRANSPARENCY_AND_ANC.value - else -> null - } - } - } - - else -> null - } - } else { - when (changedIndex) { - 1 -> { - packet = if (newLongPressArray[1]) { - LongPressPackets.ENABLE_EVERYTHING_OFF_DISABLED.value - } else { - LongPressPackets.DISABLE_ANC_OFF_DISABLED.value - } - } - - 2 -> { - packet = if (newLongPressArray[2]) { - LongPressPackets.ENABLE_EVERYTHING_OFF_DISABLED.value - } else { - LongPressPackets.DISABLE_TRANSPARENCY_OFF_DISABLED.value - } - } - - 3 -> { - packet = if (newLongPressArray[3]) { - LongPressPackets.ENABLE_EVERYTHING_OFF_DISABLED.value - } else { - LongPressPackets.DISABLE_ADAPTIVE_OFF_DISABLED.value - } - } - } - - } - packet?.let { - Log.d("AirPodsService", "Sending packet: ${it.joinToString(" ") { "%02X".format(it) }}") - sendPacket(it) - } + Log.d("AirPodsService", "setName: $name") } + @SuppressLint("MissingPermission") override fun onDestroy() { clearPacketLogs() Log.d("AirPodsService", "Service stopped is being destroyed for some reason!") @@ -2294,6 +2023,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } catch (e: Exception) { e.printStackTrace() } + try { + bleManager.stopScanning() + } catch (e: Exception) { + e.printStackTrace() + } telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE) isConnectedLocally = false CrossDevice.isAvailable = true @@ -2304,18 +2038,24 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun startHeadTracking() { isHeadTrackingActive = true - socket.outputStream.write(Enums.START_HEAD_TRACKING.value) + aacpManager.sendStartHeadTracking() HeadTracking.reset() } fun stopHeadTracking() { - socket.outputStream.write(Enums.STOP_HEAD_TRACKING.value) + aacpManager.sendStopHeadTracking() isHeadTrackingActive = false } - fun processData(data: ByteArray) { - if (isHeadTrackingActive && isHeadTrackingData(data)) { - HeadTracking.processPacket(data) + fun shouldTakeOverBasedOnAirPodsState(connectionState: String): Boolean { + if (CrossDevice.isAvailable) return true + + return when (connectionState) { + "Disconnected" -> config.takeoverWhenDisconnected + "Idle" -> config.takeoverWhenIdle + "Music" -> config.takeoverWhenMusic + "Call", "Ringing", "Hanging Up" -> config.takeoverWhenCall + else -> false } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt new file mode 100644 index 0000000..4ec3baf --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt @@ -0,0 +1,478 @@ +/* + * LibrePods - AirPods liberated from Apple's ecosystem + * + * Copyright (C) 2025 LibrePods contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +@file:OptIn(ExperimentalEncodingApi::class) + +package me.kavishdevar.librepods.utils + +import android.util.Log +import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers.entries +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Manager class for Apple Accessory Communication Protocol (AACP) + * This class is responsible for handling the L2CAP socket management, + * constructing and parsing packets for communication with Apple accessories. + */ +class AACPManager { + companion object { + private const val TAG = "AACPManager" + + object Opcodes { + const val SET_FEATURE_FLAGS: Byte = 0x4d + const val REQUEST_NOTIFICATIONS: Byte = 0x0f + const val BATTERY_INFO: Byte = 0x04 + const val CONTROL_COMMAND: Byte = 0x09 + const val EAR_DETECTION: Byte = 0x06 + const val CONVERSATION_AWARENESS: Byte = 0x4b + const val DEVICE_METADATA: Byte = 0x1d + const val RENAME: Byte = 0x1E + const val HEADTRACKING: Byte = 0x17 + const val PROXIMITY_KEYS_REQ: Byte = 0x30 + const val PROXIMITY_KEYS_RSP: Byte = 0x31 + } + + private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) + + data class ControlCommandStatus( + val identifier: ControlCommandIdentifiers, + val value: ByteArray + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ControlCommandStatus + + if (identifier != other.identifier) return false + if (!value.contentEquals(other.value)) return false + + return true + } + + override fun hashCode(): Int { + var result: Int = identifier.hashCode() + result = 31 * result + value.contentHashCode() + return result + } + } + +// @Suppress("unused") + enum class ControlCommandIdentifiers(val value: Byte) { + MIC_MODE(0x01), + BUTTON_SEND_MODE(0x05), + VOICE_TRIGGER(0x12), + SINGLE_CLICK_MODE(0x14), + DOUBLE_CLICK_MODE(0x15), + CLICK_HOLD_MODE(0x16), + DOUBLE_CLICK_INTERVAL(0x17), + CLICK_HOLD_INTERVAL(0x18), + LISTENING_MODE_CONFIGS(0x1A), + ONE_BUD_ANC_MODE(0x1B), + CROWN_ROTATION_DIRECTION(0x1C), + LISTENING_MODE(0x0D), + AUTO_ANSWER_MODE(0x1E), + CHIME_VOLUME(0x1F), + VOLUME_SWIPE_INTERVAL(0x23), + CALL_MANAGEMENT_CONFIG(0x24), + VOLUME_SWIPE_MODE(0x25), + ADAPTIVE_VOLUME_CONFIG(0x26), + SOFTWARE_MUTE_CONFIG(0x27), + CONVERSATION_DETECT_CONFIG(0x28), + SSL(0x29), + HEARING_AID(0x2C), + AUTO_ANC_STRENGTH(0x2E), + HPS_GAIN_SWIPE(0x2F), + HRM_STATE(0x30), + IN_CASE_TONE_CONFIG(0x31), + SIRI_MULTITONE_CONFIG(0x32), + HEARING_ASSIST_CONFIG(0x33), + ALLOW_OFF_OPTION(0x34); + + companion object { + fun fromByte(byte: Byte): ControlCommandIdentifiers? = + entries.find { it.value == byte } + } + } + + enum class ProximityKeyType(val value: Byte) { + IRK(0x01), + ENC_KEY(0x04); + + companion object { + fun fromByte(byte: Byte): ProximityKeyType = + ProximityKeyType.entries.find { it.value == byte }?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte") + } + } + } + var controlCommandStatusList: MutableList = mutableListOf() + var controlCommandListeners: MutableMap> = mutableMapOf() + + fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? { + return controlCommandStatusList.find { it.identifier == identifier } + } + + private fun setControlCommandStatusValue(identifier: ControlCommandIdentifiers, value: ByteArray) { + val existingStatus = getControlCommandStatus(identifier) + if (existingStatus == value) { + controlCommandStatusList.remove(existingStatus) + } + if (existingStatus != null) { + controlCommandStatusList.remove(existingStatus) + } + controlCommandListeners[identifier]?.forEach { listener -> + listener.onControlCommandReceived(ControlCommand(identifier.value, value)) + } + controlCommandStatusList.add(ControlCommandStatus(identifier, value)) + } + + interface PacketCallback { + fun onBatteryInfoReceived(batteryInfo: ByteArray) + fun onEarDetectionReceived(earDetection: ByteArray) + fun onConversationAwarenessReceived(conversationAwareness: ByteArray) + fun onControlCommandReceived(controlCommand: ByteArray) + fun onDeviceMetadataReceived(deviceMetadata: ByteArray) + fun onHeadTrackingReceived(headTracking: ByteArray) + fun onUnknownPacketReceived(packet: ByteArray) + fun onProximityKeysReceived(proximityKeys: ByteArray) + } + + interface ControlCommandListener { + fun onControlCommandReceived(controlCommand: ControlCommand) + } + + fun registerControlCommandListener(identifier: ControlCommandIdentifiers, callback: ControlCommandListener) { + controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback) + } + + private var callback: PacketCallback? = null + + fun setPacketCallback(callback: PacketCallback) { + this.callback = callback + } + + fun createDataPacket(data: ByteArray): ByteArray { + return HEADER_BYTES + data + } + + fun createControlCommandPacket(identifier: Byte, data: ByteArray): ByteArray { + val opcode = byteArrayOf(Opcodes.CONTROL_COMMAND, 0x00) + val payload = ByteArray(7) + + System.arraycopy(opcode, 0, payload, 0, 2) + payload[2] = identifier + + val dataLength = minOf(data.size, 4) + System.arraycopy(data, 0, payload, 3, dataLength) + + return payload + } + + fun sendDataPacket(data: ByteArray): Boolean { + return sendPacket(createDataPacket(data)) + } + + fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean { + val controlPacket = createControlCommandPacket(identifier, value) + setControlCommandStatusValue( + ControlCommandIdentifiers.fromByte(identifier) ?: return false, + value + ) + return sendDataPacket(controlPacket) + } + + fun sendControlCommand(identifier: Byte, value: Byte): Boolean { + val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value)) + setControlCommandStatusValue( + ControlCommandIdentifiers.fromByte(identifier) ?: return false, + byteArrayOf(value) + ) + return sendDataPacket(controlPacket) + } + + fun sendControlCommand(identifier: Byte, value: Boolean): Boolean { + val controlPacket = createControlCommandPacket(identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02)) + setControlCommandStatusValue( + ControlCommandIdentifiers.fromByte(identifier) ?: return false, + if (value) byteArrayOf(0x01) else byteArrayOf(0x02) + ) + return sendDataPacket(controlPacket) + } + + fun sendControlCommand(identifier: Byte, value: Int): Boolean { + val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value.toByte())) + setControlCommandStatusValue( + ControlCommandIdentifiers.fromByte(identifier) ?: return false, + byteArrayOf(value.toByte()) + ) + return sendDataPacket(controlPacket) + } + + fun parseProximityKeysResponse(data: ByteArray): Map { + Log.d(TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}") + if (data.size < 4) { + throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") + } + if (data[4] != Opcodes.PROXIMITY_KEYS_RSP) { + throw IllegalArgumentException("Data array does not start with PROXIMITY_KEYS_RSP opcode") + } + val keyCount = data[6].toInt() + val keys = mutableMapOf() + var offset = 7 + for (i in 0 until keyCount) { + Log.d(TAG, "Parsing Proximity Key $i") + if (offset + 3 >= data.size) { + throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") + } + val keyType = data[offset] + val keyLength = data[offset + 2].toInt() + Log.d(TAG, "Key Type: ${keyType.toString(16)}, Key Length: $keyLength") + offset += 4 + if (offset + keyLength > data.size) { + throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") + } + val key = ByteArray(keyLength) + System.arraycopy(data, offset, key, 0, keyLength) + keys[ProximityKeyType.fromByte(keyType)] = key + offset += keyLength + Log.d(TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${key.joinToString(" ") { "%02X".format(it) }}") + } + return keys + } + + fun sendRequestProximityKeys(type: Byte): Boolean { + Log.d(TAG, "Requesting proximity keys of type: ${type.toString(16)}") + return sendDataPacket(createRequestProximityKeysPacket(type)) + } + + fun createRequestProximityKeysPacket(type: Byte): ByteArray { + val opcode = byteArrayOf(Opcodes.PROXIMITY_KEYS_REQ, 0x00) + val data = byteArrayOf(type, 0x00) + return opcode + data + } + + @OptIn(ExperimentalStdlibApi::class) + fun receivePacket(packet: ByteArray) { + if (!packet.toHexString().startsWith("04000400")) { + Log.w(TAG, "Received packet does not start with expected header: ${packet.joinToString(" ") { "%02X".format(it) }}") + return + } + if (packet.size < 6) { + Log.w(TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}") + return + } + + val opcode = packet[4] + + when (opcode) { + Opcodes.BATTERY_INFO -> { + callback?.onBatteryInfoReceived(packet) + } + Opcodes.CONTROL_COMMAND -> { + val controlCommand = ControlCommand.fromByteArray(packet) + setControlCommandStatusValue( + ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return, + controlCommand.value + ) + Log.d(TAG, "Control command received: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}") + Log.d(TAG, "Control command list is now: ${ + controlCommandStatusList.joinToString(", ") { "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${it.value.joinToString(" ") { "%02X".format(it) }}" } + }") + + val controlCommandIdentifier = ControlCommandIdentifiers.fromByte(controlCommand.identifier) + if (controlCommandIdentifier != null) { + controlCommandListeners[controlCommandIdentifier]?.forEach { listener -> + listener.onControlCommandReceived(controlCommand) + } + } else { + Log.w(TAG, "Unknown control command identifier: ${controlCommand.identifier.toHexString()}") + } + + callback?.onControlCommandReceived(packet) + } + Opcodes.EAR_DETECTION -> { + callback?.onEarDetectionReceived(packet) + } + Opcodes.CONVERSATION_AWARENESS -> { + callback?.onConversationAwarenessReceived(packet) + } + Opcodes.DEVICE_METADATA -> { + callback?.onDeviceMetadataReceived(packet) + } + Opcodes.HEADTRACKING -> { + if (packet.size < 70) { + Log.w(TAG, "Received HEADTRACKING packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}") + return + } + callback?.onHeadTrackingReceived(packet) + } + Opcodes.PROXIMITY_KEYS_RSP -> { + callback?.onProximityKeysReceived(packet) + } + else -> { + callback?.onUnknownPacketReceived(packet) + } + } + } + + fun sendNotificationRequest(): Boolean { + return sendDataPacket(createRequestNotificationPacket()) + } + + fun createRequestNotificationPacket(): ByteArray { + val opcode = byteArrayOf(Opcodes.REQUEST_NOTIFICATIONS, 0x00) + val data = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte()) + return opcode + data + } + + fun sendSetFeatureFlagsPacket(): Boolean { + return sendDataPacket(createSetFeatureFlagsPacket()) + } + + fun createSetFeatureFlagsPacket(): ByteArray { + val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00) + val data = byteArrayOf(0xFF.toByte(), 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + return opcode + data + } + + fun createHandshakePacket(): ByteArray { + return byteArrayOf( + 0x00, 0x00, 0x04, 0x00, + 0x01, 0x00, 0x02, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00 + ) + } + + fun sendStartHeadTracking(): Boolean { + return sendDataPacket(createStartHeadTrackingPacket()) + } + + fun createStartHeadTrackingPacket(): ByteArray { + val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00) + val data = byteArrayOf( + 0x00, 0x00, 0x10, 0x00, 0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00, + ) + return opcode + data + } + + fun sendStopHeadTracking(): Boolean { + return sendDataPacket(createStopHeadTrackingPacket()) + } + + fun createStopHeadTrackingPacket(): ByteArray { + val opcode = byteArrayOf(Opcodes.HEADTRACKING, 0x00) + val data = byteArrayOf( + 0x00, 0x00, 0x10, 0x00, 0x11, 0x00, 0x08, 0x7E, 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00 + ) + return opcode + data + } + + fun sendRename(name: String): Boolean { + return sendDataPacket(createRenamePacket(name)) + } + + fun createRenamePacket(name: String): ByteArray { + val nameBytes = name.toByteArray() + val size = nameBytes.size + val packet = ByteArray(5 + size) + packet[0] = Opcodes.RENAME + packet[1] = 0x00 + packet[2] = size.toByte() + packet[3] = 0x00 + System.arraycopy(nameBytes, 0, packet, 4, size) + + return packet + } + + + data class ControlCommand( + val identifier: Byte, + val value: ByteArray + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ControlCommand + + if (identifier != other.identifier) return false + if (!value.contentEquals(other.value)) return false + + return true + } + + override fun hashCode(): Int { + var result: Int = identifier.toInt() + result = 31 * result + value.contentHashCode() + return result + } + + companion object { + fun fromByteArray(data: ByteArray): ControlCommand { + if (data.size < 4) { + throw IllegalArgumentException("Data array too short to parse ControlCommand") + } + if (data[0] == 0x04.toByte() && data[1] == 0x00.toByte() && data[2] == 0x04.toByte() && data[3] == 0x00.toByte()) { + val newData = ByteArray(data.size - 4) + System.arraycopy(data, 4, newData, 0, data.size - 4) + return fromByteArray(newData) + } + if (data[0] != Opcodes.CONTROL_COMMAND) { + throw IllegalArgumentException("Data array does not start with CONTROL_COMMAND opcode") + } + val identifier = data[2] + + val value = ByteArray(4) + System.arraycopy(data, 3, value, 0, 4) + + // drop trailing zeroes in the array, and return the bytearray of the reduced array + val trimmedValue = value.takeWhile { it != 0x00.toByte() }.toByteArray() + return ControlCommand(identifier, trimmedValue) + } + } + } + + @OptIn(ExperimentalStdlibApi::class) + fun sendPacket(packet: ByteArray): Boolean { + try { + Log.d(TAG, "Sending packet: ${packet.joinToString(" ") { "%02X".format(it) }}") + + if (packet[4] == Opcodes.CONTROL_COMMAND) { + val controlCommand = ControlCommand.fromByteArray(packet) + Log.d(TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${controlCommand.value.joinToString(" ") { "%02X".format(it) }}") + setControlCommandStatusValue( + ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false, + controlCommand.value + ) + } + + val socket = BluetoothConnectionManager.getCurrentSocket() + if (socket?.isConnected == true) { + socket.outputStream?.write(packet) + socket.outputStream?.flush() + return true + } else { + Log.d(TAG, "Can't send packet: Socket not initialized or connected") + return false + } + } catch (e: Exception) { + Log.e(TAG, "Error sending packet: ${e.message}") + return false + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt new file mode 100644 index 0000000..0e3f9ab --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt @@ -0,0 +1,429 @@ +/* + * LibrePods - AirPods liberated from Apple's ecosystem + * + * Copyright (C) 2025 LibrePods Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.utils + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothManager +import android.bluetooth.le.BluetoothLeScanner +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.content.SharedPreferences +import android.os.Handler +import android.os.Looper +import android.util.Log +import me.kavishdevar.librepods.services.ServiceManager +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Manager for Bluetooth Low Energy scanning operations specifically for AirPods + */ +@OptIn(ExperimentalEncodingApi::class) +class BLEManager(private val context: Context) { + + data class AirPodsStatus( + val address: String, + val lastSeen: Long = System.currentTimeMillis(), + val paired: Boolean = false, + val model: String = "Unknown", + val leftBattery: Int? = null, + val rightBattery: Int? = null, + val caseBattery: Int? = null, + val isLeftInEar: Boolean = false, + val isRightInEar: Boolean = false, + val isLeftCharging: Boolean = false, + val isRightCharging: Boolean = false, + val isCaseCharging: Boolean = false, + val lidOpen: Boolean = false, + val color: String = "Unknown", + val connectionState: String = "Unknown" + ) + + fun getMostRecentStatus(): AirPodsStatus? { + return deviceStatusMap.values.maxByOrNull { it.lastSeen } + } + + interface AirPodsStatusListener { + fun onDeviceStatusChanged(device: AirPodsStatus, previousStatus: AirPodsStatus?) + fun onBroadcastFromNewAddress(device: AirPodsStatus) + fun onLidStateChanged(lidOpen: Boolean) + fun onEarStateChanged(device: AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean) + fun onBatteryChanged(device: AirPodsStatus) + } + + private var mBluetoothLeScanner: BluetoothLeScanner? = null + private var mScanCallback: ScanCallback? = null + private var airPodsStatusListener: AirPodsStatusListener? = null + private val deviceStatusMap = mutableMapOf() + private val verifiedAddresses = mutableSetOf() + private val sharedPreferences: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + private var currentGlobalLidState: Boolean? = null + private var lastBroadcastTime: Long = 0 + private val processedAddresses = mutableSetOf() + private val modelNames = mapOf( + 0x0E20 to "AirPods Pro", + 0x1420 to "AirPods Pro 2", + 0x2420 to "AirPods Pro 2 (USB-C)", + 0x0220 to "AirPods 1", + 0x0F20 to "AirPods 2", + 0x1320 to "AirPods 3", + 0x1920 to "AirPods 4", + 0x1B20 to "AirPods 4 (ANC)", + 0x0A20 to "AirPods Max", + 0x1F20 to "AirPods Max (USB-C)" + ) + + val colorNames = mapOf( + 0x00 to "White", 0x01 to "Black", 0x02 to "Red", 0x03 to "Blue", + 0x04 to "Pink", 0x05 to "Gray", 0x06 to "Silver", 0x07 to "Gold", + 0x08 to "Rose Gold", 0x09 to "Space Gray", 0x0A to "Dark Blue", + 0x0B to "Light Blue", 0x0C to "Yellow" + ) + + val connStates = mapOf( + 0x00 to "Disconnected", 0x04 to "Idle", 0x05 to "Music", + 0x06 to "Call", 0x07 to "Ringing", 0x09 to "Hanging Up", 0xFF to "Unknown" + ) + + private val cleanupHandler = Handler(Looper.getMainLooper()) + private val cleanupRunnable = object : Runnable { + override fun run() { + cleanupStaleDevices() + checkLidStateTimeout() + cleanupHandler.postDelayed(this, CLEANUP_INTERVAL_MS) + } + } + + fun setAirPodsStatusListener(listener: AirPodsStatusListener) { + airPodsStatusListener = listener + } + + @SuppressLint("MissingPermission") + fun startScanning() { + try { + Log.d(TAG, "Starting BLE scanner") + + val btManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + val btAdapter = btManager.adapter + + if (btAdapter == null) { + Log.d(TAG, "No Bluetooth adapter available") + return + } + + if (mBluetoothLeScanner != null && mScanCallback != null) { + mBluetoothLeScanner?.stopScan(mScanCallback) + mScanCallback = null + } + + if (!btAdapter.isEnabled) { + Log.d(TAG, "Bluetooth is disabled") + return + } + + mBluetoothLeScanner = btAdapter.bluetoothLeScanner + + val scanSettings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE) + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .setNumOfMatches(ScanSettings.MATCH_NUM_MAX_ADVERTISEMENT) + .setReportDelay(500L) + .build() + + val manufacturerData = ByteArray(27) + val manufacturerDataMask = ByteArray(27) + + manufacturerData[0] = 7 + manufacturerData[1] = 25 + + manufacturerDataMask[0] = -1 + manufacturerDataMask[1] = -1 + + val scanFilter = ScanFilter.Builder() + .setManufacturerData(76, manufacturerData, manufacturerDataMask) + .build() + + mScanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + processScanResult(result) + } + + override fun onBatchScanResults(results: List) { + processedAddresses.clear() + for (result in results) { + processScanResult(result) + } + } + + override fun onScanFailed(errorCode: Int) { + Log.e(TAG, "BLE scan failed with error code: $errorCode") + } + } + + mBluetoothLeScanner?.startScan(listOf(scanFilter), scanSettings, mScanCallback) + Log.d(TAG, "BLE scanner started successfully") + + cleanupHandler.postDelayed(cleanupRunnable, CLEANUP_INTERVAL_MS) + } catch (t: Throwable) { + Log.e(TAG, "Error starting BLE scanner", t) + } + } + + @SuppressLint("MissingPermission") + fun stopScanning() { + try { + if (mBluetoothLeScanner != null && mScanCallback != null) { + Log.d(TAG, "Stopping BLE scanner") + mBluetoothLeScanner?.stopScan(mScanCallback) + mScanCallback = null + } + + cleanupHandler.removeCallbacks(cleanupRunnable) + } catch (t: Throwable) { + Log.e(TAG, "Error stopping BLE scanner", t) + } + } + + private fun processScanResult(result: ScanResult) { + try { + val scanRecord = result.scanRecord ?: return + val address = result.device.address + + if (processedAddresses.contains(address)) { + return + } + + val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return + if (manufacturerData.size <= 20) return + + if (!verifiedAddresses.contains(address)) { + val irk = getIrkFromPreferences() + if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) { + return + } + verifiedAddresses.add(address) + Log.d(TAG, "RPA verified and added to trusted list: $address") + } + + processedAddresses.add(address) + lastBroadcastTime = System.currentTimeMillis() + + val parsedStatus = parseProximityMessage(address, manufacturerData) + val previousStatus = deviceStatusMap[address] + + deviceStatusMap[address] = parsedStatus + + airPodsStatusListener?.let { listener -> + if (previousStatus == null) { + listener.onBroadcastFromNewAddress(parsedStatus) + Log.d(TAG, "New AirPods device detected: $address") + + if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) { + currentGlobalLidState = parsedStatus.lidOpen + listener.onLidStateChanged(parsedStatus.lidOpen) + Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)") + } + } else { + if (parsedStatus != previousStatus) { + listener.onDeviceStatusChanged(parsedStatus, previousStatus) + } + + if (parsedStatus.lidOpen != previousStatus.lidOpen) { + val previousGlobalState = currentGlobalLidState + currentGlobalLidState = parsedStatus.lidOpen + + if (previousGlobalState != parsedStatus.lidOpen) { + listener.onLidStateChanged(parsedStatus.lidOpen) + Log.d(TAG, "Lid state changed from ${previousGlobalState} to ${parsedStatus.lidOpen}") + } + } + + if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar || + parsedStatus.isRightInEar != previousStatus.isRightInEar) { + listener.onEarStateChanged( + parsedStatus, + parsedStatus.isLeftInEar, + parsedStatus.isRightInEar + ) + Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}") + } + + if (parsedStatus.leftBattery != previousStatus.leftBattery || + parsedStatus.rightBattery != previousStatus.rightBattery || + parsedStatus.caseBattery != previousStatus.caseBattery) { + listener.onBatteryChanged(parsedStatus) + Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}") + } + } + } + } catch (t: Throwable) { + Log.e(TAG, "Error processing scan result", t) + } + } + + private fun cleanupStaleDevices() { + val now = System.currentTimeMillis() + val staleCutoff = now - STALE_DEVICE_TIMEOUT_MS + + val staleDevices = deviceStatusMap.filter { it.value.lastSeen < staleCutoff } + + for (device in staleDevices) { + deviceStatusMap.remove(device.key) + Log.d(TAG, "Removed stale device from tracking: ${device.key}") + } + } + + private fun checkLidStateTimeout() { + val currentTime = System.currentTimeMillis() + if (currentTime - lastBroadcastTime > LID_CLOSE_TIMEOUT_MS && currentGlobalLidState == true) { + Log.d(TAG, "No broadcasts for ${LID_CLOSE_TIMEOUT_MS}ms, forcing lid state to closed") + currentGlobalLidState = false + airPodsStatusListener?.onLidStateChanged(false) + } + } + + @OptIn(ExperimentalEncodingApi::class) + private fun getIrkFromPreferences(): ByteArray? { + val irkBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null) + return if (irkBase64 != null) { + try { + Base64.decode(irkBase64) + } catch (e: Exception) { + Log.e(TAG, "Failed to decode IRK", e) + null + } + } else { + null + } + } + + private fun parseProximityMessage(address: String, data: ByteArray): AirPodsStatus { + val paired = data[2].toInt() == 1 + val modelId = ((data[3].toInt() and 0xFF) shl 8) or (data[4].toInt() and 0xFF) + val model = modelNames[modelId] ?: "Unknown ($modelId)" + + val status = data[5].toInt() and 0xFF + val podsBattery = data[6].toInt() and 0xFF + val flagsCase = data[7].toInt() and 0xFF + val lid = data[8].toInt() and 0xFF + val color = colorNames[data[9].toInt()] ?: "Unknown" + val conn = connStates[data[10].toInt()] ?: "Unknown (${data[10].toInt()})" + + val primaryLeft = ((status shr 5) and 0x01) == 1 + val thisInCase = ((status shr 6) and 0x01) == 1 + val xorFactor = primaryLeft xor thisInCase + + val isLeftInEar = if (xorFactor) (status and 0x08) != 0 else (status and 0x02) != 0 + val isRightInEar = if (xorFactor) (status and 0x02) != 0 else (status and 0x08) != 0 + + val leftBatteryNibble = if (xorFactor) (podsBattery shr 4) and 0x0F else podsBattery and 0x0F + val rightBatteryNibble = if (xorFactor) podsBattery and 0x0F else (podsBattery shr 4) and 0x0F + + val caseBattery = (flagsCase shr 4) and 0x0F + val flags = flagsCase and 0x0F + + val isRightCharging = if (xorFactor) (flags and 0x02) != 0 else (flags and 0x01) != 0 + val isLeftCharging = if (xorFactor) (flags and 0x01) != 0 else (flags and 0x02) != 0 + val isCaseCharging = (flags and 0x04) != 0 + + val lidOpen = ((lid shr 3) and 0x01) == 0 + + fun decodeBattery(n: Int): Int? = when (n) { + in 0x0..0x9 -> n * 10 + in 0xA..0xE -> 100 + 0xF -> null + else -> null + } + + return AirPodsStatus( + address = address, + lastSeen = System.currentTimeMillis(), + paired = paired, + model = model, + leftBattery = decodeBattery(leftBatteryNibble), + rightBattery = decodeBattery(rightBatteryNibble), + caseBattery = decodeBattery(caseBattery), + isLeftInEar = isLeftInEar, + isRightInEar = isRightInEar, + isLeftCharging = isLeftCharging, + isRightCharging = isRightCharging, + isCaseCharging = isCaseCharging, + lidOpen = lidOpen, + color = color, + connectionState = conn + ) + } + + private val bleStatusListener = object : BLEManager.AirPodsStatusListener { + @SuppressLint("NewApi") + override fun onDeviceStatusChanged( + device: BLEManager.AirPodsStatus, + previousStatus: BLEManager.AirPodsStatus? + ) { + if (ServiceManager.getService()?.isConnectedLocally == true) { + Log.d("AirPodsBLEService", "Checking if audio should be connected") + ServiceManager.getService()?.manuallyCheckForAudioSource() + return + } + + Log.d("AirPodsBLEService", "Device status changed, inEar: ${device.isLeftInEar}, ${device.isRightInEar}") + + if (previousStatus != null && device.connectionState != previousStatus.connectionState) { + Log.d("AirPodsBLEService", "Connection state changed from ${previousStatus.connectionState} to ${device.connectionState}") + + if (ServiceManager.getService()?.shouldTakeOverBasedOnAirPodsState(device.connectionState) == true) { + Log.d("AirPodsBLEService", "Taking over based on AirPods state: ${device.connectionState}") + + val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + val bluetoothDevice = bluetoothManager.adapter.getRemoteDevice(context.getSharedPreferences( + "settings", Context.MODE_PRIVATE).getString("mac_address", "") ?: "") + + ServiceManager.getService()?.connectToSocket(bluetoothDevice) + } + } + } + + override fun onBroadcastFromNewAddress(device: BLEManager.AirPodsStatus) { + // Implement this method if needed + } + + override fun onLidStateChanged(lidOpen: Boolean) { + // Implement this method if needed + } + + override fun onEarStateChanged(device: BLEManager.AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean) { + // Implement this method if needed + } + + override fun onBatteryChanged(device: BLEManager.AirPodsStatus) { + // Implement this method if needed + } + } + + companion object { + private const val TAG = "AirPodsBLE" + private const val CLEANUP_INTERVAL_MS = 30000L + private const val STALE_DEVICE_TIMEOUT_MS = 60000L + private const val LID_CLOSE_TIMEOUT_MS = 2000L + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothConnectionManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothConnectionManager.kt new file mode 100644 index 0000000..5655793 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothConnectionManager.kt @@ -0,0 +1,40 @@ +/* + * LibrePods - AirPods liberated from Apple's ecosystem + * + * Copyright (C) 2025 LibrePods Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.utils + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothSocket +import android.util.Log + +object BluetoothConnectionManager { + private const val TAG = "BluetoothConnectionManager" + + private var currentSocket: BluetoothSocket? = null + private var currentDevice: BluetoothDevice? = null + + fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) { + currentSocket = socket + currentDevice = device + Log.d(TAG, "Current connection set to device: ${device.address}") + } + + fun getCurrentSocket(): BluetoothSocket? { + return currentSocket + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt new file mode 100644 index 0000000..145c89f --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt @@ -0,0 +1,74 @@ +/* + * LibrePods - AirPods liberated from Apple's ecosystem + * + * Copyright (C) 2025 LibrePods Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package me.kavishdevar.librepods.utils + +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +/** + * Utilities for Bluetooth cryptography operations, particularly for + * verifying Resolvable Private Addresses (RPA) used by AirPods. + */ +object BluetoothCryptography { + + /** + * Verifies if the provided Bluetooth address is an RPA that matches the given Identity Resolving Key (IRK) + * + * @param addr The Bluetooth address to verify + * @param irk The Identity Resolving Key to use for verification + * @return true if the address is verified as an RPA matching the IRK + */ + fun verifyRPA(addr: String, irk: ByteArray): Boolean { + val rpa = addr.split(":").map { it.toInt(16).toByte() }.reversed().toByteArray() + val prand = rpa.copyOfRange(3, 6) + val hash = rpa.copyOfRange(0, 3) + val computedHash = ah(irk, prand) + return hash.contentEquals(computedHash) + } + + /** + * Performs E function (AES-128) as specified in Bluetooth Core Specification + * + * @param key The key for encryption + * @param data The data to encrypt + * @return The encrypted data + */ + fun e(key: ByteArray, data: ByteArray): ByteArray { + val swappedKey = key.reversedArray() + val swappedData = data.reversedArray() + val cipher = Cipher.getInstance("AES/ECB/NoPadding") + val secretKey = SecretKeySpec(swappedKey, "AES") + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + return cipher.doFinal(swappedData).reversedArray() + } + + /** + * Performs the ah function as specified in Bluetooth Core Specification + * + * @param k The IRK key + * @param r The random part of the address + * @return The hash part of the address + */ + fun ah(k: ByteArray, r: ByteArray): ByteArray { + val rPadded = ByteArray(16) + r.copyInto(rPadded, 0, 0, 3) + val encrypted = e(k, rPadded) + return encrypted.copyOfRange(0, 3) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt index deb1f29..f5130ea 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) package me.kavishdevar.librepods.utils @@ -40,6 +41,7 @@ import kotlinx.coroutines.launch import me.kavishdevar.librepods.services.ServiceManager import java.io.IOException import java.util.UUID +import kotlin.io.encoding.ExperimentalEncodingApi enum class CrossDevicePackets(val packet: ByteArray) { AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)), @@ -87,7 +89,7 @@ object CrossDevice { private fun startServer() { CoroutineScope(Dispatchers.IO).launch { if (!bluetoothAdapter.isEnabled) return@launch - serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid) +// serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid) Log.d("CrossDevice", "Server started") while (serverSocket != null) { if (!bluetoothAdapter.isEnabled) { @@ -233,7 +235,7 @@ object CrossDevice { Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}") if (ServiceManager.getService()?.isConnectedLocally == true) { val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) } - ServiceManager.getService()?.sendPacket(packetInHex) +// ServiceManager.getService()?.sendPacket(packetInHex) } else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) { batteryBytes = trimmedPacket ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt index d0b5dc4..b7b14bd 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.utils import android.os.Build @@ -13,6 +15,7 @@ import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.ServiceManager import java.util.Collections import java.util.concurrent.CopyOnWriteArrayList +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs import kotlin.math.max import kotlin.math.min @@ -20,21 +23,18 @@ import kotlin.math.pow @RequiresApi(Build.VERSION_CODES.Q) class GestureDetector( - private val airPodsService: AirPodsService, + private val airPodsService: AirPodsService ) { companion object { private const val TAG = "GestureDetector" - private const val START_CMD = "04 00 04 00 17 00 00 00 10 00 10 00 08 A1 02 42 0B 08 0E 10 02 1A 05 01 40 9C 00 00" - private const val STOP_CMD = "04 00 04 00 17 00 00 00 10 00 11 00 08 7E 10 02 42 0B 08 4E 10 02 1A 05 01 00 00 00 00" - private const val IMMEDIATE_FEEDBACK_THRESHOLD = 600 private const val DIRECTION_CHANGE_SENSITIVITY = 150 private const val FAST_MOVEMENT_THRESHOLD = 300.0 private const val MIN_REQUIRED_EXTREMES = 3 private const val MAX_REQUIRED_EXTREMES = 4 - + private const val MAX_VALID_ORIENTATION_VALUE = 6000 } @@ -92,7 +92,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U prevHorizontal = 0.0 prevVertical = 0.0 - airPodsService.sendPacket(START_CMD) + airPodsService.aacpManager.sendStartHeadTracking() detectionJob = CoroutineScope(Dispatchers.Default).launch { while (isRunning) { @@ -117,7 +117,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U Log.d(TAG, "Stopping gesture detection") isRunning = false - if (!doNotStop) airPodsService.sendPacket(STOP_CMD) + if (!doNotStop) airPodsService.aacpManager.sendStopHeadTracking() detectionJob?.cancel() detectionJob = null @@ -187,7 +187,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U } } - + private fun detectPeaksAndTroughs() { if (horizontalBuffer.size < 4 || verticalBuffer.size < 4) return diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt index a6f39ba..711bcbe 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt @@ -4,9 +4,6 @@ package me.kavishdevar.librepods.utils import android.content.Context import android.media.AudioAttributes -import android.media.AudioDeviceInfo -import android.media.AudioFocusRequest -import android.media.AudioManager import android.media.SoundPool import android.os.Build import android.os.SystemClock @@ -22,44 +19,6 @@ class GestureFeedback(private val context: Context) { private val soundsLoaded = AtomicBoolean(false) - private fun forceBluetoothRouting(audioManager: AudioManager) { - try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) - val bluetoothDevice = devices.find { - it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || - it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO - } - - bluetoothDevice?.let { device -> - val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) - .setAudioAttributes(AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .build()) - .build() - - audioManager.requestAudioFocus(focusRequest) - - if (!audioManager.isBluetoothScoOn) { - audioManager.isBluetoothScoOn = true - audioManager.startBluetoothSco() - } - - Log.d(TAG, "Forced audio routing to Bluetooth device") - } - } else { - if (!audioManager.isBluetoothScoOn) { - audioManager.isBluetoothScoOn = true - audioManager.startBluetoothSco() - Log.d(TAG, "Started Bluetooth SCO") - } - } - } catch (e: Exception) { - Log.e(TAG, "Failed to force Bluetooth routing", e) - } - } - private val soundPool = SoundPool.Builder() .setMaxStreams(3) .setAudioAttributes( @@ -201,12 +160,4 @@ class GestureFeedback(private val context: Context) { Log.d(TAG, "Playing ${if (isYes) "YES" else "NO"} confirmation - streamID=$streamId") } } - - fun release() { - try { - soundPool.release() - } catch (e: Exception) { - Log.e(TAG, "Error releasing resources", e) - } - } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt index d2a9e87..859f49b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt @@ -60,7 +60,6 @@ object HeadTracking { private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation { if (!isCalibrated) return Orientation() - // Add offset before normalizationval val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt index 78b1272..fa66d52 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt @@ -16,57 +16,200 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.utils import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ObjectAnimator import android.animation.PropertyValuesHolder +import android.animation.ValueAnimator import android.annotation.SuppressLint +import android.content.BroadcastReceiver import android.content.Context +import android.content.Intent +import android.content.IntentFilter import android.content.res.Resources import android.graphics.PixelFormat +import android.graphics.drawable.GradientDrawable import android.net.Uri +import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log.e import android.view.Gravity import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.VelocityTracker import android.view.View import android.view.WindowManager +import android.view.animation.AccelerateInterpolator import android.view.animation.AnticipateOvershootInterpolator +import android.view.animation.DecelerateInterpolator +import android.view.animation.OvershootInterpolator +import android.widget.FrameLayout +import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.TextView import android.widget.VideoView import androidx.core.content.ContextCompat.getString +import androidx.dynamicanimation.animation.DynamicAnimation +import androidx.dynamicanimation.animation.SpringAnimation +import androidx.dynamicanimation.animation.SpringForce import me.kavishdevar.librepods.R import me.kavishdevar.librepods.services.ServiceManager +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.math.abs enum class IslandType { CONNECTED, TAKING_OVER, MOVED_TO_REMOTE, -// CALL_GESTURE } -class IslandWindow(context: Context) { +class IslandWindow(private val context: Context) { private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager @SuppressLint("InflateParams") private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null) private var isClosing = false + private var params: WindowManager.LayoutParams? = null + + private var initialY = 0f + private var initialTouchY = 0f + private var lastTouchY = 0f + private var velocityTracker: VelocityTracker? = null + private var isBeingDragged = false + private var autoCloseHandler: Handler? = null + private var autoCloseRunnable: Runnable? = null + private var initialHeight = 0 + private var screenHeight = 0 + private var isDraggingDown = false + private var lastMoveTime = 0L + private var yMovement = 0f + private var dragDistance = 0f + + private var initialConnectedTextY = 0f + private var initialDeviceTextY = 0f + private var initialBatteryViewY = 0f + private var initialVideoViewY = 0f + private var initialTextSeparation = 0f + + private val containerView = FrameLayout(context) + + private lateinit var springAnimation: SpringAnimation + private val flingAnimator = ValueAnimator() + + private val batteryReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == AirPodsNotifications.BATTERY_DATA) { + val batteryList = intent.getParcelableArrayListExtra("data") + updateBatteryDisplay(batteryList) + } else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) { + try { + context?.unregisterReceiver(this) + } catch (e: Exception) { + e.printStackTrace() + } + } + } + } val isVisible: Boolean - get() = islandView.parent != null && islandView.visibility == View.VISIBLE + get() = containerView.parent != null && containerView.visibility == View.VISIBLE - @SuppressLint("SetTextI18n") + private fun updateBatteryDisplay(batteryList: ArrayList?) { + if (batteryList == null || batteryList.isEmpty()) return + + val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT } + val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT } + + val leftLevel = leftBattery?.level ?: 0 + val rightLevel = rightBattery?.level ?: 0 + val leftStatus = leftBattery?.status ?: BatteryStatus.DISCONNECTED + val rightStatus = rightBattery?.status ?: BatteryStatus.DISCONNECTED + + val batteryText = islandView.findViewById(R.id.island_battery_text) + val batteryProgressBar = islandView.findViewById(R.id.island_battery_progress) + + val displayBatteryLevel = when { + leftLevel > 0 && rightLevel > 0 -> minOf(leftLevel, rightLevel) + leftLevel > 0 -> leftLevel + rightLevel > 0 -> rightLevel + else -> null + } + + if (displayBatteryLevel != null) { + batteryText.text = "$displayBatteryLevel%" + batteryProgressBar.progress = displayBatteryLevel + batteryProgressBar.isIndeterminate = false + } else { + batteryText.text = "?" + batteryProgressBar.progress = 0 + batteryProgressBar.isIndeterminate = false + } + } + + @SuppressLint("SetTextI18s", "ClickableViewAccessibility") fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED) { if (ServiceManager.getService()?.islandOpen == true) return else ServiceManager.getService()?.islandOpen = true val displayMetrics = Resources.getSystem().displayMetrics val width = (displayMetrics.widthPixels * 0.95).toInt() + screenHeight = displayMetrics.heightPixels - val params = WindowManager.LayoutParams( + val batteryList = ServiceManager.getService()?.getBattery() + val batteryText = islandView.findViewById(R.id.island_battery_text) + val batteryProgressBar = islandView.findViewById(R.id.island_battery_progress) + + val displayBatteryLevel = if (batteryList != null) { + val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT } + val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT } + + when { + leftBattery?.level ?: 0 > 0 && rightBattery?.level ?: 0 > 0 -> + minOf(leftBattery!!.level, rightBattery!!.level) + leftBattery?.level ?: 0 > 0 -> leftBattery!!.level + rightBattery?.level ?: 0 > 0 -> rightBattery!!.level + batteryPercentage > 0 -> batteryPercentage + else -> null + } + } else if (batteryPercentage > 0) { + batteryPercentage + } else { + null + } + + if (displayBatteryLevel != null) { + batteryText.text = "$displayBatteryLevel%" + batteryProgressBar.progress = displayBatteryLevel + } else { + batteryText.text = "?" + batteryProgressBar.progress = 0 + } + + batteryProgressBar.isIndeterminate = false + islandView.findViewById(R.id.island_device_name).text = name + + val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA) + batteryIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED) + } else { + context.registerReceiver(batteryReceiver, batteryIntentFilter) + } + + ServiceManager.getService()?.sendBatteryBroadcast() + + containerView.removeAllViews() + val containerParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT + ) + containerView.addView(islandView, containerParams) + + params = WindowManager.LayoutParams( width, WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, @@ -77,12 +220,97 @@ class IslandWindow(context: Context) { } islandView.visibility = View.VISIBLE - islandView.findViewById(R.id.island_battery_text).text = "$batteryPercentage%" - islandView.findViewById(R.id.island_device_name).text = name + containerView.visibility = View.VISIBLE - islandView.setOnClickListener { - ServiceManager.getService()?.startMainActivity() - close() + containerView.setOnTouchListener { _, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false) + flingAnimator.cancel() + + velocityTracker?.recycle() + velocityTracker = VelocityTracker.obtain() + velocityTracker?.addMovement(event) + + initialY = containerView.translationY + initialTouchY = event.rawY + lastTouchY = event.rawY + initialHeight = islandView.height + isBeingDragged = false + isDraggingDown = false + lastMoveTime = System.currentTimeMillis() + dragDistance = 0f + + captureInitialPositions() + + true + } + MotionEvent.ACTION_MOVE -> { + velocityTracker?.addMovement(event) + val deltaY = event.rawY - initialTouchY + val moveDelta = event.rawY - lastTouchY + dragDistance += abs(moveDelta) + + isDraggingDown = moveDelta > 0 + + val currentTime = System.currentTimeMillis() + val timeDelta = currentTime - lastMoveTime + if (timeDelta > 0) { + yMovement = moveDelta / timeDelta * 10 + } + lastMoveTime = currentTime + + if (abs(deltaY) > 5 || isBeingDragged) { + isBeingDragged = true + + val dampedDeltaY = if (deltaY > 0) { + initialY + (deltaY * 0.6f) + } else { + initialY + (deltaY * 0.9f) + } + containerView.translationY = dampedDeltaY + + if (isDraggingDown && deltaY > 0) { + val stretchAmount = (deltaY * 0.5f).coerceAtMost(200f) + applyCustomStretchEffect(stretchAmount, deltaY) + } + } + + lastTouchY = event.rawY + true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + velocityTracker?.addMovement(event) + velocityTracker?.computeCurrentVelocity(1000) + val yVelocity = velocityTracker?.yVelocity ?: 0f + + if (isBeingDragged) { + val currentTranslationY = containerView.translationY + val significantVelocity = abs(yVelocity) > 800 + val significantDrag = abs(dragDistance) > 80 + + when { + yVelocity < -1200 || (currentTranslationY < -80 && !isDraggingDown) -> { + animateDismissWithInertia(yVelocity) + } + yVelocity > 1200 || (isDraggingDown && significantDrag) -> { + animateExpandWithStretch(yVelocity) + } + else -> { + springBackWithInertia(yVelocity) + } + } + } else if (dragDistance < 10) { + resetAutoCloseTimer() + } + + velocityTracker?.recycle() + velocityTracker = null + isBeingDragged = false + true + } + else -> false + } } when (type) { @@ -95,16 +323,8 @@ class IslandWindow(context: Context) { IslandType.MOVED_TO_REMOTE -> { islandView.findViewById(R.id.island_connected_text).text = getString(context, R.string.island_moved_to_remote_text) } -// IslandType.CALL_GESTURE -> { -// islandView.findViewById(R.id.island_connected_text).text = "Incoming Call from $name" -// islandView.findViewById(R.id.island_device_name).text = "Use Head Gestures to answer." -// } } - val batteryProgressBar = islandView.findViewById(R.id.island_battery_progress) - batteryProgressBar.progress = batteryPercentage - batteryProgressBar.isIndeterminate = false - val videoView = islandView.findViewById(R.id.island_video_view) val videoUri = Uri.parse("android.resource://me.kavishdevar.librepods/${R.raw.island}") videoView.setVideoURI(videoUri) @@ -113,19 +333,265 @@ class IslandWindow(context: Context) { videoView.start() } - windowManager.addView(islandView, params) + windowManager.addView(containerView, params) + + islandView.post { + initialHeight = islandView.height + captureInitialPositions() + } + + springAnimation = SpringAnimation(containerView, DynamicAnimation.TRANSLATION_Y, 0f).apply { + spring = SpringForce(0f) + .setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY) + .setStiffness(SpringForce.STIFFNESS_MEDIUM) + } val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f) val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f) val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f) - ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply { + ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply { duration = 700 interpolator = AnticipateOvershootInterpolator() start() } - Handler(Looper.getMainLooper()).postDelayed({ - close() - }, 4500) + + resetAutoCloseTimer() + } + + private fun captureInitialPositions() { + val connectedText = islandView.findViewById(R.id.island_connected_text) + val deviceText = islandView.findViewById(R.id.island_device_name) + val batteryView = islandView.findViewById(R.id.island_battery_container) + val videoView = islandView.findViewById(R.id.island_video_view) + + connectedText.post { + initialConnectedTextY = connectedText.y + initialDeviceTextY = deviceText.y + initialTextSeparation = deviceText.y - (connectedText.y + connectedText.height) + + if (batteryView != null) initialBatteryViewY = batteryView.y + initialVideoViewY = videoView.y + } + } + + private fun applyCustomStretchEffect(stretchAmount: Float, dragY: Float) { + try { + val mainLayout = islandView.findViewById(R.id.island_window_layout) + val connectedText = islandView.findViewById(R.id.island_connected_text) + val deviceText = islandView.findViewById(R.id.island_device_name) + val batteryView = islandView.findViewById(R.id.island_battery_container) + val videoView = islandView.findViewById(R.id.island_video_view) + + val stretchFactor = 1f + (stretchAmount / 300f).coerceAtMost(4.0f) + val newMinHeight = (initialHeight * stretchFactor).toInt() + mainLayout.minimumHeight = newMinHeight + + val textMarginIncrease = (stretchAmount * 0.8f).toInt() + + val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams + deviceTextParams.topMargin = textMarginIncrease + deviceText.layoutParams = deviceTextParams + + val background = mainLayout.background + if (background is GradientDrawable) { + val cornerRadius = 56f + background.cornerRadius = cornerRadius + } + + if (params != null) { + params!!.height = screenHeight + + val containerParams = containerView.layoutParams + containerParams.height = screenHeight + containerView.layoutParams = containerParams + + try { + windowManager.updateViewLayout(containerView, params) + } catch (e: Exception) { + e.printStackTrace() + } + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun resetAutoCloseTimer() { + autoCloseHandler = Handler(Looper.getMainLooper()) + autoCloseRunnable = Runnable { close() } + autoCloseHandler?.postDelayed(autoCloseRunnable!!, 4500) + } + + private fun springBackWithInertia(velocity: Float) { + springAnimation.cancel() + flingAnimator.cancel() + + springAnimation.setStartVelocity(velocity) + + val baseStiffness = SpringForce.STIFFNESS_MEDIUM + val dynamicStiffness = baseStiffness * (1f + (abs(velocity) / 3000f)) + springAnimation.spring = SpringForce(0f) + .setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY) + .setStiffness(dynamicStiffness) + + resetStretchEffects(velocity) + + if (params != null) { + params!!.height = WindowManager.LayoutParams.WRAP_CONTENT + try { + windowManager.updateViewLayout(containerView, params) + } catch (e: Exception) { + e.printStackTrace() + } + } + + springAnimation.start() + } + + private fun resetStretchEffects(velocity: Float) { + try { + val mainLayout = islandView.findViewById(R.id.island_window_layout) + val deviceText = islandView.findViewById(R.id.island_device_name) + + val heightAnimator = ValueAnimator.ofInt(mainLayout.minimumHeight, initialHeight) + heightAnimator.duration = 300 + heightAnimator.interpolator = OvershootInterpolator(1.5f) + heightAnimator.addUpdateListener { animation -> + mainLayout.minimumHeight = animation.animatedValue as Int + } + + val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams + val textMarginAnimator = ValueAnimator.ofInt(deviceTextParams.topMargin, 0) + textMarginAnimator.duration = 300 + textMarginAnimator.interpolator = OvershootInterpolator(1.5f) + textMarginAnimator.addUpdateListener { animation -> + deviceTextParams.topMargin = animation.animatedValue as Int + deviceText.layoutParams = deviceTextParams + } + + heightAnimator.start() + textMarginAnimator.start() + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun animateDismissWithInertia(velocity: Float) { + springAnimation.cancel() + flingAnimator.cancel() + + val baseDistance = -screenHeight + val velocityFactor = (abs(velocity) / 2000f).coerceIn(0.5f, 2.0f) + val targetDistance = baseDistance * velocityFactor + + val baseDuration = 400L + val velocityDurationFactor = (1500f / (abs(velocity) + 1500f)) + val duration = (baseDuration * velocityDurationFactor).toLong().coerceIn(200L, 500L) + + flingAnimator.setFloatValues(containerView.translationY, targetDistance) + flingAnimator.duration = duration + flingAnimator.addUpdateListener { animation -> + containerView.translationY = animation.animatedValue as Float + + val progress = animation.animatedFraction + containerView.scaleX = 1f - (progress * 0.5f) + containerView.scaleY = 1f - (progress * 0.5f) + + containerView.alpha = 1f - progress + } + flingAnimator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + close() + } + }) + + flingAnimator.interpolator = DecelerateInterpolator(1.2f) + flingAnimator.start() + } + + private fun animateExpandWithStretch(velocity: Float) { + springAnimation.cancel() + flingAnimator.cancel() + + val baseDuration = 600L + val velocityFactor = (1800f / (abs(velocity) + 1800f)).coerceIn(0.5f, 1.5f) + val expandDuration = (baseDuration * velocityFactor).toLong().coerceIn(300L, 700L) + + if (params != null) { + params!!.height = screenHeight + try { + windowManager.updateViewLayout(containerView, params) + } catch (e: Exception) { + e.printStackTrace() + } + } + + val containerAnimator = ValueAnimator.ofFloat(containerView.translationY, screenHeight * 0.6f) + containerAnimator.duration = expandDuration + containerAnimator.interpolator = DecelerateInterpolator(0.8f) + containerAnimator.addUpdateListener { animation -> + containerView.translationY = animation.animatedValue as Float + } + + val stretchAnimator = ValueAnimator.ofFloat(0f, 1f) + stretchAnimator.duration = expandDuration + stretchAnimator.interpolator = OvershootInterpolator(0.5f) + stretchAnimator.addUpdateListener { animation -> + val progress = animation.animatedValue as Float + animateCustomStretch(progress, expandDuration) + } + + val normalizeAnimator = ValueAnimator.ofFloat(1.0f, 0.0f) + normalizeAnimator.duration = 300 + normalizeAnimator.startDelay = expandDuration - 150 + normalizeAnimator.interpolator = AccelerateInterpolator(1.2f) + normalizeAnimator.addUpdateListener { animation -> + val progress = animation.animatedValue as Float + containerView.alpha = progress + + if (progress < 0.7f) { + islandView.findViewById(R.id.island_video_view).visibility = View.GONE + } + } + normalizeAnimator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + ServiceManager.getService()?.startMainActivity() + close() + } + }) + + containerAnimator.start() + stretchAnimator.start() + normalizeAnimator.start() + } + + private fun animateCustomStretch(progress: Float, duration: Long) { + try { + val mainLayout = islandView.findViewById(R.id.island_window_layout) + val connectedText = islandView.findViewById(R.id.island_connected_text) + val deviceText = islandView.findViewById(R.id.island_device_name) + + val targetHeight = (screenHeight * 0.7f).toInt() + val currentHeight = initialHeight + ((targetHeight - initialHeight) * progress) + mainLayout.minimumHeight = currentHeight.toInt() + + val mainLayoutParams = mainLayout.layoutParams + mainLayoutParams.height = LinearLayout.LayoutParams.MATCH_PARENT + mainLayout.layoutParams = mainLayoutParams + + val targetMargin = (400 * progress).toInt() + val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams + deviceTextParams.topMargin = targetMargin + deviceText.layoutParams = deviceTextParams + + val baseTextSize = 24f + deviceText.textSize = baseTextSize + (progress * 8f) + + val baseSubTextSize = 16f + connectedText.textSize = baseSubTextSize + (progress * 4f) + } catch (e: Exception) { + e.printStackTrace() + } } fun close() { @@ -133,21 +599,30 @@ class IslandWindow(context: Context) { if (isClosing) return isClosing = true + try { + context.unregisterReceiver(batteryReceiver) + } catch (e: Exception) { + e.printStackTrace() + } + ServiceManager.getService()?.islandOpen = false + autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return) + + resetStretchEffects(0f) val videoView = islandView.findViewById(R.id.island_video_view) videoView.stopPlayback() - val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1f, 0.5f) - val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1f, 0.5f) - val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, 0f, -200f) - ObjectAnimator.ofPropertyValuesHolder(islandView, scaleX, scaleY, translationY).apply { + val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, containerView.scaleX, 0.5f) + val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, containerView.scaleY, 0.5f) + val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, containerView.translationY, -200f) + ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply { duration = 700 interpolator = AnticipateOvershootInterpolator() addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - islandView.visibility = View.GONE + containerView.visibility = View.GONE try { - windowManager.removeView(islandView) + windowManager.removeView(containerView) } catch (e: Exception) { e("IslandWindow", "Error removing view: $e") } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt index 40ca6be..edee981 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.utils import android.content.SharedPreferences @@ -28,6 +30,7 @@ import android.util.Log import android.view.KeyEvent import androidx.annotation.RequiresApi import me.kavishdevar.librepods.services.ServiceManager +import kotlin.io.encoding.ExperimentalEncodingApi object MediaController { private var initialVolume: Int? = null @@ -86,15 +89,20 @@ object MediaController { }, 7) // i have no idea why android sends an event a hundred times after the user does something. } Log.d("MediaController", "pausedforcrossdevice: $pausedForCrossDevice Ear detection status: ${ServiceManager.getService()?.earDetectionNotification?.status}, music active: ${audioManager.isMusicActive} and cross device available: ${CrossDevice.isAvailable}") - if (!pausedForCrossDevice && CrossDevice.isAvailable && ServiceManager.getService()?.earDetectionNotification?.status?.contains(0x00) == true && audioManager.isMusicActive) { + if (!pausedForCrossDevice && audioManager.isMusicActive) { Log.d("MediaController", "Pausing for cross device and taking over.") sendPause(true) pausedForCrossDevice = true - ServiceManager.getService()?.takeOver() + ServiceManager.getService()?.takeOver("music") } } } + @Synchronized + fun getMusicActive(): Boolean { + return audioManager.isMusicActive + } + @Synchronized fun sendPause(force: Boolean = false) { Log.d("MediaController", "Sending pause with iPausedTheMedia: $iPausedTheMedia, userPlayedTheMedia: $userPlayedTheMedia, isMusicActive: ${audioManager.isMusicActive}, force: $force") diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/Packets.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/Packets.kt index 2f1a41e..752d00e 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/Packets.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/Packets.kt @@ -136,10 +136,23 @@ class AirPodsNotifications { } fun setStatus(data: ByteArray) { - if (data.size != 11) { - return + when (data.size) { + // if the whole packet is given + 11 -> { + status = data[7].toInt() + } + // if only the data is given + 1 -> { + status = data[0].toInt() + } + // if the value of control command is given + 4 -> { + status = data[0].toInt() + } + else -> { + Log.d("ANC", "Invalid ANC data size: ${data.size}") + } } - status = data[7].toInt() } val name: String = diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt index 5e6381a..e6a28e8 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) + package me.kavishdevar.librepods.utils import android.content.Context @@ -32,6 +34,7 @@ import java.io.FileOutputStream import java.io.InputStreamReader import java.net.HttpURLConnection import java.net.URL +import kotlin.io.encoding.ExperimentalEncodingApi @NoLiveLiterals class RadareOffsetFinder(context: Context) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/widgets/BatteryWidget.kt b/android/app/src/main/java/me/kavishdevar/librepods/widgets/BatteryWidget.kt index ae4c33c..f67588b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/widgets/BatteryWidget.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/widgets/BatteryWidget.kt @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) package me.kavishdevar.librepods.widgets @@ -23,6 +24,7 @@ import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.Context import me.kavishdevar.librepods.services.ServiceManager +import kotlin.io.encoding.ExperimentalEncodingApi class BatteryWidget : AppWidgetProvider() { override fun onUpdate( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/widgets/NoiseControlWidget.kt b/android/app/src/main/java/me/kavishdevar/librepods/widgets/NoiseControlWidget.kt index 710257f..3f5af9d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/widgets/NoiseControlWidget.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/widgets/NoiseControlWidget.kt @@ -16,6 +16,7 @@ * along with this program. If not, see . */ +@file:OptIn(ExperimentalEncodingApi::class) package me.kavishdevar.librepods.widgets @@ -28,6 +29,8 @@ import android.util.Log import android.widget.RemoteViews import me.kavishdevar.librepods.R import me.kavishdevar.librepods.services.ServiceManager +import me.kavishdevar.librepods.utils.AACPManager +import kotlin.io.encoding.ExperimentalEncodingApi class NoiseControlWidget : AppWidgetProvider() { override fun onUpdate( @@ -79,7 +82,12 @@ class NoiseControlWidget : AppWidgetProvider() { if (intent.action == "ACTION_SET_ANC_MODE") { val mode = intent.getIntExtra("ANC_MODE", 1) Log.d("NoiseControlWidget", "Setting ANC mode to $mode") - ServiceManager.getService()?.setANCMode(mode) + ServiceManager.getService()!! + .aacpManager + .sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, + mode.toByte() + ) } } } diff --git a/android/app/src/main/res/layout/island_window.xml b/android/app/src/main/res/layout/island_window.xml index 6341dea..fd8b8ef 100644 --- a/android/app/src/main/res/layout/island_window.xml +++ b/android/app/src/main/res/layout/island_window.xml @@ -2,10 +2,9 @@ - + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e46b8b2..7f5fdb0 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -64,4 +64,19 @@ Collect Logs Saved Logs No saved logs found + Auto-Connect preferences + Connect to your AirPods when its status is: + Disconnected + AirPods are not connected to a device + Idle + A device is connected to your AirPods, but not playing media or on a call + Playing media + A device is playing media on your AirPods + On call + A device is on a call with your AirPods + Connect to AirPods when your phone is: + Receiving a call + Your phone starts ringing + Starting media playback + Your phone starts playing media diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 2a01f29..415d5ce 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -15,6 +15,7 @@ hazeMaterials = "1.5.3" sliceBuilders = "1.1.0-alpha02" sliceCore = "1.1.0-alpha02" sliceView = "1.1.0-alpha02" +dynamicanimation = "1.1.0" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } @@ -35,6 +36,7 @@ haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", versi androidx-slice-builders = { group = "androidx.slice", name = "slice-builders", version.ref = "sliceBuilders" } androidx-slice-core = { group = "androidx.slice", name = "slice-core", version.ref = "sliceCore" } androidx-slice-view = { group = "androidx.slice", name = "slice-view", version.ref = "sliceView" } +androidx-dynamicanimation = { group = "androidx.dynamicanimation", name = "dynamicanimation", version.ref = "dynamicanimation" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }